From 87fed213482d99b856fb93fc55652cd7f24d1f1f Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 24 Jul 2026 15:14:00 +0200 Subject: [PATCH] round30: alle drei Scanner als Hintergrund-Job, Dienst-Duplikate strukturell verhindert (Migration+Constraint+Geraete-Sperre), zweiter Erkennungsversuch bei leerem Ersttreffer --- apps/backend/src/db/client.ts | 49 +++++ apps/backend/src/db/repositories/services.ts | 59 +++--- apps/backend/src/routes/scan.ts | 189 ++++++++++-------- apps/backend/src/scanJobs.ts | 22 +- apps/backend/src/scanner/networkScanner.ts | 39 +++- apps/frontend/src/hooks/useScanActivity.ts | 6 +- .../frontend/src/routes/admin/ScannerPage.tsx | 84 +++++--- 7 files changed, 308 insertions(+), 140 deletions(-) diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 7c0278a..080f94e 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -145,6 +145,55 @@ export function ensureSchema(): void { ensureColumn("services", "last_suggested_display_name", "TEXT"); ensureColumn("services", "last_suggested_category", "TEXT"); ensureColumn("devices", "last_ping", "TEXT"); + + deduplicateServicesByDeviceAndPort(); + + // Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben + // (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei + // Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft + // noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine + // Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund + // zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben + // anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten + // scheitern. + sqlite.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)` + ); +} + +/** + * Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei + * älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung + * des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt + * haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon, + * Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at). + */ +function deduplicateServicesByDeviceAndPort(): void { + const rows = sqlite + .prepare(`SELECT * FROM services ORDER BY created_at ASC`) + .all() as Array>; + + const groups = new Map>>(); + for (const row of rows) { + const key = `${row.device_id}::${row.port}`; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + + function richness(row: Record): number { + return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length; + } + + for (const group of groups.values()) { + if (group.length <= 1) continue; + + const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0]; + const toDelete = group.filter((r) => r.id !== keeper.id); + for (const row of toDelete) { + sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string); + } + } } function ensureColumn(table: string, column: string, definition: string): void { diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index c2d4849..1e24e81 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -287,29 +287,42 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult } const id = randomUUID(); - db.insert(services) - .values({ - id, - deviceId: input.deviceId, - displayName: input.suggestedDisplayName, - category: input.suggestedCategory ?? null, - favorite: false, - order: 0, - alias: "[]", - icon: input.suggestedIcon ?? null, - hostname: input.hostname, - url: input.url, - https: input.https, - visible: input.suggestedVisible ?? true, - port: input.port, - favicon: input.favicon ?? null, - description: input.description ?? null, - lastSuggestedDisplayName: input.suggestedDisplayName, - lastSuggestedCategory: input.suggestedCategory ?? null, - createdAt: timestamp, - updatedAt: timestamp, - }) - .run(); + try { + db.insert(services) + .values({ + id, + deviceId: input.deviceId, + displayName: input.suggestedDisplayName, + category: input.suggestedCategory ?? null, + favorite: false, + order: 0, + alias: "[]", + icon: input.suggestedIcon ?? null, + hostname: input.hostname, + url: input.url, + https: input.https, + visible: input.suggestedVisible ?? true, + port: input.port, + favicon: input.favicon ?? null, + description: input.description ?? null, + lastSuggestedDisplayName: input.suggestedDisplayName, + lastSuggestedCategory: input.suggestedCategory ?? null, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + } catch (err) { + // UNIQUE-Constraint (device_id, port) hat zugeschlagen - zwei + // überlappende Scans desselben Geräts haben beide "existiert noch + // nicht" gesehen und wollten beide anlegen. Statt einen Fehler nach + // oben zu werfen: der jeweils andere Versuch hat gewonnen, also einfach + // DESSEN Zeile als Ergebnis zurückgeben (kein Duplikat, kein Datenverlust). + const winner = findByDeviceAndPort(input.deviceId, input.port); + if (winner) { + return { service: winner, created: false, nameChanges: [] }; + } + throw err; + } return { service: getService(id)!, created: true, nameChanges: [] }; } diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 6eb3784..fa09e2d 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -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 { 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 { }); 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 { 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 { 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 }); }); } diff --git a/apps/backend/src/scanJobs.ts b/apps/backend/src/scanJobs.ts index 4ee25f4..67d42e9 100644 --- a/apps/backend/src/scanJobs.ts +++ b/apps/backend/src/scanJobs.ts @@ -1,4 +1,4 @@ -export type ScanJobType = "devices" | "apis"; +export type ScanJobType = "devices" | "apis" | "fritzbox"; export interface ScanJobProgress { current: number; @@ -80,3 +80,23 @@ export function failJob(type: ScanJobType, error: string): void { const job = jobs.get(type) ?? emptyStatus(); jobs.set(type, { ...job, running: false, lastError: error, cancelRequested: false }); } + +/** + * Sperrt ein einzelnes Gerät gegen ÜBERLAPPENDE Scans - unabhängig davon, ob + * der Scan über den Einzelgerät-Endpunkt oder als Teil des Sammel-Scans + * ("Alle Geräte scannen") läuft. Ohne das könnten zwei gleichzeitige Scans + * desselben Geräts beide unabhängig voneinander denselben neuen Dienst + * anlegen wollen (siehe UNIQUE-Index in client.ts als zusätzliches + * Sicherheitsnetz auf Datenbankebene). + */ +const devicesCurrentlyScanning = new Set(); + +export function tryLockDevice(deviceId: string): boolean { + if (devicesCurrentlyScanning.has(deviceId)) return false; + devicesCurrentlyScanning.add(deviceId); + return true; +} + +export function unlockDevice(deviceId: string): void { + devicesCurrentlyScanning.delete(deviceId); +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 5dbb10d..d94f3e7 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -148,6 +148,34 @@ export async function scanDeviceServices( }); const titleIsJunk = isLikelyJunkTitle(probe.title); + // Verbindung stand, aber weder Titel noch Software erkennbar (z. B. eine + // SPA, die beim allerersten Laden noch keinen Inhalt geliefert hat, oder + // ein Dienst, der kurz gebraucht hat um hochzufahren) - ein zweiter + // Versuch, bevor Sichtbarkeit/Favicon/Name dauerhaft auf Basis eines + // einzelnen, möglicherweise unvollständigen ersten Aufrufs entschieden + // werden. + let finalProbe = probe; + let finalSoftware = software; + let finalTitleIsJunk = titleIsJunk; + if (probe.ok && titleIsJunk && !software) { + const retryProbe = await probeHttp(baseUrl); + const retrySoftware = detectSoftware({ + server: retryProbe.server, + body: retryProbe.bodySnippet, + title: retryProbe.title, + port, + }); + const retryTitleIsJunk = isLikelyJunkTitle(retryProbe.title); + if (retrySoftware || !retryTitleIsJunk) { + finalProbe = retryProbe; + finalSoftware = retrySoftware; + finalTitleIsJunk = retryTitleIsJunk; + } + } + probe = finalProbe; + const softwareResolved = finalSoftware; + const titleIsJunkResolved = finalTitleIsJunk; + // 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" -> @@ -160,7 +188,7 @@ export async function scanDeviceServices( // Nur sichtbar, wenn Software erkannt wurde oder eine erfolgreiche Antwort // (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und // Ladebildschirme landen als ausgeblendeter Dienst im Adminbereich. - const visible = !!software || (probe.ok && !titleIsJunk); + const visible = !!softwareResolved || (probe.ok && !titleIsJunkResolved); found.push({ hostname: address, @@ -168,10 +196,11 @@ export async function scanDeviceServices( https: isHttps, port, favicon: probe.faviconUrl, - description: software ? `${software.name} (automatisch erkannt)` : probe.title, - suggestedDisplayName: software?.name ?? (titleIsJunk ? fallbackName : probe.title ?? fallbackName), - category: software?.category, - icon: software?.icon, + description: softwareResolved ? `${softwareResolved.name} (automatisch erkannt)` : probe.title, + suggestedDisplayName: + softwareResolved?.name ?? (titleIsJunkResolved ? fallbackName : probe.title ?? fallbackName), + category: softwareResolved?.category, + icon: softwareResolved?.icon, visible, }); } diff --git a/apps/frontend/src/hooks/useScanActivity.ts b/apps/frontend/src/hooks/useScanActivity.ts index b88aa22..f68793b 100644 --- a/apps/frontend/src/hooks/useScanActivity.ts +++ b/apps/frontend/src/hooks/useScanActivity.ts @@ -22,16 +22,18 @@ export function useScanActivity(): ScanActivity { async function poll() { try { - const [devicesRes, apisRes] = await Promise.all([ + const [devicesRes, apisRes, fritzboxRes] = await Promise.all([ fetch("/api/scan/devices/all/status"), fetch("/api/scan/apis/status"), + fetch("/api/scan/fritzbox/status"), ]); if (cancelled) return; const devicesStatus = devicesRes.ok ? await devicesRes.json() : null; const apisStatus = apisRes.ok ? await apisRes.json() : null; + const fritzboxStatus = fritzboxRes.ok ? await fritzboxRes.json() : null; - const running = !!devicesStatus?.running || !!apisStatus?.running; + const running = !!devicesStatus?.running || !!apisStatus?.running || !!fritzboxStatus?.running; const lastResult = devicesStatus?.lastResult; const pendingCount = lastResult diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 704c7e8..9da6601 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -18,22 +18,6 @@ interface DeviceChange { suggested: string; } -interface FritzBoxScanResult { - found: number; - uniqueDevices: number; - staleDevices: Device[]; - deviceChanges: DeviceChange[]; -} - -async function scanFritzBox(): Promise { - const res = await fetch("/api/scan/fritzbox", { method: "POST" }); - const body = await res.json(); - if (!res.ok) { - throw new Error(body.error ?? `FritzBox-Scan fehlgeschlagen (HTTP ${res.status})`); - } - return body; -} - async function patchDevice(id: string, patch: Record) { const res = await fetch(`/api/devices/${id}`, { method: "PATCH", @@ -441,18 +425,56 @@ export function ScannerPage() { null ); - const fritzboxMutation = useMutation({ - mutationFn: scanFritzBox, - onSuccess: (result) => { - setFritzboxSummary({ found: result.found, uniqueDevices: result.uniqueDevices }); - setFritzboxError(null); - setStaleDevices(result.staleDevices); - setDeviceChanges(result.deviceChanges); - queryClient.invalidateQueries({ queryKey: ["devices"] }); - queryClient.invalidateQueries({ queryKey: ["logs"] }); - }, - onError: (error: Error) => setFritzboxError(error.message), - }); + const [fritzboxRunning, setFritzboxRunning] = useState(false); + const lastFritzboxHandledAt = useRef(null); + + useEffect(() => { + let cancelled = false; + + async function poll() { + try { + const res = await fetch("/api/scan/fritzbox/status"); + if (!res.ok || cancelled) return; + const s = await res.json(); + + setFritzboxRunning(s.running); + + if (!s.running && s.lastResult && s.startedAt !== lastFritzboxHandledAt.current) { + lastFritzboxHandledAt.current = s.startedAt; + setFritzboxSummary({ found: s.lastResult.found, uniqueDevices: s.lastResult.uniqueDevices }); + setFritzboxError(null); + setStaleDevices(s.lastResult.staleDevices); + setDeviceChanges(s.lastResult.deviceChanges); + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["logs"] }); + } else if (!s.running && s.lastError && s.startedAt !== lastFritzboxHandledAt.current) { + lastFritzboxHandledAt.current = s.startedAt; + setFritzboxError(s.lastError); + } + } catch { + // Netzwerk-Hänger beim Poll soll nicht die Seite stören. + } + } + + poll(); + const interval = setInterval(poll, 1500); + return () => { + cancelled = true; + clearInterval(interval); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function startFritzboxScan() { + setFritzboxError(null); + const res = await fetch("/api/scan/fritzbox", { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setFritzboxError(body.error ?? `Fehlgeschlagen (HTTP ${res.status})`); + return; + } + setFritzboxRunning(true); + } // Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend // (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus @@ -548,10 +570,10 @@ export function ScannerPage() { {fritzboxSummary ? (