generated from Dicken/dickendock
round12: FritzBox-Merge-Bug vor Doppel-Ausloesung fixen, Scan-Meldungen entschaerfen, Dienstname-Fallback aus Hostname statt IP
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
BIN
launchpad-current.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user