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,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<void> {
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) => {

View File

@@ -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<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) {
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<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
@@ -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<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
// 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

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;
}
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"
>