generated from Dicken/dickendock
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export function HomePage() {
|
||||
dark:bg-white/[0.02]"
|
||||
>
|
||||
{hasFavorites ? (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{favoriteServices.length > 0 ? (
|
||||
<FavoritesBar
|
||||
items={favoriteServices}
|
||||
@@ -363,7 +363,7 @@ export function HomePage() {
|
||||
) : null}
|
||||
|
||||
{recentVisits && recentVisits.length > 0 ? (
|
||||
<div className="p-4">
|
||||
<div className="p-3">
|
||||
<FavoritesBar
|
||||
items={recentVisits}
|
||||
label="Zuletzt besucht"
|
||||
@@ -376,7 +376,7 @@ export function HomePage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-3">
|
||||
<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">
|
||||
Später lesen
|
||||
@@ -384,23 +384,23 @@ export function HomePage() {
|
||||
<ReadLaterBox />
|
||||
</div>
|
||||
{hasReadLaterChips ? (
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{readLaterItems!.slice(0, readLaterLimit).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
|
||||
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]"
|
||||
>
|
||||
<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
|
||||
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
</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}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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() {
|
||||
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."
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10">
|
||||
<button
|
||||
onClick={() => setActiveTab("visible")}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
activeTab === "visible"
|
||||
? "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>
|
||||
<button
|
||||
onClick={() => setActiveTab("hidden")}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
activeTab === "hidden"
|
||||
? "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>
|
||||
<div className="mb-4 flex items-center justify-between gap-2 border-b border-black/10 dark:border-white/10">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab("visible")}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
activeTab === "visible"
|
||||
? "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>
|
||||
<button
|
||||
onClick={() => setActiveTab("hidden")}
|
||||
className={`px-3 py-2 text-sm font-medium ${
|
||||
activeTab === "hidden"
|
||||
? "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>
|
||||
</div>
|
||||
<BackfillFaviconsButton />
|
||||
</div>
|
||||
|
||||
{sortColumn ? (
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<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 {
|
||||
@@ -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<NamedIcon[]>([]);
|
||||
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<string>();
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -74,11 +74,11 @@ export function FavoritesBar({
|
||||
return (
|
||||
<div>
|
||||
{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}
|
||||
</div>
|
||||
) : 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) => {
|
||||
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" : ""}`}
|
||||
>
|
||||
<span
|
||||
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
|
||||
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||
</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}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user