diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index 506f6f5..5678502 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -52,6 +52,7 @@ export function ensureSchema(): void { hostname TEXT NOT NULL, url TEXT NOT NULL, https INTEGER NOT NULL DEFAULT 0, + visible INTEGER NOT NULL DEFAULT 1, port INTEGER NOT NULL, favicon TEXT, description TEXT, @@ -76,4 +77,22 @@ export function ensureSchema(): void { created_at TEXT NOT NULL ); `); + + // Leichte Migration für Datenbanken, die vor Einführung von "visible" + // angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits + // existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier + // manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit + // sich am bisherigen Verhalten nichts unerwartet ändert. + ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1"); +} + +function ensureColumn(table: string, column: string, definition: string): void { + const existingColumns = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }>; + const hasColumn = existingColumns.some((col) => col.name === column); + + if (!hasColumn) { + sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } } diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 871a9df..4dbc44b 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -24,6 +24,7 @@ function mapRow(row: typeof services.$inferSelect): Service { favorite: row.favorite, alias: JSON.parse(row.alias) as string[], order: row.order, + visible: row.visible, }; } @@ -76,6 +77,7 @@ export function createService(input: ServiceCreateInput): Service { favorite: input.favorite ?? false, alias: JSON.stringify(input.alias ?? []), order: input.order ?? 0, + visible: input.visible ?? true, createdAt: timestamp, updatedAt: timestamp, }) @@ -87,7 +89,8 @@ export function createService(input: ServiceCreateInput): Service { /** * Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI). * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die - * NIEMALS displayName, category, favorite, order, alias oder icon überschreibt. + * NIEMALS displayName, category, favorite, order, alias, icon, hostname, url, + * https oder visible eines bereits bekannten Dienstes überschreibt. */ export function updateService(id: string, input: ServiceUpdateInput): Service | null { const existing = getService(id); @@ -107,6 +110,7 @@ export function updateService(id: string, input: ServiceUpdateInput): Service | ...(input.favorite !== undefined && { favorite: input.favorite }), ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), ...(input.order !== undefined && { order: input.order }), + ...(input.visible !== undefined && { visible: input.visible }), updatedAt: nowIso(), }) .where(eq(services.id, id)) @@ -134,6 +138,8 @@ export interface ServiceScanInput { suggestedCategory?: string | null; /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */ suggestedIcon?: string | null; + /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. Fehlerseite -> versteckt). */ + suggestedVisible?: boolean; } export interface ScanUpsertResult { @@ -146,12 +152,16 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null { } /** - * Legt einen per Scan gefundenen Dienst an oder aktualisiert die scan-eigenen - * Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port). + * Legt einen per Scan gefundenen Dienst an oder aktualisiert einen bereits + * bekannten Dienst (abgeglichen über deviceId + port). * - * displayName, category, favorite, order, alias und icon werden bei einem - * bestehenden Dienst NIEMALS verändert – nur beim erstmaligen Anlegen dienen - * suggestedDisplayName/suggestedCategory als sinnvoller Startwert. + * Bei einem BESTEHENDEN Dienst werden nur favicon/description aufgefrischt. + * Alles andere – auch hostname/url/https, die früher bei jedem Scan + * überschrieben wurden – bleibt unangetastet, damit manuelle Anpassungen + * (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben. + * + * suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible + * wirken NUR beim erstmaligen Anlegen. */ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult { const existing = findByDeviceAndPort(input.deviceId, input.port); @@ -160,9 +170,6 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult if (existing) { db.update(services) .set({ - hostname: input.hostname, - url: input.url, - https: input.https, favicon: input.favicon ?? existing.favicon, description: input.description ?? existing.description, updatedAt: timestamp, @@ -186,6 +193,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult hostname: input.hostname, url: input.url, https: input.https, + visible: input.suggestedVisible ?? true, port: input.port, favicon: input.favicon ?? null, description: input.description ?? null, diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index d2102fa..1724c60 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -19,8 +19,10 @@ export const devices = sqliteTable("devices", { /** * Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...). - * Benutzeränderungen (displayName, category, favorite, order, alias, icon) - * dürfen von Scans NIEMALS überschrieben werden – siehe README/Spezifikation. + * Benutzeränderungen (displayName, category, favorite, order, alias, icon, + * hostname, url, https, visible) dürfen von einem erneuten Scan eines bereits + * bekannten Dienstes NIEMALS überschrieben werden – siehe README/Spezifikation. + * Nur favicon/description werden bei jedem Scan aufgefrischt. */ export const services = sqliteTable("services", { id: text("id").primaryKey(), @@ -28,18 +30,20 @@ export const services = sqliteTable("services", { .notNull() .references(() => devices.id, { onDelete: "cascade" }), - // vom Benutzer gepflegt, wird von Scans nicht überschrieben + // vom Benutzer gepflegt bzw. nur beim erstmaligen Anlegen durch einen Scan gesetzt displayName: text("display_name").notNull(), category: text("category"), favorite: integer("favorite", { mode: "boolean" }).notNull().default(false), order: real("order").notNull().default(0), alias: text("alias").notNull().default("[]"), // JSON-Array als String icon: text("icon"), - - // von Scans aktualisierbar hostname: text("hostname").notNull(), url: text("url").notNull(), 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), + + // Bei jedem Scan aktualisierbar port: integer("port").notNull(), favicon: text("favicon"), description: text("description"), diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 0997646..f45750f 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -34,6 +34,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { suggestedDisplayName: found.suggestedDisplayName, suggestedCategory: found.category, suggestedIcon: found.icon, + suggestedVisible: found.visible, }) ); diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index a0bdf8f..523a5f2 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -18,6 +18,23 @@ export interface DiscoveredService { suggestedDisplayName: string; category?: string; icon?: string; + /** Startwert für neu angelegte Dienste: false bei Fehlerseiten/generischen Ladetiteln. */ + visible: boolean; +} + +/** + * Erkennt generische/nichtssagende Titel, die keine sinnvolle Dienst- + * Bezeichnung abgeben: HTTP-Statuszeilen ("404 Not Found", "302 Found") und + * Lade-/Weiterleitungsplatzhalter (der Scanner führt kein JavaScript aus, + * sieht bei manchen Web-UIs also nur den Ladebildschirm statt des echten Titels). + */ +function isLikelyJunkTitle(title: string | undefined): boolean { + if (!title) return true; + const trimmed = title.trim(); + if (trimmed.length === 0) return true; + if (/^\d{3}\b/.test(trimmed)) return true; // "404 Not Found", "400 ...", "302 Found" + if (/^(loading|lädt|please wait|redirecting|weiterleitung)/i.test(trimmed)) return true; + return false; } /** @@ -51,6 +68,14 @@ export async function scanDeviceServices( port, }); + const titleIsJunk = isLikelyJunkTitle(probe.title); + const fallbackName = `${address}:${port}`; + + // Nur sichtbar, wenn Software erkannt wurde oder eine erfolgreiche Antwort + // (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und + // Ladebildschirme landen als ausgeblendeter Dienst im Adminbereich. + const visible = !!software || (probe.ok && !titleIsJunk); + found.push({ hostname: address, url: baseUrl, @@ -58,9 +83,10 @@ export async function scanDeviceServices( port, favicon: probe.faviconUrl, description: software ? `${software.name} (automatisch erkannt)` : probe.title, - suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`, + suggestedDisplayName: software?.name ?? (titleIsJunk ? fallbackName : probe.title ?? fallbackName), category: software?.category, icon: software?.icon, + visible, }); } diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index a617535..d67c4f2 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -31,6 +31,7 @@ export function HomePage() { const { data: services, isLoading, isError } = useServices(); const inputRef = useRef(null); const queryClient = useQueryClient(); + const isSearching = query.trim().length > 0; const toggleFavorite = useMutation({ mutationFn: toggleServiceFavorite, @@ -39,9 +40,16 @@ export function HomePage() { }, }); + // Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer, + // siehe Admin -> Dienste) tauchen in der Suche nicht auf. + const visibleServices = useMemo( + () => (services ?? []).filter((s) => s.visible), + [services] + ); + const results = useMemo( - () => rankServices(services ?? [], query), - [services, query] + () => rankServices(visibleServices, query), + [visibleServices, query] ); // Auswahl zurücksetzen, sobald sich die Trefferliste ändert @@ -61,6 +69,7 @@ export function HomePage() { } if (document.activeElement !== inputRef.current) return; + if (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern if (e.key === "ArrowDown") { e.preventDefault(); @@ -80,7 +89,7 @@ export function HomePage() { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [results, selectedIndex]); + }, [results, selectedIndex, query]); const isOnline = !healthError && health?.status === "ok"; @@ -124,7 +133,7 @@ export function HomePage() { autoFocus /> - {isLoading ? ( + {!isSearching ? null : isLoading ? (

Lade Dienste …

@@ -140,7 +149,7 @@ export function HomePage() { onOpen={openService} onToggleFavorite={(service) => toggleFavorite.mutate(service)} emptyLabel={ - (services?.length ?? 0) === 0 + (visibleServices?.length ?? 0) === 0 ? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu." : "Keine Treffer für deine Suche." } diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index 919675a..babfab6 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -22,7 +22,7 @@ export function AdminLayout() { }, [pathname]); return ( -
+
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints sichtbar */}
@@ -92,7 +97,7 @@ export function AdminLayout() { })} -
+
+ {/* sm:ml-56 gleicht die jetzt fixierte Sidebar aus (statt Flexbox-Nebeneinander) */} +
diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index a6082ff..23fffd8 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -11,6 +11,11 @@ interface ServicePatch { alias?: string[]; order?: number; favorite?: boolean; + visible?: boolean; + hostname?: string; + port?: number; + https?: boolean; + url?: string; } async function patchService(id: string, patch: ServicePatch): Promise { @@ -38,6 +43,12 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) const [category, setCategory] = useState(service.category ?? ""); const [alias, setAlias] = useState(service.alias.join(", ")); const [order, setOrder] = useState(String(service.order)); + const [hostname, setHostname] = useState(service.hostname); + const [port, setPort] = useState(String(service.port)); + const [https, setHttps] = useState(service.https); + + const portNumber = Number(port) || service.port; + const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`; const mutation = useMutation({ mutationFn: () => @@ -49,6 +60,10 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) .map((a) => a.trim()) .filter(Boolean), order: Number(order) || 0, + hostname: hostname.trim(), + port: portNumber, + https, + url: previewUrl, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["services"] }); @@ -99,12 +114,55 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) dark:border-white/10 dark:bg-white/10 dark:text-white" />
- - + +
+ +
+ + setHostname(e.target.value)} + className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm + dark:border-white/10 dark:bg-white/10 dark:text-white" + /> +
+
+ + setPort(e.target.value)} + type="number" + min={1} + max={65535} + className="w-24 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm + dark:border-white/10 dark:bg-white/10 dark:text-white" + /> +
+
+ +
+
+ Öffnet: {previewUrl} +
+ +
+ + +
@@ -120,6 +178,11 @@ function ServiceRow({ service }: { service: Service }) { onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), }); + const visibleMutation = useMutation({ + mutationFn: () => patchService(service.id, { visible: !service.visible }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), + }); + const deleteMutation = useMutation({ mutationFn: () => deleteServiceRequest(service.id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), @@ -130,19 +193,42 @@ function ServiceRow({ service }: { service: Service }) { } return ( - + - +
+ + +
-
{service.displayName}
-
{service.hostname}
+
+ {service.displayName} + {!service.visible ? ( + + ausgeblendet + + ) : null} +
+
+ {service.hostname}:{service.port} +
{service.category ?? "–"} @@ -155,7 +241,7 @@ function ServiceRow({ service }: { service: Service }) { rel="noopener noreferrer" className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white" > - öffnen + öffnen{service.https ? " (https)" : ""} @@ -174,12 +260,17 @@ function ServiceRow({ service }: { service: Service }) { export function ServicesPage() { const { data: services, isLoading, isError } = useServices(); + const hiddenCount = services?.filter((s) => !s.visible).length ?? 0; return (
0 + ? `Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).` + : "Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." + } /> {isLoading ? ( diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 278846b..19595f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -127,3 +127,30 @@ Grundgerüst aus Commit 1. Damit ist die komplette in der ursprünglichen Projektübergabe beschriebene Funktionalität umgesetzt. + +## Bugfixes aus dem ersten Praxistest (nach Commit 8) + +Basierend auf echtem Testen gegen ein reales Homelab (FritzBox + gescannte +Geräte): + +- **Neues Feld `visible` auf Diensten.** Scan-Treffer mit Fehlerantworten + (404, 400, 302 als Titel) oder generischen Ladebildschirm-Titeln + ("Loading...", der Scanner führt kein JavaScript aus) werden beim + erstmaligen Anlegen automatisch als `visible: false` markiert – sie + erscheinen nicht mehr in der Suche, bleiben aber unter Admin -> Dienste + sichtbar, umschaltbar (👁️/🙈) und löschbar. Bestehende Datenbanken werden + beim Start automatisch migriert (`ALTER TABLE ... ADD COLUMN`), keine + Daten gehen verloren. +- **Hostname/URL/HTTPS sind jetzt im Adminbereich editierbar** (Dienste -> + Bearbeiten) und werden ab sofort bei einem erneuten Scan eines bereits + bekannten Dienstes **nicht mehr überschrieben** (vorher wurden diese Felder + bei jedem Scan aufgefrischt). Nur `favicon`/`description` werden weiterhin + aktualisiert. Achtung: Wird dabei der **Port** geändert, entsteht beim + nächsten Scan ein neuer Eintrag für den ursprünglichen Port, da die + Zuordnung über `deviceId + port` läuft. +- **Suchergebnisliste erscheint nur noch bei aktiver Eingabe** – vorher war + sie auch bei leerem Suchfeld dauerhaft aufgeklappt. +- **Port wird jetzt überall mit angezeigt** (`hostname:port` statt nur + `hostname`), in der Suche wie im Adminbereich. +- **Admin-Sidebar ist jetzt echt fixiert** (`fixed` statt `static`) – vorher + scrollte "Zurück zur Suche" bei langem Seiteninhalt aus dem Sichtfeld. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6322312..c3b1691 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -39,6 +39,8 @@ export interface Service { favorite: boolean; alias: string[]; order: number; + /** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */ + visible: boolean; } export interface HealthStatus { diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index cb315b1..9afd388 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -40,6 +40,7 @@ export const ServiceCreateSchema = z.object({ favorite: z.boolean().optional(), alias: z.array(z.string()).optional(), order: z.number().optional(), + visible: z.boolean().optional(), }); export type ServiceCreateInput = z.infer; diff --git a/packages/ui/src/ResultsList.tsx b/packages/ui/src/ResultsList.tsx index d3d2673..cd7b019 100644 --- a/packages/ui/src/ResultsList.tsx +++ b/packages/ui/src/ResultsList.tsx @@ -76,7 +76,7 @@ export function ResultsList({ {service.displayName} - {service.hostname} + {service.hostname}:{service.port}