generated from Dicken/dickendock
Fix: FritzBox faelschlich als Home Assistant erkannt + Reverse-DNS + Namens-Aenderungsvorschlaege beim Scan
This commit is contained in:
@@ -216,7 +216,11 @@ DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (nul
|
|||||||
|
|
||||||
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält
|
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält
|
||||||
zusätzlich staleServices (nicht mehr gefundene
|
zusätzlich staleServices (nicht mehr gefundene
|
||||||
Dienste, werden NICHT automatisch gelöscht)
|
Dienste), nameChanges (abweichender Name/
|
||||||
|
Kategorie ggü. gespeichertem Wert) und
|
||||||
|
deviceNameSuggestion (per Reverse-DNS) -
|
||||||
|
nichts davon wird automatisch übernommen,
|
||||||
|
nur zur Bestätigung im Adminbereich zurückgegeben
|
||||||
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064;
|
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064;
|
||||||
Antwort enthält zusätzlich staleDevices
|
Antwort enthält zusätzlich staleDevices
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,22 @@ export interface ServiceScanInput {
|
|||||||
suggestedVisible?: boolean;
|
suggestedVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScanNameChange {
|
||||||
|
field: "displayName" | "category";
|
||||||
|
current: string | null;
|
||||||
|
suggested: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanUpsertResult {
|
export interface ScanUpsertResult {
|
||||||
service: Service;
|
service: Service;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
|
/**
|
||||||
|
* Wird nur bei bereits bekannten Diensten befüllt, wenn ein frischer Scan
|
||||||
|
* einen anderen Namen/Kategorie vorschlagen würde als aktuell gespeichert.
|
||||||
|
* Wird NICHT automatisch übernommen (siehe updateService-Dokumentation
|
||||||
|
* oben) - nur zur manuellen Durchsicht/Bestätigung im Adminbereich.
|
||||||
|
*/
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function findByDeviceAndPort(deviceId: string, port: number): Service | null {
|
function findByDeviceAndPort(deviceId: string, port: number): Service | null {
|
||||||
@@ -176,9 +189,12 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null {
|
|||||||
* Alles andere – auch hostname/url/https, die früher bei jedem Scan
|
* Alles andere – auch hostname/url/https, die früher bei jedem Scan
|
||||||
* überschrieben wurden – bleibt unangetastet, damit manuelle Anpassungen
|
* überschrieben wurden – bleibt unangetastet, damit manuelle Anpassungen
|
||||||
* (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben.
|
* (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben.
|
||||||
|
* Falls der frische Scan einen anderen Namen/Kategorie vorschlagen würde,
|
||||||
|
* wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt
|
||||||
|
* entweder stillschweigend ignoriert oder automatisch übernommen zu werden.
|
||||||
*
|
*
|
||||||
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
||||||
* wirken NUR beim erstmaligen Anlegen.
|
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
||||||
*/
|
*/
|
||||||
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
|
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
|
||||||
const existing = findByDeviceAndPort(input.deviceId, input.port);
|
const existing = findByDeviceAndPort(input.deviceId, input.port);
|
||||||
@@ -193,7 +209,28 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
})
|
})
|
||||||
.where(eq(services.id, existing.id))
|
.where(eq(services.id, existing.id))
|
||||||
.run();
|
.run();
|
||||||
return { service: getService(existing.id)!, created: false };
|
|
||||||
|
const nameChanges: ScanNameChange[] = [];
|
||||||
|
if (input.suggestedDisplayName && input.suggestedDisplayName !== existing.displayName) {
|
||||||
|
nameChanges.push({
|
||||||
|
field: "displayName",
|
||||||
|
current: existing.displayName,
|
||||||
|
suggested: input.suggestedDisplayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.suggestedCategory !== undefined &&
|
||||||
|
(input.suggestedCategory ?? null) !== existing.category &&
|
||||||
|
input.suggestedCategory
|
||||||
|
) {
|
||||||
|
nameChanges.push({
|
||||||
|
field: "category",
|
||||||
|
current: existing.category,
|
||||||
|
suggested: input.suggestedCategory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { service: getService(existing.id)!, created: false, nameChanges };
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
@@ -219,5 +256,5 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return { service: getService(id)!, created: true };
|
return { service: getService(id)!, created: true, nameChanges: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||||
|
|
||||||
const discovered = await scanDeviceServices(device);
|
const { services: discovered, suggestedHostname } = await scanDeviceServices(device);
|
||||||
|
|
||||||
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
||||||
// sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert
|
// sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert
|
||||||
@@ -70,11 +70,28 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const foundPorts = new Set(discovered.map((d) => d.port));
|
const foundPorts = new Set(discovered.map((d) => d.port));
|
||||||
const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port));
|
const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port));
|
||||||
|
|
||||||
|
// Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste
|
||||||
|
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch
|
||||||
|
// übernommen, nur zur Bestätigung zurückgegeben.
|
||||||
|
const nameChanges = results.flatMap((r) =>
|
||||||
|
r.nameChanges.map((change) => ({
|
||||||
|
serviceId: r.service.id,
|
||||||
|
serviceHostname: r.service.hostname,
|
||||||
|
servicePort: r.service.port,
|
||||||
|
...change,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell
|
||||||
|
// gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung.
|
||||||
|
const deviceNameSuggestion =
|
||||||
|
suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null;
|
||||||
|
|
||||||
logRepo.logScan({
|
logRepo.logScan({
|
||||||
type: "device",
|
type: "device",
|
||||||
targetId: device.id,
|
targetId: device.id,
|
||||||
level: "info",
|
level: "info",
|
||||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}`,
|
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +102,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updated,
|
updated,
|
||||||
services: results.map((r) => r.service),
|
services: results.map((r) => r.service),
|
||||||
staleServices,
|
staleServices,
|
||||||
|
nameChanges,
|
||||||
|
deviceNameSuggestion,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const detail = err instanceof Error ? err.message : String(err);
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lookup } from "node:dns/promises";
|
import { lookup, reverse } from "node:dns/promises";
|
||||||
|
|
||||||
export interface DnsResolution {
|
export interface DnsResolution {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -23,3 +23,19 @@ export async function resolveHostname(shortName: string): Promise<DnsResolution
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umgekehrte DNS-Abfrage (PTR-Record): fragt anhand der IP nach einem Namen.
|
||||||
|
* Viele Router/DHCP-Server (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier
|
||||||
|
* automatisch den vom Gerät gemeldeten DHCP-Hostnamen ein, auch wenn die
|
||||||
|
* normale Vorwärtsauflösung (Name -> IP) nichts findet. Nützlich als
|
||||||
|
* zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts.
|
||||||
|
*/
|
||||||
|
export async function reverseLookup(ip: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const names = await reverse(ip);
|
||||||
|
return names[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resolveHostname } from "./dns.js";
|
import { resolveHostname, reverseLookup } from "./dns.js";
|
||||||
import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
||||||
import { probeHttp } from "./http.js";
|
import { probeHttp } from "./http.js";
|
||||||
import { detectSoftware } from "./softwareDetection.js";
|
import { detectSoftware } from "./softwareDetection.js";
|
||||||
@@ -37,6 +37,17 @@ function isLikelyJunkTitle(title: string | undefined): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceScanResult {
|
||||||
|
services: DiscoveredService[];
|
||||||
|
/**
|
||||||
|
* Vorschlag für einen besseren Gerätenamen per Reverse-DNS (PTR), falls die
|
||||||
|
* normale Vorwärtsauflösung nichts Besseres als die reine IP gefunden hat.
|
||||||
|
* Wird NICHT automatisch übernommen - nur zur manuellen Bestätigung
|
||||||
|
* zurückgegeben (siehe routes/scan.ts).
|
||||||
|
*/
|
||||||
|
suggestedHostname: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse
|
* Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse
|
||||||
* (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80,
|
* (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80,
|
||||||
@@ -46,11 +57,17 @@ function isLikelyJunkTitle(title: string | undefined): boolean {
|
|||||||
export async function scanDeviceServices(
|
export async function scanDeviceServices(
|
||||||
device: ScanTarget,
|
device: ScanTarget,
|
||||||
extraPorts: number[] = TYPICAL_PORTS
|
extraPorts: number[] = TYPICAL_PORTS
|
||||||
): Promise<DiscoveredService[]> {
|
): Promise<DeviceScanResult> {
|
||||||
const dnsResult = await resolveHostname(device.hostname);
|
const dnsResult = await resolveHostname(device.hostname);
|
||||||
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
|
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
|
||||||
const address = dnsResult?.hostname ?? device.ip;
|
const address = dnsResult?.hostname ?? device.ip;
|
||||||
|
|
||||||
|
// Nur wenn die Vorwärtsauflösung nichts Besseres als die IP gefunden hat,
|
||||||
|
// zusätzlich per Reverse-DNS (PTR) versuchen - viele Router/DHCP-Server
|
||||||
|
// (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier den vom Gerät
|
||||||
|
// gemeldeten Namen ein.
|
||||||
|
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||||
|
|
||||||
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
||||||
const found: DiscoveredService[] = [];
|
const found: DiscoveredService[] = [];
|
||||||
|
|
||||||
@@ -65,6 +82,7 @@ export async function scanDeviceServices(
|
|||||||
const software = detectSoftware({
|
const software = detectSoftware({
|
||||||
server: probe.server,
|
server: probe.server,
|
||||||
body: probe.bodySnippet,
|
body: probe.bodySnippet,
|
||||||
|
title: probe.title,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,5 +108,5 @@ export async function scanDeviceServices(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return found;
|
return { services: found, suggestedHostname };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface SoftwareSignatureInput {
|
export interface SoftwareSignatureInput {
|
||||||
server?: string;
|
server?: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
title?: string;
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,12 +17,29 @@ function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
|
|||||||
return !!input.body && pattern.test(input.body);
|
return !!input.body && pattern.test(input.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function titleMatches(input: SoftwareSignatureInput, pattern: RegExp): boolean {
|
||||||
|
return !!input.title && pattern.test(input.title.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
|
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
|
||||||
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
|
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
|
||||||
|
*
|
||||||
|
* Grundsatz: der `<title>`-Tag ist ein viel präziseres Signal als der
|
||||||
|
* komplette Seiteninhalt und wird deshalb wo möglich zuerst geprüft. Reine
|
||||||
|
* Volltextsuche im Body (bodyContains) besonders bei Marken-/Produktnamen
|
||||||
|
* aus generischen Alltagswörtern (z. B. "Home Assistant") kann in
|
||||||
|
* minifiziertem JavaScript anderer Web-UIs zufällig zutreffen - siehe
|
||||||
|
* Bugreport: FritzBox-Repeater wurden fälschlich als "Home Assistant"
|
||||||
|
* erkannt, weil beide Wörter irgendwo im ausgelieferten JS vorkamen.
|
||||||
*/
|
*/
|
||||||
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
|
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
|
||||||
{ name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) },
|
{
|
||||||
|
name: "Home Assistant",
|
||||||
|
category: "Smart Home",
|
||||||
|
matches: (i) =>
|
||||||
|
titleMatches(i, /^home\s*assistant$/i) || bodyContains(i, /frontend_latest\//i),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Synology DSM",
|
name: "Synology DSM",
|
||||||
category: "NAS",
|
category: "NAS",
|
||||||
|
|||||||
@@ -44,12 +44,35 @@ async function deleteServiceRequest(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchService(id: string, patch: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/services/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanNameChange {
|
||||||
|
serviceId: string;
|
||||||
|
serviceHostname: string;
|
||||||
|
servicePort: number;
|
||||||
|
field: "displayName" | "category";
|
||||||
|
current: string | null;
|
||||||
|
suggested: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ScanResult {
|
interface ScanResult {
|
||||||
scannedPorts: number;
|
scannedPorts: number;
|
||||||
ports: number[];
|
ports: number[];
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
staleServices: Service[];
|
staleServices: Service[];
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
|
deviceNameSuggestion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanDevice(id: string): Promise<ScanResult> {
|
async function scanDevice(id: string): Promise<ScanResult> {
|
||||||
@@ -150,6 +173,109 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NameChangesReview({
|
||||||
|
nameChanges,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: (change: ScanNameChange) =>
|
||||||
|
patchService(change.serviceId, { [change.field]: change.suggested }),
|
||||||
|
onSuccess: (_data, change) => {
|
||||||
|
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = nameChanges.filter(
|
||||||
|
(c) => !handled.has(`${c.serviceId}:${c.field}`)
|
||||||
|
);
|
||||||
|
if (remaining.length === 0) return null;
|
||||||
|
|
||||||
|
const fieldLabel = (field: ScanNameChange["field"]) =>
|
||||||
|
field === "displayName" ? "Name" : "Kategorie";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{remaining.map((c) => (
|
||||||
|
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-black/70 dark:text-white/70">
|
||||||
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}"
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
||||||
|
>
|
||||||
|
Behalten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => applyMutation.mutate(c)}
|
||||||
|
disabled={applyMutation.isPending}
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||||||
|
Hinweis schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceNameSuggestionBanner({
|
||||||
|
deviceId,
|
||||||
|
suggestion,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
deviceId: string;
|
||||||
|
suggestion: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: () => patchDevice(deviceId, { hostname: suggestion }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-black/70 dark:text-white/70">
|
||||||
|
Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||||
|
Behalten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) {
|
function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||||
@@ -203,6 +329,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [scanMessage, setScanMessage] = useState<string | null>(null);
|
const [scanMessage, setScanMessage] = useState<string | null>(null);
|
||||||
const [staleServices, setStaleServices] = useState<Service[]>([]);
|
const [staleServices, setStaleServices] = useState<Service[]>([]);
|
||||||
|
const [nameChanges, setNameChanges] = useState<ScanNameChange[]>([]);
|
||||||
|
const [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
@@ -214,6 +342,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
`Ports offen: ${portsText} · ${result.created} neu · ${result.updated} aktualisiert`
|
`Ports offen: ${portsText} · ${result.created} neu · ${result.updated} aktualisiert`
|
||||||
);
|
);
|
||||||
setStaleServices(result.staleServices);
|
setStaleServices(result.staleServices);
|
||||||
|
setNameChanges(result.nameChanges);
|
||||||
|
setDeviceNameSuggestion(result.deviceNameSuggestion);
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
},
|
},
|
||||||
@@ -293,12 +423,22 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{(scanMessage || staleServices.length > 0) && (
|
{(scanMessage || staleServices.length > 0 || nameChanges.length > 0 || deviceNameSuggestion) && (
|
||||||
<tr className="border-b border-black/5 dark:border-white/5">
|
<tr className="border-b border-black/5 dark:border-white/5">
|
||||||
<td colSpan={8} className="px-4 pb-3">
|
<td colSpan={8} className="px-4 pb-3">
|
||||||
{scanMessage ? (
|
{scanMessage ? (
|
||||||
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
|
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{deviceNameSuggestion ? (
|
||||||
|
<DeviceNameSuggestionBanner
|
||||||
|
deviceId={device.id}
|
||||||
|
suggestion={deviceNameSuggestion}
|
||||||
|
onDone={() => setDeviceNameSuggestion(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{nameChanges.length > 0 ? (
|
||||||
|
<NameChangesReview nameChanges={nameChanges} onDone={() => setNameChanges([])} />
|
||||||
|
) : null}
|
||||||
{staleServices.length > 0 ? (
|
{staleServices.length > 0 ? (
|
||||||
<StaleServicesReview staleServices={staleServices} onDone={() => setStaleServices([])} />
|
<StaleServicesReview staleServices={staleServices} onDone={() => setStaleServices([])} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -33,13 +33,99 @@ async function deleteServiceRequest(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScanNameChange {
|
||||||
|
serviceId: string;
|
||||||
|
serviceHostname: string;
|
||||||
|
servicePort: number;
|
||||||
|
field: "displayName" | "category";
|
||||||
|
current: string | null;
|
||||||
|
suggested: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchService(id: string, patch: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/services/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
async function scanDeviceById(id: string) {
|
async function scanDeviceById(id: string) {
|
||||||
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
||||||
}
|
}
|
||||||
return body as { created: number; updated: number; staleServices: Service[] };
|
return body as {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
staleServices: Service[];
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
|
deviceNameSuggestion: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameChangesReview({
|
||||||
|
nameChanges,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: (change: ScanNameChange) =>
|
||||||
|
patchService(change.serviceId, { [change.field]: change.suggested }),
|
||||||
|
onSuccess: (_data, change) => {
|
||||||
|
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = nameChanges.filter((c) => !handled.has(`${c.serviceId}:${c.field}`));
|
||||||
|
if (remaining.length === 0) return null;
|
||||||
|
|
||||||
|
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
|
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
||||||
|
{remaining.length} erkannte Änderung(en) über alle gescannten Geräte hinweg weichen vom
|
||||||
|
gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
|
{remaining.map((c) => (
|
||||||
|
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-black/70 dark:text-white/70">
|
||||||
|
{c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „
|
||||||
|
{c.suggested}"
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
|
||||||
|
>
|
||||||
|
Behalten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||||||
|
Hinweis schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StaleServicesReview({
|
function StaleServicesReview({
|
||||||
@@ -154,6 +240,7 @@ export function ScannerPage() {
|
|||||||
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
|
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
|
||||||
const [bulkRunning, setBulkRunning] = useState(false);
|
const [bulkRunning, setBulkRunning] = useState(false);
|
||||||
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
|
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
|
||||||
|
const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]);
|
||||||
const [staleDevices, setStaleDevices] = useState<Device[]>([]);
|
const [staleDevices, setStaleDevices] = useState<Device[]>([]);
|
||||||
|
|
||||||
const fritzboxMutation = useMutation({
|
const fritzboxMutation = useMutation({
|
||||||
@@ -170,9 +257,11 @@ export function ScannerPage() {
|
|||||||
if (!devices || devices.length === 0) return;
|
if (!devices || devices.length === 0) return;
|
||||||
setBulkRunning(true);
|
setBulkRunning(true);
|
||||||
setBulkStaleServices([]);
|
setBulkStaleServices([]);
|
||||||
|
setBulkNameChanges([]);
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
const allStale: Service[] = [];
|
const allStale: Service[] = [];
|
||||||
|
const allNameChanges: ScanNameChange[] = [];
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
try {
|
try {
|
||||||
@@ -180,6 +269,7 @@ export function ScannerPage() {
|
|||||||
created += result.created;
|
created += result.created;
|
||||||
updated += result.updated;
|
updated += result.updated;
|
||||||
allStale.push(...result.staleServices);
|
allStale.push(...result.staleServices);
|
||||||
|
allNameChanges.push(...result.nameChanges);
|
||||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
||||||
} catch {
|
} catch {
|
||||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
@@ -188,10 +278,11 @@ export function ScannerPage() {
|
|||||||
|
|
||||||
setBulkStatus(
|
setBulkStatus(
|
||||||
`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${
|
`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${
|
||||||
allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden (siehe unten)` : ""
|
allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden` : ""
|
||||||
}.`
|
}${allNameChanges.length > 0 ? `, ${allNameChanges.length} Namensänderung(en) vorgeschlagen` : ""} (siehe unten).`
|
||||||
);
|
);
|
||||||
setBulkStaleServices(allStale);
|
setBulkStaleServices(allStale);
|
||||||
|
setBulkNameChanges(allNameChanges);
|
||||||
setBulkRunning(false);
|
setBulkRunning(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
@@ -254,6 +345,9 @@ export function ScannerPage() {
|
|||||||
{bulkStatus ? (
|
{bulkStatus ? (
|
||||||
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{bulkNameChanges.length > 0 ? (
|
||||||
|
<NameChangesReview nameChanges={bulkNameChanges} onDone={() => setBulkNameChanges([])} />
|
||||||
|
) : null}
|
||||||
{bulkStaleServices.length > 0 ? (
|
{bulkStaleServices.length > 0 ? (
|
||||||
<StaleServicesReview
|
<StaleServicesReview
|
||||||
staleServices={bulkStaleServices}
|
staleServices={bulkStaleServices}
|
||||||
|
|||||||
Reference in New Issue
Block a user