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

753 lines
29 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 { 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<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 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<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();
}
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 (
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-blue-700 dark:text-blue-400">
{nameChanges.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
</p>
<button
onClick={() => nameChanges.forEach((c) => onResolve(nameChangeKey(c)))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{nameChanges.map((c) => (
<li key={nameChangeKey(c)} 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={() => onResolve(nameChangeKey(c))} 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>
</div>
);
}
function NewServicesList({
newServices,
onResolve,
}: {
newServices: Service[];
onResolve: (id: string) => void;
}) {
if (newServices.length === 0) return null;
return (
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
{newServices.length} neue(r) Dienst(e) gefunden:
</p>
<button
onClick={() => newServices.forEach((s) => onResolve(s.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{newServices.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>
<Button
size="icon"
variant="ghost"
onClick={() => onResolve(s.id)}
title="Bestätigen (aus dieser Liste entfernen)"
aria-label="Bestätigen"
>
<FontAwesomeIcon icon={faCheck} />
</Button>
</li>
))}
</ul>
</div>
);
}
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 (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-amber-700 dark:text-amber-400">
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
nicht mehr offen):
</p>
<button
onClick={() => staleServices.forEach((s) => onResolve(s.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{staleServices.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={() => onResolve(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>
</div>
);
}
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 (
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-blue-700 dark:text-blue-400">
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
</p>
<button
onClick={() => deviceChanges.forEach((c) => onResolve(`${c.deviceId}:${c.field}`))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{deviceChanges.map((c) => (
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}"
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${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>
</div>
);
}
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 (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-amber-700 dark:text-amber-400">
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
mehr:
</p>
<button
onClick={() => staleDevices.forEach((d) => onResolve(d.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="space-y-1">
{staleDevices.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{d.hostname} ({d.ip})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(d.id)}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
</ul>
</div>
);
}
function ApiScannerCard() {
const queryClient = useQueryClient();
const [status, setStatus] = usePersistedState<string | null>("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 (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
<h2 className="font-medium text-black dark:text-white">API-Scanner</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
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{" "}
<Link to="/admin/apis" className="underline">
Admin APIs
</Link>
.
</p>
<Button variant="primary" className="mt-4" onClick={startScan} disabled={running}>
{running ? "Scanne …" : "APIs jetzt scannen"}
</Button>
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
{lastNewFindings.length > 0 ? (
<>
<div className="mt-4 flex items-center justify-end">
<button
onClick={() => setLastNewFindings([])}
className="text-xs text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-72 space-y-2 overflow-y-auto text-sm">
{lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
<li key={serviceId} className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-black dark:text-white">{serviceName}</div>
<Button
size="icon"
variant="ghost"
onClick={() =>
setLastNewFindings((prev) => prev.filter((f) => f.serviceId !== serviceId))
}
title="Bestätigen (aus dieser Liste entfernen)"
aria-label="Bestätigen"
>
<FontAwesomeIcon icon={faCheck} />
</Button>
</div>
<ul className="mt-1 flex flex-wrap gap-2">
{entries.map((e) => (
<li key={e.id}>
<span className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-black/60 dark:bg-white/10 dark:text-white/60">
{e.type} · {e.path}
</span>
</li>
))}
</ul>
</li>
))}
</ul>
</>
) : null}
</div>
);
}
export function ScannerPage() {
const queryClient = useQueryClient();
const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
const [bulkRunning, setBulkRunning] = useState(false);
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
"scanner:bulkStaleServices",
[]
);
const [bulkNewServices, setBulkNewServices] = usePersistedState<Service[]>(
"scanner:bulkNewServices",
[]
);
const [bulkNameChanges, setBulkNameChanges] = usePersistedState<ScanNameChange[]>(
"scanner:bulkNameChanges",
[]
);
const [staleDevices, setStaleDevices] = usePersistedState<Device[]>("scanner:staleDevices", []);
const [deviceChanges, setDeviceChanges] = usePersistedState<DeviceChange[]>(
"scanner:deviceChanges",
[]
);
const [fritzboxSummary, setFritzboxSummary] = usePersistedState<
{ found: number; uniqueDevices: number } | null
>("scanner:fritzboxSummary", null);
const [fritzboxError, setFritzboxError] = usePersistedState<string | null>(
"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 (
<div>
<AdminPageHeader
title="Scanner"
description="Scans laufen ausschließlich manuell nie automatisch oder zeitgesteuert."
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="font-medium text-black dark:text-white">FritzBox-Scan</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
Liest die Geräteliste der FritzBox per TR-064 und legt/aktualisiert Geräte.
Erfordert <code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_HOST</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_USERNAME</code> und{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_PASSWORD</code> in
der <code className="rounded bg-black/5 px-1 dark:bg-white/10">.env</code>.
</p>
<Button
variant="primary"
className="mt-4"
onClick={startFritzboxScan}
disabled={fritzboxRunning}
>
{fritzboxRunning ? "Scanne …" : "FritzBox jetzt scannen"}
</Button>
{fritzboxSummary ? (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
{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)"
: ""}
.
</p>
) : null}
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
<DeviceChangesReview
deviceChanges={deviceChanges}
onResolve={(key) => setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))}
/>
<StaleDevicesReview
staleDevices={staleDevices}
onResolve={(id) => setStaleDevices((prev) => prev.filter((d) => d.id !== id))}
/>
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="font-medium text-black dark:text-white">Alle Geräte scannen</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
Führt den Netzwerk-Scan (DNS, Ports, Titel/Favicon, Softwareerkennung) nacheinander
für alle {devices?.length ?? 0} bekannten Geräte aus. Für ein einzelnes Gerät lieber
den Button in der Geräte-Tabelle nutzen.
</p>
<div className="mt-4 flex items-center gap-2">
<Button
variant="primary"
onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0}
>
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button>
{bulkRunning ? (
<Button
size="icon"
variant="ghost"
onClick={() => cancelBulkScan()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
</div>
{bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : null}
<NewServicesList
newServices={bulkNewServices}
onResolve={(id) => setBulkNewServices((prev) => prev.filter((s) => s.id !== id))}
/>
<NameChangesReview
nameChanges={bulkNameChanges}
onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
/>
<StaleServicesReview
staleServices={bulkStaleServices}
onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
/>
</div>
<ApiScannerCard />
</div>
</div>
);
}