Files
homelab/site/admin.js
T

584 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();