generated from Dicken/dickendock
round29: Scanner laufen jetzt als Hintergrund-Job im Backend (behebt API-Scan-504-Timeout und macht Scan-Status/-Fortschritt geraeteuebergreifend sichtbar)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -82,35 +83,10 @@ async function patchService(id: string, patch: Record<string, unknown>) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function scanDeviceById(id: string, signal?: AbortSignal) {
|
||||
const res = await fetch(`/api/scan/devices/${id}`, { 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 as {
|
||||
created: number;
|
||||
updated: number;
|
||||
staleServices: Service[];
|
||||
newServices: Service[];
|
||||
nameChanges: ScanNameChange[];
|
||||
deviceNameSuggestion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function nameChangeKey(c: ScanNameChange): string {
|
||||
return `${c.serviceId}:${c.field}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modul-Ebene statt useRef: der Scan-Loop selbst ist eine normale
|
||||
* JS-Funktion, kein an die Komponente gebundener Zustand - läuft weiter,
|
||||
* auch wenn man die Seite verlässt und zurückkommt (siehe usePersistedState
|
||||
* weiter unten). Ein useRef würde bei einem Seitenwechsel zurückgesetzt und
|
||||
* der Abbrechen-Button hätte dann keinen gültigen Controller mehr.
|
||||
*/
|
||||
let bulkAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
||||
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
||||
@@ -330,32 +306,67 @@ function StaleDevicesReview({
|
||||
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", []);
|
||||
const lastHandledStartedAt = useRef<string | null>(null);
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/scan/apis", { method: "POST" });
|
||||
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.json() as Promise<{
|
||||
checked: number;
|
||||
servicesWithApi: number;
|
||||
totalFound: number;
|
||||
newFindings: { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[];
|
||||
}>;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const newCount = result.newFindings.reduce((sum, f) => sum + f.apis.length, 0);
|
||||
setStatus(
|
||||
`Fertig: ${result.checked} Dienst(e) geprüft, ${result.totalFound} API-Endpunkt(e) insgesamt bei ${result.servicesWithApi} Dienst(en)` +
|
||||
(newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.")
|
||||
);
|
||||
setLastNewFindings(result.newFindings);
|
||||
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
|
||||
},
|
||||
onError: (err: Error) => setStatus(err.message),
|
||||
});
|
||||
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 !== lastHandledStartedAt.current) {
|
||||
lastHandledStartedAt.current = 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">
|
||||
@@ -363,23 +374,16 @@ function ApiScannerCard() {
|
||||
<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 nur auf Knopfdruck, nie zusammen mit dem normalen
|
||||
Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "}
|
||||
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={() => {
|
||||
setStatus(null);
|
||||
scanMutation.mutate();
|
||||
}}
|
||||
disabled={scanMutation.isPending}
|
||||
>
|
||||
{scanMutation.isPending ? "Scanne …" : "APIs jetzt scannen"}
|
||||
<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}
|
||||
|
||||
@@ -403,6 +407,7 @@ function ApiScannerCard() {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export function ScannerPage() {
|
||||
@@ -410,7 +415,7 @@ export function ScannerPage() {
|
||||
const { data: devices } = useDevices();
|
||||
|
||||
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
|
||||
const [bulkRunning, setBulkRunning] = usePersistedState<boolean>("scanner:bulkRunning", false);
|
||||
const [bulkRunning, setBulkRunning] = useState(false);
|
||||
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
|
||||
"scanner:bulkStaleServices",
|
||||
[]
|
||||
@@ -449,56 +454,78 @@ export function ScannerPage() {
|
||||
onError: (error: Error) => setFritzboxError(error.message),
|
||||
});
|
||||
|
||||
async function scanAllDevices() {
|
||||
if (!devices || devices.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
bulkAbortController = controller;
|
||||
setBulkRunning(true);
|
||||
setBulkStaleServices([]);
|
||||
setBulkNameChanges([]);
|
||||
setBulkNewServices([]);
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let scannedCount = 0;
|
||||
const allStale: Service[] = [];
|
||||
const allNameChanges: ScanNameChange[] = [];
|
||||
const allNew: Service[] = [];
|
||||
// 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.
|
||||
const lastHandledResultAt = useRef<string | null>(null);
|
||||
|
||||
for (const device of devices) {
|
||||
if (controller.signal.aborted) break;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const result = await scanDeviceById(device.id, controller.signal);
|
||||
created += result.created;
|
||||
updated += result.updated;
|
||||
scannedCount++;
|
||||
allStale.push(...result.staleServices);
|
||||
allNameChanges.push(...result.nameChanges);
|
||||
allNew.push(...result.newServices);
|
||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neue(r) Dienst(e) bisher)`);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") break;
|
||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||
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 !== lastHandledResultAt.current) {
|
||||
lastHandledResultAt.current = 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.
|
||||
}
|
||||
}
|
||||
|
||||
void updated;
|
||||
const pending = allNameChanges.length + allStale.length;
|
||||
const wasAborted = controller.signal.aborted;
|
||||
setBulkStatus(
|
||||
(wasAborted ? `Abgebrochen nach ${scannedCount} von ${devices.length} Gerät(en). ` : `Fertig: ${devices.length} Gerät(e) gescannt. `) +
|
||||
`${created} neue(r) Dienst(e) gefunden.` +
|
||||
(pending > 0
|
||||
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
|
||||
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
|
||||
);
|
||||
setBulkStaleServices(allStale);
|
||||
setBulkNameChanges(allNameChanges);
|
||||
setBulkNewServices(allNew);
|
||||
setBulkRunning(false);
|
||||
bulkAbortController = null;
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||
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 (
|
||||
@@ -566,7 +593,7 @@ async function scanAllDevices() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => bulkAbortController?.abort()}
|
||||
onClick={() => cancelBulkScan()}
|
||||
title="Scan abbrechen"
|
||||
aria-label="Scan abbrechen"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user