"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([]); const [shelves, setShelves] = useState([]); 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(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([]); const [editBusy, setEditBusy] = useState(false); const [upgradingId, setUpgradingId] = useState(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 ( <>