round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege

This commit is contained in:
2026-07-24 00:26:08 +02:00
parent b098007742
commit d87885f16f
7 changed files with 252 additions and 108 deletions

View File

@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js";
import { findBestIconMatch } from "../scanner/iconDb.js";
import { suggestIconsForServiceName } from "../scanner/iconDb.js";
interface ServiceListQuery {
deviceId?: string;
@@ -75,23 +75,19 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(204).send();
});
// Nachträgliches Ergänzen fehlender Favicons für bereits bestehende
// Dienste (z. B. von vor diesem Feature) - versucht für jeden Dienst OHNE
// Favicon einen passenden Treffer in der externen Icon-Datenbank zu
// finden (siehe scanner/iconDb.ts), anhand des aktuellen Anzeigenamens.
// Rein additiv: bestehende Favicons werden nie überschrieben, nur leere
// Felder aufgefüllt.
app.post("/api/services/backfill-favicons", async () => {
const services = serviceRepo.listServices();
let updated = 0;
// Liefert für jeden Dienst OHNE Favicon eine Liste möglicher Treffer aus
// der externen Icon-Datenbank (siehe scanner/iconDb.ts), damit der Nutzer
// selbst auswählen kann statt dass automatisch geraten wird (mehrdeutige
// Namen wie "LXC Adguard" könnten sonst falsch zugeordnet werden).
app.get("/api/services/favicon-suggestions", async () => {
const services = serviceRepo.listServices().filter((s) => !s.favicon);
const suggestions = [];
for (const service of services) {
if (service.favicon) continue;
const match = await findBestIconMatch(service.displayName);
if (match) {
serviceRepo.updateService(service.id, { favicon: match });
updated++;
const candidates = await suggestIconsForServiceName(service.displayName);
if (candidates.length > 0) {
suggestions.push({ serviceId: service.id, serviceName: service.displayName, candidates });
}
}
return { checked: services.length, updated };
return { checked: services.length, suggestions };
});
}

View File

@@ -80,31 +80,93 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` }));
}
/**
* Generische Infrastruktur-/Container-Begriffe, die beim Zerlegen eines
* Dienstnamens in Wörter ignoriert werden - "LXC Adguard" soll als "Adguard"
* gesucht werden, nicht als "LXC" (das nie ein sinnvoller Icon-Treffer wäre).
*/
const STOPWORDS = new Set([
"lxc", "vm", "docker", "container", "server", "service", "app", "box",
"host", "node", "instance", "srv", "ct", "the", "der", "die", "das",
]);
function normalize(s: string): string {
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
function significantWords(name: string): string[] {
return name
.split(/[\s_-]+/)
.map((w) => normalize(w))
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
}
async function exactMatch(candidate: string, names: string[]): Promise<string | null> {
const nameSet = new Set(names);
if (nameSet.has(candidate)) return candidate;
const collapsed = candidate.replace(/-/g, "");
const found = names.find((n) => n.replace(/-/g, "") === collapsed);
return found ?? null;
}
/**
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
* Software-Namen ("Plex", "Pi-hole", "Adguard Home") - für den Fall, dass
* der Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts).
* Bewusst konservativ: nur ein Treffer, wenn der normalisierte Name exakt
* (oder nach Entfernen von Bindestrichen) übereinstimmt - eine reine
* Teilstring-Suche würde bei kurzen/generischen Namen zu viele falsche
* Zuordnungen liefern (z. B. "nas" -> "nas4free" wäre noch plausibel, aber
* "web" könnte alles Mögliche treffen).
* Software-Namen ("Plex", "LXC Adguard", "Pi-hole") - für den Fall, dass der
* Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts). Läuft
* unbeaufsichtigt während eines Scans, liefert deshalb nur EINEN Treffer
* (den ersten gefundenen: erst der ganze Name, dann Wort für Wort, generische
* Begriffe wie "LXC"/"Docker" werden dabei übersprungen) statt einer Auswahl.
*/
export async function findBestIconMatch(name: string): Promise<string | null> {
const normalized = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length < 2) return null;
const names = await getIconNames();
const nameSet = new Set(names);
if (nameSet.has(normalized)) return `${CDN_BASE}/${normalized}.png`;
const collapsed = normalized.replace(/-/g, "");
const exactCollapsed = names.find((n) => n.replace(/-/g, "") === collapsed);
if (exactCollapsed) return `${CDN_BASE}/${exactCollapsed}.png`;
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const match = await exactMatch(wholeName, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
for (const word of significantWords(name)) {
const match = await exactMatch(word, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
return null;
}
/**
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten (ganzer
* Name + jedes einzelne aussagekräftige Wort, exakte Treffer UND
* Teilstring-Treffer) statt nur den ersten - für die interaktive
* "Fehlende Favicons ergänzen"-Übersicht, wo der Mensch selbst auswählt,
* welcher Treffer stimmt, statt dass automatisch geraten wird.
*/
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
const names = await getIconNames();
const seen = new Set<string>();
const results: IconSearchResult[] = [];
function addIfNew(iconName: string) {
if (seen.has(iconName)) return;
seen.add(iconName);
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
}
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const exact = await exactMatch(wholeName, names);
if (exact) addIfNew(exact);
}
for (const word of significantWords(name)) {
const exact = await exactMatch(word, names);
if (exact) addIfNew(exact);
if (results.length >= limit) break;
for (const n of names) {
if (results.length >= limit) break;
if (n.includes(word)) addIfNew(n);
}
}
return results.slice(0, limit);
}