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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 19:51:22 +00:00
parent 9915c883b1
commit c924e9d30a
6 changed files with 733 additions and 187 deletions

View file

@ -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}
}

View file

@ -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 '<span class="src ' + esc(l.src) + '">' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + '</span> ' + (l.err ? '<span class="e">' : '') + esc(l.text) + (l.err ? '</span>' : ''); }).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) + '<mark>' + text.slice(idx, idx + eq.length) + '</mark>'; at = idx + eq.length; }
text = out + text.slice(at);
}
return '<div class="ln' + (isErr(l) ? ' e' : '') + (hitSeq && l.seq === hitSeq ? ' hit' : '') + '" title="' + clock(l.t) + '"><span class="src ' + esc(l.src) + '">' + clock(l.t) + ' ' + esc(l.src.padEnd(6)) + '</span> ' + text + '</div>';
}
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 += '<i class="mk ' + (sw ? 'swap' : 'err') + '" style="left:' + ((l.t - t0) / span * 100).toFixed(2) + '%" title="' + clock(l.t) + (sw ? ' program swap' : ' error') + '"></i>'; }
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 = '<div class="empty">' + (m.cards.length ? 'No card switched on. Open settings to pick one.' : 'No GPU worker. The node runs on its own.') + '</div>';
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 ? '<span class="pc">stopping: miners first, then the node</span>' :
(s.display_name ? '<span class="pc nm" title="' + esc(s.display_name) + '">' + esc(s.display_name) + '</span>' : '') +
'<span class="pc">' + esc(m.state === 'mining' ? 'mining' : m.state) + '</span><span class="pc">node ' + esc(nodeWord) + '</span><span class="pc up">up ' + uptime(s.uptime_s) + '</span>' +
(s.address.display ? '<span class="pc ad" title="rewards to ' + esc(s.address.display) + '">rewards to ' + esc(s.address.display.slice(0, 6) + '…' + s.address.display.slice(-4)) + '</span>' : '');
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;
};
})();

View file

@ -304,8 +304,10 @@
</aside>
</div>
<!-- the log drawer -->
<div class="drawer" id="drawer">
<!-- the log drawer: chips filter by source, search filters by text, the ruler and the jump controls move in time.
The list is virtualised (fixed row height, only the visible rows are in the DOM) so 20,000 lines scroll smoothly. -->
<div class="drawer" id="drawer" aria-label="Logs">
<div class="drawer-grip" id="drawer-grip" title="Drag to resize"><i></i></div>
<div class="drawer-head">
<div class="chips" id="log-chips">
<button class="chip on" data-src="">all</button>
@ -314,17 +316,37 @@
<button class="chip" data-src="miner">miner</button>
<button class="chip" data-src="watch">watch</button>
</div>
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
<div class="log-search">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>
<input type="search" id="log-search" placeholder="search" spellcheck="false" autocomplete="off" aria-label="Search the log">
<span class="count mono" id="log-count"></span>
</div>
<div class="log-nav">
<input type="text" class="mono" id="log-time" placeholder="HH:MM:SS" maxlength="8" spellcheck="false" autocomplete="off" aria-label="Jump to a time" title="Jump to a time (Enter)">
<button class="chip" id="log-last-error" title="Jump to the last error">last error</button>
<button class="chip" id="log-last-swap" title="Jump to the last program swap">last swap</button>
</div>
<div class="log-tools">
<span class="mono dim at" id="log-at" title="Time of the top line in view"></span>
<button class="chip" id="log-wrap" title="Wrap long lines (up to 5,000 lines)">wrap</button>
<button class="chip" id="log-copy" title="Copy the lines in view">copy</button>
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
</div>
</div>
<pre class="log" id="log"></pre>
<div class="log-ruler" id="log-ruler" title="Click to jump"><span class="t0 mono" id="log-ruler-0"></span><span class="t1 mono" id="log-ruler-1"></span><i class="win" id="log-ruler-win"></i></div>
<div class="log-view" id="log-view">
<div class="log-pad" id="log-pad"><pre class="log" id="log"></pre></div>
<div class="log-empty mono" id="log-empty" hidden>Nothing logged yet.</div>
</div>
<button class="btn small log-jump" id="log-jump" hidden><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"></path></svg><span id="log-jump-text">Newest</span></button>
</div>
<footer class="bottom" id="bottom" hidden>
<div class="left">
<button class="btn small" id="btn-pause">Pause</button>
<button class="btn small ghost" id="btn-logs">Logs</button>
<button class="btn small ghost" id="btn-logs" aria-expanded="false" aria-controls="drawer"><span id="btn-logs-text">Logs</span><svg class="chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6"></path></svg></button>
</div>
<div class="mid mono" id="foot-status"></div>
<div class="mid mono" id="foot-status" aria-live="off"></div>
<div class="right">
<span class="mono dim" id="foot-version"></span>
<button class="btn small ghost danger" id="btn-quit">Quit</button>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

178
tools/ui-mock/server.mjs Normal file
View file

@ -0,0 +1,178 @@
#!/usr/bin/env node
// A stand-in for the Igneum Miner engine, for working on app/igneum-app/ui without touching a running miner.
// Serves the UI files at /t/mock/ (the same path shape as the engine) and replays the recorded API responses in
// recorded/ with every timestamp shifted to now. POSTs are logged and answered {ok:true}; a few (pause, resume,
// settings) are applied to the in-memory state so the dashboard reacts. Nothing here reaches the real engine.
//
// node tools/ui-mock/server.mjs [port] default 4310
// open http://127.0.0.1:4310/t/mock/?scenario=live
//
// Scenarios (set with ?scenario= on the page URL, or GET /mock/scenario?set=<name>):
// live the recorded state as it was (Apple M5 Max mining, node synced)
// syncing node syncing, 41,000 headers, blocks climbing at a steady rate (the ETA)
// nodedown node failed and restarting, miner waiting
// nocards no GPU found (cards screen with ?screen=cards, and the dashboard)
// integrated one integrated card only, off by default
// clock clock skew that blocks mining
// paused miner paused
// biglog 12,000 log lines with errors and program swaps (virtualised list)
// fresh setup not done (welcome, cards, address screens)
// job a remote job running
// The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const uiDir = path.resolve(here, '../../app/igneum-app/ui');
const coin = path.resolve(here, '../../brand/igneum-coin-1024.png');
const port = parseInt(process.argv[2] || '4310', 10);
const PREFIX = '/t/mock';
const recState = JSON.parse(fs.readFileSync(path.join(here, 'recorded/state.json'), 'utf8'));
const recLog = JSON.parse(fs.readFileSync(path.join(here, 'recorded/log.json'), 'utf8')).lines;
const t0 = Date.now() / 1000; // server start
const shift = t0 - recState.now; // recorded clock -> live clock
let scenario = 'live';
let paused = false;
let seq = 0;
let log = [];
const posts = [];
function clone(v) { return JSON.parse(JSON.stringify(v)); }
function now() { return Date.now() / 1000; }
function seedLog() {
seq = 0; log = [];
for (const l of recLog) log.push({ seq: ++seq, t: l.t + shift, src: l.src, err: l.err, text: l.text });
if (scenario === 'biglog') {
const base = log[log.length - 1].t - 3600 * 2;
const srcs = ['node', 'node', 'node', 'miner1', 'watch', 'app'];
for (let i = 0; i < 12000; i++) {
const src = srcs[i % srcs.length];
const t = base + i * 0.6;
let text, err = false;
if (i % 1500 === 1499) { src === 'app'; text = '[build] hourly program changed: new program ' + i; }
else if (i % 977 === 500) { err = true; text = 'worker: kernel launch failed (code 702) on identity mac-d937c69d-1; restarting in 5 s'; }
else if (src === 'node') text = '2026-10-04 18:00:00.000+01:00 [INFO ] PoW accepted ' + (i * 2654435761 >>> 0).toString(16).padStart(8, '0') + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (30000 + i) + ', nonce 0x' + (i * 97).toString(16) + ')';
else if (src === 'miner1') text = (t).toFixed(3) + ' ACCEPTED block nonce=0x' + (i * 31).toString(16) + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)';
else if (src === 'watch') text = t.toFixed(3) + ' node1 blocks=' + (30000 + i) + ' headers=' + (30000 + i) + ' daa=' + (30000 + i) + ' tips=1 peers=4 synced=true';
else text = 'status: accepted ' + (5000 + i / 6 | 0) + ' blocks, 17.0 MH/s, mining | node ' + (30000 + i) + ' blocks, 4 peers, synced';
log.push({ seq: ++seq, t, src: i % 1500 === 1499 ? 'app' : src, err, text });
}
log.sort((a, b) => a.t - b.t); log.forEach((l, i) => { l.seq = i + 1; }); seq = log.length;
}
}
// a trickle of new lines so the drawer has something to follow
let tick = 0;
function growLog() {
const t = now(); tick++;
const h = (t * 1000 | 0).toString(16);
log.push({ seq: ++seq, t, src: 'node', err: false, text: new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 23) + ' [INFO ] PoW accepted ' + h + 'c954de57e97dc514cd8a92d by igneum-lottery-v2-bound (daa ' + (39571 + tick) + ')' });
if (tick % 4 === 0) log.push({ seq: ++seq, t, src: 'miner1', err: false, text: t.toFixed(3) + ' ACCEPTED block nonce=0x' + h + ' (gpu worker, cpu re-check ok, identity mac-d937c69d-1)' });
if (tick % 10 === 0) log.push({ seq: ++seq, t, src: 'watch', err: false, text: t.toFixed(3) + ' node1 blocks=' + (39571 + tick) + ' headers=' + (39571 + tick) + ' tips=1 peers=4 synced=true' });
if (tick % 45 === 0) log.push({ seq: ++seq, t, src: 'app', err: true, text: '[error] mock: a sample error line so "last error" has somewhere to jump' });
if (tick % 120 === 60) log.push({ seq: ++seq, t, src: 'app', err: false, text: '[build] hourly program changed: new program (mock swap)' });
while (log.length > 40000) log.shift();
}
setInterval(growLog, 1000);
function stateNow() {
const s = clone(recState);
const t = now();
s.now = t;
s.uptime_s = recState.uptime_s + Math.floor(t - t0);
s.mining.found = recState.mining.found.map(x => x + shift).filter(x => t - x < 3600);
s.events = recState.events.map(e => ({ ...e, t: e.t + shift }));
s.finality.last_lock_at += shift; s.finality.age_s = t - s.finality.last_lock_at;
s.update.checked_at += shift; s.clock.checked_at += shift; s.jobs.checked_at += shift;
s.program.eta_s = Math.max(0, Math.round(3600 - ((t - t0) % 3600)));
s.node.blocks = recState.node.blocks + Math.floor(t - t0); s.node.headers = s.node.blocks; s.node.daa = s.node.blocks;
s.node.last_reading_age_s = (t - t0) % 10;
// a live-looking hash rate
const wob = Math.sin((t - t0) / 7) * 1.4;
s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total;
// a fresh block every ~9 s on average
const extra = []; for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4);
s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b);
s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length;
if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40);
s.display_name = 'studio-mac-pro-with-a-long-name';
s.host = s.display_name;
if (paused || scenario === 'paused') { s.mining.paused = true; s.mining.state = 'paused'; s.mining.cards[0].state = 'off'; s.mining.hash_total = 0; s.mining.cards[0].hash_now = 0; }
if (scenario === 'syncing') {
const rate = 38; // blocks per second
s.node.state = 'syncing'; s.node.synced = false; s.node.headers = 412000; s.node.blocks = Math.min(412000, 30000 + Math.floor((t - t0) * rate)); s.node.daa = s.node.blocks;
s.node.message = 'downloading blocks'; s.node.peers = 2; s.node.tips = 0;
s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1; s.program.message = '';
s.finality = { age_s: 0, last_lock: 0, last_lock_at: 0, message: 'Finality starts after the node is synced.', votes: 0 };
}
if (scenario === 'nodedown') {
s.node.state = 'restarting'; s.node.synced = false; s.node.restart_in_s = 7 - Math.floor(t - t0) % 8; s.node.restarts = 3; s.node.peers = 0; s.node.message = 'the node exited (code 101)';
s.mining.state = 'waiting'; s.mining.hash_total = 0; s.mining.cards[0].state = 'waiting'; s.mining.cards[0].hash_now = 0; s.mining.found = []; s.program.eta_s = -1;
s.events = [{ kind: 'error', t: t - 12, text: 'node exited with code 101; restarting in 8 s' }].concat(s.events).slice(0, 40);
}
if (scenario === 'nocards') { s.mining.cards = []; s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = []; s.detect_message = 'No Metal, CUDA or OpenCL device answered.'; s.detecting = false; }
if (scenario === 'integrated') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'intel::Intel Iris Xe', name: 'Intel Iris Xe Graphics', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '96 EUs, shared memory', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default, around 0.3 MH/s and it slows the desktop', identities: 1, prepared: false }];
s.mining.state = 'idle'; s.mining.hash_total = 0; s.mining.found = [];
}
if (scenario === 'clock') { s.clock = { ...s.clock, severity: 'block', skew_s: -412, message: 'This machine\'s clock is 6 min 52 s behind the network. Blocks it finds are rejected until the clock is right.', hint: 'System Settings > General > Date & Time: turn on "Set time and date automatically"' }; s.mining.state = 'waiting'; s.mining.hash_total = 0; }
if (scenario === 'fresh') { s.setup_done = false; s.phase = 'welcome'; s.address = { display: '', key_saved: false, source: '', value: '', wallet_file: '' }; }
if (scenario === 'job') { s.jobs = { ...s.jobs, active: true, id: 'job-2026-10-04-17', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', started_at: t - 400, results: ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], message: '' }; }
if (scenario === 'nvidia') {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 },
{ ...recState.mining.cards[0], key: 'intel::Intel UHD 770', name: 'Intel UHD Graphics 770', kind: 'integrated', vendor: 'other', worker: 'OpenCL', detail: '32 EUs', vram_mb: 0, enabled: false, state: 'off', hash_now: 0, hash_avg: 0, accepted: 0, ids: [], reason: 'integrated graphics: off by default', identities: 1 }];
s.mining.hash_total = 510.6; s.display_name = 'rig-01';
}
return s;
}
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
function send(res, code, type, body, cache) {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': cache ? 'public, max-age=86400' : 'no-store', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function json(res, v) { send(res, 200, 'application/json; charset=utf-8', JSON.stringify(v)); }
seedLog();
http.createServer((req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname === '/mock/scenario') {
if (u.searchParams.get('set')) { scenario = u.searchParams.get('set'); paused = false; seedLog(); }
return json(res, { scenario, lines: log.length, posts: posts.slice(-20) });
}
if (u.pathname === '/mock/posts') return json(res, posts);
if (!u.pathname.startsWith(PREFIX)) return send(res, 404, 'text/plain', 'not found');
let rest = u.pathname.slice(PREFIX.length) || '/';
if (rest === '/' || rest === '/index.html') {
if (u.searchParams.get('scenario') && u.searchParams.get('scenario') !== scenario) { scenario = u.searchParams.get('scenario'); paused = false; seedLog(); }
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
}
if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow());
if (rest === '/api/log' && req.method === 'GET') {
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);
return json(res, { lines: out });
}
if (req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; }); req.on('end', () => {
posts.push({ t: now(), path: rest, body: body.slice(0, 300) }); console.log('POST', rest, body.slice(0, 200));
if (rest === '/api/pause') paused = true; if (rest === '/api/resume') paused = false;
if (rest === '/api/key/reveal') return json(res, { ok: true, private_key: '0x' + 'ab'.repeat(32) });
if (rest === '/api/setup') return json(res, { ok: true, address: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8', private_key: '0x' + 'cd'.repeat(32), wallet_file: '/Users/you/Library/Application Support/Igneum/app/wallet.json' });
json(res, { ok: true });
});
return;
}
if (rest === '/coin.png') return send(res, 200, types['.png'], fs.readFileSync(coin), true);
const file = path.normalize(path.join(uiDir, rest));
if (!file.startsWith(uiDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
send(res, 200, types[path.extname(file)] || 'application/octet-stream', fs.readFileSync(file), rest.startsWith('/fonts/'));
}).listen(port, '127.0.0.1', () => console.log('ui-mock on http://127.0.0.1:' + port + PREFIX + '/ scenario=' + scenario));