generated from Dicken/dickendock
Geraete-Aenderungen (Name/IP) jetzt auch nur als Vorschlag statt automatisch, klarere FritzBox-Zaehlung
This commit is contained in:
@@ -113,6 +113,24 @@ export interface DeviceScanInput {
|
||||
source: Device["source"];
|
||||
}
|
||||
|
||||
export interface DeviceChange {
|
||||
field: "hostname" | "ip";
|
||||
current: string;
|
||||
suggested: string;
|
||||
}
|
||||
|
||||
export interface DeviceScanUpsertResult {
|
||||
device: Device;
|
||||
/**
|
||||
* Wird nur bei bereits bekannten Geräten befüllt, wenn ein frischer Scan
|
||||
* (z. B. FritzBox meldet eine neue IP oder einen anderen Namen) vom
|
||||
* gespeicherten Wert abweicht. Wird NICHT automatisch übernommen - nur zur
|
||||
* manuellen Bestätigung im Adminbereich zurückgegeben, analog zu
|
||||
* nameChanges bei Diensten (siehe services.ts).
|
||||
*/
|
||||
changes: DeviceChange[];
|
||||
}
|
||||
|
||||
function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null {
|
||||
const all = listDevices();
|
||||
if (mac) {
|
||||
@@ -124,11 +142,19 @@ function findByMacOrIp(mac: string | null | undefined, ip: string): Device | nul
|
||||
|
||||
/**
|
||||
* Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes
|
||||
* (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im
|
||||
* Sinne der Spezifikation (die betrifft nur Dienste) – Scans dürfen hier
|
||||
* alle Felder aktualisieren.
|
||||
* (abgeglichen über MAC, sonst IP).
|
||||
*
|
||||
* Bei einem BESTEHENDEN Gerät werden hostname/ip NICHT mehr automatisch
|
||||
* überschrieben (frühere Version tat das bedingungslos) - stattdessen als
|
||||
* changes zurückgegeben, damit der Nutzer selbst entscheidet, ob er eine neue
|
||||
* IP/einen neuen Namen übernehmen möchte. Nur online/lastScan gelten als
|
||||
* "Live-Status" und werden immer aktualisiert. Achtung: Wird eine geänderte
|
||||
* IP bei einem Gerät OHNE bekannte MAC nie bestätigt, kann ein künftiger Scan
|
||||
* dasselbe physische Gerät unter der neuen IP nicht mehr wiedererkennen und
|
||||
* legt stattdessen einen neuen Eintrag an - mit MAC-Adresse (meist vorhanden)
|
||||
* bleibt die Wiedererkennung unabhängig von der IP stabil.
|
||||
*/
|
||||
export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||
export function upsertDeviceFromScan(input: DeviceScanInput): DeviceScanUpsertResult {
|
||||
const existing = findByMacOrIp(input.mac, input.ip);
|
||||
const timestamp = nowIso();
|
||||
const mac = input.mac ?? existing?.mac ?? null;
|
||||
@@ -140,8 +166,6 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||
if (existing) {
|
||||
db.update(devices)
|
||||
.set({
|
||||
hostname: input.hostname,
|
||||
ip: input.ip,
|
||||
mac,
|
||||
manufacturer,
|
||||
model: input.model ?? existing.model,
|
||||
@@ -152,7 +176,16 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||
})
|
||||
.where(eq(devices.id, existing.id))
|
||||
.run();
|
||||
return getDevice(existing.id)!;
|
||||
|
||||
const changes: DeviceChange[] = [];
|
||||
if (input.hostname && input.hostname !== existing.hostname) {
|
||||
changes.push({ field: "hostname", current: existing.hostname, suggested: input.hostname });
|
||||
}
|
||||
if (input.ip && input.ip !== existing.ip) {
|
||||
changes.push({ field: "ip", current: existing.ip, suggested: input.ip });
|
||||
}
|
||||
|
||||
return { device: getDevice(existing.id)!, changes };
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
@@ -172,5 +205,5 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||
})
|
||||
.run();
|
||||
|
||||
return getDevice(id)!;
|
||||
return { device: getDevice(id)!, changes: [] };
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function pluginRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
try {
|
||||
const devices = await loaded.plugin.importDevices();
|
||||
const imported = devices.map((d) =>
|
||||
const results = devices.map((d) =>
|
||||
deviceRepo.upsertDeviceFromScan({
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
@@ -39,14 +39,18 @@ export async function pluginRoutes(app: FastifyInstance): Promise<void> {
|
||||
source: "plugin",
|
||||
})
|
||||
);
|
||||
const imported = results.map((r) => r.device);
|
||||
const deviceChanges = results.flatMap((r) =>
|
||||
r.changes.map((c) => ({ deviceId: r.device.id, ...c }))
|
||||
);
|
||||
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
level: "info",
|
||||
message: `Plugin "${name}": ${imported.length} Gerät(e) importiert`,
|
||||
message: `Plugin "${name}": ${imported.length} Gerät(e) importiert${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""}`,
|
||||
});
|
||||
|
||||
return { imported: imported.length, devices: imported };
|
||||
return { imported: imported.length, devices: imported, deviceChanges };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
logRepo.logScan({
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
|
||||
|
||||
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
|
||||
const devices = hosts.map((h) =>
|
||||
const results = hosts.map((h) =>
|
||||
deviceRepo.upsertDeviceFromScan({
|
||||
hostname: h.hostname,
|
||||
ip: h.ip,
|
||||
@@ -149,6 +149,26 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
);
|
||||
|
||||
// Mehrere TR-064-Einträge können auf dasselbe Gerät zusammengeführt
|
||||
// werden (z. B. IPv4+IPv6 oder WLAN+Mesh-Eintrag desselben Geräts,
|
||||
// abgeglichen über MAC/IP) - deshalb kann "gefundene Einträge" > Anzahl
|
||||
// eindeutiger Geräte sein. Für die Rückgabe nach Geräte-ID dedupliziert.
|
||||
const seenIds = new Set<string>();
|
||||
const devices = results
|
||||
.map((r) => r.device)
|
||||
.filter((d) => {
|
||||
if (seenIds.has(d.id)) return false;
|
||||
seenIds.add(d.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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 – nicht automatisch gelöscht, nur zur
|
||||
// manuellen Durchsicht zurückgegeben.
|
||||
@@ -158,10 +178,16 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
logRepo.logScan({
|
||||
type: "fritzbox",
|
||||
level: "info",
|
||||
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}`,
|
||||
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, devices, staleDevices };
|
||||
return {
|
||||
found: hosts.length,
|
||||
uniqueDevices: devices.length,
|
||||
devices,
|
||||
staleDevices,
|
||||
deviceChanges,
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
logRepo.logScan({
|
||||
|
||||
@@ -5,9 +5,19 @@ import type { Device, Service } from "@launchpad/shared";
|
||||
import { useDevices } from "../../hooks/useDevices.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
interface DeviceChange {
|
||||
deviceId: string;
|
||||
deviceHostname: string;
|
||||
field: "hostname" | "ip";
|
||||
current: string;
|
||||
suggested: string;
|
||||
}
|
||||
|
||||
interface FritzBoxScanResult {
|
||||
found: number;
|
||||
uniqueDevices: number;
|
||||
staleDevices: Device[];
|
||||
deviceChanges: DeviceChange[];
|
||||
}
|
||||
|
||||
async function scanFritzBox(): Promise<FritzBoxScanResult> {
|
||||
@@ -19,6 +29,18 @@ async function scanFritzBox(): Promise<FritzBoxScanResult> {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function patchDevice(id: string, patch: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/devices/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Gerät konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteDeviceRequest(id: string) {
|
||||
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
@@ -184,6 +206,62 @@ function StaleServicesReview({
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceChangesReview({
|
||||
deviceChanges,
|
||||
onDone,
|
||||
}: {
|
||||
deviceChanges: DeviceChange[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
|
||||
onSuccess: (_data, change) => {
|
||||
setHandled((prev) => new Set(prev).add(`${change.deviceId}:${change.field}`));
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
},
|
||||
});
|
||||
|
||||
const remaining = deviceChanges.filter((c) => !handled.has(`${c.deviceId}:${c.field}`));
|
||||
if (remaining.length === 0) return null;
|
||||
|
||||
const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP");
|
||||
|
||||
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} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
||||
</p>
|
||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||
{remaining.map((c) => (
|
||||
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||
<span className="text-black/70 dark:text-white/70">
|
||||
{c.deviceHostname} – {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.deviceId}:${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 StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||
@@ -242,11 +320,13 @@ export function ScannerPage() {
|
||||
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
|
||||
const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]);
|
||||
const [staleDevices, setStaleDevices] = useState<Device[]>([]);
|
||||
const [deviceChanges, setDeviceChanges] = useState<DeviceChange[]>([]);
|
||||
|
||||
const fritzboxMutation = useMutation({
|
||||
mutationFn: scanFritzBox,
|
||||
onSuccess: (result) => {
|
||||
setStaleDevices(result.staleDevices);
|
||||
setDeviceChanges(result.deviceChanges);
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||
return result;
|
||||
@@ -316,12 +396,20 @@ export function ScannerPage() {
|
||||
</Button>
|
||||
{fritzboxMutation.isSuccess ? (
|
||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{fritzboxMutation.data.found} Gerät(e) gefunden.
|
||||
{fritzboxMutation.data.found} Eintrag/Einträge von der FritzBox,{" "}
|
||||
{fritzboxMutation.data.uniqueDevices} eindeutige Geräte
|
||||
{fritzboxMutation.data.found !== fritzboxMutation.data.uniqueDevices
|
||||
? " (mehrere Einträge desselben Geräts wurden zusammengeführt)"
|
||||
: ""}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{fritzboxMutation.isError ? (
|
||||
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p>
|
||||
) : null}
|
||||
{deviceChanges.length > 0 ? (
|
||||
<DeviceChangesReview deviceChanges={deviceChanges} onDone={() => setDeviceChanges([])} />
|
||||
) : null}
|
||||
{staleDevices.length > 0 ? (
|
||||
<StaleDevicesReview staleDevices={staleDevices} onDone={() => setStaleDevices([])} />
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user