round13: manuelle vs. echte Aenderungen trennen, Scan-Uebersicht ueber Navigation persistieren, ausgeblendete Dienste als Tab, Suche ueber Geraetenamen

This commit is contained in:
2026-07-23 01:21:31 +02:00
parent 1f8218dff1
commit 2772c29036
10 changed files with 322 additions and 126 deletions

View File

@@ -53,6 +53,8 @@ export function ensureSchema(): void {
url TEXT NOT NULL, url TEXT NOT NULL,
https INTEGER NOT NULL DEFAULT 0, https INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1, visible INTEGER NOT NULL DEFAULT 1,
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
category_edited_manually INTEGER NOT NULL DEFAULT 0,
port INTEGER NOT NULL, port INTEGER NOT NULL,
favicon TEXT, favicon TEXT,
description TEXT, description TEXT,
@@ -123,6 +125,11 @@ export function ensureSchema(): void {
// sich am bisherigen Verhalten nichts unerwartet ändert. // sich am bisherigen Verhalten nichts unerwartet ändert.
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("categories", "color", "TEXT"); ensureColumn("categories", "color", "TEXT");
// Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs.
// "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
} }
function ensureColumn(table: string, column: string, definition: string): void { function ensureColumn(table: string, column: string, definition: string): void {

View File

@@ -91,6 +91,14 @@ export function createService(input: ServiceCreateInput): Service {
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die
* NIEMALS displayName, category, favorite, order, alias, icon, hostname, url, * NIEMALS displayName, category, favorite, order, alias, icon, hostname, url,
* https oder visible eines bereits bekannten Dienstes überschreibt. * https oder visible eines bereits bekannten Dienstes überschreibt.
*
* Ein PATCH mit displayName/category markiert das jeweilige Feld standardmäßig
* als "manuell bearbeitet" (displayNameEditedManually/categoryEditedManually),
* damit ein späterer Scan-Vorschlag, der davon abweicht, in der Änderungs-
* übersicht klar als "weicht nur wegen deiner manuellen Anpassung ab" markiert
* werden kann statt als echte inhaltliche Änderung. Beim Übernehmen eines
* Scan-Vorschlags wird die jeweilige *EditedManually-Flag explizit auf false
* gesetzt (siehe ScannerPage/DevicesPage "Übernehmen"-Button).
*/ */
export function updateService(id: string, input: ServiceUpdateInput): Service | null { export function updateService(id: string, input: ServiceUpdateInput): Service | null {
const existing = getService(id); const existing = getService(id);
@@ -98,12 +106,18 @@ export function updateService(id: string, input: ServiceUpdateInput): Service |
db.update(services) db.update(services)
.set({ .set({
...(input.displayName !== undefined && { displayName: input.displayName }), ...(input.displayName !== undefined && {
displayName: input.displayName,
displayNameEditedManually: input.displayNameEditedManually ?? true,
}),
...(input.hostname !== undefined && { hostname: input.hostname }), ...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.url !== undefined && { url: input.url }), ...(input.url !== undefined && { url: input.url }),
...(input.https !== undefined && { https: input.https }), ...(input.https !== undefined && { https: input.https }),
...(input.port !== undefined && { port: input.port }), ...(input.port !== undefined && { port: input.port }),
...(input.category !== undefined && { category: input.category }), ...(input.category !== undefined && {
category: input.category,
categoryEditedManually: input.categoryEditedManually ?? true,
}),
...(input.icon !== undefined && { icon: input.icon }), ...(input.icon !== undefined && { icon: input.icon }),
...(input.favicon !== undefined && { favicon: input.favicon }), ...(input.favicon !== undefined && { favicon: input.favicon }),
...(input.description !== undefined && { description: input.description }), ...(input.description !== undefined && { description: input.description }),
@@ -163,6 +177,14 @@ export interface ScanNameChange {
field: "displayName" | "category"; field: "displayName" | "category";
current: string | null; current: string | null;
suggested: string; suggested: string;
/**
* true, wenn der aktuell gespeicherte Wert vom Scan-Vorschlag nur deshalb
* abweicht, weil der Nutzer ihn zuvor manuell angepasst hat (siehe
* displayNameEditedManually/categoryEditedManually). Solche Änderungen sind
* erwartbar/harmlos und sollten in der UI klar von echten inhaltlichen
* Änderungen getrennt dargestellt werden.
*/
manualOverride: boolean;
} }
export interface ScanUpsertResult { export interface ScanUpsertResult {
@@ -201,6 +223,10 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
const timestamp = nowIso(); const timestamp = nowIso();
if (existing) { if (existing) {
// Rohzeile inkl. der *EditedManually-Flags laden, die mapRow() bewusst
// nicht nach außen gibt (nur intern für den Vergleich hier relevant).
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
db.update(services) db.update(services)
.set({ .set({
favicon: input.favicon ?? existing.favicon, favicon: input.favicon ?? existing.favicon,
@@ -216,6 +242,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
field: "displayName", field: "displayName",
current: existing.displayName, current: existing.displayName,
suggested: input.suggestedDisplayName, suggested: input.suggestedDisplayName,
manualOverride: existingRow.displayNameEditedManually,
}); });
} }
if ( if (
@@ -227,6 +254,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
field: "category", field: "category",
current: existing.category, current: existing.category,
suggested: input.suggestedCategory, suggested: input.suggestedCategory,
manualOverride: existingRow.categoryEditedManually,
}); });
} }

View File

@@ -42,6 +42,17 @@ export const services = sqliteTable("services", {
https: integer("https", { mode: "boolean" }).notNull().default(false), https: integer("https", { mode: "boolean" }).notNull().default(false),
// Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich. // Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich.
visible: integer("visible", { mode: "boolean" }).notNull().default(true), visible: integer("visible", { mode: "boolean" }).notNull().default(true),
// Wird true, sobald displayName/category einmal manuell über die Admin-UI
// gesetzt wurden (siehe updateService). Dient dazu, bei einem erneuten Scan
// zu erkennen: weicht der Wert vom Scan-Vorschlag ab, WEIL der Nutzer ihn
// manuell angepasst hat (harmlos, nur zur Info) oder weil sich der
// tatsächlich erkannte Wert geändert hat (echte Änderung)?
displayNameEditedManually: integer("display_name_edited_manually", { mode: "boolean" })
.notNull()
.default(false),
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
.notNull()
.default(false),
// Bei jedem Scan aktualisierbar // Bei jedem Scan aktualisierbar
port: integer("port").notNull(), port: integer("port").notNull(),

View File

@@ -0,0 +1,40 @@
import { useCallback, useState } from "react";
/**
* Wie useState, aber der Wert überlebt zusätzlich das Verlassen und erneute
* Betreten einer Seite (z. B. Scanner -> Geräte -> zurück zu Scanner), ohne
* dass ein laufender Scan erneut ausgeführt werden muss. Nutzt sessionStorage
* (pro Browser-Tab, weg nach Schließen des Tabs - bewusst kein dauerhafter
* Speicher, da es sich um vorübergehende "wartet auf Bestätigung"-Zustände
* handelt, keine echten Nutzdaten).
*/
export function usePersistedState<T>(key: string, initial: T): [T, (value: T | ((prev: T) => T)) => void] {
const storageKey = `launchpad:${key}`;
const [state, setState] = useState<T>(() => {
try {
const raw = sessionStorage.getItem(storageKey);
return raw !== null ? (JSON.parse(raw) as T) : initial;
} catch {
return initial;
}
});
const setPersisted = useCallback(
(value: T | ((prev: T) => T)) => {
setState((prev) => {
const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
try {
sessionStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// z. B. Speicher voll oder Privatmodus - dann bleibt der Zustand
// wenigstens für den Rest dieser Sitzung im Memory erhalten.
}
return next;
});
},
[storageKey]
);
return [state, setPersisted];
}

View File

@@ -5,6 +5,7 @@ import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button }
import { rankServices, type SearchResult } from "@launchpad/shared"; import { rankServices, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js"; import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js"; import { useBookmarks } from "../hooks/useBookmarks.js";
import { useDevices } from "../hooks/useDevices.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.js"; import { useTheme } from "../hooks/useTheme.js";
import { useCategories } from "../hooks/useCategories.js"; import { useCategories } from "../hooks/useCategories.js";
@@ -92,6 +93,7 @@ export function HomePage() {
const { health, error: healthError } = useBackendHealth(); const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices(); const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks(); const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
const { data: devices } = useDevices();
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const { data: recentVisits } = useRecentVisits(); const { data: recentVisits } = useRecentVisits();
const { data: readLaterItems } = useReadLater(); const { data: readLaterItems } = useReadLater();
@@ -138,15 +140,20 @@ export function HomePage() {
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer, // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
// siehe Admin -> Dienste) tauchen in der Suche nicht auf. // siehe Admin -> Dienste) tauchen in der Suche nicht auf.
const allItems: SearchResult[] = useMemo(() => { const allItems: SearchResult[] = useMemo(() => {
const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname]));
const visibleServices: SearchResult[] = (services ?? []) const visibleServices: SearchResult[] = (services ?? [])
.filter((s) => s.visible) .filter((s) => s.visible)
.map((s) => ({ ...s, kind: "service" as const })); .map((s) => ({
...s,
kind: "service" as const,
deviceHostname: deviceHostnameById.get(s.deviceId) ?? null,
}));
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({ const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
...b, ...b,
kind: "bookmark" as const, kind: "bookmark" as const,
})); }));
return [...visibleServices, ...bookmarkItems]; return [...visibleServices, ...bookmarkItems];
}, [services, bookmarks]); }, [services, bookmarks, devices]);
const results = useMemo(() => rankServices(allItems, query), [allItems, query]); const results = useMemo(() => rankServices(allItems, query), [allItems, query]);

View File

@@ -63,6 +63,8 @@ interface ScanNameChange {
field: "displayName" | "category"; field: "displayName" | "category";
current: string | null; current: string | null;
suggested: string; suggested: string;
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde. */
manualOverride: boolean;
} }
interface ScanResult { interface ScanResult {
@@ -184,8 +186,10 @@ function NameChangesReview({
const [handled, setHandled] = useState<Set<string>>(new Set()); const [handled, setHandled] = useState<Set<string>>(new Set());
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: (change: ScanNameChange) => mutationFn: (change: ScanNameChange) => {
patchService(change.serviceId, { [change.field]: change.suggested }), const resetFlag = change.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
return patchService(change.serviceId, { [change.field]: change.suggested, [resetFlag]: false });
},
onSuccess: (_data, change) => { onSuccess: (_data, change) => {
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`)); setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
@@ -199,14 +203,13 @@ function NameChangesReview({
const fieldLabel = (field: ScanNameChange["field"]) => const fieldLabel = (field: ScanNameChange["field"]) =>
field === "displayName" ? "Name" : "Kategorie"; field === "displayName" ? "Name" : "Kategorie";
const manual = remaining.filter((c) => c.manualOverride);
const real = remaining.filter((c) => !c.manualOverride);
function renderList(list: ScanNameChange[]) {
return ( 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"> <ul className="space-y-1">
{remaining.map((c) => ( {list.map((c) => (
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2"> <li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
{c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „{c.suggested}" {c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „{c.suggested}"
@@ -231,7 +234,28 @@ function NameChangesReview({
</li> </li>
))} ))}
</ul> </ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40"> );
}
return (
<div className="mt-2 space-y-2">
{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:
</p>
{renderList(manual)}
</div>
) : null}
<button onClick={onDone} className="text-black/40 underline dark:text-white/40">
Hinweis schließen Hinweis schließen
</button> </button>
</div> </div>

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Device, Service } from "@launchpad/shared"; import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { usePersistedState } from "../../hooks/usePersistedState.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
interface DeviceChange { interface DeviceChange {
@@ -62,6 +62,8 @@ interface ScanNameChange {
field: "displayName" | "category"; field: "displayName" | "category";
current: string | null; current: string | null;
suggested: string; 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>) { 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({ function NameChangesReview({
nameChanges, nameChanges,
onDone, onResolve,
}: { }: {
nameChanges: ScanNameChange[]; nameChanges: ScanNameChange[];
onDone: () => void; onResolve: (key: string) => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: (change: ScanNameChange) => mutationFn: acceptNameChange,
patchService(change.serviceId, { [change.field]: change.suggested }),
onSuccess: (_data, change) => { onSuccess: (_data, change) => {
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`)); onResolve(nameChangeKey(change));
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
}, },
}); });
const remaining = nameChanges.filter((c) => !handled.has(`${c.serviceId}:${c.field}`)); if (nameChanges.length === 0) return null;
if (remaining.length === 0) return null;
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie"); const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
const manual = nameChanges.filter((c) => c.manualOverride);
const real = nameChanges.filter((c) => !c.manualOverride);
function renderList(list: ScanNameChange[]) {
return ( 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"> <ul className="max-h-60 space-y-1 overflow-y-auto">
{remaining.map((c) => ( {list.map((c) => (
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2"> <li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
{c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „ {c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „
{c.suggested}" {c.suggested}"
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button size="sm" variant="ghost" onClick={() => onResolve(nameChangeKey(c))}>
size="sm"
variant="ghost"
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
>
Behalten Behalten
</Button> </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 Übernehmen
</Button> </Button>
</div> </div>
</li> </li>
))} ))}
</ul> </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> </div>
); );
} }
function StaleServicesReview({ function StaleServicesReview({
staleServices, staleServices,
onDone, onResolve,
}: { }: {
staleServices: Service[]; staleServices: Service[];
onDone: () => void; onResolve: (id: string) => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteServiceRequest(id), mutationFn: (id: string) => deleteServiceRequest(id),
onSuccess: (_data, id) => { onSuccess: (_data, id) => {
setHandled((prev) => new Set(prev).add(id)); onResolve(id);
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
}, },
}); });
const remaining = staleServices.filter((s) => !handled.has(s.id)); if (staleServices.length === 0) return null;
if (remaining.length === 0) return null;
return ( return (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs"> <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"> <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): nicht mehr offen):
</p> </p>
<ul className="max-h-60 space-y-1 overflow-y-auto"> <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"> <li key={s.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
{s.displayName} ({s.hostname}:{s.port}) {s.displayName} ({s.hostname}:{s.port})
</span> </span>
<div className="flex gap-1"> <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 Behalten
</Button> </Button>
<Button <Button
@@ -199,98 +229,95 @@ function StaleServicesReview({
</li> </li>
))} ))}
</ul> </ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div> </div>
); );
} }
function DeviceChangesReview({ function DeviceChangesReview({
deviceChanges, deviceChanges,
onDone, onResolve,
}: { }: {
deviceChanges: DeviceChange[]; deviceChanges: DeviceChange[];
onDone: () => void; onResolve: (key: string) => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }), mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
onSuccess: (_data, change) => { onSuccess: (_data, change) => {
setHandled((prev) => new Set(prev).add(`${change.deviceId}:${change.field}`)); onResolve(`${change.deviceId}:${change.field}`);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
}, },
}); });
const remaining = deviceChanges.filter((c) => !handled.has(`${c.deviceId}:${c.field}`)); if (deviceChanges.length === 0) return null;
if (remaining.length === 0) return null;
const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP"); const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP");
return ( return (
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs"> <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"> <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> </p>
<ul className="max-h-60 space-y-1 overflow-y-auto"> <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"> <li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
{c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}" {c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}"
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button size="sm" variant="ghost" onClick={() => onResolve(`${c.deviceId}:${c.field}`)}>
size="sm"
variant="ghost"
onClick={() => setHandled((prev) => new Set(prev).add(`${c.deviceId}:${c.field}`))}
>
Behalten Behalten
</Button> </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 Übernehmen
</Button> </Button>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div> </div>
); );
} }
function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) { function StaleDevicesReview({
staleDevices,
onResolve,
}: {
staleDevices: Device[];
onResolve: (id: string) => void;
}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteDeviceRequest(id), mutationFn: (id: string) => deleteDeviceRequest(id),
onSuccess: (_data, id) => { onSuccess: (_data, id) => {
setHandled((prev) => new Set(prev).add(id)); onResolve(id);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
}, },
}); });
const remaining = staleDevices.filter((d) => !handled.has(d.id)); if (staleDevices.length === 0) return null;
if (remaining.length === 0) return null;
return ( return (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs"> <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"> <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: mehr:
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{remaining.map((d) => ( {staleDevices.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2"> <li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70"> <span className="text-black/70 dark:text-white/70">
{d.hostname} ({d.ip}) {d.hostname} ({d.ip})
</span> </span>
<div className="flex gap-1"> <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 Behalten
</Button> </Button>
<Button <Button
@@ -305,9 +332,6 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
</li> </li>
))} ))}
</ul> </ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div> </div>
); );
} }
@@ -315,22 +339,41 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[];
export function ScannerPage() { export function ScannerPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
const [bulkRunning, setBulkRunning] = useState(false); const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]); const [bulkRunning, setBulkRunning] = usePersistedState<boolean>("scanner:bulkRunning", false);
const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]); const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
const [staleDevices, setStaleDevices] = useState<Device[]>([]); "scanner:bulkStaleServices",
const [deviceChanges, setDeviceChanges] = useState<DeviceChange[]>([]); []
);
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({ const fritzboxMutation = useMutation({
mutationFn: scanFritzBox, mutationFn: scanFritzBox,
onSuccess: (result) => { onSuccess: (result) => {
setFritzboxSummary({ found: result.found, uniqueDevices: result.uniqueDevices });
setFritzboxError(null);
setStaleDevices(result.staleDevices); setStaleDevices(result.staleDevices);
setDeviceChanges(result.deviceChanges); setDeviceChanges(result.deviceChanges);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["logs"] }); queryClient.invalidateQueries({ queryKey: ["logs"] });
return result;
}, },
onError: (error: Error) => setFritzboxError(error.message),
}); });
async function scanAllDevices() { 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; void updated;
const pending = allNameChanges.length + allStale.length; const pending = allNameChanges.length + allStale.length;
setBulkStatus( setBulkStatus(
@@ -403,25 +440,25 @@ export function ScannerPage() {
> >
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"} {fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
</Button> </Button>
{fritzboxMutation.isSuccess ? ( {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">
{fritzboxMutation.data.found} Eintrag/Einträge von der FritzBox,{" "} {fritzboxSummary.found} Eintrag/Einträge von der FritzBox,{" "}
{fritzboxMutation.data.uniqueDevices} eindeutige Geräte {fritzboxSummary.uniqueDevices} eindeutige Geräte
{fritzboxMutation.data.found !== fritzboxMutation.data.uniqueDevices {fritzboxSummary.found !== fritzboxSummary.uniqueDevices
? " (mehrere Einträge desselben Geräts wurden zusammengeführt)" ? " (mehrere Einträge desselben Geräts wurden zusammengeführt)"
: ""} : ""}
. .
</p> </p>
) : null} ) : null}
{fritzboxMutation.isError ? ( {fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p> <DeviceChangesReview
) : null} deviceChanges={deviceChanges}
{deviceChanges.length > 0 ? ( onResolve={(key) => setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))}
<DeviceChangesReview deviceChanges={deviceChanges} onDone={() => setDeviceChanges([])} /> />
) : null} <StaleDevicesReview
{staleDevices.length > 0 ? ( staleDevices={staleDevices}
<StaleDevicesReview staleDevices={staleDevices} onDone={() => setStaleDevices([])} /> onResolve={(id) => setStaleDevices((prev) => prev.filter((d) => d.id !== id))}
) : null} />
</div> </div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
@@ -442,15 +479,14 @@ 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
<NameChangesReview nameChanges={bulkNameChanges} onDone={() => setBulkNameChanges([])} /> nameChanges={bulkNameChanges}
) : null} onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
{bulkStaleServices.length > 0 ? ( />
<StaleServicesReview <StaleServicesReview
staleServices={bulkStaleServices} staleServices={bulkStaleServices}
onDone={() => setBulkStaleServices([])} onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
/> />
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -426,6 +426,7 @@ export function ServicesPage() {
const [localOrder, setLocalOrder] = useState<Service[] | null>(null); const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
const [sortColumn, setSortColumn] = useState<SortColumn>(null); const [sortColumn, setSortColumn] = useState<SortColumn>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible");
const macByDeviceId = useMemo(() => { const macByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {}; const map: Record<string, string | null> = {};
@@ -456,10 +457,15 @@ export function ServicesPage() {
const baseList = localOrder ?? services ?? []; const baseList = localOrder ?? services ?? [];
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0; const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
const visibleCount = services ? services.length - hiddenCount : 0;
const tabFilteredBase = useMemo(
() => baseList.filter((s) => (activeTab === "visible" ? s.visible : !s.visible)),
[baseList, activeTab]
);
const list = useMemo(() => { const list = useMemo(() => {
if (!sortColumn) return baseList; if (!sortColumn) return tabFilteredBase;
const sorted = [...baseList].sort((a, b) => { const sorted = [...tabFilteredBase].sort((a, b) => {
let cmp = 0; let cmp = 0;
switch (sortColumn) { switch (sortColumn) {
case "displayName": case "displayName":
@@ -491,7 +497,7 @@ export function ServicesPage() {
return sortDirection === "asc" ? cmp : -cmp; return sortDirection === "asc" ? cmp : -cmp;
}); });
return sorted; return sorted;
}, [baseList, sortColumn, sortDirection, ipByDeviceId]); }, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]);
function handleHeaderClick(column: SortColumn) { function handleHeaderClick(column: SortColumn) {
if (sortColumn === column) { if (sortColumn === column) {
@@ -539,13 +545,32 @@ export function ServicesPage() {
<div> <div>
<AdminPageHeader <AdminPageHeader
title="Dienste" title="Dienste"
description={ description="Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
hiddenCount > 0
? `Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
: "Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
}
/> />
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10">
<button
onClick={() => setActiveTab("visible")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "visible"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
Sichtbar ({visibleCount})
</button>
<button
onClick={() => setActiveTab("hidden")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "hidden"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
🙈 Ausgeblendet ({hiddenCount})
</button>
</div>
{sortColumn ? ( {sortColumn ? (
<div className="mb-3"> <div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}> <Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
@@ -600,8 +625,9 @@ export function ServicesPage() {
</div> </div>
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40"> <p className="text-sm text-black/40 dark:text-white/40">
Noch keine Dienste vorhanden. Scanne ein Gerät unter Geräte, um automatisch welche {activeTab === "hidden"
zu finden, oder importiere eine Liste oben. ? "Keine ausgeblendeten Dienste."
: "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}
</p> </p>
)} )}
</div> </div>

View File

@@ -41,6 +41,13 @@ export interface Service {
order: number; order: number;
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */ /** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
visible: boolean; visible: boolean;
/**
* Nicht vom Backend geliefert, sondern optional vom Frontend ergänzt (aus
* den bekannten Geräten), damit die Suche auch über den sprechenden
* Gerätenamen greift - `hostname` ist bei Diensten oft nur die IP, wenn
* keine DNS-Auflösung möglich war.
*/
deviceHostname?: string | null;
} }
export interface HealthStatus { export interface HealthStatus {
@@ -102,6 +109,8 @@ export interface Rankable {
description: string | null; description: string | null;
favorite: boolean; favorite: boolean;
order: number; order: number;
/** Optional: sprechender Gerätename, zusätzlich zum technischen hostname-Feld durchsucht. */
deviceHostname?: string | null;
} }
/** /**
@@ -133,15 +142,16 @@ export function rankService<T extends Rankable>(item: T, query: string): number
const displayName = item.displayName.toLowerCase(); const displayName = item.displayName.toLowerCase();
const hostname = item.hostname.toLowerCase(); const hostname = item.hostname.toLowerCase();
const deviceHostname = (item.deviceHostname ?? "").toLowerCase();
const description = (item.description ?? "").toLowerCase(); const description = (item.description ?? "").toLowerCase();
const alias = item.alias.map((a) => a.toLowerCase()); const alias = item.alias.map((a) => a.toLowerCase());
if (displayName.startsWith(q)) return 1; if (displayName.startsWith(q)) return 1;
if (alias.some((a) => a.startsWith(q))) return 2; if (alias.some((a) => a.startsWith(q))) return 2;
if (hostname.startsWith(q)) return 3; if (hostname.startsWith(q) || deviceHostname.startsWith(q)) return 3;
if (displayName.includes(q)) return 4; if (displayName.includes(q)) return 4;
if (alias.some((a) => a.includes(q))) return 5; if (alias.some((a) => a.includes(q))) return 5;
if (hostname.includes(q)) return 6; if (hostname.includes(q) || deviceHostname.includes(q)) return 6;
if (description.includes(q)) return 7; if (description.includes(q)) return 7;
return null; return null;

View File

@@ -57,6 +57,13 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
icon: z.string().nullable().optional(), icon: z.string().nullable().optional(),
favicon: z.string().nullable().optional(), favicon: z.string().nullable().optional(),
// Standardmäßig markiert ein PATCH mit displayName/category den jeweiligen
// Wert als "manuell bearbeitet" (siehe updateService-Doku in
// repositories/services.ts). Wird explizit false mitgeschickt - z. B. beim
// Übernehmen eines Scan-Vorschlags in der Änderungsübersicht -, bleibt der
// Wert stattdessen als "vom Scan" markiert.
displayNameEditedManually: z.boolean().optional(),
categoryEditedManually: z.boolean().optional(),
}); });
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>; export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;