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