generated from Dicken/dickendock
round59: Serverseitige, geraeteuebergreifende Bestaetigung von Scan-Vorschlaegen statt nur lokalem sessionStorage
This commit is contained in:
@@ -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<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> {
|
||||
// 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<void> {
|
||||
// 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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user