generated from Dicken/dickendock
round29: Scanner laufen jetzt als Hintergrund-Job im Backend (behebt API-Scan-504-Timeout und macht Scan-Status/-Fortschritt geraeteuebergreifend sichtbar)
This commit is contained in:
@@ -3,25 +3,51 @@ 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 () => {
|
||||
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();
|
||||
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 totalFound = 0;
|
||||
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);
|
||||
if (found.length > 0) {
|
||||
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.`,
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -6,67 +6,25 @@ 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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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);
|
||||
@@ -167,6 +125,24 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
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({
|
||||
@@ -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.
|
||||
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
||||
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
||||
|
||||
82
apps/backend/src/scanJobs.ts
Normal file
82
apps/backend/src/scanJobs.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
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(
|
||||
`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.")
|
||||
`Prüfe ${s.progress.currentLabel ?? "…"} (${s.progress.current + 1} von ${s.progress.total})`
|
||||
);
|
||||
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"] });
|
||||
},
|
||||
onError: (err: Error) => setStatus(err.message),
|
||||
});
|
||||
} 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,57 +454,79 @@ 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();
|
||||
|
||||
void updated;
|
||||
const pending = allNameChanges.length + allStale.length;
|
||||
const wasAborted = controller.signal.aborted;
|
||||
setBulkRunning(status.running);
|
||||
|
||||
if (status.running && status.progress) {
|
||||
setBulkStatus(
|
||||
(wasAborted ? `Abgebrochen nach ${scannedCount} von ${devices.length} Gerät(en). ` : `Fertig: ${devices.length} Gerät(e) gescannt. `) +
|
||||
`${created} neue(r) Dienst(e) gefunden.` +
|
||||
`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(allStale);
|
||||
setBulkNameChanges(allNameChanges);
|
||||
setBulkNewServices(allNew);
|
||||
setBulkRunning(false);
|
||||
bulkAbortController = null;
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -566,7 +593,7 @@ async function scanAllDevices() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => bulkAbortController?.abort()}
|
||||
onClick={() => cancelBulkScan()}
|
||||
title="Scan abbrechen"
|
||||
aria-label="Scan abbrechen"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user