diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 0d4149c..e26dc8b 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -25,14 +25,17 @@ function mapRow(row: typeof devices.$inferSelect): Device { } /** - * Aktualisiert NUR online-Status + lastPing (Zeitstempel des Live-Status- - * Checks) - bewusst getrennt von jedem "echten" Scan-Upsert. Wird - * ausschließlich vom optionalen Ping-Heartbeat genutzt (siehe - * liveStatus.ts), niemals von einem Scan-Endpunkt. + * Aktualisiert online-Status + (nur bei online=true) lastPing. Ein + * fehlgeschlagener Ping ist keine "Sichtung" - lastPing soll ausschließlich + * den Zeitpunkt zeigen, an dem das Gerät zuletzt tatsächlich geantwortet + * hat, nicht wann zuletzt VERSUCHT wurde, es zu erreichen. Sonst würde + * "zuletzt gesehen" bei einem dauerhaft offline/entfernten Gerät trotzdem + * ständig auf "gerade eben" weiterrücken, nur weil der Heartbeat es + * regelmäßig (erfolglos) anpingt. */ export function updateDeviceOnlineStatus(id: string, online: boolean): void { db.update(devices) - .set({ online, lastPing: nowIso() }) + .set(online ? { online, lastPing: nowIso() } : { online }) .where(eq(devices.id, id)) .run(); } diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts index 88d2e7c..2f4e04d 100644 --- a/apps/backend/src/routes/services.ts +++ b/apps/backend/src/routes/services.ts @@ -2,6 +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"; interface ServiceListQuery { deviceId?: string; @@ -73,4 +74,24 @@ export async function serviceRoutes(app: FastifyInstance): Promise { } 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; + for (const service of services) { + if (service.favicon) continue; + const match = await findBestIconMatch(service.displayName); + if (match) { + serviceRepo.updateService(service.id, { favicon: match }); + updated++; + } + } + return { checked: services.length, updated }; + }); } diff --git a/apps/backend/src/scanner/iconDb.ts b/apps/backend/src/scanner/iconDb.ts index ac84b35..a12c97f 100644 --- a/apps/backend/src/scanner/iconDb.ts +++ b/apps/backend/src/scanner/iconDb.ts @@ -79,3 +79,32 @@ export async function searchIconDb(query: string, limit = 24): Promise n.includes(q)).slice(0, limit); return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` })); } + +/** + * 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). + */ +export async function findBestIconMatch(name: string): Promise { + 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`; + + return null; +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 576afdb..7ed42d1 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -2,6 +2,7 @@ import { resolveHostname, reverseLookup } from "./dns.js"; import { isPortOpen, TYPICAL_PORTS } from "./ports.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; +import { findBestIconMatch } from "./iconDb.js"; export interface ScanTarget { hostname: string; @@ -151,5 +152,23 @@ export async function scanDeviceServices( } } + // Bleibt danach IMMER NOCH kein Favicon übrig (kein bestätigtes eigenes, + // kein Sibling), zuletzt versuchen, per erkanntem Software-/Anzeigenamen + // ein passendes Icon aus der externen Icon-Datenbank zu finden (siehe + // iconDb.ts). Nur bei eindeutigem Treffer - lieber gar kein Favicon als + // ein falsch zugeordnetes. + for (const service of found) { + if (service.favicon) continue; + const nameToMatch = service.category ? service.suggestedDisplayName : null; + if (!nameToMatch) continue; + try { + const match = await findBestIconMatch(nameToMatch); + if (match) service.favicon = match; + } catch { + // Icon-Datenbank nicht erreichbar (kein Internetzugang o. ä.) - der + // Scan soll deswegen nicht fehlschlagen, einfach ohne Favicon weiter. + } + } + return { services: found, suggestedHostname }; } diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 323e199..ab32fa9 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -334,7 +334,7 @@ export function HomePage() { dark:bg-white/[0.02]" > {hasFavorites ? ( -
+
{favoriteServices.length > 0 ? ( 0 ? ( -
+
) : null} -
+
Später lesen @@ -384,23 +384,23 @@ export function HomePage() {
{hasReadLaterChips ? ( -
+
{readLaterItems!.slice(0, readLaterLimit).map((item) => ( diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 90ad102..c4d8b19 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -503,6 +503,46 @@ function SortableHeader({ ); } +function BackfillFaviconsButton() { + const queryClient = useQueryClient(); + const [message, setMessage] = useState(null); + + const mutation = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/services/backfill-favicons", { method: "POST" }); + if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`); + return res.json() as Promise<{ checked: number; updated: number }>; + }, + onSuccess: (result) => { + setMessage( + result.updated > 0 + ? `${result.updated} von ${result.checked} Diensten mit einem passenden Favicon aus der Icon-Datenbank ergänzt.` + : `Keine passenden Treffer gefunden (${result.checked} Dienste ohne Favicon geprüft).` + ); + queryClient.invalidateQueries({ queryKey: ["services"] }); + }, + onError: () => setMessage("Fehlgeschlagen - ist die Icon-Datenbank erreichbar (Internetzugang)?"), + }); + + return ( +
+ {message ? {message} : null} + +
+ ); +} + export function ServicesPage() { const { data: services, isLoading, isError } = useServices(); const { data: devices } = useDevices(); @@ -633,27 +673,30 @@ export function ServicesPage() { description="Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." /> -
- - +
+
+ + +
+
{sortColumn ? ( diff --git a/packages/ui/src/FaviconPicker.tsx b/packages/ui/src/FaviconPicker.tsx index 89cb11e..6515b73 100644 --- a/packages/ui/src/FaviconPicker.tsx +++ b/packages/ui/src/FaviconPicker.tsx @@ -17,15 +17,27 @@ function resolvableSrc(src: string): string { return `/api/favicon-proxy?url=${encodeURIComponent(src)}`; } -/** Prüft per Image-Preload, ob eine Favicon-URL tatsächlich ein Bild liefert - * (nicht nur eine plausibel aussehende, aber tote/404-URL). */ -function checkImageLoads(src: string): Promise { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(true); - img.onerror = () => resolve(false); - img.src = resolvableSrc(src); - }); +/** + * Lädt eine Favicon-URL tatsächlich (nicht nur eine Existenzprüfung) und + * bildet einen Inhalts-Hash daraus. Liefert null, wenn das Laden fehlschlägt. + * Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter + * unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die + * alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich + * würde solche Duplikate nicht erkennen. + */ +async function loadAndHash(src: string): Promise { + try { + const res = await fetch(resolvableSrc(src)); + if (!res.ok) return null; + const buffer = await res.arrayBuffer(); + if (buffer.byteLength === 0) return null; + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } catch { + return null; + } } interface NamedIcon { @@ -33,7 +45,8 @@ interface NamedIcon { name: string; } -/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder. */ +/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder + * und dedupliziert dabei nach Bildinhalt (nicht nur nach URL). */ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] { const [loadable, setLoadable] = useState([]); const key = items.map((i) => i.url).join("|"); @@ -42,9 +55,13 @@ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] { let cancelled = false; setLoadable([]); (async () => { + const seenHashes = new Set(); const results: NamedIcon[] = []; for (const item of items) { - if (await checkImageLoads(item.url)) results.push(item); + const hash = await loadAndHash(item.url); + if (!hash || seenHashes.has(hash)) continue; + seenHashes.add(hash); + results.push(item); } if (!cancelled) setLoadable(results); })(); diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx index 1fd786b..c0ad21e 100644 --- a/packages/ui/src/FavoritesBar.tsx +++ b/packages/ui/src/FavoritesBar.tsx @@ -74,11 +74,11 @@ export function FavoritesBar({ return (
{label ? ( -
+
{label}
) : null} -
+
{list.map((item) => { const ringColor = item.category ? categoryColors?.[item.category] : undefined; return ( @@ -95,20 +95,20 @@ export function FavoritesBar({ ? `${item.displayName} (${item.hostname}:${item.port})` : `${item.displayName} (${item.hostname})` } - className={`group flex w-16 flex-col items-center gap-1.5 rounded-xl p-1.5 + className={`group flex w-14 flex-col items-center gap-1 rounded-xl p-1 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${ onReorder ? "cursor-grab active:cursor-grabbing" : "" } ${draggedId === item.id ? "opacity-40" : ""}`} > - + {item.displayName}