const state = { components: [], filter: "all", generatedAt: null }; const publicAccessModes = new Set(["public"]); const onlineStates = new Set(["up"]); const knownComponentIcons = { gitea: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg", hedgedoc: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", hedgedoc2: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg", "code-server": "https://cdn.simpleicons.org/coder", chat: "https://xiteng.site/icons/services/chat.svg", comfyui: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg", invokeai: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg", authentik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", "authentik-worker": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", "authentik-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", "seaweedfs-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg", "authentik-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", "gitea-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", "hedgedoc-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg", "authentik-redis": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg", "cloudflare-tunnel": "https://cdn.simpleicons.org/cloudflare", traefik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg", portal: "https://xiteng.site/favicon.svg" }; const requestedFocusId = new URLSearchParams(window.location.search).get("focus") || ""; const focusComponentId = /^[a-z0-9][a-z0-9-]*$/.test(requestedFocusId) ? requestedFocusId : ""; let focusHandled = false; const statusLabels = { running: "运行中", restarting: "重启中", unhealthy: "异常", degraded: "部分异常", paused: "已暂停", exited: "已停止", dead: "不可用", created: "待启动", missing: "已消失", archived: "已归档", unknown: "未知" }; const monitorLabels = { up: "服务可用", down: "服务不可用", degraded: "等待重试", pending: "等待检查", paused: "探测暂停", unmonitored: "未配置探测" }; function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) { return "—"; } const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } const digits = value >= 100 || unitIndex === 0 ? 0 : 1; return `${value.toFixed(digits)} ${units[unitIndex]}`; } function normalizedPercent(value) { return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0; } function updateMetric(name, percent, detail, secondary) { const value = normalizedPercent(percent); document.getElementById(`${name}-value`).textContent = Number.isFinite(percent) ? `${value.toFixed(1)}%` : "N/A"; const progress = document.getElementById(`${name}-progress`); progress.value = value; progress.textContent = `${value.toFixed(1)}%`; document.getElementById(`${name}-detail`).textContent = detail; document.getElementById(`${name}-secondary`).textContent = secondary; } function renderMetrics(payload) { document.getElementById("device-hostname").textContent = payload.hostname || "HOMELAB"; updateMetric( "cpu", payload.cpu?.usagePercent, `${payload.cpu?.logicalCores || "—"} 线程 · ${payload.cpu?.model || "Unknown CPU"}`, `LOAD ${payload.cpu?.load1?.toFixed(2) ?? "—"} / ${payload.cpu?.load5?.toFixed(2) ?? "—"} / ${payload.cpu?.load15?.toFixed(2) ?? "—"}` ); updateMetric( "memory", payload.memory?.usagePercent, `${formatBytes(payload.memory?.usedBytes)} / ${formatBytes(payload.memory?.totalBytes)}`, `AVAILABLE ${formatBytes(payload.memory?.availableBytes)}` ); updateMetric( "disk", payload.disk?.usagePercent, `${formatBytes(payload.disk?.usedBytes)} / ${formatBytes(payload.disk?.totalBytes)}`, `${payload.disk?.device || "ROOT"} · AVAILABLE ${formatBytes(payload.disk?.availableBytes)}` ); const gpu = payload.gpu?.devices?.[0]; if (payload.gpu?.available && gpu) { updateMetric( "gpu", gpu.utilizationPercent, gpu.name, `VRAM ${gpu.memoryUsedMiB?.toFixed(0) ?? "—"} / ${gpu.memoryTotalMiB?.toFixed(0) ?? "—"} MiB · ${gpu.temperatureCelsius?.toFixed(0) ?? "—"}°C · ${gpu.powerDrawWatts?.toFixed(0) ?? "—"} W` ); } else { updateMetric("gpu", null, "未发现 NVIDIA GPU", "VRAM —"); } const updated = new Date(payload.generatedAt); document.getElementById("metrics-updated").textContent = `实时公开指标 · ${updated.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`; } function renderMetricsError() { for (const name of ["cpu", "memory", "disk", "gpu"]) { updateMetric(name, null, "指标暂时不可用", "等待采集器恢复"); } document.getElementById("metrics-updated").textContent = "主机指标暂时不可用"; } async function loadMetrics() { try { const response = await fetch("/api/metrics", { headers: {"Accept": "application/json"}, cache: "no-store" }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } renderMetrics(await response.json()); } catch (error) { console.error("Failed to load host metrics", error); renderMetricsError(); } } function createElement(tagName, className, text) { const element = document.createElement(tagName); if (className) { element.className = className; } if (text !== undefined) { element.textContent = text; } return element; } function componentIconSources(component) { const sources = []; const explicit = safeHref(component.iconUrl); if (explicit) sources.push(explicit); const known = safeHref(knownComponentIcons[component.id]); if (known) sources.push(known); const service = safeHref(component.url); if (service) { const origin = new URL(service).origin; sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`); } return [...new Set(sources)]; } function componentIcon(component) { const icon = createElement("span", "component-icon"); icon.append(createElement("span", "component-icon-fallback", component.icon || component.name.slice(0, 2).toUpperCase())); const sources = componentIconSources(component); const trySource = (index) => { if (index >= sources.length) return; const image = document.createElement("img"); image.alt = ""; image.decoding = "async"; image.referrerPolicy = "no-referrer"; let finished = false; const timeout = window.setTimeout(() => { finished = true; trySource(index + 1); }, 4000); image.addEventListener("load", () => { if (finished) return; finished = true; window.clearTimeout(timeout); icon.querySelector("img")?.remove(); icon.append(image); icon.classList.add("has-image"); }, {once: true}); image.addEventListener("error", () => { if (finished) return; finished = true; window.clearTimeout(timeout); trySource(index + 1); }, {once: true}); image.src = sources[index]; }; trySource(0); return icon; } function isPublic(component) { return publicAccessModes.has(component.access); } function matchesFilter(component) { if (state.filter === "public") { return isPublic(component); } if (state.filter === "restricted") { return !isPublic(component); } if (state.filter === "online") { return onlineStates.has(component.monitor?.status); } return true; } function safeHref(value) { if (!value) { return null; } try { const url = new URL(value); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } } function componentCard(component) { const href = safeHref(component.url); const card = createElement(href ? "a" : "article", `component-card accent-${component.accent || "ink"}`); card.dataset.componentId = component.id; if (href) { card.href = href; if (component.navigation !== "same-tab") { card.target = "_blank"; card.rel = "noopener noreferrer"; } card.setAttribute("aria-label", component.navigation === "same-tab" ? `打开 ${component.name}` : `在新标签打开 ${component.name}`); } const head = createElement("div", "component-head"); head.append(componentIcon(component)); const states = createElement("div", "component-state-stack"); const status = createElement( "span", `component-status status-${component.status || "unknown"}`, `容器 · ${statusLabels[component.status] || statusLabels.unknown}` ); states.append(status); if (component.monitor?.enabled) { states.append(createElement( "span", `component-status component-monitor-status monitor-${component.monitor.status || "pending"}`, monitorLabels[component.monitor.status] || "等待检查" )); } head.append(states); card.append(head); card.append(createElement("p", "component-kicker", component.category || "组件")); card.append(createElement("h3", "", component.name)); card.append(createElement("p", "component-description", component.description || "未提供说明")); const foot = createElement("div", "component-foot"); const tags = createElement("div", "component-tags"); tags.append(createElement( "span", `tag ${isPublic(component) ? "access-public" : "access-restricted"}`, component.accessLabel || (isPublic(component) ? "无需登录" : "受控访问") )); if (component.endpoint) { tags.append(createElement("span", "tag", component.endpoint)); } if (component.instanceCount > 1) { tags.append(createElement("span", "tag", `${component.instanceCount} 实例`)); } if (component.monitor?.enabled && Number.isFinite(component.monitor.uptime24h)) { tags.append(createElement("span", "tag", `24H ${component.monitor.uptime24h.toFixed(2)}%`)); } if (component.monitor?.enabled && Number.isFinite(component.monitor.responseTimeMs)) { tags.append(createElement("span", "tag", `${component.monitor.responseTimeMs} ms`)); } foot.append(tags); const meta = createElement("div", "component-meta"); meta.append(createElement("span", "", component.image || component.service || "Docker service")); if (href) { meta.append(createElement("span", "component-open", component.navigation === "same-tab" ? "打开 →" : "新标签打开 ↗")); } else { meta.append(createElement("span", "", component.service || "internal")); } foot.append(meta); card.append(foot); return card; } function renderCatalog(targetId, section) { const target = document.getElementById(targetId); const visible = state.components.filter((component) => component.section === section && matchesFilter(component)); target.replaceChildren(); if (!visible.length) { target.append(createElement("div", "empty-state", "当前筛选条件下没有组件。")); return; } const groups = new Map(); for (const component of visible) { const category = component.category || "其他"; if (!groups.has(category)) { groups.set(category, []); } groups.get(category).push(component); } for (const [category, components] of groups) { const group = createElement("section", "category-group"); group.append(createElement("h3", "category-title", `${category} · ${components.length}`)); const grid = createElement("div", "component-grid"); for (const component of components) { grid.append(componentCard(component)); } group.append(grid); target.append(group); } } function renderSummary() { const total = state.components.length; const running = state.components.filter((component) => component.monitor?.status === "up").length; const restricted = state.components.filter((component) => !isPublic(component)).length; document.getElementById("component-count").textContent = String(total); document.getElementById("running-count").textContent = String(running); document.getElementById("restricted-count").textContent = String(restricted); const updated = document.getElementById("catalog-updated"); if (state.generatedAt) { const date = new Date(state.generatedAt); updated.textContent = `统一发现与健康检查 · 更新于 ${date.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`; } } function focusReturnedComponent() { if (focusHandled || !focusComponentId) return; const card = document.querySelector(`[data-component-id="${CSS.escape(focusComponentId)}"]`); if (!card) return; focusHandled = true; window.requestAnimationFrame(() => { card.scrollIntoView({behavior: "smooth", block: "center"}); card.classList.add("return-focus"); window.setTimeout(() => card.classList.remove("return-focus"), 1800); }); const url = new URL(window.location.href); url.searchParams.delete("focus"); window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); } function render() { renderSummary(); renderCatalog("services-catalog", "services"); renderCatalog("infrastructure-catalog", "infrastructure"); focusReturnedComponent(); } function renderError(message) { for (const targetId of ["services-catalog", "infrastructure-catalog"]) { const target = document.getElementById(targetId); target.replaceChildren(createElement("div", "error-state", message)); } document.getElementById("catalog-updated").textContent = "组件目录暂时不可用"; } async function loadComponents() { try { const response = await fetch("/api/components", { headers: {"Accept": "application/json"}, cache: "no-store" }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = await response.json(); if (!Array.isArray(payload.components)) { throw new Error("invalid registry response"); } state.components = payload.components; if (focusComponentId && state.components.some((component) => component.id === focusComponentId)) { state.filter = "all"; for (const button of document.querySelectorAll(".filter")) { button.classList.toggle("active", button.dataset.filter === "all"); } } state.generatedAt = payload.generatedAt; render(); } catch (error) { console.error("Failed to load component catalog", error); renderError("无法读取动态组件目录,请稍后刷新。"); } } for (const button of document.querySelectorAll(".filter")) { button.addEventListener("click", () => { state.filter = button.dataset.filter || "all"; for (const item of document.querySelectorAll(".filter")) { item.classList.toggle("active", item === button); } render(); }); } document.getElementById("year").textContent = String(new Date().getFullYear()); loadComponents(); loadMetrics(); setInterval(() => { if (document.visibilityState === "visible") { loadComponents(); } }, 30000); setInterval(() => { if (document.visibilityState === "visible") { loadMetrics(); } }, 5000); if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("/sw.js").catch((error) => { console.error("Failed to register service worker", error); }); }); }