Fix: visible-Flag für Scan-Müll, editierbare Hostname/Port/HTTPS, Sidebar fixiert, Suche nur bei Eingabe

This commit is contained in:
2026-07-19 12:27:45 +02:00
parent d65897b8e1
commit 20ea4d382d
12 changed files with 238 additions and 44 deletions

View File

@@ -52,6 +52,7 @@ export function ensureSchema(): void {
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
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,
port INTEGER NOT NULL, port INTEGER NOT NULL,
favicon TEXT, favicon TEXT,
description TEXT, description TEXT,
@@ -76,4 +77,22 @@ export function ensureSchema(): void {
created_at TEXT NOT NULL 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}`);
}
} }

View File

@@ -24,6 +24,7 @@ function mapRow(row: typeof services.$inferSelect): Service {
favorite: row.favorite, favorite: row.favorite,
alias: JSON.parse(row.alias) as string[], alias: JSON.parse(row.alias) as string[],
order: row.order, order: row.order,
visible: row.visible,
}; };
} }
@@ -76,6 +77,7 @@ export function createService(input: ServiceCreateInput): Service {
favorite: input.favorite ?? false, favorite: input.favorite ?? false,
alias: JSON.stringify(input.alias ?? []), alias: JSON.stringify(input.alias ?? []),
order: input.order ?? 0, order: input.order ?? 0,
visible: input.visible ?? true,
createdAt: timestamp, createdAt: timestamp,
updatedAt: 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). * Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI).
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die * 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 { export function updateService(id: string, input: ServiceUpdateInput): Service | null {
const existing = getService(id); const existing = getService(id);
@@ -107,6 +110,7 @@ export function updateService(id: string, input: ServiceUpdateInput): Service |
...(input.favorite !== undefined && { favorite: input.favorite }), ...(input.favorite !== undefined && { favorite: input.favorite }),
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
...(input.order !== undefined && { order: input.order }), ...(input.order !== undefined && { order: input.order }),
...(input.visible !== undefined && { visible: input.visible }),
updatedAt: nowIso(), updatedAt: nowIso(),
}) })
.where(eq(services.id, id)) .where(eq(services.id, id))
@@ -134,6 +138,8 @@ export interface ServiceScanInput {
suggestedCategory?: string | null; suggestedCategory?: string | null;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */ /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */
suggestedIcon?: string | null; suggestedIcon?: string | null;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. Fehlerseite -> versteckt). */
suggestedVisible?: boolean;
} }
export interface ScanUpsertResult { 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 * Legt einen per Scan gefundenen Dienst an oder aktualisiert einen bereits
* Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port). * bekannten Dienst (abgeglichen über deviceId + port).
* *
* displayName, category, favorite, order, alias und icon werden bei einem * Bei einem BESTEHENDEN Dienst werden nur favicon/description aufgefrischt.
* bestehenden Dienst NIEMALS verändert nur beim erstmaligen Anlegen dienen * Alles andere auch hostname/url/https, die früher bei jedem Scan
* suggestedDisplayName/suggestedCategory als sinnvoller Startwert. * ü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 { export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
const existing = findByDeviceAndPort(input.deviceId, input.port); const existing = findByDeviceAndPort(input.deviceId, input.port);
@@ -160,9 +170,6 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
if (existing) { if (existing) {
db.update(services) db.update(services)
.set({ .set({
hostname: input.hostname,
url: input.url,
https: input.https,
favicon: input.favicon ?? existing.favicon, favicon: input.favicon ?? existing.favicon,
description: input.description ?? existing.description, description: input.description ?? existing.description,
updatedAt: timestamp, updatedAt: timestamp,
@@ -186,6 +193,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
hostname: input.hostname, hostname: input.hostname,
url: input.url, url: input.url,
https: input.https, https: input.https,
visible: input.suggestedVisible ?? true,
port: input.port, port: input.port,
favicon: input.favicon ?? null, favicon: input.favicon ?? null,
description: input.description ?? null, description: input.description ?? null,

View File

@@ -19,8 +19,10 @@ export const devices = sqliteTable("devices", {
/** /**
* Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...). * Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...).
* Benutzeränderungen (displayName, category, favorite, order, alias, icon) * Benutzeränderungen (displayName, category, favorite, order, alias, icon,
* dürfen von Scans NIEMALS überschrieben werden siehe README/Spezifikation. * 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", { export const services = sqliteTable("services", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -28,18 +30,20 @@ export const services = sqliteTable("services", {
.notNull() .notNull()
.references(() => devices.id, { onDelete: "cascade" }), .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(), displayName: text("display_name").notNull(),
category: text("category"), category: text("category"),
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false), favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
order: real("order").notNull().default(0), order: real("order").notNull().default(0),
alias: text("alias").notNull().default("[]"), // JSON-Array als String alias: text("alias").notNull().default("[]"), // JSON-Array als String
icon: text("icon"), icon: text("icon"),
// von Scans aktualisierbar
hostname: text("hostname").notNull(), hostname: text("hostname").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
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.
visible: integer("visible", { mode: "boolean" }).notNull().default(true),
// Bei jedem Scan aktualisierbar
port: integer("port").notNull(), port: integer("port").notNull(),
favicon: text("favicon"), favicon: text("favicon"),
description: text("description"), description: text("description"),

View File

@@ -34,6 +34,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
suggestedDisplayName: found.suggestedDisplayName, suggestedDisplayName: found.suggestedDisplayName,
suggestedCategory: found.category, suggestedCategory: found.category,
suggestedIcon: found.icon, suggestedIcon: found.icon,
suggestedVisible: found.visible,
}) })
); );

View File

@@ -18,6 +18,23 @@ export interface DiscoveredService {
suggestedDisplayName: string; suggestedDisplayName: string;
category?: string; category?: string;
icon?: 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, 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({ found.push({
hostname: address, hostname: address,
url: baseUrl, url: baseUrl,
@@ -58,9 +83,10 @@ export async function scanDeviceServices(
port, port,
favicon: probe.faviconUrl, favicon: probe.faviconUrl,
description: software ? `${software.name} (automatisch erkannt)` : probe.title, 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, category: software?.category,
icon: software?.icon, icon: software?.icon,
visible,
}); });
} }

View File

@@ -31,6 +31,7 @@ export function HomePage() {
const { data: services, isLoading, isError } = useServices(); const { data: services, isLoading, isError } = useServices();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isSearching = query.trim().length > 0;
const toggleFavorite = useMutation({ const toggleFavorite = useMutation({
mutationFn: toggleServiceFavorite, 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( const results = useMemo(
() => rankServices(services ?? [], query), () => rankServices(visibleServices, query),
[services, query] [visibleServices, query]
); );
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert // Auswahl zurücksetzen, sobald sich die Trefferliste ändert
@@ -61,6 +69,7 @@ export function HomePage() {
} }
if (document.activeElement !== inputRef.current) return; if (document.activeElement !== inputRef.current) return;
if (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@@ -80,7 +89,7 @@ export function HomePage() {
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [results, selectedIndex]); }, [results, selectedIndex, query]);
const isOnline = !healthError && health?.status === "ok"; const isOnline = !healthError && health?.status === "ok";
@@ -124,7 +133,7 @@ export function HomePage() {
autoFocus autoFocus
/> />
{isLoading ? ( {!isSearching ? null : isLoading ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40"> <p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">
Lade Dienste Lade Dienste
</p> </p>
@@ -140,7 +149,7 @@ export function HomePage() {
onOpen={openService} onOpen={openService}
onToggleFavorite={(service) => toggleFavorite.mutate(service)} onToggleFavorite={(service) => toggleFavorite.mutate(service)}
emptyLabel={ emptyLabel={
(services?.length ?? 0) === 0 (visibleServices?.length ?? 0) === 0
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu." ? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu."
: "Keine Treffer für deine Suche." : "Keine Treffer für deine Suche."
} }

View File

@@ -22,7 +22,7 @@ export function AdminLayout() {
}, [pathname]); }, [pathname]);
return ( return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 sm:flex"> <div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints sichtbar */} {/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints sichtbar */}
<div <div
className="flex items-center gap-3 border-b border-black/10 bg-white/80 px-4 py-3 className="flex items-center gap-3 border-b border-black/10 bg-white/80 px-4 py-3
@@ -51,11 +51,16 @@ export function AdminLayout() {
/> />
) : null} ) : null}
{/*
Immer "fixed" (nicht nur mobil) sonst scrollt die Sidebar bei langen
Seiteninhalten mit dem restlichen Dokument mit und "Zurück zur Suche"
verschwindet erst nach dem Runterscrollen aus dem Blickfeld.
*/}
<aside <aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-black/10 className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-black/10
bg-white transition-transform duration-200 ease-out dark:border-white/10 bg-white transition-transform duration-200 ease-out dark:border-white/10
dark:bg-neutral-950 sm:static sm:z-auto sm:w-56 sm:shrink-0 sm:translate-x-0 dark:bg-neutral-950 sm:z-auto sm:w-56 sm:translate-x-0 sm:bg-white/70
sm:bg-white/70 sm:transition-none dark:sm:bg-white/5 ${ sm:transition-none dark:sm:bg-white/5 ${
mobileOpen ? "translate-x-0" : "-translate-x-full" mobileOpen ? "translate-x-0" : "-translate-x-full"
}`} }`}
> >
@@ -92,7 +97,7 @@ export function AdminLayout() {
})} })}
</nav> </nav>
<div className="border-t border-black/10 p-3 dark:border-white/10"> <div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
<Link <Link
to="/" to="/"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
@@ -105,7 +110,8 @@ export function AdminLayout() {
</div> </div>
</aside> </aside>
<main className="flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-8"> {/* sm:ml-56 gleicht die jetzt fixierte Sidebar aus (statt Flexbox-Nebeneinander) */}
<main className="overflow-x-hidden p-4 sm:ml-56 sm:p-8">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -11,6 +11,11 @@ interface ServicePatch {
alias?: string[]; alias?: string[];
order?: number; order?: number;
favorite?: boolean; favorite?: boolean;
visible?: boolean;
hostname?: string;
port?: number;
https?: boolean;
url?: string;
} }
async function patchService(id: string, patch: ServicePatch): Promise<Service> { async function patchService(id: string, patch: ServicePatch): Promise<Service> {
@@ -38,6 +43,12 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
const [category, setCategory] = useState(service.category ?? ""); const [category, setCategory] = useState(service.category ?? "");
const [alias, setAlias] = useState(service.alias.join(", ")); const [alias, setAlias] = useState(service.alias.join(", "));
const [order, setOrder] = useState(String(service.order)); 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({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -49,6 +60,10 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
.map((a) => a.trim()) .map((a) => a.trim())
.filter(Boolean), .filter(Boolean),
order: Number(order) || 0, order: Number(order) || 0,
hostname: hostname.trim(),
port: portNumber,
https,
url: previewUrl,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] }); 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" dark:border-white/10 dark:bg-white/10 dark:text-white"
/> />
</div> </div>
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern <div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
</Button>
<Button size="sm" variant="ghost" onClick={onDone}> <div>
Abbrechen <label className="mb-1 block text-xs text-black/50 dark:text-white/50">
</Button> Hostname / IP
</label>
<input
value={hostname}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Port</label>
<input
value={port}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs text-black/50 dark:text-white/50">
<input
type="checkbox"
checked={https}
onChange={(e) => setHttps(e.target.checked)}
className="rounded"
/>
HTTPS
</label>
</div>
<div className="text-xs text-black/40 dark:text-white/40">
Öffnet: <span className="font-mono">{previewUrl}</span>
</div>
<div className="flex w-full gap-2 pt-1">
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Abbrechen
</Button>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@@ -120,6 +178,11 @@ function ServiceRow({ service }: { service: Service }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
}); });
const visibleMutation = useMutation({
mutationFn: () => patchService(service.id, { visible: !service.visible }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => deleteServiceRequest(service.id), mutationFn: () => deleteServiceRequest(service.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
@@ -130,19 +193,42 @@ function ServiceRow({ service }: { service: Service }) {
} }
return ( return (
<tr className="border-b border-black/5 last:border-0 dark:border-white/5"> <tr
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
service.visible ? "" : "opacity-50"
}`}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <div className="flex items-center gap-1.5">
onClick={() => favoriteMutation.mutate()} <button
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"} onClick={() => favoriteMutation.mutate()}
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`} aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
> className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
</button>
</button>
<button
onClick={() => visibleMutation.mutate()}
aria-label={service.visible ? "In der Suche ausblenden" : "In der Suche einblenden"}
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
>
{service.visible ? "👁️" : "🙈"}
</button>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{service.displayName}</div> <div className="flex items-center gap-2">
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div> <span className="font-medium text-black dark:text-white">{service.displayName}</span>
{!service.visible ? (
<span className="rounded-full bg-black/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/50 dark:bg-white/10 dark:text-white/50">
ausgeblendet
</span>
) : null}
</div>
<div className="text-xs text-black/40 dark:text-white/40">
{service.hostname}:{service.port}
</div>
</td> </td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? ""}</td> <td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? ""}</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60"> <td className="px-4 py-3 text-black/60 dark:text-white/60">
@@ -155,7 +241,7 @@ function ServiceRow({ service }: { service: Service }) {
rel="noopener noreferrer" 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" 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)" : ""}
</a> </a>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -174,12 +260,17 @@ function ServiceRow({ service }: { service: Service }) {
export function ServicesPage() { export function ServicesPage() {
const { data: services, isLoading, isError } = useServices(); const { data: services, isLoading, isError } = useServices();
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
return ( return (
<div> <div>
<AdminPageHeader <AdminPageHeader
title="Dienste" title="Dienste"
description="Name, Kategorie, Alias und Reihenfolge bleiben bei erneuten Scans erhalten." description={
hiddenCount > 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 ? ( {isLoading ? (

View File

@@ -127,3 +127,30 @@ Grundgerüst aus Commit 1.
Damit ist die komplette in der ursprünglichen Projektübergabe beschriebene Damit ist die komplette in der ursprünglichen Projektübergabe beschriebene
Funktionalität umgesetzt. 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.

View File

@@ -39,6 +39,8 @@ export interface Service {
favorite: boolean; favorite: boolean;
alias: string[]; alias: string[];
order: number; order: number;
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
visible: boolean;
} }
export interface HealthStatus { export interface HealthStatus {

View File

@@ -40,6 +40,7 @@ export const ServiceCreateSchema = z.object({
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
alias: z.array(z.string()).optional(), alias: z.array(z.string()).optional(),
order: z.number().optional(), order: z.number().optional(),
visible: z.boolean().optional(),
}); });
export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>; export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>;

View File

@@ -76,7 +76,7 @@ export function ResultsList({
{service.displayName} {service.displayName}
</span> </span>
<span className="block truncate text-xs text-black/40 dark:text-white/40"> <span className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname} {service.hostname}:{service.port}
</span> </span>
</span> </span>