aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-07 18:24:12 -0500
committerCraig Jennings <c@cjennings.net>2026-07-07 18:24:12 -0500
commit7955be70ea7751c68d6a9b2ca3683ee697a38726 (patch)
treeb241c157d36238f9769660c0701ca24a2696abf2 /docs/prototypes/2026-07-07-maint-console-E4-interactive.html
parent96881fccae125c8803ac5c26b8ddb7ee77be363a (diff)
downloadarchsetup-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-E4-interactive.html')
-rw-r--r--docs/prototypes/2026-07-07-maint-console-E4-interactive.html546
1 files changed, 546 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-07-maint-console-E4-interactive.html b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
new file mode 100644
index 0000000..c678a8d
--- /dev/null
+++ b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html
@@ -0,0 +1,546 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>MAINT · ratio — Approach E4 · Interactive view-swap console</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:2rem 1.4rem 5rem;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground);
+ display:flex;justify-content:center}
+.console{width:1060px;max-width:100%}
+
+/* ---- primitives (widget-gallery kit) ---- */
+.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}
+@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.sm{font-size:10.5px;padding:4px 9px;border-radius:7px}
+.key.done{opacity:.45;pointer-events:none}
+
+.bar{width:100%;height:12px;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:58px;height:58px;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:8.5px}
+
+.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.readout small{color:var(--dim);font-size:11px;font-weight:400}
+
+.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;flex:1}
+.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem}
+
+.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px}
+.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px}
+
+.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+
+/* masthead */
+.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.mast .brand{display:flex;flex-direction:column;gap:2px}
+.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase}
+.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px}
+.mast .brand .ti b{color:var(--gold)}
+.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700}
+.mast .spacer{flex:1}
+.mast .doctor{display:flex;align-items:center;gap:8px}
+.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px}
+
+/* updates */
+.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px;
+ border:1px solid var(--fail);border-radius:11px;
+ background:linear-gradient(180deg,#181210,#0a0c0d)}
+.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;
+ border:1px solid var(--fail);border-radius:5px;padding:2px 7px}
+.updbar .rd{display:flex;flex-direction:column;line-height:1.05}
+.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}
+.updbar .rd .n.cve{color:var(--fail)}
+.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase}
+.updbar .spacer{flex:1}
+.updbar .msg{color:var(--dim);font-size:11px;max-width:300px}
+
+/* overview stations */
+.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px}
+.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px;
+ transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)}
+.station:hover{border-color:#3a352c;transform:translateY(-1px)}
+.station.attn{border-left-color:var(--amber)}
+.station.crit{border-left-color:var(--fail)}
+.station.clear{border-left-color:var(--pass)}
+.station .top{display:flex;align-items:center;gap:9px}
+.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700}
+.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px}
+.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
+.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)}
+.station .st .okc{color:var(--pass)}
+.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px}
+
+/* category detail view */
+.catplate{padding:0 0 15px}
+.cathead{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid #201d17;margin-bottom:13px}
+.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:13px}
+.cathead .cnt{color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center}
+.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)}
+.cathead .cnt .okc{color:var(--pass)}
+.cathead .navs{margin-left:auto;display:flex;gap:7px}
+.catbody{padding:0 16px}
+.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px}
+.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px;
+ display:flex;flex-direction:column;gap:8px;position:relative;min-height:86px}
+.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;
+ display:flex;align-items:center;gap:7px;padding-right:70px}
+.inst .lever{position:absolute;top:9px;right:9px}
+.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto}
+.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6}
+.inst .sub{color:var(--dim);font-size:10.5px}
+.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto}
+.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px}
+.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em}
+.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px}
+
+/* doctor / review views */
+.owell{padding:13px 16px}
+.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;
+ color:var(--cream);font-size:13px}
+.owell .oh .l{color:var(--gold)}
+.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.queued b{color:var(--dim)}
+.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px}
+
+.rvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:#141210;
+ font-size:12.5px;margin-bottom:6px}
+.rvrow .who{color:var(--cream);font-weight:700}
+.rvrow .why{color:var(--dim);font-size:11px}
+.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:11px;margin:10px 2px 0;line-height:1.5}
+
+/* toast */
+#toast{position:fixed;bottom:26px;right:26px;display:flex;flex-direction:column;gap:8px;z-index:9}
+.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)}}
+
+.crumb{color:var(--dim);font-size:11px;margin:0 4px 9px;display:flex;gap:6px;align-items:center}
+.crumb a{color:var(--gold);cursor:pointer;text-decoration:none}
+.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="console">
+
+ <!-- persistent masthead -->
+ <div class="plate mast">
+ <div class="brand">
+ <div class="ey">archsetup · maintenance console · E4 interactive</div>
+ <div class="ti">MAINT · <b>ratio</b></div>
+ </div>
+ <div class="verdict" id="verdict"><span class="lamp"></span> HEALTHY</div>
+ <div class="spacer"></div>
+ <div class="doctor">
+ <button class="key" id="homekey" onclick="go('home')">HOME</button>
+ <span class="lbl" style="margin-left:8px">Doctor</span>
+ <button class="key" onclick="go('doctor')">CLEAN UP</button>
+ <button class="key" onclick="go('review')">REVIEW &amp; FIX</button>
+ </div>
+ </div>
+
+ <!-- swapped view -->
+ <div id="view"></div>
+
+ <div class="footnote">
+ Fully interactive: views replace each other (no inline collapse). HOME = overview of all systems;
+ click a station to drill in; PREV/NEXT walk the categories; CLEAN UP streams the output well and
+ actually updates the metrics; REVIEW &amp; FIX lists every Confirm item — destructive ones arm on
+ first press, fire on second. Workflow items always escalate (toast) — the console never runs them.
+ </div>
+</div>
+<div id="toast"></div>
+
+<script>
+/* ================= state ================= */
+const S = {
+ 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 GiB', st:'ok'}, integ:{v:'clean', st:'ok'},
+ deeptrim:{v:'keep 3', st:'ok'},
+ orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'no', st:'ok'},
+ failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'},
+ cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'},
+ jerr:{v:'12 real', st:'ok'}, khw:{v:'clean', st:'ok'},
+ 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'},
+ docker:{v:3, st:'warn'}, rsync:{v:'3h ago', st:'ok'}, dstopped:{v:2, st:'ok'},
+ cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'},
+ snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'},
+ fw:{v:'active', st:'ok'}, listen:{v:3, st:'ok'}, ts:{v:'4/4', st:'ok'},
+ f2b:{v:'active', st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'},
+};
+
+const CATS = [
+ {id:'storage', name:'Storage & Filesystem', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']},
+ {id:'packages', name:'Packages & Security', ids:['orphans','pacnew','keyring','reboot']},
+ {id:'systemd', name:'systemd & Boot', ids:['failed','timers','sysrun','taint']},
+ {id:'logs', name:'Logs & Coredumps', ids:['cores','journald','applog','jerr','khw']},
+ {id:'power', name:'Memory · Thermal · Power', ids:['temps','mem','unclean','zram','throttle','battery']},
+ {id:'services', name:'Services & Backups', ids:['docker','rsync','dstopped','cron','libvirt']},
+ {id:'snapshots',name:'Snapshots', ids:['snapper','stimer']},
+ {id:'network', name:'Network & Posture', ids:['fw','listen','ts','f2b','ntp','dns']},
+];
+
+let view = 'home';
+let armed = null; // id of armed destructive key
+let doctorTimer = null;
+
+/* ================= helpers ================= */
+const $ = id => document.getElementById(id);
+function counts(cat){
+ let attn=0, ok=0, crit=false;
+ cat.ids.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 catLamp(cat){ const c=counts(cat); return c.crit?'red':c.attn?'gold':''; }
+function toast(msg, err){
+ const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg;
+ $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600);
+}
+function esc(id){ toast('escalates to the system-health-check workflow — not run from the console', true); }
+
+/* generic lever runner: busy → mutate → re-render */
+function fire(act){
+ const a = ACTIONS[act];
+ if(a.arm && armed!==act){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; }
+ armed=null;
+ toast(a.busy||'running…');
+ setTimeout(()=>{ a.run(); toast(a.doneMsg); render(); }, a.ms||800);
+}
+
+const ACTIONS = {
+ clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB',
+ run(){ S.cache={v:0.4, st:'ok'}; } },
+ scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion',
+ run(){ S.scrub={v:0, st:'ok'}; } },
+ fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active',
+ run(){ S.fstrim={v:'ON', st:'ok'}; S.timers={v:'5/5', st:'ok'}; } },
+ deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press DEEP TRIM again', busy:'paccache -rk1 …', ms:1100,
+ doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', 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 13 orphaned packages — press again', busy:'pacman -Rns (13 pkgs) …', ms:1200,
+ doneMsg:'orphans removed — 13 pkgs', run(){ S.orphans={v:0, st:'ok'}; } },
+ pacnew:{ busy:'diffing 2 .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged',
+ run(){ S.pacnew={v:0, st:'ok'}; } },
+ keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed',
+ run(){ S.keyring={v:0, st:'ok'}; } },
+ timers:{ busy:'systemctl enable --now (1 timer) …', ms:800, doneMsg:'all 5 maintenance timers firing',
+ run(){ S.timers={v:'5/5', st:'ok'}; S.fstrim.st==='fail' && (S.fstrim={v:'ON',st:'ok'}); } },
+ cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared — 18 removed',
+ run(){ S.cores={v:0, st:'ok'}; } },
+ journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB',
+ run(){ S.journald={v:0.3, st:'ok'}; } },
+ applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', run(){} },
+ 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', run(){ S.docker={v:0.2, st:'ok'}; } },
+ snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900,
+ doneMsg:'snapper — retention applied', run(){ S.snapper={v:31, st:'ok'}; } },
+};
+
+/* ================= view renderers ================= */
+function lever(act, label, cls){
+ const armedNow = armed===act;
+ return `<button class="key sm ${cls||''} ${armedNow?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${armedNow?label+'?':label}</button>`;
+}
+function wkey(label){ return `<button class="key sm" onclick="event.stopPropagation();esc()">${label||'WORKFLOW'}</button>`; }
+function lrow(id, name, extra){
+ const m=S[id];
+ return `<div class="lrow"><span class="lamp ${lampCls(m.st)}"></span><span class="who">${name}</span>
+ <span class="what">${extra||''}${m.v}</span></div>`;
+}
+
+function renderHome(){
+ const stations = CATS.map(c=>{
+ const k=counts(c);
+ const st = 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>`;
+ return `<div class="plate station ${k.crit?'crit':k.attn?'attn':'clear'}" onclick="go('cat:${c.id}')">
+ <div class="top"><span class="lamp ${catLamp(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div>
+ <div class="st">${st}</div></div>`;
+ }).join('');
+ return `
+ <div class="updbar">
+ <span class="q">Updates</span>
+ <div class="rd"><span class="n">47</span><span class="k">pending</span></div>
+ <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div>
+ <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div>
+ <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span>
+ <span class="spacer"></span>
+ <button class="key red" onclick="esc()">RUN WORKFLOW</button>
+ </div>
+ <div class="ovhead"><span class="engrave">Overview<span class="cnt">· ${CATS.length} systems</span></span></div>
+ <div class="overview">${stations}</div>`;
+}
+
+function heroBar(id, cap, unit, pct, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div>
+ <div class="bar ${m.st==='warn'?'warn':m.st==='ok'?'okc':''}">${o.tick?'<span class="tk" style="left:'+o.tick+'%"></span>':''}<span style="width:${pct}%"></span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroCount(id, cap, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div class="val"><span class="readout" ${m.st==='fail'?'style="color:var(--fail)"':m.st==='warn'&&o.tint?'style="color:var(--amber)"':''}>${m.v}${o.unit?'<small>'+o.unit+'</small>':''}</span></div>
+ <div class="sub">${o.sub||''}</div></div>`;
+}
+function heroRing(id, cap, pct, color, small, opts){
+ const m=S[id]; const o=opts||{};
+ return `<div class="inst s3">
+ ${o.lever||''}
+ <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div>
+ <div style="display:flex;justify-content:center;margin-top:auto">
+ <span class="ring" style="--p:${pct};--rc:${color}"><b>${m.v}</b><small>${small}</small></span></div>
+ <div class="sub" style="text-align:center">${o.sub||''}</div></div>`;
+}
+
+const CATVIEW = {
+ storage(){ return {
+ heroes:
+ heroBar('disk','Disk usage','%',S.disk.v,{sub:'root · btrfs · thr 80 / 90'})+
+ heroBar('cache','Package cache','GB',Math.min(100,S.cache.v/10*100),{lever:`<span class="lever">${lever('clean_cache','CLEAN')}</span>`,tick:100,sub:'Auto · threshold ~10 GB'})+
+ heroRing('scrub','btrfs scrub',Math.min(100,S.scrub.v/55*100),S.scrub.st==='ok'?'var(--pass)':'var(--amber)','days',{lever:`<span class="lever">${lever('scrub','SCRUB')}</span>`,sub:S.scrub.st==='ok'?'scrub fresh':'past 30d cadence'})+
+ heroCount('fstrim','fstrim.timer',{lever:`<span class="lever">${lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':'')}</span>`,sub:'Confirm · weekly SSD TRIM'}),
+ strip:
+ lrow('smart','SMART')+lrow('wear','SMART wear')+lrow('deverr','device errors')+
+ lrow('unalloc','btrfs unalloc')+lrow('integ','pacman integrity')+
+ lrow('deeptrim','deep-trim', lever('deeptrim','DEEP TRIM')+' ')
+ };},
+ packages(){ return {
+ heroes:
+ heroCount('orphans','Orphans',{lever:`<span class="lever">${lever('orphans','REMOVE')}</span>`,sub:'Confirm · unowned pkgs',tint:1})+
+ heroCount('pacnew','.pacnew files',{lever:`<span class="lever">${lever('pacnew','REVIEW')}</span>`,sub:'Confirm · config merges',tint:1}),
+ strip:
+ lrow('keyring','keyring age (d)', lever('keyring','REFRESH')+' ')+
+ lrow('reboot','reboot required')
+ };},
+ systemd(){ return {
+ heroes:
+ heroCount('failed','Failed units',{lever:`<span class="lever">${wkey()}</span>`,sub:'Workflow · investigate why',tint:1})+
+ heroCount('timers','Maint timers',{lever:`<span class="lever">${lever('timers','ENABLE')}</span>`,sub:'Confirm · paccache/scrub/fstrim/…',tint:1}),
+ strip: lrow('sysrun','is-system-running')+lrow('taint','kernel taint')
+ };},
+ logs(){ return {
+ heroes:
+ heroCount('cores','Coredumps 7d',{lever:`<span class="lever">${lever('cores','CLEAR')}</span>`,sub:'Auto · keeps last 3 days',tint:1}),
+ strip:
+ lrow('journald','journald size (GB)', lever('journald','VACUUM')+' ')+
+ lrow('applog','app-log cleanup', lever('applog','RUN')+' ')+
+ lrow('jerr','journal errors')+lrow('khw','kernel/hw events')
+ };},
+ power(){ return {
+ heroes:
+ `<div class="inst s5"><div class="cap"><span class="lamp"></span> Temperatures</div>
+ <div class="gaugepair">
+ <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div>
+ <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div>
+ </div></div>`+
+ `<div class="inst s4"><div class="cap"><span class="lamp"></span> Memory free</div>
+ <div class="val"><span class="readout">104<small> GB</small></span></div>
+ <div class="bar okc"><span style="width:83%"></span></div><div class="sub">of 125 GB · 0 OOM</div></div>`+
+ `<div class="inst s3"><span class="lever">${wkey()}</span><div class="cap"><span class="lamp gold"></span> Unclean boot</div>
+ <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div>
+ <div class="bar warn"><span style="width:75%"></span></div><div class="sub">Workflow · investigate</div></div>`,
+ strip: lrow('zram','swap / zram')+lrow('throttle','thermal throttle')+lrow('battery','battery')
+ };},
+ services(){ return {
+ heroes:
+ heroCount('docker','Docker reclaim',{lever:`<span class="lever">${lever('docker','PRUNE')}</span>`,unit:' GB',sub:'Confirm · tiers 1+2',tint:1}),
+ strip:
+ lrow('rsync','rsyncshot backup')+lrow('dstopped','docker stopped')+
+ lrow('cron','cron')+lrow('libvirt','libvirt VMs')
+ };},
+ snapshots(){ return {
+ heroes:'',
+ strip:
+ lrow('snapper','snapper count', lever('snapper','PRUNE')+' ')+
+ lrow('stimer','snapshot timer')
+ };},
+ network(){ return {
+ heroes:'',
+ strip:
+ lrow('fw','firewall')+lrow('listen','unexpected listeners')+lrow('ts','tailscale')+
+ lrow('f2b','fail2ban')+lrow('ntp','NTP sync')+lrow('dns','DNS / NM')
+ };},
+};
+
+function renderCat(cid){
+ const idx = CATS.findIndex(c=>c.id===cid);
+ const cat = CATS[idx];
+ const k = counts(cat);
+ const prev = CATS[(idx+CATS.length-1)%CATS.length].id;
+ const next = CATS[(idx+1)%CATS.length].id;
+ const body = CATVIEW[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>`;
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ ${cat.name.toLowerCase()}</div>
+ <div class="plate catplate">
+ <div class="cathead">
+ <span class="lamp ${catLamp(cat)}"></span>
+ <span class="nm">${cat.name}</span>
+ <span class="cnt">${cnt}</span>
+ <span class="navs">
+ <button class="key sm" onclick="go('cat:${prev}')">◂ PREV</button>
+ <button class="key sm" onclick="go('home')">HOME</button>
+ <button class="key sm" onclick="go('cat:${next}')">NEXT ▸</button>
+ </span>
+ </div>
+ <div class="catbody">
+ ${body.heroes?`<div class="heroes">${body.heroes}</div>`:''}
+ <div class="strip">${body.strip}</div>
+ </div>
+ </div>`;
+}
+
+/* ---- doctor (clean up) ---- */
+let docSteps = [];
+function renderDoctor(){
+ const rows = docSteps.map(s=>{
+ const lamp = s.st==='run'?'busy':s.st==='done'?'':'off';
+ return `<div class="ostep ${s.st==='wait'?'queued':''}"><span class="lamp ${lamp}"></span>
+ <span><b>${s.name}</b> <span class="ev">${s.ev}</span></span></div>`;
+ }).join('');
+ const running = docSteps.some(s=>s.st!=='done');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · clean up</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING ${running?'<span class="lamp busy"></span>':'<span class="lamp"></span>'}</span>
+ <span style="color:var(--dim);font-size:11px">${docSteps.length} Auto actions</span></div>
+ ${rows}
+ <div class="ofoot"><button class="key ${running?'done':''}" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+function startDoctor(){
+ clearTimeout(doctorTimer);
+ docSteps = [
+ {name:'cache trim', act:'clean_cache', st:'wait', ev:'queued',
+ res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'done · reclaimed 8.4 GB') : 'done · nothing to reclaim'},
+ {name:'journal vacuum',act:'journald', st:'wait', ev:'queued',
+ res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'done · reclaimed 0.9 GB') : 'done · already tight'},
+ {name:'coredump clear',act:'cores', st:'wait', ev:'queued',
+ res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return 'done · '+n+' cleared';})() : 'done · none to clear'},
+ {name:'app-log cleanup',act:'applog', st:'wait', ev:'queued',
+ res:()=> 'done · nothing older than 7d'},
+ ];
+ let i = 0;
+ function step(){
+ if(i>0){ docSteps[i-1].st='done'; docSteps[i-1].ev=docSteps[i-1].res(); }
+ if(i<docSteps.length){ docSteps[i].st='run'; docSteps[i].ev='running…'; i++;
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ doctorTimer=setTimeout(step, 1000);
+ } else {
+ if(view==='doctor') $('view').innerHTML=renderDoctor();
+ toast('clean up complete');
+ }
+ }
+ step();
+}
+
+/* ---- review & fix ---- */
+const REVIEW = [
+ {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', okv:()=>S.fstrim.st==='ok'},
+ {act:'scrub', who:'btrfs scrub', why:'34d since last scrub (30d cadence)', id:'scrub', okv:()=>S.scrub.st==='ok'},
+ {act:'orphans', who:'orphans', why:'13 unowned packages', id:'orphans', okv:()=>S.orphans.st==='ok'},
+ {act:'pacnew', who:'.pacnew files', why:'2 configs awaiting merge', id:'pacnew', okv:()=>S.pacnew.st==='ok'},
+ {act:'timers', who:'maint timers', why:'1 of 5 not firing', id:'timers', okv:()=>S.timers.st==='ok'},
+ {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', okv:()=>S.keyring.st==='ok'},
+ {act:'docker', who:'docker reclaim', why:'3 GB reclaimable (tiers 1+2)', id:'docker', okv:()=>S.docker.st==='ok'},
+ {act:'snapper', who:'snapper', why:'retention within budget — optional', id:'snapper', okv:()=>S.snapper.v<42},
+ {act:'deeptrim',who:'deep-trim', why:'keep 1 version — frees most, less downgrade headroom', id:'deeptrim', okv:()=>S.deeptrim.v==='keep 1'},
+];
+function renderReview(){
+ const rows = REVIEW.map(r=>{
+ const done = r.okv();
+ return `<div class="rvrow">
+ <span class="lamp ${done?'':lampCls(S[r.id].st)}"></span>
+ <span class="who">${r.who}</span><span class="why">${r.why}</span>
+ <span class="keys">${done?'<span class="res">done ✓</span>':lever(r.act, ACTIONS[r.act].arm?'FIX':'FIX')}</span>
+ </div>`;
+ }).join('');
+ return `
+ <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · review &amp; fix</div>
+ <div class="plate owell">
+ <div class="oh"><span>MAINT · <span class="l">ratio</span> · REVIEW &amp; FIX</span>
+ <span style="color:var(--dim);font-size:11px">${REVIEW.filter(r=>!r.okv()).length} Confirm items</span></div>
+ ${rows}
+ <div class="rvnote">Destructive fixes (orphan removal, deep-trim, prunes) arm on first press and fire on the
+ second. Workflow items — failed units, unclean boots, updates, CVEs — are deliberately absent here: they
+ escalate to the health-check workflow.</div>
+ <div class="ofoot"><button class="key" onclick="go('home')">DONE</button></div>
+ </div>`;
+}
+
+/* ================= router ================= */
+function go(v){
+ armed = null;
+ view = v;
+ if(v==='doctor') startDoctor();
+ render();
+ window.scrollTo({top:0, behavior:'smooth'});
+}
+function render(){
+ $('homekey').classList.toggle('on', view==='home');
+ const el = $('view');
+ if(view==='home') el.innerHTML = renderHome();
+ else if(view==='doctor') el.innerHTML = renderDoctor();
+ else if(view==='review') el.innerHTML = renderReview();
+ else if(view.startsWith('cat:')) el.innerHTML = renderCat(view.slice(4));
+}
+render();
+</script>
+</body>
+</html>