generated from Dicken/dickendock
round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user