diff --git a/apps/backend/src/scanner/iconDb.ts b/apps/backend/src/scanner/iconDb.ts index ba320ba..265b9da 100644 --- a/apps/backend/src/scanner/iconDb.ts +++ b/apps/backend/src/scanner/iconDb.ts @@ -81,19 +81,35 @@ export async function searchIconDb(query: string, limit = 24): Promise w.length >= 3 && !STOPWORDS.has(w)); } -async function exactMatch(candidate: string, names: string[]): Promise { - 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 { 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 { } /** - * 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 { 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; } diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 7ed42d1..1e40878 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -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, diff --git a/apps/backend/src/scanner/ports.ts b/apps/backend/src/scanner/ports.ts index 01fc745..895a224 100644 --- a/apps/backend/src/scanner/ports.ts +++ b/apps/backend/src/scanner/ports.ts @@ -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, diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 45064e8..27ae6cf 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -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() { > - + {item.displayName} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 88a3b84..203066d 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -402,11 +402,6 @@ function ServiceRow({ ) : null} - {!service.visible ? ( - - ausgeblendet - - ) : null} @@ -586,7 +581,7 @@ function FaviconSuggestionsPanel() { disabled={applyMutation.isPending} className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10" > - + ))} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6462e46..6fab6d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -59,6 +59,11 @@ export interface Service { * keine DNS-Auflösung möglich war. */ deviceHostname?: string | null; + /** + * Ebenfalls nur frontend-seitig ergänzt - Online-Status des zugehörigen + * Geräts, für den kleinen Online/Offline-Punkt in der Trefferliste. + */ + deviceOnline?: boolean; } export interface HealthStatus { @@ -125,13 +130,53 @@ export interface Rankable { } /** - * Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste und - * Lesezeichen zusammen, aber unterscheidbar über `kind` (siehe - * "getrennt von Diensten" bei Favoriten). + * Synthetische Rankable-Form für Geräte in der Suche - Geräte haben keine + * eigene URL zum Öffnen (siehe DeviceSearchResult.kind==="device" in + * ResultsList: wird ausgegraut und nicht anklickbar dargestellt), sollen + * aber trotzdem über Name/IP/MAC auffindbar sein (IP/MAC landen in `alias`, + * dort durchsucht rankService bereits). + */ +export interface DeviceSearchResult { + kind: "device"; + id: string; + displayName: string; + hostname: string; + ip: string; + mac: string | null; + online: boolean; + favicon: null; + category: null; + favorite: false; + order: number; + alias: string[]; + description: string | null; +} + +/** Synthetische Rankable-Form für Später-lesen-Einträge in der Suche. */ +export interface ReadLaterSearchResult { + kind: "readlater"; + id: string; + displayName: string; + hostname: string; + url: string; + favicon: string | null; + category: null; + favorite: false; + order: number; + alias: string[]; + description: string | null; +} + +/** + * Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste, + * Lesezeichen, Geräte und Später-lesen-Einträge zusammen, aber + * unterscheidbar über `kind`. */ export type SearchResult = | (Service & { kind: "service" }) - | (Bookmark & { kind: "bookmark" }); + | (Bookmark & { kind: "bookmark" }) + | DeviceSearchResult + | ReadLaterSearchResult; /** * Ranking-Stufen für die Suche, gemäß Spezifikation: @@ -194,6 +239,37 @@ export function rankServices(items: T[], query: string): T[] .map((entry) => entry.item); } +/** + * Wie rankServices, aber für die Startseiten-Suche, die mehrere Arten von + * Treffern mischt (Dienste, Lesezeichen, Später-lesen, Geräte). Dienste + * stehen dabei IMMER vor allen anderen Arten, unabhängig von der + * Treffergüte - sie sind die eigentlichen "Ziele" der App. Geräte stehen + * immer zuletzt (sie sind nur informativ, nicht direkt anklickbar, siehe + * DeviceSearchResult). Lesezeichen/Später-lesen liegen dazwischen. + */ +export function rankSearchResults(items: SearchResult[], query: string): SearchResult[] { + const q = query.trim(); + if (q.length === 0) return []; + + function kindPriority(kind: SearchResult["kind"]): number { + if (kind === "service") return 0; + if (kind === "device") return 2; + return 1; // bookmark, readlater + } + + return items + .map((item) => ({ item, rank: rankService(item, q) })) + .filter((entry): entry is { item: SearchResult; rank: number } => entry.rank !== null) + .sort((a, b) => { + const kindDiff = kindPriority(a.item.kind) - kindPriority(b.item.kind); + if (kindDiff !== 0) return kindDiff; + if (a.rank !== b.rank) return a.rank - b.rank; + if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1; + return a.item.order - b.item.order; + }) + .map((entry) => entry.item); +} + /** * Bekannte Domain-Fragmente -> naheliegende Kategorie, für den Kategorie- * Vorschlag beim Anlegen eines Lesezeichens (siehe BookmarksPage.tsx). Rein diff --git a/packages/ui/src/Favicon.tsx b/packages/ui/src/Favicon.tsx index 20f7d58..22d0ce7 100644 --- a/packages/ui/src/Favicon.tsx +++ b/packages/ui/src/Favicon.tsx @@ -3,12 +3,13 @@ import { useState, useEffect } from "react"; export interface FaviconProps { src?: string | null; fallbackLetter: string; - size?: "sm" | "md"; + size?: "sm" | "md" | "lg"; } const SIZE_CLASSES: Record, string> = { sm: "h-4 w-4", md: "h-5 w-5", + lg: "h-7 w-7", }; /** diff --git a/packages/ui/src/FaviconPicker.tsx b/packages/ui/src/FaviconPicker.tsx index 61fbd45..077abfa 100644 --- a/packages/ui/src/FaviconPicker.tsx +++ b/packages/ui/src/FaviconPicker.tsx @@ -131,9 +131,9 @@ function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect onMouseEnter={() => setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null)} onMouseLeave={() => setAnchorRect(null)} onClick={() => onSelect(url)} - className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10" + className="rounded-lg p-1 hover:bg-black/5 dark:hover:bg-white/10" > - + ); @@ -205,7 +205,7 @@ export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps {open ? (
-
+
{showingSearch ? ( searching ? (

Suche …

) : loadableDbResults.length > 0 ? ( -
+
{loadableDbResults.map((r) => ( Bereits im System verwendet

-
+
{loadableExisting.map((r) => ( - + {item.displayName} diff --git a/packages/ui/src/ResultsList.tsx b/packages/ui/src/ResultsList.tsx index 640eb79..7f22266 100644 --- a/packages/ui/src/ResultsList.tsx +++ b/packages/ui/src/ResultsList.tsx @@ -26,11 +26,31 @@ export interface ResultsListProps { onToggleFavorite?: (item: SearchResult) => void; } +function OnlineDot({ online }: { online: boolean }) { + return ( + + ); +} + +const KIND_BADGE: Record, string> = { + bookmark: "Lesezeichen", + readlater: "Später lesen", + device: "Gerät", +}; + /** - * Zeigt die (bereits per rankServices sortierten) Suchtreffer an – Dienste - * und Lesezeichen gemeinsam, unterscheidbar an einem kleinen Badge. Die - * Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement gesteuert; - * diese Komponente ist rein darstellend + klick-/tastaturbar. + * Zeigt die (bereits per rankSearchResults sortierten) Suchtreffer an – + * Dienste, Lesezeichen, Später-lesen und Geräte gemeinsam, unterscheidbar an + * einem kleinen Badge. Geräte haben keine eigene URL zum Öffnen und werden + * deshalb ausgegraut und nicht anklickbar dargestellt (nur zum Auffinden per + * Name/IP/MAC, siehe rankSearchResults) - Dienste und Geräte zeigen + * zusätzlich einen Online/Offline-Punkt. + * + * Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement + * gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar. * * Füllt die Höhe des Elternelements aus (h-full) und scrollt selbst intern – * das Elternelement muss dafür `flex-1 min-h-0` sein (klassischer @@ -64,43 +84,53 @@ export function ResultsList({ > {results.map((item, index) => { const active = index === selectedIndex; + const isDevice = item.kind === "device"; const subtitle = - item.kind === "service" ? `${item.hostname}:${item.port}` : item.hostname; + item.kind === "service" + ? `${item.hostname}:${item.port}` + : item.kind === "device" + ? item.ip + : item.hostname; const categoryColor = item.category ? categoryColors?.[item.category] : undefined; + const online = item.kind === "device" ? item.online : item.kind === "service" ? item.deviceOnline : undefined; return (
  • onHover(index)} - onClick={() => onOpen(item)} + onClick={() => !isDevice && onOpen(item)} onKeyDown={(e: KeyboardEvent) => { - if (e.key === "Enter") onOpen(item); + if (e.key === "Enter" && !isDevice) onOpen(item); }} style={ - categoryColor + categoryColor && !isDevice ? { backgroundColor: hexToRgba(categoryColor, active ? 0.18 : 0.1) ?? undefined } : undefined } - className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left - transition-colors ${ - categoryColor - ? "" - : active - ? "bg-black/5 dark:bg-white/10" - : "hover:bg-black/[0.03] dark:hover:bg-white/5" - }`} + className={`flex w-full items-center gap-3 px-5 py-3 text-left transition-colors ${ + isDevice + ? "cursor-default opacity-50" + : `cursor-pointer ${ + categoryColor + ? "" + : active + ? "bg-black/5 dark:bg-white/10" + : "hover:bg-black/[0.03] dark:hover:bg-white/5" + }` + }`} > + {online !== undefined ? : null} {item.displayName} - {item.kind === "bookmark" ? ( + {item.kind !== "service" ? ( - Lesezeichen + {KIND_BADGE[item.kind]} ) : null} @@ -122,25 +152,27 @@ export function ResultsList({ ) : null} - + {!isDevice ? : null} - + {item.kind === "service" || item.kind === "bookmark" ? ( + + ) : null}
  • );