diff --git a/apps/backend/src/routes/apis.ts b/apps/backend/src/routes/apis.ts index caf29d9..5a1f3b6 100644 --- a/apps/backend/src/routes/apis.ts +++ b/apps/backend/src/routes/apis.ts @@ -3,46 +3,78 @@ import * as serviceRepo from "../db/repositories/services.js"; import * as apiRepo from "../db/repositories/apis.js"; import * as logRepo from "../db/repositories/logs.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 * unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits * bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts) * 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 { app.get("/api/detected-apis", async () => { return apiRepo.listDetectedApis(); }); - app.post("/api/scan/apis", async () => { - const services = serviceRepo.listServices(); - let servicesWithApi = 0; - let totalFound = 0; - const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = []; + app.get("/api/scan/apis/status", async () => { + return getJobStatus("apis"); + }); - for (const service of services) { - const found = await detectApis(service.url); - if (found.length > 0) { - const { added } = apiRepo.replaceApisForService(service.id, found); - servicesWithApi++; - totalFound += found.length; - if (added.length > 0) { - newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added }); - } - } else { - apiRepo.deleteApisForService(service.id); - } + app.post("/api/scan/apis", async (_request, reply) => { + if (isJobRunning("apis")) { + return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." }); } - logRepo.logScan({ - type: "api", - targetId: null, - level: "info", - message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`, - }); + const services = serviceRepo.listServices(); + startJob("apis", services.length); - return { checked: services.length, servicesWithApi, totalFound, newFindings }; + // 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 totalFound = 0; + const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = []; + + try { + for (let i = 0; i < services.length; i++) { + const service = services[i]; + updateJobProgress("apis", i, service.displayName); + + const found = await detectApis(service.url); + if (found.length > 0) { + const { added } = apiRepo.replaceApisForService(service.id, found); + servicesWithApi++; + totalFound += found.length; + if (added.length > 0) { + newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added }); + } + } else { + apiRepo.deleteApisForService(service.id); + } + } + + logRepo.logScan({ + type: "api", + targetId: null, + level: "info", + message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`, + }); + + 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) => { diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 49e8f7f..6eb3784 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -6,6 +6,230 @@ import * as logRepo from "../db/repositories/logs.js"; import * as settingsRepo from "../db/repositories/settings.js"; import { scanDeviceServices } from "../scanner/networkScanner.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"; + +/** + * Scannt EIN Gerät (Ports, Titel/Favicon, Softwareerkennung) und schreibt die + * Ergebnisse in die Datenbank. Ausgelagert aus der Route, damit sowohl der + * Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all + * weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren. + */ +async function performDeviceScan(device: Device) { + // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu + // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). + const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); + + const { services: discovered, suggestedHostname } = await scanDeviceServices( + device, + undefined, + servicesBeforeScan.map((s) => s.port) + ); + + // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit + // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert + // werden kann (services.category ist reiner Freitext, kein Fremdschlüssel). + for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) { + categoryRepo.ensureCategory(category); + } + + const results = discovered.map((found) => + serviceRepo.upsertServiceFromScan({ + deviceId: device.id, + hostname: found.hostname, + url: found.url, + https: found.https, + port: found.port, + favicon: found.favicon, + description: found.description, + suggestedDisplayName: found.suggestedDisplayName, + suggestedCategory: found.category, + suggestedIcon: found.icon, + suggestedVisible: found.visible, + }) + ); + + deviceRepo.upsertDeviceFromScan({ + hostname: device.hostname, + ip: device.ip, + mac: device.mac, + manufacturer: device.manufacturer, + model: device.model, + online: discovered.length > 0, + source: device.source, + }); + + const created = results.filter((r) => r.created).length; + const updated = results.filter((r) => !r.created).length; + const ports = discovered.map((d) => d.port).sort((a, b) => a - b); + + // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden + // (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern + // erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie + // bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur + // manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan + // (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden" + // gemeldet werden. + const foundPorts = new Set(discovered.map((d) => d.port)); + const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); + const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + const staleServices = servicesBeforeScan.filter((s) => { + if (foundPorts.has(s.port)) return false; + const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0; + return now - lastSeen >= staleThresholdMs; + }); + + // Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste + // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch + // übernommen, nur zur Bestätigung zurückgegeben. + const nameChanges = results.flatMap((r) => + r.nameChanges.map((change) => ({ + serviceId: r.service.id, + serviceHostname: r.service.hostname, + servicePort: r.service.port, + ...change, + })) + ); + + // Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell + // gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung. + const deviceNameSuggestion = + suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null; + + logRepo.logScan({ + type: "device", + targetId: device.id, + level: "info", + message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""}`, + }); + + return { + deviceId: device.id, + scannedPorts: discovered.length, + ports, + created, + updated, + services: results.map((r) => r.service), + newServices: results.filter((r) => r.created).map((r) => r.service), + staleServices, + nameChanges, + 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 { + 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) { + const detail = err instanceof Error ? err.message : String(err); + logRepo.logScan({ + type: "device", + targetId: device.id, + level: "error", + message: `${device.hostname} (${device.ip}): Scan fehlgeschlagen – ${detail}`, + }); + request.log.error(err); + return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail }); + } + }); + + // 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>; + 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 @@ -52,133 +276,6 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { 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 { - 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 - // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). - const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); - - const { services: discovered, suggestedHostname } = await scanDeviceServices( - device, - undefined, - servicesBeforeScan.map((s) => s.port) - ); - - // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit - // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert - // werden kann (services.category ist reiner Freitext, kein Fremdschlüssel). - for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) { - categoryRepo.ensureCategory(category); - } - - const results = discovered.map((found) => - serviceRepo.upsertServiceFromScan({ - deviceId: device.id, - hostname: found.hostname, - url: found.url, - https: found.https, - port: found.port, - favicon: found.favicon, - description: found.description, - suggestedDisplayName: found.suggestedDisplayName, - suggestedCategory: found.category, - suggestedIcon: found.icon, - suggestedVisible: found.visible, - }) - ); - - deviceRepo.upsertDeviceFromScan({ - hostname: device.hostname, - ip: device.ip, - mac: device.mac, - manufacturer: device.manufacturer, - model: device.model, - online: discovered.length > 0, - source: device.source, - }); - - const created = results.filter((r) => r.created).length; - const updated = results.filter((r) => !r.created).length; - const ports = discovered.map((d) => d.port).sort((a, b) => a - b); - - // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden - // (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern - // erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie - // bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur - // manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan - // (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden" - // gemeldet werden. - const foundPorts = new Set(discovered.map((d) => d.port)); - const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); - const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000; - const now = Date.now(); - const staleServices = servicesBeforeScan.filter((s) => { - if (foundPorts.has(s.port)) return false; - const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0; - return now - lastSeen >= staleThresholdMs; - }); - - // Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste - // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch - // übernommen, nur zur Bestätigung zurückgegeben. - const nameChanges = results.flatMap((r) => - r.nameChanges.map((change) => ({ - serviceId: r.service.id, - serviceHostname: r.service.hostname, - servicePort: r.service.port, - ...change, - })) - ); - - // Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell - // gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung. - const deviceNameSuggestion = - suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null; - - logRepo.logScan({ - type: "device", - targetId: device.id, - level: "info", - message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""}`, - }); - - return { - deviceId: device.id, - scannedPorts: discovered.length, - ports, - created, - updated, - services: results.map((r) => r.service), - newServices: results.filter((r) => r.created).map((r) => r.service), - staleServices, - nameChanges, - deviceNameSuggestion, - }; - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - logRepo.logScan({ - type: "device", - targetId: device.id, - level: "error", - message: `${device.hostname} (${device.ip}): Scan fehlgeschlagen – ${detail}`, - }); - request.log.error(err); - return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail }); - } - }); // FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte. // Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional diff --git a/apps/backend/src/scanJobs.ts b/apps/backend/src/scanJobs.ts new file mode 100644 index 0000000..4ee25f4 --- /dev/null +++ b/apps/backend/src/scanJobs.ts @@ -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 | null; + lastError: string | null; + cancelRequested: boolean; +} + +const jobs = new Map(); + +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): 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 }); +} diff --git a/apps/frontend/src/hooks/useScanActivity.ts b/apps/frontend/src/hooks/useScanActivity.ts index fa3f0eb..b88aa22 100644 --- a/apps/frontend/src/hooks/useScanActivity.ts +++ b/apps/frontend/src/hooks/useScanActivity.ts @@ -5,43 +5,51 @@ interface ScanActivity { pendingCount: number; } -function readJson(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("scanner:bulkStaleServices", []); - const nameChanges = readJson("scanner:bulkNameChanges", []); - const staleDevices = readJson("scanner:staleDevices", []); - const deviceChanges = readJson("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(() => computeActivity()); + const [activity, setActivity] = useState({ 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; diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 29fdfc5..704c7e8 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -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) { 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("scanner:apiStatus", null); + const [running, setRunning] = useState(false); const [lastNewFindings, setLastNewFindings] = usePersistedState< { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[] >("scanner:apiNewFindings", []); + const lastHandledStartedAt = useRef(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 (
@@ -363,23 +374,16 @@ function ApiScannerCard() {

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{" "} Admin → APIs .

- {status ?

{status}

: null} @@ -403,6 +407,7 @@ function ApiScannerCard() { ) : null}
); + } export function ScannerPage() { @@ -410,7 +415,7 @@ export function ScannerPage() { const { data: devices } = useDevices(); const [bulkStatus, setBulkStatus] = usePersistedState("scanner:bulkStatus", null); - const [bulkRunning, setBulkRunning] = usePersistedState("scanner:bulkRunning", false); + const [bulkRunning, setBulkRunning] = useState(false); const [bulkStaleServices, setBulkStaleServices] = usePersistedState( "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(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() {