Files
homelab/site/app.js

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);
});
});
}