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