generated from Dicken/dickendock
753 lines
29 KiB
TypeScript
753 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|