feat: rebuild xiteng.site homelab platform
This commit is contained in:
+456
@@ -0,0 +1,456 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user