From c924e9d30ac7e117f584a71dbf488811aadd9838 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:51:22 +0000 Subject: [PATCH] Miner app UI: log drawer rebuilt (open state, scroll to newest, time jump, last error and swap, search, copy, drag height, virtualised), every screen on one scale, canvas and polling idle when hidden Log drawer: the Logs button shows its open state (Close logs, chevron); a Newest button appears when the user scrolls up and follow re-engages at the end; a time ruler with error and swap marks, an HH:MM:SS jump box, last error and last swap buttons; substring search with a match count and highlights; copy the lines in view; the height drags from a grip (140 px to 70% of the window, remembered); a window under 700 px tall opens a 180 px drawer. The list is virtualised (19 px rows, only the rows in view in the DOM; up to 20,000 lines kept; wrap mode under 5,000 lines). Polling runs only while the drawer is open and the window is visible. Screens: one type scale and spacing scale in app.css, tabular figures everywhere, the bottom bar in pieces (machine name truncates first, address always short, uptime dropped under 1100 px), tile captions carry their full text as a title, syncing shows an ETA from the measured block rate, the engine-away state names itself on the node tile, short-window rules, the key sheet scrolls on a short window, compact settings card rows on two lines, only a block that just arrived flashes (not the whole strip on first paint). Performance: the minute grid is drawn once into an offscreen layer; the strip redraws twice a second and at the display rate only during a flash; nothing draws and the state poll drops to every 5 s while document.hidden. ?debug=1 prints a budget line every 5 s and exposes window.__igneumBench. tools/ui-mock: a stand-in engine (node tools/ui-mock/server.mjs) replaying recorded, scrubbed API responses with scenarios (syncing, nodedown, nocards, integrated, clock, paused, biglog, fresh, job, nvidia), so UI work never touches a running miner. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/app.css | 352 ++++++++++++++++++----------- app/igneum-app/ui/app.js | 354 ++++++++++++++++++++++++++---- app/igneum-app/ui/index.html | 34 ++- tools/ui-mock/recorded/log.json | 1 + tools/ui-mock/recorded/state.json | 1 + tools/ui-mock/server.mjs | 178 +++++++++++++++ 6 files changed, 733 insertions(+), 187 deletions(-) create mode 100644 tools/ui-mock/recorded/log.json create mode 100644 tools/ui-mock/recorded/state.json create mode 100644 tools/ui-mock/server.mjs diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 5a2ab8610..ba2bcc827 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -1,5 +1,6 @@ /* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash; - Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */ + Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. + One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), used by every screen. */ @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')} @font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')} @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')} @@ -9,53 +10,66 @@ @font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')} @font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')} -:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; ---gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px; ---sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; ---top:60px;--bottom:52px} +:root{ + /* colour */ + --obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; + --ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A; + /* type scale */ + --t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px; + /* spacing scale */ + --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px; + --gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3); + --sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; + --top:60px;--bottom:52px;--drawer-h:260px} *{box-sizing:border-box} html,body{height:100%} -body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none} -.mono,code,pre{font-family:var(--mono)} +body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-lg);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums} +.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums} .dim{color:var(--ash)} h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance} -h1{font-weight:900;font-size:52px;letter-spacing:-.01em} -h2{font-weight:700;font-size:32px} -h3{font-weight:700;font-size:16px} +h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em} +h2{font-weight:700;font-size:var(--t-h2)} +h3{font-weight:700;font-size:var(--t-h3)} p{margin:0} a{color:inherit} button{font:inherit;color:inherit} -.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px} +input,textarea{font-variant-numeric:tabular-nums} +.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap} .eyebrow.ember{color:var(--ember)} [hidden]{display:none !important} +::selection{background:rgba(242,84,27,.45)} /* buttons */ -.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease;white-space:nowrap} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:var(--t-lg);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap} .btn:hover{transform:translateY(-1px);border-color:var(--ash)} .btn:active{transform:none} .btn:disabled{opacity:.4;cursor:default;transform:none} .btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)} -.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B} -.btn.big{min-height:52px;padding:12px 28px;font-size:16px} -.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px} -.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;border-radius:7px;font-family:var(--mono);font-weight:500} +.btn.primary:hover{background:var(--ember-hi);border-color:var(--ember-hi)} +.btn.big{min-height:52px;padding:12px 28px;font-size:var(--t-xl)} +.btn.small{min-height:34px;padding:6px 14px;font-size:var(--t-base);border-radius:8px} +.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500} .btn.ghost{border-color:transparent;color:var(--ink-2)} .btn.ghost:hover{border-color:var(--line-2);color:var(--bone)} +.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)} .btn.danger:hover{color:var(--ember);border-color:var(--ember)} +.btn .chev{transition:transform .2s ease;color:var(--ash)} +.btn.on .chev{transform:rotate(180deg);color:var(--molten)} .icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} .icon-btn:hover{color:var(--bone);border-color:var(--ash)} :focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} +@media (prefers-reduced-motion:reduce){.btn,.btn .chev{transition:none}} /* top bar */ -.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} .top button,.top .pill{-webkit-app-region:no-drag} body.mac .top{padding-left:92px} -.brand{display:flex;align-items:center;gap:10px} +.brand{display:flex;align-items:center;gap:10px;min-width:0} .brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em} -.brand .miner{font-family:var(--mono);font-size:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px} -.top-right{display:flex;align-items:center;gap:12px} -.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite)} -.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)} +.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px} +.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto} +.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center} +.pill.on{color:var(--molten);border-color:var(--molten-40)} .pill.warn{color:var(--ember)} .dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px} .dot.small{width:7px;height:7px;flex-basis:7px} @@ -64,115 +78,118 @@ body.mac .top{padding-left:92px} @keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}} @media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}} -/* update banner */ -.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} - +/* banners under the top bar: clock, update, job */ +.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:var(--ember-12);border-bottom:1px solid var(--ember-40);font-size:var(--t-md);line-height:1.4} .banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} -.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} -.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} -.banner.update{flex-wrap:wrap;row-gap:8px} +.banner.clock.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)} +.banner .hint{font-size:var(--t-xs);color:var(--ash);flex-basis:100%;text-align:center} +.banner.update{flex-wrap:wrap;row-gap:var(--s-2)} .banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} .banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px} .banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} -/* remote job strip (src/jobrun.rs): running, then the outcome */ -.banner.job{flex-wrap:wrap;row-gap:8px;background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} +.banner.job{flex-wrap:wrap;row-gap:var(--s-2);background:var(--molten-10);border-bottom-color:var(--molten-40)} .banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)} -.banner.job .job-results{flex-basis:100%;margin:0;font-size:11px;line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto} -.job-history table{margin-top:8px} -.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:11px;padding:4px 6px 4px 0} +.banner.job .job-results{flex-basis:100%;margin:0;font-size:var(--t-xs);line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto} +.job-history table{margin-top:var(--s-2)} +.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0} .job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top} .job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)} .job-history tr.running td{color:var(--molten)} -.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} -.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} -.clock-msg{font-size:14px;color:var(--bone);line-height:1.45} +.clock-card{margin-top:var(--s-3);border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)} +.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)} +.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45} .clock-card .note{margin-top:0} /* screens */ -main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} +main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain} body.has-bottom main{bottom:var(--bottom)} -body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))} .screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.screen{animation:none}} /* welcome */ -.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:18px;padding:40px 0 48px} +.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-7) 0 48px} .coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} .coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite} .coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} @keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} -.lead{font-size:18px;color:var(--ink-2);max-width:54ch} +@media (prefers-reduced-motion:reduce){.coin-wrap::before{animation:none}} +.lead{font-size:var(--t-2xl);color:var(--ink-2);max-width:54ch} .three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} .tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)} -.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25} -.tile .s{font-size:13px;color:var(--ash);line-height:1.45} -.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px} -.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em} +.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)} +.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25} +.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45} +.tile .h{font-family:var(--head);font-weight:700;font-size:var(--t-h3);margin-bottom:var(--s-2)} +.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px} +.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em} /* steps */ -.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px} -.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch} -.step .cta{justify-content:flex-start;margin-top:8px} -.note{font-size:13px;color:var(--ash);line-height:1.5} -.note.small{font-size:12px;word-break:break-all} -.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px} +.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:var(--s-4)} +.step .sub{font-size:var(--t-xl);color:var(--ink-2);max-width:60ch} +.step .cta{justify-content:flex-start;margin-top:var(--s-2)} +.note{font-size:var(--t-base);color:var(--ash);line-height:1.5} +.note.small{font-size:var(--t-sm);word-break:break-all} +.cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)} .card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} .card.detecting{display:flex;align-items:center;gap:18px} -.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px} -.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px} +.card.detecting .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl)} +.card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)} .spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} @keyframes spin{to{transform:rotate(360deg)}} .gpu{display:flex;align-items:center;gap:18px} -.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} .gpu .badge.apple{color:var(--bone)} -.gpu .badge.nvidia{color:#8BE37A} +.gpu .badge.nvidia{color:var(--nvidia)} .gpu .badge.amd{color:var(--ember)} -.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2} -.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} +.gpu .name{font-family:var(--head);font-weight:700;font-size:var(--t-2xl);line-height:1.2} +.gpu .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} .gpu .meta b{color:var(--ink-2);font-weight:500} .gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} .gpu.off .tick{background:var(--line-2)} .gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} @keyframes draw{to{stroke-dashoffset:0}} -.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} +.gpu .msg{font-size:var(--t-sm);color:var(--ember);margin-top:var(--s-1)} /* GPU rows (first run and settings) */ -.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0} +.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap} .gpu-row.off{opacity:.72} -.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} .gpu-row .badge.apple{color:var(--bone)} -.gpu-row .badge.nvidia{color:#8BE37A} +.gpu-row .badge.nvidia{color:var(--nvidia)} .gpu-row .badge.amd{color:var(--ember)} -.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} -.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} -.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500} -.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)} +.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)} +.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap} +.kind.discrete,.kind.apple{color:var(--molten);border-color:var(--molten-40)} .kind.external{color:var(--bone)} -.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} +.gpu-row .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} .gpu-row .meta b{color:var(--ink-2);font-weight:500} -.gpu-row .reason{font-size:12px;color:var(--ash)} -.gpu-row .msg{font-size:12px;color:var(--ember)} +.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)} +.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)} .ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} -.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px} +.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)} .ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} .ids button:hover{border-color:var(--ash)} -.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text} +.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text} .ids input:focus{outline:none;border-color:var(--ember)} .ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} .gpu-row.off .ids{opacity:.4;pointer-events:none} .gpu-row .switch{padding:0;flex:0 0 auto} -.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px} +.cards.compact .gpu-row{padding:12px 14px;gap:var(--s-3);border-radius:14px} .cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} -.cards.compact .gpu-row .name{font-size:14px} +.cards.compact .gpu-row .name{font-size:var(--t-md)} +.cards.compact .gpu-row .info{flex-basis:calc(100% - 50px);min-width:120px} +.cards.compact .gpu-row .power{margin-left:50px} +.cards.compact .gpu-row .switch{margin-left:auto} .cards.compact .ids .k{display:none} -.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.power{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto} .power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} .power input[type=range]{width:110px;accent-color:var(--ember)} -.power .pv{font-size:12px;color:var(--ink-2);min-width:84px} +.power .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:84px} .cards.compact .power .k{display:none} .cards.compact .power input[type=range]{width:80px} .gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} @@ -186,22 +203,22 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card /* per-card tiles on the dashboard */ .gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} .gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} -.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap} .gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} -.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.gpu-tile .h .unit{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);font-weight:500;letter-spacing:.08em} .gpu-tile.idle .h{color:var(--ash)} -.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} +.gpu-tile .m{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} .gpu-tile .m b{color:var(--ink-2);font-weight:500} -.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} .gpu-tile .st.mining{color:var(--molten)} .gpu-tile .st.bad{color:var(--ember)} .gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -.gpu-tile .msg{font-size:12px;color:var(--ash)} -.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)} +.gpu-tile .msg{font-size:var(--t-sm);color:var(--ash)} +.gpu-off{margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash)} /* address options */ -.options{display:flex;flex-direction:column;gap:12px;margin-top:6px} -.option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} +.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px} +.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} .option:hover{border-color:var(--line-2)} .option.on{border-color:var(--ember);background:#1A1614} .option input[type=radio]{position:absolute;opacity:0;width:0;height:0} @@ -209,61 +226,63 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card .option.on .radio{border-color:var(--ember)} .option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)} .option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1} -.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px} -.option .s{font-size:14px;color:var(--ash);line-height:1.5} -.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px} -.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text} +.option .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl);display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.option .s{font-size:var(--t-md);color:var(--ash);line-height:1.5} +.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid var(--molten-40);border-radius:999px;padding:2px 8px} +.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text} .addr-input:focus{outline:none;border-color:var(--ember)} .option:not(.on) .addr-input{display:none} -.err{font-size:13px;color:var(--ember)} +.err{font-size:var(--t-base);color:var(--ember)} /* dashboard */ #screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} body[data-phase="dashboard"] #screen-dashboard{display:flex} .strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} .cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px} +.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap} +.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em} .cell.ember .v{color:var(--ember)} -.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em} .cell .v.state{text-transform:capitalize;font-size:22px} -.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cell.ok .v.state{color:var(--molten)} .cell.bad .v.state{color:var(--ember)} +.cell.bad .s{color:var(--ember)} .grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} .col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} -.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} -.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap} +.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)} .stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} #dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)} -.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)} -.legend span{display:inline-flex;align-items:center;gap:8px} +.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)} +.legend span{display:inline-flex;align-items:center;gap:var(--s-2)} .sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} .sw.ember{background:var(--ember);border-color:var(--ember)} .sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} .sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} .tbl{overflow-x:auto} -table{border-collapse:collapse;width:100%;font-size:13px} +table{border-collapse:collapse;width:100%;font-size:var(--t-base)} th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} tr:last-child td{border-bottom:0} -td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)} td .st.mining{color:var(--molten)} td .st.bad{color:var(--ember)} td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px} -.empty{color:var(--ash);font-size:13px;padding:10px 0} +td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:normal;max-width:280px} +.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0} .kv{display:flex;flex-direction:column} -.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)} +.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)} .kv>div:last-child{border-bottom:0} -.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} -.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap} +.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right} .card .note{margin-top:10px} -.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto} +.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:300px;overflow:auto} .feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} .feed>div:last-child{border-bottom:0} -.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px} +.feed>div>span:first-child{min-width:0} +.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)} .feed .k{color:var(--molten);margin-right:6px} .feed .k.error{color:var(--ember)} .feed .k.block{color:var(--ember)} @@ -271,61 +290,128 @@ td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);wh /* sheet (the key) */ .sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} -.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} -.sheet .sub{font-size:15px;color:var(--ink-2)} -.field{display:flex;flex-direction:column;gap:8px;margin-top:6px} -.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:13px;word-break:break-all;user-select:text;-webkit-user-select:text} +.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} +.sheet .sub{font-size:var(--t-lg);color:var(--ink-2)} +.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px} +.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:var(--t-base);word-break:break-all;user-select:text;-webkit-user-select:text} .box span{flex:1;min-width:0} .box.key{color:var(--molten)} -.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px} -.check.small{font-size:12px;color:var(--ash)} -.check input{width:18px;height:18px;accent-color:var(--ember)} +.check{display:flex;align-items:center;gap:10px;font-size:var(--t-md);cursor:pointer;margin-top:var(--s-1)} +.check.small{font-size:var(--t-sm);color:var(--ash);margin-top:0} +.check input{width:18px;height:18px;accent-color:var(--ember);margin:0} +.check.small input{width:15px;height:15px} .sheet .cta{justify-content:flex-start} /* settings panel */ .panel-wrap{justify-content:flex-end;padding:0} .panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} @keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} -.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)} -.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px} -.row{display:flex;gap:10px;align-items:center} +@media (prefers-reduced-motion:reduce){.panel,.sheet{animation:none}} +.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line);flex:0 0 auto} +.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px;overscroll-behavior:contain} +.row{display:flex;gap:10px;align-items:center;min-width:0} .row.between{justify-content:space-between} -.row .addr-input{margin-top:0} -.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text} +.row .addr-input{margin-top:0;min-width:0} +.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:var(--t-md);user-select:text;-webkit-user-select:text} .num:focus{outline:none;border-color:var(--ember)} -.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0} +.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:6px 0;line-height:1.35} .switch input{position:absolute;opacity:0;width:0;height:0} .switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease} .switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} .switch input:checked+.track{background:var(--ember)} .switch input:checked+.track::after{transform:translateX(18px)} +.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px} /* bottom bar */ -.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} -.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px} -.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1} -.bottom .right .mono{font-size:12px} +.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} +.bottom .left,.bottom .right{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto} +.bottom .mid{font-size:var(--t-sm);color:var(--ash);display:flex;align-items:center;justify-content:center;gap:0;min-width:0;flex:1;overflow:hidden;white-space:nowrap} +.bottom .mid .pc{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.bottom .mid .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)} +.bottom .mid .nm{color:var(--ink-2);max-width:220px;flex-shrink:1} +.bottom .mid .ad{flex-shrink:0} +.bottom .right .mono{font-size:var(--t-sm)} +@media (max-width:1100px){.bottom .mid .up{display:none}.bottom .mid .nm{max-width:140px}.log-tools .at{display:none}.log-search{flex-basis:120px;max-width:200px}} +@media (max-width:960px){.bottom .mid .ad{display:none}} /* log drawer */ -.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column} -body.drawer-open .drawer{height:260px} -.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto} -.chips{display:flex;gap:6px} -.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer} -.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)} -.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} +.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)} +body.drawer-open .drawer{height:var(--drawer-h)} +body.drawer-drag .drawer{transition:none} +body.drawer-drag{cursor:row-resize} +body.drawer-drag main{pointer-events:none} +@media (prefers-reduced-motion:reduce){.drawer{transition:none}} +.drawer-grip{height:10px;flex:0 0 10px;cursor:row-resize;display:flex;align-items:center;justify-content:center;touch-action:none} +.drawer-grip i{width:44px;height:3px;border-radius:2px;background:var(--line-2);transition:background .15s ease} +.drawer-grip:hover i,body.drawer-drag .drawer-grip i{background:var(--ash)} +.drawer-head{display:flex;align-items:center;gap:var(--s-3);padding:2px var(--gutter) 8px;border-bottom:1px solid var(--line);flex:0 0 auto;flex-wrap:wrap;row-gap:6px} +.chips{display:flex;gap:6px;flex-wrap:wrap} +.chip{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer;white-space:nowrap;line-height:1.3;transition:color .15s ease,border-color .15s ease} +.chip:hover{color:var(--ink-2);border-color:var(--line-2)} +.chip.on{color:var(--molten);border-color:var(--molten-40)} +.chip:disabled{opacity:.4;cursor:default} +.log-search{display:flex;align-items:center;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:3px 10px 3px 10px;color:var(--ash);flex:1 1 160px;min-width:140px;max-width:300px} +.log-search:focus-within{border-color:var(--molten-40);color:var(--ink-2)} +.log-search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--bone);font-family:var(--mono);font-size:var(--t-sm);padding:2px 0;user-select:text;-webkit-user-select:text} +.log-search input::-webkit-search-cancel-button{-webkit-appearance:none} +.log-search input::placeholder{color:var(--ash)} +.log-search .count{font-size:var(--t-xs);color:var(--ash);white-space:nowrap;font-variant-numeric:tabular-nums} +.log-nav{display:flex;align-items:center;gap:6px} +.log-nav input{width:84px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:4px 10px;color:var(--bone);font-size:var(--t-xs);letter-spacing:.06em;outline:0;user-select:text;-webkit-user-select:text;text-align:center} +.log-nav input:focus{border-color:var(--molten-40)} +.log-nav input::placeholder{color:var(--ash);letter-spacing:.04em} +.log-tools{display:flex;align-items:center;gap:var(--s-2);margin-left:auto} +.log-tools .at{font-size:var(--t-xs);min-width:64px;text-align:right} +.log-ruler{position:relative;height:16px;flex:0 0 16px;background:var(--graphite);border-bottom:1px solid var(--line);cursor:pointer;overflow:hidden} +.log-ruler.empty{cursor:default} +.log-ruler.empty>*{display:none} +.log-ruler .t0,.log-ruler .t1{position:absolute;top:0;z-index:2;font-size:9px;line-height:16px;color:var(--ash);padding:0 6px;margin:0 var(--gutter);pointer-events:none;letter-spacing:.04em;background:var(--graphite);border-radius:0 0 6px 0} +.log-ruler .t1{right:0;border-radius:0 0 0 6px} +.log-ruler .mk{position:absolute;top:4px;bottom:4px;width:2px;margin-left:-1px;background:var(--ember);opacity:.9;pointer-events:none} +.log-ruler .mk.swap{background:var(--molten)} +.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(244,241,236,.14);border-left:1px solid rgba(244,241,236,.35);border-right:1px solid rgba(244,241,236,.35);min-width:3px;pointer-events:none} +.log-ruler:hover .win{background:rgba(244,241,236,.2)} +.log-view{position:relative;flex:1;overflow:auto;overscroll-behavior:contain;user-select:text;-webkit-user-select:text;contain:strict} +.log-pad{position:relative;min-height:100%} +.log{margin:0;position:absolute;top:0;left:0;right:0;padding:6px var(--gutter);font-size:var(--t-sm);line-height:19px;color:var(--ink-2);white-space:pre;will-change:transform} +.log .ln{height:19px;overflow:hidden;text-overflow:ellipsis;white-space:pre} +.log-view.wrap .log{position:relative;padding-bottom:12px} +.log-view.wrap .log .ln{height:auto;white-space:pre-wrap;word-break:break-all;overflow:visible} .log .src{color:var(--ash)} -.log .src.node{color:#7FA7C9} -.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)} +.log .src.node{color:var(--node-blue)} +.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4,.log .src.miner5,.log .src.miner6,.log .src.miner7,.log .src.miner8{color:var(--molten)} .log .src.app{color:var(--ember)} -.log .e{color:var(--ember)} +.log .src.watch{color:var(--ash)} +.log .e,.log .e .src{color:var(--ember)} +.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)} +.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px} +.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center} +.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px} +.log-jump:hover{background:#1c1a18;border-color:var(--molten)} -.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)} +.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center} +body.drawer-open .toast{bottom:calc(var(--bottom) + var(--drawer-h) + 16px)} +/* narrow windows: the strip folds to two columns, the side column drops under the main one */ @media (max-width:860px){ .strip{grid-template-columns:repeat(2,minmax(0,1fr))} .grid{grid-template-columns:1fr} .three{grid-template-columns:1fr} h1{font-size:40px} } +/* short windows (under 700 px): tighter paddings, a lower hero, a smaller canvas, so the bottom bar and the drawer + always have room */ +@media (max-height:700px){ + :root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)} + #screen-dashboard{padding:16px 0 20px} + #dag{height:150px} + .hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)} + .coin-wrap{width:104px;height:104px} + .coin{width:104px;height:104px} + h1{font-size:40px} + .lead{font-size:var(--t-xl)} + .step{padding:32px 0 32px} + .feed{max-height:220px} + .drawer-head{padding-bottom:6px} +} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 9c512dc45..7fe3d0508 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -11,6 +11,22 @@ if (params.get('screen')) forced = params.get('screen'); var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated if (params.get('host') === 'mac') document.body.classList.add('mac'); + // ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and + // exposes the counters as window.__igneumPerf + var debug = params.get('debug') === '1'; + // the hosts hide the window to the menu bar or the tray: nothing draws or polls fast while document.hidden. + // ?visible=1 ignores that (screenshots from a hidden pane). + var forceVisible = params.get('visible') === '1'; + function hiddenNow() { return document.hidden && !forceVisible; } + var perf = { frames: 0, draw: 0, render: 0, log: 0, poll: 0, polls: 0, since: performance.now() }; + if (debug) { + window.__igneumPerf = perf; + setInterval(function () { + var s = (performance.now() - perf.since) / 1000; + console.log('[perf] ' + (hiddenNow() ? 'hidden' : 'visible') + ' ' + (perf.frames / s).toFixed(1) + ' fps · js ms/s: canvas ' + (perf.draw / s).toFixed(2) + ' render ' + (perf.render / s).toFixed(2) + ' log ' + (perf.log / s).toFixed(2) + ' poll ' + (perf.poll / s).toFixed(2) + ' · ' + (perf.polls / s).toFixed(2) + ' polls/s · total ' + ((perf.draw + perf.render + perf.log + perf.poll) / s / 10).toFixed(2) + '% of one core'); + perf.frames = 0; perf.draw = 0; perf.render = 0; perf.log = 0; perf.poll = 0; perf.polls = 0; perf.since = performance.now(); + }, 5000); + } // ---------- helpers ---------- function api(path, body) { @@ -26,11 +42,12 @@ function clock(t) { var d = new Date(t * 1000); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); } var toastT = null; function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, 2200); } - function copyText(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast('Copied'); }, function () { fallbackCopy(text); }); } - else fallbackCopy(text); + function copyText(text, msg) { + msg = msg || 'Copied'; + if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { toast(msg); }, function () { fallbackCopy(text, msg); }); } + else fallbackCopy(text, msg); } - function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast('Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } + function fallbackCopy(text, msg) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); toast(msg || 'Copied'); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } document.addEventListener('click', function (e) { var b = e.target.closest('[data-copy]'); if (!b) return; copyText($(b.getAttribute('data-copy')).textContent); @@ -44,7 +61,7 @@ $('bottom').hidden = !dash; $('btn-settings').hidden = !dash; document.body.classList.toggle('has-bottom', dash); - if (!dash) document.body.classList.remove('drawer-open'); + if (!dash && drawerOpen()) setDrawer(false); if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } api('api/phase', { phase: p }); } @@ -157,72 +174,278 @@ if (!state) return; api(state.mining.paused ? 'api/resume' : 'api/pause', {}); }); - $('btn-logs').addEventListener('click', function () { document.body.classList.toggle('drawer-open'); if (document.body.classList.contains('drawer-open')) pollLog(); }); + $('btn-logs').addEventListener('click', function () { setDrawer(!document.body.classList.contains('drawer-open')); }); $('btn-quit').addEventListener('click', function () { if (!confirm('Quit Igneum Miner? The miner stops first, then the node.')) return; api('api/quit', {}); $('pill-text').textContent = 'stopping'; $('btn-quit').disabled = true; }); // ---------- log drawer ---------- - var logAfter = 0, logFilter = '', logLines = []; - document.querySelectorAll('.chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('.chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); renderLog(); }); }); - function renderLog() { - var el = $('log'), follow = $('log-follow').checked; - var lines = logLines.filter(function (l) { return !logFilter || l.src.indexOf(logFilter) === 0; }).slice(-600); - el.innerHTML = lines.map(function (l) { return '' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + ' ' + (l.err ? '' : '') + esc(l.text) + (l.err ? '' : ''); }).join('\n'); - if (follow) el.scrollTop = el.scrollHeight; + // logLines: everything fetched this session (newest last, capped at LOG_CAP). view: the lines after the source chip + // and the search box. The list is virtualised: rows have a fixed height (RH) and only the rows in the viewport are in + // the DOM, so 20,000 lines cost the same as 20. Wrap mode renders the whole view with wrapped lines when it is small + // enough. Polling runs only while the drawer is open and the window is visible. + var LOG_CAP = 20000, WRAP_MAX = 5000, RH = 19, OVERSCAN = 8; + var logAfter = 0, logFilter = '', logQuery = '', logLines = [], view = [], viewDirty = true, wrap = false, follow = true; + var logView = $('log-view'), logPre = $('log'), logPad = $('log-pad'); + var winStart = -1, winEnd = -1, autoScroll = false, scrollRaf = 0, rulerAt = 0, rulerCount = -1, searchT = null, logTimer = null; + var ERR_RE = /\[error\]|\berror\b|\bfailed\b|\bpanic\b|exited with code/i, SWAP_RE = /hourly program changed|program changed|seed change|hot swap|new program|exit-on-seed-change/i; + function isErr(l) { return l.err || ERR_RE.test(l.text); } + function isSwap(l) { return SWAP_RE.test(l.text); } + function drawerOpen() { return document.body.classList.contains('drawer-open'); } + function setDrawer(open) { + document.body.classList.toggle('drawer-open', open); + var b = $('btn-logs'); b.classList.toggle('on', open); b.setAttribute('aria-expanded', open ? 'true' : 'false'); + $('btn-logs-text').textContent = open ? 'Close logs' : 'Logs'; + if (open) { applyDrawerHeight(); pollLog(); scheduleLogPoll(); viewDirty = true; renderLog(true); } + else if (logTimer) { clearTimeout(logTimer); logTimer = null; } } + // height: dragged by the grip, clamped to [140, 70% of the window]; a short window gets a smaller default + var drawerH = 0; + function defaultDrawerH() { return window.innerHeight < 700 ? 180 : 260; } + function applyDrawerHeight() { + var max = Math.floor(window.innerHeight * 0.7), min = 140; + if (!drawerH) { try { drawerH = parseInt(localStorage.getItem('igneum.drawer-h'), 10) || 0; } catch (e) { } } + var h = Math.max(min, Math.min(max, drawerH || defaultDrawerH())); + document.body.style.setProperty('--drawer-h', h + 'px'); + if (drawerOpen()) renderLog(true); + } + (function () { + var grip = $('drawer-grip'), startY = 0, startH = 0; + grip.addEventListener('pointerdown', function (e) { + startY = e.clientY; startH = parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10) || defaultDrawerH(); + grip.setPointerCapture(e.pointerId); document.body.classList.add('drawer-drag'); e.preventDefault(); + }); + grip.addEventListener('pointermove', function (e) { + if (!document.body.classList.contains('drawer-drag')) return; + drawerH = startH + (startY - e.clientY); applyDrawerHeight(); + }); + function end(e) { if (!document.body.classList.contains('drawer-drag')) return; document.body.classList.remove('drawer-drag'); try { localStorage.setItem('igneum.drawer-h', String(parseInt(getComputedStyle(document.body).getPropertyValue('--drawer-h'), 10))); } catch (x) { } } + grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end); + grip.addEventListener('dblclick', function () { drawerH = defaultDrawerH(); applyDrawerHeight(); try { localStorage.removeItem('igneum.drawer-h'); } catch (x) { } }); + })(); + window.addEventListener('resize', function () { if (drawerOpen()) applyDrawerHeight(); }); + document.querySelectorAll('#log-chips .chip').forEach(function (c) { c.addEventListener('click', function () { document.querySelectorAll('#log-chips .chip').forEach(function (x) { x.classList.toggle('on', x === c); }); logFilter = c.getAttribute('data-src'); viewDirty = true; renderLog(true); }); }); + $('log-search').addEventListener('input', function () { clearTimeout(searchT); var v = this.value; searchT = setTimeout(function () { logQuery = v.trim().toLowerCase(); viewDirty = true; renderLog(true); }, 120); }); + $('log-search').addEventListener('keydown', function (e) { if (e.key === 'Escape') { this.value = ''; logQuery = ''; viewDirty = true; renderLog(true); } }); + $('log-wrap').addEventListener('click', function () { wrap = !wrap; this.classList.toggle('on', wrap); logView.classList.toggle('wrap', wrap); renderLog(true); }); + $('log-follow').addEventListener('change', function () { setFollow(this.checked); if (follow) renderLog(true); }); + $('log-jump').addEventListener('click', function () { setFollow(true); renderLog(true); }); + $('log-copy').addEventListener('click', function () { + var r = visibleRange(), rows = view.slice(r[0], r[1]); + if (!rows.length) { toast('Nothing to copy'); return; } + copyText(rows.map(function (l) { return clock(l.t) + ' ' + l.src.padEnd(6) + ' ' + l.text; }).join('\n'), 'Copied ' + rows.length + ' line' + (rows.length === 1 ? '' : 's')); + }); + $('log-last-error').addEventListener('click', function () { jumpToLast(isErr, 'No error in the log'); }); + $('log-last-swap').addEventListener('click', function () { jumpToLast(isSwap, 'No program swap in the log yet'); }); + $('log-time').addEventListener('keydown', function (e) { if (e.key === 'Enter') { jumpToTime(this.value); } }); + $('log-time').addEventListener('change', function () { jumpToTime(this.value); }); + $('log-ruler').addEventListener('click', function (e) { + if (!view.length) return; + var r = this.getBoundingClientRect(), f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); + var t0 = view[0].t, t1 = view[view.length - 1].t; + jumpToIndex(lowerBound(view, t0 + f * (t1 - t0))); + }); + function setFollow(on) { follow = on; $('log-follow').checked = on; $('log-jump').hidden = on; } + function lowerBound(arr, t) { var lo = 0, hi = arr.length; while (lo < hi) { var mid = (lo + hi) >> 1; if (arr[mid].t < t) lo = mid + 1; else hi = mid; } return lo; } + function jumpToTime(v) { + var m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec((v || '').trim()); + if (!m) { toast('Type a time as HH:MM:SS'); return; } + if (!view.length) { toast('Nothing in the log'); return; } + var last = new Date(view[view.length - 1].t * 1000), d = new Date(last.getTime()); + d.setHours(+m[1], +m[2], +(m[3] || 0), 0); + if (d.getTime() > last.getTime() + 1000) d.setDate(d.getDate() - 1); // a time later than the newest line means yesterday + var target = d.getTime() / 1000; + if (target < view[0].t - 1) { toast('The log starts at ' + clock(view[0].t)); return; } + jumpToIndex(lowerBound(view, target)); + } + function jumpToLast(test, missing) { + for (var i = view.length - 1; i >= 0; i--) if (test(view[i])) { jumpToIndex(i); return; } + toast(missing); + } + var hitSeq = 0, hitT = 0; + function jumpToIndex(i) { + if (!view.length) return; + i = Math.max(0, Math.min(view.length - 1, i)); + setFollow(false); + hitSeq = view[i].seq; hitT = performance.now(); + autoScroll = true; + logView.scrollTop = Math.max(0, rowTop(i) - Math.floor(logView.clientHeight / 3)); + renderLog(true); + setTimeout(function () { hitSeq = 0; renderLog(true); }, 1600); + } + function rowTop(i) { + if (!wrap) return i * RH; + var kids = logPre.children; if (i < kids.length) return kids[i].offsetTop; return logPre.scrollHeight; + } + function visibleRange() { + if (!view.length) return [0, 0]; + if (wrap) { // find by offsetTop (the rows are all in the DOM) + var kids = logPre.children, top = logView.scrollTop, bottom = top + logView.clientHeight, a = -1, b = kids.length; + for (var k = 0; k < kids.length; k++) { var y = kids[k].offsetTop; if (a < 0 && y + kids[k].offsetHeight > top) a = k; if (y >= bottom) { b = k; break; } } + return [Math.max(0, a), b]; + } + var s = Math.floor(logView.scrollTop / RH), n = Math.ceil(logView.clientHeight / RH) + 1; + return [Math.max(0, s), Math.min(view.length, s + n)]; + } + function rebuildView() { + var q = logQuery; + view = logLines.filter(function (l) { return (!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0); }); + viewDirty = false; winStart = winEnd = -1; + } + function appendView(lines) { + var q = logQuery; + for (var i = 0; i < lines.length; i++) { var l = lines[i]; if ((!logFilter || l.src.indexOf(logFilter) === 0) && (!q || l.text.toLowerCase().indexOf(q) >= 0 || l.src.indexOf(q) >= 0)) view.push(l); } + } + function rowHtml(l) { + var text = esc(l.text), q = logQuery; + if (q) { // highlight every match (case-insensitive, on the escaped text; the query itself is escaped too) + var eq = esc(q), lower = text.toLowerCase(), out = '', at = 0, idx; + while ((idx = lower.indexOf(eq, at)) >= 0) { out += text.slice(at, idx) + '' + text.slice(idx, idx + eq.length) + ''; at = idx + eq.length; } + text = out + text.slice(at); + } + return '
' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + ' ' + text + '
'; + } + function renderLog(force) { + if (!drawerOpen()) return; + var a = performance.now(); + if (viewDirty) rebuildView(); + var n = view.length; + $('log-empty').hidden = n > 0; + $('log-empty').textContent = logLines.length ? 'No line matches.' : 'Nothing logged yet. Lines appear as the node and the miner write them.'; + $('log-count').textContent = (logQuery || logFilter) ? (n + ' of ' + logLines.length) : (n ? String(n) : ''); + var canWrap = n <= WRAP_MAX; $('log-wrap').disabled = !canWrap; $('log-wrap').title = canWrap ? 'Wrap long lines' : 'Wrap is off above 5,000 lines; filter or search first'; + if (wrap && canWrap) { + logPad.style.height = ''; logPre.style.transform = ''; + if (force || winStart !== 0 || winEnd !== n) { logPre.innerHTML = view.map(rowHtml).join(''); winStart = 0; winEnd = n; } + } else { + if (wrap && !canWrap) { wrap = false; $('log-wrap').classList.remove('on'); logView.classList.remove('wrap'); } + logPad.style.height = (n * RH) + 'px'; + if (follow) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); } + else if (logView.scrollTop > n * RH) { autoScroll = true; logView.scrollTop = Math.max(0, n * RH - logView.clientHeight); } + var r = visibleRange(), s = Math.max(0, r[0] - OVERSCAN), e = Math.min(n, r[1] + OVERSCAN); + if (force || s !== winStart || e !== winEnd) { + var h = ''; for (var i = s; i < e; i++) h += rowHtml(view[i]); + logPre.innerHTML = h; logPre.style.transform = 'translateY(' + (s * RH) + 'px)'; winStart = s; winEnd = e; + } + } + if (follow && wrap) { autoScroll = true; logView.scrollTop = logView.scrollHeight; } + updateAt(); updateRuler(force); + perf.log += performance.now() - a; + } + function updateAt() { + var r = visibleRange(); + $('log-at').textContent = view.length ? clock(view[Math.min(view.length - 1, r[0])].t) : ''; + } + // the ruler: the span of the view, error (ember) and swap (molten) marks, and the window in view; rebuilt when the + // count changes, at most every 2 s, from a sample of at most 4,000 lines + function updateRuler(force) { + var ruler = $('log-ruler'), n = view.length; + if (!n) { ruler.classList.add('empty'); return; } + ruler.classList.remove('empty'); + var t0 = view[0].t, t1 = view[n - 1].t, span = Math.max(1, t1 - t0); + var r = visibleRange(), win = $('log-ruler-win'), i0 = Math.min(n - 1, r[0]), i1 = Math.max(i0, Math.min(n - 1, r[1] - 1)); + win.style.left = ((view[i0].t - t0) / span * 100) + '%'; + win.style.width = Math.max(0.5, (view[i1].t - view[i0].t) / span * 100) + '%'; + var now = performance.now(); + if (n === rulerCount && now - rulerAt < 2000) return; + rulerAt = now; rulerCount = n; + $('log-ruler-0').textContent = clock(t0); $('log-ruler-1').textContent = clock(t1); + ruler.querySelectorAll('.mk').forEach(function (m) { m.remove(); }); + var step = Math.max(1, Math.floor(n / 4000)), h = ''; + for (var i = 0; i < n; i += step) { var l = view[i]; var sw = isSwap(l); if (sw || isErr(l)) h += ''; } + win.insertAdjacentHTML('beforebegin', h); + } + logView.addEventListener('scroll', function () { + if (autoScroll) { autoScroll = false; return; } + var atEnd = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - RH; + if (atEnd !== follow) setFollow(atEnd); + if (!scrollRaf) scrollRaf = requestAnimationFrame(function () { scrollRaf = 0; renderLog(false); }); + }); function pollLog() { - if (!document.body.classList.contains('drawer-open')) return; - api('api/log?after=' + logAfter + '&limit=400').then(function (r) { - if (r.lines && r.lines.length) { logLines = logLines.concat(r.lines).slice(-3000); logAfter = r.lines[r.lines.length - 1].seq; renderLog(); } + if (!drawerOpen() || hiddenNow()) return; + api('api/log?after=' + logAfter + '&limit=' + (logAfter ? 400 : 5000)).then(function (r) { + perf.polls++; + if (r.lines && r.lines.length) { + logLines = logLines.concat(r.lines); logAfter = r.lines[r.lines.length - 1].seq; + if (logLines.length > LOG_CAP) { logLines = logLines.slice(-LOG_CAP); viewDirty = true; } else if (!viewDirty) appendView(r.lines); + var a = performance.now(); renderLog(false); perf.poll += performance.now() - a; + } }).catch(function () { }); } - setInterval(pollLog, 1500); + function scheduleLogPoll() { clearTimeout(logTimer); logTimer = setTimeout(function () { pollLog(); if (drawerOpen()) scheduleLogPoll(); }, 1500); } // ---------- the blocks strip (your blocks over the last 10 minutes) ---------- - var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, seen = {}, born = {}; - function size() { W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); } + // The minute grid never moves (it hangs off "now", which is always the right edge), so it is drawn once per size + // into an offscreen layer. A frame is clear + the layer + the rail + the blocks. Blocks drift 1.7 px/s, so the + // strip redraws twice a second; while a block flashes (1.2 s after it appears) it runs at the display rate. Nothing + // draws while the window is hidden (the hosts hide it to the menu bar or the tray). + var c = $('dag'), x = c.getContext('2d'), W = 0, H = 0, born = {}, grid = document.createElement('canvas'), gx = grid.getContext('2d'); + var SPAN = 600, S = 18, PAD = 10, RIGHT_PAD = 26; + function size() { + W = c.clientWidth; H = c.clientHeight; if (!W || !H) return; + c.width = W * dpr; c.height = H * dpr; x.setTransform(dpr, 0, 0, dpr, 0, 0); + grid.width = W * dpr; grid.height = H * dpr; gx.setTransform(dpr, 0, 0, dpr, 0, 0); + gx.clearRect(0, 0, W, H); + var right = W - RIGHT_PAD, left = PAD, pxs = (right - left) / SPAN; + gx.font = '500 10px IBM Plex Mono, monospace'; gx.textAlign = 'center'; + for (var m = 0; m <= 10; m++) { + var px = right - m * 60 * pxs; + gx.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; gx.lineWidth = 1; gx.setLineDash(m === 0 ? [] : [3, 6]); + gx.beginPath(); gx.moveTo(px, 14); gx.lineTo(px, H - 18); gx.stroke(); gx.setLineDash([]); + if (px > 30) { gx.fillStyle = 'rgba(154,154,158,0.8)'; gx.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } + } + var rail = H * 0.5; + gx.strokeStyle = 'rgba(58,58,66,0.9)'; gx.lineWidth = 1; gx.beginPath(); gx.moveTo(left, rail); gx.lineTo(right, rail); gx.stroke(); + requestDraw(); + } window.addEventListener('resize', function () { size(); }); function rrect(px, py, w, h, r) { x.beginPath(); x.moveTo(px + r, py); x.lineTo(px + w - r, py); x.quadraticCurveTo(px + w, py, px + w, py + r); x.lineTo(px + w, py + h - r); x.quadraticCurveTo(px + w, py + h, px + w - r, py + h); x.lineTo(px + r, py + h); x.quadraticCurveTo(px, py + h, px, py + h - r); x.lineTo(px, py + r); x.quadraticCurveTo(px, py, px + r, py); x.closePath(); } + // returns true while something is animating (a flash), so the caller keeps the fast rate function draw(now) { if (!W || !H) size(); - if (!W) return; + if (!W) return false; x.clearRect(0, 0, W, H); - var span = 600, S = 18, pad = 10, right = W - 26, left = pad; - var pxs = (right - left) / span; + x.drawImage(grid, 0, 0, W, H); + var right = W - RIGHT_PAD, pxs = (right - PAD) / SPAN; var nowU = state ? (state.now + (performance.now() - stateAt) / 1000) : Date.now() / 1000; - // minute lines - x.font = '500 10px IBM Plex Mono, monospace'; x.textAlign = 'center'; - for (var m = 0; m <= 10; m++) { - var t = nowU - m * 60, px = right - (nowU - t) * pxs; - x.strokeStyle = m === 0 ? 'rgba(255,179,92,0.5)' : 'rgba(42,42,48,0.9)'; x.lineWidth = 1; x.setLineDash(m === 0 ? [] : [3, 6]); - x.beginPath(); x.moveTo(px, 14); x.lineTo(px, H - 18); x.stroke(); x.setLineDash([]); - if (px > 30) { x.fillStyle = 'rgba(154,154,158,0.8)'; x.fillText(m === 0 ? 'now' : '-' + m + ' min', px, H - 5); } - } var found = state ? state.mining.found : []; - var rail = H * 0.5; - x.strokeStyle = 'rgba(58,58,66,0.9)'; x.lineWidth = 1; x.beginPath(); x.moveTo(left, rail); x.lineTo(right, rail); x.stroke(); - var shown = 0; - found.forEach(function (t, i) { - var age = nowU - t; if (age < 0 || age > span) return; shown++; + var rail = H * 0.5, shown = 0, animating = false; + for (var i = 0; i < found.length; i++) { + var t = found[i], age = nowU - t; if (age < 0 || age > SPAN) continue; shown++; var key = String(t); - if (!born[key]) born[key] = now; + if (!born[key]) born[key] = age < 5 ? now : now - 2000; // only a block that just arrived flashes, not the whole strip on first paint var fl = Math.max(0, 1 - (now - born[key]) / 1200); + if (fl > 0) animating = true; var px = right - age * pxs, py = rail + ((i % 3) - 1) * 22; if (fl > 0) { var g = x.createRadialGradient(px, py, 0, px, py, S * 2.2); g.addColorStop(0, 'rgba(255,179,92,' + (0.6 * fl) + ')'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.beginPath(); x.arc(px, py, S * 2.2, 0, Math.PI * 2); x.fill(); } x.fillStyle = '#F2541B'; rrect(px - S / 2, py - S / 2, S, S, 5); x.fill(); if (fl > 0) { x.strokeStyle = 'rgba(255,179,92,' + fl + ')'; x.lineWidth = 2; rrect(px - S / 2 - 2, py - S / 2 - 2, S + 4, S + 4, 6); x.stroke(); } - }); + } + // forget flashes older than the strip, so the map does not grow for a day-long run + if (found.length && Object.keys(born).length > found.length + 50) { var keep = {}; for (var k = 0; k < found.length; k++) if (born[String(found[k])]) keep[String(found[k])] = born[String(found[k])]; born = keep; } if (!shown) { x.fillStyle = 'rgba(154,154,158,0.9)'; x.font = '500 12px IBM Plex Mono, monospace'; x.textAlign = 'center'; var msg = !state ? 'waiting for the engine' : state.mining.state === 'mining' ? 'mining: your blocks appear here as the node accepts them' : state.mining.state === 'paused' ? 'paused' : state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync'; x.fillText(msg, W / 2, rail - 14); } + return animating && !reduce; } - var stateAt = performance.now(); - function frame(now) { if (phase === 'dashboard') draw(now); if (!reduce) requestAnimationFrame(frame); else setTimeout(function () { frame(performance.now()); }, 1000); } - requestAnimationFrame(frame); + var stateAt = performance.now(), drawTimer = null, drawRaf = 0; + function frame(now) { + drawRaf = 0; clearTimeout(drawTimer); drawTimer = null; + if (hiddenNow()) return; + var fast = false; + if (phase === 'dashboard') { var a = performance.now(); fast = draw(now); perf.draw += performance.now() - a; perf.frames++; } + if (fast) drawRaf = requestAnimationFrame(frame); + else drawTimer = setTimeout(function () { drawTimer = null; drawRaf = requestAnimationFrame(frame); }, reduce ? 1000 : 500); + } + function requestDraw() { if (hiddenNow() || drawRaf) return; clearTimeout(drawTimer); drawTimer = null; drawRaf = requestAnimationFrame(frame); } + document.addEventListener('visibilitychange', function () { + if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; } + else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } } + }); + requestDraw(); // ---------- render ---------- // ---------- GPU rows: one per card, with a switch and an identities stepper ---------- @@ -313,7 +536,20 @@ var b = e.target.closest('[data-power-retry]'); if (!b) return; api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); }); }); - var lastEventsKey = ''; + var lastEventsKey = '', lastFoot = ''; + function skewWord(sk) { sk = Math.round(Math.abs(sk)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; } + // sync ETA from the block rate over the last minute of readings: "about 2 h 10 min left" + var syncSamples = []; + function syncEta(n, now) { + if (n.state !== 'syncing' || !(n.headers > n.blocks)) { syncSamples = []; return ''; } + if (!syncSamples.length || now - syncSamples[syncSamples.length - 1].t >= 1) syncSamples.push({ t: now, b: n.blocks }); + while (syncSamples.length > 2 && now - syncSamples[0].t > 90) syncSamples.shift(); + var a = syncSamples[0], b = syncSamples[syncSamples.length - 1]; + if (b.t - a.t < 5 || b.b <= a.b) return syncSamples.length > 15 ? 'no progress in the last minute' : 'measuring the sync rate'; + var rate = (b.b - a.b) / (b.t - a.t), left = (n.headers - n.blocks) / rate; + var word = left < 60 ? 'under a minute' : left < 3600 ? 'about ' + Math.round(left / 60) + ' min' : left < 86400 ? 'about ' + Math.floor(left / 3600) + ' h ' + Math.round(left % 3600 / 60) + ' min' : 'over a day'; + return word + ' left at ' + Math.round(rate) + ' blocks/s'; + } function renderDashboard(s) { var m = s.mining, n = s.node, p = s.program, f = s.finality; $('d-hash').textContent = m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1); @@ -325,18 +561,22 @@ $('d-node').textContent = nodeWord; // the live numbers always show, so a stuck label is visible as stuck var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's'); - $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; - $('d-node-sub').title = n.last_reading_age_s >= 0 ? 'last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''; + var eta = syncEta(n, s.now); + var syncing = n.state === 'syncing' && n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + Math.floor(n.blocks / n.headers * 100) + '%)' : ''; + $('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + skewWord(s.clock.skew_s) + ' ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': sync it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : syncing || (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live; + $('d-node-sub').title = $('d-node-sub').textContent + (n.last_reading_age_s >= 0 ? ' · last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : ''); + $('d-hash-sub').title = $('d-hash-sub').textContent; $('d-blocks-sub').title = $('d-blocks-sub').textContent; var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block')); if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); } - else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; } + else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = n.state === 'syncing' ? 'after the node is synced' : 'waiting for the node'; } + $('d-eta-sub').title = $('d-eta-sub').textContent; var nowU = s.now; $('d-found-10').textContent = m.found.filter(function (t) { return nowU - t <= 600; }).length; $('d-found-60').textContent = m.found.length; $('d-chain-blocks').textContent = compact(n.blocks); // per-card tiles (enabled cards), the rest in one muted line var on = m.cards.filter(function (c) { return c.enabled; }), off = m.cards.filter(function (c) { return !c.enabled; }); - $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPUs' : ''); + $('d-cards-eyebrow').textContent = on.length + ' worker' + (on.length === 1 ? '' : 's') + (m.cards.length > on.length ? ' of ' + m.cards.length + ' GPU' + (m.cards.length === 1 ? '' : 's') : ''); var tb = $('d-cards'); if (!on.length) tb.innerHTML = '
' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '
'; else tb.innerHTML = on.map(function (cd) { @@ -378,7 +618,12 @@ } // bottom $('btn-pause').textContent = m.paused ? 'Resume' : 'Pause'; - $('foot-status').textContent = (s.quitting ? 'stopping: miners first, then the node' : (s.display_name ? s.display_name + ' · ' : '') + (m.state === 'mining' ? 'mining' : m.state) + ' · node ' + nodeWord + ' · up ' + uptime(s.uptime_s) + (s.address.display ? ' · rewards to ' + s.address.display.slice(0, 8) + '…' + s.address.display.slice(-4) : '')); + // the bottom line: the machine name truncates first, the address is always short, the rest never wraps + var foot = s.quitting ? 'stopping: miners first, then the node' : + (s.display_name ? '' + esc(s.display_name) + '' : '') + + '' + esc(m.state === 'mining' ? 'mining' : m.state) + 'node ' + esc(nodeWord) + 'up ' + uptime(s.uptime_s) + '' + + (s.address.display ? 'rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '' : ''); + if (foot !== lastFoot) { lastFoot = foot; $('foot-status').innerHTML = foot; } $('foot-version').textContent = 'v' + s.version; } function renderPill(s) { @@ -537,9 +782,12 @@ if (phase === 'dashboard') renderDashboard(s); if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; } } - var failures = 0, first = true; + var failures = 0, first = true, pollTick = 0; function poll() { + // hidden windows poll every 5 s (the pill and the tray text still need a recent state), visible ones every second + if (hiddenNow() && (pollTick++ % 5)) return; api('api/state').then(function (s) { + var a = performance.now(); failures = 0; if (first) { first = false; @@ -550,11 +798,21 @@ if (start === 'cards') renderCards(s); } render(s); + perf.render += performance.now() - a; perf.polls++; + if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); } }).catch(function () { failures++; - if (failures >= 4) { $('pill').classList.add('warn'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; } + if (failures >= 4) { $('pill').classList.add('warn', 'away'); $('pill-text').textContent = state && state.quitting ? 'stopped' : 'engine away'; if (phase === 'dashboard') { $('d-node').textContent = 'no answer'; $('d-node-sub').textContent = 'the engine is not answering; the window reconnects by itself'; $('d-node-cell').classList.add('bad'); } } }); } poll(); setInterval(poll, 1000); size(); + // debug: time N synchronous calls of each hot path (ms per call), independent of the host's frame rate + if (debug) window.__igneumBench = function (n) { + n = n || 100; var out = {}, a, i; + a = performance.now(); for (i = 0; i < n; i++) draw(performance.now()); out.drawMsPerFrame = +((performance.now() - a) / n).toFixed(3); + if (state) { a = performance.now(); for (i = 0; i < n; i++) render(state); out.renderMsPerPoll = +((performance.now() - a) / n).toFixed(3); } + if (drawerOpen()) { a = performance.now(); for (i = 0; i < n; i++) renderLog(true); out.logMsPerRender = +((performance.now() - a) / n).toFixed(3); } + return out; + }; })(); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index f68a688cc..814cc454e 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -304,8 +304,10 @@ - -
+ +
+
@@ -314,17 +316,37 @@
- + +
+ + + +
+
+ + + + +
-

+  
+
+
+ +
+