Files
LaunchPad/apps/frontend/src/routes/admin/DevicesPage.tsx

690 lines
27 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.
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>
);
}