From a7036849b129e4c1867585af9e1757596a83e942 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 14:00:10 +0200 Subject: [PATCH] round59: Serverseitige, geraeteuebergreifende Bestaetigung von Scan-Vorschlaegen statt nur lokalem sessionStorage --- apps/backend/src/db/client.ts | 16 ++ .../src/db/repositories/scanDismissals.ts | 76 ++++++++++ apps/backend/src/db/schema.ts | 21 +++ apps/backend/src/routes/scan.ts | 143 +++++++++++++++--- .../frontend/src/routes/admin/ScannerPage.tsx | 61 ++++++-- 5 files changed, 282 insertions(+), 35 deletions(-) create mode 100644 apps/backend/src/db/repositories/scanDismissals.ts diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 080f94e..daf5704 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -128,6 +128,13 @@ export function ensureSchema(): void { status INTEGER NOT NULL, detected_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS scan_dismissals ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + dismissed_at TEXT NOT NULL + ); `); // Leichte Migration für Datenbanken, die vor Einführung von "visible"/ @@ -159,6 +166,15 @@ export function ensureSchema(): void { sqlite.exec( `CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)` ); + + // Verhindert doppelte Einträge für denselben Vorschlag (z. B. wenn zwei + // Browser fast gleichzeitig denselben neuen Dienst bestätigen) - der + // Dismiss-Endpunkt selbst nutzt ohnehin "INSERT OR IGNORE" (siehe + // db/repositories/scanDismissals.ts), dieser Index macht das zusätzlich + // auf Datenbankebene verbindlich. + sqlite.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_dismissals_kind_key ON scan_dismissals(kind, dedupe_key)` + ); } /** diff --git a/apps/backend/src/db/repositories/scanDismissals.ts b/apps/backend/src/db/repositories/scanDismissals.ts new file mode 100644 index 0000000..4e95948 --- /dev/null +++ b/apps/backend/src/db/repositories/scanDismissals.ts @@ -0,0 +1,76 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "../client.js"; +import { scanDismissals } from "../schema.js"; + +/** + * Art des bestätigten/verworfenen Scan-Vorschlags. "dedupeKey" identifiziert + * das konkrete Element innerhalb dieser Art: + * - "new_service" / "stale_service": Dienst-ID + * - "stale_device": Geräte-ID + * - "name_change": ":" (feld = "displayName" | "category") + * - "device_change": ":" (feld = "hostname" | "ip") + */ +export type DismissalKind = + | "new_service" + | "stale_service" + | "stale_device" + | "name_change" + | "device_change"; + +/** + * Merkt einen Vorschlag als erledigt (bestätigt ODER bewusst verworfen - + * beides bedeutet "nicht mehr anzeigen"). "INSERT OR IGNORE" über den + * UNIQUE-Index (kind, dedupe_key, siehe db/client.ts) macht das + * idempotent - ein zweifach gesendeter Dismiss (z. B. Doppelklick, oder zwei + * Browser fast gleichzeitig) legt keinen doppelten Eintrag an. + */ +export function dismiss(kind: DismissalKind, dedupeKey: string): void { + db.insert(scanDismissals) + .values({ id: randomUUID(), kind, dedupeKey, dismissedAt: new Date().toISOString() }) + .onConflictDoNothing() + .run(); +} + +/** + * Liefert alle bereits erledigten dedupeKeys für eine Vorschlags-Art - zum + * Herausfiltern aus einem frischen Scan-Ergebnis (siehe routes/scan.ts), + * bevor es an den Browser geschickt wird. Ein Set für O(1)-Lookups, da diese + * Funktion typischerweise direkt vor einem .filter() über potenziell viele + * Vorschläge aufgerufen wird. + */ +export function getDismissedKeys(kind: DismissalKind): Set { + const rows = db + .select({ dedupeKey: scanDismissals.dedupeKey }) + .from(scanDismissals) + .where(eq(scanDismissals.kind, kind)) + .all(); + return new Set(rows.map((r) => r.dedupeKey)); +} + +/** + * Räumt alte Dismiss-Einträge für einen Vorschlag auf, sobald er durch einen + * NEUEN Scan wieder aktuell werden könnte - z. B. wird ein Dienst, der einmal + * als "verschwunden" bestätigt/verworfen wurde, bei einem künftigen Scan + * erneut gefunden: dann soll ein danach ERNEUT verschwindender Dienst wieder + * normal vorgeschlagen werden, nicht durch einen Jahre alten Dismiss-Eintrag + * stumm bleiben. Wird von den Reposities (services.ts/devices.ts) beim + * erneuten Auftauchen aufgerufen. + */ +export function clearDismissal(kind: DismissalKind, dedupeKey: string): void { + db.delete(scanDismissals) + .where(and(eq(scanDismissals.kind, kind), eq(scanDismissals.dedupeKey, dedupeKey))) + .run(); +} + +/** + * Wie clearDismissal(), aber für mehrere dedupeKeys derselben Art auf einmal + * (z. B. alle Ports eines Geräts, die im aktuellen Scan wieder gefunden + * wurden) - vermeidet N einzelne DELETE-Statements. + */ +export function clearDismissals(kind: DismissalKind, dedupeKeys: string[]): void { + if (dedupeKeys.length === 0) return; + db.delete(scanDismissals) + .where(and(eq(scanDismissals.kind, kind), inArray(scanDismissals.dedupeKey, dedupeKeys))) + .run(); +} diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9208940..7242295 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -170,3 +170,24 @@ export const detectedApis = sqliteTable("detected_apis", { status: integer("status").notNull(), detectedAt: text("detected_at").notNull(), }); + +/** + * Merkt sich, welche Scan-Vorschläge (neuer Dienst, verschwundener Dienst, + * verschwundenes Gerät, Namens-/Kategorie-Änderung) bereits bestätigt ODER + * bewusst verworfen ("Behalten") wurden - SERVERSEITIG statt nur im + * sessionStorage des jeweiligen Browsers (siehe Bugreport: Bestätigen auf + * einem Gerät/Browser hatte auf einem anderen keine Wirkung, dort musste + * derselbe Vorschlag erneut bestätigt werden). "kind" unterscheidet die Art + * des Vorschlags ("new_service", "stale_service", "stale_device", + * "name_change"), "dedupeKey" identifiziert das konkrete Element (z. B. die + * Dienst-/Geräte-ID, bei Namensänderungen ":"). Scan-Ergebnisse + * werden beim Ausliefern gegen diese Tabelle gefiltert (siehe routes/scan.ts) + * - ein einmal bestätigter/verworfener Vorschlag verschwindet dadurch für + * ALLE Browser/Geräte gleichzeitig, nicht nur lokal. + */ +export const scanDismissals = sqliteTable("scan_dismissals", { + id: text("id").primaryKey(), + kind: text("kind").notNull(), + dedupeKey: text("dedupe_key").notNull(), + dismissedAt: text("dismissed_at").notNull(), +}); diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index f4b862a..62d4820 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -4,6 +4,7 @@ import * as serviceRepo from "../db/repositories/services.js"; import * as categoryRepo from "../db/repositories/categories.js"; import * as logRepo from "../db/repositories/logs.js"; import * as settingsRepo from "../db/repositories/settings.js"; +import * as dismissalRepo from "../db/repositories/scanDismissals.js"; import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js"; import { fullPortRange } from "../scanner/ports.js"; import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.js"; @@ -138,23 +139,41 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?: const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); - const staleServices = servicesBeforeScan.filter((s) => { + const staleServiceCandidates = servicesBeforeScan.filter((s) => { if (foundPorts.has(s.port)) return false; const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0; return now - lastSeen >= staleThresholdMs; }); + // Ein Dienst, der in DIESEM Scan wieder gefunden wurde, ist nicht mehr + // "verschwunden" - eine frühere Bestätigung/Ablehnung ("ist ok, weg damit" + // bzw. "nein, das war ein Fehlalarm") bezog sich auf DIESE Abwesenheits- + // Episode und soll eine KÜNFTIGE (neue) Abwesenheit nicht stumm + // unterdrücken. Alte Dismiss-Einträge für wiedergefundene Dienste daher + // hier auflösen. + dismissalRepo.clearDismissals( + "stale_service", + servicesBeforeScan.filter((s) => foundPorts.has(s.port)).map((s) => s.id) + ); + const dismissedStaleServiceIds = dismissalRepo.getDismissedKeys("stale_service"); + const staleServices = staleServiceCandidates.filter((s) => !dismissedStaleServiceIds.has(s.id)); + // 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, - })) - ); + // übernommen, nur zur Bestätigung zurückgegeben. dedupeKey enthält den + // vorgeschlagenen WERT mit, damit eine einmal verworfene Änderung nicht + // auch eine völlig ANDERE künftige Änderung an demselben Feld unterdrückt. + const dismissedNameChangeKeys = dismissalRepo.getDismissedKeys("name_change"); + const nameChanges = results + .flatMap((r) => + r.nameChanges.map((change) => ({ + serviceId: r.service.id, + serviceHostname: r.service.hostname, + servicePort: r.service.port, + ...change, + })) + ) + .filter((c) => !dismissedNameChangeKeys.has(`${c.serviceId}:${c.field}:${c.suggested}`)); // Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell // gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung. @@ -168,6 +187,8 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?: 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` : ""} [${formatToolsUsed(toolsUsed)}]`, }); + const dismissedNewServiceIds = dismissalRepo.getDismissedKeys("new_service"); + return { deviceId: device.id, scannedPorts: discovered.length, @@ -175,7 +196,10 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?: created, updated, services: results.map((r) => r.service), - newServices: results.filter((r) => r.created).map((r) => r.service), + newServices: results + .filter((r) => r.created) + .map((r) => r.service) + .filter((s) => !dismissedNewServiceIds.has(s.id)), staleServices, nameChanges, deviceNameSuggestion, @@ -188,7 +212,65 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?: * zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs * protokolliert und ist unter Admin -> Logs einsehbar. */ +/** + * Filtert die Vorschlagslisten in einem (beim Scan-Abschluss eingefrorenen) + * "lastResult" NOCHMAL gegen den AKTUELLEN Dismiss-Stand, bei jedem + * Status-Abruf neu. Nötig, weil ein Bestätigen/Verwerfen NACH Scan-Ende + * passieren kann (der Normalfall) - ohne diese Live-Filterung würde ein + * anderer Browser/Gerät, der denselben eingefrorenen Snapshot abfragt, + * weiterhin den alten (unbestätigten) Stand sehen, obwohl der Vorschlag + * anderswo längst bestätigt wurde (siehe Bugreport). + */ +function reapplyDismissals(status: ReturnType): ReturnType { + if (!status.lastResult) return status; + const r = status.lastResult as Record; + + const filterByIdKind = (list: unknown, kind: dismissalRepo.DismissalKind) => { + if (!Array.isArray(list)) return list; + const dismissed = dismissalRepo.getDismissedKeys(kind); + return list.filter((item) => !dismissed.has((item as { id: string }).id)); + }; + const filterNameChangeLike = ( + list: unknown, + kind: dismissalRepo.DismissalKind, + idField: "serviceId" | "deviceId" + ) => { + if (!Array.isArray(list)) return list; + const dismissed = dismissalRepo.getDismissedKeys(kind); + return list.filter((item) => { + const c = item as Record; + return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`); + }); + }; + + const patched: Record = { ...r }; + if ("newServices" in r) patched.newServices = filterByIdKind(r.newServices, "new_service"); + if ("staleServices" in r) patched.staleServices = filterByIdKind(r.staleServices, "stale_service"); + if ("nameChanges" in r) patched.nameChanges = filterNameChangeLike(r.nameChanges, "name_change", "serviceId"); + if ("staleDevices" in r) patched.staleDevices = filterByIdKind(r.staleDevices, "stale_device"); + if ("deviceChanges" in r) patched.deviceChanges = filterNameChangeLike(r.deviceChanges, "device_change", "deviceId"); + + return { ...status, lastResult: patched }; +} + export async function scanRoutes(app: FastifyInstance): Promise { + // Merkt einen Scan-Vorschlag (neuer Dienst, verschwundener Dienst/Gerät, + // Namens-/Kategorie-Änderung) SERVERSEITIG als erledigt - egal ob durch + // "Übernehmen" oder "Behalten" ausgelöst, beides heißt hier "nicht mehr + // anzeigen". Wird vom Frontend bei jeder Bestätigen/Verwerfen-Aktion + // aufgerufen (siehe ScannerPage.tsx) - vorher lief das rein lokal über + // sessionStorage, wodurch ein Bestätigen auf einem Gerät/Browser auf einem + // anderen wirkungslos blieb (siehe Bugreport). + app.post("/api/scan/dismiss", async (request, reply) => { + const { kind, key } = request.body as { kind?: string; key?: string }; + const validKinds = ["new_service", "stale_service", "stale_device", "name_change", "device_change"]; + if (!kind || !validKinds.includes(kind) || !key) { + return reply.code(400).send({ error: "kind (eine der bekannten Arten) und key sind erforderlich." }); + } + dismissalRepo.dismiss(kind as dismissalRepo.DismissalKind, key); + return { dismissed: true }; + }); + app.post("/api/scan/devices/:id", async (request, reply) => { const { id } = request.params as { id: string }; const { full } = request.query as { full?: string }; @@ -228,7 +310,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { // 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"); + return reapplyDismissals(getJobStatus("devices")); }); app.post("/api/scan/devices/all/cancel", async () => { @@ -598,7 +680,7 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { // Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional // FRITZBOX_PORT, Default 49000) als Umgebungsvariablen. app.get("/api/scan/fritzbox/status", async () => { - return getJobStatus("fritzbox"); + return reapplyDismissals(getJobStatus("fritzbox")); }); app.post("/api/scan/fritzbox", async (_request, reply) => { @@ -662,18 +744,20 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { // Vergleich - der Platzhalter wurde als ganz normale "Namensänderung" // vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten // Namen dauerhaft überschreiben. - const deviceChanges = results.flatMap((r, i) => { - const scannedIp = mergedHosts[i].ip; - return r.changes - .filter((c) => { - if (c.field !== "hostname") return true; - const suggestedIsPlaceholder = isGenericFritzboxHostname(c.suggested, scannedIp); - const currentIsPlaceholder = isGenericFritzboxHostname(c.current, scannedIp); - return !(suggestedIsPlaceholder && !currentIsPlaceholder); - }) - .map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })); - } - ); + const dismissedDeviceChangeKeys = dismissalRepo.getDismissedKeys("device_change"); + const deviceChanges = results + .flatMap((r, i) => { + const scannedIp = mergedHosts[i].ip; + return r.changes + .filter((c) => { + if (c.field !== "hostname") return true; + const suggestedIsPlaceholder = isGenericFritzboxHostname(c.suggested, scannedIp); + const currentIsPlaceholder = isGenericFritzboxHostname(c.current, scannedIp); + return !(suggestedIsPlaceholder && !currentIsPlaceholder); + }) + .map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })); + }) + .filter((c) => !dismissedDeviceChangeKeys.has(`${c.deviceId}:${c.field}:${c.suggested}`)); // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht // mehr in der Liste sind – werden erst nach mindestens @@ -692,11 +776,20 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { // IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet, // obwohl es im selben Scan noch da war, nur unter neuer IP. const foundDeviceIds = new Set(devices.map((d) => d.id)); + + // Wie bei staleServices (siehe performDeviceScan oben): ein wieder + // gefundenes Gerät löst eine frühere "verschwunden"-Bestätigung auf, + // damit eine KÜNFTIGE Abwesenheit erneut vorgeschlagen wird, statt + // von einem alten Dismiss-Eintrag stumm unterdrückt zu werden. + dismissalRepo.clearDismissals("stale_device", Array.from(foundDeviceIds)); + const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); + const dismissedStaleDeviceIds = dismissalRepo.getDismissedKeys("stale_device"); const staleDevices = devicesBeforeScan.filter((d) => { if (foundDeviceIds.has(d.id)) return false; + if (dismissedStaleDeviceIds.has(d.id)) return false; const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0; return now - lastSeen >= staleThresholdMs; }); diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 3d1cbf3..a95253c 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -68,7 +68,33 @@ async function patchService(id: string, patch: Record) { } function nameChangeKey(c: ScanNameChange): string { - return `${c.serviceId}:${c.field}`; + return `${c.serviceId}:${c.field}:${c.suggested}`; +} + +function deviceChangeKey(c: { deviceId: string; field: string; suggested: string }): string { + return `${c.deviceId}:${c.field}:${c.suggested}`; +} + +/** + * Meldet eine Bestätigung/Ablehnung eines Scan-Vorschlags SERVERSEITIG + * (siehe routes/scan.ts POST /api/scan/dismiss), damit sie für ALLE + * Browser/Geräte gilt, nicht nur lokal im aufrufenden Browser + * (sessionStorage) - siehe Bugreport. + */ +async function dismissSuggestion(kind: string, key: string): Promise { + try { + await fetch("/api/scan/dismiss", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, key }), + }); + } catch { + // Netzwerk-Hänger hier soll die Bedienung nicht blockieren - die lokale + // Ausblendung (siehe Aufrufer) greift trotzdem sofort; bleibt der + // Dismiss serverseitig aus, taucht der Vorschlag beim nächsten Scan + // erneut auf, ist also kein Datenverlust, nur ein einmal erneutes + // Bestätigen nötig. + } } /** @@ -276,7 +302,7 @@ function DeviceChangesReview({ const applyMutation = useMutation({ mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }), onSuccess: (_data, change) => { - onResolve(`${change.deviceId}:${change.field}`); + onResolve(deviceChangeKey(change)); queryClient.invalidateQueries({ queryKey: ["devices"] }); }, }); @@ -292,7 +318,7 @@ function DeviceChangesReview({ {deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:

+