stremio/apps/admin-ui/src/app/dashboard/page.tsx
Jos Vooges | STH 756b2adda8 Add Accounts menu and mobile card layouts; reorganize admin IA.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 02:36:36 +02:00

270 lines
9.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Dashboard {
nodesOnline: number;
nodesOffline: number;
totalStorage: string;
freeStorage: string;
movies: number;
series: number;
episodes: number;
availableMovies?: number;
availableSeries?: number;
mediaFiles?: number;
availableFiles?: number;
moviesMissingImdb?: number;
moviesWithImdbStreams?: number;
seriesMissingImdb?: number;
seriesWithImdb?: number;
scannedFiles?: number;
activeStreams: number;
currentBandwidth: string;
}
function formatBytes(bytes: string | number | null | undefined): string {
if (bytes === null || bytes === undefined || bytes === "") return "0 B";
let n: bigint;
try {
n = typeof bytes === "bigint" ? bytes : BigInt(String(bytes));
} catch {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let val = Number(n);
if (!Number.isFinite(val)) return "0 B";
let i = 0;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
export default function DashboardPage() {
useAuth();
const [data, setData] = useState<Dashboard | null>(null);
const [error, setError] = useState("");
const [dedupeMsg, setDedupeMsg] = useState("");
const [imdbMsg, setImdbMsg] = useState("");
const [repairMsg, setRepairMsg] = useState("");
const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
.then(async (r) => {
const json = await r.json();
if (!r.ok) {
throw new Error(json.error?.message ?? `HTTP ${r.status}`);
}
setData(json);
})
.catch((err) => {
console.error(err);
setError(err instanceof Error ? err.message : "Laden mislukt");
});
}, []);
useEffect(() => {
load();
}, [load]);
async function repairLibrary() {
if (
!confirm(
"Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Ontbrekende IMDb-IDs backfillen\n\nDit kan enkele minuten duren."
)
) {
return;
}
setRepairMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/repair", {
method: "POST",
credentials: "include",
});
const json = await res.json();
if (!res.ok) {
setRepairMsg(json.error?.message ?? "Mislukt");
return;
}
const imdb = json.imdb ?? {};
setRepairMsg(
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files weg · IMDb films +${imdb.movies?.updated ?? 0}, series +${imdb.series?.updated ?? 0}. Herlaad daarna de Stremio-addon.`
);
load();
}
async function backfillImdb() {
if (
!confirm(
"IMDb-IDs ophalen via TMDB voor ALLE films én series die die nog missen? Dit kan enkele minuten duren. Series-afleveringenlijst blijft op privé-IDs; IMDb is alleen voor Cinemeta-bronnen."
)
) {
return;
}
setImdbMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/backfill-imdb", {
method: "POST",
credentials: "include",
});
const json = await res.json();
if (!res.ok) {
setImdbMsg(json.error?.message ?? "Mislukt");
return;
}
const movies = json.movies ?? json;
const series = json.series ?? {};
setImdbMsg(
`Films: gecontroleerd ${movies.checked ?? 0}, bijgewerkt ${movies.updated ?? 0}, open ${movies.remaining ?? 0} · Series: gecontroleerd ${series.checked ?? 0}, bijgewerkt ${series.updated ?? 0}, open ${series.remaining ?? 0}`
);
load();
}
async function dedupe() {
if (
!confirm(
"Duplicaten samenvoegen? Titels met dezelfde naam worden samengevoegd (TMDB-versie wint)."
)
) {
return;
}
setDedupeMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/dedupe", {
method: "POST",
credentials: "include",
});
const json = await res.json();
if (!res.ok) {
setDedupeMsg(json.error?.message ?? "Mislukt");
return;
}
setDedupeMsg(
`Klaar: ${json.moviesMerged} film-duplicaten, ${json.seriesMerged} serie-duplicaten, ${json.emptied} lege records opgeruimd.`
);
load();
}
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Cluster telemetry</div>
<h1>Dashboard</h1>
</div>
</div>
{error && <div className="error">{error}</div>}
{data && (
<>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Cluster</h2>
</div>
<div className="grid">
<div className="card">
<h2>Nodes online</h2>
<div className="value">{data.nodesOnline ?? 0}</div>
</div>
<div className="card">
<h2>Nodes offline</h2>
<div className="value">{data.nodesOffline ?? 0}</div>
</div>
<div className="card">
<h2>Live streams</h2>
<div className="value">{data.activeStreams ?? 0}</div>
</div>
<div className="card">
<h2>Total storage</h2>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.totalStorage)}
</div>
</div>
<div className="card">
<h2>Free storage</h2>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.freeStorage)}
</div>
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Bibliotheek</h2>
</div>
<div className="grid">
<div className="card">
<h2>Films</h2>
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.movies ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Series</h2>
<div className="value">{data.availableSeries ?? data.series ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.series ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Bestanden</h2>
<div className="value">{data.availableFiles ?? 0}</div>
<p className="muted tiny" style={{ marginTop: "0.5rem" }}>
{data.scannedFiles ?? 0} gescand
</p>
</div>
<div className="card">
<h2>Episodes</h2>
<div className="value">{data.episodes ?? 0}</div>
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Onderhoud</h2>
<p className="muted">Alleen gebruiken bij migratie of catalogusproblemen.</p>
</div>
<div className="card tools-card">
<div className="tool-block">
<h3 className="card-subtitle">Herstel na pad-wijziging</h3>
<p className="muted">
Duplicaten + dode files + IMDb-backfill na Unraid pad-migratie.
</p>
<button type="button" onClick={repairLibrary}>
Bibliotheek herstellen
</button>
{repairMsg && <p className="muted tiny">{repairMsg}</p>}
</div>
<div className="tool-block">
<h3 className="card-subtitle">Duplicaten</h3>
<p className="muted">Zelfde titel samenvoegen (TMDB wint).</p>
<button type="button" onClick={dedupe}>
Duplicaten samenvoegen
</button>
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
</div>
<div className="tool-block">
<h3 className="card-subtitle">IMDb-backfill</h3>
<p className="muted">
Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
{data.seriesMissingImdb ?? "?"}
</p>
<button type="button" onClick={backfillImdb}>
IMDb-IDs backfillen
</button>
{imdbMsg && <p className="muted tiny">{imdbMsg}</p>}
</div>
</div>
</section>
</>
)}
{!data && !error && <p className="muted">Laden...</p>}
</div>
</>
);
}