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

@@ -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 {

View File

@@ -287,6 +287,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
}
const id = randomUUID();
try {
db.insert(services)
.values({
id,
@@ -310,6 +311,18 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
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: [] };
}

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,8 +314,18 @@ 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." });
}
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");
@@ -353,13 +385,12 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
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 {
finishJob("fritzbox", {
found: hosts.length,
uniqueDevices: devices.length,
devices,
staleDevices,
deviceChanges,
};
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
@@ -367,8 +398,10 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`,
});
request.log.error(err);
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
failJob("fritzbox", detail);
}
})();
return reply.code(202).send({ started: true });
});
}

View File

@@ -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);
}

View File

@@ -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,
});
}

View File

@@ -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

View File

@@ -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 });
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(result.staleDevices);
setDeviceChanges(result.deviceChanges);
setStaleDevices(s.lastResult.staleDevices);
setDeviceChanges(s.lastResult.deviceChanges);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
onError: (error: Error) => setFritzboxError(error.message),
});
} 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">