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 apiRepo from "../db/repositories/apis.js";
|
||||||
import * as logRepo from "../db/repositories/logs.js";
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
import { detectApis } from "../scanner/apiDetector.js";
|
import { detectApis } from "../scanner/apiDetector.js";
|
||||||
|
import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning } from "../scanJobs.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
|
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
|
||||||
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
|
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
|
||||||
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
|
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
|
||||||
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
|
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
|
||||||
|
*
|
||||||
|
* Läuft als Hintergrund-Job im Backend (siehe scanJobs.ts), nicht mehr
|
||||||
|
* innerhalb einer einzigen HTTP-Anfrage: bei vielen Diensten (jeweils ~40
|
||||||
|
* geprüfte Pfade) kann das insgesamt länger dauern, als ein
|
||||||
|
* Reverse-Proxy/Browser auf eine Antwort wartet (führte zu HTTP 504). Der
|
||||||
|
* POST-Aufruf startet den Job nur und antwortet sofort, der Fortschritt wird
|
||||||
|
* über GET .../status abgefragt - das macht den laufenden Scan zudem
|
||||||
|
* geräteübergreifend sichtbar (der Status lebt im Backend, nicht im Browser).
|
||||||
*/
|
*/
|
||||||
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get("/api/detected-apis", async () => {
|
app.get("/api/detected-apis", async () => {
|
||||||
return apiRepo.listDetectedApis();
|
return apiRepo.listDetectedApis();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scan/apis", async () => {
|
app.get("/api/scan/apis/status", async () => {
|
||||||
|
return getJobStatus("apis");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/apis", async (_request, reply) => {
|
||||||
|
if (isJobRunning("apis")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
const services = serviceRepo.listServices();
|
const services = serviceRepo.listServices();
|
||||||
|
startJob("apis", services.length);
|
||||||
|
|
||||||
|
// Bewusst NICHT awaited - der Job läuft im Hintergrund weiter, die
|
||||||
|
// HTTP-Antwort kommt sofort zurück (siehe Doku oben).
|
||||||
|
void (async () => {
|
||||||
let servicesWithApi = 0;
|
let servicesWithApi = 0;
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||||
|
|
||||||
for (const service of services) {
|
try {
|
||||||
|
for (let i = 0; i < services.length; i++) {
|
||||||
|
const service = services[i];
|
||||||
|
updateJobProgress("apis", i, service.displayName);
|
||||||
|
|
||||||
const found = await detectApis(service.url);
|
const found = await detectApis(service.url);
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
const { added } = apiRepo.replaceApisForService(service.id, found);
|
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||||
@@ -42,7 +68,13 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { checked: services.length, servicesWithApi, totalFound, newFindings };
|
finishJob("apis", { checked: services.length, servicesWithApi, totalFound, newFindings });
|
||||||
|
} catch (err) {
|
||||||
|
failJob("apis", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return reply.code(202).send({ started: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||||
|
|||||||
@@ -6,67 +6,25 @@ import * as logRepo from "../db/repositories/logs.js";
|
|||||||
import * as settingsRepo from "../db/repositories/settings.js";
|
import * as settingsRepo from "../db/repositories/settings.js";
|
||||||
import { scanDeviceServices } from "../scanner/networkScanner.js";
|
import { scanDeviceServices } from "../scanner/networkScanner.js";
|
||||||
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
|
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
|
||||||
|
import {
|
||||||
|
startJob,
|
||||||
|
updateJobProgress,
|
||||||
|
finishJob,
|
||||||
|
failJob,
|
||||||
|
getJobStatus,
|
||||||
|
isJobRunning,
|
||||||
|
isCancelRequested,
|
||||||
|
requestCancel,
|
||||||
|
} from "../scanJobs.js";
|
||||||
|
import type { Device } from "@launchpad/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
|
* Scannt EIN Gerät (Ports, Titel/Favicon, Softwareerkennung) und schreibt die
|
||||||
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher
|
* Ergebnisse in die Datenbank. Ausgelagert aus der Route, damit sowohl der
|
||||||
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall.
|
* Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all
|
||||||
|
* weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren.
|
||||||
*/
|
*/
|
||||||
function isGenericFritzboxHostname(hostname: string, ip: string): boolean {
|
async function performDeviceScan(device: Device) {
|
||||||
return hostname === `PC-${ip.replace(/\./g, "-")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
|
|
||||||
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
|
|
||||||
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
|
|
||||||
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
|
|
||||||
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
|
|
||||||
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
|
|
||||||
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
|
|
||||||
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
|
|
||||||
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
|
|
||||||
*/
|
|
||||||
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
|
||||||
const byMac = new Map<string, FritzBoxHost[]>();
|
|
||||||
const withoutMac: FritzBoxHost[] = [];
|
|
||||||
|
|
||||||
for (const host of hosts) {
|
|
||||||
if (!host.mac) {
|
|
||||||
withoutMac.push(host);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = host.mac.toLowerCase();
|
|
||||||
const group = byMac.get(key);
|
|
||||||
if (group) group.push(host);
|
|
||||||
else byMac.set(key, [host]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
|
|
||||||
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
|
|
||||||
const chosen = named ?? group[0];
|
|
||||||
const online = group.find((h) => h.online) ?? group[0];
|
|
||||||
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...merged, ...withoutMac];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
|
|
||||||
* scannen") aus der Admin-UI ausgelöst – es gibt keinerlei automatischen/
|
|
||||||
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
|
|
||||||
* protokolliert und ist unter Admin -> Logs einsehbar.
|
|
||||||
*/
|
|
||||||
export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|
||||||
app.post("/api/scan/devices/:id", async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const device = deviceRepo.getDevice(id);
|
|
||||||
if (!device) {
|
|
||||||
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
|
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
|
||||||
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||||
@@ -167,6 +125,24 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
nameChanges,
|
nameChanges,
|
||||||
deviceNameSuggestion,
|
deviceNameSuggestion,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
|
||||||
|
* scannen") aus der Admin-UI ausgelöst – es gibt keinerlei automatischen/
|
||||||
|
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
|
||||||
|
* protokolliert und ist unter Admin -> Logs einsehbar.
|
||||||
|
*/
|
||||||
|
export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post("/api/scan/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const device = deviceRepo.getDevice(id);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await performDeviceScan(device);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(err);
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
logRepo.logScan({
|
logRepo.logScan({
|
||||||
@@ -180,6 +156,127 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sammel-Scan über ALLE Geräte - läuft als Hintergrund-Job im Backend
|
||||||
|
// (siehe scanJobs.ts), nicht mehr als eine lange, vom Frontend
|
||||||
|
// client-seitig orchestrierte Schleife aus Einzelaufrufen. Zwei Vorteile:
|
||||||
|
// 1) Der Fortschritt ist über GET .../status geräteübergreifend sichtbar -
|
||||||
|
// öffnet jemand den Adminbereich auf einem ANDEREN Gerät/Browser während
|
||||||
|
// ein Scan läuft, sieht er das trotzdem (der Zustand lebt im Backend,
|
||||||
|
// nicht im Browser-sessionStorage).
|
||||||
|
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
|
||||||
|
// HTTP-Anfrage bis zum Timeout.
|
||||||
|
app.get("/api/scan/devices/all/status", async () => {
|
||||||
|
return getJobStatus("devices");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/devices/all/cancel", async () => {
|
||||||
|
requestCancel("devices");
|
||||||
|
return { cancelling: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/devices/all", async (_request, reply) => {
|
||||||
|
if (isJobRunning("devices")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein Geräte-Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = deviceRepo.listDevices();
|
||||||
|
startJob("devices", devices.length);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
type DeviceScanResult = Awaited<ReturnType<typeof performDeviceScan>>;
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let scannedCount = 0;
|
||||||
|
const allStale: DeviceScanResult["staleServices"] = [];
|
||||||
|
const allNameChanges: DeviceScanResult["nameChanges"] = [];
|
||||||
|
const allNew: DeviceScanResult["newServices"] = [];
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const device of devices) {
|
||||||
|
if (isCancelRequested("devices")) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
updateJobProgress("devices", scannedCount, device.hostname);
|
||||||
|
try {
|
||||||
|
const result = await performDeviceScan(device);
|
||||||
|
created += result.created;
|
||||||
|
updated += result.updated;
|
||||||
|
allStale.push(...result.staleServices);
|
||||||
|
allNameChanges.push(...result.nameChanges);
|
||||||
|
allNew.push(...result.newServices);
|
||||||
|
} catch {
|
||||||
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
|
}
|
||||||
|
scannedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
finishJob("devices", {
|
||||||
|
totalDevices: devices.length,
|
||||||
|
scannedCount,
|
||||||
|
cancelled,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
staleServices: allStale,
|
||||||
|
nameChanges: allNameChanges,
|
||||||
|
newServices: allNew,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
failJob("devices", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return reply.code(202).send({ started: true, totalDevices: devices.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
|
||||||
|
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher
|
||||||
|
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall.
|
||||||
|
*/
|
||||||
|
function isGenericFritzboxHostname(hostname: string, ip: string): boolean {
|
||||||
|
return hostname === `PC-${ip.replace(/\./g, "-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
|
||||||
|
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
|
||||||
|
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
|
||||||
|
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
|
||||||
|
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
|
||||||
|
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
|
||||||
|
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
|
||||||
|
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
|
||||||
|
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
|
||||||
|
*/
|
||||||
|
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
||||||
|
const byMac = new Map<string, FritzBoxHost[]>();
|
||||||
|
const withoutMac: FritzBoxHost[] = [];
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (!host.mac) {
|
||||||
|
withoutMac.push(host);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = host.mac.toLowerCase();
|
||||||
|
const group = byMac.get(key);
|
||||||
|
if (group) group.push(host);
|
||||||
|
else byMac.set(key, [host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
|
||||||
|
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
|
||||||
|
const chosen = named ?? group[0];
|
||||||
|
const online = group.find((h) => h.online) ?? group[0];
|
||||||
|
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...merged, ...withoutMac];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
|
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
|
||||||
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
||||||
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
||||||
|
|||||||
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;
|
pendingCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson<T>(key: string, fallback: T): T {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(`launchpad:${key}`);
|
|
||||||
return raw !== null ? (JSON.parse(raw) as T) : fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeActivity(): ScanActivity {
|
|
||||||
const running = readJson("scanner:bulkRunning", false);
|
|
||||||
const staleServices = readJson<unknown[]>("scanner:bulkStaleServices", []);
|
|
||||||
const nameChanges = readJson<unknown[]>("scanner:bulkNameChanges", []);
|
|
||||||
const staleDevices = readJson<unknown[]>("scanner:staleDevices", []);
|
|
||||||
const deviceChanges = readJson<unknown[]>("scanner:deviceChanges", []);
|
|
||||||
const pendingCount =
|
|
||||||
staleServices.length + nameChanges.length + staleDevices.length + deviceChanges.length;
|
|
||||||
return { running, pendingCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
|
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
|
||||||
* unbestätigte Scan-Ergebnisse gibt (siehe Scanner-Seite, dort alles über
|
* unbestätigte Scan-Ergebnisse gibt - für ein Abzeichen am
|
||||||
* usePersistedState/sessionStorage gehalten) - für ein Abzeichen am
|
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite UND jedem
|
||||||
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite aus.
|
* Gerät/Browser aus, da der Zustand jetzt vom BACKEND abgefragt wird
|
||||||
*
|
* (siehe apps/backend/src/scanJobs.ts), nicht mehr aus dem lokalen
|
||||||
* sessionStorage-Änderungen aus einer ANDEREN Komponente im selben Tab
|
* sessionStorage des jeweiligen Browsers gelesen wird - Ersteres kann
|
||||||
* lösen kein "storage"-Event aus (das feuert nur tab-übergreifend) - daher
|
* naturgemäß nicht geräteübergreifend sein.
|
||||||
* hier bewusst ein einfaches, günstiges Polling statt eines echten
|
|
||||||
* Reaktivitäts-Mechanismus.
|
|
||||||
*/
|
*/
|
||||||
export function useScanActivity(): ScanActivity {
|
export function useScanActivity(): ScanActivity {
|
||||||
const [activity, setActivity] = useState<ScanActivity>(() => computeActivity());
|
const [activity, setActivity] = useState<ScanActivity>({ running: false, pendingCount: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => setActivity(computeActivity()), 1500);
|
let cancelled = false;
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const [devicesRes, apisRes] = await Promise.all([
|
||||||
|
fetch("/api/scan/devices/all/status"),
|
||||||
|
fetch("/api/scan/apis/status"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
|
||||||
|
const apisStatus = apisRes.ok ? await apisRes.json() : null;
|
||||||
|
|
||||||
|
const running = !!devicesStatus?.running || !!apisStatus?.running;
|
||||||
|
|
||||||
|
const lastResult = devicesStatus?.lastResult;
|
||||||
|
const pendingCount = lastResult
|
||||||
|
? (lastResult.nameChanges?.length ?? 0) + (lastResult.staleServices?.length ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
setActivity({ running, pendingCount });
|
||||||
|
} catch {
|
||||||
|
// Netzwerk-Hänger beim Poll ignorieren, nächster Versuch folgt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return activity;
|
return activity;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -82,35 +83,10 @@ async function patchService(id: string, patch: Record<string, unknown>) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanDeviceById(id: string, signal?: AbortSignal) {
|
|
||||||
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal });
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
|
||||||
}
|
|
||||||
return body as {
|
|
||||||
created: number;
|
|
||||||
updated: number;
|
|
||||||
staleServices: Service[];
|
|
||||||
newServices: Service[];
|
|
||||||
nameChanges: ScanNameChange[];
|
|
||||||
deviceNameSuggestion: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameChangeKey(c: ScanNameChange): string {
|
function nameChangeKey(c: ScanNameChange): string {
|
||||||
return `${c.serviceId}:${c.field}`;
|
return `${c.serviceId}:${c.field}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Modul-Ebene statt useRef: der Scan-Loop selbst ist eine normale
|
|
||||||
* JS-Funktion, kein an die Komponente gebundener Zustand - läuft weiter,
|
|
||||||
* auch wenn man die Seite verlässt und zurückkommt (siehe usePersistedState
|
|
||||||
* weiter unten). Ein useRef würde bei einem Seitenwechsel zurückgesetzt und
|
|
||||||
* der Abbrechen-Button hätte dann keinen gültigen Controller mehr.
|
|
||||||
*/
|
|
||||||
let bulkAbortController: AbortController | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
||||||
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
||||||
@@ -330,32 +306,67 @@ function StaleDevicesReview({
|
|||||||
function ApiScannerCard() {
|
function ApiScannerCard() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
|
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
const [lastNewFindings, setLastNewFindings] = usePersistedState<
|
const [lastNewFindings, setLastNewFindings] = usePersistedState<
|
||||||
{ serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]
|
{ serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]
|
||||||
>("scanner:apiNewFindings", []);
|
>("scanner:apiNewFindings", []);
|
||||||
|
const lastHandledStartedAt = useRef<string | null>(null);
|
||||||
|
|
||||||
const scanMutation = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: async () => {
|
let cancelled = false;
|
||||||
const res = await fetch("/api/scan/apis", { method: "POST" });
|
|
||||||
if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`);
|
async function poll() {
|
||||||
return res.json() as Promise<{
|
try {
|
||||||
checked: number;
|
const res = await fetch("/api/scan/apis/status");
|
||||||
servicesWithApi: number;
|
if (!res.ok || cancelled) return;
|
||||||
totalFound: number;
|
const s = await res.json();
|
||||||
newFindings: { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[];
|
|
||||||
}>;
|
setRunning(s.running);
|
||||||
},
|
|
||||||
onSuccess: (result) => {
|
if (s.running && s.progress) {
|
||||||
const newCount = result.newFindings.reduce((sum, f) => sum + f.apis.length, 0);
|
|
||||||
setStatus(
|
setStatus(
|
||||||
`Fertig: ${result.checked} Dienst(e) geprüft, ${result.totalFound} API-Endpunkt(e) insgesamt bei ${result.servicesWithApi} Dienst(en)` +
|
`Prüfe ${s.progress.currentLabel ?? "…"} (${s.progress.current + 1} von ${s.progress.total})`
|
||||||
(newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.")
|
|
||||||
);
|
);
|
||||||
setLastNewFindings(result.newFindings);
|
} else if (s.lastResult && s.startedAt !== lastHandledStartedAt.current) {
|
||||||
|
lastHandledStartedAt.current = s.startedAt;
|
||||||
|
const r = s.lastResult;
|
||||||
|
const newCount = r.newFindings.reduce(
|
||||||
|
(sum: number, f: { apis: unknown[] }) => sum + f.apis.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Fertig: ${r.checked} Dienst(e) geprüft, ${r.totalFound} API-Endpunkt(e) insgesamt bei ${r.servicesWithApi} Dienst(en)` +
|
||||||
|
(newCount > 0
|
||||||
|
? `, davon ${newCount} neu/geändert seit dem letzten Scan.`
|
||||||
|
: ", keine Änderungen seit dem letzten Scan.")
|
||||||
|
);
|
||||||
|
setLastNewFindings(r.newFindings);
|
||||||
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
|
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
|
||||||
},
|
} else if (s.lastError) {
|
||||||
onError: (err: Error) => setStatus(err.message),
|
setStatus(`Fehlgeschlagen: ${s.lastError}`);
|
||||||
});
|
}
|
||||||
|
} catch {
|
||||||
|
// Netzwerk-Hänger beim Poll soll nicht die Seite stören.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function startScan() {
|
||||||
|
setStatus("Scan gestartet …");
|
||||||
|
const res = await fetch("/api/scan/apis", { method: "POST" });
|
||||||
|
if (res.status === 409) {
|
||||||
|
setStatus("Es läuft bereits ein API-Scan (evtl. von einem anderen Gerät gestartet).");
|
||||||
|
}
|
||||||
|
setRunning(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
|
||||||
@@ -363,23 +374,16 @@ function ApiScannerCard() {
|
|||||||
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
||||||
Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
|
Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
|
||||||
(OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet
|
(OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet
|
||||||
neue/geänderte Funde hier auf. Läuft nur auf Knopfdruck, nie zusammen mit dem normalen
|
neue/geänderte Funde hier auf. Läuft im Hintergrund (auch bei vielen Diensten kein
|
||||||
Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "}
|
Zeitüberschreitungsfehler mehr) und nie zusammen mit dem normalen Geräte-Scan. Die
|
||||||
|
vollständige, dauerhafte Liste steht immer unter{" "}
|
||||||
<Link to="/admin/apis" className="underline">
|
<Link to="/admin/apis" className="underline">
|
||||||
Admin → APIs
|
Admin → APIs
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="primary" className="mt-4" onClick={startScan} disabled={running}>
|
||||||
variant="primary"
|
{running ? "Scanne …" : "APIs jetzt scannen"}
|
||||||
className="mt-4"
|
|
||||||
onClick={() => {
|
|
||||||
setStatus(null);
|
|
||||||
scanMutation.mutate();
|
|
||||||
}}
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
>
|
|
||||||
{scanMutation.isPending ? "Scanne …" : "APIs jetzt scannen"}
|
|
||||||
</Button>
|
</Button>
|
||||||
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
||||||
|
|
||||||
@@ -403,6 +407,7 @@ function ApiScannerCard() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScannerPage() {
|
export function ScannerPage() {
|
||||||
@@ -410,7 +415,7 @@ export function ScannerPage() {
|
|||||||
const { data: devices } = useDevices();
|
const { data: devices } = useDevices();
|
||||||
|
|
||||||
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
|
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
|
||||||
const [bulkRunning, setBulkRunning] = usePersistedState<boolean>("scanner:bulkRunning", false);
|
const [bulkRunning, setBulkRunning] = useState(false);
|
||||||
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
|
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
|
||||||
"scanner:bulkStaleServices",
|
"scanner:bulkStaleServices",
|
||||||
[]
|
[]
|
||||||
@@ -449,57 +454,79 @@ export function ScannerPage() {
|
|||||||
onError: (error: Error) => setFritzboxError(error.message),
|
onError: (error: Error) => setFritzboxError(error.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function scanAllDevices() {
|
// Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend
|
||||||
if (!devices || devices.length === 0) return;
|
// (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus
|
||||||
const controller = new AbortController();
|
// Einzelaufrufen im Browser. Dieser Poll-Mechanismus läuft IMMER, solange
|
||||||
bulkAbortController = controller;
|
// die Seite offen ist - egal ob DIESER Browser den Scan gestartet hat oder
|
||||||
setBulkRunning(true);
|
// ein anderes Gerät: der Fortschritt/das letzte Ergebnis kommt direkt vom
|
||||||
setBulkStaleServices([]);
|
// Backend, ist also für jeden sichtbar, der die Seite gerade offen hat.
|
||||||
setBulkNameChanges([]);
|
const lastHandledResultAt = useRef<string | null>(null);
|
||||||
setBulkNewServices([]);
|
|
||||||
let created = 0;
|
|
||||||
let updated = 0;
|
|
||||||
let scannedCount = 0;
|
|
||||||
const allStale: Service[] = [];
|
|
||||||
const allNameChanges: ScanNameChange[] = [];
|
|
||||||
const allNew: Service[] = [];
|
|
||||||
|
|
||||||
for (const device of devices) {
|
useEffect(() => {
|
||||||
if (controller.signal.aborted) break;
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const result = await scanDeviceById(device.id, controller.signal);
|
const res = await fetch("/api/scan/devices/all/status");
|
||||||
created += result.created;
|
if (!res.ok || cancelled) return;
|
||||||
updated += result.updated;
|
const status = await res.json();
|
||||||
scannedCount++;
|
|
||||||
allStale.push(...result.staleServices);
|
|
||||||
allNameChanges.push(...result.nameChanges);
|
|
||||||
allNew.push(...result.newServices);
|
|
||||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neue(r) Dienst(e) bisher)`);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") break;
|
|
||||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updated;
|
setBulkRunning(status.running);
|
||||||
const pending = allNameChanges.length + allStale.length;
|
|
||||||
const wasAborted = controller.signal.aborted;
|
if (status.running && status.progress) {
|
||||||
setBulkStatus(
|
setBulkStatus(
|
||||||
(wasAborted ? `Abgebrochen nach ${scannedCount} von ${devices.length} Gerät(en). ` : `Fertig: ${devices.length} Gerät(e) gescannt. `) +
|
`Scanne ${status.progress.currentLabel ?? "…"} (${status.progress.current + 1} von ${status.progress.total})`
|
||||||
`${created} neue(r) Dienst(e) gefunden.` +
|
);
|
||||||
|
} else if (status.lastResult && status.startedAt !== lastHandledResultAt.current) {
|
||||||
|
lastHandledResultAt.current = status.startedAt;
|
||||||
|
const r = status.lastResult;
|
||||||
|
const pending = r.nameChanges.length + r.staleServices.length;
|
||||||
|
setBulkStatus(
|
||||||
|
(r.cancelled
|
||||||
|
? `Abgebrochen nach ${r.scannedCount} von ${r.totalDevices} Gerät(en). `
|
||||||
|
: `Fertig: ${r.totalDevices} Gerät(e) gescannt. `) +
|
||||||
|
`${r.created} neue(r) Dienst(e) gefunden.` +
|
||||||
(pending > 0
|
(pending > 0
|
||||||
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
|
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
|
||||||
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
|
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
|
||||||
);
|
);
|
||||||
setBulkStaleServices(allStale);
|
setBulkStaleServices(r.staleServices);
|
||||||
setBulkNameChanges(allNameChanges);
|
setBulkNameChanges(r.nameChanges);
|
||||||
setBulkNewServices(allNew);
|
setBulkNewServices(r.newServices);
|
||||||
setBulkRunning(false);
|
|
||||||
bulkAbortController = null;
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Netzwerk-Hänger beim Poll soll nicht die Seite stören - nächster
|
||||||
|
// Versuch folgt automatisch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function scanAllDevices() {
|
||||||
|
if (!devices || devices.length === 0) return;
|
||||||
|
const res = await fetch("/api/scan/devices/all", { method: "POST" });
|
||||||
|
if (res.status === 409) {
|
||||||
|
setBulkStatus("Es läuft bereits ein Scan (evtl. von einem anderen Gerät gestartet).");
|
||||||
|
setBulkRunning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBulkRunning(true);
|
||||||
|
setBulkStatus("Scan gestartet …");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelBulkScan() {
|
||||||
|
await fetch("/api/scan/devices/all/cancel", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -566,7 +593,7 @@ async function scanAllDevices() {
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => bulkAbortController?.abort()}
|
onClick={() => cancelBulkScan()}
|
||||||
title="Scan abbrechen"
|
title="Scan abbrechen"
|
||||||
aria-label="Scan abbrechen"
|
aria-label="Scan abbrechen"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user