270 lines
9.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|