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

@@ -3,25 +3,51 @@ import * as serviceRepo from "../db/repositories/services.js";
import * as apiRepo from "../db/repositories/apis.js"; import * as apiRepo from "../db/repositories/apis.js";
import * as logRepo from "../db/repositories/logs.js"; import * as logRepo from "../db/repositories/logs.js";
import { detectApis } from "../scanner/apiDetector.js"; import { detectApis } from "../scanner/apiDetector.js";
import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning } from "../scanJobs.js";
/** /**
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett * API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits * unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts) * bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck. * und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
*
* Läuft als Hintergrund-Job im Backend (siehe scanJobs.ts), nicht mehr
* innerhalb einer einzigen HTTP-Anfrage: bei vielen Diensten (jeweils ~40
* geprüfte Pfade) kann das insgesamt länger dauern, als ein
* Reverse-Proxy/Browser auf eine Antwort wartet (führte zu HTTP 504). Der
* POST-Aufruf startet den Job nur und antwortet sofort, der Fortschritt wird
* über GET .../status abgefragt - das macht den laufenden Scan zudem
* geräteübergreifend sichtbar (der Status lebt im Backend, nicht im Browser).
*/ */
export async function apiRoutes(app: FastifyInstance): Promise<void> { export async function apiRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/detected-apis", async () => { app.get("/api/detected-apis", async () => {
return apiRepo.listDetectedApis(); return apiRepo.listDetectedApis();
}); });
app.post("/api/scan/apis", async () => { app.get("/api/scan/apis/status", async () => {
return getJobStatus("apis");
});
app.post("/api/scan/apis", async (_request, reply) => {
if (isJobRunning("apis")) {
return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." });
}
const services = serviceRepo.listServices(); const services = serviceRepo.listServices();
startJob("apis", services.length);
// Bewusst NICHT awaited - der Job läuft im Hintergrund weiter, die
// HTTP-Antwort kommt sofort zurück (siehe Doku oben).
void (async () => {
let servicesWithApi = 0; let servicesWithApi = 0;
let totalFound = 0; let totalFound = 0;
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = []; const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
for (const service of services) { try {
for (let i = 0; i < services.length; i++) {
const service = services[i];
updateJobProgress("apis", i, service.displayName);
const found = await detectApis(service.url); const found = await detectApis(service.url);
if (found.length > 0) { if (found.length > 0) {
const { added } = apiRepo.replaceApisForService(service.id, found); const { added } = apiRepo.replaceApisForService(service.id, found);
@@ -42,7 +68,13 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`, message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
}); });
return { checked: services.length, servicesWithApi, totalFound, newFindings }; finishJob("apis", { checked: services.length, servicesWithApi, totalFound, newFindings });
} catch (err) {
failJob("apis", err instanceof Error ? err.message : "Unbekannter Fehler");
}
})();
return reply.code(202).send({ started: true });
}); });
app.post("/api/scan/apis/:serviceId", async (request, reply) => { app.post("/api/scan/apis/:serviceId", async (request, reply) => {

View File

@@ -6,67 +6,25 @@ import * as logRepo from "../db/repositories/logs.js";
import * as settingsRepo from "../db/repositories/settings.js"; import * as settingsRepo from "../db/repositories/settings.js";
import { scanDeviceServices } from "../scanner/networkScanner.js"; import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
import {
startJob,
updateJobProgress,
finishJob,
failJob,
getJobStatus,
isJobRunning,
isCancelRequested,
requestCancel,
} from "../scanJobs.js";
import type { Device } from "@launchpad/shared";
/** /**
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für * Scannt EIN Gerät (Ports, Titel/Favicon, Softwareerkennung) und schreibt die
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher * Ergebnisse in die Datenbank. Ausgelagert aus der Route, damit sowohl der
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall. * Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all
* weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren.
*/ */
function isGenericFritzboxHostname(hostname: string, ip: string): boolean { async function performDeviceScan(device: Device) {
return hostname === `PC-${ip.replace(/\./g, "-")}`;
}
/**
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
*/
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
const byMac = new Map<string, FritzBoxHost[]>();
const withoutMac: FritzBoxHost[] = [];
for (const host of hosts) {
if (!host.mac) {
withoutMac.push(host);
continue;
}
const key = host.mac.toLowerCase();
const group = byMac.get(key);
if (group) group.push(host);
else byMac.set(key, [host]);
}
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
const chosen = named ?? group[0];
const online = group.find((h) => h.online) ?? group[0];
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
});
return [...merged, ...withoutMac];
}
/**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
* scannen") aus der Admin-UI ausgelöst es gibt keinerlei automatischen/
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
* protokolliert und ist unter Admin -> Logs einsehbar.
*/
export async function scanRoutes(app: FastifyInstance): Promise<void> {
app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
try {
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
@@ -167,6 +125,24 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
nameChanges, nameChanges,
deviceNameSuggestion, deviceNameSuggestion,
}; };
}
/**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
* scannen") aus der Admin-UI ausgelöst es gibt keinerlei automatischen/
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
* protokolliert und ist unter Admin -> Logs einsehbar.
*/
export async function scanRoutes(app: FastifyInstance): Promise<void> {
app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
try {
return await performDeviceScan(device);
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({ logRepo.logScan({
@@ -180,6 +156,127 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
} }
}); });
// Sammel-Scan über ALLE Geräte - läuft als Hintergrund-Job im Backend
// (siehe scanJobs.ts), nicht mehr als eine lange, vom Frontend
// client-seitig orchestrierte Schleife aus Einzelaufrufen. Zwei Vorteile:
// 1) Der Fortschritt ist über GET .../status geräteübergreifend sichtbar -
// öffnet jemand den Adminbereich auf einem ANDEREN Gerät/Browser während
// ein Scan läuft, sieht er das trotzdem (der Zustand lebt im Backend,
// nicht im Browser-sessionStorage).
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
// HTTP-Anfrage bis zum Timeout.
app.get("/api/scan/devices/all/status", async () => {
return getJobStatus("devices");
});
app.post("/api/scan/devices/all/cancel", async () => {
requestCancel("devices");
return { cancelling: true };
});
app.post("/api/scan/devices/all", async (_request, reply) => {
if (isJobRunning("devices")) {
return reply.code(409).send({ error: "Es läuft bereits ein Geräte-Scan." });
}
const devices = deviceRepo.listDevices();
startJob("devices", devices.length);
void (async () => {
type DeviceScanResult = Awaited<ReturnType<typeof performDeviceScan>>;
let created = 0;
let updated = 0;
let scannedCount = 0;
const allStale: DeviceScanResult["staleServices"] = [];
const allNameChanges: DeviceScanResult["nameChanges"] = [];
const allNew: DeviceScanResult["newServices"] = [];
let cancelled = false;
try {
for (const device of devices) {
if (isCancelRequested("devices")) {
cancelled = true;
break;
}
updateJobProgress("devices", scannedCount, device.hostname);
try {
const result = await performDeviceScan(device);
created += result.created;
updated += result.updated;
allStale.push(...result.staleServices);
allNameChanges.push(...result.nameChanges);
allNew.push(...result.newServices);
} catch {
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
}
scannedCount++;
}
finishJob("devices", {
totalDevices: devices.length,
scannedCount,
cancelled,
created,
updated,
staleServices: allStale,
nameChanges: allNameChanges,
newServices: allNew,
});
} catch (err) {
failJob("devices", err instanceof Error ? err.message : "Unbekannter Fehler");
}
})();
return reply.code(202).send({ started: true, totalDevices: devices.length });
});
/**
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall.
*/
function isGenericFritzboxHostname(hostname: string, ip: string): boolean {
return hostname === `PC-${ip.replace(/\./g, "-")}`;
}
/**
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
*/
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
const byMac = new Map<string, FritzBoxHost[]>();
const withoutMac: FritzBoxHost[] = [];
for (const host of hosts) {
if (!host.mac) {
withoutMac.push(host);
continue;
}
const key = host.mac.toLowerCase();
const group = byMac.get(key);
if (group) group.push(host);
else byMac.set(key, [host]);
}
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
const chosen = named ?? group[0];
const online = group.find((h) => h.online) ?? group[0];
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
});
return [...merged, ...withoutMac];
}
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte. // FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional // Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen. // FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.

View File

@@ -0,0 +1,82 @@
export type ScanJobType = "devices" | "apis";
export interface ScanJobProgress {
current: number;
total: number;
currentLabel: string | null;
}
export interface ScanJobStatus {
running: boolean;
startedAt: string | null;
progress: ScanJobProgress | null;
lastResult: Record<string, unknown> | null;
lastError: string | null;
cancelRequested: boolean;
}
const jobs = new Map<ScanJobType, ScanJobStatus>();
function emptyStatus(): ScanJobStatus {
return {
running: false,
startedAt: null,
progress: null,
lastResult: null,
lastError: null,
cancelRequested: false,
};
}
/**
* Zentraler, server-seitiger Status je Scanner-Typ ("devices" = "Alle
* Geräte scannen", "apis" = API-Scanner). Bewusst NICHT in der Datenbank,
* nur im Arbeitsspeicher des Backend-Prozesses - der Zustand "ein Scan läuft
* gerade" muss keinen Neustart überleben, aber er MUSS geräteübergreifend
* sichtbar sein (jeder Browser/jedes Gerät, das den Adminbereich öffnet,
* soll denselben laufenden Scan sehen). Ein rein browserseitiger Zustand
* (sessionStorage) kann das prinzipiell nicht leisten.
*/
export function getJobStatus(type: ScanJobType): ScanJobStatus {
return jobs.get(type) ?? emptyStatus();
}
export function isJobRunning(type: ScanJobType): boolean {
return getJobStatus(type).running;
}
export function startJob(type: ScanJobType, total: number): void {
jobs.set(type, {
running: true,
startedAt: new Date().toISOString(),
progress: { current: 0, total, currentLabel: null },
lastResult: null,
lastError: null,
cancelRequested: false,
});
}
export function updateJobProgress(type: ScanJobType, current: number, currentLabel: string | null): void {
const job = jobs.get(type);
if (!job) return;
job.progress = { current, total: job.progress?.total ?? current, currentLabel };
}
export function isCancelRequested(type: ScanJobType): boolean {
return jobs.get(type)?.cancelRequested ?? false;
}
export function requestCancel(type: ScanJobType): void {
const job = jobs.get(type);
if (job) job.cancelRequested = true;
}
export function finishJob(type: ScanJobType, result: Record<string, unknown>): void {
const job = jobs.get(type) ?? emptyStatus();
jobs.set(type, { ...job, running: false, lastResult: result, cancelRequested: false });
}
export function failJob(type: ScanJobType, error: string): void {
const job = jobs.get(type) ?? emptyStatus();
jobs.set(type, { ...job, running: false, lastError: error, cancelRequested: false });
}

View File

@@ -5,43 +5,51 @@ interface ScanActivity {
pendingCount: number; 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 * Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
* unbestätigte Scan-Ergebnisse gibt (siehe Scanner-Seite, dort alles über * unbestätigte Scan-Ergebnisse gibt - für ein Abzeichen am
* usePersistedState/sessionStorage gehalten) - für ein Abzeichen am * "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite UND jedem
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite aus. * Gerät/Browser aus, da der Zustand jetzt vom BACKEND abgefragt wird
* * (siehe apps/backend/src/scanJobs.ts), nicht mehr aus dem lokalen
* sessionStorage-Änderungen aus einer ANDEREN Komponente im selben Tab * sessionStorage des jeweiligen Browsers gelesen wird - Ersteres kann
* lösen kein "storage"-Event aus (das feuert nur tab-übergreifend) - daher * naturgemäß nicht geräteübergreifend sein.
* hier bewusst ein einfaches, günstiges Polling statt eines echten
* Reaktivitäts-Mechanismus.
*/ */
export function useScanActivity(): ScanActivity { export function useScanActivity(): ScanActivity {
const [activity, setActivity] = useState<ScanActivity>(() => computeActivity()); const [activity, setActivity] = useState<ScanActivity>({ running: false, pendingCount: 0 });
useEffect(() => { useEffect(() => {
const interval = setInterval(() => setActivity(computeActivity()), 1500); let cancelled = false;
return () => clearInterval(interval);
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; return activity;

View File

@@ -1,3 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -82,35 +83,10 @@ async function patchService(id: string, patch: Record<string, unknown>) {
return res.json(); 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 { function nameChangeKey(c: ScanNameChange): string {
return `${c.serviceId}:${c.field}`; 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 * Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst * explizit die *EditedManually-Flag auf false zurück, damit der Dienst
@@ -330,32 +306,67 @@ function StaleDevicesReview({
function ApiScannerCard() { function ApiScannerCard() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null); const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
const [running, setRunning] = useState(false);
const [lastNewFindings, setLastNewFindings] = usePersistedState< const [lastNewFindings, setLastNewFindings] = usePersistedState<
{ serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[] { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]
>("scanner:apiNewFindings", []); >("scanner:apiNewFindings", []);
const lastHandledStartedAt = useRef<string | null>(null);
const scanMutation = useMutation({ useEffect(() => {
mutationFn: async () => { let cancelled = false;
const res = await fetch("/api/scan/apis", { method: "POST" });
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`); async function poll() {
return res.json() as Promise<{ try {
checked: number; const res = await fetch("/api/scan/apis/status");
servicesWithApi: number; if (!res.ok || cancelled) return;
totalFound: number; const s = await res.json();
newFindings: { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[];
}>; setRunning(s.running);
},
onSuccess: (result) => { if (s.running && s.progress) {
const newCount = result.newFindings.reduce((sum, f) => sum + f.apis.length, 0);
setStatus( setStatus(
`Fertig: ${result.checked} Dienst(e) geprüft, ${result.totalFound} API-Endpunkt(e) insgesamt bei ${result.servicesWithApi} Dienst(en)` + `Prüfe ${s.progress.currentLabel ?? "…"} (${s.progress.current + 1} von ${s.progress.total})`
(newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.")
); );
setLastNewFindings(result.newFindings); } 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"] }); queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
}, } else if (s.lastError) {
onError: (err: Error) => setStatus(err.message), 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 ( return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2"> <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"> <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 Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
(OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet (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 neue/geänderte Funde hier auf. Läuft im Hintergrund (auch bei vielen Diensten kein
Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "} 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"> <Link to="/admin/apis" className="underline">
Admin APIs Admin APIs
</Link> </Link>
. .
</p> </p>
<Button <Button variant="primary" className="mt-4" onClick={startScan} disabled={running}>
variant="primary" {running ? "Scanne …" : "APIs jetzt scannen"}
className="mt-4"
onClick={() => {
setStatus(null);
scanMutation.mutate();
}}
disabled={scanMutation.isPending}
>
{scanMutation.isPending ? "Scanne …" : "APIs jetzt scannen"}
</Button> </Button>
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null} {status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
@@ -403,6 +407,7 @@ function ApiScannerCard() {
) : null} ) : null}
</div> </div>
); );
} }
export function ScannerPage() { export function ScannerPage() {
@@ -410,7 +415,7 @@ export function ScannerPage() {
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null); 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[]>( const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
"scanner:bulkStaleServices", "scanner:bulkStaleServices",
[] []
@@ -449,57 +454,79 @@ export function ScannerPage() {
onError: (error: Error) => setFritzboxError(error.message), onError: (error: Error) => setFritzboxError(error.message),
}); });
async function scanAllDevices() { // Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend
if (!devices || devices.length === 0) return; // (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus
const controller = new AbortController(); // Einzelaufrufen im Browser. Dieser Poll-Mechanismus läuft IMMER, solange
bulkAbortController = controller; // die Seite offen ist - egal ob DIESER Browser den Scan gestartet hat oder
setBulkRunning(true); // ein anderes Gerät: der Fortschritt/das letzte Ergebnis kommt direkt vom
setBulkStaleServices([]); // Backend, ist also für jeden sichtbar, der die Seite gerade offen hat.
setBulkNameChanges([]); const lastHandledResultAt = useRef<string | null>(null);
setBulkNewServices([]);
let created = 0;
let updated = 0;
let scannedCount = 0;
const allStale: Service[] = [];
const allNameChanges: ScanNameChange[] = [];
const allNew: Service[] = [];
for (const device of devices) { useEffect(() => {
if (controller.signal.aborted) break; let cancelled = false;
async function poll() {
try { try {
const result = await scanDeviceById(device.id, controller.signal); const res = await fetch("/api/scan/devices/all/status");
created += result.created; if (!res.ok || cancelled) return;
updated += result.updated; const status = await res.json();
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
}
}
void updated; setBulkRunning(status.running);
const pending = allNameChanges.length + allStale.length;
const wasAborted = controller.signal.aborted; if (status.running && status.progress) {
setBulkStatus( setBulkStatus(
(wasAborted ? `Abgebrochen nach ${scannedCount} von ${devices.length} Gerät(en). ` : `Fertig: ${devices.length} Gerät(e) gescannt. `) + `Scanne ${status.progress.currentLabel ?? "…"} (${status.progress.current + 1} von ${status.progress.total})`
`${created} neue(r) Dienst(e) gefunden.` + );
} 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 > 0
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.` ? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.") : " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
); );
setBulkStaleServices(allStale); setBulkStaleServices(r.staleServices);
setBulkNameChanges(allNameChanges); setBulkNameChanges(r.nameChanges);
setBulkNewServices(allNew); setBulkNewServices(r.newServices);
setBulkRunning(false);
bulkAbortController = null;
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] }); 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 ( return (
<div> <div>
@@ -566,7 +593,7 @@ async function scanAllDevices() {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
onClick={() => bulkAbortController?.abort()} onClick={() => cancelBulkScan()}
title="Scan abbrechen" title="Scan abbrechen"
aria-label="Scan abbrechen" aria-label="Scan abbrechen"
> >