diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index cec6f02..62d7e6e 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -53,6 +53,8 @@ export function ensureSchema(): void { url TEXT NOT NULL, https INTEGER NOT NULL DEFAULT 0, 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, favicon TEXT, description TEXT, @@ -123,6 +125,11 @@ export function ensureSchema(): void { // sich am bisherigen Verhalten nichts unerwartet ändert. ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1"); 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 { diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 4596af4..53e1d49 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -91,6 +91,14 @@ export function createService(input: ServiceCreateInput): Service { * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die * NIEMALS displayName, category, favorite, order, alias, icon, hostname, url, * 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 { const existing = getService(id); @@ -98,12 +106,18 @@ export function updateService(id: string, input: ServiceUpdateInput): Service | db.update(services) .set({ - ...(input.displayName !== undefined && { displayName: input.displayName }), + ...(input.displayName !== undefined && { + displayName: input.displayName, + displayNameEditedManually: input.displayNameEditedManually ?? true, + }), ...(input.hostname !== undefined && { hostname: input.hostname }), ...(input.url !== undefined && { url: input.url }), ...(input.https !== undefined && { https: input.https }), ...(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.favicon !== undefined && { favicon: input.favicon }), ...(input.description !== undefined && { description: input.description }), @@ -163,6 +177,14 @@ export interface ScanNameChange { field: "displayName" | "category"; current: string | null; 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 { @@ -201,6 +223,10 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult const timestamp = nowIso(); 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) .set({ favicon: input.favicon ?? existing.favicon, @@ -216,6 +242,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult field: "displayName", current: existing.displayName, suggested: input.suggestedDisplayName, + manualOverride: existingRow.displayNameEditedManually, }); } if ( @@ -227,6 +254,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult field: "category", current: existing.category, suggested: input.suggestedCategory, + manualOverride: existingRow.categoryEditedManually, }); } diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 5ffa317..7be54d4 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -42,6 +42,17 @@ export const services = sqliteTable("services", { https: integer("https", { mode: "boolean" }).notNull().default(false), // Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich. 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 port: integer("port").notNull(), diff --git a/apps/frontend/src/hooks/usePersistedState.ts b/apps/frontend/src/hooks/usePersistedState.ts new file mode 100644 index 0000000..982d920 --- /dev/null +++ b/apps/frontend/src/hooks/usePersistedState.ts @@ -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(key: string, initial: T): [T, (value: T | ((prev: T) => T)) => void] { + const storageKey = `launchpad:${key}`; + + const [state, setState] = useState(() => { + 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]; +} diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index c779acc..328a250 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -5,6 +5,7 @@ import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } import { rankServices, type SearchResult } from "@launchpad/shared"; import { useServices } from "../hooks/useServices.js"; import { useBookmarks } from "../hooks/useBookmarks.js"; +import { useDevices } from "../hooks/useDevices.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useTheme } from "../hooks/useTheme.js"; import { useCategories } from "../hooks/useCategories.js"; @@ -92,6 +93,7 @@ export function HomePage() { const { health, error: healthError } = useBackendHealth(); const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices(); const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks(); + const { data: devices } = useDevices(); const { data: categories } = useCategories(); const { data: recentVisits } = useRecentVisits(); const { data: readLaterItems } = useReadLater(); @@ -138,15 +140,20 @@ export function HomePage() { // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer, // siehe Admin -> Dienste) tauchen in der Suche nicht auf. const allItems: SearchResult[] = useMemo(() => { + const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname])); const visibleServices: SearchResult[] = (services ?? []) .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) => ({ ...b, kind: "bookmark" as const, })); return [...visibleServices, ...bookmarkItems]; - }, [services, bookmarks]); + }, [services, bookmarks, devices]); const results = useMemo(() => rankServices(allItems, query), [allItems, query]); diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 960b0fd..ba373cd 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -63,6 +63,8 @@ interface ScanNameChange { field: "displayName" | "category"; current: string | null; suggested: string; + /** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde. */ + manualOverride: boolean; } interface ScanResult { @@ -184,8 +186,10 @@ function NameChangesReview({ const [handled, setHandled] = useState>(new Set()); const applyMutation = useMutation({ - mutationFn: (change: ScanNameChange) => - patchService(change.serviceId, { [change.field]: change.suggested }), + mutationFn: (change: ScanNameChange) => { + const resetFlag = change.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually"; + return patchService(change.serviceId, { [change.field]: change.suggested, [resetFlag]: false }); + }, onSuccess: (_data, change) => { setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`)); queryClient.invalidateQueries({ queryKey: ["services"] }); @@ -199,14 +203,13 @@ function NameChangesReview({ const fieldLabel = (field: ScanNameChange["field"]) => field === "displayName" ? "Name" : "Kategorie"; + const manual = remaining.filter((c) => c.manualOverride); + const real = remaining.filter((c) => !c.manualOverride); - return ( -
-

- {remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab: -

+ function renderList(list: ScanNameChange[]) { + return (
    - {remaining.map((c) => ( + {list.map((c) => (
  • {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}" @@ -231,7 +234,28 @@ function NameChangesReview({
  • ))}
-
diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index c2608bd..51e7dd8 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -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) { @@ -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>(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 ( -
-

- {remaining.length} erkannte Änderung(en) über alle gescannten Geräte hinweg weichen vom - gespeicherten Wert ab: -

+ function renderList(list: ScanNameChange[]) { + return (
    - {remaining.map((c) => ( -
  • + {list.map((c) => ( +
  • {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „ {c.suggested}"
    - -
  • ))}
- + ); + } + + return ( +
+ {real.length > 0 ? ( +
+

+ {real.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab: +

+ {renderList(real)} +
+ ) : null} + {manual.length > 0 ? ( +
+

+ {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: +

+ {renderList(manual)} +
+ ) : null}
); } function StaleServicesReview({ staleServices, - onDone, + onResolve, }: { staleServices: Service[]; - onDone: () => void; + onResolve: (id: string) => void; }) { const queryClient = useQueryClient(); - const [handled, setHandled] = useState>(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 (

- {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):

    - {remaining.map((s) => ( + {staleServices.map((s) => (
  • {s.displayName} ({s.hostname}:{s.port})
    -
-
); } function DeviceChangesReview({ deviceChanges, - onDone, + onResolve, }: { deviceChanges: DeviceChange[]; - onDone: () => void; + onResolve: (key: string) => void; }) { const queryClient = useQueryClient(); - const [handled, setHandled] = useState>(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 (

- {remaining.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab: + {deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:

    - {remaining.map((c) => ( + {deviceChanges.map((c) => (
  • {c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
    - -
  • ))}
-
); } -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>(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 (

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

    - {remaining.map((d) => ( + {staleDevices.map((d) => (
  • {d.hostname} ({d.ip})
    -
-
); } @@ -315,22 +339,41 @@ function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; export function ScannerPage() { const queryClient = useQueryClient(); const { data: devices } = useDevices(); - const [bulkStatus, setBulkStatus] = useState(null); - const [bulkRunning, setBulkRunning] = useState(false); - const [bulkStaleServices, setBulkStaleServices] = useState([]); - const [bulkNameChanges, setBulkNameChanges] = useState([]); - const [staleDevices, setStaleDevices] = useState([]); - const [deviceChanges, setDeviceChanges] = useState([]); + + const [bulkStatus, setBulkStatus] = usePersistedState("scanner:bulkStatus", null); + const [bulkRunning, setBulkRunning] = usePersistedState("scanner:bulkRunning", false); + const [bulkStaleServices, setBulkStaleServices] = usePersistedState( + "scanner:bulkStaleServices", + [] + ); + const [bulkNameChanges, setBulkNameChanges] = usePersistedState( + "scanner:bulkNameChanges", + [] + ); + const [staleDevices, setStaleDevices] = usePersistedState("scanner:staleDevices", []); + const [deviceChanges, setDeviceChanges] = usePersistedState( + "scanner:deviceChanges", + [] + ); + const [fritzboxSummary, setFritzboxSummary] = usePersistedState< + { found: number; uniqueDevices: number } | null + >("scanner:fritzboxSummary", null); + const [fritzboxError, setFritzboxError] = usePersistedState( + "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"} - {fritzboxMutation.isSuccess ? ( + {fritzboxSummary ? (

- {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)" : ""} .

) : null} - {fritzboxMutation.isError ? ( -

{(fritzboxMutation.error as Error).message}

- ) : null} - {deviceChanges.length > 0 ? ( - setDeviceChanges([])} /> - ) : null} - {staleDevices.length > 0 ? ( - setStaleDevices([])} /> - ) : null} + {fritzboxError ?

{fritzboxError}

: null} + setDeviceChanges((prev) => prev.filter((c) => `${c.deviceId}:${c.field}` !== key))} + /> + setStaleDevices((prev) => prev.filter((d) => d.id !== id))} + />
@@ -442,15 +479,14 @@ export function ScannerPage() { {bulkStatus ? (

{bulkStatus}

) : null} - {bulkNameChanges.length > 0 ? ( - setBulkNameChanges([])} /> - ) : null} - {bulkStaleServices.length > 0 ? ( - setBulkStaleServices([])} - /> - ) : null} + setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))} + /> + setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))} + />
diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index fa80246..2eabdc8 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -426,6 +426,7 @@ export function ServicesPage() { const [localOrder, setLocalOrder] = useState(null); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible"); const macByDeviceId = useMemo(() => { const map: Record = {}; @@ -456,10 +457,15 @@ export function ServicesPage() { const baseList = localOrder ?? services ?? []; 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(() => { - if (!sortColumn) return baseList; - const sorted = [...baseList].sort((a, b) => { + if (!sortColumn) return tabFilteredBase; + const sorted = [...tabFilteredBase].sort((a, b) => { let cmp = 0; switch (sortColumn) { case "displayName": @@ -491,7 +497,7 @@ export function ServicesPage() { return sortDirection === "asc" ? cmp : -cmp; }); return sorted; - }, [baseList, sortColumn, sortDirection, ipByDeviceId]); + }, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]); function handleHeaderClick(column: SortColumn) { if (sortColumn === column) { @@ -539,13 +545,32 @@ export function ServicesPage() {
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." - } + 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." /> +
+ + +
+ {sortColumn ? (
) : (

- Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche - zu finden, oder importiere eine Liste oben. + {activeTab === "hidden" + ? "Keine ausgeblendeten Dienste." + : "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}

)}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee2981a..7fc0b16 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -41,6 +41,13 @@ export interface Service { order: number; /** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */ 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 { @@ -102,6 +109,8 @@ export interface Rankable { description: string | null; favorite: boolean; order: number; + /** Optional: sprechender Gerätename, zusätzlich zum technischen hostname-Feld durchsucht. */ + deviceHostname?: string | null; } /** @@ -133,15 +142,16 @@ export function rankService(item: T, query: string): number const displayName = item.displayName.toLowerCase(); const hostname = item.hostname.toLowerCase(); + const deviceHostname = (item.deviceHostname ?? "").toLowerCase(); const description = (item.description ?? "").toLowerCase(); const alias = item.alias.map((a) => a.toLowerCase()); if (displayName.startsWith(q)) return 1; 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 (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; return null; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 90806ec..d96a791 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -57,6 +57,13 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({ description: z.string().nullable().optional(), icon: 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;