round12: FritzBox-Merge-Bug vor Doppel-Ausloesung fixen, Scan-Meldungen entschaerfen, Dienstname-Fallback aus Hostname statt IP

This commit is contained in:
2026-07-22 23:01:01 +02:00
parent 54f224b650
commit 1f8218dff1
7 changed files with 129 additions and 20 deletions

View File

@@ -136,6 +136,20 @@ function findByMacOrIp(mac: string | null | undefined, ip: string): Device | nul
if (mac) { if (mac) {
const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase()); const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase());
if (byMac) return byMac; 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; return all.find((d) => d.ip === ip) ?? null;
} }

View File

@@ -4,7 +4,52 @@ 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 { scanDeviceServices } from "../scanner/networkScanner.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-<ip>"-Platzhalter, sofern vorhanden).
*/
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
const byMac = new Map<string, FritzBoxHost[]>();
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 * Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
@@ -139,7 +184,11 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox"); const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
const hosts = await fetchFritzBoxHosts({ host, port, username, password }); 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({ deviceRepo.upsertDeviceFromScan({
hostname: h.hostname, hostname: h.hostname,
ip: h.ip, ip: h.ip,
@@ -149,18 +198,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
}) })
); );
// Mehrere TR-064-Einträge können auf dasselbe Gerät zusammengeführt const devices = results.map((r) => r.device);
// 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<string>();
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 // Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte
// einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch // einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch

View File

@@ -37,6 +37,27 @@ function isLikelyJunkTitle(title: string | undefined): boolean {
return false; 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 { export interface DeviceScanResult {
services: DiscoveredService[]; services: DiscoveredService[];
/** /**
@@ -87,7 +108,14 @@ export async function scanDeviceServices(
}); });
const titleIsJunk = isLikelyJunkTitle(probe.title); 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 // Nur sichtbar, wenn Software erkannt wurde oder eine erfolgreiche Antwort
// (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und // (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und

View File

@@ -338,8 +338,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
mutationFn: () => scanDevice(device.id), mutationFn: () => scanDevice(device.id),
onSuccess: (result) => { onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine"; const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
const pending = result.nameChanges.length + result.staleServices.length;
setScanMessage( 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); setStaleServices(result.staleServices);
setNameChanges(result.nameChanges); setNameChanges(result.nameChanges);

View File

@@ -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( setBulkStatus(
`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${ `Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue(r) Dienst(e) gefunden.` +
allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden` : "" (pending > 0
}${allNameChanges.length > 0 ? `, ${allNameChanges.length} Namensänderung(en) vorgeschlagen` : ""} (siehe unten).` ? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
); );
setBulkStaleServices(allStale); setBulkStaleServices(allStale);
setBulkNameChanges(allNameChanges); setBulkNameChanges(allNameChanges);

View File

@@ -240,6 +240,7 @@ function ServiceRow({
service, service,
deviceMac, deviceMac,
deviceIp, deviceIp,
deviceHostname,
draggable, draggable,
onDragStart, onDragStart,
onDragOver, onDragOver,
@@ -249,6 +250,7 @@ function ServiceRow({
service: Service; service: Service;
deviceMac: string | null; deviceMac: string | null;
deviceIp: string | null; deviceIp: string | null;
deviceHostname: string | null;
draggable: boolean; draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void; onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void; onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
@@ -328,7 +330,16 @@ function ServiceRow({
</div> </div>
</td> </td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60"> <td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{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 ? (
<span title={`Technisch: ${service.hostname}`}>{deviceHostname}</span>
) : (
service.hostname
)}
</td> </td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60"> <td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{deviceIp ?? ""} {deviceIp ?? ""}
@@ -428,6 +439,12 @@ export function ServicesPage() {
return map; return map;
}, [devices]); }, [devices]);
const hostnameByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.hostname;
return map;
}, [devices]);
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: reorderServicesRequest, mutationFn: reorderServicesRequest,
onSuccess: () => { onSuccess: () => {
@@ -569,6 +586,7 @@ export function ServicesPage() {
service={service} service={service}
deviceMac={macByDeviceId[service.deviceId] ?? null} deviceMac={macByDeviceId[service.deviceId] ?? null}
deviceIp={ipByDeviceId[service.deviceId] ?? null} deviceIp={ipByDeviceId[service.deviceId] ?? null}
deviceHostname={hostnameByDeviceId[service.deviceId] ?? null}
draggable={dragEnabled} draggable={dragEnabled}
isDragging={draggedId === service.id} isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)} onDragStart={handleDragStart(service.id)}

BIN
launchpad-current.zip Normal file

Binary file not shown.