// Direction B — section panels (Storage / Downloads / Seeding / Calendar / Tasks / Shoko / Failed) const B2 = (() => { const { useMemo, useState, useEffect } = React; const { bytes, speed, pct, relTime, duration, airTime, weekday, STATUS_TONE, seedingHealth, sourceLink, sourceCalendarLink, LINKS, activeDownloads, completedSeeds } = window.HL; const { tones, Chip, Panel } = window.DirectionB; // ── responsive ───────────────────────────────────────────────────── // The dashboard is desktop-first; below this width the multi-column // grids collapse and the calendar shrinks its forecast. One hook, // called once in OperatorDashboard, threads `narrow` down as a prop. const NARROW_BP = 880; function useNarrow() { const get = () => typeof window !== "undefined" && window.innerWidth < NARROW_BP; const [narrow, setNarrow] = useState(get); useEffect(() => { const on = () => setNarrow(get()); window.addEventListener("resize", on); return () => window.removeEventListener("resize", on); }, []); return narrow; } // ── Storage (gauge cards) ────────────────────────────────────────── function Storage({ section, narrow }) { const pools = section.data || []; const cols = narrow ? 1 : Math.min(pools.length, 3); // Surface aggregate health on the Panel border so the status is visible // even when the panel is collapsed. const panelTone = pools.some(p => !p.healthy) ? "bad" : pools.some(p => (p.used_percent || 0) > 90) ? "warn" : undefined; return (
{pools.map((p, i) => { const tone = !p.healthy ? "bad" : (p.used_percent || 0) > 90 ? "warn" : "ok"; const used = (p.used_percent || 0) / 100; const r = 56; const c = 2 * Math.PI * r; return (
{/* radial gauge */} {pct(p.used_percent, 0)} USED
{p.name} {p.status}
{bytes(p.free_bytes)} free of {bytes(p.size_bytes)}
{p.fragmentation_percent ?? "—"}% frag · {p.scrub_errors ?? 0} scrub err
); })}
); } // ── Downloads ────────────────────────────────────────────────────── function Downloads({ section }) { const items = activeDownloads(section.data); // Nothing actively downloading — hide the panel entirely. Kept visible // only if the collector errored, so the degraded state is still surfaced. if (items.length === 0 && !section.error) return null; // Route the OPEN link to whichever downloader(s) actually have active // items — one button if only one client is busy, two (labelled) if both. const hasQbit = items.some(d => d.client === "qbittorrent"); const hasJd = items.some(d => d.client !== "qbittorrent"); const links = [ hasQbit && { href: LINKS.qbit, label: "qbt" }, hasJd && { href: LINKS.jdownloader, label: "jd" }, ].filter(Boolean); return ( 1 ? links : undefined} href={links.length === 1 ? links[0].href : undefined}> {items.length === 0 ? (
Queue empty.
) : items.map((d, i) => { const tone = STATUS_TONE[d.state] || "muted"; return (
{d.client === "qbittorrent" ? "QBIT" : "JD"}
{d.name}
{pct(d.progress * 100, 1)}
{bytes(d.size)} {d.dl_speed > 0 && ↓ {speed(d.dl_speed)}} {d.up_speed > 0 && ↑ {speed(d.up_speed)}} {d.eta_seconds && ETA {duration(d.eta_seconds)}} {d.tracker_host && {d.tracker_host}}
{d.state}
); })} ); } // ── Seeding health ───────────────────────────────────────────────── function Seeding({ section, narrow }) { const seeds = completedSeeds(section.data); // No completed seeds at all — hide the panel (host strip would be empty // too). Kept visible on a collector error to surface the degraded state. if (seeds.length === 0 && !section.error) return null; const annotated = seeds.map(d => ({ d, h: seedingHealth(d) })); // Only flagged rows — green seeds aren't actionable, the host strip rolls them up. const flagged = annotated .filter(x => x.h && (x.h.level === "risk" || x.h.level === "watch")) .sort((a, b) => { const order = { risk: 0, watch: 1 }; return (order[a.h.level] ?? 9) - (order[b.h.level] ?? 9); }); const byHost = {}; annotated.filter(x => x.h).forEach(({ d, h }) => { const host = d.tracker_host || "unknown"; const b = byHost[host] || (byHost[host] = { host, total: 0, risk: 0, watch: 0, ratio: 0, up: 0 }); b.total++; b.ratio += d.ratio || 0; b.up += d.uploaded || 0; if (h.level === "risk") b.risk++; if (h.level === "watch") b.watch++; }); const hosts = Object.values(byHost).sort((a, b) => b.risk - a.risk || b.watch - a.watch); // Surface aggregate seeding health on the Panel border so the status is // visible even when the panel is collapsed. const panelTone = hosts.some(h => h.risk) ? "bad" : hosts.some(h => h.watch) ? "warn" : undefined; return ( {/* host strip — per-tracker rollup, all hosts shown so the green ones reassure */}
{hosts.map((h, i) => { const tone = h.risk ? "bad" : h.watch ? "warn" : "ok"; return (
{h.host}
{(h.ratio / h.total).toFixed(2)} avg ratio
{h.total} seeds · {bytes(h.up)} uploaded {h.risk ? ` · ${h.risk} risk` : ""}{h.watch ? ` · ${h.watch} watch` : ""}
); })}
{/* per-torrent rows — only risk/watch */} {flagged.length === 0 ? (
All private-tracker seeds healthy — no strike-risk items.
) : flagged.map(({ d, h }, i) => { const tone = h.level === "risk" ? "bad" : "warn"; const seedH = (d.seeding_time || 0) / 3600; const mono = "var(--mono)"; const lbl = { fontFamily: mono, fontSize: 10, color: "var(--b-muted)" }; const ratioCell = (
{(d.ratio || 0).toFixed(2)}
ratio
); const seedCell = (
{duration(d.seeding_time || 0)}
seed time
); const upCell = (
{bytes(d.uploaded)}
↑ {d.up_speed ? speed(d.up_speed) : "idle"}
); const titleBlock = (
{d.name}
{d.tracker_host} · {bytes(d.size)}{d.category ? ` · ${d.category}` : ""} {h.reasons.length ? ` · ${h.reasons.join(", ")}` : ""}
); // On narrow viewports the 5-column desktop grid shears each metric // into an unreadable vertical column. Switch to a stacked layout: // chip + title on top, metrics in a 3-up row below. if (narrow) { return (
{h.level} {titleBlock}
{ratioCell}{seedCell}{upCell}
); } return (
{h.level} {titleBlock} {ratioCell} {seedCell} {upCell}
); })}
); } // ── RomM live scan ───────────────────────────────────────────────── // Connects straight to RomM's Socket.IO (same-site, so RomM's session // cookie rides along — no creds in the page) and renders live library- // scan progress. Read-only: it only listens for scan:* broadcasts. // RomM scan state is hoisted into a hook so the parent can know whether the // RomM panel will actually render (it's null when idle/offline) and size the // activity-row grid accordingly. function useRommScan() { const [conn, setConn] = useState("connecting"); const [scan, setScan] = useState(null); useEffect(() => { if (!window.io) { setConn("down"); return; } const sock = window.io("wss://romm.amochis.link", { path: "/ws/socket.io", transports: ["websocket"], withCredentials: true, }); const patch = (p) => setScan((s) => ({ phase: "scanning", ...(s && s.phase === "scanning" ? s : {}), ...p })); sock.on("connect", () => setConn("live")); sock.on("disconnect", () => setConn("down")); sock.on("connect_error", () => setConn("down")); sock.on("scan:scanning_platform", (p) => setScan((s) => { const prev = s && s.phase === "scanning" ? s : {}; const st = prev.stats || {}; return { ...prev, phase: "scanning", platform: p, baseline: { scanned: st.scanned_roms || 0, new: st.new_roms || 0, identified: st.identified_roms || 0 } }; })); sock.on("scan:scanning_rom", (r) => patch({ rom: r })); sock.on("scan:update_stats", (st) => patch({ stats: st })); sock.on("scan:done", (st) => setScan({ phase: "done", stats: st })); sock.on("scan:done_ko", (e) => setScan({ phase: "failed", error: String(e) })); return () => sock.close(); }, []); return { conn, scan }; } // Whether the Romm panel would actually render — keep in sync with Romm()'s // own early-return at the top of its body. function rommVisible(state) { return state.conn === "live" && !!state.scan; } function Romm({ state }) { const { conn, scan } = state; const muted = { fontFamily: "var(--mono)", fontSize: 12, color: "var(--b-muted)" }; const pad = { padding: "12px 16px" }; const st = scan && scan.stats; const bl = scan && scan.baseline || { scanned: 0, new: 0, identified: 0 }; // Per-platform deltas since the current platform started. const dScanned = (st ? st.scanned_roms : 0) - bl.scanned; const dNew = (st ? st.new_roms : 0) - bl.new; const dIdentified = (st ? st.identified_roms : 0) - bl.identified; // Discover vs rescan — scoped to the current platform. // If new roms appeared on this platform, files on disk > known games → // show identification coverage. Otherwise scanned/total is linear and fine. const isDiscover = dNew > 0; const platTotal = dScanned > 0 ? dScanned : 1; const platFrac = isDiscover ? Math.min(1, dIdentified / platTotal) : (st && st.total_roms ? Math.min(1, (st.scanned_roms || 0) / st.total_roms) : 0); // Overall progress: platforms scanned / total platforms. const platBar = st && st.total_platforms ? Math.min(1, (st.scanned_platforms || 0) / st.total_platforms) : 0; const status = conn !== "live" ? (conn === "down" ? "offline" : "…") : scan && scan.phase === "scanning" ? "scanning" : "live"; if (conn !== "live" || !scan) return null; let body; if (scan.phase === "failed") { body =
Scan failed — {scan.error}
; } else if (scan.phase === "done") { body =
● Scan complete
{st &&
+{st.new_roms} new · {st.identified_roms} identified · {st.scanned_roms} roms
}
; } else { body =
Scanning {scan.platform ? (scan.platform.display_name || scan.platform.name) : "…"} {st && st.total_platforms ? {" "}({st.scanned_platforms || 0}/{st.total_platforms}) : null}
{/* Overall platform progress bar */}
{st && st.total_platforms ? pct(platBar * 100, 0) : "—"}
{scan.rom &&
{scan.rom.name || scan.rom.fs_name}
} {st &&
{isDiscover ? `${dIdentified}/${dScanned} identified · +${dNew} new` : `${dScanned} scanned`}
}
; } return ( {body} ); } // ── Calendar (week strip) ────────────────────────────────────────── function Calendar({ section, narrow }) { const items = section.data || []; const days = narrow ? 2 : 7; // narrow screens can't fit a 7-day strip const today = new Date(); today.setHours(0, 0, 0, 0); const week = Array.from({ length: days }, (_, i) => ({ date: new Date(today.getTime() + i * 86400 * 1000), items: [], })); items.forEach(it => { const d = new Date(it.air_date); const idx = Math.floor((new Date(d).setHours(0,0,0,0) - today.getTime()) / 86400000); if (idx >= 0 && idx < days) week[idx].items.push(it); }); const tag = (s) => s === "radarr" ? "MOV" : s === "sonarr-es" ? "ES" : "TV"; return (
{week.map((col, i) => (
{weekday(col.date.toISOString())} {col.date.getDate()} {col.items.length > 0 && ( {col.items.length} )}
{col.items.length === 0 && (
)} {col.items.map((it, j) => (
{tag(it.source)} {airTime(it.air_date)} {it.has_file && HAVE}
{it.title}
))}
))}
); } // ── Tasks (single panel, grouped sub-headers) ────────────────────── function Tasks({ section, narrow }) { const items = section.data || []; const groups = {}; items.forEach(t => { (groups[t.category] || (groups[t.category] = [])).push(t); }); const order = [ ["snapshot", "Snapshots", LINKS.truenasDataProtection], ["replication", "Replication", LINKS.truenasDataProtection], ["cloud_sync", "Cloud sync", LINKS.truenasDataProtection], ["backup", "Backups", LINKS.urbackup], ]; // Surface failed tasks on the Panel border so the status is visible even // when the panel is collapsed. const panelTone = items.some(t => t.last_status === "failed") ? "bad" : undefined; return (
{order.map(([key, label, href], col) => { const list = groups[key] || []; const fails = list.filter(t => t.last_status === "failed").length; const last = col < order.length - 1; return (
{label} {list.length} {fails > 0 && {fails}}
{list.map((t, i) => { const tone = STATUS_TONE[t.last_status] || "muted"; return (
{t.name.replace(/^(Snapshot|Replication|Cloud sync|urBackup): /, "")} {relTime(t.last_run)}
); })}
); })}
); } // Small inline action button used in panel headers. Idle → running → // done/error flash → idle. Calls onClick (which returns a Promise) and // tracks state itself. function RunNowButton({ label = "Run now", busyLabel = "Running…", onClick }) { const [state, setState] = useState("idle"); // idle | running | done | error const [msg, setMsg] = useState(""); async function handle() { if (state === "running") return; setState("running"); setMsg(""); try { await onClick(); setState("done"); setTimeout(() => setState("idle"), 2500); } catch (e) { setState("error"); setMsg(String(e.message || e)); setTimeout(() => setState("idle"), 4500); } } const visibleLabel = state === "running" ? busyLabel : state === "done" ? "Linked ✓" : state === "error" ? `Failed: ${msg}` : label; const tone = state === "done" ? "var(--b-ok)" : state === "error" ? "var(--b-bad)" : "var(--b-text)"; return ( ); } // ── Shoko + Failed (paired) ──────────────────────────────────────── function Shoko({ section }) { const items = section.data || []; async function runAutoLink() { const r = await fetch("/api/shoko/auto-link", { method: "POST", cache: "no-store" }); const body = await r.json().catch(() => ({})); if (!r.ok) { throw new Error(body.detail || `HTTP ${r.status}`); } if (body.state !== "SUCCESS") { throw new Error(body.error || body.state || "unknown failure"); } // Backend already ran poll_now on success — pull the fresh snapshot. await window.HL.refresh(); } return ( }> {items.length === 0 ? (
Library clean.
) : items.map((f, i) => (
{f.path} {f.resolution} {bytes(f.size)}
))}
); } function Failed({ section }) { const items = section.data || []; return ( {items.length === 0 ? (
Queue clean.
) : items.map((f, i) => (
{f.status} {f.source} · {f.state}
{f.title}
{f.messages.map((m, j) => (
{m}
))}
))} ); } // ── root ─────────────────────────────────────────────────────────── function OperatorDashboard({ snapshot }) { const sections = snapshot?.data || {}; const t = useMemo(() => window.DirectionB.triage(sections), [sections]); const narrow = useNarrow(); const romm = useRommScan(); const [refreshing, setRefreshing] = useState(false); // Default the live-downloads poll to ON if there's anything actively // downloading at first paint, so the user lands on a live view without // having to flip it themselves. const [liveDl, setLiveDl] = useState(() => activeDownloads(snapshot?.data?.downloads?.data || []).length > 0); useEffect(() => { if (liveDl) window.HL.setLiveDownloads(true); }, []); const doRefresh = async () => { if (refreshing) return; setRefreshing(true); try { await window.HL.refresh(); } finally { setRefreshing(false); } }; const toggleLive = () => { const on = !liveDl; window.HL.setLiveDownloads(on); setLiveDl(on); }; const ctrlBase = { fontFamily: "var(--mono)", fontSize: 11, height: 28, padding: "0 10px", borderRadius: 6, border: "1px solid var(--b-border)", background: "transparent", color: "var(--b-muted)", display: "inline-flex", alignItems: "center", gap: 6, }; return (
{(() => { const truenasAlerts = t.failedTasks.length + t.stoppedApps.length + t.degradedPools.length; const dotTone = truenasAlerts > 0 ? tones.bad : tones.ok; return ( ); })()} HOMELAB OPS {/* domain subtitle — redundant context on mobile (you're on it), hidden so the header fits one row */} {!narrow && ( amochis.link )}
{/* Mock-fallback warning — only surfaced when the API is unreachable. Replaces the old always-on LIVE chip (which was redundant once the green dot on LIVE DOWNLOADS communicates the same thing). */} {snapshot && !snapshot.live && ● MOCK} {/* Last-updated timestamp — hidden on narrow viewports so the header keeps to a single line on mobile. */} {!narrow && ( {snapshot?.fetchedAt ? `updated ${relTime(snapshot.fetchedAt)}` : "loading…"} )}
{window.DirectionB.Hero({ sections, t, narrow })} {/* alerts row — laid out based on which sources have items: - alerts only: full-width alerts panel - alerts + (failed or shoko): 2-col grid, alerts left, queue side-rail right - no alerts but failed/shoko: queue panels on their own (full width) - none of the above: nothing rendered */} {(() => { const failed = sections.failed_downloads?.data || []; const shoko = sections.shoko_unrecognized?.data || []; const truenas = t.failedTasks.length + t.stoppedApps.length + t.degradedPools.length; const hasAlerts = truenas > 0; const hasQueue = failed.length > 0 || shoko.length > 0; if (!hasAlerts && !hasQueue) return null; if (!hasAlerts && hasQueue) { const cols = narrow ? "1fr" : (failed.length > 0 && shoko.length > 0 ? "1.3fr 1fr" : "1fr"); return (
{failed.length > 0 && } {shoko.length > 0 && }
); } if (hasAlerts && !hasQueue) { return window.DirectionB.Alerts({ sections, t }); } return (
{window.DirectionB.Alerts({ sections, t })}
{failed.length > 0 && } {shoko.length > 0 && }
); })()} {(() => { // Activity row: Downloads · Seeding · RomM. Each panel hides itself // when it has nothing to show. Grid columns = visible-panel count // (1 / 2 / 3) so two panels split 50/50 instead of being squashed // against an empty third track. Always 1 column on narrow screens. const dl = sections.downloads; const showDl = dl && (!!dl.error || activeDownloads(dl.data).length > 0); const showSeed = dl && (!!dl.error || completedSeeds(dl.data).length > 0); const showRomm = rommVisible(romm); const count = (showDl ? 1 : 0) + (showSeed ? 1 : 0) + (showRomm ? 1 : 0); if (count === 0) return null; const cols = narrow ? 1 : count; return (
{showDl && } {showSeed && } {showRomm && }
); })()} {sections.calendar && } {sections.storage && } {sections.tasks && }
); } return { OperatorDashboard }; })(); window.DirectionBRoot = B2;