Queue a second full sync when one is already running so newly indexed seasons are not lost after upgrade/restart.
634 lines
23 KiB
TypeScript
634 lines
23 KiB
TypeScript
"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'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>
|
||
</>
|
||
);
|
||
}
|