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) { 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) { 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 { 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 (
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" />
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" />
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" />
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" />
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" />
); } function NameChangesReview({ nameChanges, onDone, }: { nameChanges: ScanNameChange[]; onDone: () => void; }) { const queryClient = useQueryClient(); const [handled, setHandled] = useState>(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 (

{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:

    {remaining.map((c) => (
  • {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
  • ))}
); } 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 (
Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
); } function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) { const queryClient = useQueryClient(); const [handled, setHandled] = useState>(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 (

{remaining.length} Dienst(e) beim letzten Scan nicht mehr gefunden (Port nicht mehr offen):

    {remaining.map((s) => (
  • {s.displayName} ({s.hostname}:{s.port})
  • ))}
); } function DeviceRow({ device }: { device: DeviceWithServices }) { const queryClient = useQueryClient(); const [scanMessage, setScanMessage] = useState(null); const [staleServices, setStaleServices] = useState([]); const [nameChanges, setNameChanges] = useState([]); const [deviceNameSuggestion, setDeviceNameSuggestion] = useState(null); const [expanded, setExpanded] = useState(false); const [editing, setEditing] = useState(false); const [scanAbort, setScanAbort] = useState(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 setEditing(false)} />; } return ( <> {device.ip} {device.mac ?? "–"} {device.manufacturer ?? "–"} {device.model ?? "–"} {device.online ? "Online" : "Offline"}
{(() => { // "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")}`; })()}
{device.services.length}
{scanMutation.isPending && scanAbort ? ( ) : null}
{(scanMessage || staleServices.length > 0 || nameChanges.length > 0 || deviceNameSuggestion) && ( {scanMessage ? (

{scanMessage}

) : null} {deviceNameSuggestion ? ( setDeviceNameSuggestion(null)} /> ) : null} {nameChanges.length > 0 ? ( setNameChanges([])} /> ) : null} {staleServices.length > 0 ? ( setStaleServices([])} /> ) : null} )} {expanded && ( {device.services.length === 0 ? (

Keine Dienste auf diesem Gerät.

) : (
{device.services.map((s) => ( {s.displayName} :{s.port} ))}
)} )} ); } 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 (
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" />
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" />
{mutation.isError ? ( {(mutation.error as Error).message} ) : null}
); } 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 ( ); } export function DevicesPage() { const { data: devices, isLoading, isError } = useDevices(); const [sortColumn, setSortColumn] = useState(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 (
{isLoading ? (

Lade Geräte …

) : isError ? (

Geräte konnten nicht geladen werden.

) : list.length > 0 ? (
{list.map((device) => ( ))}
) : (

Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan unter „Scanner“.

)}
); }