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