round24: Favicon-Groesse und Wort-Erkennung verbessert, ausgeblendet-Badge entfernt, Proxmox-Port-Bug gefixt, Suche um Geraete und Spaeter-lesen erweitert

This commit is contained in:
2026-07-24 00:58:28 +02:00
parent d87885f16f
commit 6a2bf24498
10 changed files with 331 additions and 94 deletions

View File

@@ -81,19 +81,35 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
}
/**
* 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).
* Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett
* ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein
* sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht
* nach "LXC").
*/
const STOPWORDS = new Set([
"lxc", "vm", "docker", "container", "server", "service", "app", "box",
"host", "node", "instance", "srv", "ct", "the", "der", "die", "das",
"lxc", "vm", "docker", "container", "srv", "ct", "instance",
"the", "der", "die", "das",
]);
/**
* Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES
* Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen,
* nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die
* Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg
* versucht, nachdem alle spezifischeren Wörter erfolglos blieben.
*/
const GENERIC_WORDS = new Set([
"cam", "camera", "media", "print", "printer", "bridge", "hub", "sync",
"box", "sensor", "plug", "light", "dock", "home", "server", "service",
"app", "web", "tv", "cast", "stream", "monitor", "gateway", "node",
"backup", "storage", "network", "device", "system",
]);
function normalize(s: string): string {
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
/** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */
function significantWords(name: string): string[] {
return name
.split(/[\s_-]+/)
@@ -101,33 +117,58 @@ function significantWords(name: string): string[] {
.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;
/** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */
function bigrams(words: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`);
return out;
}
/** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */
function byPriority(words: string[]): string[] {
const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
return [...specific, ...generic];
}
function exactMatch(candidate: string, names: string[]): string | null {
if (names.includes(candidate)) return candidate;
const collapsed = candidate.replace(/-/g, "");
const found = names.find((n) => n.replace(/-/g, "") === collapsed);
return found ?? null;
return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null;
}
/**
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
* 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.
* Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - 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 und ausschließlich über exakte (bzw. nach
* Entfernen von Bindestrichen exakte) Übereinstimmungen - keine
* Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte
* Zuordnung zu unzuverlässig.
*
* Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt
* "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere,
* nicht-generische) zuerst.
*/
export async function findBestIconMatch(name: string): Promise<string | null> {
const names = await getIconNames();
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const match = await exactMatch(wholeName, names);
const match = exactMatch(wholeName, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
for (const word of significantWords(name)) {
const match = await exactMatch(word, names);
const words = significantWords(name);
for (const pair of bigrams(words)) {
const match = exactMatch(pair, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
for (const word of byPriority(words)) {
const match = exactMatch(word, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
@@ -135,11 +176,17 @@ export async function findBestIconMatch(name: string): Promise<string | 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.
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur
* den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht,
* wo der Mensch selbst auswählt, welcher Treffer stimmt.
*
* Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne
* Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist -
* Teilstring-Treffer, wieder erst über spezifische Wörter. Generische
* Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur
* herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben -
* genau das war das gemeldete Problem ("Cam Blink Sync" fand nur
* Kamera-Zubehör über "Cam" statt "Blink").
*/
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
const names = await getIconNames();
@@ -147,20 +194,31 @@ export async function suggestIconsForServiceName(name: string, limit = 8): Promi
const results: IconSearchResult[] = [];
function addIfNew(iconName: string) {
if (seen.has(iconName)) return;
if (seen.has(iconName) || results.length >= limit) return;
seen.add(iconName);
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
}
const words = significantWords(name);
const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w));
const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w));
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const exact = await exactMatch(wholeName, names);
const exact = exactMatch(wholeName, names);
if (exact) addIfNew(exact);
}
for (const pair of bigrams(words)) {
const exact = exactMatch(pair, names);
if (exact) addIfNew(exact);
}
for (const word of specificWords) {
const exact = exactMatch(word, names);
if (exact) addIfNew(exact);
}
for (const word of significantWords(name)) {
const exact = await exactMatch(word, names);
if (exact) addIfNew(exact);
// Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist.
for (const word of specificWords) {
if (results.length >= limit) break;
for (const n of names) {
if (results.length >= limit) break;
@@ -168,5 +226,21 @@ export async function suggestIconsForServiceName(name: string, limit = 8): Promi
}
}
return results.slice(0, limit);
// Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg,
// wenn spezifische Wörter überhaupt nichts geliefert haben.
if (results.length === 0) {
for (const word of genericWords) {
const exact = exactMatch(word, names);
if (exact) addIfNew(exact);
}
for (const word of genericWords) {
if (results.length >= limit) break;
for (const n of names) {
if (results.length >= limit) break;
if (n.includes(word)) addIfNew(n);
}
}
}
return results;
}

View File

@@ -97,9 +97,23 @@ export async function scanDeviceServices(
const open = await isPortOpen(device.ip, port);
if (!open) continue;
const isHttps = port === 443 || port === 9443;
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const probe = await probeHttp(baseUrl);
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
let probe = await probeHttp(baseUrl);
// probe.status === undefined heißt: die Verbindung ist auf HTTP-Ebene
// gescheitert (z. B. TLS-Handshake-Fehler bei einem HTTPS-only-Dienst wie
// Proxmox auf Port 8006, den die Ports-Liste oben nicht kennt) - nicht
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
// der zusätzliche Versuch.
if (probe.status === undefined) {
isHttps = !isHttps;
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const retryProbe = await probeHttp(baseUrl);
if (retryProbe.status !== undefined) probe = retryProbe;
}
const software = detectSoftware({
server: probe.server,

View File

@@ -3,7 +3,18 @@ import { connect } from "node:net";
/**
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
*/
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443];
export const TYPICAL_PORTS = [
80, 443, 81, 8000, 8080, 8081, 8443, 8888,
3000, 3001, 5000, 5001,
8006, // Proxmox VE
9000, 9090, 9091, 9443, // Portainer, Cockpit/Prometheus, Transmission
8123, // Home Assistant
32400, // Plex
7878, 8989, 9117, 6789, // Radarr, Sonarr, Jackett/Prowlarr, NZBGet/SABnzbd
19999, // Netdata
5665, 5666, // Icinga/Nagios
8181, 8282,
];
/**
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,

View File

@@ -4,7 +4,7 @@ import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons";
import { rankServices, type SearchResult } from "@launchpad/shared";
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
import { useDevices } from "../hooks/useDevices.js";
@@ -133,8 +133,9 @@ export function HomePage() {
const isLoading = servicesLoading || bookmarksLoading;
function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) {
if ("kind" in item && item.kind === "device") return; // Geräte haben keine eigene URL, nicht anklickbar
window.open(item.url, "_blank", "noopener,noreferrer");
if ("kind" in item && item.kind && item.id) {
if ("kind" in item && (item.kind === "service" || item.kind === "bookmark") && item.id) {
recordVisit(item.kind, item.id).then(() => {
queryClient.invalidateQueries({ queryKey: ["recent-visits"] });
});
@@ -167,24 +168,57 @@ export function HomePage() {
});
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
// siehe Admin -> Dienste) tauchen in der Suche nicht auf.
// siehe Admin -> Dienste) tauchen in der Suche nicht auf. Geräte und
// Später-lesen-Einträge sind jetzt ebenfalls durchsuchbar (siehe
// rankSearchResults: Dienste stehen dabei immer zuerst, Geräte immer
// zuletzt - Geräte haben keine eigene URL zum Öffnen, siehe ResultsList).
const allItems: SearchResult[] = useMemo(() => {
const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname]));
const deviceOnlineById = new Map((devices ?? []).map((d) => [d.id, d.online]));
const visibleServices: SearchResult[] = (services ?? [])
.filter((s) => s.visible)
.map((s) => ({
...s,
kind: "service" as const,
deviceHostname: deviceHostnameById.get(s.deviceId) ?? null,
deviceOnline: deviceOnlineById.get(s.deviceId) ?? false,
}));
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
...b,
kind: "bookmark" as const,
}));
return [...visibleServices, ...bookmarkItems];
}, [services, bookmarks, devices]);
const deviceItems: SearchResult[] = (devices ?? []).map((d) => ({
kind: "device" as const,
id: d.id,
displayName: d.hostname,
hostname: d.hostname,
ip: d.ip,
mac: d.mac,
online: d.online,
favicon: null,
category: null,
favorite: false,
order: 0,
alias: [d.ip, d.mac].filter((v): v is string => !!v),
description: d.ip,
}));
const readLaterSearchItems: SearchResult[] = (readLaterItems ?? []).map((r) => ({
kind: "readlater" as const,
id: r.id,
displayName: r.displayName,
hostname: "",
url: r.url,
favicon: r.favicon,
category: null,
favorite: false,
order: 0,
alias: [],
description: null,
}));
return [...visibleServices, ...bookmarkItems, ...deviceItems, ...readLaterSearchItems];
}, [services, bookmarks, devices, readLaterItems]);
const results = useMemo(() => rankServices(allItems, query), [allItems, query]);
const results = useMemo(() => rankSearchResults(allItems, query), [allItems, query]);
const favoriteServices = useMemo(
() =>
@@ -390,7 +424,7 @@ export function HomePage() {
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</span>
<span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
<span className="line-clamp-2 w-full text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
{item.displayName}
</span>
</button>

View File

@@ -402,11 +402,6 @@ function ServiceRow({
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
</span>
) : null}
{!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>
</td>
@@ -586,7 +581,7 @@ function FaviconSuggestionsPanel() {
disabled={applyMutation.isPending}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={c.url} fallbackLetter={c.name} size="sm" />
<Favicon src={c.url} fallbackLetter={c.name} size="lg" />
</button>
))}
</div>