feat(sv): Storage · capacity card — ZFS pools, dropped pools, per-CT disk

Read-only Proxmox storage health (same PROXMOX_RO_TOKEN as the cluster card):
ZFS pool health+usage, dropped zfspool storages (the donatello/leonardo SATA
signal), and per-LXC rootfs fill, with a HEALTHY/WATCH/ATTENTION roll-up.
Closes the monitoring gap from the 2026-06-09 audit (C1 + H2 were invisible).
Pure normalizeStorage() unit-tested (4 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-09 03:27:15 +10:00
parent 91a45b4b6c
commit 1b960ec52b
10 changed files with 264 additions and 3 deletions

View File

@@ -635,3 +635,14 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
.hidden { display: none !important; }
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
.sv-cluster .status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
.sv-cluster .ok { color: var(--ok); }
.sv-cluster .bad { color: var(--bad); }
.sv-cluster .st-meter { height: 3px; background: var(--accent-soft); border-radius: 2px; margin: 3px 0 9px; overflow: hidden; }
.sv-cluster .st-fill { height: 100%; border-radius: 2px; }
.sv-cluster .st-fill.ok { background: var(--ok); }
.sv-cluster .st-fill.warn { background: var(--warn); }
.sv-cluster .st-fill.bad { background: var(--bad); }
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }

View File

@@ -0,0 +1,62 @@
// public/views/cards/storage.js — Proxmox storage health: ZFS pools, dropped pools,
// and per-container disk fill. Surfaces the two failure modes that have actually bitten
// this homelab (a pool dropping off the SATA bus; a container rootfs filling up).
import { el, mount } from '../../dom.js';
import { api } from '../../api.js';
let body, timer;
const gb = b => (b >= 1e12 ? (b / 1e12).toFixed(1) + 'T' : Math.round(b / 1e9) + 'G');
const cls = s => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : 'ok');
const dotClass = s => 'status-' + (s === 'crit' ? 'down' : s === 'warn' ? 'warn' : 'ok');
function meterRow(label, value, p, status) {
const wrap = el('div', { class: dotClass(status) });
wrap.appendChild(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, el('span', { class: 'dot' }), label),
el('span', { class: cls(status) }, value)));
if (p != null) {
wrap.appendChild(el('div', { class: 'st-meter' },
el('div', { class: 'st-fill ' + cls(status), style: { width: Math.min(p, 100) + '%' } })));
}
return wrap;
}
async function load() {
if (!body) return;
try {
const s = await api.get('/api/storage');
if (s.error) { mount(body, el('span', { class: 'muted' }, 'Storage: ' + s.error)); return; }
const kids = [];
// overall badge
kids.push(el('div', { class: 'sv-row' },
el('span', { class: 'k' }, 'Status'),
el('span', { class: 'cl-badge ' + (s.worst === 'ok' ? 'ok' : 'bad') },
s.worst === 'ok' ? 'HEALTHY' : s.worst === 'warn' ? 'WATCH' : 'ATTENTION')));
// dropped pools first (most urgent — e.g. donatello/leonardo off the bus)
for (const d of (s.down || []))
kids.push(meterRow(d.name + ' · ' + d.node, '⚠ ' + String(d.state).toUpperCase(), null, 'crit'));
// imported ZFS pools
for (const p of (s.pools || []))
kids.push(meterRow(p.name + ' · ' + p.node,
(p.health !== 'ONLINE' ? p.health + ' · ' : '') + (p.pct ?? '') + '%', p.pct, p.status));
// container disk fill (top few by %)
const top = (s.guests || []).slice(0, 5);
if (top.length) kids.push(el('div', { class: 'sv-subhdr' }, 'Container disk'));
for (const g of top)
kids.push(meterRow('CT ' + g.vmid + ' ' + g.name, g.pct + '% · ' + gb(g.used) + '/' + gb(g.total), g.pct, g.status));
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
} catch { mount(body, el('span', { class: 'muted' }, 'Storage unavailable')); }
}
export default {
id: 'storage', title: 'Storage · capacity', size: 'm',
mount(e) { body = e; load(); },
start() { timer = setInterval(load, 30000); },
stop() { clearInterval(timer); body = null; }
};

View File

@@ -14,8 +14,9 @@ import search from './cards/search.js';
import speedtest from './cards/speedtest.js';
import aiUsage from './cards/ai_usage.js';
import cluster from './cards/cluster.js';
import storage from './cards/storage.js';
const CARD_MODULES = [clock, weather, hostPerf, cluster, jobs, inbox, search, speedtest, aiUsage];
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
let active = []; // mounted cards needing stop()