generated from Dicken/dickendock
round13: manuelle vs. echte Aenderungen trennen, Scan-Uebersicht ueber Navigation persistieren, ausgeblendete Dienste als Tab, Suche ueber Geraetenamen
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@launchpad/ui";
|
||||
import type { Device, Service } from "@launchpad/shared";
|
||||
import { useDevices } from "../../hooks/useDevices.js";
|
||||
import { usePersistedState } from "../../hooks/usePersistedState.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
interface DeviceChange {
|
||||
@@ -62,6 +62,8 @@ interface ScanNameChange {
|
||||
field: "displayName" | "category";
|
||||
current: string | null;
|
||||
suggested: string;
|
||||
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde (siehe unten). */
|
||||
manualOverride: boolean;
|
||||
}
|
||||
|
||||
async function patchService(id: string, patch: Record<string, unknown>) {
|
||||
@@ -91,100 +93,128 @@ async function scanDeviceById(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function nameChangeKey(c: ScanNameChange): string {
|
||||
return `${c.serviceId}:${c.field}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
||||
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
||||
* danach wieder als "vom Scan" statt "manuell angepasst" gilt.
|
||||
*/
|
||||
function acceptNameChange(c: ScanNameChange) {
|
||||
const resetFlag = c.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
|
||||
return patchService(c.serviceId, { [c.field]: c.suggested, [resetFlag]: false });
|
||||
}
|
||||
|
||||
function NameChangesReview({
|
||||
nameChanges,
|
||||
onDone,
|
||||
onResolve,
|
||||
}: {
|
||||
nameChanges: ScanNameChange[];
|
||||
onDone: () => void;
|
||||
onResolve: (key: string) => 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 }),
|
||||
mutationFn: acceptNameChange,
|
||||
onSuccess: (_data, change) => {
|
||||
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
|
||||
onResolve(nameChangeKey(change));
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
});
|
||||
|
||||
const remaining = nameChanges.filter((c) => !handled.has(`${c.serviceId}:${c.field}`));
|
||||
if (remaining.length === 0) return null;
|
||||
if (nameChanges.length === 0) return null;
|
||||
|
||||
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
|
||||
const manual = nameChanges.filter((c) => c.manualOverride);
|
||||
const real = nameChanges.filter((c) => !c.manualOverride);
|
||||
|
||||
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>
|
||||
function renderList(list: ScanNameChange[]) {
|
||||
return (
|
||||
<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">
|
||||
{list.map((c) => (
|
||||
<li key={nameChangeKey(c)} 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}`))}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(nameChangeKey(c))}>
|
||||
Behalten
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{real.length > 0 ? (
|
||||
<div className="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">
|
||||
{real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||
</p>
|
||||
{renderList(real)}
|
||||
</div>
|
||||
) : null}
|
||||
{manual.length > 0 ? (
|
||||
<div className="rounded-xl border border-black/10 bg-black/[0.02] p-3 text-xs dark:border-white/10 dark:bg-white/[0.02]">
|
||||
<p className="mb-2 font-medium text-black/60 dark:text-white/60">
|
||||
{manual.length} Abweichung(en), weil du diesen Wert bereits manuell angepasst hast – der
|
||||
Scan schlägt lediglich erneut den automatisch erkannten Wert vor, es hat sich nichts am
|
||||
Dienst selbst geändert:
|
||||
</p>
|
||||
{renderList(manual)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleServicesReview({
|
||||
staleServices,
|
||||
onDone,
|
||||
onResolve,
|
||||
}: {
|
||||
staleServices: Service[];
|
||||
onDone: () => void;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteServiceRequest(id),
|
||||
onSuccess: (_data, id) => {
|
||||
setHandled((prev) => new Set(prev).add(id));
|
||||
onResolve(id);
|
||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||
},
|
||||
});
|
||||
|
||||
const remaining = staleServices.filter((s) => !handled.has(s.id));
|
||||
if (remaining.length === 0) return null;
|
||||
if (staleServices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
||||
{remaining.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
||||
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
||||
nicht mehr offen):
|
||||
</p>
|
||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||
{remaining.map((s) => (
|
||||
{staleServices.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-black/70 dark:text-white/70">
|
||||
{s.displayName} ({s.hostname}:{s.port})
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(s.id)}>
|
||||
Behalten
|
||||
</Button>
|
||||
<Button
|
||||
@@ -199,98 +229,95 @@ function StaleServicesReview({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||||
Hinweis schließen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceChangesReview({
|
||||
deviceChanges,
|
||||
onDone,
|
||||
onResolve,
|
||||
}: {
|
||||
deviceChanges: DeviceChange[];
|
||||
onDone: () => void;
|
||||
onResolve: (key: string) => 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}`));
|
||||
onResolve(`${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;
|
||||
if (deviceChanges.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:
|
||||
{deviceChanges.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) => (
|
||||
{deviceChanges.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}`))}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)}>
|
||||
Behalten
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}>
|
||||
<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 }) {
|
||||
function StaleDevicesReview({
|
||||
staleDevices,
|
||||
onResolve,
|
||||
}: {
|
||||
staleDevices: Device[];
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteDeviceRequest(id),
|
||||
onSuccess: (_data, id) => {
|
||||
setHandled((prev) => new Set(prev).add(id));
|
||||
onResolve(id);
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
},
|
||||
});
|
||||
|
||||
const remaining = staleDevices.filter((d) => !handled.has(d.id));
|
||||
if (remaining.length === 0) return null;
|
||||
if (staleDevices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
||||
{remaining.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||
mehr:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{remaining.map((d) => (
|
||||
{staleDevices.map((d) => (
|
||||
<li key={d.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-black/70 dark:text-white/70">
|
||||
{d.hostname} ({d.ip})
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(d.id))}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onResolve(d.id)}>
|
||||
Behalten
|
||||
</Button>
|
||||
<Button
|
||||
@@ -305,9 +332,6 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
|
||||
Hinweis schließen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -315,22 +339,41 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
|
||||
export function ScannerPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: devices } = useDevices();
|
||||
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
|
||||
const [bulkRunning, setBulkRunning] = useState(false);
|
||||
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
|
||||
const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]);
|
||||
const [staleDevices, setStaleDevices] = useState<Device[]>([]);
|
||||
const [deviceChanges, setDeviceChanges] = useState<DeviceChange[]>([]);
|
||||
|
||||
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
|
||||
const [bulkRunning, setBulkRunning] = usePersistedState<boolean>("scanner:bulkRunning", false);
|
||||
const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
|
||||
"scanner:bulkStaleServices",
|
||||
[]
|
||||
);
|
||||
const [bulkNameChanges, setBulkNameChanges] = usePersistedState<ScanNameChange[]>(
|
||||
"scanner:bulkNameChanges",
|
||||
[]
|
||||
);
|
||||
const [staleDevices, setStaleDevices] = usePersistedState<Device[]>("scanner:staleDevices", []);
|
||||
const [deviceChanges, setDeviceChanges] = usePersistedState<DeviceChange[]>(
|
||||
"scanner:deviceChanges",
|
||||
[]
|
||||
);
|
||||
const [fritzboxSummary, setFritzboxSummary] = usePersistedState<
|
||||
{ found: number; uniqueDevices: number } | null
|
||||
>("scanner:fritzboxSummary", null);
|
||||
const [fritzboxError, setFritzboxError] = usePersistedState<string | null>(
|
||||
"scanner:fritzboxError",
|
||||
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"] });
|
||||
return result;
|
||||
},
|
||||
onError: (error: Error) => setFritzboxError(error.message),
|
||||
});
|
||||
|
||||
async function scanAllDevices() {
|
||||
@@ -356,12 +399,6 @@ export function ScannerPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// "updated" zählt jeden bereits bekannten Dienst, dessen Favicon/Beschreibung
|
||||
// aufgefrischt wurde - das ist normal und keine "Änderung" im Sinne von
|
||||
// Namens-/Kategorie-Anpassungen. Deshalb hier NICHT mit anzeigen, um nicht
|
||||
// den Eindruck zu erwecken, es sei bereits etwas Wichtiges verändert
|
||||
// worden. Echte Änderungen/verschwundene Dienste stehen explizit unten in
|
||||
// den Listen und müssen einzeln bestätigt werden.
|
||||
void updated;
|
||||
const pending = allNameChanges.length + allStale.length;
|
||||
setBulkStatus(
|
||||
@@ -403,25 +440,25 @@ export function ScannerPage() {
|
||||
>
|
||||
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||
</Button>
|
||||
{fritzboxMutation.isSuccess ? (
|
||||
{fritzboxSummary ? (
|
||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{fritzboxMutation.data.found} Eintrag/Einträge von der FritzBox,{" "}
|
||||
{fritzboxMutation.data.uniqueDevices} eindeutige Geräte
|
||||
{fritzboxMutation.data.found !== fritzboxMutation.data.uniqueDevices
|
||||
{fritzboxSummary.found} Eintrag/Einträge von der FritzBox,{" "}
|
||||
{fritzboxSummary.uniqueDevices} eindeutige Geräte
|
||||
{fritzboxSummary.found !== fritzboxSummary.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}
|
||||
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
|
||||
<DeviceChangesReview
|
||||
deviceChanges={deviceChanges}
|
||||
onResolve={(key) => setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))}
|
||||
/>
|
||||
<StaleDevicesReview
|
||||
staleDevices={staleDevices}
|
||||
onResolve={(id) => setStaleDevices((prev) => prev.filter((d) => d.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||
@@ -442,15 +479,14 @@ export function ScannerPage() {
|
||||
{bulkStatus ? (
|
||||
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||
) : null}
|
||||
{bulkNameChanges.length > 0 ? (
|
||||
<NameChangesReview nameChanges={bulkNameChanges} onDone={() => setBulkNameChanges([])} />
|
||||
) : null}
|
||||
{bulkStaleServices.length > 0 ? (
|
||||
<StaleServicesReview
|
||||
staleServices={bulkStaleServices}
|
||||
onDone={() => setBulkStaleServices([])}
|
||||
/>
|
||||
) : null}
|
||||
<NameChangesReview
|
||||
nameChanges={bulkNameChanges}
|
||||
onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
|
||||
/>
|
||||
<StaleServicesReview
|
||||
staleServices={bulkStaleServices}
|
||||
onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user