feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+583
View File
@@ -0,0 +1,583 @@
const state = {
session: null,
identity: null,
identitySearch: "",
credentials: [],
providers: [],
audit: [],
components: [],
busy: new Set()
};
const lifecycleLabels = {
active: "当前存在",
missing: "已经消失",
archived: "已经归档"
};
const monitorLabels = {
up: "可用",
down: "不可用",
degraded: "等待重试",
pending: "等待检查",
paused: "已暂停",
unmonitored: "未配置"
};
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"
};
function element(tagName, className, text) {
const node = document.createElement(tagName);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
function componentIconSources(component) {
const sources = [];
try {
if (component.iconUrl) sources.push(new URL(component.iconUrl).href);
} catch {}
if (knownComponentIcons[component.id]) sources.push(knownComponentIcons[component.id]);
try {
const origin = new URL(component.url).origin;
sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`);
} catch {}
return [...new Set(sources)];
}
function componentIcon(component) {
const icon = element("span", "component-icon");
icon.append(element("span", "component-icon-fallback", component.icon || component.id.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 formatTime(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
async function request(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
...options,
headers: {
"Accept": "application/json",
...(options.body ? {"Content-Type": "application/json"} : {}),
...(options.headers || {})
}
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = {error: text};
}
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function actionButton(label, handler, {dangerous = false, disabled = false} = {}) {
const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label);
button.type = "button";
button.disabled = disabled;
button.addEventListener("click", handler);
return button;
}
function pill(label, tone = "") {
return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label);
}
async function loadSession() {
const payload = await request("/api/admin/session");
state.session = payload.identity;
document.getElementById("admin-session").textContent =
`${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`;
}
function groupChecks(selectedIds) {
const wrapper = element("div", "admin-checks");
for (const group of state.identity?.groups || []) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = group.id;
input.checked = selectedIds.includes(group.id);
label.append(input, document.createTextNode(group.name));
wrapper.append(label);
}
return wrapper;
}
function renderIdentityUser(user) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", user.name || user.username), element("code", "", `${user.username} · ${user.uuid}`));
const badges = element("div", "admin-component-status");
badges.append(
pill(user.active ? "可登录" : "已停用", user.active ? "monitor-up" : "monitor-down"),
pill(`TOTP ${user.totpCount}`),
pill(`Passkey ${user.passkeyCount}`),
...(user.administrator ? [pill("管理员", "monitor-up")] : [])
);
heading.append(title, badges);
const meta = element("p", "admin-help", `${user.email || "未设置邮箱"} · 最近登录 ${formatTime(user.lastLogin)} · 创建 ${formatTime(user.createdAt)}`);
const groups = element("p", "admin-help", `用户组:${user.groups.map((group) => group.name).join("、") || "无"}`);
const actions = element("div", "admin-actions");
actions.append(actionButton("编辑", async () => {
const name = window.prompt("显示名", user.name || user.username);
if (name === null) return;
const email = window.prompt("邮箱,可留空", user.email || "");
if (email === null) return;
await mutateIdentity(`/api/admin/identity/users/${user.id}/update`, {name, email});
}));
if (user.username !== "liooil") {
actions.append(actionButton(user.active ? "停用" : "启用", () => mutateIdentity(
`/api/admin/identity/users/${user.id}/${user.active ? "disable" : "enable"}`,
{}
), {dangerous: user.active}));
}
actions.append(actionButton("设置临时密码", async () => {
const password = window.prompt("输入至少 12 个字符的临时密码");
if (password) await mutateIdentity(`/api/admin/identity/users/${user.id}/password`, {password});
}));
actions.append(actionButton("生成恢复链接", async () => {
const payload = await mutateIdentity(`/api/admin/identity/users/${user.id}/recovery`, {}, false);
window.prompt("一小时内有效的密码设置链接", payload.result.link);
}));
actions.append(actionButton("注销全部会话", () => mutateIdentity(`/api/admin/identity/users/${user.id}/sessions`, {}), {dangerous: true}));
if (user.totpCount) actions.append(actionButton("重置 TOTP", async () => {
if (window.confirm(`删除 ${user.username} 的全部 ${user.totpCount} 个 TOTP 设备?`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-totp`, {});
}, {dangerous: true}));
if (user.passkeyCount) actions.append(actionButton("重置 Passkey", async () => {
if (window.confirm(`删除 ${user.username} 的全部 ${user.passkeyCount} 个 Passkey`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-passkeys`, {});
}, {dangerous: true}));
record.append(heading, meta, groups, actions);
return record;
}
function renderIdentityGroup(group) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", group.name), element("code", "", group.id));
heading.append(title, pill(`${group.users.length} 个用户`));
const checks = element("div", "admin-checks");
for (const user of state.identity.users) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = String(user.id);
input.checked = group.userIds.includes(user.id);
label.append(input, document.createTextNode(user.username));
checks.append(label);
}
const actions = element("div", "admin-actions");
actions.append(actionButton("保存成员", async () => {
const desired = new Set([...checks.querySelectorAll("input:checked")].map((input) => Number(input.value)));
for (const user of state.identity.users) {
const current = group.userIds.includes(user.id);
if (current !== desired.has(user.id)) {
await request(`/api/admin/identity/groups/${group.id}/members`, {
method: "POST",
body: JSON.stringify({userId: user.id, member: desired.has(user.id)})
});
}
}
await loadIdentity();
}));
if (group.name !== "liuhome") {
actions.append(actionButton("重命名", async () => {
const name = window.prompt("用户组名称", group.name);
if (name) await mutateIdentity(`/api/admin/identity/groups/${group.id}/update`, {name});
}));
actions.append(actionButton("删除", async () => {
if (window.confirm(`删除用户组 ${group.name}`)) await mutateIdentity(`/api/admin/identity/groups/${group.id}/delete`, {});
}, {dangerous: true}));
}
record.append(heading, checks, actions);
return record;
}
function renderIdentityApplication(application) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", application.name), element("code", "", application.slug));
heading.append(title, pill(application.available ? (application.allowedGroups.join("、") || "所有已登录用户") : "未配置", application.available ? "monitor-up" : "monitor-down"));
const checks = groupChecks(application.allowedGroupIds);
const actions = element("div", "admin-actions");
actions.append(actionButton("保存允许组", async () => {
const groupIds = [...checks.querySelectorAll("input:checked")].map((input) => input.value);
await mutateIdentity(`/api/admin/identity/applications/${application.slug}/groups`, {groupIds});
}, {disabled: !application.available}));
record.append(heading, checks, actions);
return record;
}
function renderIdentity() {
const status = document.getElementById("identity-status");
status.className = `admin-notice ${state.identity.healthy ? "success" : "warning"}`;
status.textContent = state.identity.healthy
? `Authentik 已连接;基础访问组为 ${state.identity.protectedGroup}`
: "Authentik 健康检查失败。";
document.getElementById("admin-users").textContent = String(state.identity.users.length);
const users = document.getElementById("identity-users");
users.replaceChildren(...state.identity.users.map(renderIdentityUser));
const groups = document.getElementById("identity-groups");
groups.replaceChildren(...state.identity.groups.map(renderIdentityGroup));
const applications = document.getElementById("identity-applications");
applications.replaceChildren(...state.identity.applications.map(renderIdentityApplication));
const audit = document.getElementById("identity-audit");
audit.replaceChildren(...state.identity.audit.map((event) => {
const row = element("div", "audit-row");
row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actor), element("strong", "", event.action), element("span", "", event.target));
return row;
}));
if (!state.identity.audit.length) audit.append(element("div", "empty-state", "暂无身份修改记录。"));
}
async function loadIdentity() {
const query = state.identitySearch ? `?search=${encodeURIComponent(state.identitySearch)}` : "";
state.identity = await request(`/api/admin/identity${query}`);
renderIdentity();
}
async function mutateIdentity(path, body, reload = true) {
const payload = await request(path, {method: "POST", body: JSON.stringify(body)});
if (reload) await loadIdentity();
return payload;
}
function credentialActions(credential) {
const actions = element("div", "admin-actions");
actions.append(actionButton("校验密文", () => runCredentialAction(credential, "verify")));
actions.append(actionButton("替换", async () => {
const apiKey = window.prompt(`输入 ${credential.providerId} / ${credential.name} 的新 API Key`);
if (!apiKey) return;
await runCredentialAction(credential, "replace", {secret: {provider: {apiKey}}});
}));
actions.append(actionButton("永久删除", async () => {
if (window.confirm(`永久删除 ${credential.providerId} / ${credential.name}`)) {
await runCredentialAction(credential, "delete");
}
}, {dangerous: true}));
return actions;
}
function renderCredential(credential) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const identity = element("div");
identity.append(element("h3", "", `${credential.providerId} / ${credential.name}`));
identity.append(element("code", "", credential.id));
const badges = element("div", "admin-component-status");
badges.append(pill(`sha256:${credential.fingerprint}`));
heading.append(identity, badges);
const details = element("dl", "admin-details vault-details");
const values = [
["所有者", `${credential.owner.username} · ${credential.owner.sub}`],
["身份源", credential.owner.issuer],
["创建", formatTime(credential.createdAt)],
["更新", formatTime(credential.updatedAt)]
];
for (const [name, value] of values) {
const item = element("div");
item.append(element("dt", "", name), element("dd", "", value));
details.append(item);
}
record.append(heading, details, credentialActions(credential));
return record;
}
function renderAuditEvent(event) {
const row = element("div", "audit-row");
row.append(
element("time", "", formatTime(event.createdAt)),
element("code", "", event.actorUsername),
element("strong", "", event.action),
element("span", "", event.detail || event.targetId || "—"),
pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down")
);
return row;
}
function renderVault() {
const credentials = document.getElementById("vault-credentials");
credentials.replaceChildren();
if (state.credentials.length) {
state.credentials.forEach((credential) => credentials.append(renderCredential(credential)));
} else {
credentials.append(element("div", "empty-state", "Vault 中没有 Backend Credential。"));
}
document.getElementById("admin-credentials").textContent = String(state.credentials.length);
const providerSelect = document.getElementById("admin-credential-provider");
providerSelect.replaceChildren();
state.providers.filter((provider) => provider.connection.type === "backend").forEach((provider) => {
providerSelect.append(new Option(`${provider.name} (${provider.id})`, provider.id));
});
const audit = document.getElementById("vault-audit");
audit.replaceChildren();
if (state.audit.length) state.audit.forEach((event) => audit.append(renderAuditEvent(event)));
else audit.append(element("div", "empty-state", "暂无 Vault 审计事件。"));
}
async function loadVault() {
try {
const [providers, credentials, audit] = await Promise.all([
request("/api/admin/vault/providers"),
request("/api/admin/vault/credentials"),
request("/api/admin/vault/audit")
]);
state.providers = providers.providers || [];
state.credentials = credentials.credentials || [];
state.audit = audit.events || [];
renderVault();
} catch (error) {
document.getElementById("vault-credentials").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`));
}
}
async function runCredentialAction(credential, action, body = {}) {
await withBusy(`credential-${credential.id}`, async () => {
const payload = await request(`/api/admin/vault/credentials/${credential.id}/${action}`, {
method: "POST",
body: JSON.stringify(body)
});
if (action === "verify") {
window.alert(payload.verified ? "密文完整,可以使用。" : "密文校验失败,请立即替换或吊销。" );
}
await loadVault();
});
}
function componentActionButton(component, action, label, dangerous = false) {
return actionButton(label, () => runComponentAction(component, action), {
dangerous,
disabled: state.busy.has(`component-${component.id}`)
});
}
function componentActions(component) {
const actions = element("div", "admin-actions");
actions.append(component.lifecycle === "archived"
? componentActionButton(component, "restore", "恢复")
: componentActionButton(component, "archive", "归档"));
if (component.monitor.enabled) {
actions.append(component.monitor.status === "paused"
? componentActionButton(component, "resume", "恢复探测")
: componentActionButton(component, "pause", "暂停探测"));
}
if (["missing", "archived"].includes(component.lifecycle)) {
actions.append(componentActionButton(component, "purge", "永久清理", true));
}
return actions;
}
function componentRow(component) {
const row = element("article", "admin-component");
const identity = element("div", "admin-component-identity");
identity.append(componentIcon(component));
const title = element("div");
title.append(element("h2", "", component.name), element("code", "", component.id));
identity.append(title);
const status = element("div", "admin-component-status");
status.append(
pill(lifecycleLabels[component.lifecycle] || component.lifecycle, `lifecycle-${component.lifecycle}`),
pill(monitorLabels[component.monitor.status] || component.monitor.status, `monitor-${component.monitor.status}`)
);
const details = element("dl", "admin-details");
const values = [
["最后发现", formatTime(component.lastSeen)],
["最后检查", formatTime(component.monitor.checkedAt)],
["响应时间", Number.isFinite(component.monitor.responseTimeMs) ? `${component.monitor.responseTimeMs} ms` : "—"],
["24h 可用率", Number.isFinite(component.monitor.uptime24h) ? `${component.monitor.uptime24h.toFixed(2)}%` : "—"]
];
for (const [name, value] of values) {
const item = element("div");
item.append(element("dt", "", name), element("dd", "", value));
details.append(item);
}
if (component.monitor.error) {
details.append(element("p", "admin-error", `最近错误:${component.monitor.error}`));
}
row.append(identity, status, details, componentActions(component));
return row;
}
function renderComponents() {
const target = document.getElementById("admin-components");
target.replaceChildren();
document.getElementById("admin-total").textContent = String(state.components.length);
document.getElementById("admin-down").textContent = String(
state.components.filter((component) => component.monitor.status === "down").length
);
if (!state.components.length) {
target.append(element("div", "empty-state", "Registry 中没有组件。"));
return;
}
state.components.forEach((component) => target.append(componentRow(component)));
}
async function loadComponents() {
try {
const payload = await request("/api/admin/components");
state.components = Array.isArray(payload.components) ? payload.components : [];
renderComponents();
document.getElementById("admin-updated").textContent = `更新于 ${formatTime(payload.generatedAt)}`;
} catch (error) {
document.getElementById("admin-components").replaceChildren(
element("div", "error-state", `管理数据暂时不可用:${error.message}`)
);
document.getElementById("admin-updated").textContent = "Registry 管理接口不可用";
}
}
async function runComponentAction(component, action) {
if (action === "purge" && !window.confirm(`永久清理 ${component.name} 及其全部监控历史?此操作不可撤销。`)) {
return;
}
await withBusy(`component-${component.id}`, async () => {
await request(`/api/admin/components/${component.id}/${action}`, {method: "POST", body: "{}"});
await loadComponents();
});
}
async function withBusy(key, operation) {
if (state.busy.has(key)) {
return;
}
state.busy.add(key);
try {
await operation();
} catch (error) {
window.alert(`操作失败:${error.message}`);
} finally {
state.busy.delete(key);
}
}
document.getElementById("vault-refresh").addEventListener("click", loadVault);
document.getElementById("admin-refresh").addEventListener("click", loadComponents);
document.getElementById("identity-refresh").addEventListener("click", loadIdentity);
document.getElementById("identity-search-form").addEventListener("submit", async (event) => {
event.preventDefault();
state.identitySearch = String(new FormData(event.currentTarget).get("search") || "").trim();
await loadIdentity();
});
document.getElementById("user-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
await withBusy("user-create", async () => {
await mutateIdentity("/api/admin/identity/users", Object.fromEntries(new FormData(event.currentTarget).entries()));
event.currentTarget.reset();
});
});
document.getElementById("group-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
await withBusy("group-create", async () => {
await mutateIdentity("/api/admin/identity/groups", Object.fromEntries(new FormData(event.currentTarget).entries()));
event.currentTarget.reset();
});
});
document.getElementById("credential-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const payload = {
providerId: data.get("providerId"),
name: data.get("name"),
secret: {provider: {apiKey: data.get("apiKey")}}
};
await withBusy("credential-create", async () => {
await request("/api/admin/vault/credentials", {
method: "POST",
body: JSON.stringify(payload)
});
event.currentTarget.reset();
await loadVault();
});
});
async function initialize() {
try {
await loadSession();
await Promise.all([loadIdentity(), loadVault(), loadComponents()]);
} catch (error) {
document.getElementById("admin-session").textContent = `管理员会话不可用:${error.message}`;
}
}
initialize();