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:
2026-07-24 13:29:01 +02:00
parent f5845ec762
commit 2106c17301
5 changed files with 534 additions and 288 deletions

View File

@@ -5,43 +5,51 @@ interface ScanActivity {
pendingCount: number;
}
function readJson<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(`launchpad:${key}`);
return raw !== null ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function computeActivity(): ScanActivity {
const running = readJson("scanner:bulkRunning", false);
const staleServices = readJson<unknown[]>("scanner:bulkStaleServices", []);
const nameChanges = readJson<unknown[]>("scanner:bulkNameChanges", []);
const staleDevices = readJson<unknown[]>("scanner:staleDevices", []);
const deviceChanges = readJson<unknown[]>("scanner:deviceChanges", []);
const pendingCount =
staleServices.length + nameChanges.length + staleDevices.length + deviceChanges.length;
return { running, pendingCount };
}
/**
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
* unbestätigte Scan-Ergebnisse gibt (siehe Scanner-Seite, dort alles über
* usePersistedState/sessionStorage gehalten) - für ein Abzeichen am
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite aus.
*
* sessionStorage-Änderungen aus einer ANDEREN Komponente im selben Tab
* lösen kein "storage"-Event aus (das feuert nur tab-übergreifend) - daher
* hier bewusst ein einfaches, günstiges Polling statt eines echten
* Reaktivitäts-Mechanismus.
* unbestätigte Scan-Ergebnisse gibt - für ein Abzeichen am
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite UND jedem
* Gerät/Browser aus, da der Zustand jetzt vom BACKEND abgefragt wird
* (siehe apps/backend/src/scanJobs.ts), nicht mehr aus dem lokalen
* sessionStorage des jeweiligen Browsers gelesen wird - Ersteres kann
* naturgemäß nicht geräteübergreifend sein.
*/
export function useScanActivity(): ScanActivity {
const [activity, setActivity] = useState<ScanActivity>(() => computeActivity());
const [activity, setActivity] = useState<ScanActivity>({ running: false, pendingCount: 0 });
useEffect(() => {
const interval = setInterval(() => setActivity(computeActivity()), 1500);
return () => clearInterval(interval);
let cancelled = false;
async function poll() {
try {
const [devicesRes, apisRes] = await Promise.all([
fetch("/api/scan/devices/all/status"),
fetch("/api/scan/apis/status"),
]);
if (cancelled) return;
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
const apisStatus = apisRes.ok ? await apisRes.json() : null;
const running = !!devicesStatus?.running || !!apisStatus?.running;
const lastResult = devicesStatus?.lastResult;
const pendingCount = lastResult
? (lastResult.nameChanges?.length ?? 0) + (lastResult.staleServices?.length ?? 0)
: 0;
setActivity({ running, pendingCount });
} catch {
// Netzwerk-Hänger beim Poll ignorieren, nächster Versuch folgt.
}
}
poll();
const interval = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
return activity;

View File

@@ -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"
>