diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-07 18:24:12 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-07 18:24:12 -0500 |
| commit | 7955be70ea7751c68d6a9b2ca3683ee697a38726 (patch) | |
| tree | b241c157d36238f9769660c0701ca24a2696abf2 /docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html | |
| parent | 96881fccae125c8803ac5c26b8ddb7ee77be363a (diff) | |
| download | archsetup-7955be70ea7751c68d6a9b2ca3683ee697a38726.tar.gz archsetup-7955be70ea7751c68d6a9b2ca3683ee697a38726.zip | |
docs: add maintenance console design + interactive prototypes
The design doc replaces the install-health framing in system-monitor-design-ideas.org with a maintenance console: dense metric subpanels behind a persistent selector, a doctor that streams into a results wall, determinate remedies only (guard-armed updates, curation lifecycles for noise, listeners, containers, and orphans), rotary band selectors on the tall subpanels, and a CLI-first build with a four-layer test strategy. Every decision is dated in the doc.
Prototypes A-E5 keep the exploration path: five divergent layouts converging into E5, a fully interactive simulation whose GOOD/BAD snapshots become the build's test fixtures.
todo.org gains the spec-create task, the panel keybinding-family task, and scope notes on the net/bt doctor retrofit.
Diffstat (limited to 'docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html')
| -rw-r--r-- | docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html | 1696 |
1 files changed, 1696 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html new file mode 100644 index 0000000..95ec4c9 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html @@ -0,0 +1,1696 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — E5 · Selector + dense subpanel</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:1.6rem 1.2rem 4rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center;align-items:flex-start} + +.capsule{width:960px;max-width:100%;background:var(--panel);color:var(--silver); + border:1.6px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);position:relative} + +/* ---- primitives ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite; + box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4); + white-space:nowrap} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)} +.key.dis{opacity:.3;cursor:not-allowed} +.key.dis:hover{color:var(--silver);border-color:#33302b} +.key.sm{font-size:10px;padding:3px 8px;border-radius:7px} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;white-space:nowrap} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.badge.pass{background:var(--pass);color:var(--cream)} + +.bar{width:100%;height:11px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px;transition:width .5s ease} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:54px;height:54px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8px} + +.readout{color:var(--cream);font-size:21px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em;line-height:1} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:10px} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.68rem} + +.lrow{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;background:#141210;font-size:12px} +.lrow .who{color:var(--silver)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:10.5px;display:flex;align-items:center;gap:7px} + +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:16px} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:5px}.ladder i:nth-child(2){height:9px} +.ladder i:nth-child(3){height:12px}.ladder i:nth-child(4){height:16px} +.ladder i.on{background:var(--pass)} +.ladder.bad i.on{background:var(--fail)} + +/* faceplate */ +.face{display:flex;align-items:center;gap:11px;padding:11px 13px;margin-bottom:11px; + background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:11px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.face .word{color:var(--cream);font-size:15.5px;font-weight:700;letter-spacing:.22em} +.face .spacer{flex:1} +.face .unit{color:var(--steel);font-size:.72rem;letter-spacing:.1em} +.face .xbtn{width:24px;height:24px;border-radius:50%;border:1px solid #33302b;background:transparent; + color:var(--dim);cursor:pointer;font-size:12px;line-height:1;display:grid;place-items:center;font-family:var(--mono)} +.face .xbtn:hover{color:var(--fail);border-color:var(--fail)} + +.ctlrow{display:flex;align-items:center;gap:8px;margin-bottom:11px;padding:0 2px} +.ctlrow .lbl{color:var(--steel);font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-right:3px} +.ctlrow .spacer{flex:1} + +.updbar{display:flex;align-items:center;gap:13px;padding:7px 12px;margin-bottom:12px; + border:1px solid var(--wash);border-radius:9px;background:linear-gradient(180deg,#141310,#0a0c0d);font-size:11.5px} +.updbar .q{color:var(--dim);font-size:.58rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--wash);border-radius:5px;padding:1px 6px} +.updbar.ok{border-color:var(--pass)} .updbar.ok .q{color:var(--pass);border-color:var(--pass)} +.updbar.warn{border-color:var(--amber)} .updbar.warn .q{color:var(--amber);border-color:var(--amber)} +.updbar.crit{border-color:var(--fail)} .updbar.crit .q{color:var(--fail);border-color:var(--fail)} +.updbar b{color:var(--cream);font-variant-numeric:tabular-nums} +.updbar .cve{color:var(--fail);font-weight:700} +.updbar .spacer{flex:1} +.updbar .dimtx{color:var(--dim);font-size:10.5px} + +/* selector */ +.selector{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:13px} +.selbtn{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:9px; + background:linear-gradient(180deg,#191715,#131110);border:1px solid #262320;border-left:3px solid var(--wash); + transition:border-color .1s} +.selbtn:hover{border-color:#3a352c} +.selbtn .nm{color:var(--silver);font-size:11.5px;font-weight:700;letter-spacing:.02em} +.selbtn .ct{margin-left:auto;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap} +.selbtn .ct .a{color:var(--amber)} .selbtn .ct .a.crit{color:var(--fail)} .selbtn .ct .k{color:var(--pass)} +.selbtn.attn{border-left-color:var(--amber)} +.selbtn.crit{border-left-color:var(--fail)} +.selbtn.clear{border-left-color:var(--pass)} +.selbtn.loading{border-left-color:var(--amber)} +.selbtn.sel{background:linear-gradient(180deg,#4a5768,var(--slate));border-color:var(--gold);border-left-width:3px} +.selbtn.sel .nm{color:var(--gold)} +.selbtn.sel .ct, .selbtn.sel .ct .a, .selbtn.sel .ct .k{color:var(--cream)} + +/* subpanel */ +.sub{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:13px 14px;min-height:330px} +.subhead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.subhead .nm{color:var(--gold);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:12.5px} +.subhead .cnt{color:var(--dim);font-size:11px;margin-left:auto;display:flex;gap:9px;align-items:center} +.subhead .cnt .att{color:var(--amber)} .subhead .cnt .att.crit{color:var(--fail)} .subhead .cnt .okc{color:var(--pass)} + +.wgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:10px} +.cell{background:#100f0e;border:1px solid #1f1c18;border-radius:9px;padding:9px 10px; + display:flex;flex-direction:column;gap:7px;position:relative;min-height:76px} +.cell .cap{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase; + display:flex;align-items:center;gap:6px;padding-right:56px} +.cell .lever{position:absolute;top:7px;right:7px} +.cell .val{display:flex;align-items:baseline;gap:7px;margin-top:auto} +.cell .sub2{color:var(--dim);font-size:10px} +.cell .center{display:flex;justify-content:center;margin-top:auto} +.gpair{display:flex;gap:14px;align-items:center;justify-content:center;margin-top:auto} +.gpair .g{display:flex;flex-direction:column;align-items:center;gap:2px} +.gpair .g .lb{color:var(--steel);font-size:8.5px;letter-spacing:.1em} + +.striph{margin:9px 0 7px} +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:5px 9px} +.strip.two{grid-template-columns:repeat(2,1fr)} + +/* segmented selector (gallery widget 06) */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;width:max-content} +.seg button{font:inherit;font-size:10px;color:var(--silver);background:#191715;border:0; + border-right:1px solid #33302b;padding:5px 9px;cursor:pointer;letter-spacing:.06em} +.seg button:last-child{border-right:0} +.seg button:hover{color:var(--gold)} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +/* evidence column headers */ +.evh{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;margin-bottom:4px} +.colh{display:flex;align-items:center;gap:8px;min-width:0} +.colh .engrave{flex:1;min-width:0} + +/* ===== rotary band selector (amplifier input-selector idiom) ===== */ +.deck{display:flex;align-items:center;gap:22px;margin:13px 4px 11px;padding:9px 14px; + background:linear-gradient(180deg,#161412,#100f0e);border:1px solid #201d17;border-radius:10px} +.knob{width:46px;height:46px;border-radius:50%;position:relative;cursor:pointer;flex:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob:hover{border-color:var(--gold)} +.knob .ind{position:absolute;left:50%;top:4px;width:2px;height:14px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 19px;border-radius:1px; + box-shadow:0 0 5px rgba(255,215,95,.6);transition:transform .25s cubic-bezier(.3,1.3,.5,1)} +.bands{display:flex;gap:26px;align-items:flex-end} +.band{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none} +.band .lamp{width:7px;height:7px} +.band .bl{color:var(--steel);font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;white-space:nowrap} +.band:hover .bl{color:var(--silver)} +.band.on .bl{color:var(--cream)} +.band .ul{height:2px;width:100%;border-radius:1px;background:transparent;transition:background .15s} +.band.on .ul{background:var(--gold);box-shadow:0 0 6px rgba(218,181,61,.5)} + +/* journal digest rows — capped at ~3.5 rows; the half row signals scrollability */ +/* rows are fixed 30px so the 3.5-row cap is exact: 3×30 + 3×4 gap + 15 half = 117 */ +.jlist{display:flex;flex-direction:column;gap:4px;max-height:117px;overflow-y:auto;padding-right:4px} +.jlist .jrow{height:30px;min-height:30px;padding:2px 8px;flex:none} + +/* dark scrollbars, consistent with the panel family (slate thumb on dark track) */ +.capsule ::-webkit-scrollbar{width:8px;height:8px} +.capsule ::-webkit-scrollbar-track{background:#0a0c0d;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb{background:#2c2f32;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb:hover{background:#54677d} +.capsule *{scrollbar-width:thin;scrollbar-color:#2c2f32 #0a0c0d} +.jrow{gap:8px} +.jrow .jmsg{color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0} +.jrow .what{margin-left:0;flex:none} +.jrow.known .who{color:var(--dim)} + +.skel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px; + min-height:260px;color:var(--dim);font-size:11px;letter-spacing:.24em;text-transform:uppercase} + +/* doctor / review */ +.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px} +.ostep .lamp{margin-top:3px;width:8px;height:8px} +.ostep b{color:var(--cream);font-weight:700} +.ostep .ev{color:var(--steel);font-size:11px;display:block} +.ostep .ev.evfail{color:var(--fail)} +.ostep.queued b{color:var(--dim)} +.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px} +/* review list: fixed 32px rows + 5px gap (37 stride); cap = 8.5 rows so the + panel holds its shape and the half row cues the scroll */ +.rvlist{max-height:312px;overflow-y:auto;padding-right:4px} +.rvrow{display:flex;align-items:center;gap:10px;padding:2px 10px;border-radius:8px;background:#141210; + font-size:12px;margin-bottom:5px;height:32px;min-height:32px} +.rvrow .who{color:var(--cream);font-weight:700} +.rvrow .why{color:var(--dim);font-size:10.5px} +.rvrow .why.errtx{color:var(--fail)} +.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center} +.rvrow .res{color:var(--pass);font-size:11px} +.rvnote{color:var(--dim);font-size:10.5px;margin:9px 2px 0;line-height:1.5} + +.errline{color:var(--fail);font-size:10px;line-height:1.35;margin-top:3px} + +/* results wall (output-well idiom from the net/bt panels) */ +.rwall{margin-top:12px;background:var(--well);border:1px solid #201d17;border-radius:11px;padding:10px 14px 12px} +.rwall .rhead{display:flex;align-items:center;gap:10px;margin-bottom:4px} +.rwall .rhead .keys{margin-left:auto;display:flex;gap:6px} +/* rows fixed 30px so the 3.5-entry cap is exact: 3.5 × 30 = 105 */ +.rwall .rbody{max-height:105px;overflow-y:auto;padding-right:4px} +.rwall .rstep{display:flex;gap:9px;align-items:center;height:30px;min-height:30px;font-size:12px} +.rwall .rstep .tm{color:var(--dim);font-size:10px;font-variant-numeric:tabular-nums;flex:none; + display:flex;flex-direction:column;line-height:1.2} +.rwall .rstep .tm .dt{font-size:8.5px;color:#5d636a;letter-spacing:.04em} +.rwall .rstep .lamp{width:8px;height:8px} +.rwall .rstep .rtxt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rwall .rstep b{color:var(--cream);font-weight:700} +.rwall .rstep .ev{color:var(--steel);font-size:11px} +.rwall .rstep .ev.evfail{color:var(--fail)} + +#toast{position:absolute;bottom:16px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:9; + align-items:flex-end;pointer-events:none;max-width:70%} +.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease} +.toastw.err{background:#5e3a2e} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +#closed{display:none;color:var(--dim);font-size:12px;border:1px dashed var(--wash); + border-radius:10px;padding:14px 20px;cursor:pointer} +#closed:hover{color:var(--gold);border-color:var(--gold)} + +/* page-level prototype chrome (NOT part of the panel) */ +.pagectl{max-width:960px;margin:0 0 12px;display:flex;align-items:center;gap:8px; + color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase} +.footnote{color:var(--dim);font-size:10.5px;margin-top:14px;line-height:1.55;max-width:960px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div> + +<!-- ===== prototype controls (page level, not panel UI) ===== --> +<div class="pagectl"> + prototype controls — + <button class="key sm" id="k-fail" onclick="toggleFail()" title="make every action fail so the error UX is visible">SIM FAIL</button> + <button class="key sm" id="k-bad" onclick="toggleBad()" title="load the degraded-machine snapshot: every metric in its warn/fail state">SIM BAD DAY</button> + <button class="key sm" id="k-zfs" onclick="toggleZfs()" title="show the velox/ZFS storage capability set instead of btrfs">SIM ZFS</button> + <button class="key sm" onclick="reloadPanel()" title="replay the open-and-hydrate sequence">SIM RELOAD</button> +</div> + +<div class="capsule" id="capsule"> + + <!-- faceplate --> + <div class="face"> + <span class="lamp" id="facelamp"></span> + <span class="word">MAINTENANCE</span> + <span class="badge red" id="cvebadge">3 CVE</span> + <span class="badge ghost" id="attbadge">9 ATTN</span> + <span class="badge red" id="errbadge" style="display:none">0 FAILED</span> + <span class="spacer"></span> + <span class="unit">MNT·01</span> + <button class="xbtn" title="Close (Esc)" onclick="closePanel()">✕</button> + </div> + + <!-- doctor control row --> + <div class="ctlrow"> + <span class="lbl">Doctor</span> + <button class="key" id="k-clean" onclick="go('doctor')">CLEAN UP</button> + <button class="key" id="k-review" onclick="go('review')">REVIEW & FIX</button> + <span class="spacer"></span> + <span class="lbl" style="margin-right:0" id="lastscan">last scan 07:41 · 44 checks · 0.9s</span> + </div> + + <!-- updates quarantine --> + <div class="updbar" id="updbar"> + <span class="q">Updates</span> + <span><b id="upd-p">47</b> pending</span> + <span class="cve" id="upd-c">3 CVE</span> + <span><b id="upd-a">5</b> AUR stale</span> + <span><b id="upd-f">0</b> firmware</span> + <span class="dimtx">mesa · linux-lts · systemd — guard-checked before any apply</span> + <span class="spacer"></span> + <span id="upd-rb"></span> + <button class="key sm" id="k-update" onclick="fire('sysupdate')">UPDATE</button> + <button class="key sm" id="k-topgrade" onclick="fire('topgrade')">TOPGRADE</button> + </div> + + <!-- persistent selector --> + <div class="selector" id="selector"></div> + + <!-- swapped subpanel --> + <div class="sub" id="sub"></div> + + <!-- results wall: appears on first action; toggle / copy / close --> + <div class="rwall" id="rwall" style="display:none"> + <div class="rhead"> + <span class="engrave" style="flex:1">Results<span class="cnt" id="rcount">· 0</span></span> + <span class="keys"> + <button class="key sm" id="rtoggle" onclick="toggleWall()">HIDE</button> + <button class="key sm" onclick="copyWall()">COPY</button> + </span> + </div> + <div class="rbody" id="rbody"></div> + </div> + + <div id="toast"></div> +</div> + +<div id="closed" onclick="openPanel()">MNT·01 closed — click to reopen (waybar: click the maint tag)</div> + +<div class="footnote"> + Prototype notes (not part of the panel): selector stays put — the gold/slate tile is the selection; the + subpanel below swaps. ✕ or Esc closes (chip reopens; reopening replays the hydrate sequence). CLEAN UP + streams into the subpanel and mutates real state; REVIEW & FIX lists every Confirm lever, destructive + ones arm-then-fire. Metrics without a determinate lever are read-only telemetry (agent/workflow assistance + is vLater). SIM FAIL makes every action fail (red lamp, error line, RETRY key, FAILED badge); SIM BAD DAY + loads a degraded-machine snapshot so every metric shows its warn/fail face, doctor or not; SIM RELOAD + replays the open: tiles pulse amber per tier (local reads land first, process probes next, package scans + last) and settle individually — cached values would fill the board instantly in the real build, with lamps + confirming freshness as probes return. +</div> +</div> + +<script> +/* ================= state snapshots ================= */ +function GOOD(){ return { + cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:0, st:'ok'}, + deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118', st:'ok'}, integ:{v:'clean', st:'ok'}, + deeptrim:{v:'keep 3', st:'ok'}, smartlast:'passed 2026-07-01', devrows:[], + dtop:[['~/videos','620 GB'],['~/music','240 GB'],['~/pictures','180 GB'],['/var/lib/docker','38 GB'],['~/.cache','22 GB']], + orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'NO', st:'ok'}, + orphlist:[['rust','1.2 GB'],['electron28','210 MB'],['python-build','84 MB'],['go-tools','61 MB'], + ['libplacebo','44 MB'],['ocaml','38 MB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB'], + ['xcb-util-old','7 MB'],['libdazzle','6 MB'],['gtest','5 MB'],['orc-old','4 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['mesa','CVE-2026-1104','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono +2', fwnames:'', + failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}], + cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'}, + jerr:{v:12, st:'ok'}, khw:{v:'clean', st:'ok'}, + coregroups:[['chrome',12,'13:02'],['hyprland',3,'06-07'],['hypridle',2,'11:40'],['dunst',1,'10:05']], + khwev:[], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:26, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:140, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:8, first:'07:02', last:'12:10', unit:null}, + {id:'insync', msg:'QSocketNotifier: invalid socket', n:6, first:'08:15', last:'13:20', unit:null}, + {id:'gammastep', msg:'Wayland connection experienced a fatal error', n:3, first:'07:02', last:'07:03', unit:'gammastep.service'}, + {id:'hypridle', msg:'Failed to inhibit idle', n:2, first:'09:41', last:'11:12', unit:'hypridle.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:1, first:'10:05', last:'10:05', unit:'dunst.service'}, + ], + temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'}, + zram:{v:'16 GiB', st:'ok'}, throttle:{v:'NO', st:'ok'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'power', st:'ok'}, batt:{v:94, st:'ok'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','4.2 GB'],['emacs','1.9 GB'],['insync','0.9 GB'],['waybar','0.3 GB'],['mpd','0.2 GB']], + boots:[['07-07 07:01','clean'],['07-06 08:12','unclean'],['07-05 09:45','unclean'],['07-04 07:58','clean'],['07-03 08:30','unclean']], + pevents:[], + docker:{v:3, st:'warn'}, rsync:{v:'3h', st:'ok'}, dstopped:{v:2, st:'ok'}, + cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','14.2 GB','1.8 GB'],['containers','0.4 GB','0.1 GB'],['volumes','6.8 GB','0.3 GB'],['build cache','0.8 GB','0.8 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}], + snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'}, + snaptypes:{timeline:31, single:8, prepost:3, oldsingle:'2026-06-12'}, + oldest:{v:8, st:'ok'}, retention:{v:'H6 D7 W2 M2 Y0', st:'ok'}, + fw:{v:'active', st:'ok'}, listen:{v:0, st:'ok'}, ts:{v:'4/4', st:'ok'}, tsdown:[], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + ], + f2b:{v:0, st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'}, + tgage:{v:9, st:'warn'}, + upd:{p:47, c:3, a:5, f:0}, + zcap:{v:62, st:'ok'}, zfrag:{v:31, st:'ok'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'ONLINE', st:'ok'}, zerr:{v:'0 / 0 / 0', st:'ok'}, +};} +function BAD(){ return { + cache:{v:17, st:'warn'}, scrub:{v:74, st:'fail'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:92, st:'fail'}, smart:{v:'FAILING', st:'fail'}, wear:{v:91, st:'warn'}, + deverr:{v:'14 / 2', st:'fail'}, unalloc:{v:'9', st:'warn'}, integ:{v:'3 modified', st:'warn'}, + deeptrim:{v:'never run', st:'warn'}, smartlast:'failed 2026-07-06 — reallocated sectors', + devrows:[['nvme0n1','12 read · 2 write · 3 corruption'],['nvme1n1','2 read · 0 write · 1 corruption']], + dtop:[['~/videos','1.4 TB'],['/var/lib/docker','160 GB'],['~/music','240 GB'],['~/.cache','96 GB'],['~/downloads','88 GB']], + orphans:{v:51, st:'warn'}, pacnew:{v:7, st:'warn'}, keyring:{v:94, st:'warn'}, reboot:{v:'YES', st:'warn'}, + orphlist:[['rust','1.2 GB'],['cuda-11','2.8 GB'],['electron28','210 MB'],['electron25','195 MB'], + ['python-build','84 MB'],['go-tools','61 MB'],['libplacebo','44 MB'],['ocaml','38 MB'], + ['texlive-extra','1.1 GB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/sudoers.pacnew', safe:false, why:'needs merge — review carefully'}, + {f:'/etc/ssh/sshd_config.pacnew', safe:false, why:'needs merge'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['sudo','CVE-2026-3011','high'],['mesa','CVE-2026-1104','medium'], + ['curl','CVE-2026-2440','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono · paru-git +8', fwnames:'UEFI 3.07→3.09 · NVMe fw', + failed:{v:4, st:'fail'}, timers:{v:'2/5', st:'fail'}, sysrun:{v:'degraded', st:'fail'}, taint:{v:'W', st:'warn'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}, + {u:'fail2ban', since:'07:44', info:'exit 1 — bad jail'}, + {u:'cronie', since:'07:02', info:'exit 203'}, + {u:'smartd', since:'07:02', info:'exit 1'}], + cores:{v:112, st:'fail'}, journald:{v:3.8, st:'warn'}, applog:{v:'stale', st:'warn'}, + jerr:{v:214, st:'warn'}, khw:{v:'MCE + I/O', st:'fail'}, + coregroups:[['chrome',61,'13:41'],['hyprland',23,'12:15'],['electron',17,'11:03'],['dunst',11,'13:10']], + khwev:[['12:44','MCE: CPU 3 bank 5 — corrected'],['11:02','nvme0n1: I/O error, sector 88121'],['10:58','thermal: package throttle 91°C']], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:31, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:180, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:9, first:'07:02', last:'12:10', unit:null}, + {id:'systemd-oomd', msg:'Failed to acquire memory pressure info', n:88, first:'07:05', last:'13:55', unit:'systemd-oomd.service'}, + {id:'wpa_supplicant', msg:'bgscan simple: Failed to enable signal monitoring', n:61, first:'07:02', last:'13:59', unit:'wpa_supplicant.service'}, + {id:'hyprland', msg:'GLES2 framebuffer incomplete', n:31, first:'08:11', last:'13:40', unit:null}, + {id:'rsyncshot', msg:'rsync error: partial transfer (code 23)', n:23, first:'02:30', last:'13:30', unit:'rsyncshot.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:11, first:'07:20', last:'13:10', unit:'dunst.service'}, + ], + temps:{v:'91/78', st:'fail'}, mem:{v:6, st:'warn'}, unclean:{v:82, st:'warn'}, + zram:{v:'none', st:'warn'}, throttle:{v:'YES', st:'fail'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'perf', st:'ok'}, batt:{v:71, st:'warn'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','38.4 GB'],['java (deepsat)','22.1 GB'],['emacs','2.1 GB'],['Hyprland','1.9 GB'],['insync','1.4 GB'],['dockerd','1.1 GB']], + boots:[['07-07 07:05','unclean'],['07-06 21:12','unclean'],['07-06 08:12','unclean'],['07-05 09:45','clean'],['07-04 07:58','unclean']], + pevents:[['13:41','OOM killed chrome (38 GB)'],['12:20','thermal throttle 91°C · 340 s'],['09:15','OOM killed java (22 GB)']], + docker:{v:22, st:'warn'}, rsync:{v:'52h', st:'fail'}, dstopped:{v:5, st:'warn'}, + cron:{v:'down', st:'fail'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','38.1 GB','18.2 GB'],['containers','1.2 GB','0.4 GB'],['volumes','9.8 GB','2.1 GB'],['build cache','1.3 GB','1.3 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}, + {name:'jellyfin', when:'2h ago', code:137},{name:'pihole', when:'9h ago', code:1}, + {name:'registry', when:'3d ago', code:0}], + snapper:{v:214, st:'warn'}, stimer:{v:'inactive', st:'fail'}, + snaptypes:{timeline:62, single:140, prepost:12, oldsingle:'2026-01-03'}, + oldest:{v:194, st:'fail'}, retention:{v:'H6 D7 W2 M10 Y2', st:'warn'}, + fw:{v:'inactive', st:'fail'}, listen:{v:9, st:'warn'}, ts:{v:'1/4', st:'fail'}, + tsdown:['truenas','worker','cjennings'], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + {proc:'python', port:8000, bind:'0.0.0.0', unit:null}, + {proc:'ncat', port:4444, bind:'0.0.0.0', unit:null}, + {proc:'smbd', port:445, bind:'0.0.0.0', unit:'smb'}, + {proc:'node', port:3000, bind:'127.0.0.1', unit:null}, + {proc:'postgres', port:5432, bind:'127.0.0.1', unit:'postgresql'}, + {proc:'cupsd', port:631, bind:'127.0.0.1', unit:'cups'}, + ], + f2b:{v:'down', st:'fail'}, ntp:{v:'unsynced', st:'warn'}, dns:{v:'FAIL', st:'fail'}, + tgage:{v:34, st:'fail'}, + upd:{p:214, c:9, a:12, f:2}, + zcap:{v:91, st:'fail'}, zfrag:{v:68, st:'warn'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'DEGRADED', st:'fail'}, zerr:{v:'3 / 0 / 1', st:'fail'}, +};} +let S = GOOD(); + +const CATS = [ + {id:'storage', name:'STORAGE', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']}, + {id:'packages', name:'PACKAGES', ids:['orphans','pacnew','keyring','reboot','tgage']}, + {id:'systemd', name:'SYSTEMD', ids:['failed','timers','sysrun','taint']}, + {id:'logs', name:'LOGS', ids:['cores','journald','applog','jerr','khw']}, + {id:'power', name:'MEM·PWR', ids:['temps','mem','unclean','zram','throttle','battery']}, + {id:'services', name:'SERVICES', ids:['docker','rsync','dstopped','cron','libvirt']}, + {id:'snapshots',name:'SNAPSHOTS', ids:['snapper','stimer','oldest','retention']}, + {id:'network', name:'NETWORK', ids:['fw','listen','ts','f2b','ntp','dns']}, +]; +/* diagnostic (no lever here) set — drives the faceplate verdict lamp */ +const DIAG = ['disk','smart','wear','deverr','unalloc','integ','sysrun','taint','jerr','khw', + 'temps','mem','zram','throttle','battery','listen','dstopped','libvirt','oldest','retention', + 'failed','unclean','rsync','dns','zcap','zfrag','zhealth','zerr','batt']; + +let view = 'cat:storage'; +let armed = null; +let doctorTimer = null; +let failMode = false; +let badMode = false; +const ERR = {}; +const loaded = new Set(); // categories whose probes have landed +let loadTimers = []; + +/* ================= helpers ================= */ +const $ = id => document.getElementById(id); +const BTRFS_IDS = ['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']; +const ZFS_IDS = ['zcap','cache','scrub','ztrim','smart','wear','zhealth','zerr','zfrag','integ','deeptrim']; +const PWR_RATIO = ['temps','mem','unclean','zram','throttle','battery','cpumode']; +const PWR_VELOX = ['temps','mem','unclean','zram','throttle','batt','blimit','cpumode']; +function catIds(cat){ + if(cat.id==='storage') return zfsMode?ZFS_IDS:BTRFS_IDS; + if(cat.id==='power') return zfsMode?PWR_VELOX:PWR_RATIO; + return cat.ids; +} +/* fixable/watch split per category — sets expectations before a single cell is read */ +function fixwatch(cid){ + switch(cid){ + case 'storage': return zfsMode?[7,4]:[8,2]; + case 'packages': return [5,0]; + case 'systemd': return [2,2]; + case 'logs': return [4,1]; + case 'power': return zfsMode?[3,5]:[2,5]; + case 'services': return [4,1]; + case 'snapshots':return [3,1]; + case 'network': return [6,0]; + } +} +function counts(cat){ + let attn=0, ok=0, crit=false; + catIds(cat).forEach(i=>{ const st=S[i].st; + if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; }); + return {attn, ok, crit}; +} +function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; } +function toast(msg, err){ + const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg; + $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600); +} + +/* ---- prototype toggles ---- */ +function toggleFail(){ + failMode = !failMode; + const k=$('k-fail'); k.classList.toggle('red', failMode); + k.textContent = failMode ? 'SIM FAIL ON' : 'SIM FAIL'; + toast(failMode ? 'failure simulation ON — every action will fail' : 'failure simulation off', failMode); +} +function toggleBad(){ + badMode = !badMode; + const k=$('k-bad'); k.classList.toggle('red', badMode); + k.textContent = badMode ? 'BAD DAY ON' : 'SIM BAD DAY'; + S = badMode ? BAD() : GOOD(); + Object.keys(ERR).forEach(k2=>delete ERR[k2]); + render(); + toast(badMode ? 'degraded snapshot loaded — every metric at its warn/fail face' : 'healthy snapshot restored', badMode); +} +/* ---- journal noise list: shipped defaults + user marks (two layers) ---- */ +const NOISE = [ + {id:'bluetoothd', pat:'Hands-Free Voice gateway connect failed', date:'2026-02-27', shipped:true, disabled:false}, + {id:'pixman', pat:'_pixman_log_error: BadDrawable', date:'2026-02-27', shipped:true, disabled:false}, + {id:'xkbcomp', pat:'Errors from xkbcomp are not fatal', date:'2026-03-24', shipped:true, disabled:false}, +]; +const nkey = g => g.id+'|'+(g.pat||g.msg); +function noiseFor(g){ return NOISE.find(n=>!n.disabled && n.id===g.id && n.pat===g.msg); } +function jSignal(){ return S.jgroups.filter(g=>!noiseFor(g)).sort((a,b)=>b.n-a.n); } +function jKnown(){ return S.jgroups.filter(g=>noiseFor(g)); } +function recalcJerr(){ + const sum = jSignal().reduce((s,g)=>s+g.n,0); + S.jerr = {v:sum, st: sum===0?'ok': sum<=25?'ok':'warn'}; +} +function markKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + if(armed!=='mark:'+key){ armed='mark:'+key; render(); + toast('mark known: '+g.id+' · "'+g.msg.slice(0,40)+'…" — press again', true); return; } + armed=null; + NOISE.push({id:g.id, pat:g.msg, date:today(), shipped:false, disabled:false}); + resAdd('mark known', g.id+' · '+g.msg.slice(0,50)+' ('+g.n+' this boot)', 'done'); + render(); +} +function unmarkKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + const n = noiseFor(g); if(!n) return; + if(n.shipped) n.disabled = true; + else NOISE.splice(NOISE.indexOf(n),1); + resAdd('unmark', g.id+' — back in signal'+(n.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearMarks(){ + if(armed!=='clearmarks'){ armed='clearmarks'; render(); + toast('clear all user marks + re-enable shipped defaults — press again', true); return; } + armed=null; + const removed = NOISE.filter(n=>!n.shipped).length; + for(let i=NOISE.length-1;i>=0;i--){ if(!NOISE[i].shipped) NOISE.splice(i,1); else NOISE[i].disabled=false; } + resAdd('clear marks', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} + +/* ---- listeners: expected-list curation + guarded per-socket remedies ---- */ +const LEXPECT = [ + {proc:'sshd', port:22, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'mpd', port:6600, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'tailscaled', port:41641, date:'2026-02-27', shipped:true, disabled:false}, +]; +const lkey = r => r.proc+'|'+r.port; +const isPub = b => b==='0.0.0.0'||b==='::'; +function lexpFor(r){ return LEXPECT.find(p=>!p.disabled && p.proc===r.proc && p.port===r.port); } +function lsnSignal(){ return S.lsn.filter(r=>!lexpFor(r)); } +function recalcListen(){ + const sig = lsnSignal(); + const pub = sig.filter(r=>isPub(r.bind)).length; + S.listen = {v:sig.length, st: sig.length===0?'ok': pub?'fail':'warn'}; +} +function markExpected(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + if(armed!=='lexp:'+key){ armed='lexp:'+key; render(); + toast('mark expected: '+r.proc+' :'+r.port+' — press again', true); return; } + armed=null; + LEXPECT.push({proc:r.proc, port:r.port, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', r.proc+' :'+r.port+' ('+r.bind+')', 'done'); + render(); +} +function unmarkExpected(key){ + const p = LEXPECT.find(x=>!x.disabled && x.proc+'|'+x.port===key); if(!p) return; + if(p.shipped) p.disabled = true; else LEXPECT.splice(LEXPECT.indexOf(p),1); + resAdd('unmark expected', p.proc+' :'+p.port+' — back in signal'+(p.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearLsn(){ + if(armed!=='clearlsn'){ armed='clearlsn'; render(); + toast('clear user expected-marks + re-enable shipped — press again', true); return; } + armed=null; + const removed = LEXPECT.filter(p=>!p.shipped).length; + for(let i=LEXPECT.length-1;i>=0;i--){ if(!LEXPECT[i].shipped) LEXPECT.splice(i,1); else LEXPECT[i].disabled=false; } + resAdd('clear expected', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} +function lsnFix(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + const verb = r.unit ? 'systemctl stop '+r.unit : 'SIGTERM '+r.proc; + if(armed!=='lsnfix:'+key){ armed='lsnfix:'+key; render(); + toast((r.unit?'STOP ':'KILL ')+r.proc+' :'+r.port+' — '+verb+' · press again', true); return; } + armed=null; + const e = resAdd(r.unit?'stop unit':'kill listener', verb+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', r.unit?'systemctl: unit busy — stop refused':r.proc+' respawned — supervised? check its unit'); } + else { S.lsn.splice(S.lsn.indexOf(r),1); + resDone(e,'done', r.proc+' stopped — port '+r.port+' closed'); } + render(); + }, 800); +} + +/* ---- rotary sub-section selector state ---- */ +const SUBSEC = {packages:'orphans', logs:'signal', services:'containers'}; +const DECKDEFS = {}; +function setSubsec(cat,id){ SUBSEC[cat]=id; armed=null; render(); } +function cycleSubsec(cat){ + const defs = DECKDEFS[cat]; if(!defs||!defs.length) return; + const i = defs.findIndex(d=>d.id===SUBSEC[cat]); + setSubsec(cat, defs[(i+1)%defs.length].id); +} +function deck(cat, defs){ + DECKDEFS[cat] = defs; + const cur = SUBSEC[cat]; + const i = Math.max(0, defs.findIndex(d=>d.id===cur)); + const ang = defs.length>1 ? -50 + i*(100/(defs.length-1)) : 0; + const bands = defs.map(d=>`<span class="band ${d.id===cur?'on':''}" onclick="setSubsec('${cat}','${d.id}')"> + <span class="lamp ${d.lamp||''}"></span> + <span class="bl">${d.label}${d.count!==undefined&&d.count!==''?' · '+d.count:''}</span> + <span class="ul"></span></span>`).join(''); + return `<div class="deck"> + <span class="knob" onclick="cycleSubsec('${cat}')" title="cycle sections"> + <span class="ind" style="transform:rotate(${ang}deg)"></span></span> + <span class="bands">${bands}</span></div>`; +} + +/* ---- orphans: KEEP curation (the rust lesson) + per-package REMOVE ---- */ +const OKEEP = []; // user layer: packages marked intentional +const okept = name => OKEEP.some(k=>k.name===name); +function orphKeep(name){ + if(okept(name)) return; + OKEEP.push({name, date:today()}); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resAdd('keep', name+' marked intentional — excluded from removal', 'done'); + render(); +} +function orphUnkeep(name){ + const k = OKEEP.find(x=>x.name===name); if(!k) return; + OKEEP.splice(OKEEP.indexOf(k),1); + S.orphans.v++; S.orphans.st='warn'; + resAdd('unkeep', name+' — back in the orphan set', 'done'); + render(); +} +function orphRemove(name){ + const row = S.orphlist.find(r=>r[0]===name); if(!row) return; + if(armed!=='orem:'+name){ armed='orem:'+name; render(); + toast('pacman -Rns '+name+' ('+row[1]+') — press again', true); return; } + armed=null; + const e = resAdd('orphan remove', 'pacman -Rns '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','pacman: breaking dependency — '+name+' required by another package'); } + else { S.orphlist.splice(S.orphlist.indexOf(row),1); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resDone(e,'done',name+' removed — '+row[1]+' freed'); } + render(); + }, 900); +} + +/* ---- pacnew: per-file DELETE (safe) / MERGE (terminal delegation) ---- */ +function pacnewAct(file){ + const p = S.pacnews.find(x=>x.f===file); if(!p) return; + const e = resAdd(p.safe?'pacnew delete':'pacnew merge', + p.safe ? 'rm '+file+' — '+p.why+' …' : 'opening diff in terminal — '+file+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', p.safe?'rm: permission denied':'diff tool not found'); } + else { S.pacnews.splice(S.pacnews.indexOf(p),1); + S.pacnew.v = Math.max(0, S.pacnew.v-1); + if(S.pacnew.v===0) S.pacnew.st='ok'; + resDone(e,'done', p.safe ? file+' deleted — current config is authoritative' + : file+' merged in terminal · pacnew deleted'); } + render(); + }, 700); +} + +/* ---- failed units: per-unit roster remedies ---- */ +function funitAct(u, reset){ + const r = S.funits.find(x=>x.u===u); if(!r) return; + const e = resAdd(reset?'reset-failed':'unit restart', + (reset?'systemctl reset-failed ':'systemctl restart ')+u+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', u+': start request repeated too quickly'); } + else { S.funits.splice(S.funits.indexOf(r),1); + S.failed.v = Math.max(0, S.failed.v-1); + if(S.failed.v===0){ S.failed.st='ok'; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } + resDone(e,'done', u+(reset?' cleared from failed list':' restarted — running')); } + render(); + }, 800); +} + +/* ---- containers: expected-list curation + per-container START ---- */ +const CEXPECT = [ + {name:'winvm', date:'2026-02-27', shipped:true, disabled:false}, + {name:'deepsat-pg', date:'2026-06-20', shipped:false, disabled:false}, +]; +const cexp = name => CEXPECT.some(p=>!p.disabled && p.name===name); +function recalcCtrs(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)).length; + S.dstopped = {v:S.ctrs.length, st: unexp?'warn':'ok'}; +} +function ctrStartOne(name){ + const c = S.ctrs.find(x=>x.name===name); if(!c) return; + const e = resAdd('container start', 'docker start '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', name+' exits immediately (code 1) — see docker logs'); } + else { S.ctrs.splice(S.ctrs.indexOf(c),1); + resDone(e,'done', name+' running'); } + render(); + }, 800); +} +function ctrMark(name){ + if(armed!=='cexp:'+name){ armed='cexp:'+name; render(); + toast('mark expected: '+name+' stays stopped by design — press again', true); return; } + armed=null; + CEXPECT.push({name, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', name+' — expected-stopped (on-demand)', 'done'); + render(); +} +function ctrUnmark(name){ + const p = CEXPECT.find(x=>!x.disabled && x.name===name); if(!p) return; + if(p.shipped) p.disabled=true; else CEXPECT.splice(CEXPECT.indexOf(p),1); + resAdd('unmark expected', name+' — back in signal', 'done'); + render(); +} + +/* ---- snapshots: stale manual (single) snapshot delete ---- */ +function snapDelStale(){ + if(armed!=='snapstale'){ armed='snapstale'; render(); + toast('delete '+(S.snaptypes.single-2)+' stale manual snapshots (keep newest 2) — press again', true); return; } + armed=null; + const n = S.snaptypes.single-2; + const e = resAdd('stale snapshots', 'snapper delete ('+n+' singles) …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','snapper: config busy — snapshot in progress'); } + else { S.snaptypes.single=2; S.snaptypes.oldsingle='recent'; + S.snapper.v = S.snaptypes.timeline+S.snaptypes.single+S.snaptypes.prepost; + if(S.snapper.v<100) S.snapper.st='ok'; + resDone(e,'done', n+' manual snapshots deleted — space freed on next cleanup'); } + render(); + }, 900); +} + +/* kill process: arm-to-fire SIGTERM on a top-memory item; session-critical names protected */ +const PROTECTED = ['systemd','Hyprland','maintenance-panel']; +function killProc(name){ + const row = S.memtop.find(r=>r[0]===name); if(!row) return; + if(armed!=='kill:'+name){ armed='kill:'+name; render(); + toast('KILL '+name+' ('+row[1]+') — SIGTERM · press again', true); return; } + armed=null; + const e = resAdd('kill process', 'SIGTERM '+name+' ('+row[1]+') …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail',name+' ignored SIGTERM — state D (uninterruptible IO)'); } + else { + S.memtop.splice(S.memtop.indexOf(row),1); + const gb = parseFloat(row[1])||0; + S.mem.v = Math.min(125, Math.round((S.mem.v+gb)*10)/10); + if(S.mem.v>16 && S.mem.st!=='ok') S.mem={v:S.mem.v, st:'ok'}; + resDone(e,'done',name+' exited cleanly · '+row[1]+' freed'); + } + render(); + }, 700); +} + +/* cpu mode: free selector — set the active EPP mode to anything */ +const MODE_LABELS = {perf:'performance', bal:'balance_performance', power:'power'}; +function setMode(m){ + if(S.cpumode.v===m) return; + const e = resAdd('cpu mode', 'writing EPP hint: '+MODE_LABELS[m]+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','sysfs: energy_performance_preference write refused'); } + else { S.cpumode.v=m; resDone(e,'done','EPP → '+MODE_LABELS[m]+' (all cores)'); } + render(); + }, 500); +} + +let zfsMode = false; +function toggleZfs(){ + zfsMode = !zfsMode; + const k=$('k-zfs'); k.classList.toggle('on', zfsMode); + k.textContent = zfsMode ? 'ZFS ON' : 'SIM ZFS'; + Object.keys(ERR).forEach(k2=>{ if(ACT2CAT[k2]==='storage') delete ERR[k2]; }); + render(); + toast(zfsMode ? 'storage shows the velox/ZFS capability set' : 'storage shows the ratio/btrfs capability set'); +} +function reloadPanel(){ openPanel(); } + +/* ---- actions ---- */ +/* ---- results wall ---- */ +const RES = []; +let wallHidden = false; +const ANAMES = { clean_cache:'cache trim', scrub:'btrfs scrub', fstrim:'fstrim enable', + deeptrim:'deep trim', orphans:'orphan removal', pacnew:'pacnew merge', keyring:'keyring refresh', + timers:'timer enable', cores:'coredump clear', journald:'journal vacuum', applog:'app-log cleanup', + docker:'docker prune', snapper:'snapper cleanup', unit_restart:'unit restart', + f2b_fix:'fail2ban restart', cron_fix:'cronie start', ntp_fix:'chrony restart', + ts_fix:'tailscaled restart', stimer_fix:'snapshot timers', zram_fix:'zram enable', + netdoc:'net doctor', balance:'btrfs balance', reinstall:'pkg reinstall', rsyncnow:'backup run', + smarttest:'smart self-test', retention:'retention repair', reboot:'reboot', + sysupdate:'system update', topgrade:'topgrade', ztrim_fix:'zpool autotrim', + openjournal:'open journal', bat_limit:'charge limit', + ctr_start:'container start', fw_fix:'firewall enable' }; +function now(){ return new Date().toTimeString().slice(0,8); } +function today(){ const d=new Date(); + return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } +function resAdd(name, ev, st){ + const e = {d:today(), t:now(), name, ev, st:st||'run'}; + RES.push(e); renderWall(); return e; +} +function resDone(e, st, ev){ e.st=st; e.ev=ev; e.d=today(); e.t=now(); renderWall(); } +function renderWall(){ + const w = $('rwall'); + if(!RES.length){ w.style.display='none'; return; } + w.style.display='block'; + $('rcount').textContent = '· '+RES.length; + $('rtoggle').textContent = wallHidden ? 'SHOW' : 'HIDE'; + const b = $('rbody'); + b.style.display = wallHidden ? 'none' : 'block'; + b.innerHTML = RES.map(e=>{ + const lamp = e.st==='run'?'busy':e.st==='fail'?'red':''; + return `<div class="rstep"><span class="tm"><span class="dt">${e.d}</span>${e.t}</span><span class="lamp ${lamp}"></span> + <span class="rtxt" title="${e.name} — ${e.ev}"><b>${e.name}</b> <span class="ev ${e.st==='fail'?'evfail':''}">${e.ev}</span></span></div>`; + }).join(''); + b.scrollTop = b.scrollHeight; +} +function toggleWall(){ wallHidden=!wallHidden; renderWall(); } +function copyWall(){ + const txt = RES.map(e=>`${e.d} ${e.t} ${e.st.toUpperCase().padEnd(4)} ${e.name} — ${e.ev}`).join('\n'); + (navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject()) + .then(()=>toast('copied '+RES.length+' result lines')) + .catch(()=>toast('clipboard unavailable', true)); +} + +function fire(act){ + const a = ACTIONS[act]; + // guard-armed updates: first press runs the guard check and arms; second press overrides + if(a.guardArm && armed!==act){ + armed = act; + const e = resAdd(ANAMES[act]||act, a.busy||'checking guard…'); + setTimeout(()=>{ + resDone(e,'fail','guard: mesa in update set — press again to run anyway, or apply from a TTY'); + render(); + }, 600); + render(); + return; + } + if(a.arm && armed!==act && !ERR[act]){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; } + const overridden = a.guardArm && armed===act; + armed=null; + const e = resAdd(ANAMES[act]||act, (overridden && a.busy2) ? a.busy2 : (a.busy||'running…')); + setTimeout(()=>{ + if(failMode){ ERR[act]=a.err||'exited 1'; resDone(e,'fail',ERR[act]); } + else { delete ERR[act]; a.run(); resDone(e,'done',a.doneMsg); + if(a.guardArm && S.reboot.v==='YES') + resAdd('reboot offer','kernel/mesa updated — REBOOT key armed on the updates strip','done'); + } + render(); + }, a.ms||800); +} +const ACTIONS = { + clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB', + err:'paccache: /var/cache/pacman/pkg — permission denied (polkit dismissed)', + run(){ S.cache={v:0.4, st:'ok'}; } }, + scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion', + err:'btrfs: scrub already running on / — see dmesg', + run(){ S.scrub={v:0, st:'ok'}; } }, + fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active', + err:'systemctl: unit fstrim.timer is masked', + run(){ S.fstrim={v:'ON', st:'ok'}; if(S.timers.st==='warn') S.timers={v:'5/5', st:'ok'}; } }, + deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press again', busy:'paccache -rk1 …', ms:1100, + doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', + err:'paccache -rk1: exited 1 — cache dir busy (pacman lock held)', + run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } }, + orphans:{ arm:true, armMsg:'remove all unkept orphans — press again', busy:'pacman -Rns (unkept set) …', ms:1200, + doneMsg:'orphans removed — kept packages skipped', + err:'pacman -Rns: breaking dependency — rust required by cargo-audit', + run(){ S.orphlist = S.orphlist.filter(r=>okept(r[0])); S.orphans={v:0, st:'ok'}; } }, + pacnew:{ busy:'diffing .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged', + err:'merge aborted — locale.gen.pacnew conflicts, needs manual merge', + run(){ S.pacnew={v:0, st:'ok'}; } }, + keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed', + err:'pacman -Sy: mirror timeout — no network?', + run(){ S.keyring={v:0, st:'ok'}; } }, + timers:{ busy:'systemctl enable --now …', ms:800, doneMsg:'all 5 maintenance timers firing', + err:'systemctl: failed to enable — unit file not found', + run(){ S.timers={v:'5/5', st:'ok'}; if(S.fstrim.st==='fail') S.fstrim={v:'ON',st:'ok'}; } }, + cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared', + err:'coredumpctl: permission denied — /var/lib/systemd/coredump', + run(){ S.cores={v:0, st:'ok'}; S.coregroups=[]; } }, + journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB', + err:'journalctl: vacuum failed — rotation in progress, retry', + run(){ S.journald={v:0.3, st:'ok'}; } }, + applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', + err:'log-cleanup: exited 1 — ~/.local/var/log not writable', + run(){ S.applog={v:'ok', st:'ok'}; } }, + docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200, + doneMsg:'docker pruned — reclaimed 2.8 GB', + err:'docker: cannot connect to daemon — is docker.service running?', + run(){ S.docker={v:0.2, st:'ok'}; S.ddf = S.ddf.map(r=>[r[0], r[1], '0 GB']); } }, + snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900, + doneMsg:'snapper — retention applied', + err:'snapper: config busy — snapshot in progress', + run(){ S.snapper={v:Math.min(S.snapper.v,31), st:'ok'}; } }, + /* --- group A: service restarts / enables --- */ + unit_restart:{ busy:'systemctl restart (all failed units) …', ms:900, doneMsg:'units restarted — running', + err:'systemd-oomd: start request repeated too quickly', + run(){ S.funits=[]; S.failed={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + f2b_fix:{ busy:'systemctl restart fail2ban …', ms:800, doneMsg:'fail2ban running — sshd jail active', + err:'fail2ban: failed to start — bad jail config', + run(){ S.f2b={v:0, st:'ok'}; } }, + cron_fix:{ busy:'systemctl start cronie …', ms:700, doneMsg:'cronie running — entries live', + err:'cronie: unit masked', + run(){ S.cron={v:'ok', st:'ok'}; } }, + ntp_fix:{ busy:'systemctl restart chronyd + makestep …', ms:800, doneMsg:'clock stepped — synchronized', + err:'chronyd: no reachable sources', + run(){ S.ntp={v:'synced', st:'ok'}; } }, + ts_fix:{ busy:'systemctl restart tailscaled …', ms:900, doneMsg:'tailscaled up — peers reconnecting', + err:'tailscaled: bind: address already in use', + run(){ S.ts={v:'4/4', st:'ok'}; S.tsdown=[]; } }, + stimer_fix:{ busy:'systemctl enable --now snapper timers …', ms:700, doneMsg:'snapshot timers firing', + err:'systemctl: unit not found', + run(){ S.stimer={v:'active', st:'ok'}; } }, + zram_fix:{ busy:'re-applying zram-generator config …', ms:800, doneMsg:'zram active — 16 GiB zstd', + err:'zram-generator: module not loaded', + run(){ S.zram={v:'16 GiB', st:'ok'}; } }, + netdoc:{ busy:'opening NET·01 …', ms:400, doneMsg:'delegated to the net panel doctor (NET·01)', + err:'net panel not running', + run(){} }, + /* --- group B: deterministic maintenance ops --- */ + balance:{ arm:true, armMsg:'btrfs balance is IO-heavy and slow — press again', busy:'btrfs balance -dusage=50 …', ms:1500, + doneMsg:'balance complete — unallocated recovered', + err:'balance cancelled — ENOSPC during relocation', + run(){ S.unalloc={v:'118', st:'ok'}; } }, + reinstall:{ busy:'pacman -S (owning packages) …', ms:1100, doneMsg:'modified files restored — integrity clean', + err:'pacman: failed to commit transaction', + run(){ S.integ={v:'clean', st:'ok'}; } }, + rsyncnow:{ busy:'rsyncshot manual run …', ms:1400, doneMsg:'backup complete — synced to truenas', + err:'rsync exit 23 — partial transfer (permissions)', + run(){ S.rsync={v:'now', st:'ok'}; } }, + smarttest:{ busy:'smartctl -t short (both NVMe) …', ms:900, doneMsg:'short self-test started — results in ~2 min', + err:'smartctl: device busy', + run(){ S.smartlast='passed just now (short test)'; } }, + retention:{ busy:'writing sane retention (M2 Y0) + cleanup …', ms:1000, doneMsg:'retention repaired — old chain pruned', + err:'snapperd: config locked', + run(){ S.retention={v:'H6 D7 W2 M2 Y0', st:'ok'}; S.oldest={v:8, st:'ok'}; S.snapper={v:Math.min(S.snapper.v,42), st:'ok'}; } }, + /* --- group D: disruptive but determinate --- */ + reboot:{ arm:true, armMsg:'reboot the machine — press again', busy:'rebooting … (simulated)', ms:1200, + doneMsg:'rebooted — new kernel running, taint cleared', + err:'logind: inhibitor lock held (backup in progress)', + run(){ S.reboot={v:'NO', st:'ok'}; S.taint={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + ctr_start:{ busy:'docker start (all unexpected exited) …', ms:1000, + doneMsg:'containers started — running again', + err:'docker: container exits immediately (code 1) — see docker logs', + run(){ S.ctrs = S.ctrs.filter(c=>cexp(c.name)); } }, + fw_fix:{ busy:'ufw enable …', ms:800, + doneMsg:'firewall active — mosh rule loaded', + err:'ufw: iptables lock held — retry', + run(){ S.fw={v:'active', st:'ok'}; } }, + bat_limit:{ busy:'writing charge_control_end_threshold=80 …', ms:700, + doneMsg:'charge limit set — 80% (battery longevity)', + err:'sysfs: permission denied', + run(){ S.blimit={v:'80%', st:'ok'}; } }, + openjournal:{ busy:'spawning terminal: journalctl -p err -b …', ms:400, + doneMsg:'opened in terminal — journalctl -p err -b', + err:'no terminal available', + run(){} }, + /* --- zfs (velox capability set) --- */ + ztrim_fix:{ busy:'zpool set autotrim=on + zpool trim …', ms:1000, + doneMsg:'autotrim enabled — initial trim running', + err:'zpool: pool is busy (resilver in progress)', + run(){ S.ztrim={v:'ON', st:'ok'}; } }, + /* --- updates (Confirm; guard arms instead of blocking — user decides) --- */ + sysupdate:{ guardArm:true, busy:'checking update set against the live-update guard …', + busy2:'pacman -Syu … (guard overridden)', ms:1400, + doneMsg:'system updated — 47 packages · reboot recommended', + err:'pacman: failed to commit transaction (conflicting files)', + run(){ S.upd.p=0; S.upd.c=0; S.reboot={v:'YES', st:'warn'}; } }, + topgrade:{ guardArm:true, busy:'guard check, then topgrade (--disable git) …', + busy2:'topgrade full run … (guard overridden)', ms:1800, + doneMsg:'topgrade complete — system + ecosystems · reboot recommended', + err:'topgrade: system step failed — pacman lock held', + run(){ S.upd.p=0; S.upd.c=0; S.upd.a=0; S.tgage={v:0, st:'ok'}; S.reboot={v:'YES', st:'warn'}; } }, +}; + +/* ================= widget builders ================= */ +function lever(act, label, cls){ + const a = armed===act; + if(ERR[act]) return `<button class="key sm red" onclick="event.stopPropagation();fire('${act}')">RETRY</button>`; + return `<button class="key sm ${cls||''} ${a?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${a?label+'?':label}</button>`; +} +function errline(act){ return act && ERR[act] ? `<div class="errline">✗ ${ERR[act]}</div>` : ''; } +function lampFor(id, act){ return (act && ERR[act]) ? 'red' : lampCls(S[id].st); } +function lrow(id, name, extra, valOverride, act){ + return `<div class="lrow"><span class="lamp ${lampFor(id,act)}"></span><span class="who">${name}</span> + <span class="what">${extra||''}${(act&&ERR[act])?'✗ failed':(valOverride!==undefined?valOverride:S[id].v)}</span></div>`; +} +function barCls(st){ return st==='ok'?'okc':'warn'; } +function cellBar(id,cap,unit,pct,o){ o=o||{}; const m=S[id]; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div> + <div class="bar ${barCls(m.st)}">${o.tick?`<span class="tk" style="left:${o.tick}%"></span>`:''}<span style="width:${Math.min(100,pct)}%"></span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellCount(id,cap,o){ o=o||{}; const m=S[id]; + const col = m.st==='fail'?'style="color:var(--fail)"':(m.st==='warn'?'style="color:var(--amber)"':''); + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout" ${col}>${m.v}${o.unit?`<small>${o.unit}</small>`:''}</span>${o.badge||''}</div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellRing(id,cap,pct,small,o){ o=o||{}; const m=S[id]; + const rc = (o.act&&ERR[o.act])?'var(--fail)':m.st==='ok'?'var(--pass)':m.st==='fail'?'var(--fail)':'var(--amber)'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="center"><span class="ring" style="--p:${Math.min(100,pct)};--rc:${rc}"><b>${m.v}</b><small>${small}</small></span></div> + <div class="sub2" style="text-align:center">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellBadge(id,cap,o){ o=o||{}; const m=S[id]; + const cls = m.st==='ok'?'pass':m.st==='fail'?'red':m.st==='warn'?'':'ghost'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="badge ${cls}">${String(m.v).toUpperCase()}</span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } + +/* dynamic rosters */ +function timerRoster(){ + const st = S.timers.st; + const rows = [ + ['paccache.timer','weekly', true], + ['btrfs-scrub@-.timer','monthly', st!=='fail'], + ['fstrim.timer','weekly', S.fstrim.st==='ok'], + ['reflector.timer','weekly', st!=='fail'], + ['snapper-cleanup.timer','daily', true], + ]; + return rows.map(([n,c,ok])=>`<div class="lrow"><span class="lamp ${ok?'':'red'}"></span> + <span class="who">${n}</span><span class="what">${ok?c+' · ok':'disabled'}</span></div>`).join(''); +} +function backupRoster(){ + if(S.rsync.st==='ok') return ` + <div class="lrow"><span class="lamp"></span><span class="who">hourly</span><span class="what">3h ago · ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">daily 02:30</span><span class="what">11h ago · ok</span></div>`; + return ` + <div class="lrow"><span class="lamp red"></span><span class="who">hourly</span><span class="what">52h ago · stale</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">daily 02:30</span><span class="what">rsync exit 23 · failed</span></div>`; +} +function peerRoster(){ + const down = S.tsdown||[]; + return ['truenas','velox','worker','cjennings'].map(p=>{ + const d = down.includes(p); + return `<div class="lrow"><span class="lamp ${d?'red':''}"></span><span class="who">${p}</span> + <span class="what">${d?'offline':'online'}</span></div>`;}).join(''); +} + +/* ================= subpanel templates ================= */ +const SUB = { +storage(){ + if(zfsMode) return ` + <div class="wgrid"> + ${cellBar('zcap','zpool capacity','%',S.zcap.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'CoW slows > 80 · best-fit > 90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','zpool scrub',S.scrub.v/60*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'warn 35d · crit 60d'})} + ${cellCount('ztrim','zpool autotrim',{lever:S.ztrim.st!=='ok'?lever('ztrim_fix','ENABLE',S.ztrim.st==='fail'?'red':''):'',act:'ztrim_fix',sub:'the fstrim counterpart on ZFS'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'NVMe'})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0%':'high wear — plan replacement'})} + ${cellBadge('zhealth','Pool health',{sub:'zpool status -x'})} + ${cellCount('zerr','Pool errors',{sub:'read / write / cksum — no reset lever; investigate'})} + </div> + <div class="engrave striph">filesystem<span class="cnt">· 3</span></div> + <div class="strip"> + ${lrow('zfrag','fragmentation', '', S.zfrag.v+'% — no defrag exists; keep < 80% full')} + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; + return ` + <div class="wgrid"> + ${cellBar('disk','Disk usage','%',S.disk.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'root · btrfs · thr 80/90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','btrfs scrub',S.scrub.v/55*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'past 30d cadence'})} + ${cellCount('fstrim','fstrim.timer',{lever:lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':''),act:'fstrim',sub:'Confirm · weekly TRIM'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'last test: '+S.smartlast})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0% both drives':'high wear — plan replacement'})} + ${cellCount('deverr','Device errors',{sub:S.devrows.length?'per-device below · reset stays manual':'read / write · RAID1 · watch only'})} + ${cellCount('unalloc','btrfs unallocated',{lever:S.unalloc.st!=='ok'?lever('balance','BALANCE'):'',act:'balance',unit:' GiB',sub:'chunk headroom — raw space not yet carved into chunks'})} + </div> + ${S.devrows.length?` + <div class="engrave striph">device errors — which drive<span class="cnt">· cross-check SMART</span></div> + <div class="jlist">${S.devrows.map(r=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${r[0]}</b></span><span class="what">${r[1]}</span></div>`).join('')}</div>`:''} + <div class="engrave striph">disk — top consumers<span class="cnt">· evidence only</span></div> + <div class="jlist">${S.dtop.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]}</span></div>`).join('')}</div> + <div class="engrave striph">filesystem<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; }, + +packages(){ + const kept = OKEEP.map(k=>k.name); + const orows = S.orphlist.filter(r=>!kept.includes(r[0])).map(r=>{ + const a = armed==='orem:'+r[0]; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${r[0]}</b></span><span class="jmsg">${r[1]}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();orphKeep('${r[0]}')">KEEP</button> + <button class="key sm ${a?'armed':''}" onclick="event.stopPropagation();orphRemove('${r[0]}')">${a?'REMOVE?':'REMOVE'}</button> + </span></div>`; + }).join(''); + const krows = OKEEP.map(k=>`<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${k.name}</span><span class="what">kept ${k.date} + <button class="key sm" onclick="event.stopPropagation();orphUnkeep('${k.name}')">UNKEEP</button></span></div>`).join(''); + const prows = S.pacnews.map(p=>`<div class="lrow jrow"><span class="lamp ${p.safe?'':'gold'}"></span> + <span class="who"><b>${p.f.split('/').pop()}</b></span><span class="jmsg">${p.f} · ${p.why}</span> + <span class="what"><button class="key sm" onclick="event.stopPropagation();pacnewAct('${p.f}')">${p.safe?'DELETE':'MERGE'}</button></span></div>`).join(''); + const crows = S.upd.c ? S.cves.map(c=>`<div class="lrow jrow"> + <span class="lamp ${c[2]==='high'?'red':c[2]==='medium'?'gold':''}"></span> + <span class="who"><b>${c[0]}</b></span><span class="jmsg">${c[1]}</span> + <span class="what" style="color:${c[2]==='high'?'var(--fail)':'var(--dim)'}">${c[2]} · fix: UPDATE</span></div>`).join('') : ''; + return ` + <div class="wgrid"> + ${cellCount('orphans','Orphans',{lever:lever('orphans','REMOVE ALL'),act:'orphans',sub:'digest below · kept pkgs skipped'})} + ${cellCount('pacnew','.pacnew',{lever:lever('pacnew','REVIEW'),act:'pacnew',sub:'per-file below'})} + ${cellRing('keyring','Keyring age',S.keyring.v/30*100,'days',{lever:lever('keyring','REFRESH'),act:'keyring',sub:'refresh < 30d'})} + ${cellBadge('reboot','Reboot req.',{lever:S.reboot.v==='YES'?lever('reboot','REBOOT','red'):'',act:'reboot',sub:'kernel == modules'})} + </div> + <div class="wgrid"> + ${cellRing('tgage','Topgrade age',S.tgage.v/14*100,'days',{lever:lever('topgrade','TOPGRADE'),act:'topgrade',sub:'ecosystems · guard-aware'})} + </div> + ${deck('packages',[ + {id:'orphans', label:'ORPHANS', count:S.orphans.v, lamp:S.orphans.v?'gold':''}, + {id:'pacnew', label:'PACNEW', count:S.pacnews.length, lamp:S.pacnews.length?'gold':''}, + {id:'advis', label:'ADVISORIES', count:S.upd.c, lamp:S.upd.c?'red':''}, + ])} + ${SUBSEC.packages==='orphans'?` + <div class="engrave striph">orphans — top by size<span class="cnt">· KEEP = intentional (the rust lesson)</span></div> + <div class="jlist">${orows || '<div class="rvnote">no unkept orphans.</div>'}</div> + ${OKEEP.length?`<div class="evh" style="margin-top:8px">kept · ${OKEEP.length}</div><div class="jlist">${krows}</div>`:''}`:''} + ${SUBSEC.packages==='pacnew'?` + <div class="engrave striph">pacnew files<span class="cnt">· safe-delete vs needs-merge</span></div> + <div class="jlist">${prows || '<div class="rvnote">none pending ✓</div>'}</div>`:''} + ${SUBSEC.packages==='advis'?` + <div class="engrave striph">security advisories<span class="cnt">· arch-audit</span></div> + <div class="jlist">${crows || '<div class="rvnote">clean ✓</div>'}</div>`:''} + <div class="engrave striph">update surface<span class="cnt">· informational</span></div> + <div class="strip"> + <div class="lrow"><span class="lamp ${S.upd.c?'red':''}"></span><span class="who">arch-audit</span><span class="what">${S.upd.c?S.upd.c+' CVE — named above':'clean'}</span></div> + <div class="lrow"><span class="lamp gold"></span><span class="who">pending updates</span><span class="what">${S.upd.p}</span></div> + <div class="lrow"><span class="lamp ${S.upd.a>8?'gold':''}"></span><span class="who">AUR / foreign</span><span class="what">${S.upd.a?S.upd.a+' — '+S.aurnames:'fresh'}</span></div> + ${S.upd.f?`<div class="lrow"><span class="lamp gold"></span><span class="who">firmware</span><span class="what">${S.upd.f} — ${S.fwnames}</span></div>`:''} + </div>`; }, + +systemd(){ + const frows = S.funits.map(f=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${f.u}</b></span><span class="jmsg">since ${f.since} · ${f.info} · journalctl -u ${f.u}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}')">RESTART</button> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}',true)">RESET</button> + </span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('failed','Failed units',{lever:S.failed.v>1?lever('unit_restart','RESTART ALL'):'',act:'unit_restart',sub:S.failed.v?'roster below · per-unit keys':'none failed'})} + ${cellCount('timers','Maint timers',{lever:lever('timers','ENABLE'),act:'timers',sub:'Confirm · enable missing'})} + ${cellBadge('sysrun','is-system-running',{sub:S.sysrun.st==='ok'?'whole-system verdict · watch':'degraded — '+S.failed.v+' failed unit'+(S.failed.v===1?'':'s')+' below'})} + ${cellCount('taint','Kernel taint',{sub:S.taint.st==='ok'?'0 = untainted · watch':'W = kernel warned once — clears on reboot'})} + </div> + ${S.funits.length?`<div class="engrave striph">failed units<span class="cnt">· ${S.funits.length}</span></div> + <div class="jlist">${frows}</div>`:''} + <div class="engrave striph">timer roster<span class="cnt">· 5</span></div> + <div class="strip">${timerRoster()}</div>`; }, + +logs(){ + const sig = jSignal(), kn = jKnown(); + const sigRows = sig.slice(0,10).map(g=>{ + const key = nkey(g); + const armedNow = armed==='mark:'+key; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g.id}</b></span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${g.first}–${g.last}${g.unit?' · journalctl -u '+g.unit.replace('.service',''):''} + <button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();markKnown('${key}')">${armedNow?'MARK?':'MARK KNOWN'}</button></span></div>`; + }).join(''); + const knRows = kn.map(g=>{ + const key = nkey(g); const n = noiseFor(g); + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${g.id}</span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${n.shipped?'shipped default':'marked '+n.date} + <button class="key sm" onclick="event.stopPropagation();unmarkKnown('${key}')">UNMARK</button></span></div>`; + }).join(''); + const cgrows = S.coregroups.map(g=>`<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g[0]}</b></span><span class="jmsg">coredumpctl info ${g[0]}</span> + <span class="what">×${g[1]} · last ${g[2]}</span></div>`).join(''); + const khrows = S.khwev.map(e=>`<div class="lrow jrow"><span class="who" style="color:var(--dim)">${e[0]}</span> + <span class="jmsg" style="color:var(--fail)">${e[1]}</span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('cores','Coredumps 7d',{lever:lever('cores','CLEAR'),act:'cores',sub:S.coregroups.length?'by binary below · keep 3d':'Auto · keep 3d'})} + ${cellBar('journald','journald','GB',S.journald.v/2*100,{lever:lever('journald','VACUUM'),act:'journald',sub:'Auto · cap 2 GB'})} + ${cellCount('jerr','Journal errors',{sub:'this boot · noise-filtered'})} + ${cellBadge('khw','Kernel / HW',{sub:S.khw.st==='ok'?'MCE · USB · I/O · thermal — watch':'events below — hardware, watch only'})} + </div> + ${deck('logs',[ + {id:'signal', label:'SIGNAL', count:S.jerr.v, lamp:S.jerr.st==='ok'?(S.jerr.v?'gold':''):'gold'}, + {id:'noise', label:'KNOWN NOISE', count:kn.length, lamp:'off'}, + {id:'cores', label:'COREDUMPS', count:S.cores.v, lamp:lampCls(S.cores.st)}, + {id:'hw', label:'KERNEL/HW', count:S.khwev.length||'', lamp:lampCls(S.khw.st)}, + ])} + ${SUBSEC.logs==='signal'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">journal — signal<span class="cnt">· ${sig.length} group${sig.length===1?'':'s'} · ${S.jerr.v} entries</span></span> + ${lever('openjournal','OPEN JOURNAL')} + </div> + <div class="jlist">${sigRows || '<div class="rvnote">no unexplained errors this boot ✓</div>'}</div>`:''} + ${SUBSEC.logs==='noise'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">known noise<span class="cnt">· ${kn.length} pattern${kn.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearmarks'?'armed':''}" onclick="event.stopPropagation();clearMarks()">${armed==='clearmarks'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${knRows || '<div class="rvnote">no known-noise patterns match this boot.</div>'}</div>`:''} + ${SUBSEC.logs==='cores'?` + <div class="engrave striph">coredumps — by binary<span class="cnt">· ${S.cores.v} total · coredumpctl info</span></div> + <div class="jlist">${cgrows || '<div class="rvnote">none ✓</div>'}</div> + <div class="evh" style="margin-top:8px">rotation</div> + <div class="jlist">${lrow('applog','app-log cleanup', lever('applog','RUN')+' ', S.applog.v==='ok'?'7d rotation':'stale >7d', 'applog')}</div>`:''} + ${SUBSEC.logs==='hw'?` + <div class="engrave striph">kernel / hw events<span class="cnt">· hardware — watch only</span></div> + <div class="jlist">${khrows || '<div class="rvnote">clean — no MCE, I/O, USB, or thermal events ✓</div>'}</div>`:''}`; }, + +power(){ + const [tc,tg] = String(S.temps.v).split('/'); + const trc = S.temps.st==='ok'?'var(--pass)':S.temps.st==='fail'?'var(--fail)':'var(--amber)'; + const seg = ['perf','bal','power'].map(m=> + `<button class="${S.cpumode.v===m?'on':''}" onclick="event.stopPropagation();setMode('${m}')">${m.toUpperCase()}</button>`).join(''); + const modeCell = ` + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.cpumode.st)}"></span> CPU mode</div> + <div class="val"><span class="seg">${seg}</span></div> + <div class="sub2">EPP hint · ${zfsMode?'intel_pstate':'amd-pstate'} active</div></div>`; + const veloxCells = zfsMode ? ` + ${cellRing('batt','Battery health',S.batt.v,'%',{sub:S.batt.st==='ok'?'of design · 187 cycles · hardware — watch only':'degraded · plan replacement — watch only'})} + ${cellCount('blimit','Charge limit',{lever:S.blimit.st!=='ok'?lever('bat_limit','SET 80%'):'',act:'bat_limit',sub:'longevity cap · sysfs threshold'})}` : ''; + const evRows = (rows,kind) => rows.length + ? rows.map(r=>{ + if(kind==='boot') return `<div class="lrow jrow"><span class="lamp ${r[1]==='clean'?'':'red'}"></span><span class="who">${r[0]}</span><span class="what">${r[1]}</span></div>`; + if(kind==='ev') return `<div class="lrow jrow"><span class="who" style="color:var(--dim)">${r[0]}</span><span class="jmsg" style="color:var(--fail)">${r[1]}</span></div>`; + // top-memory rows: KILL (arm-to-fire) — disabled on session-critical names + const prot = PROTECTED.includes(r[0]); + const armedNow = armed==='kill:'+r[0]; + const key = prot + ? `<button class="key sm dis" title="session-critical — protected">KILL</button>` + : `<button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();killProc('${r[0]}')">${armedNow?'KILL?':'KILL'}</button>`; + return `<div class="lrow jrow"><span class="who">${r[0]}</span><span class="what">${r[1]} ${key}</span></div>`; + }).join('') + : `<div class="lrow jrow"><span class="what" style="margin-left:0">none this boot ✓</span></div>`; + return ` + <div class="wgrid"> + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.temps.st)}"></span> Temperatures</div> + <div class="gpair"> + <div class="g"><span class="ring" style="--p:${tc};--rc:${trc}"><b>${tc}°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:${tg};--rc:${trc}"><b>${tg}°</b></span><span class="lb">NVME</span></div> + </div> + <div class="sub2" style="text-align:center">hardware — watch only</div></div> + ${cellBar('mem','Memory free','GB',S.mem.v/125*100,{sub:'of 125 GB · evidence below'})} + ${cellBar('unclean','Unclean boots','%',S.unclean.v,{sub:'evidence below — watch only'})} + ${cellCount('zram','Swap / zram',{lever:S.zram.st!=='ok'?lever('zram_fix','ENABLE'):'',act:'zram_fix',sub:S.zram.st==='ok'?'zstd · active':'no swap configured'})} + </div> + <div class="wgrid"> + ${modeCell} + ${veloxCells} + ${!zfsMode?`<div class="cell"><div class="cap"><span class="lamp off"></span> Battery</div> + <div class="val"><span class="readout" style="color:var(--dim)">n/a</span></div> + <div class="sub2">desktop — no battery</div></div>`:''} + ${cellBadge('throttle','Thermal throttle',{sub:S.throttle.st==='ok'?'no events · hardware — watch only':'active — cooling issue · watch only'})} + </div> + <div class="strip striph" style="align-items:center"> + <span class="engrave">top memory</span> + <span class="engrave" style="grid-column:span 2">evidence — watch only</span> + </div> + <div class="strip"> + <div><div class="evh">kill: sigterm · arm-to-fire</div><div class="jlist">${evRows(S.memtop,'mem')}</div></div> + <div><div class="evh">recent boots</div><div class="jlist">${evRows(S.boots,'boot')}</div></div> + <div><div class="evh">throttle / oom</div><div class="jlist">${evRows(S.pevents,'ev')}</div></div> + </div>`; }, + +services(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)); + const expd = S.ctrs.filter(c=>cexp(c.name)); + const urows = unexp.map(c=>{ + const aM = armed==='cexp:'+c.name; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${c.name}</b></span><span class="jmsg">exited ${c.when} · code ${c.code}${c.code?' · docker logs '+c.name:''}</span> + <span class="what"> + <button class="key sm ${aM?'armed':''}" onclick="event.stopPropagation();ctrMark('${c.name}')">${aM?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm" onclick="event.stopPropagation();ctrStartOne('${c.name}')">START</button> + </span></div>`; + }).join(''); + const erows = expd.map(c=>{ + const p = CEXPECT.find(x=>!x.disabled&&x.name===c.name)||{}; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${c.name}</span><span class="jmsg">exited ${c.when}</span> + <span class="what">${p.shipped?'shipped default':'marked '+(p.date||'')} + <button class="key sm" onclick="event.stopPropagation();ctrUnmark('${c.name}')">UNMARK</button></span></div>`; + }).join(''); + const dfrows = S.ddf.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]} · reclaimable ${r[2]}</span></div>`).join(''); + const cronRows = S.cron.st==='ok' ? ` + <div class="lrow"><span class="lamp"></span><span class="who">log-cleanup (12:00)</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot hourly</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot daily 02:30</span><span class="what">present</span></div>` : ` + <div class="lrow"><span class="lamp red"></span><span class="who">log-cleanup (12:00)</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot hourly</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot daily 02:30</span><span class="what">not running — cronie down</span></div>`; + return ` + <div class="wgrid"> + ${cellCount('docker','Docker reclaim',{lever:lever('docker','PRUNE'),act:'docker',unit:' GB',sub:'breakdown below · tiers 1+2'})} + ${cellCount('rsync','Last backup',{lever:lever('rsyncnow','RUN NOW'),act:'rsyncnow',unit:' ago',sub:'rsyncshot → truenas'})} + ${cellCount('dstopped','Stopped containers',{lever:unexp.length>1?lever('ctr_start','START ALL'):'',act:'ctr_start',sub:unexp.length?unexp.length+' unexpected · '+expd.length+' expected':'all expected'})} + ${cellBadge('cron','cron',{lever:S.cron.st!=='ok'?lever('cron_fix','START'):'',act:'cron_fix',sub:'entry roster below'})} + </div> + ${deck('services',[ + {id:'containers', label:'CONTAINERS', count:unexp.length?unexp.length+'!':expd.length, lamp:unexp.length?'gold':''}, + {id:'docker', label:'DOCKER DISK', count:S.docker.v+' GB', lamp:lampCls(S.docker.st)}, + {id:'cron', label:'CRON & BACKUPS', count:'', lamp:(S.cron.st!=='ok'||S.rsync.st!=='ok')?'red':''}, + ])} + ${SUBSEC.services==='containers'?` + ${unexp.length?`<div class="engrave striph">containers — signal<span class="cnt">· ${unexp.length} unexpected</span></div> + <div class="jlist">${urows}</div>`:''} + <div class="engrave striph">containers — expected<span class="cnt">· ${expd.length}</span></div> + <div class="jlist">${erows || '<div class="rvnote">no expected-stopped containers present.</div>'}</div>`:''} + ${SUBSEC.services==='docker'?` + <div class="engrave striph">docker disk<span class="cnt">· system df · reclaimable by type</span></div> + <div class="jlist">${dfrows}</div> + <div class="evh" style="margin-top:8px">virt</div> + <div class="jlist">${lrow('libvirt','libvirt VMs','', 'shut off · expected')}</div>`:''} + ${SUBSEC.services==='cron'?` + <div class="engrave striph">cron entries — expected<span class="cnt">· 3</span></div> + <div class="jlist">${cronRows}</div> + <div class="evh" style="margin-top:8px">backups</div> + <div class="jlist">${backupRoster()}</div>`:''}`; }, + +snapshots(){ + const t = S.snaptypes; + const staleN = Math.max(0, t.single-2); + const aS = armed==='snapstale'; + return ` + <div class="wgrid"> + ${cellCount('snapper','Snapper count',{lever:lever('snapper','PRUNE'),act:'snapper',sub:S.snapper.st==='ok'?'by type below':'pile-up — see singles below'})} + ${cellBadge('stimer','Auto-timer',{lever:S.stimer.st!=='ok'?lever('stimer_fix','ENABLE'):'',act:'stimer_fix',sub:'timeline + cleanup'})} + ${cellCount('oldest','Oldest',{unit:' days',sub:S.oldest.st==='ok'?'within budget':'chain hoards space'})} + <div class="cell">${S.retention.st!=='ok'?`<span class="lever">${lever('retention','FIX')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('retention','retention')}"></span> Retention</div> + <div class="val" style="font-size:12px;color:${S.retention.st==='ok'?'var(--cream)':'var(--amber)'}">${S.retention.v}</div> + <div class="sub2">${S.retention.st==='ok'?'monthly=2 (the /home lesson)':'monthly=10 — silently hoards space'}</div>${errline('retention')}</div> + </div> + <div class="engrave striph">by type<span class="cnt">· singles escape timeline cleanup</span></div> + <div class="jlist"> + <div class="lrow jrow"><span class="lamp"></span><span class="who">timeline</span><span class="what">${t.timeline} · auto-pruned</span></div> + <div class="lrow jrow"><span class="lamp ${staleN>3?'gold':''}"></span><span class="who"><b>single (manual)</b></span> + <span class="jmsg">oldest ${t.oldsingle}</span> + <span class="what">${t.single}${staleN>3?` <button class="key sm ${aS?'armed':''}" onclick="event.stopPropagation();snapDelStale()">${aS?'DELETE?':'DELETE STALE'}</button>`:''}</span></div> + <div class="lrow jrow"><span class="lamp"></span><span class="who">pre / post</span><span class="what">${t.prepost} · paired with pacman ops</span></div> + </div>`; }, + +network(){ + const n = parseInt(S.ts.v)||0; + const bars = [1,2,3,4].map(i=>`<i class="${i<=n?'on':''}"></i>`).join(''); + const sig = lsnSignal(); + const exp = LEXPECT.filter(p=>!p.disabled); + const pubN = sig.filter(r=>isPub(r.bind)).length; + const sigRows = sig.map(r=>{ + const key = lkey(r); + const aFix = armed==='lsnfix:'+key, aMark = armed==='lexp:'+key; + const bindTag = isPub(r.bind) + ? `<span class="badge red" style="font-size:.55rem">PUBLIC</span>` + : `<span style="color:var(--dim);font-size:10px">loopback</span>`; + return `<div class="lrow jrow"><span class="lamp ${isPub(r.bind)?'red':'gold'}"></span> + <span class="who"><b>${r.proc}</b></span> + <span class="jmsg">:${r.port} · ${r.bind}${r.unit?' · '+r.unit+'.service':''}</span> + <span class="what">${bindTag} + <button class="key sm ${aMark?'armed':''}" onclick="event.stopPropagation();markExpected('${key}')">${aMark?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm ${aFix?'armed':''}" onclick="event.stopPropagation();lsnFix('${key}')">${aFix?(r.unit?'STOP?':'KILL?'):(r.unit?'STOP':'KILL')}</button> + </span></div>`; + }).join(''); + const expRows = exp.map(p=>{ + const key = p.proc+'|'+p.port; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${p.proc}</span><span class="jmsg">:${p.port}</span> + <span class="what">${p.shipped?'shipped default':'marked '+p.date} + <button class="key sm" onclick="event.stopPropagation();unmarkExpected('${key}')">UNMARK</button></span></div>`; + }).join(''); + const fwNote = S.fw.st!=='ok' && pubN + ? ` <span style="color:var(--fail);letter-spacing:0;text-transform:none">· ufw down — ${pubN} public bind${pubN===1?'':'s'} exposed</span>` : ''; + return ` + <div class="wgrid"> + ${cellBadge('fw','Firewall',{lever:S.fw.st!=='ok'?lever('fw_fix','ENABLE',S.fw.st==='fail'?'red':''):'',act:'fw_fix',sub:'ufw · mosh rule'})} + ${cellCount('listen','Unexpected listeners',{sub:S.listen.st==='ok'?'all matched · '+exp.length+' expected':S.listen.v+' unmatched · '+pubN+' public-bind'})} + <div class="cell">${S.ts.st!=='ok'?`<span class="lever">${lever('ts_fix','RESTART')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('ts','ts_fix')}"></span> Tailscale</div> + <div class="val"><span class="ladder ${S.ts.st==='fail'?'bad':''}">${bars}</span> + <span class="readout" style="font-size:15px">${S.ts.v}</span></div> + <div class="sub2">peers online</div>${errline('ts_fix')}</div> + ${cellCount('f2b','fail2ban',{lever:S.f2b.st!=='ok'?lever('f2b_fix','RESTART'):'',act:'f2b_fix',sub:S.f2b.st==='ok'?'sshd jail · '+S.f2b.v+' bans 7d':'service down'})} + </div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">listeners — signal<span class="cnt">· ${sig.length} unexpected</span>${fwNote}</span> + </div> + <div class="jlist">${sigRows || '<div class="rvnote">every listener matches the expected list.</div>'}</div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">expected<span class="cnt">· ${exp.length} pattern${exp.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearlsn'?'armed':''}" onclick="event.stopPropagation();clearLsn()">${armed==='clearlsn'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${expRows}</div> + <div class="engrave striph">peers<span class="cnt">· 4</span></div> + <div class="strip">${peerRoster()}</div> + <div class="engrave striph">resolution<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('ntp','NTP (chrony)', S.ntp.st!=='ok'?lever('ntp_fix','RESTART')+' ':'', S.ntp.st==='ok'?'synced · +2 ms':undefined, 'ntp_fix')} + ${lrow('dns','DNS / NetworkManager', S.dns.st!=='ok'?lever('netdoc','NET DOCTOR')+' ':'', undefined, 'netdoc')} + </div>`; }, +}; + +/* ---- doctor: streams into the results wall; the metrics subpanel stays put ---- */ +let doctorRunning = false; +function startDoctor(mode){ + if(doctorRunning){ toast('doctor already running'); return; } + const title = mode==='reclaim' ? 'reclaim space' : 'clean up'; + const steps = [ + {name:'cache trim', act:'clean_cache', + res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'reclaimed 8.4 GB') : 'nothing to reclaim'}, + {name:'journal vacuum', act:'journald', + res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'reclaimed 0.9 GB') : 'already tight'}, + {name:'coredump clear', act:'cores', + res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return n+' cleared';})() : 'none to clear'}, + {name:'app-log cleanup', act:'applog', + res:()=> (S.applog={v:'ok',st:'ok'}, 'nothing older than 7d')}, + ]; + if(mode==='reclaim'){ + steps.push( + {name:'docker prune (tier 1)', act:'docker', + res:()=> S.docker.st==='warn' ? (S.docker={v:0.2,st:'ok'}, 'reclaimed 2.8 GB') : 'nothing to prune'}, + {name:'snapper cleanup', act:'snapper', + res:()=> (S.snapper={v:Math.min(S.snapper.v,31),st:'ok'}, 'retention applied')}, + {name:'disk re-read', act:'applog', + res:()=> { S.disk={v:Math.max(60,S.disk.v-11), st:S.disk.v-11>90?'fail':S.disk.v-11>80?'warn':'ok'}; return 'now '+S.disk.v+'%'; }}, + ); + } + doctorRunning = true; + resAdd('doctor · '+title, 'started · '+steps.length+' actions', 'done'); + render(); + let i=0, entry=null, failed=0; + function step(){ + if(entry){ + const p = steps[i-1]; + if(failMode){ ERR[p.act]=ACTIONS[p.act].err; failed++; resDone(entry,'fail',ERR[p.act]); } + else { delete ERR[p.act]; resDone(entry,'done',p.res()); } + render(); + } + if(i<steps.length){ + entry = resAdd(steps[i].name, 'running…'); i++; + doctorTimer = setTimeout(step, 1000); + } else { + doctorRunning = false; + resAdd('doctor · '+title, + `${steps.length-failed} done${failed?' · '+failed+' failed':''}`, + failed?'fail':'done'); + render(); + } + } + step(); +} + +/* ---- review & fix ---- */ +const REVIEW = [ + {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', ok:()=>zfsMode||S.fstrim.st==='ok'}, + {act:'ztrim_fix', who:'zpool autotrim', why:'TRIM disabled on the pool', id:'ztrim', ok:()=>!zfsMode||S.ztrim.st==='ok'}, + {act:'scrub', who:'scrub', why:'past the scrub cadence', id:'scrub', ok:()=>S.scrub.st==='ok'}, + {act:'orphans', who:'orphans', why:'unowned packages present', id:'orphans', ok:()=>S.orphans.st==='ok'}, + {act:'pacnew', who:'.pacnew files', why:'configs awaiting merge', id:'pacnew', ok:()=>S.pacnew.st==='ok'}, + {act:'timers', who:'maint timers', why:'timers not firing', id:'timers', ok:()=>S.timers.st==='ok'}, + {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', ok:()=>S.keyring.st==='ok'}, + {act:'docker', who:'docker reclaim', why:'space reclaimable (tiers 1+2)', id:'docker', ok:()=>S.docker.st==='ok'}, + {act:'snapper', who:'snapper', why:'retention pass', id:'snapper', ok:()=>S.snapper.st==='ok'&&S.snapper.v<42}, + {act:'deeptrim',who:'deep-trim', why:'keep 1 version — max reclaim, less downgrade headroom', id:'deeptrim', ok:()=>S.deeptrim.v==='keep 1'}, + {act:'unit_restart', who:'failed units', why:'restart the failed unit(s)', id:'failed', ok:()=>S.failed.st==='ok'}, + {act:'tgage', who:'topgrade', why:'ecosystem updates stale', id:'tgage', ok:()=>S.tgage.st==='ok', useAct:'topgrade'}, + {act:'f2b_fix', who:'fail2ban', why:'service down', id:'f2b', ok:()=>S.f2b.st==='ok'}, + {act:'cron_fix',who:'cronie', why:'service down', id:'cron', ok:()=>S.cron.st==='ok'}, + {act:'ntp_fix', who:'NTP (chrony)', why:'clock unsynchronized', id:'ntp', ok:()=>S.ntp.st==='ok'}, + {act:'ts_fix', who:'tailscaled', why:'peers unreachable', id:'ts', ok:()=>S.ts.st==='ok'}, + {act:'stimer_fix', who:'snapshot timer', why:'auto-snapshots not firing', id:'stimer', ok:()=>S.stimer.st==='ok'}, + {act:'zram_fix',who:'zram', why:'no swap configured', id:'zram', ok:()=>S.zram.st==='ok'}, + {act:'balance', who:'btrfs balance', why:'unallocated headroom low', id:'unalloc', ok:()=>zfsMode||S.unalloc.st==='ok'}, + {act:'reinstall', who:'file integrity', why:'modified package files', id:'integ', ok:()=>S.integ.st==='ok'}, + {act:'retention', who:'snapshot retention', why:'monthly limit hoards space', id:'retention', ok:()=>S.retention.st==='ok'}, + {act:'rsyncnow', who:'backup', why:'stale or failed — run now', id:'rsync', ok:()=>S.rsync.st==='ok'}, + {act:'reboot', who:'reboot', why:'running kernel != installed', id:'reboot', ok:()=>S.reboot.v!=='YES'}, + {act:'bat_limit', who:'charge limit', why:'no charge cap — battery longevity', id:'blimit', ok:()=>!zfsMode||S.blimit.st==='ok'}, + {act:'ctr_start', who:'stopped containers', why:'unexpected exited containers', id:'dstopped', ok:()=>S.dstopped.st==='ok'}, + {act:'fw_fix', who:'firewall', why:'ufw inactive — host exposed', id:'fw', ok:()=>S.fw.st==='ok'}, +]; +function renderReview(){ + const items = REVIEW.filter(r=>!r.ok()||ERR[r.useAct||r.act]); + const rows = items.map(r=>{ + const act = r.useAct||r.act; + const done = r.ok(); + const failed = !!ERR[act]; + return `<div class="rvrow"> + <span class="lamp ${failed?'red':done?'':lampCls(S[r.id].st)}"></span> + <span class="who">${r.who}</span> + <span class="why ${failed?'errtx':''}">${failed?'✗ '+ERR[act]:r.why}</span> + <span class="keys">${done?'<span class="res">done ✓</span>':lever(act,'FIX')}</span></div>`; + }).join(''); + return ` + <div class="subhead"><span class="lamp gold"></span><span class="nm">Doctor · Review & fix</span> + <span class="cnt">${items.length} Confirm items</span></div> + <div class="rvlist">${rows||'<div class="rvnote">nothing needs review — all Confirm items clear.</div>'}</div> + <div class="rvnote">Destructive fixes arm on first press, fire on the second. Metrics without a determinate + lever — failed units, unclean boots, updates, CVEs — stay on their subpanels as read-only telemetry.</div> + <div class="ofoot"><button class="key" onclick="go('cat:storage')">DONE</button></div>`; +} + +/* ================= loading / hydration ================= */ +/* tiers mirror real probe cost: t0 = local file/daemon reads, t1 = process probes + (smartctl, docker df, snapper, du), t2 = package scans (cached in real build) */ +const TIERS = [ + {ms:220, cats:['systemd','power','network','snapshots']}, + {ms:650, cats:['storage','logs','services']}, + {ms:1150, cats:['packages']}, +]; +function startLoad(){ + loadTimers.forEach(clearTimeout); loadTimers=[]; + loaded.clear(); + $('lastscan').textContent = 'scanning…'; + render(); + TIERS.forEach(t=>{ + loadTimers.push(setTimeout(()=>{ + t.cats.forEach(c=>loaded.add(c)); + if(loaded.size===CATS.length) $('lastscan').textContent='last scan just now · 44 checks · 1.2s'; + render(); + }, t.ms)); + }); +} + +/* ================= render ================= */ +const ACT2CAT = { clean_cache:'storage', scrub:'storage', fstrim:'storage', deeptrim:'storage', + balance:'storage', reinstall:'storage', smarttest:'storage', ztrim_fix:'storage', + orphans:'packages', pacnew:'packages', keyring:'packages', reboot:'packages', topgrade:'packages', + timers:'systemd', unit_restart:'systemd', + cores:'logs', journald:'logs', applog:'logs', openjournal:'logs', + zram_fix:'power', bat_limit:'power', + docker:'services', cron_fix:'services', rsyncnow:'services', ctr_start:'services', fw_fix:'network', + snapper:'snapshots', stimer_fix:'snapshots', retention:'snapshots', + f2b_fix:'network', ntp_fix:'network', ts_fix:'network', netdoc:'network' }; +function catErr(cid){ return Object.keys(ERR).some(a=>ACT2CAT[a]===cid); } + +function renderSelector(){ + $('selector').innerHTML = CATS.map(c=>{ + const sel = view==='cat:'+c.id; + if(!loaded.has(c.id)){ + return `<div class="selbtn loading ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp busy"></span><span class="nm">${c.name}</span><span class="ct">···</span></div>`; + } + const k = counts(c); + const err = catErr(c.id); + const ct = err ? `<span class="a crit">✗ fail</span> · <span class="k">${k.ok}✓</span>` + : k.attn ? `<span class="a ${k.crit?'crit':''}">${k.attn}!</span> · <span class="k">${k.ok}✓</span>` + : `<span class="k">${k.ok} ✓</span>`; + return `<div class="selbtn ${(k.crit||err)?'crit':k.attn?'attn':'clear'} ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp ${(k.crit||err)?'red':k.attn?'gold':''}"></span> + <span class="nm">${c.name}</span><span class="ct">${ct}</span></div>`; + }).join(''); +} +function renderSub(){ + const el = $('sub'); + if(view==='review'){ el.innerHTML = renderReview(); return; } + const cid = view.slice(4); + if(!loaded.has(cid)){ + const cat = CATS.find(c=>c.id===cid); + el.innerHTML = `<div class="subhead"><span class="lamp busy"></span><span class="nm">${cat.name}</span> + <span class="cnt">gathering…</span></div> + <div class="skel"><span class="lamp busy" style="width:14px;height:14px"></span>probing</div>`; + return; + } + const cat = CATS.find(c=>c.id===cid); + const k = counts(cat); + const [nfix,nwatch] = fixwatch(cid); + const cnt = (k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`) + + ` · ${nfix} fixable · ${nwatch} watch`; + el.innerHTML = `<div class="subhead"> + <span class="lamp ${k.crit?'red':k.attn?'gold':''}"></span> + <span class="nm">${cat.name}</span><span class="cnt">${cnt}</span></div>` + SUB[cid](); +} +function render(){ + recalcJerr(); recalcListen(); recalcCtrs(); + renderSelector(); renderSub(); + // faceplate verdict = worst diagnostic state + let worst='ok'; + DIAG.forEach(d=>{ const st=S[d].st; + if(st==='fail') worst='fail'; else if(st==='warn'&&worst!=='fail') worst='warn'; }); + const fl=$('facelamp'); + fl.className = 'lamp ' + (loaded.size<CATS.length?'busy':worst==='fail'?'red':worst==='warn'?'gold':''); + // badges + let attn=0; CATS.forEach(c=>{ if(loaded.has(c.id)){ attn+=counts(c).attn; } }); + $('attbadge').textContent = loaded.size<CATS.length ? 'SCANNING' : (attn ? attn+' ATTN' : 'ALL CLEAR'); + const errN = Object.keys(ERR).length; + $('errbadge').style.display = errN ? '' : 'none'; + $('errbadge').textContent = errN + ' FAILED'; + // updates strip: green when nothing, amber for a few, red for CVEs / a lot + const u = S.upd; + const ucls = (u.c>0 || u.p>100) ? 'crit' : (u.p>0 || u.a>0 || u.f>0) ? 'warn' : 'ok'; + $('updbar').className = 'updbar ' + ucls; + // CVE badge: red and visible only when there are CVEs to show + $('cvebadge').style.display = S.upd.c ? '' : 'none'; + $('cvebadge').textContent = S.upd.c + ' CVE'; + $('upd-p').textContent = S.upd.p; $('upd-c').textContent = S.upd.c + ' CVE'; + $('upd-c').classList.toggle('cve', S.upd.c>0); + $('upd-c').style.color = S.upd.c ? '' : 'var(--dim)'; + $('upd-a').textContent = S.upd.a; $('upd-f').textContent = S.upd.f; + $('k-clean').classList.toggle('on', doctorRunning); + $('k-review').classList.toggle('on', view==='review'); + // guard-armed update keys: red + '?' while awaiting the override press + $('k-update').classList.toggle('armed', armed==='sysupdate'); + $('k-update').textContent = armed==='sysupdate' ? 'UPDATE?' : 'UPDATE'; + $('k-topgrade').classList.toggle('armed', armed==='topgrade'); + $('k-topgrade').textContent = armed==='topgrade' ? 'TOPGRADE?' : 'TOPGRADE'; + // reboot offer key appears on the updates strip once an update lands + $('upd-rb').innerHTML = S.reboot.v==='YES' ? lever('reboot','REBOOT','red') : ''; +} +function go(v){ + armed=null; + if(v==='doctor'||v==='reclaim'){ startDoctor(v==='reclaim'?'reclaim':undefined); return; } + view=v; render(); +} +function closePanel(){ $('capsule').style.display='none'; $('closed').style.display='block'; } +function openPanel(){ $('capsule').style.display='block'; $('closed').style.display='none'; startLoad(); } +document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); }); +startLoad(); + +/* live refresh sim: the thermal/memory group re-reads every 3 s while its + subpanel is visible (panel-open gated, like the audio panel's meters) */ +setInterval(()=>{ + if($('capsule').style.display==='none') return; + if(view!=='cat:power' || !loaded.has('power')) return; + const j = (v,lo,hi)=>Math.max(lo,Math.min(hi, v + (Math.random()<0.5?-1:1))); + let [tc,tg] = String(S.temps.v).split('/').map(Number); + if(badMode){ tc=j(tc,88,94); tg=j(tg,74,80); } else { tc=j(tc,57,65); tg=j(tg,50,58); } + S.temps.v = tc+'/'+tg; + S.mem.v = Math.round((S.mem.v + (Math.random()-0.5)*0.6)*10)/10; + render(); +}, 3000); +</script> +</body> +</html> |
