generated from Dicken/dickendock
Fix: visible-Flag für Scan-Müll, editierbare Hostname/Port/HTTPS, Sidebar fixiert, Suche nur bei Eingabe
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
suggestedDisplayName: found.suggestedDisplayName,
|
||||
suggestedCategory: found.category,
|
||||
suggestedIcon: found.icon,
|
||||
suggestedVisible: found.visible,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,12 +114,55 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||
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,19 +193,42 @@ 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">
|
||||
<button
|
||||
onClick={() => favoriteMutation.mutate()}
|
||||
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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => favoriteMutation.mutate()}
|
||||
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
|
||||
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 ? (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user