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,
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +114,48 @@ 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>
|
||||||
|
|
||||||
|
<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}>
|
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
@@ -106,6 +163,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</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,8 +193,13 @@ 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">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => favoriteMutation.mutate()}
|
onClick={() => favoriteMutation.mutate()}
|
||||||
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||||||
@@ -139,10 +207,28 @@ function ServiceRow({ service }: { service: Service }) {
|
|||||||
>
|
>
|
||||||
★
|
★
|
||||||
</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 ? (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user