341 lines
12 KiB
TypeScript
341 lines
12 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 [repairBusy, setRepairBusy] = useState(false);
|
||
const [dedupeBusy, setDedupeBusy] = useState(false);
|
||
const [imdbBusy, setImdbBusy] = useState(false);
|
||
|
||
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• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig."
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
setRepairBusy(true);
|
||
setRepairMsg("Bezig — duplicaten en dode files opruimen…");
|
||
try {
|
||
const res = await fetch("/api/v1/admin/library/repair", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const json = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setRepairMsg(json.error?.message ?? `Mislukt (HTTP ${res.status})`);
|
||
return;
|
||
}
|
||
const imdb = json.imdb ?? {};
|
||
const imdbRemaining =
|
||
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
|
||
setRepairMsg(
|
||
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.`
|
||
);
|
||
load();
|
||
} catch (err) {
|
||
setRepairMsg(
|
||
err instanceof Error
|
||
? `Verbinding mislukt: ${err.message}`
|
||
: "Verbinding mislukt — timeout of server offline?"
|
||
);
|
||
} finally {
|
||
setRepairBusy(false);
|
||
}
|
||
}
|
||
|
||
async function backfillImdb() {
|
||
if (
|
||
!confirm(
|
||
"IMDb-IDs ophalen via TMDB voor films en series die die nog missen? Dit gebeurt in batches (duurt even bij grote bibliotheken)."
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
setImdbBusy(true);
|
||
setImdbMsg("Batch 1 bezig…");
|
||
let batch = 0;
|
||
let totalMoviesUpdated = 0;
|
||
let totalSeriesUpdated = 0;
|
||
let prevRemaining = Number.POSITIVE_INFINITY;
|
||
try {
|
||
while (batch < 300) {
|
||
batch += 1;
|
||
setImdbMsg(`Batch ${batch} bezig…`);
|
||
const res = await fetch("/api/v1/admin/library/backfill-imdb", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ limit: 100 }),
|
||
});
|
||
const json = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setImdbMsg(json.error?.message ?? "Mislukt");
|
||
return;
|
||
}
|
||
const movies = json.movies ?? {};
|
||
const series = json.series ?? {};
|
||
const checked = (movies.checked ?? 0) + (series.checked ?? 0);
|
||
const updated = (movies.updated ?? 0) + (series.updated ?? 0);
|
||
totalMoviesUpdated += movies.updated ?? 0;
|
||
totalSeriesUpdated += series.updated ?? 0;
|
||
const remaining = (movies.remaining ?? 0) + (series.remaining ?? 0);
|
||
|
||
if (remaining === 0) {
|
||
setImdbMsg(
|
||
`Klaar: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · alles ingevuld`
|
||
);
|
||
load();
|
||
return;
|
||
}
|
||
if (checked === 0) {
|
||
setImdbMsg(
|
||
`Klaar: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} open (geen IMDb op TMDB)`
|
||
);
|
||
load();
|
||
return;
|
||
}
|
||
if (remaining >= prevRemaining && updated === 0) {
|
||
setImdbMsg(
|
||
`Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)`
|
||
);
|
||
load();
|
||
return;
|
||
}
|
||
prevRemaining = remaining;
|
||
setImdbMsg(
|
||
`Batch ${batch}: +${movies.updated ?? 0} films, +${series.updated ?? 0} series · nog ${remaining} open…`
|
||
);
|
||
}
|
||
setImdbMsg(
|
||
`Batchlimiet bereikt: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · druk opnieuw op de knop om verder te gaan`
|
||
);
|
||
load();
|
||
} catch (err) {
|
||
setImdbMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
|
||
} finally {
|
||
setImdbBusy(false);
|
||
}
|
||
}
|
||
|
||
async function dedupe() {
|
||
if (
|
||
!confirm(
|
||
"Duplicaten samenvoegen? Titels met dezelfde naam worden samengevoegd (TMDB-versie wint)."
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
setDedupeBusy(true);
|
||
setDedupeMsg("Bezig…");
|
||
try {
|
||
const res = await fetch("/api/v1/admin/library/dedupe", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const json = await res.json().catch(() => ({}));
|
||
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();
|
||
} catch (err) {
|
||
setDedupeMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
|
||
} finally {
|
||
setDedupeBusy(false);
|
||
}
|
||
}
|
||
|
||
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} disabled={repairBusy}>
|
||
{repairBusy ? "Bezig…" : "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} disabled={dedupeBusy}>
|
||
{dedupeBusy ? "Bezig…" : "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} disabled={imdbBusy}>
|
||
{imdbBusy ? "Bezig…" : "IMDb-IDs backfillen"}
|
||
</button>
|
||
{imdbMsg && <p className="muted tiny">{imdbMsg}</p>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
{!data && !error && <p className="muted">Laden...</p>}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|