// 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}
)}
))}
);
}
// ── 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 (
{visibleLabel}
);
}
// ── 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}
OPEN ↗
{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…"}
)}
↻{narrow && !refreshing ? "" : ` ${refreshing ? "REFRESHING" : "REFRESH"}`}
{narrow
? (liveDl ? "LIVE · 5s" : "LIVE")
: (liveDl ? "LIVE DOWNLOADS · 5s" : "LIVE DOWNLOADS")}
{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;