round20: Startseite ueberarbeitet, Favicon-Auswahl bereinigt und Icon-Datenbank integriert, Ping-Schalter repariert

This commit is contained in:
2026-07-23 19:34:09 +02:00
parent ec49534b60
commit 68ca9ec5e1
14 changed files with 381 additions and 176 deletions

View File

@@ -16,6 +16,7 @@ import { recentVisitsRoutes } from "./routes/recentVisits.js";
import { settingsRoutes } from "./routes/settings.js";
import { readLaterRoutes } from "./routes/readLater.js";
import { faviconProxyRoutes } from "./routes/faviconProxy.js";
import { iconsRoutes } from "./routes/icons.js";
import { loadPlugins } from "./plugins/loader.js";
import { startLiveStatusHeartbeat } from "./liveStatus.js";
import * as serviceRepo from "./db/repositories/services.js";
@@ -76,6 +77,7 @@ async function main() {
await app.register(settingsRoutes);
await app.register(readLaterRoutes);
await app.register(faviconProxyRoutes);
await app.register(iconsRoutes);
app.get("/", async () => {
return { name: "LaunchPad API", status: "running" };

View File

@@ -44,6 +44,7 @@ export function startLiveStatusHeartbeat(): void {
const online = await pingHost(device.ip);
deviceRepo.updateDeviceOnlineStatus(device.id, online);
}
settingsRepo.setSetting("liveStatusLastRunAt", new Date().toISOString());
} finally {
running = false;
}

View File

@@ -0,0 +1,10 @@
import type { FastifyInstance } from "fastify";
import { searchIconDb } from "../scanner/iconDb.js";
export async function iconsRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/icons/search", async (request) => {
const { q } = request.query as { q?: string };
const results = await searchIconDb(q ?? "");
return { results };
});
}

View File

@@ -10,6 +10,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
liveStatusEnabled: all.liveStatusEnabled === "true",
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
};
});
@@ -67,6 +68,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
liveStatusEnabled: all.liveStatusEnabled === "true",
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
};
});
}

View File

@@ -0,0 +1,81 @@
import { request } from "node:https";
const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json";
const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag
let cachedNames: string[] | null = null;
let cachedAt = 0;
let inFlight: Promise<string[]> | null = null;
function fetchJson(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const req = request(url, { timeout: 8000 }, (res) => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`));
res.resume();
return;
}
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
} catch (e) {
reject(e);
}
});
});
req.on("timeout", () => req.destroy(new Error("Timeout")));
req.on("error", reject);
req.end();
});
}
/**
* Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem
* homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für
* Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert).
*
* Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen
* Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst)
* - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der
* Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die
* Suche einfach keine Treffer statt einen Fehler zu werfen.
*/
async function getIconNames(): Promise<string[]> {
if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames;
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const tree = (await fetchJson(TREE_URL)) as { png?: string[] };
const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, ""));
cachedNames = names;
cachedAt = Date.now();
return names;
} catch {
// Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl
// (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter.
return cachedNames ?? [];
} finally {
inFlight = null;
}
})();
return inFlight;
}
export interface IconSearchResult {
name: string;
url: string;
}
export async function searchIconDb(query: string, limit = 24): Promise<IconSearchResult[]> {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const names = await getIconNames();
const matches = names.filter((n) => n.includes(q)).slice(0, limit);
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` }));
}

View File

@@ -136,16 +136,19 @@ export async function scanDeviceServices(
});
}
// Fallback-Reihenfolge für Dienste ohne bestätigtes Favicon (kein
// <link rel="icon"> im HTML gefunden): zuerst das Favicon eines ANDEREN
// Dienstes auf demselben Gerät übernehmen (typischerweise dieselbe
// Software/dasselbe Gerät, das Icon passt meist trotzdem) - erst wenn auch
// das fehlt, wird als letzter Ausweg "/favicon.ico" am eigenen Port
// geraten (kann ins Leere laufen, ist aber besser als gar kein Versuch).
// Fallback für Dienste ohne bestätigtes Favicon (kein <link rel="icon">
// im HTML gefunden): das Favicon eines ANDEREN Dienstes auf demselben
// Gerät übernehmen (typischerweise dieselbe Software/dasselbe Gerät, das
// Icon passt meist trotzdem). Gibt es auch das nicht, bleibt favicon
// bewusst leer (Buchstaben-Fallback in der UI) statt blind auf
// "/favicon.ico" zu raten - eine geratene, unbestätigte Adresse sähe in
// der Favicon-Auswahl (Bearbeiten-Formular) wie ein echtes Icon aus,
// obwohl sie oft ins Leere läuft.
const firstSiblingFavicon = found.find((s) => s.favicon)?.favicon;
for (const service of found) {
if (service.favicon) continue;
service.favicon = firstSiblingFavicon ?? `${service.url}/favicon.ico`;
if (firstSiblingFavicon) {
for (const service of found) {
if (!service.favicon) service.favicon = firstSiblingFavicon;
}
}
return { services: found, suggestedHostname };