stremio/apps/admin-ui/src/app/nodes/page.tsx
Jos Vooges | STH af1f471b29 Fix dropped library sync after startup scan and bump media-node to 1.3.10.
Queue a second full sync when one is already running so newly indexed seasons are not lost after upgrade/restart.
2026-09-17 02:07:52 +02:00

634 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface ScanRootRow {
id?: string;
path: string;
shelfId: string;
shelfName?: string;
kind?: "movie" | "series";
}
interface Node {
id: string;
name: string;
hostname: string | null;
location: string | null;
locationType: string;
status: string;
version: string | null;
publicStreamUrl: string;
lastSeen: string | null;
totalStorage: string;
freeStorage: string;
activeStreams: number;
libraryFileCount: number;
fullScanInterval: string;
fullScanAt: string;
scanStatus?: string;
moviesPaths: string[];
seriesPaths: string[];
scanRoots: ScanRootRow[];
revoked: boolean;
}
interface Shelf {
id: string;
name: string;
slug: string;
kind: "MOVIE" | "SERIES";
}
const MASTER_URL = "https://master.vonas.nl";
export default function NodesPage() {
useAuth();
const [nodes, setNodes] = useState<Node[]>([]);
const [shelves, setShelves] = useState<Shelf[]>([]);
const [showEnroll, setShowEnroll] = useState(false);
const [enrollPlatform, setEnrollPlatform] = useState<"unraid" | "synology">("unraid");
const [enrollName, setEnrollName] = useState("");
const [enrollLocation, setEnrollLocation] = useState("");
const [enrollToken, setEnrollToken] = useState("");
const [publicUrl, setPublicUrl] = useState("https://node1.example.com");
const [moviesPath, setMoviesPath] = useState("/mnt/user/movies");
const [seriesPath, setSeriesPath] = useState("/mnt/user/series");
const [copied, setCopied] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editLocation, setEditLocation] = useState("");
const [editPublicUrl, setEditPublicUrl] = useState("");
const [editScanInterval, setEditScanInterval] = useState("off");
const [editScanAt, setEditScanAt] = useState("03:30");
const [editRoots, setEditRoots] = useState<ScanRootRow[]>([]);
const [editBusy, setEditBusy] = useState(false);
const [upgradingId, setUpgradingId] = useState<string | null>(null);
const [targetNodeVersion, setTargetNodeVersion] = useState("1.3.10");
function loadNodes() {
fetch("/api/v1/admin/nodes", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
setNodes(d.nodes ?? []);
if (d.targetNodeVersion) setTargetNodeVersion(d.targetNodeVersion);
});
}
function loadShelves() {
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
.then((r) => r.json())
.then((d) => setShelves(d.shelves ?? []));
}
useEffect(() => {
loadNodes();
loadShelves();
const t = setInterval(loadNodes, 15000);
return () => clearInterval(t);
}, []);
async function createEnrollmentToken() {
const res = await fetch("/api/v1/admin/nodes/enrollment-tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: enrollName, location: enrollLocation }),
});
const data = await res.json();
setEnrollToken(data.token);
}
function setPlatform(platform: "unraid" | "synology") {
setEnrollPlatform(platform);
if (platform === "synology") {
setMoviesPath((p) => (p.startsWith("/mnt/") || !p ? "/volume1/Media/Films/Films" : p));
setSeriesPath((p) => (p.startsWith("/mnt/") || !p ? "/volume1/Media/Series/Series" : p));
if (!enrollName || enrollName.startsWith("Unraid")) {
setEnrollName("Synology-Node-01");
}
} else {
setMoviesPath((p) => (p.startsWith("/volume") || !p ? "/mnt/user/movies" : p));
setSeriesPath((p) => (p.startsWith("/volume") || !p ? "/mnt/user/series" : p));
if (!enrollName || enrollName.startsWith("Synology")) {
setEnrollName("Unraid-Node-01");
}
}
}
function installCommand() {
const script = enrollPlatform === "synology" ? "synology.sh" : "unraid.sh";
const defaultName =
enrollPlatform === "synology" ? "Synology-Node-01" : "Unraid-Node-01";
const series = seriesPath.trim()
? ` \\\n --series='${seriesPath.trim()}'`
: "";
const mediaRoot =
enrollPlatform === "synology"
? ` \\\n --media-root='/volume1'`
: "";
return `curl -fsSL ${MASTER_URL}/install/${script} | bash -s -- \\
--token='${enrollToken}' \\
--name='${enrollName || defaultName}' \\
--public-url='${publicUrl.trim()}' \\
--movies='${moviesPath.trim()}'${series}${mediaRoot}`;
}
async function copyInstall() {
await navigator.clipboard.writeText(installCommand());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
async function rescanNode(id: string) {
const res = await fetch(`/api/v1/admin/nodes/${id}/rescan`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const data = await res.json();
alert(data.error?.message ?? "Rescan mislukt");
return;
}
alert("Rescan aangevraagd");
}
async function resetLibraryNode(id: string, name: string) {
if (
!confirm(
`Bibliotheek van “${name}” volledig wissen uit de database en opnieuw scannen?\n\n` +
`Alle films/series-bestanden van deze node verdwijnen uit Master. ` +
`Titels die alleen op deze node stonden worden opgeruimd. Daarna start een volle rescan.\n\n` +
`Dit kan niet ongedaan worden gemaakt.`
)
) {
return;
}
if (!confirm(`Laatste check: echt resetten voor “${name}”?`)) return;
const res = await fetch(`/api/v1/admin/nodes/${id}/reset-library`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
alert(data.error?.message ?? "Reset mislukt");
return;
}
alert(
data.message ??
`Klaar: ${data.filesRemoved ?? 0} files, ${data.moviesRemoved ?? 0} films, ${data.seriesRemoved ?? 0} series verwijderd. Rescan gestart.`
);
loadNodes();
}
async function restartNode(id: string) {
if (!confirm("Node herstarten? De container komt terug via restart-policy.")) return;
const res = await fetch(`/api/v1/admin/nodes/${id}/restart`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const data = await res.json();
alert(data.error?.message ?? "Herstart mislukt");
return;
}
alert("Herstart aangevraagd");
}
async function upgradeNode(id: string, name: string) {
if (!confirm(`Node "${name}" upgraden naar ${targetNodeVersion}?`)) return;
setUpgradingId(id);
try {
const res = await fetch(`/api/v1/admin/nodes/${id}/upgrade`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (data.manualRequired) {
await navigator.clipboard.writeText(data.command);
alert(
`${data.message ?? "Handmatige upgrade nodig."}\n\nCommando gekopieerd — plak in SSH op de ${data.platform === "synology" ? "Synology" : "Unraid"}-host:\n\n${data.command}`
);
return;
}
if (!res.ok) {
alert(data.error?.message ?? "Upgrade mislukt");
return;
}
alert(data.message ?? `Upgrade naar ${targetNodeVersion} gestart.`);
loadNodes();
} finally {
setUpgradingId(null);
}
}
async function revokeNode(id: string) {
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
method: "POST",
credentials: "include",
});
loadNodes();
}
async function deleteNode(id: string, name: string) {
if (
!confirm(
`Node "${name}" definitief verwijderen?\n\nAlle bestanden van deze node verdwijnen uit de catalogus. Dit kan niet ongedaan worden gemaakt.`
)
) {
return;
}
const res = await fetch(`/api/v1/admin/nodes/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Verwijderen mislukt");
return;
}
loadNodes();
}
async function disableNode(id: string) {
if (!confirm("Node tijdelijk uitschakelen?")) return;
await fetch(`/api/v1/admin/nodes/${id}/disable`, {
method: "POST",
credentials: "include",
});
loadNodes();
}
function openEdit(node: Node) {
setEditId(node.id);
setEditName(node.name);
setEditLocation(node.location ?? "");
setEditPublicUrl(node.publicStreamUrl);
setEditScanInterval(node.fullScanInterval ?? "off");
setEditScanAt(node.fullScanAt ?? "03:30");
if (node.scanRoots?.length) {
setEditRoots(node.scanRoots.map((r) => ({ path: r.path, shelfId: r.shelfId })));
} else {
const defaults: ScanRootRow[] = [];
const filmShelf = shelves.find((s) => s.slug === "films")?.id ?? "";
const seriesShelf = shelves.find((s) => s.slug === "series")?.id ?? "";
for (const p of node.moviesPaths ?? []) defaults.push({ path: p, shelfId: filmShelf });
for (const p of node.seriesPaths ?? []) defaults.push({ path: p, shelfId: seriesShelf });
setEditRoots(defaults.length ? defaults : [{ path: "", shelfId: filmShelf }]);
}
}
async function saveEdit() {
if (!editId) return;
setEditBusy(true);
try {
const scanRoots = editRoots
.map((r) => ({ path: r.path.trim(), shelfId: r.shelfId }))
.filter((r) => r.path && r.shelfId);
const res = await fetch(`/api/v1/admin/nodes/${editId}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editName,
location: editLocation,
publicStreamUrl: editPublicUrl.trim().replace(/\/$/, ""),
fullScanInterval: editScanInterval.trim() || "off",
fullScanAt: editScanAt.trim() || "03:30",
scanRoots,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Opslaan mislukt");
return;
}
setEditId(null);
loadNodes();
} finally {
setEditBusy(false);
}
}
const defaultMovieShelf = shelves.find((s) => s.slug === "films")?.id ?? shelves.find((s) => s.kind === "MOVIE")?.id ?? "";
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Infrastructure</div>
<h1>Nodes</h1>
</div>
<button onClick={() => setShowEnroll(!showEnroll)}>
{showEnroll ? "Annuleren" : "Node toevoegen"}
</button>
</div>
{showEnroll && (
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Node toevoegen
</h2>
<div className="form-group">
<label>Platform</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
type="button"
onClick={() => setPlatform("unraid")}
style={{
opacity: enrollPlatform === "unraid" ? 1 : 0.55,
outline: enrollPlatform === "unraid" ? "2px solid #71717a" : undefined,
}}
>
Unraid
</button>
<button
type="button"
onClick={() => setPlatform("synology")}
style={{
opacity: enrollPlatform === "synology" ? 1 : 0.55,
outline: enrollPlatform === "synology" ? "2px solid #71717a" : undefined,
}}
>
Synology
</button>
</div>
</div>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.9rem" }}>
{enrollPlatform === "synology" ? (
<>
SSH + Container Manager. Brede mount <code>/volume1</code> — daarna scanpaden via Edit.
Meerdere NAS&apos;en: eigen naam + publieke URL per box.
</>
) : (
<>
Eenmalig installeren met brede <code>/mnt/user</code>-mount. Daarna: planken in Instellingen,
scanpaden + plankkeuze hier onder Edit.
</>
)}
</p>
<div className="form-group">
<label>Naam</label>
<input
value={enrollName}
onChange={(e) => setEnrollName(e.target.value)}
placeholder={enrollPlatform === "synology" ? "Synology-Node-01" : "Unraid-Node-01"}
/>
</div>
<div className="form-group">
<label>Locatie</label>
<input value={enrollLocation} onChange={(e) => setEnrollLocation(e.target.value)} placeholder="Thuis" />
</div>
<div className="form-group">
<label>Publieke node-URL (NPM)</label>
<input value={publicUrl} onChange={(e) => setPublicUrl(e.target.value)} placeholder="https://node1.example.com" />
</div>
<div className="form-group">
<label>Eerste films-pad (bootstrap)</label>
<input
value={moviesPath}
onChange={(e) => setMoviesPath(e.target.value)}
placeholder={
enrollPlatform === "synology" ? "/volume1/Media/Films/Films" : "/mnt/user/movies"
}
/>
</div>
<div className="form-group">
<label>Eerste series-pad (optioneel)</label>
<input
value={seriesPath}
onChange={(e) => setSeriesPath(e.target.value)}
placeholder={
enrollPlatform === "synology" ? "/volume1/Media/Series/Series" : "/mnt/user/series"
}
/>
</div>
<button onClick={createEnrollmentToken}>Token genereren</button>
{enrollToken && (
<>
<div className="token-display" style={{ marginTop: "1rem" }}>{enrollToken}</div>
<h2 style={{ fontSize: "0.95rem", color: "#fafafa", textTransform: "none", margin: "1.25rem 0 0.5rem" }}>
{enrollPlatform === "synology" ? "Synology SSH" : "Unraid-terminal"}
</h2>
<pre className="token-display" style={{ whiteSpace: "pre-wrap", fontSize: "0.8rem" }}>
{installCommand()}
</pre>
<button onClick={copyInstall} style={{ marginTop: "0.75rem" }}>
{copied ? "Gekopieerd" : "Kopieer install-commando"}
</button>
</>
)}
</div>
)}
{editId && (
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Node bewerken
</h2>
<div className="form-group">
<label>Naam</label>
<input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div className="form-group">
<label>Locatie</label>
<input value={editLocation} onChange={(e) => setEditLocation(e.target.value)} />
</div>
<div className="form-group">
<label>Publieke stream-URL (NPM)</label>
<input value={editPublicUrl} onChange={(e) => setEditPublicUrl(e.target.value)} />
</div>
<h3 style={{ fontSize: "0.95rem", color: "#e4e4e7", margin: "1.25rem 0 0.75rem" }}>
Scanpaden → Stremio-plank
</h3>
<p style={{ color: "#a1a1aa", fontSize: "0.8rem", marginBottom: "0.75rem" }}>
Planken beheer je onder Instellingen. Zelfde plank op meerdere nodes = één Stremio-lijst.
</p>
{editRoots.map((row, idx) => (
<div
key={idx}
style={{
display: "grid",
gridTemplateColumns: "1fr 180px auto",
gap: "0.5rem",
marginBottom: "0.5rem",
alignItems: "center",
}}
>
<input
value={row.path}
onChange={(e) => {
const next = [...editRoots];
next[idx] = { ...next[idx], path: e.target.value };
setEditRoots(next);
}}
placeholder="/mnt/user/Kids Films"
style={{ marginBottom: 0 }}
/>
<select
value={row.shelfId}
onChange={(e) => {
const next = [...editRoots];
next[idx] = { ...next[idx], shelfId: e.target.value };
setEditRoots(next);
}}
style={{
background: "#0f1117",
border: "1px solid #2a2d3a",
color: "#fafafa",
borderRadius: 8,
padding: "0.5rem 0.75rem",
}}
>
<option value="">Kies plank…</option>
{shelves.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.kind === "MOVIE" ? "films" : "series"})
</option>
))}
</select>
<button
type="button"
className="danger"
onClick={() => setEditRoots(editRoots.filter((_, i) => i !== idx))}
>
×
</button>
</div>
))}
<button
type="button"
onClick={() => setEditRoots([...editRoots, { path: "", shelfId: defaultMovieShelf }])}
style={{ background: "#374151", marginBottom: "1rem" }}
>
Pad toevoegen
</button>
<div className="form-group">
<label>Nachtelijke full scan (HH:MM of off)</label>
<input value={editScanAt} onChange={(e) => setEditScanAt(e.target.value)} placeholder="03:30" />
</div>
<div className="form-group">
<label>Extra interval-scan (off / 6h / 24h)</label>
<input value={editScanInterval} onChange={(e) => setEditScanInterval(e.target.value)} placeholder="off" />
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button onClick={saveEdit} disabled={editBusy}>
{editBusy ? "Bezig…" : "Opslaan & toepassen"}
</button>
<button onClick={() => restartNode(editId)} style={{ background: "#0e7490" }}>
Herstart
</button>
<button onClick={() => setEditId(null)} style={{ background: "#374151" }}>
Annuleren
</button>
</div>
</div>
)}
<div className="card">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Locatie</th>
<th>Status</th>
<th>Scan</th>
<th>Paden</th>
<th>Bestanden</th>
<th>Streams</th>
<th>Versie</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.id}>
<td>
<div>{node.name}</div>
<div style={{ fontSize: "0.75rem", color: "#71717a" }}>{node.publicStreamUrl}</div>
</td>
<td>{node.location ?? "-"}</td>
<td>
<span className={`status ${node.status.toLowerCase()}`}>{node.status}</span>
</td>
<td style={{ fontSize: "0.8rem" }}>
{node.scanStatus === "scanning" ? (
<span style={{ color: "#f59e0b" }}>bezig…</span>
) : node.scanStatus === "idle" ? (
<span style={{ color: "#71717a" }}>idle</span>
) : (
<span style={{ color: "#71717a" }}>{node.scanStatus ?? "-"}</span>
)}
</td>
<td style={{ fontSize: "0.75rem", color: "#a1a1aa" }}>
{node.scanRoots?.length
? `${node.scanRoots.length} roots`
: `${(node.moviesPaths?.length ?? 0) + (node.seriesPaths?.length ?? 0)} paden`}
</td>
<td>{node.libraryFileCount}</td>
<td>{node.activeStreams}</td>
<td>
{node.version ?? "-"}
{node.version && node.version !== targetNodeVersion && (
<span style={{ color: "#f59e0b", fontSize: "0.7rem", marginLeft: 4 }}>update</span>
)}
</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button onClick={() => openEdit(node)}>Edit</button>
<button onClick={() => rescanNode(node.id)}>Rescan</button>
<button
className="danger"
onClick={() => resetLibraryNode(node.id, node.name)}
title="Wis alle content van deze node uit de DB en scan opnieuw"
>
Bibliotheek reset
</button>
{node.version !== targetNodeVersion && (
<button
onClick={() => upgradeNode(node.id, node.name)}
disabled={upgradingId === node.id}
style={{ background: "#7c3aed" }}
>
{upgradingId === node.id ? "Upgraden…" : "Upgrade"}
</button>
)}
<button onClick={() => restartNode(node.id)} style={{ background: "#0e7490" }}>
Herstart
</button>
{!node.revoked && (
<>
<button onClick={() => disableNode(node.id)} style={{ background: "#b45309" }}>
Disable
</button>
<button className="danger" onClick={() => revokeNode(node.id)}>
Revoke
</button>
</>
)}
<button className="danger" onClick={() => deleteNode(node.id, node.name)}>
Verwijderen
</button>
</td>
</tr>
))}
{nodes.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: "center", color: "#71717a" }}>
Geen nodes gevonden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}