generated from Dicken/dickendock
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:
@@ -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<Record<string, unknown>>;
|
||||
|
||||
const groups = new Map<string, Array<Record<string, unknown>>>();
|
||||
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<string, unknown>): 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 {
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,22 +18,6 @@ interface DeviceChange {
|
||||
suggested: string;
|
||||
}
|
||||
|
||||
interface FritzBoxScanResult {
|
||||
found: number;
|
||||
uniqueDevices: number;
|
||||
staleDevices: Device[];
|
||||
deviceChanges: DeviceChange[];
|
||||
}
|
||||
|
||||
async function scanFritzBox(): Promise<FritzBoxScanResult> {
|
||||
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<string, unknown>) {
|
||||
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<string | null>(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() {
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-4"
|
||||
onClick={() => fritzboxMutation.mutate()}
|
||||
disabled={fritzboxMutation.isPending}
|
||||
onClick={startFritzboxScan}
|
||||
disabled={fritzboxRunning}
|
||||
>
|
||||
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||
{fritzboxRunning ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||
</Button>
|
||||
{fritzboxSummary ? (
|
||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
|
||||
Reference in New Issue
Block a user