round22: Startseite kompakter, Ping-Zeitstempel-Bug behoben, echte Favicon-Deduplizierung per Bildinhalt, automatischer Favicon-Abgleich aus Icon-Datenbank + Nachruest-Button

This commit is contained in:
2026-07-23 20:58:58 +02:00
parent 6a66128172
commit b098007742
8 changed files with 181 additions and 49 deletions

View File

@@ -25,14 +25,17 @@ function mapRow(row: typeof devices.$inferSelect): Device {
} }
/** /**
* Aktualisiert NUR online-Status + lastPing (Zeitstempel des Live-Status- * Aktualisiert online-Status + (nur bei online=true) lastPing. Ein
* Checks) - bewusst getrennt von jedem "echten" Scan-Upsert. Wird * fehlgeschlagener Ping ist keine "Sichtung" - lastPing soll ausschließlich
* ausschließlich vom optionalen Ping-Heartbeat genutzt (siehe * den Zeitpunkt zeigen, an dem das Gerät zuletzt tatsächlich geantwortet
* liveStatus.ts), niemals von einem Scan-Endpunkt. * 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 { export function updateDeviceOnlineStatus(id: string, online: boolean): void {
db.update(devices) db.update(devices)
.set({ online, lastPing: nowIso() }) .set(online ? { online, lastPing: nowIso() } : { online })
.where(eq(devices.id, id)) .where(eq(devices.id, id))
.run(); .run();
} }

View File

@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import { findBestIconMatch } from "../scanner/iconDb.js";
interface ServiceListQuery { interface ServiceListQuery {
deviceId?: string; deviceId?: string;
@@ -73,4 +74,24 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
} }
return reply.code(204).send(); 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 };
});
} }

View File

@@ -79,3 +79,32 @@ export async function searchIconDb(query: string, limit = 24): Promise<IconSearc
const matches = names.filter((n) => n.includes(q)).slice(0, limit); const matches = names.filter((n) => n.includes(q)).slice(0, limit);
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` })); 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<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`;
return null;
}

View File

@@ -2,6 +2,7 @@ import { resolveHostname, reverseLookup } from "./dns.js";
import { isPortOpen, TYPICAL_PORTS } from "./ports.js"; import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
import { probeHttp } from "./http.js"; import { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js"; import { detectSoftware } from "./softwareDetection.js";
import { findBestIconMatch } from "./iconDb.js";
export interface ScanTarget { export interface ScanTarget {
hostname: string; 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 }; return { services: found, suggestedHostname };
} }

View File

@@ -334,7 +334,7 @@ export function HomePage() {
dark:bg-white/[0.02]" dark:bg-white/[0.02]"
> >
{hasFavorites ? ( {hasFavorites ? (
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-3 p-3">
{favoriteServices.length > 0 ? ( {favoriteServices.length > 0 ? (
<FavoritesBar <FavoritesBar
items={favoriteServices} items={favoriteServices}
@@ -363,7 +363,7 @@ export function HomePage() {
) : null} ) : null}
{recentVisits && recentVisits.length > 0 ? ( {recentVisits && recentVisits.length > 0 ? (
<div className="p-4"> <div className="p-3">
<FavoritesBar <FavoritesBar
items={recentVisits} items={recentVisits}
label="Zuletzt besucht" label="Zuletzt besucht"
@@ -376,7 +376,7 @@ export function HomePage() {
</div> </div>
) : null} ) : null}
<div className="p-4"> <div className="p-3">
<div className="mb-2.5 flex items-center justify-between gap-2"> <div className="mb-2.5 flex items-center justify-between gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35"> <span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
Später lesen Später lesen
@@ -384,23 +384,23 @@ export function HomePage() {
<ReadLaterBox /> <ReadLaterBox />
</div> </div>
{hasReadLaterChips ? ( {hasReadLaterChips ? (
<div className="flex flex-wrap gap-2.5"> <div className="flex flex-wrap gap-2">
{readLaterItems!.slice(0, readLaterLimit).map((item) => ( {readLaterItems!.slice(0, readLaterLimit).map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")} onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
title={item.displayName} title={item.displayName}
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]" transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
> >
<span <span
className="flex h-11 w-11 items-center justify-center rounded-xl border className="flex h-9 w-9 items-center justify-center rounded-xl border
border-black/10 bg-white/80 shadow-sm transition-transform border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5" group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
> >
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" /> <Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</span> </span>
<span className="w-full truncate text-center text-[11px] leading-tight text-black/55 dark:text-white/55"> <span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
{item.displayName} {item.displayName}
</span> </span>
</button> </button>

View File

@@ -503,6 +503,46 @@ function SortableHeader({
); );
} }
function BackfillFaviconsButton() {
const queryClient = useQueryClient();
const [message, setMessage] = useState<string | null>(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 (
<div className="flex items-center gap-2 pb-2">
{message ? <span className="text-xs text-black/40 dark:text-white/40">{message}</span> : null}
<Button
size="sm"
variant="ghost"
onClick={() => {
setMessage(null);
mutation.mutate();
}}
disabled={mutation.isPending}
title="Für alle Dienste ohne Favicon einen passenden Treffer aus der Icon-Datenbank suchen"
>
{mutation.isPending ? "Suche …" : "Fehlende Favicons ergänzen"}
</Button>
</div>
);
}
export function ServicesPage() { export function ServicesPage() {
const { data: services, isLoading, isError } = useServices(); const { data: services, isLoading, isError } = useServices();
const { data: devices } = useDevices(); 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." 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."
/> />
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10"> <div className="mb-4 flex items-center justify-between gap-2 border-b border-black/10 dark:border-white/10">
<button <div className="flex gap-1">
onClick={() => setActiveTab("visible")} <button
className={`px-3 py-2 text-sm font-medium ${ onClick={() => setActiveTab("visible")}
activeTab === "visible" className={`px-3 py-2 text-sm font-medium ${
? "border-b-2 border-black text-black dark:border-white dark:text-white" activeTab === "visible"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70" ? "border-b-2 border-black text-black dark:border-white dark:text-white"
}`} : "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
> }`}
Sichtbar ({visibleCount}) >
</button> Sichtbar ({visibleCount})
<button </button>
onClick={() => setActiveTab("hidden")} <button
className={`px-3 py-2 text-sm font-medium ${ onClick={() => setActiveTab("hidden")}
activeTab === "hidden" className={`px-3 py-2 text-sm font-medium ${
? "border-b-2 border-black text-black dark:border-white dark:text-white" activeTab === "hidden"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70" ? "border-b-2 border-black text-black dark:border-white dark:text-white"
}`} : "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
> }`}
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount}) >
</button> <FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button>
</div>
<BackfillFaviconsButton />
</div> </div>
{sortColumn ? ( {sortColumn ? (

View File

@@ -17,15 +17,27 @@ function resolvableSrc(src: string): string {
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`; 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). */ * Lädt eine Favicon-URL tatsächlich (nicht nur eine Existenzprüfung) und
function checkImageLoads(src: string): Promise<boolean> { * bildet einen Inhalts-Hash daraus. Liefert null, wenn das Laden fehlschlägt.
return new Promise((resolve) => { * Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter
const img = new Image(); * unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die
img.onload = () => resolve(true); * alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich
img.onerror = () => resolve(false); * würde solche Duplikate nicht erkennen.
img.src = resolvableSrc(src); */
}); async function loadAndHash(src: string): Promise<string | null> {
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 { interface NamedIcon {
@@ -33,7 +45,8 @@ interface NamedIcon {
name: string; 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[] { function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
const [loadable, setLoadable] = useState<NamedIcon[]>([]); const [loadable, setLoadable] = useState<NamedIcon[]>([]);
const key = items.map((i) => i.url).join("|"); const key = items.map((i) => i.url).join("|");
@@ -42,9 +55,13 @@ function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
let cancelled = false; let cancelled = false;
setLoadable([]); setLoadable([]);
(async () => { (async () => {
const seenHashes = new Set<string>();
const results: NamedIcon[] = []; const results: NamedIcon[] = [];
for (const item of items) { 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); if (!cancelled) setLoadable(results);
})(); })();

View File

@@ -74,11 +74,11 @@ export function FavoritesBar({
return ( return (
<div> <div>
{label ? ( {label ? (
<div className="mb-2.5 text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35"> <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
{label} {label}
</div> </div>
) : null} ) : null}
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2.5"> <div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-2">
{list.map((item) => { {list.map((item) => {
const ringColor = item.category ? categoryColors?.[item.category] : undefined; const ringColor = item.category ? categoryColors?.[item.category] : undefined;
return ( return (
@@ -95,20 +95,20 @@ export function FavoritesBar({
? `${item.displayName} (${item.hostname}:${item.port})` ? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})` : `${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] ${ transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06] ${
onReorder ? "cursor-grab active:cursor-grabbing" : "" onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`} } ${draggedId === item.id ? "opacity-40" : ""}`}
> >
<span <span
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined} style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
className="flex h-11 w-11 items-center justify-center rounded-xl border className="flex h-9 w-9 items-center justify-center rounded-xl border
border-black/10 bg-white/80 shadow-sm transition-transform border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5" group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
> >
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" /> <Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</span> </span>
<span className="w-full truncate text-center text-[11px] leading-tight text-black/55 dark:text-white/55"> <span className="w-full truncate text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
{item.displayName} {item.displayName}
</span> </span>
</button> </button>