diff --git a/README.md b/README.md index beb23f0..d1eb47e 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,12 @@ POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthäl nichts davon wird automatisch übernommen, nur zur Bestätigung im Adminbereich zurückgegeben POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064; - Antwort enthält zusätzlich staleDevices + Antwort enthält found (rohe Einträge) und + uniqueDevices (nach MAC/IP zusammengeführt, + kann kleiner als found sein - kein Datenverlust), + zusätzlich staleDevices und deviceChanges + (abweichender Name/IP ggü. gespeichertem Wert, + nichts davon wird automatisch übernommen) GET /api/logs optional ?limit= (Default 100, Max 500) diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 3ffd29a..9cae0e7 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -113,6 +113,24 @@ export interface DeviceScanInput { source: Device["source"]; } +export interface DeviceChange { + field: "hostname" | "ip"; + current: string; + suggested: string; +} + +export interface DeviceScanUpsertResult { + device: Device; + /** + * Wird nur bei bereits bekannten Geräten befüllt, wenn ein frischer Scan + * (z. B. FritzBox meldet eine neue IP oder einen anderen Namen) vom + * gespeicherten Wert abweicht. Wird NICHT automatisch übernommen - nur zur + * manuellen Bestätigung im Adminbereich zurückgegeben, analog zu + * nameChanges bei Diensten (siehe services.ts). + */ + changes: DeviceChange[]; +} + function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null { const all = listDevices(); if (mac) { @@ -124,11 +142,19 @@ function findByMacOrIp(mac: string | null | undefined, ip: string): Device | nul /** * Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes - * (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im - * Sinne der Spezifikation (die betrifft nur Dienste) – Scans dürfen hier - * alle Felder aktualisieren. + * (abgeglichen über MAC, sonst IP). + * + * Bei einem BESTEHENDEN Gerät werden hostname/ip NICHT mehr automatisch + * überschrieben (frühere Version tat das bedingungslos) - stattdessen als + * changes zurückgegeben, damit der Nutzer selbst entscheidet, ob er eine neue + * IP/einen neuen Namen übernehmen möchte. Nur online/lastScan gelten als + * "Live-Status" und werden immer aktualisiert. Achtung: Wird eine geänderte + * IP bei einem Gerät OHNE bekannte MAC nie bestätigt, kann ein künftiger Scan + * dasselbe physische Gerät unter der neuen IP nicht mehr wiedererkennen und + * legt stattdessen einen neuen Eintrag an - mit MAC-Adresse (meist vorhanden) + * bleibt die Wiedererkennung unabhängig von der IP stabil. */ -export function upsertDeviceFromScan(input: DeviceScanInput): Device { +export function upsertDeviceFromScan(input: DeviceScanInput): DeviceScanUpsertResult { const existing = findByMacOrIp(input.mac, input.ip); const timestamp = nowIso(); const mac = input.mac ?? existing?.mac ?? null; @@ -140,8 +166,6 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device { if (existing) { db.update(devices) .set({ - hostname: input.hostname, - ip: input.ip, mac, manufacturer, model: input.model ?? existing.model, @@ -152,7 +176,16 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device { }) .where(eq(devices.id, existing.id)) .run(); - return getDevice(existing.id)!; + + const changes: DeviceChange[] = []; + if (input.hostname && input.hostname !== existing.hostname) { + changes.push({ field: "hostname", current: existing.hostname, suggested: input.hostname }); + } + if (input.ip && input.ip !== existing.ip) { + changes.push({ field: "ip", current: existing.ip, suggested: input.ip }); + } + + return { device: getDevice(existing.id)!, changes }; } const id = randomUUID(); @@ -172,5 +205,5 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device { }) .run(); - return getDevice(id)!; + return { device: getDevice(id)!, changes: [] }; } diff --git a/apps/backend/src/routes/plugins.ts b/apps/backend/src/routes/plugins.ts index 2b976a7..bc9679b 100644 --- a/apps/backend/src/routes/plugins.ts +++ b/apps/backend/src/routes/plugins.ts @@ -28,7 +28,7 @@ export async function pluginRoutes(app: FastifyInstance): Promise { try { const devices = await loaded.plugin.importDevices(); - const imported = devices.map((d) => + const results = devices.map((d) => deviceRepo.upsertDeviceFromScan({ hostname: d.hostname, ip: d.ip, @@ -39,14 +39,18 @@ export async function pluginRoutes(app: FastifyInstance): Promise { source: "plugin", }) ); + const imported = results.map((r) => r.device); + const deviceChanges = results.flatMap((r) => + r.changes.map((c) => ({ deviceId: r.device.id, ...c })) + ); logRepo.logScan({ type: "device", level: "info", - message: `Plugin "${name}": ${imported.length} Gerät(e) importiert`, + message: `Plugin "${name}": ${imported.length} Gerät(e) importiert${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""}`, }); - return { imported: imported.length, devices: imported }; + return { imported: imported.length, devices: imported, deviceChanges }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index b5c3123..15bc40b 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -139,7 +139,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox"); const hosts = await fetchFritzBoxHosts({ host, port, username, password }); - const devices = hosts.map((h) => + const results = hosts.map((h) => deviceRepo.upsertDeviceFromScan({ hostname: h.hostname, ip: h.ip, @@ -149,6 +149,26 @@ export async function scanRoutes(app: FastifyInstance): Promise { }) ); + // Mehrere TR-064-Einträge können auf dasselbe Gerät zusammengeführt + // werden (z. B. IPv4+IPv6 oder WLAN+Mesh-Eintrag desselben Geräts, + // abgeglichen über MAC/IP) - deshalb kann "gefundene Einträge" > Anzahl + // eindeutiger Geräte sein. Für die Rückgabe nach Geräte-ID dedupliziert. + const seenIds = new Set(); + const devices = results + .map((r) => r.device) + .filter((d) => { + if (seenIds.has(d.id)) return false; + seenIds.add(d.id); + return true; + }); + + // Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte + // einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch + // übernommen, nur zur Bestätigung zurückgegeben. + const deviceChanges = results.flatMap((r) => + r.changes.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })) + ); + // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht // mehr in der Liste sind – nicht automatisch gelöscht, nur zur // manuellen Durchsicht zurückgegeben. @@ -158,10 +178,16 @@ export async function scanRoutes(app: FastifyInstance): Promise { logRepo.logScan({ type: "fritzbox", level: "info", - message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}`, + message: `FritzBox-Scan: ${hosts.length} Eintrag/Einträge (${devices.length} eindeutige Geräte)${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""}`, }); - return { found: hosts.length, devices, staleDevices }; + return { + found: hosts.length, + uniqueDevices: devices.length, + devices, + staleDevices, + deviceChanges, + }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index b45e484..b4ac893 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -5,9 +5,19 @@ import type { Device, Service } from "@launchpad/shared"; import { useDevices } from "../../hooks/useDevices.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; +interface DeviceChange { + deviceId: string; + deviceHostname: string; + field: "hostname" | "ip"; + current: string; + suggested: string; +} + interface FritzBoxScanResult { found: number; + uniqueDevices: number; staleDevices: Device[]; + deviceChanges: DeviceChange[]; } async function scanFritzBox(): Promise { @@ -19,6 +29,18 @@ async function scanFritzBox(): Promise { return body; } +async function patchDevice(id: string, patch: Record) { + const res = await fetch(`/api/devices/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + throw new Error(`Gerät konnte nicht aktualisiert werden (HTTP ${res.status})`); + } + return res.json(); +} + async function deleteDeviceRequest(id: string) { const res = await fetch(`/api/devices/${id}`, { method: "DELETE" }); if (!res.ok && res.status !== 404) { @@ -184,6 +206,62 @@ function StaleServicesReview({ ); } +function DeviceChangesReview({ + deviceChanges, + onDone, +}: { + deviceChanges: DeviceChange[]; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + const [handled, setHandled] = useState>(new Set()); + + const applyMutation = useMutation({ + mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }), + onSuccess: (_data, change) => { + setHandled((prev) => new Set(prev).add(`${change.deviceId}:${change.field}`)); + queryClient.invalidateQueries({ queryKey: ["devices"] }); + }, + }); + + const remaining = deviceChanges.filter((c) => !handled.has(`${c.deviceId}:${c.field}`)); + if (remaining.length === 0) return null; + + const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP"); + + return ( +
+

+ {remaining.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab: +

+
    + {remaining.map((c) => ( +
  • + + {c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}" + +
    + + +
    +
  • + ))} +
+ +
+ ); +} + function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) { const queryClient = useQueryClient(); const [handled, setHandled] = useState>(new Set()); @@ -242,11 +320,13 @@ export function ScannerPage() { const [bulkStaleServices, setBulkStaleServices] = useState([]); const [bulkNameChanges, setBulkNameChanges] = useState([]); const [staleDevices, setStaleDevices] = useState([]); + const [deviceChanges, setDeviceChanges] = useState([]); const fritzboxMutation = useMutation({ mutationFn: scanFritzBox, onSuccess: (result) => { setStaleDevices(result.staleDevices); + setDeviceChanges(result.deviceChanges); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["logs"] }); return result; @@ -316,12 +396,20 @@ export function ScannerPage() { {fritzboxMutation.isSuccess ? (

- {fritzboxMutation.data.found} Gerät(e) gefunden. + {fritzboxMutation.data.found} Eintrag/Einträge von der FritzBox,{" "} + {fritzboxMutation.data.uniqueDevices} eindeutige Geräte + {fritzboxMutation.data.found !== fritzboxMutation.data.uniqueDevices + ? " (mehrere Einträge desselben Geräts wurden zusammengeführt)" + : ""} + .

) : null} {fritzboxMutation.isError ? (

{(fritzboxMutation.error as Error).message}

) : null} + {deviceChanges.length > 0 ? ( + setDeviceChanges([])} /> + ) : null} {staleDevices.length > 0 ? ( setStaleDevices([])} /> ) : null}