Files
Void-Homelab/public/views/sacred_valley.js
root 1b960ec52b 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>
2026-06-09 03:27:15 +10:00

186 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { renderHealthBand, stopHealthBand } from './health_band.js';
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
import { svCard } from '../components/sv_card.js';
import { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
import jobs from './cards/jobs.js';
import inbox from './cards/inbox.js';
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, storage, jobs, inbox, search, speedtest, aiUsage];
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
let active = []; // mounted cards needing stop()
let renderGen = 0; // guards overlapping async renders
let editing = false;
let layout = { card_order: [], hidden: [], sizes: {} };
const grid = () => document.getElementById('sv-cards');
async function saveLayout() {
try { await api.put('/api/dashboard/layout', layout); }
catch (e) { console.error('save layout', e); }
}
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
function spanOf(def) {
const v = layout.sizes?.[def.id];
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
if (typeof v === 'string') return STR_SPAN[v] || 6;
return STR_SPAN[def.size] || 6;
}
function curSpan(id) {
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
}
function setSpan(id, delta) {
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
layout.sizes = { ...layout.sizes, [id]: span };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) {
node.style.gridColumn = 'span ' + span;
const lbl = node.querySelector('.sv-span-val');
if (lbl) lbl.textContent = span;
}
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const stepper = el('span', { class: 'sv-ed-span' },
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, ''),
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
}
function mountOne(def) {
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
root.appendChild(editOverlay(def));
grid().appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
function setSize(id, s) {
layout.sizes = { ...layout.sizes, [id]: s };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) node.dataset.size = s;
saveLayout();
}
function hideCard(id) {
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
const def = BY_ID.get(id);
if (def?.stop) def.stop();
active = active.filter(d => d.id !== id);
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
renderTray();
saveLayout();
}
function showCard(id) {
layout.hidden = layout.hidden.filter(x => x !== id);
const def = BY_ID.get(id);
if (def) { mountOne(def); wireDrag(); }
renderTray();
saveLayout();
}
function onReorder(newOrder) {
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid().appendChild(frag);
layout.card_order = newOrder;
saveLayout();
}
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
function wireDrag() {
let dragId = null;
grid().querySelectorAll('.sv-card').forEach(card => {
if (card._wired) return; card._wired = true;
const g = card.querySelector('.sv-grip');
if (g) {
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
}
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
}
function renderTray() {
const tray = document.getElementById('sv-tray');
if (!tray) return;
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
mount(tray,
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
tray.style.display = editing ? 'flex' : 'none';
}
function toggleEdit() {
editing = !editing;
grid().classList.toggle('editing', editing);
const btn = document.getElementById('sv-edit-btn');
if (btn) btn.textContent = editing ? 'Done' : 'Edit layout';
const reset = document.getElementById('sv-reset-btn');
if (reset) reset.style.display = editing ? '' : 'none';
renderTray();
}
let mainEl;
async function resetLayout() {
layout = { card_order: [], hidden: [], sizes: {} };
await saveLayout();
render(mainEl);
}
export async function render(main) {
mainEl = main;
const myGen = ++renderGen;
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand();
editing = false;
mount(main,
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
el('div', { class: 'sv-head' },
el('p', { class: 'view-sub', style: { margin: 0 } }, 'The homelab, at a glance.'),
el('div', { class: 'sv-toolbar' },
el('button', { class: 'sv-reset-btn', id: 'sv-reset-btn', style: { display: 'none' }, onclick: resetLayout }, 'Reset'),
el('button', { class: 'sv-edit-btn', id: 'sv-edit-btn', onclick: toggleEdit }, 'Edit layout'))),
el('div', { id: 'sv-cards' }),
el('div', { id: 'sv-tray', style: { display: 'none' } }),
el('div', { id: 'sv-health' }),
el('div', { id: 'sv-devices' })
);
layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
if (myGen !== renderGen) return;
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
wireDrag();
renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices'));
}