round30: alle drei Scanner als Hintergrund-Job, Dienst-Duplikate strukturell verhindert (Migration+Constraint+Geraete-Sperre), zweiter Erkennungsversuch bei leerem Ersttreffer

This commit is contained in:
2026-07-24 15:14:00 +02:00
parent 2106c17301
commit 87fed21348
7 changed files with 308 additions and 140 deletions

View File

@@ -15,6 +15,8 @@ import {
isJobRunning,
isCancelRequested,
requestCancel,
tryLockDevice,
unlockDevice,
} from "../scanJobs.js";
import type { Device } from "@launchpad/shared";
@@ -141,6 +143,10 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
if (!tryLockDevice(device.id)) {
return reply.code(409).send({ error: "Für dieses Gerät läuft bereits ein Scan." });
}
try {
return await performDeviceScan(device);
} catch (err) {
@@ -153,6 +159,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
});
request.log.error(err);
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail });
} finally {
unlockDevice(device.id);
}
});
@@ -199,6 +207,14 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
break;
}
updateJobProgress("devices", scannedCount, device.hostname);
if (!tryLockDevice(device.id)) {
// Für dieses eine Gerät läuft bereits ein separater Scan (z. B.
// über den "Jetzt scannen"-Button auf der Geräte-Seite) -
// überspringen statt zu überlappen, sonst könnten beide
// gleichzeitig denselben neuen Dienst anlegen wollen.
scannedCount++;
continue;
}
try {
const result = await performDeviceScan(device);
created += result.created;
@@ -208,6 +224,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
allNew.push(...result.newServices);
} catch {
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
} finally {
unlockDevice(device.id);
}
scannedCount++;
}
@@ -280,7 +298,11 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
app.post("/api/scan/fritzbox", async (request, reply) => {
app.get("/api/scan/fritzbox/status", async () => {
return getJobStatus("fritzbox");
});
app.post("/api/scan/fritzbox", async (_request, reply) => {
const host = process.env.FRITZBOX_HOST;
const username = process.env.FRITZBOX_USERNAME;
const password = process.env.FRITZBOX_PASSWORD;
@@ -292,83 +314,94 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
});
}
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
try {
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
// 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,
mac: h.mac,
online: h.online,
source: "fritzbox",
})
);
const devices = results.map((r) => r.device);
// 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 werden erst nach mindestens
// staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht
// vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein
// kurzzeitig offline/im Standby befindliches Gerät (Reboot,
// WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen
// vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe
// upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht
// mehr da.
//
// "Gefunden" wird über die ID der in DIESEM Scan tatsächlich
// upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert
// sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem
// korrekt über seine MAC wiedererkannt und upgeserted - ein reiner
// 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));
const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
const now = Date.now();
const staleDevices = devicesBeforeScan.filter((d) => {
if (foundDeviceIds.has(d.id)) return false;
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
return now - lastSeen >= staleThresholdMs;
});
logRepo.logScan({
type: "fritzbox",
level: "info",
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,
uniqueDevices: devices.length,
devices,
staleDevices,
deviceChanges,
};
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
type: "fritzbox",
level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`,
});
request.log.error(err);
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
if (isJobRunning("fritzbox")) {
return reply.code(409).send({ error: "Es läuft bereits ein FritzBox-Scan." });
}
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
startJob("fritzbox", 1);
// Wie bei den anderen Scannern: läuft im Hintergrund weiter, die
// HTTP-Antwort kommt sofort - macht den Status auch hier
// geräteübergreifend abfragbar statt nur im Browser sichtbar, der ihn
// gestartet hat.
void (async () => {
try {
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
// 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,
mac: h.mac,
online: h.online,
source: "fritzbox",
})
);
const devices = results.map((r) => r.device);
// 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 werden erst nach mindestens
// staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht
// vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein
// kurzzeitig offline/im Standby befindliches Gerät (Reboot,
// WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen
// vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe
// upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht
// mehr da.
//
// "Gefunden" wird über die ID der in DIESEM Scan tatsächlich
// upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert
// sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem
// korrekt über seine MAC wiedererkannt und upgeserted - ein reiner
// 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));
const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
const now = Date.now();
const staleDevices = devicesBeforeScan.filter((d) => {
if (foundDeviceIds.has(d.id)) return false;
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
return now - lastSeen >= staleThresholdMs;
});
logRepo.logScan({
type: "fritzbox",
level: "info",
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` : ""}`,
});
finishJob("fritzbox", {
found: hosts.length,
uniqueDevices: devices.length,
staleDevices,
deviceChanges,
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
type: "fritzbox",
level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`,
});
failJob("fritzbox", detail);
}
})();
return reply.code(202).send({ started: true });
});
}