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,
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}`);
}
}

View File

@@ -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,

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 ...).
* 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"),

View File

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

View File

@@ -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,
});
}

View File

@@ -31,6 +31,7 @@ export function HomePage() {
const { data: services, isLoading, isError } = useServices();
const inputRef = useRef<HTMLInputElement>(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 ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">
Lade Dienste
</p>
@@ -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."
}

View File

@@ -22,7 +22,7 @@ export function AdminLayout() {
}, [pathname]);
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 */}
<div
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}
{/*
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
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
dark:bg-neutral-950 sm:static sm:z-auto sm:w-56 sm:shrink-0 sm:translate-x-0
sm:bg-white/70 sm:transition-none dark:sm:bg-white/5 ${
dark:bg-neutral-950 sm:z-auto sm:w-56 sm:translate-x-0 sm:bg-white/70
sm:transition-none dark:sm:bg-white/5 ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
@@ -92,7 +97,7 @@ export function AdminLayout() {
})}
</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
to="/"
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>
</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 />
</main>
</div>

View File

@@ -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<Service> {
@@ -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,6 +114,48 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
</div>
<div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
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>
@@ -106,6 +163,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
Abbrechen
</Button>
</div>
</div>
</td>
</tr>
);
@@ -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,8 +193,13 @@ function ServiceRow({ service }: { service: Service }) {
}
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">
<div className="flex items-center gap-1.5">
<button
onClick={() => favoriteMutation.mutate()}
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
@@ -139,10 +207,28 @@ function ServiceRow({ service }: { service: Service }) {
>
</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 className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{service.displayName}</div>
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div>
<div className="flex items-center gap-2">
<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 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">
@@ -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)" : ""}
</a>
</td>
<td className="px-4 py-3">
@@ -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 (
<div>
<AdminPageHeader
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 ? (

View File

@@ -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.

View File

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

View File

@@ -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<typeof ServiceCreateSchema>;

View File

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