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_display_name", "TEXT");
|
||||||
ensureColumn("services", "last_suggested_category", "TEXT");
|
ensureColumn("services", "last_suggested_category", "TEXT");
|
||||||
ensureColumn("devices", "last_ping", "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 {
|
function ensureColumn(table: string, column: string, definition: string): void {
|
||||||
|
|||||||
@@ -287,29 +287,42 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
db.insert(services)
|
try {
|
||||||
.values({
|
db.insert(services)
|
||||||
id,
|
.values({
|
||||||
deviceId: input.deviceId,
|
id,
|
||||||
displayName: input.suggestedDisplayName,
|
deviceId: input.deviceId,
|
||||||
category: input.suggestedCategory ?? null,
|
displayName: input.suggestedDisplayName,
|
||||||
favorite: false,
|
category: input.suggestedCategory ?? null,
|
||||||
order: 0,
|
favorite: false,
|
||||||
alias: "[]",
|
order: 0,
|
||||||
icon: input.suggestedIcon ?? null,
|
alias: "[]",
|
||||||
hostname: input.hostname,
|
icon: input.suggestedIcon ?? null,
|
||||||
url: input.url,
|
hostname: input.hostname,
|
||||||
https: input.https,
|
url: input.url,
|
||||||
visible: input.suggestedVisible ?? true,
|
https: input.https,
|
||||||
port: input.port,
|
visible: input.suggestedVisible ?? true,
|
||||||
favicon: input.favicon ?? null,
|
port: input.port,
|
||||||
description: input.description ?? null,
|
favicon: input.favicon ?? null,
|
||||||
lastSuggestedDisplayName: input.suggestedDisplayName,
|
description: input.description ?? null,
|
||||||
lastSuggestedCategory: input.suggestedCategory ?? null,
|
lastSuggestedDisplayName: input.suggestedDisplayName,
|
||||||
createdAt: timestamp,
|
lastSuggestedCategory: input.suggestedCategory ?? null,
|
||||||
updatedAt: timestamp,
|
createdAt: timestamp,
|
||||||
})
|
updatedAt: timestamp,
|
||||||
.run();
|
})
|
||||||
|
.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: [] };
|
return { service: getService(id)!, created: true, nameChanges: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
isJobRunning,
|
isJobRunning,
|
||||||
isCancelRequested,
|
isCancelRequested,
|
||||||
requestCancel,
|
requestCancel,
|
||||||
|
tryLockDevice,
|
||||||
|
unlockDevice,
|
||||||
} from "../scanJobs.js";
|
} from "../scanJobs.js";
|
||||||
import type { Device } from "@launchpad/shared";
|
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" });
|
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 {
|
try {
|
||||||
return await performDeviceScan(device);
|
return await performDeviceScan(device);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -153,6 +159,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
request.log.error(err);
|
request.log.error(err);
|
||||||
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
updateJobProgress("devices", scannedCount, device.hostname);
|
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 {
|
try {
|
||||||
const result = await performDeviceScan(device);
|
const result = await performDeviceScan(device);
|
||||||
created += result.created;
|
created += result.created;
|
||||||
@@ -208,6 +224,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
allNew.push(...result.newServices);
|
allNew.push(...result.newServices);
|
||||||
} catch {
|
} catch {
|
||||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
|
} finally {
|
||||||
|
unlockDevice(device.id);
|
||||||
}
|
}
|
||||||
scannedCount++;
|
scannedCount++;
|
||||||
}
|
}
|
||||||
@@ -280,7 +298,11 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
|||||||
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
|
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
|
||||||
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
||||||
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
// 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 host = process.env.FRITZBOX_HOST;
|
||||||
const username = process.env.FRITZBOX_USERNAME;
|
const username = process.env.FRITZBOX_USERNAME;
|
||||||
const password = process.env.FRITZBOX_PASSWORD;
|
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;
|
if (isJobRunning("fritzbox")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein FritzBox-Scan." });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface ScanJobProgress {
|
||||||
current: number;
|
current: number;
|
||||||
@@ -80,3 +80,23 @@ export function failJob(type: ScanJobType, error: string): void {
|
|||||||
const job = jobs.get(type) ?? emptyStatus();
|
const job = jobs.get(type) ?? emptyStatus();
|
||||||
jobs.set(type, { ...job, running: false, lastError: error, cancelRequested: false });
|
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);
|
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
|
// Kein brauchbarer Titel gefunden (z. B. weil eine SPA ihren echten Titel
|
||||||
// erst per JavaScript setzt, das der Scanner nicht ausführt) - dann lieber
|
// erst per JavaScript setzt, das der Scanner nicht ausführt) - dann lieber
|
||||||
// den bereits bekannten Gerätenamen ("plug-shelly-heiz-links" ->
|
// 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
|
// 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
|
||||||
// Ladebildschirme landen als ausgeblendeter Dienst im Adminbereich.
|
// Ladebildschirme landen als ausgeblendeter Dienst im Adminbereich.
|
||||||
const visible = !!software || (probe.ok && !titleIsJunk);
|
const visible = !!softwareResolved || (probe.ok && !titleIsJunkResolved);
|
||||||
|
|
||||||
found.push({
|
found.push({
|
||||||
hostname: address,
|
hostname: address,
|
||||||
@@ -168,10 +196,11 @@ export async function scanDeviceServices(
|
|||||||
https: isHttps,
|
https: isHttps,
|
||||||
port,
|
port,
|
||||||
favicon: probe.faviconUrl,
|
favicon: probe.faviconUrl,
|
||||||
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
|
description: softwareResolved ? `${softwareResolved.name} (automatisch erkannt)` : probe.title,
|
||||||
suggestedDisplayName: software?.name ?? (titleIsJunk ? fallbackName : probe.title ?? fallbackName),
|
suggestedDisplayName:
|
||||||
category: software?.category,
|
softwareResolved?.name ?? (titleIsJunkResolved ? fallbackName : probe.title ?? fallbackName),
|
||||||
icon: software?.icon,
|
category: softwareResolved?.category,
|
||||||
|
icon: softwareResolved?.icon,
|
||||||
visible,
|
visible,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ export function useScanActivity(): ScanActivity {
|
|||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const [devicesRes, apisRes] = await Promise.all([
|
const [devicesRes, apisRes, fritzboxRes] = await Promise.all([
|
||||||
fetch("/api/scan/devices/all/status"),
|
fetch("/api/scan/devices/all/status"),
|
||||||
fetch("/api/scan/apis/status"),
|
fetch("/api/scan/apis/status"),
|
||||||
|
fetch("/api/scan/fritzbox/status"),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
|
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
|
||||||
const apisStatus = apisRes.ok ? await apisRes.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 lastResult = devicesStatus?.lastResult;
|
||||||
const pendingCount = lastResult
|
const pendingCount = lastResult
|
||||||
|
|||||||
@@ -18,22 +18,6 @@ interface DeviceChange {
|
|||||||
suggested: string;
|
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>) {
|
async function patchDevice(id: string, patch: Record<string, unknown>) {
|
||||||
const res = await fetch(`/api/devices/${id}`, {
|
const res = await fetch(`/api/devices/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -441,18 +425,56 @@ export function ScannerPage() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const fritzboxMutation = useMutation({
|
const [fritzboxRunning, setFritzboxRunning] = useState(false);
|
||||||
mutationFn: scanFritzBox,
|
const lastFritzboxHandledAt = useRef<string | null>(null);
|
||||||
onSuccess: (result) => {
|
|
||||||
setFritzboxSummary({ found: result.found, uniqueDevices: result.uniqueDevices });
|
useEffect(() => {
|
||||||
setFritzboxError(null);
|
let cancelled = false;
|
||||||
setStaleDevices(result.staleDevices);
|
|
||||||
setDeviceChanges(result.deviceChanges);
|
async function poll() {
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
try {
|
||||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
const res = await fetch("/api/scan/fritzbox/status");
|
||||||
},
|
if (!res.ok || cancelled) return;
|
||||||
onError: (error: Error) => setFritzboxError(error.message),
|
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
|
// Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend
|
||||||
// (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus
|
// (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus
|
||||||
@@ -548,10 +570,10 @@ export function ScannerPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
onClick={() => fritzboxMutation.mutate()}
|
onClick={startFritzboxScan}
|
||||||
disabled={fritzboxMutation.isPending}
|
disabled={fritzboxRunning}
|
||||||
>
|
>
|
||||||
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
{fritzboxRunning ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||||
</Button>
|
</Button>
|
||||||
{fritzboxSummary ? (
|
{fritzboxSummary ? (
|
||||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user