generated from Dicken/dickendock
690 lines
27 KiB
TypeScript
690 lines
27 KiB
TypeScript
import { useMemo, useState, type FormEvent } from "react";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons";
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { Button, Favicon } from "@launchpad/ui";
|
||
import type { Service } from "@launchpad/shared";
|
||
import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js";
|
||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||
|
||
async function createDevice(input: { hostname: string; ip: string }) {
|
||
const res = await fetch("/api/devices", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
throw new Error(body.error ?? `Gerät konnte nicht angelegt werden (HTTP ${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function patchDevice(id: string, patch: Record<string, unknown>) {
|
||
const res = await fetch(`/api/devices/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Gerät konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function deleteDevice(id: string) {
|
||
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
|
||
if (!res.ok && res.status !== 404) {
|
||
throw new Error(`Gerät konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||
}
|
||
}
|
||
|
||
async function deleteServiceRequest(id: string) {
|
||
const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
|
||
if (!res.ok && res.status !== 404) {
|
||
throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||
}
|
||
}
|
||
|
||
async function patchService(id: string, patch: Record<string, unknown>) {
|
||
const res = await fetch(`/api/services/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
interface ScanNameChange {
|
||
serviceId: string;
|
||
serviceHostname: string;
|
||
servicePort: number;
|
||
field: "displayName" | "category";
|
||
current: string | null;
|
||
suggested: string;
|
||
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde. */
|
||
manualOverride: boolean;
|
||
}
|
||
|
||
interface ScanResult {
|
||
scannedPorts: number;
|
||
ports: number[];
|
||
created: number;
|
||
updated: number;
|
||
staleServices: Service[];
|
||
nameChanges: ScanNameChange[];
|
||
deviceNameSuggestion: string | null;
|
||
}
|
||
|
||
async function scanDevice(id: string, signal?: AbortSignal, full = false): Promise<ScanResult> {
|
||
const res = await fetch(`/api/scan/devices/${id}${full ? "?full=true" : ""}`, { method: "POST", signal });
|
||
const body = await res.json();
|
||
if (!res.ok) {
|
||
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone: () => void }) {
|
||
const queryClient = useQueryClient();
|
||
const [hostname, setHostname] = useState(device.hostname);
|
||
const [ip, setIp] = useState(device.ip);
|
||
const [mac, setMac] = useState(device.mac ?? "");
|
||
const [manufacturer, setManufacturer] = useState(device.manufacturer ?? "");
|
||
const [model, setModel] = useState(device.model ?? "");
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: () =>
|
||
patchDevice(device.id, {
|
||
hostname: hostname.trim(),
|
||
ip: ip.trim(),
|
||
mac: mac.trim() || undefined,
|
||
manufacturer: manufacturer.trim() || undefined,
|
||
model: model.trim() || undefined,
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||
onDone();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||
<td colSpan={8} className="px-4 py-3">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Hostname</label>
|
||
<input
|
||
value={hostname}
|
||
onChange={(e) => setHostname(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">IP</label>
|
||
<input
|
||
value={ip}
|
||
onChange={(e) => setIp(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
MAC-Adresse
|
||
</label>
|
||
<input
|
||
value={mac}
|
||
onChange={(e) => setMac(e.target.value)}
|
||
placeholder="AA:BB:CC:DD:EE:FF"
|
||
className="w-40 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Hersteller</label>
|
||
<input
|
||
value={manufacturer}
|
||
onChange={(e) => setManufacturer(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Modell</label>
|
||
<input
|
||
value={model}
|
||
onChange={(e) => setModel(e.target.value)}
|
||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
|
||
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function NameChangesReview({
|
||
nameChanges,
|
||
onDone,
|
||
}: {
|
||
nameChanges: ScanNameChange[];
|
||
onDone: () => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||
|
||
const applyMutation = useMutation({
|
||
mutationFn: (change: ScanNameChange) => {
|
||
const resetFlag = change.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
|
||
return patchService(change.serviceId, { [change.field]: change.suggested, [resetFlag]: false });
|
||
},
|
||
onSuccess: (_data, change) => {
|
||
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||
},
|
||
});
|
||
|
||
const remaining = nameChanges.filter(
|
||
(c) => !handled.has(`${c.serviceId}:${c.field}`)
|
||
);
|
||
if (remaining.length === 0) return null;
|
||
|
||
const fieldLabel = (field: ScanNameChange["field"]) =>
|
||
field === "displayName" ? "Name" : "Kategorie";
|
||
|
||
return (
|
||
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||
{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||
</p>
|
||
<ul className="space-y-1">
|
||
{remaining.map((c) => (
|
||
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||
<span className="text-black/70 dark:text-white/70">
|
||
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
||
title="Behalten (Vorschlag verwerfen)"
|
||
aria-label="Behalten (Vorschlag verwerfen)"
|
||
><FontAwesomeIcon icon={faBan} /></Button>
|
||
<Button
|
||
size="icon"
|
||
variant="primary"
|
||
onClick={() => applyMutation.mutate(c)}
|
||
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||
Hinweis schließen
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DeviceNameSuggestionBanner({
|
||
deviceId,
|
||
suggestion,
|
||
onDone,
|
||
}: {
|
||
deviceId: string;
|
||
suggestion: string;
|
||
onDone: () => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
|
||
const applyMutation = useMutation({
|
||
mutationFn: () => patchDevice(deviceId, { hostname: suggestion }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||
onDone();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-black/70 dark:text-white/70">
|
||
Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
|
||
<Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) {
|
||
const queryClient = useQueryClient();
|
||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteServiceRequest(id),
|
||
onSuccess: (_data, id) => {
|
||
setHandled((prev) => new Set(prev).add(id));
|
||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||
},
|
||
});
|
||
|
||
const remaining = staleServices.filter((s) => !handled.has(s.id));
|
||
if (remaining.length === 0) return null;
|
||
|
||
return (
|
||
<div className="mt-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
||
{remaining.length} Dienst(e) beim letzten Scan nicht mehr gefunden (Port nicht mehr offen):
|
||
</p>
|
||
<ul className="space-y-1">
|
||
{remaining.map((s) => (
|
||
<li key={s.id} className="flex items-center justify-between gap-2">
|
||
<span className="text-black/70 dark:text-white/70">
|
||
{s.displayName} ({s.hostname}:{s.port})
|
||
</span>
|
||
<div className="flex gap-1">
|
||
<Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
|
||
<Button
|
||
size="icon"
|
||
variant="danger"
|
||
onClick={() => deleteMutation.mutate(s.id)}
|
||
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||
Hinweis schließen
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DeviceRow({ device }: { device: DeviceWithServices }) {
|
||
const queryClient = useQueryClient();
|
||
const [scanMessage, setScanMessage] = useState<string | null>(null);
|
||
const [staleServices, setStaleServices] = useState<Service[]>([]);
|
||
const [nameChanges, setNameChanges] = useState<ScanNameChange[]>([]);
|
||
const [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null);
|
||
const [expanded, setExpanded] = useState(false);
|
||
const [editing, setEditing] = useState(false);
|
||
const [scanAbort, setScanAbort] = useState<AbortController | null>(null);
|
||
|
||
const scanMutation = useMutation({
|
||
mutationFn: (full: boolean) => {
|
||
const controller = new AbortController();
|
||
setScanAbort(controller);
|
||
return scanDevice(device.id, controller.signal, full);
|
||
},
|
||
onSuccess: (result) => {
|
||
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
|
||
const pending = result.nameChanges.length + result.staleServices.length;
|
||
setScanMessage(
|
||
`Ports offen: ${portsText} · ${result.created} neu` +
|
||
(pending > 0 ? ` · ${pending} Änderung(en) warten auf Bestätigung` : "")
|
||
);
|
||
setStaleServices(result.staleServices);
|
||
setNameChanges(result.nameChanges);
|
||
setDeviceNameSuggestion(result.deviceNameSuggestion);
|
||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||
// War bisher vergessen: der Scan selbst schreibt zwar schon einen
|
||
// Log-Eintrag (siehe performDeviceScan in routes/scan.ts, gilt für
|
||
// Einzelgerät- UND Sammel-Scan gleichermaßen), aber ohne diese
|
||
// Invalidierung blieb eine bereits geöffnete Admin -> Logs-Seite auf
|
||
// dem alten Stand, bis man sie manuell neu lädt (siehe Bugreport).
|
||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||
},
|
||
onError: (err: Error) => {
|
||
setScanMessage(err.name === "AbortError" ? "Scan abgebrochen." : err.message);
|
||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||
},
|
||
onSettled: () => setScanAbort(null),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: () => deleteDevice(device.id),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
|
||
});
|
||
|
||
if (editing) {
|
||
return <EditDeviceForm device={device} onDone={() => setEditing(false)} />;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
|
||
<td className="px-4 py-3">
|
||
<button
|
||
onClick={() => setExpanded((e) => !e)}
|
||
className="flex items-center gap-1.5 text-left"
|
||
title={expanded ? "Dienste ausblenden" : "Dienste anzeigen"}
|
||
>
|
||
<span className="text-black/30 dark:text-white/30">{expanded ? "▾" : "▸"}</span>
|
||
<span className="font-medium text-black dark:text-white">{device.hostname}</span>
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{device.ip}</td>
|
||
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
||
{device.mac ?? "–"}
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.manufacturer ?? "–"}</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.model ?? "–"}</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 text-xs ${
|
||
device.online ? "text-emerald-600 dark:text-emerald-400" : "text-black/40 dark:text-white/40"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`h-1.5 w-1.5 rounded-full ${device.online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
|
||
/>
|
||
{device.online ? "Online" : "Offline"}
|
||
</span>
|
||
<div className="text-[10px] text-black/30 dark:text-white/30">
|
||
{(() => {
|
||
// "Zuletzt gesehen" soll den neueren der beiden Zeitpunkte
|
||
// zeigen - ein echter Scan (lastScan) oder ein reiner
|
||
// Ping-Live-Status-Check (lastPing, siehe Einstellungen) -
|
||
// sonst wirkt es inkonsistent, wenn der Online-Punkt frisch
|
||
// vom Ping aktualisiert wurde, der Zeitstempel aber noch den
|
||
// alten Scan zeigt.
|
||
const scanTime = device.lastScan ? new Date(device.lastScan).getTime() : 0;
|
||
const pingTime = device.lastPing ? new Date(device.lastPing).getTime() : 0;
|
||
const latest = Math.max(scanTime, pingTime);
|
||
if (latest === 0) return "nie gescannt";
|
||
const label = pingTime > scanTime ? "zuletzt gesehen (Ping)" : "zuletzt gesehen";
|
||
return `${label}: ${new Date(latest).toLocaleString("de-DE")}`;
|
||
})()}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
setScanMessage("Portscan läuft (alle 65535 Ports) …");
|
||
scanMutation.mutate(true);
|
||
}}
|
||
disabled={scanMutation.isPending}
|
||
title="Prüft alle 65535 Ports (nicht nur die üblichen) - dauert dadurch ein paar Sekunden länger, findet dafür auch Dienste auf unüblichen Ports"
|
||
>
|
||
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
||
</Button>
|
||
{scanMutation.isPending && scanAbort ? (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
onClick={() => scanAbort.abort()}
|
||
title="Scan abbrechen"
|
||
aria-label="Scan abbrechen"
|
||
>
|
||
<FontAwesomeIcon icon={faXmark} />
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
size="icon"
|
||
variant="danger"
|
||
onClick={() => deleteMutation.mutate()}
|
||
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
{(scanMessage || staleServices.length > 0 || nameChanges.length > 0 || deviceNameSuggestion) && (
|
||
<tr className="border-b border-black/5 dark:border-white/5">
|
||
<td colSpan={8} className="px-4 pb-3">
|
||
{scanMessage ? (
|
||
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
|
||
) : null}
|
||
{deviceNameSuggestion ? (
|
||
<DeviceNameSuggestionBanner
|
||
deviceId={device.id}
|
||
suggestion={deviceNameSuggestion}
|
||
onDone={() => setDeviceNameSuggestion(null)}
|
||
/>
|
||
) : null}
|
||
{nameChanges.length > 0 ? (
|
||
<NameChangesReview nameChanges={nameChanges} onDone={() => setNameChanges([])} />
|
||
) : null}
|
||
{staleServices.length > 0 ? (
|
||
<StaleServicesReview staleServices={staleServices} onDone={() => setStaleServices([])} />
|
||
) : null}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
|
||
{expanded && (
|
||
<tr className="border-b border-black/5 bg-black/[0.015] dark:border-white/5 dark:bg-white/[0.02]">
|
||
<td colSpan={8} className="px-4 py-3">
|
||
{device.services.length === 0 ? (
|
||
<p className="text-xs text-black/40 dark:text-white/40">Keine Dienste auf diesem Gerät.</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{device.services.map((s) => (
|
||
<a
|
||
key={s.id}
|
||
href={s.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/60
|
||
px-2.5 py-1 text-xs text-black/70 hover:bg-black/5 dark:border-white/10
|
||
dark:bg-white/5 dark:text-white/70 dark:hover:bg-white/10"
|
||
>
|
||
<Favicon src={s.favicon} fallbackLetter={s.displayName} size="sm" />
|
||
{s.displayName}
|
||
<span className="text-black/30 dark:text-white/30">:{s.port}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AddDeviceForm() {
|
||
const queryClient = useQueryClient();
|
||
const [hostname, setHostname] = useState("");
|
||
const [ip, setIp] = useState("");
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: createDevice,
|
||
onSuccess: () => {
|
||
setHostname("");
|
||
setIp("");
|
||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||
},
|
||
});
|
||
|
||
function handleSubmit(e: FormEvent) {
|
||
e.preventDefault();
|
||
if (!hostname.trim() || !ip.trim()) return;
|
||
mutation.mutate({ hostname: hostname.trim(), ip: ip.trim() });
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-2">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||
Hostname
|
||
</label>
|
||
<input
|
||
value={hostname}
|
||
onChange={(e) => setHostname(e.target.value)}
|
||
placeholder="z. B. synology"
|
||
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||
text-black outline-none focus:border-black/30 dark:border-white/10
|
||
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||
IP-Adresse
|
||
</label>
|
||
<input
|
||
value={ip}
|
||
onChange={(e) => setIp(e.target.value)}
|
||
placeholder="192.168.1.10"
|
||
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||
text-black outline-none focus:border-black/30 dark:border-white/10
|
||
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||
/>
|
||
</div>
|
||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||
Gerät hinzufügen
|
||
</Button>
|
||
{mutation.isError ? (
|
||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||
) : null}
|
||
</form>
|
||
);
|
||
}
|
||
|
||
type SortColumn = "hostname" | "ip" | "mac" | "manufacturer" | "model" | "online" | "services" | null;
|
||
|
||
function SortableHeader({
|
||
label,
|
||
column,
|
||
activeColumn,
|
||
direction,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
column: SortColumn;
|
||
activeColumn: SortColumn;
|
||
direction: "asc" | "desc";
|
||
onClick: (column: SortColumn) => void;
|
||
}) {
|
||
const active = activeColumn === column;
|
||
return (
|
||
<th className="px-4 py-2 font-medium">
|
||
<button
|
||
onClick={() => onClick(column)}
|
||
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||
active ? "text-black dark:text-white" : ""
|
||
}`}
|
||
>
|
||
{label}
|
||
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
|
||
</button>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
export function DevicesPage() {
|
||
const { data: devices, isLoading, isError } = useDevices();
|
||
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||
|
||
const list = useMemo(() => {
|
||
const base = devices ?? [];
|
||
if (!sortColumn) return base;
|
||
const sorted = [...base].sort((a, b) => {
|
||
let cmp = 0;
|
||
switch (sortColumn) {
|
||
case "hostname":
|
||
cmp = a.hostname.localeCompare(b.hostname);
|
||
break;
|
||
case "ip":
|
||
cmp = a.ip.localeCompare(b.ip, undefined, { numeric: true });
|
||
break;
|
||
case "mac":
|
||
cmp = (a.mac ?? "").localeCompare(b.mac ?? "");
|
||
break;
|
||
case "manufacturer":
|
||
cmp = (a.manufacturer ?? "").localeCompare(b.manufacturer ?? "");
|
||
break;
|
||
case "model":
|
||
cmp = (a.model ?? "").localeCompare(b.model ?? "");
|
||
break;
|
||
case "online":
|
||
cmp = Number(a.online) - Number(b.online);
|
||
break;
|
||
case "services":
|
||
cmp = a.services.length - b.services.length;
|
||
break;
|
||
}
|
||
return sortDirection === "asc" ? cmp : -cmp;
|
||
});
|
||
return sorted;
|
||
}, [devices, sortColumn, sortDirection]);
|
||
|
||
function handleHeaderClick(column: SortColumn) {
|
||
if (sortColumn === column) {
|
||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||
} else {
|
||
setSortColumn(column);
|
||
setSortDirection("asc");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<AdminPageHeader
|
||
title="Geräte"
|
||
description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck. Klick auf den Gerätenamen zeigt die zugehörigen Dienste."
|
||
/>
|
||
|
||
<div className="mb-6">
|
||
<AddDeviceForm />
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p>
|
||
) : isError ? (
|
||
<p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p>
|
||
) : list.length > 0 ? (
|
||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[980px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||
<SortableHeader label="Gerät" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="MAC" column="mac" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Hersteller" column="manufacturer" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Modell" column="model" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Status" column="online" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Dienste" column="services" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{list.map((device) => (
|
||
<DeviceRow key={device.id} device={device} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-black/40 dark:text-white/40">
|
||
Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan
|
||
unter „Scanner“.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|