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: