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>
186 lines
7.4 KiB
JavaScript
186 lines
7.4 KiB
JavaScript
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'));
|
||
}
|