diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 9cae0e7..965c1dc 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -136,6 +136,20 @@ function findByMacOrIp(mac: string | null | undefined, ip: string): Device | nul if (mac) { const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase()); if (byMac) return byMac; + + // Keine MAC-Übereinstimmung: nur noch über die IP abgleichen, wenn das per + // IP gefundene Gerät selbst KEINE eigene MAC-Adresse gespeichert hat. Hat + // es bereits eine (andere) MAC, handelt es sich nachweislich um ein + // anderes physisches Gerät, das (noch) dieselbe IP gemeldet bekommt - z. B. + // weil die FritzBox eine wiederverwendete/gerade neu vergebene IP kurzzeitig + // sowohl im alten als auch im neuen Host-Eintrag führt. Ohne diese Prüfung + // würde ein per Scan neu entdecktes Gerät fälschlich mit einem bereits + // bekannten, anderen Gerät zusammengeführt und als "Namensänderung" + // vorgeschlagen (siehe Bugreport: leere Datenbank, trotzdem sofort eine + // vorgeschlagene Änderung bei der allerersten FritzBox-Abfrage). + const byIp = all.find((d) => d.ip === ip); + if (byIp && !byIp.mac) return byIp; + return null; } return all.find((d) => d.ip === ip) ?? null; } diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 15bc40b..37062e3 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -4,7 +4,52 @@ 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 { scanDeviceServices } from "../scanner/networkScanner.js"; -import { fetchFritzBoxHosts } from "../scanner/fritzbox.js"; +import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; + +/** + * Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für + * Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher + * Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall. + */ +function isGenericFritzboxHostname(hostname: string, ip: string): boolean { + return hostname === `PC-${ip.replace(/\./g, "-")}`; +} + +/** + * Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE + * TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben + * dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln + * nacheinander upgeserted, legt der erste Eintrag das Gerät an und der + * zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte + * Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank + * (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag + * zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter + * Name statt "PC-"-Platzhalter, sofern vorhanden). + */ +function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { + const byMac = new Map(); + const withoutMac: FritzBoxHost[] = []; + + for (const host of hosts) { + if (!host.mac) { + withoutMac.push(host); + continue; + } + const key = host.mac.toLowerCase(); + const group = byMac.get(key); + if (group) group.push(host); + else byMac.set(key, [host]); + } + + const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => { + const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip)); + const chosen = named ?? group[0]; + const online = group.find((h) => h.online) ?? group[0]; + return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online }; + }); + + return [...merged, ...withoutMac]; +} /** * Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt @@ -139,7 +184,11 @@ 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 results = hosts.map((h) => + // Mehrere TR-064-Einträge für dasselbe physische Gerät (gleiche MAC) + // werden VOR dem Upsert zusammengeführt (siehe mergeFritzboxHosts oben) - + // deshalb kann "gefundene Einträge" > Anzahl eindeutiger Geräte sein. + const mergedHosts = mergeFritzboxHosts(hosts); + const results = mergedHosts.map((h) => deviceRepo.upsertDeviceFromScan({ hostname: h.hostname, ip: h.ip, @@ -149,18 +198,7 @@ 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; - }); + const devices = results.map((r) => r.device); // Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte // einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 9f15b71..f24b888 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -37,6 +37,27 @@ function isLikelyJunkTitle(title: string | undefined): boolean { return false; } +/** Prüft, ob ein String eine reine IPv4-Adresse ist (nicht als "Name" brauchbar). */ +function isIpAddress(value: string): boolean { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(value); +} + +/** + * Macht aus einem technischen Hostnamen ("plug-shelly-heiz-links", + * "nas_synology-01") einen lesbaren Anzeigenamen ("Plug Shelly Heiz Links"). + * Liefert null, wenn der Hostname selbst keine brauchbare Information trägt + * (leer oder reine IP) - dann bleibt nur der IP:Port-Fallback übrig. + */ +function humanizeHostname(hostname: string): string | null { + if (!hostname || isIpAddress(hostname)) return null; + const words = hostname + .replace(/\.(home|local|fritz\.box)$/i, "") + .split(/[-_.\s]+/) + .filter(Boolean); + if (words.length === 0) return null; + return words.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w)).join(" "); +} + export interface DeviceScanResult { services: DiscoveredService[]; /** @@ -87,7 +108,14 @@ export async function scanDeviceServices( }); const titleIsJunk = isLikelyJunkTitle(probe.title); - const fallbackName = `${address}:${port}`; + // Kein brauchbarer Titel gefunden (z. B. weil eine SPA ihren echten Titel + // erst per JavaScript setzt, das der Scanner nicht ausführt) - dann lieber + // den bereits bekannten Gerätenamen ("plug-shelly-heiz-links" -> + // "Plug Shelly Heiz Links") als Name verwenden statt einer nichtssagenden + // IP:Port-Kombination. Nur wenn auch der Gerätename keine Information + // trägt (leer/reine IP, z. B. bei unbenannten Geräten), bleibt IP:Port + // als letzter Ausweg übrig. + const fallbackName = humanizeHostname(device.hostname) ?? `${address}:${port}`; // Nur sichtbar, wenn Software erkannt wurde oder eine erfolgreiche Antwort // (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index c462476..960b0fd 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -338,8 +338,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { mutationFn: () => scanDevice(device.id), onSuccess: (result) => { const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine"; + const pending = result.nameChanges.length + result.staleServices.length; setScanMessage( - `Ports offen: ${portsText} · ${result.created} neu · ${result.updated} aktualisiert` + `Ports offen: ${portsText} · ${result.created} neu` + + (pending > 0 ? ` · ${pending} Änderung(en) warten auf Bestätigung` : "") ); setStaleServices(result.staleServices); setNameChanges(result.nameChanges); diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index b4ac893..c2608bd 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -356,10 +356,19 @@ export function ScannerPage() { } } + // "updated" zählt jeden bereits bekannten Dienst, dessen Favicon/Beschreibung + // aufgefrischt wurde - das ist normal und keine "Änderung" im Sinne von + // Namens-/Kategorie-Anpassungen. Deshalb hier NICHT mit anzeigen, um nicht + // den Eindruck zu erwecken, es sei bereits etwas Wichtiges verändert + // worden. Echte Änderungen/verschwundene Dienste stehen explizit unten in + // den Listen und müssen einzeln bestätigt werden. + void updated; + const pending = allNameChanges.length + allStale.length; setBulkStatus( - `Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${ - allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden` : "" - }${allNameChanges.length > 0 ? `, ${allNameChanges.length} Namensänderung(en) vorgeschlagen` : ""} (siehe unten).` + `Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue(r) Dienst(e) gefunden.` + + (pending > 0 + ? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.` + : " Keine Änderungen an bestehenden Diensten vorgeschlagen.") ); setBulkStaleServices(allStale); setBulkNameChanges(allNameChanges); diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 0997a2d..fa80246 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -240,6 +240,7 @@ function ServiceRow({ service, deviceMac, deviceIp, + deviceHostname, draggable, onDragStart, onDragOver, @@ -249,6 +250,7 @@ function ServiceRow({ service: Service; deviceMac: string | null; deviceIp: string | null; + deviceHostname: string | null; draggable: boolean; onDragStart: (e: DragEvent) => void; onDragOver: (e: DragEvent) => void; @@ -328,7 +330,16 @@ function ServiceRow({ - {service.hostname} + {/* Der gespeicherte Hostname ist oft nur die IP (wenn DNS nichts + Besseres auflösen konnte) - dann lieber den bekannten Gerätenamen + zeigen statt der IP doppelt (die steht schon in der Spalte + daneben). Der technische Wert bleibt beim Bearbeiten unverändert + editierbar/verwendbar, hier geht es nur um die Anzeige. */} + {service.hostname === deviceIp && deviceHostname ? ( + {deviceHostname} + ) : ( + service.hostname + )} {deviceIp ?? "–"} @@ -428,6 +439,12 @@ export function ServicesPage() { return map; }, [devices]); + const hostnameByDeviceId = useMemo(() => { + const map: Record = {}; + for (const d of devices ?? []) map[d.id] = d.hostname; + return map; + }, [devices]); + const reorderMutation = useMutation({ mutationFn: reorderServicesRequest, onSuccess: () => { @@ -569,6 +586,7 @@ export function ServicesPage() { service={service} deviceMac={macByDeviceId[service.deviceId] ?? null} deviceIp={ipByDeviceId[service.deviceId] ?? null} + deviceHostname={hostnameByDeviceId[service.deviceId] ?? null} draggable={dragEnabled} isDragging={draggedId === service.id} onDragStart={handleDragStart(service.id)} diff --git a/launchpad-current.zip b/launchpad-current.zip new file mode 100644 index 0000000..5c6b2d6 Binary files /dev/null and b/launchpad-current.zip differ