457 lines
15 KiB
JavaScript
457 lines
15 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|