import { useState, useEffect } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBan, faCheck, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { Button } from "@launchpad/ui"; import type { Device, Service } from "@launchpad/shared"; import { useDevices } from "../../hooks/useDevices.js"; import type { DetectedApiEntry } from "../../hooks/useDetectedApis.js"; import { usePersistedState } from "../../hooks/usePersistedState.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; interface DeviceChange { deviceId: string; deviceHostname: string; field: "hostname" | "ip"; current: string; suggested: string; } 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 deleteDeviceRequest(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})`); } } 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 (siehe unten). */ manualOverride: boolean; } 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(); } function nameChangeKey(c: ScanNameChange): string { return `${c.serviceId}:${c.field}`; } /** * Merkt sich (dauerhaft über sessionStorage, NICHT über React-State), welches * Backend-Scan-Ergebnis (identifiziert über seinen startedAt-Zeitstempel) * bereits in die lokale Anzeige übernommen wurde. Bewusst kein * usePersistedState/useState hier: der Poll-Callback läuft in einem * setInterval, das nur einmal erzeugt wird (leeres deps-Array) - ein * React-State-Wert wäre in diesem Callback für immer auf seinem Anfangswert * eingefroren (stale closure). Gleichzeitig darf der Wert NICHT nur im * Speicher (useRef) leben, sonst würde ein Neuladen der Seite ihn auf null * zurücksetzen und dieselbe Meldung (inkl. bereits bestätigter Einträge) * einfach erneut aus dem immer noch unveränderten Backend-Ergebnis * importieren - genau das gemeldete Problem. */ function getHandledResultId(key: string): string | null { try { return sessionStorage.getItem(`launchpad:${key}`); } catch { return null; } } function setHandledResultId(key: string, value: string): void { try { sessionStorage.setItem(`launchpad:${key}`, value); } catch { // Speicher evtl. nicht verfügbar (Privatmodus o. ä.) - dann greift die // Deduplizierung für die aktuelle Sitzung eben nicht, kein Absturz nötig. } } /** * Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei * explizit die *EditedManually-Flag auf false zurück, damit der Dienst * danach wieder als "vom Scan" statt "manuell angepasst" gilt. */ function acceptNameChange(c: ScanNameChange) { const resetFlag = c.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually"; return patchService(c.serviceId, { [c.field]: c.suggested, [resetFlag]: false }); } function NameChangesReview({ nameChanges, onResolve, }: { nameChanges: ScanNameChange[]; onResolve: (key: string) => void; }) { const queryClient = useQueryClient(); const applyMutation = useMutation({ mutationFn: acceptNameChange, onSuccess: (_data, change) => { onResolve(nameChangeKey(change)); queryClient.invalidateQueries({ queryKey: ["services"] }); }, }); if (nameChanges.length === 0) return null; const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie"); return (

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

    {nameChanges.map((c) => (
  • {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „ {c.suggested}"
  • ))}
); } function NewServicesList({ newServices, onResolve, }: { newServices: Service[]; onResolve: (id: string) => void; }) { if (newServices.length === 0) return null; return (

{newServices.length} neue(r) Dienst(e) gefunden:

    {newServices.map((s) => (
  • {s.displayName} ({s.hostname}:{s.port})
  • ))}
); } function StaleServicesReview({ staleServices, onResolve, }: { staleServices: Service[]; onResolve: (id: string) => void; }) { const queryClient = useQueryClient(); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteServiceRequest(id), onSuccess: (_data, id) => { onResolve(id); queryClient.invalidateQueries({ queryKey: ["services"] }); }, }); if (staleServices.length === 0) return null; return (

{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port nicht mehr offen):

    {staleServices.map((s) => (
  • {s.displayName} ({s.hostname}:{s.port})
  • ))}
); } function DeviceChangesReview({ deviceChanges, onResolve, }: { deviceChanges: DeviceChange[]; onResolve: (key: string) => void; }) { const queryClient = useQueryClient(); const applyMutation = useMutation({ mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }), onSuccess: (_data, change) => { onResolve(`${change.deviceId}:${change.field}`); queryClient.invalidateQueries({ queryKey: ["devices"] }); }, }); if (deviceChanges.length === 0) return null; const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP"); return (

{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:

    {deviceChanges.map((c) => (
  • {c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
  • ))}
); } function StaleDevicesReview({ staleDevices, onResolve, }: { staleDevices: Device[]; onResolve: (id: string) => void; }) { const queryClient = useQueryClient(); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteDeviceRequest(id), onSuccess: (_data, id) => { onResolve(id); queryClient.invalidateQueries({ queryKey: ["devices"] }); }, }); if (staleDevices.length === 0) return null; return (

{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht mehr:

    {staleDevices.map((d) => (
  • {d.hostname} ({d.ip})
  • ))}
); } function ApiScannerCard() { const queryClient = useQueryClient(); const [status, setStatus] = usePersistedState("scanner:apiStatus", null); const [running, setRunning] = useState(false); const [lastNewFindings, setLastNewFindings] = usePersistedState< { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[] >("scanner:apiNewFindings", []); useEffect(() => { let cancelled = false; async function poll() { try { const res = await fetch("/api/scan/apis/status"); if (!res.ok || cancelled) return; const s = await res.json(); setRunning(s.running); if (s.running && s.progress) { setStatus( `Prüfe ${s.progress.currentLabel ?? "…"} (${s.progress.current + 1} von ${s.progress.total})` ); } else if (s.lastResult && s.startedAt !== getHandledResultId("scanner:apiLastHandledAt")) { setHandledResultId("scanner:apiLastHandledAt", s.startedAt); const r = s.lastResult; const newCount = r.newFindings.reduce( (sum: number, f: { apis: unknown[] }) => sum + f.apis.length, 0 ); setStatus( `Fertig: ${r.checked} Dienst(e) geprüft, ${r.totalFound} API-Endpunkt(e) insgesamt bei ${r.servicesWithApi} Dienst(en)` + (newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.") ); setLastNewFindings(r.newFindings); queryClient.invalidateQueries({ queryKey: ["detected-apis"] }); } else if (s.lastError) { setStatus(`Fehlgeschlagen: ${s.lastError}`); } } catch { // Netzwerk-Hänger beim Poll soll nicht die Seite stören. } } poll(); const interval = setInterval(poll, 1500); return () => { cancelled = true; clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function startScan() { setStatus("Scan gestartet …"); const res = await fetch("/api/scan/apis", { method: "POST" }); if (res.status === 409) { setStatus("Es läuft bereits ein API-Scan (evtl. von einem anderen Gerät gestartet)."); } setRunning(true); } return (

API-Scanner

Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade (OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet neue/geänderte Funde hier auf. Läuft im Hintergrund (auch bei vielen Diensten kein Zeitüberschreitungsfehler mehr) und nie zusammen mit dem normalen Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "} Admin → APIs .

{status ?

{status}

: null} {lastNewFindings.length > 0 ? ( <>
    {lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
  • {serviceName}
      {entries.map((e) => (
    • {e.type} · {e.path}
    • ))}
  • ))}
) : null}
); } export function ScannerPage() { const queryClient = useQueryClient(); const { data: devices } = useDevices(); const [bulkStatus, setBulkStatus] = usePersistedState("scanner:bulkStatus", null); const [bulkRunning, setBulkRunning] = useState(false); const [bulkStaleServices, setBulkStaleServices] = usePersistedState( "scanner:bulkStaleServices", [] ); const [bulkNewServices, setBulkNewServices] = usePersistedState( "scanner:bulkNewServices", [] ); const [bulkNameChanges, setBulkNameChanges] = usePersistedState( "scanner:bulkNameChanges", [] ); const [staleDevices, setStaleDevices] = usePersistedState("scanner:staleDevices", []); const [deviceChanges, setDeviceChanges] = usePersistedState( "scanner:deviceChanges", [] ); const [fritzboxSummary, setFritzboxSummary] = usePersistedState< { found: number; uniqueDevices: number } | null >("scanner:fritzboxSummary", null); const [fritzboxError, setFritzboxError] = usePersistedState( "scanner:fritzboxError", null ); const [fritzboxRunning, setFritzboxRunning] = useState(false); useEffect(() => { let cancelled = false; async function poll() { try { const res = await fetch("/api/scan/fritzbox/status"); if (!res.ok || cancelled) return; const s = await res.json(); setFritzboxRunning(s.running); if (!s.running && s.lastResult && s.startedAt !== getHandledResultId("scanner:fritzboxLastHandledAt")) { setHandledResultId("scanner:fritzboxLastHandledAt", s.startedAt); setFritzboxSummary({ found: s.lastResult.found, uniqueDevices: s.lastResult.uniqueDevices }); setFritzboxError(null); setStaleDevices(s.lastResult.staleDevices); setDeviceChanges(s.lastResult.deviceChanges); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["logs"] }); } else if (!s.running && s.lastError && s.startedAt !== getHandledResultId("scanner:fritzboxLastHandledAt")) { setHandledResultId("scanner:fritzboxLastHandledAt", s.startedAt); setFritzboxError(s.lastError); } } catch { // Netzwerk-Hänger beim Poll soll nicht die Seite stören. } } poll(); const interval = setInterval(poll, 1500); return () => { cancelled = true; clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function startFritzboxScan() { setFritzboxError(null); const res = await fetch("/api/scan/fritzbox", { method: "POST" }); if (!res.ok) { const body = await res.json().catch(() => ({})); setFritzboxError(body.error ?? `Fehlgeschlagen (HTTP ${res.status})`); return; } setFritzboxRunning(true); } // Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend // (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus // Einzelaufrufen im Browser. Dieser Poll-Mechanismus läuft IMMER, solange // die Seite offen ist - egal ob DIESER Browser den Scan gestartet hat oder // ein anderes Gerät: der Fortschritt/das letzte Ergebnis kommt direkt vom // Backend, ist also für jeden sichtbar, der die Seite gerade offen hat. useEffect(() => { let cancelled = false; async function poll() { try { const res = await fetch("/api/scan/devices/all/status"); if (!res.ok || cancelled) return; const status = await res.json(); setBulkRunning(status.running); if (status.running && status.progress) { setBulkStatus( `Scanne ${status.progress.currentLabel ?? "…"} (${status.progress.current + 1} von ${status.progress.total})` ); } else if (status.lastResult && status.startedAt !== getHandledResultId("scanner:bulkLastHandledAt")) { setHandledResultId("scanner:bulkLastHandledAt", status.startedAt); const r = status.lastResult; const pending = r.nameChanges.length + r.staleServices.length; setBulkStatus( (r.cancelled ? `Abgebrochen nach ${r.scannedCount} von ${r.totalDevices} Gerät(en). ` : `Fertig: ${r.totalDevices} Gerät(e) gescannt. `) + `${r.created} neue(r) Dienst(e) gefunden.` + (pending > 0 ? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.` : " Keine Änderungen an bestehenden Diensten vorgeschlagen.") ); setBulkStaleServices(r.staleServices); setBulkNameChanges(r.nameChanges); setBulkNewServices(r.newServices); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["logs"] }); } } catch { // Netzwerk-Hänger beim Poll soll nicht die Seite stören - nächster // Versuch folgt automatisch. } } poll(); const interval = setInterval(poll, 1500); return () => { cancelled = true; clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function scanAllDevices() { if (!devices || devices.length === 0) return; const res = await fetch("/api/scan/devices/all", { method: "POST" }); if (res.status === 409) { setBulkStatus("Es läuft bereits ein Scan (evtl. von einem anderen Gerät gestartet)."); setBulkRunning(true); return; } setBulkRunning(true); setBulkStatus("Scan gestartet …"); } async function cancelBulkScan() { await fetch("/api/scan/devices/all/cancel", { method: "POST" }); } return (

FritzBox-Scan

Liest die Geräteliste der FritzBox per TR-064 und legt/aktualisiert Geräte. Erfordert FRITZBOX_HOST,{" "} FRITZBOX_USERNAME und{" "} FRITZBOX_PASSWORD in der .env.

{fritzboxSummary ? (

{fritzboxSummary.found} Eintrag/Einträge von der FritzBox,{" "} {fritzboxSummary.uniqueDevices} eindeutige Geräte {fritzboxSummary.found !== fritzboxSummary.uniqueDevices ? " (mehrere Einträge desselben Geräts wurden zusammengeführt)" : ""} .

) : null} {fritzboxError ?

{fritzboxError}

: null} setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))} /> setStaleDevices((prev) => prev.filter((d) => d.id !== id))} />

Alle Geräte scannen

Führt einen vollständigen Portscan (alle 65535 Ports, nicht nur die üblichen) samt DNS/Titel/Favicon/Softwareerkennung nacheinander für alle {devices?.length ?? 0} bekannten Geräte aus. Läuft im Hintergrund (Fortschritt siehe unten) und kann bei vielen Geräten durchaus einige Minuten dauern – dafür werden auch Dienste auf unüblichen Ports gefunden.

{bulkRunning ? ( ) : null}
{bulkStatus ? (

{bulkStatus}

) : null} setBulkNewServices((prev) => prev.filter((s) => s.id !== id))} /> setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))} /> setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))} />
); }