generated from Dicken/dickendock
round59: Serverseitige, geraeteuebergreifende Bestaetigung von Scan-Vorschlaegen statt nur lokalem sessionStorage
This commit is contained in:
@@ -128,6 +128,13 @@ export function ensureSchema(): void {
|
|||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
detected_at TEXT 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"/
|
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
|
||||||
@@ -159,6 +166,15 @@ export function ensureSchema(): void {
|
|||||||
sqlite.exec(
|
sqlite.exec(
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)`
|
`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)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
@@ -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": "<serviceId>:<feld>" (feld = "displayName" | "category")
|
||||||
|
* - "device_change": "<deviceId>:<feld>" (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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -170,3 +170,24 @@ export const detectedApis = sqliteTable("detected_apis", {
|
|||||||
status: integer("status").notNull(),
|
status: integer("status").notNull(),
|
||||||
detectedAt: text("detected_at").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 "<id>:<feld>"). 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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as serviceRepo from "../db/repositories/services.js";
|
|||||||
import * as categoryRepo from "../db/repositories/categories.js";
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
import * as logRepo from "../db/repositories/logs.js";
|
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 * as dismissalRepo from "../db/repositories/scanDismissals.js";
|
||||||
import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js";
|
import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js";
|
||||||
import { fullPortRange } from "../scanner/ports.js";
|
import { fullPortRange } from "../scanner/ports.js";
|
||||||
import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.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 staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
|
||||||
const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000;
|
const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleServices = servicesBeforeScan.filter((s) => {
|
const staleServiceCandidates = servicesBeforeScan.filter((s) => {
|
||||||
if (foundPorts.has(s.port)) return false;
|
if (foundPorts.has(s.port)) return false;
|
||||||
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
|
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
|
||||||
return now - lastSeen >= staleThresholdMs;
|
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
|
// Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste
|
||||||
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch
|
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch
|
||||||
// übernommen, nur zur Bestätigung zurückgegeben.
|
// übernommen, nur zur Bestätigung zurückgegeben. dedupeKey enthält den
|
||||||
const nameChanges = results.flatMap((r) =>
|
// 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) => ({
|
r.nameChanges.map((change) => ({
|
||||||
serviceId: r.service.id,
|
serviceId: r.service.id,
|
||||||
serviceHostname: r.service.hostname,
|
serviceHostname: r.service.hostname,
|
||||||
servicePort: r.service.port,
|
servicePort: r.service.port,
|
||||||
...change,
|
...change,
|
||||||
}))
|
}))
|
||||||
);
|
)
|
||||||
|
.filter((c) => !dismissedNameChangeKeys.has(`${c.serviceId}:${c.field}:${c.suggested}`));
|
||||||
|
|
||||||
// Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell
|
// Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell
|
||||||
// gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung.
|
// 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)}]`,
|
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 {
|
return {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
scannedPorts: discovered.length,
|
scannedPorts: discovered.length,
|
||||||
@@ -175,7 +196,10 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?:
|
|||||||
created,
|
created,
|
||||||
updated,
|
updated,
|
||||||
services: results.map((r) => r.service),
|
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,
|
staleServices,
|
||||||
nameChanges,
|
nameChanges,
|
||||||
deviceNameSuggestion,
|
deviceNameSuggestion,
|
||||||
@@ -188,7 +212,65 @@ async function performDeviceScan(device: Device, fullScan = false, preFetched?:
|
|||||||
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
|
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
|
||||||
* protokolliert und ist unter Admin -> Logs einsehbar.
|
* 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<typeof getJobStatus>): ReturnType<typeof getJobStatus> {
|
||||||
|
if (!status.lastResult) return status;
|
||||||
|
const r = status.lastResult as Record<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched: Record<string, unknown> = { ...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<void> {
|
export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// 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) => {
|
app.post("/api/scan/devices/:id", async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { full } = request.query as { full?: string };
|
const { full } = request.query as { full?: string };
|
||||||
@@ -228,7 +310,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
|
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
|
||||||
// HTTP-Anfrage bis zum Timeout.
|
// HTTP-Anfrage bis zum Timeout.
|
||||||
app.get("/api/scan/devices/all/status", async () => {
|
app.get("/api/scan/devices/all/status", async () => {
|
||||||
return getJobStatus("devices");
|
return reapplyDismissals(getJobStatus("devices"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scan/devices/all/cancel", async () => {
|
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
|
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
||||||
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
||||||
app.get("/api/scan/fritzbox/status", async () => {
|
app.get("/api/scan/fritzbox/status", async () => {
|
||||||
return getJobStatus("fritzbox");
|
return reapplyDismissals(getJobStatus("fritzbox"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scan/fritzbox", async (_request, reply) => {
|
app.post("/api/scan/fritzbox", async (_request, reply) => {
|
||||||
@@ -662,7 +744,9 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
|||||||
// Vergleich - der Platzhalter wurde als ganz normale "Namensänderung"
|
// Vergleich - der Platzhalter wurde als ganz normale "Namensänderung"
|
||||||
// vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten
|
// vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten
|
||||||
// Namen dauerhaft überschreiben.
|
// Namen dauerhaft überschreiben.
|
||||||
const deviceChanges = results.flatMap((r, i) => {
|
const dismissedDeviceChangeKeys = dismissalRepo.getDismissedKeys("device_change");
|
||||||
|
const deviceChanges = results
|
||||||
|
.flatMap((r, i) => {
|
||||||
const scannedIp = mergedHosts[i].ip;
|
const scannedIp = mergedHosts[i].ip;
|
||||||
return r.changes
|
return r.changes
|
||||||
.filter((c) => {
|
.filter((c) => {
|
||||||
@@ -672,8 +756,8 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
|||||||
return !(suggestedIsPlaceholder && !currentIsPlaceholder);
|
return !(suggestedIsPlaceholder && !currentIsPlaceholder);
|
||||||
})
|
})
|
||||||
.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c }));
|
.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
|
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||||
// mehr in der Liste sind – werden erst nach mindestens
|
// 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,
|
// IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet,
|
||||||
// obwohl es im selben Scan noch da war, nur unter neuer IP.
|
// obwohl es im selben Scan noch da war, nur unter neuer IP.
|
||||||
const foundDeviceIds = new Set(devices.map((d) => d.id));
|
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 staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
|
||||||
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
|
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const dismissedStaleDeviceIds = dismissalRepo.getDismissedKeys("stale_device");
|
||||||
const staleDevices = devicesBeforeScan.filter((d) => {
|
const staleDevices = devicesBeforeScan.filter((d) => {
|
||||||
if (foundDeviceIds.has(d.id)) return false;
|
if (foundDeviceIds.has(d.id)) return false;
|
||||||
|
if (dismissedStaleDeviceIds.has(d.id)) return false;
|
||||||
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
|
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
|
||||||
return now - lastSeen >= staleThresholdMs;
|
return now - lastSeen >= staleThresholdMs;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,7 +68,33 @@ async function patchService(id: string, patch: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nameChangeKey(c: ScanNameChange): string {
|
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<void> {
|
||||||
|
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({
|
const applyMutation = useMutation({
|
||||||
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
|
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
|
||||||
onSuccess: (_data, change) => {
|
onSuccess: (_data, change) => {
|
||||||
onResolve(`${change.deviceId}:${change.field}`);
|
onResolve(deviceChangeKey(change));
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -292,7 +318,7 @@ function DeviceChangesReview({
|
|||||||
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => deviceChanges.forEach((c) => onResolve(`${c.deviceId}:${c.field}`))}
|
onClick={() => deviceChanges.forEach((c) => onResolve(deviceChangeKey(c)))}
|
||||||
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
>
|
>
|
||||||
Alle bestätigen
|
Alle bestätigen
|
||||||
@@ -300,12 +326,12 @@ function DeviceChangesReview({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{deviceChanges.map((c) => (
|
{deviceChanges.map((c) => (
|
||||||
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={deviceChangeKey(c)} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-black/70 dark:text-white/70">
|
<span className="text-black/70 dark:text-white/70">
|
||||||
{c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
|
{c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button size="icon" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
|
<Button size="icon" variant="ghost" onClick={() => onResolve(deviceChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -716,11 +742,17 @@ export function ScannerPage() {
|
|||||||
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
|
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
|
||||||
<DeviceChangesReview
|
<DeviceChangesReview
|
||||||
deviceChanges={deviceChanges}
|
deviceChanges={deviceChanges}
|
||||||
onResolve={(key) => setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))}
|
onResolve={(key) => {
|
||||||
|
void dismissSuggestion("device_change", key);
|
||||||
|
setDeviceChanges((prev) => prev.filter((c) => deviceChangeKey(c) !== key));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<StaleDevicesReview
|
<StaleDevicesReview
|
||||||
staleDevices={staleDevices}
|
staleDevices={staleDevices}
|
||||||
onResolve={(id) => setStaleDevices((prev) => prev.filter((d) => d.id !== id))}
|
onResolve={(id) => {
|
||||||
|
void dismissSuggestion("stale_device", id);
|
||||||
|
setStaleDevices((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -757,15 +789,24 @@ export function ScannerPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<NewServicesList
|
<NewServicesList
|
||||||
newServices={bulkNewServices}
|
newServices={bulkNewServices}
|
||||||
onResolve={(id) => setBulkNewServices((prev) => prev.filter((s) => s.id !== id))}
|
onResolve={(id) => {
|
||||||
|
void dismissSuggestion("new_service", id);
|
||||||
|
setBulkNewServices((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<NameChangesReview
|
<NameChangesReview
|
||||||
nameChanges={bulkNameChanges}
|
nameChanges={bulkNameChanges}
|
||||||
onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
|
onResolve={(key) => {
|
||||||
|
void dismissSuggestion("name_change", key);
|
||||||
|
setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<StaleServicesReview
|
<StaleServicesReview
|
||||||
staleServices={bulkStaleServices}
|
staleServices={bulkStaleServices}
|
||||||
onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
|
onResolve={(id) => {
|
||||||
|
void dismissSuggestion("stale_service", id);
|
||||||
|
setBulkStaleServices((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user