// Direction B — "Operator" // Grafana-leaning. KPI tiles, gauges, more status color. Cooler slate base. const B = (() => { const { useMemo, useState } = React; const { bytes, speed, pct, relTime, duration, airTime, weekday, STATUS_TONE, seedingHealth, sourceLink, LINKS } = window.HL; const tones = { ok: "var(--b-ok)", bad: "var(--b-bad)", warn: "var(--b-warn)", info: "var(--b-info)", muted: "var(--b-muted)", }; // Per-panel collapsed state, persisted to localStorage so it survives // reloads and sessions. Key is derived from the panel title. const COLLAPSE_KEY_PREFIX = "dashboard:collapsed:"; const slugify = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); const readCollapsed = (slug) => { if (!slug) return false; try { return window.localStorage.getItem(COLLAPSE_KEY_PREFIX + slug) === "1"; } catch { return false; } }; const writeCollapsed = (slug, val) => { if (!slug) return; try { if (val) window.localStorage.setItem(COLLAPSE_KEY_PREFIX + slug, "1"); else window.localStorage.removeItem(COLLAPSE_KEY_PREFIX + slug); } catch { /* private mode etc — fall back to session-only state */ } }; const Chip = ({ tone = "muted", children, solid }) => ( {children} ); // `href` → a single "OPEN ↗" link. `links` → one labelled // "OPEN ↗" per entry ([{ href, label }, ...]); use when a panel // can route to more than one owning app. const Panel = ({ title, count, error, updated, href, links, children, tone, dense, action }) => { const slug = useMemo(() => slugify(title), [title]); const [collapsed, setCollapsed] = useState(() => readCollapsed(slug)); const collapsible = !!slug; const toggle = () => { const next = !collapsed; setCollapsed(next); writeCollapsed(slug, next); }; const stop = (e) => e.stopPropagation(); return ( {collapsible && ( ▾ )} {title} {count != null && {count}} {error && degraded} {action && {action}} {updated && {relTime(updated)} } {(links && links.length ? links : href ? [{ href }] : []).map((l, i) => ( OPEN{l.label ? ` ${l.label.toUpperCase()}` : ""} ↗ ))} {!collapsed && {children}} ); }; // ── triage ────────────────────────────────────────────────────────── function triage(sections) { const failedTasks = (sections.tasks?.data || []).filter(t => t.last_status === "failed"); const stoppedApps = sections.services?.data || []; const degradedPools = (sections.storage?.data || []).filter(p => !p.healthy); const failedDownloads = sections.failed_downloads?.data || []; const seedingAnnotated = (sections.downloads?.data || []) .map(d => ({ d, h: seedingHealth(d) })) .filter(x => x.h); const strikes = seedingAnnotated.filter(x => x.h.level === "risk"); const watches = seedingAnnotated.filter(x => x.h.level === "watch"); return { failedTasks, stoppedApps, degradedPools, failedDownloads, strikes, watches }; } // ── KPI tile (big) ────────────────────────────────────────────────── const Tile = ({ label, value, tone = "muted", sub, gauge }) => ( {label} {value} {gauge != null && ( )} {sub && {sub}} ); function Hero({ sections, t, narrow }) { const dl = sections.downloads?.data || []; const dlSpeed = dl.reduce((a, x) => a + (x.dl_speed || 0), 0); const upSpeed = dl.filter(x => x.client === "qbittorrent").reduce((a, x) => a + (x.up_speed || 0), 0); const pool = (sections.storage?.data || []).reduce((a, p) => ({ size: a.size + (p.size_bytes || 0), free: a.free + (p.free_bytes || 0), }), { size: 0, free: 0 }); const usedFrac = pool.size ? (1 - pool.free / pool.size) : 0; // Status tile = TrueNAS lab health only. Strike-risk seeds, failed imports, and // Shoko unrecognized are INDEPENDENT KPIs reflected in their own tiles/panels. const truenasAlerts = t.failedTasks.length + t.stoppedApps.length + t.degradedPools.length; return ( 1 ? "S" : ""}`} sub={truenasAlerts === 0 ? "0 task · 0 app · 0 pool" : `${t.failedTasks.length} task · ${t.stoppedApps.length} app · ${t.degradedPools.length} pool`} /> 0.85 ? "warn" : "ok"} value={pct(usedFrac * 100, 0)} gauge={usedFrac} sub={`${bytes(pool.free)} free / ${bytes(pool.size)}`} /> x.state === "downloading").length} active · ${dl.length} total`} /> ); } // ── alerts banner ─────────────────────────────────────────────────── function Alerts({ sections, t }) { const lines = []; t.failedTasks.forEach(x => lines.push({ tone: "bad", code: "TASK", title: x.name, sub: `failed ${relTime(x.last_run)}` + (x.next_run ? ` · retry ${relTime(x.next_run)}` : ""), href: x.category === "backup" ? LINKS.urbackup : LINKS.truenas, })); t.stoppedApps.forEach(x => lines.push({ tone: "bad", code: "APP", title: x.name, sub: `state: ${x.state.toLowerCase()} — open logs on TrueNAS`, href: LINKS.truenasApp(x.name), })); t.degradedPools.forEach(p => lines.push({ tone: "bad", code: "POOL", title: p.name, sub: `${p.status} · ${p.scrub_errors|0} scrub errors · ${pct(p.used_percent, 0)} used`, href: LINKS.truenasPool(p.name), })); // Strike-risk seeds, failed imports, and Shoko unrecognized are INDEPENDENT // KPIs/panels — not part of the TrueNAS alerts list. if (lines.length === 0) return null; return ( {lines.map((l, i) => ( {l.code} {l.title} {l.sub} {l.href && ( OPEN ↗ )} ))} ); } return { tones, Chip, Panel, Tile, triage, Hero, Alerts }; })(); window.DirectionB = B;