feat: rebuild xiteng.site homelab platform
@@ -0,0 +1,142 @@
|
||||
# Xiteng Site component labels
|
||||
|
||||
`xiteng.site` does not contain a hardcoded service list. A Docker container opts one or more
|
||||
components into the public catalog with labels under this namespace:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "xiteng.site.component.example.enabled=true"
|
||||
- "xiteng.site.component.example.name=Example"
|
||||
- "xiteng.site.component.example.description=What this component does."
|
||||
- "xiteng.site.component.example.section=services"
|
||||
- "xiteng.site.component.example.category=开发与协作"
|
||||
- "xiteng.site.component.example.url=https://example.xiteng.site"
|
||||
- "xiteng.site.component.example.endpoint=example.xiteng.site:443"
|
||||
- "xiteng.site.component.example.access=sso"
|
||||
- "xiteng.site.component.example.access-label=需要 Authentik"
|
||||
- "xiteng.site.component.example.icon=EX"
|
||||
- "xiteng.site.component.example.icon-url=https://example.xiteng.site/favicon.svg"
|
||||
- "xiteng.site.component.example.accent=blue"
|
||||
- "xiteng.site.component.example.order=100"
|
||||
- "xiteng.site.component.example.navigation=new-tab"
|
||||
- "xiteng.site.component.example.portal-link=native"
|
||||
- "xiteng.site.component.example.monitor.enabled=true"
|
||||
- "xiteng.site.component.example.monitor.url=http://example:8080/healthz"
|
||||
```
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Required | Values / behavior |
|
||||
|---|---:|---|
|
||||
| `enabled` | yes | Only the exact value `true` publishes the component. |
|
||||
| `name` | yes | Public display name. |
|
||||
| `description` | recommended | Public description; never put secrets here. |
|
||||
| `section` | yes | `services` or `infrastructure`. |
|
||||
| `category` | recommended | Dynamic group heading. |
|
||||
| `url` | no | Only HTTP(S) URLs are accepted. No URL renders a non-clickable card. |
|
||||
| `endpoint` | no | Public protocol endpoint or connection hint. |
|
||||
| `access` | yes | Machine-readable mode such as `public`, `sso`, `mixed`, `access-key`, `ssh-key`, `local`, or `internal`. |
|
||||
| `access-label` | recommended | Human-readable access boundary shown on the card. |
|
||||
| `icon` | no | Final fallback text or emoji, limited to eight characters. |
|
||||
| `icon-url` | no | Preferred HTTP(S) icon URL. Without it, the UI tries `/favicon.svg`, `/favicon.ico`, then `/favicon.png` on the component origin before showing `icon`. |
|
||||
| `accent` | no | `red`, `green`, `yellow`, `blue`, or `ink`. |
|
||||
| `order` | no | Numeric order inside a section; defaults to `999`. |
|
||||
| `navigation` | no | `new-tab` (default for URLs), `same-tab`, or `endpoint` (default without a URL). |
|
||||
| `portal-link` | no | `embedded`, `native`, or `none`; documents how the service returns to the Portal. |
|
||||
| `monitor.enabled` | no | `true` enables the built-in HTTP GET probe. |
|
||||
| `monitor.url` | when enabled | Internal HTTP(S) target. It is never returned by the public API. |
|
||||
| `monitor.interval` | no | Check interval in seconds, default `60`. |
|
||||
| `monitor.failures` | no | Consecutive failures before `down`, default `3`. |
|
||||
| `monitor.timeout` | no | Request timeout in seconds, default `10`. |
|
||||
| `monitor.accept` | no | Accepted HTTP codes, default `200-299`; comma-separated values and ranges are supported. |
|
||||
|
||||
The component id (`example` above) must be globally stable. One container may publish multiple
|
||||
components by using multiple ids, which is useful for services such as SeaweedFS Web and its S3 API.
|
||||
|
||||
The registry reads container state and image names from Docker. It returns only the public fields above plus
|
||||
Compose project/service names, runtime status, sanitized monitor state, response time, last check time, and
|
||||
24-hour availability. Internal monitor URLs, errors, environment variables, mounts, raw labels, Docker
|
||||
configuration, and secret values are never returned to the public site container.
|
||||
|
||||
## Navigation contract
|
||||
|
||||
Components with a public HTTP(S) URL open in a new tab by default with `noopener noreferrer`, leaving the
|
||||
Portal available in the original tab. Owned or officially customizable applications link back to
|
||||
`https://xiteng.site/?focus=<component-id>#services`; the Portal clears incompatible filters, scrolls to the
|
||||
component card, briefly highlights it, and then removes the `focus` query parameter. Protocol endpoints and
|
||||
internal-only components remain non-clickable. Do not inject navigation into third-party HTML at the proxy.
|
||||
|
||||
## Monitoring and lifecycle
|
||||
|
||||
The Registry is the only discovery and lifecycle control plane. It uses the stable component id as the
|
||||
database primary key, so a component can never create multiple monitors. It performs bounded-concurrency
|
||||
HTTP GET probes and stores state in `site/data/registry.db`:
|
||||
|
||||
```text
|
||||
active → missing → archived → purged
|
||||
```
|
||||
|
||||
- `active`: the component Label is currently present on a Docker container;
|
||||
- `missing`: the container or Label disappeared, but catalog and monitor history remain for 30 days;
|
||||
- `archived`: monitoring is paused and the component is hidden from the default public catalog;
|
||||
- `purged`: an administrator explicitly removes the component and all of its monitoring data.
|
||||
|
||||
A component that returns with the same id before purge reuses its existing history. Raw checks are retained
|
||||
for 30 days; hourly and daily aggregates are retained for 365 days. Response bodies are never stored.
|
||||
|
||||
`https://xiteng.site/admin` and `/api/admin/*` are protected by Authentik ForwardAuth and additionally
|
||||
require the exact username `liooil`; this check is repeated in the site backend and Key Vault. All other
|
||||
users use `/account` and `/api/account/*`. The account page is the canonical self-service surface for
|
||||
profile name/email, avatar resolution, password recovery, TOTP, Passkeys, Authentik sessions, and Provider
|
||||
configuration constrained by the session's `(issuer, sub)` pair. Provider definition, model list, and an
|
||||
optional credential are saved from one form. Backend credentials are encrypted by Key Vault; Frontend
|
||||
credentials remain in the current browser's IndexedDB. Either mode can be saved and connectivity-tested in
|
||||
the same action. The public homepage remains unauthenticated.
|
||||
|
||||
The admin page is the canonical control plane for human users, ordinary groups, password setup/recovery,
|
||||
session revocation, authenticator status/reset, and a group-only application access matrix. The native
|
||||
Authentik admin UI is hidden. TOTP and Passkey enrollment launches dedicated Authentik setup flows and
|
||||
returns to `/account`; list, rename, delete, reset, and ownership checks remain in the Portal API. The account
|
||||
page hashes the normalized email with SHA-256 in the browser, then loads Gravatar and Libravatar directly with
|
||||
no referrer; a deterministic initials image remains visible while loading and on failure. Avatar bytes never
|
||||
pass through the Portal backend. The Portal accesses Authentik with a server-only API token and
|
||||
records every identity mutation in a local JSONL audit; password values are never logged. `liuhome` is
|
||||
protected and currently contains `liooil` and `ziyue`; all managed non-public applications are restricted
|
||||
to that group.
|
||||
|
||||
The default Authentik identification stage enables WebAuthn conditional UI. A discoverable Passkey can
|
||||
authenticate directly on `auth.xiteng.site`; Authentik's default flow policies then skip both password and
|
||||
the later MFA stage. Username/password plus TOTP remains available as a fallback, and new Passkeys are
|
||||
enrolled with the default `resident_key_requirement=preferred` setup stage.
|
||||
|
||||
## Host metrics
|
||||
|
||||
The internal `metrics` service publishes the sanitized `/api/metrics` payload used by the device status
|
||||
cards. It reads host CPU and memory counters from a read-only `/proc` mount, root filesystem capacity
|
||||
through a read-only bind on the same filesystem, and NVIDIA GPU telemetry through the `utility` driver
|
||||
capability. The public payload is limited to:
|
||||
|
||||
- CPU usage, model, logical core count, and load averages;
|
||||
- memory and root filesystem used/available/total values;
|
||||
- GPU model, utilization, VRAM, temperature, and power;
|
||||
- hostname and collection timestamp.
|
||||
|
||||
The metrics container has no Docker socket and no public router. The site server proxies its fixed
|
||||
internal endpoint as `/api/metrics`; arbitrary host files and commands are not exposed.
|
||||
|
||||
## PWA and icons
|
||||
|
||||
`favicon.svg` is the source artwork for browser and install icons. The PNG and ICO derivatives live in
|
||||
`icons/` and at `favicon.ico`. `manifest.webmanifest` enables standalone installation and shortcuts to the
|
||||
service and infrastructure catalogs. `sw.js` caches only the public page shell and static artwork. Runtime
|
||||
component/metrics APIs, Authentik paths, and all administration requests always use the network and are
|
||||
never written to the PWA cache.
|
||||
|
||||
Setting `enabled=false`, removing the labels, or removing the container moves the component to `missing`
|
||||
without changing `index.html` or `app.js`. The old `homepage.*` and `kuma.*` namespaces are no longer read;
|
||||
new and existing components use only the `xiteng.site.component.*` schema.
|
||||
|
||||
Static edge caching is a separate, security-sensitive declaration and is intentionally not part of
|
||||
the public component schema. A service can opt public, user-independent asset directories into the
|
||||
shared cache with `xiteng.site.cache.<policy>.*` labels. See `edge-cache/README.md` for the schema;
|
||||
never apply it to API, admin, callback, tokenized download, HTML, or user-content paths.
|
||||
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#f0c94b">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>我的账户 / xiteng.site</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/account.js?v=2" defer></script>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="topbar">
|
||||
<nav class="shell nav" aria-label="账户导航">
|
||||
<a class="brand" href="/">xiteng.site / account</a>
|
||||
<div class="nav-links">
|
||||
<a id="account-admin-link" href="/admin" hidden>系统管理</a>
|
||||
<a href="https://chat.xiteng.site">Chat</a>
|
||||
<a href="/">公开主页</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="shell admin-main">
|
||||
<section class="admin-heading">
|
||||
<p class="eyebrow">PROFILE / SECURITY / KEY VAULT</p>
|
||||
<h1>我的账户</h1>
|
||||
<p>管理个人资料、密码、双因素认证、Passkey、登录会话和 AI Provider 凭据。</p>
|
||||
<p id="account-session" class="admin-session" aria-live="polite">正在验证会话…</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel account-profile-panel">
|
||||
<div class="admin-panel-heading"><div><p class="section-kicker">PROFILE</p><h2>个人资料</h2></div></div>
|
||||
<div class="account-profile-layout">
|
||||
<div class="account-avatar-card">
|
||||
<img id="account-avatar" class="account-avatar" alt="当前用户头像" referrerpolicy="no-referrer">
|
||||
<strong id="account-avatar-name">正在读取…</strong>
|
||||
<span id="account-avatar-source" class="admin-help"></span>
|
||||
<button id="account-avatar-refresh" class="admin-action" type="button">重新解析头像</button>
|
||||
</div>
|
||||
<form id="account-profile-form" class="admin-form compact">
|
||||
<label>用户名<input id="account-username" disabled></label>
|
||||
<label>显示名<input name="name" maxlength="150" required></label>
|
||||
<label>邮箱<input name="email" type="email" maxlength="254" autocomplete="email"></label>
|
||||
<button class="admin-action" type="submit">保存个人资料</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel">
|
||||
<div class="admin-panel-heading">
|
||||
<div><p class="section-kicker">SECURITY</p><h2>登录与安全</h2></div>
|
||||
<button id="account-security-refresh" class="admin-action" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="account-security-grid">
|
||||
<section class="account-security-card">
|
||||
<h3>密码</h3>
|
||||
<p class="admin-help">生成 15 分钟有效的 Authentik 密码设置链接。现有会话不会自动注销。</p>
|
||||
<button id="account-password" class="admin-action" type="button">修改密码</button>
|
||||
</section>
|
||||
<section class="account-security-card">
|
||||
<div class="account-security-heading"><h3>TOTP 验证器</h3><button id="account-add-totp" class="admin-action" type="button">添加 TOTP</button></div>
|
||||
<div id="account-totp" class="admin-list"><div class="loading-card">正在读取…</div></div>
|
||||
</section>
|
||||
<section class="account-security-card">
|
||||
<div class="account-security-heading"><h3>Passkey</h3><button id="account-add-passkey" class="admin-action" type="button">添加 Passkey</button></div>
|
||||
<div id="account-passkeys" class="admin-list"><div class="loading-card">正在读取…</div></div>
|
||||
</section>
|
||||
<section class="account-security-card account-sessions-card">
|
||||
<div class="account-security-heading"><h3>登录会话</h3><button id="account-logout-all" class="admin-action dangerous" type="button">注销全部会话</button></div>
|
||||
<div id="account-sessions" class="admin-list"><div class="loading-card">正在读取…</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel">
|
||||
<div class="admin-panel-heading">
|
||||
<div><p class="section-kicker">BUILT-IN + CUSTOM</p><h2>Provider Registry</h2></div>
|
||||
<button id="account-refresh" class="admin-action" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="admin-notice">只需填写 URL 与 API Key 即可先行探测;测试不会保存 Provider 或凭据。Backend Key 保存到 Key Vault;Frontend Key 必须跳转到 Chat 后保存在 <code>chat.xiteng.site</code> 的浏览器存储中。</div>
|
||||
<form id="account-provider-form" class="admin-form provider-editor-form">
|
||||
<div class="provider-probe-fields">
|
||||
<label>Provider URL<input name="baseUrl" type="url" required placeholder="https://api.example.com/v1"></label>
|
||||
<label>API Key<input name="apiKey" type="password" maxlength="32768" autocomplete="new-password" placeholder="探测使用;Frontend 保存时会跳转到 Chat 重新输入"></label>
|
||||
<button id="account-provider-test" class="admin-action" type="button">探测 Provider</button>
|
||||
</div>
|
||||
<details class="provider-advanced">
|
||||
<summary>进阶设置</summary>
|
||||
<div class="provider-advanced-grid">
|
||||
<label>ID<input name="id" required maxlength="80" placeholder="自动根据域名生成"></label>
|
||||
<label>名称<input name="name" required maxlength="160" placeholder="自动根据域名生成"></label>
|
||||
<label>API 协议
|
||||
<select name="api" required>
|
||||
<option value="">自动探测</option>
|
||||
<option value="openai-completions">OpenAI Completions</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic-messages">Anthropic Messages</option>
|
||||
<option value="google-generative-ai">Google Generative AI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>连接
|
||||
<select name="connectionType" required>
|
||||
<option value="backend">Backend</option>
|
||||
<option value="frontend">Frontend</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>认证
|
||||
<select name="authType" required>
|
||||
<option value="">自动探测</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="header">Header</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>认证 Header<input name="authHeader" maxlength="120" placeholder="x-api-key"></label>
|
||||
<label>默认模型<input name="defaultModel" required maxlength="300" placeholder="探测后可从模型列表选择"></label>
|
||||
<label>模型发现类型
|
||||
<select name="discoveryType" required>
|
||||
<option value="">根据 API 协议自动选择</option>
|
||||
<option value="openai-models-list">OpenAI Models List</option>
|
||||
<option value="anthropic-models-list">Anthropic Models List</option>
|
||||
<option value="google-models-list">Google Models List</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>模型列表 URL<input name="discoveryUrl" type="url" placeholder="自动根据 Provider URL 生成"></label>
|
||||
<label>Proxy 类型
|
||||
<select name="proxyType">
|
||||
<option value="">None</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="relay">Frontend Relay</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Proxy URL<input name="proxyUrl" placeholder="socks5://proxy:1080" disabled></label>
|
||||
<fieldset class="provider-credential-fields">
|
||||
<legend>Proxy 凭据</legend>
|
||||
<label>Proxy 用户名<input name="proxyUsername" autocomplete="off" disabled></label>
|
||||
<label>Proxy 密码 / Relay Token<input name="proxyPassword" type="password" autocomplete="new-password" disabled></label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
<div class="admin-actions provider-editor-actions">
|
||||
<button class="admin-action" type="submit">保存 Provider 与凭据</button>
|
||||
</div>
|
||||
<div id="account-provider-test-result" class="provider-test-result" aria-live="polite"></div>
|
||||
</form>
|
||||
<div id="account-providers" class="admin-list"><div class="loading-card">正在读取 Provider…</div></div>
|
||||
<div class="admin-panel-heading provider-credentials-heading"><div><p class="section-kicker">SAVED BACKEND SECRETS</p><h3>已保存的 Backend 凭据</h3></div></div>
|
||||
<div id="account-credentials" class="admin-list"><div class="loading-card">正在读取凭据元数据…</div></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel">
|
||||
<div class="admin-panel-heading"><div><p class="section-kicker">AUDIT</p><h2>我的审计记录</h2></div></div>
|
||||
<div id="account-audit" class="admin-audit"></div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,789 @@
|
||||
const state = {session: null, identity: null, providers: [], credentials: [], audit: [], busy: new Set()};
|
||||
|
||||
|
||||
function element(tagName, className, text) {
|
||||
const node = document.createElement(tagName);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
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"});
|
||||
}
|
||||
|
||||
function avatarInitials(profile) {
|
||||
const source = String(profile.name || profile.username || "U").trim() || "U";
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)[0]}` : [...source].slice(0, 2).join("")).toUpperCase();
|
||||
}
|
||||
|
||||
function avatarPlaceholder(profile) {
|
||||
const initials = avatarInitials(profile);
|
||||
const source = String(profile.username || profile.name || initials);
|
||||
let hash = 0;
|
||||
for (const character of source) hash = ((hash << 5) - hash + character.codePointAt(0)) | 0;
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const escaped = initials.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="32" fill="hsl(${hue} 58% 48%)"/><text x="128" y="145" text-anchor="middle" font-family="system-ui,sans-serif" font-size="82" font-weight="800" fill="white">${escaped}</text></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
async function avatarHash(email) {
|
||||
const input = new TextEncoder().encode(String(email || "").trim().toLowerCase());
|
||||
const digest = await crypto.subtle.digest("SHA-256", input);
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function loadAccountAvatar(profile, refresh = false) {
|
||||
const avatar = document.getElementById("account-avatar");
|
||||
const requestId = String(Number(avatar.dataset.requestId || "0") + 1);
|
||||
avatar.dataset.requestId = requestId;
|
||||
avatar.dataset.loading = "true";
|
||||
avatar.src = avatarPlaceholder(profile);
|
||||
if (!String(profile.email || "").trim()) {
|
||||
avatar.dataset.loading = "false";
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = await avatarHash(profile.email);
|
||||
if (avatar.dataset.requestId !== requestId) return;
|
||||
const cacheBuster = refresh ? `&v=${Date.now()}` : "";
|
||||
const sources = [
|
||||
`https://www.gravatar.com/avatar/${hash}?d=404&s=256${cacheBuster}`,
|
||||
`https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256${cacheBuster}`
|
||||
];
|
||||
const trySource = (sourceIndex) => {
|
||||
if (avatar.dataset.requestId !== requestId) return;
|
||||
if (sourceIndex >= sources.length) {
|
||||
avatar.dataset.loading = "false";
|
||||
avatar.avatarLoader = null;
|
||||
return;
|
||||
}
|
||||
const image = new Image();
|
||||
image.referrerPolicy = "no-referrer";
|
||||
avatar.avatarLoader = image;
|
||||
const timeout = window.setTimeout(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
trySource(sourceIndex + 1);
|
||||
}, 5000);
|
||||
image.onload = () => {
|
||||
window.clearTimeout(timeout);
|
||||
if (avatar.dataset.requestId !== requestId) return;
|
||||
avatar.src = image.src;
|
||||
avatar.dataset.loading = "false";
|
||||
if (avatar.avatarLoader === image) avatar.avatarLoader = null;
|
||||
};
|
||||
image.onerror = () => {
|
||||
window.clearTimeout(timeout);
|
||||
trySource(sourceIndex + 1);
|
||||
};
|
||||
image.src = sources[sourceIndex];
|
||||
};
|
||||
trySource(0);
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
cache: "no-store",
|
||||
...options,
|
||||
headers: {"Accept": "application/json", ...(options.body ? {"Content-Type": "application/json"} : {})}
|
||||
});
|
||||
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) {
|
||||
const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function pill(label, tone = "") {
|
||||
return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label);
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const payload = await request("/api/account/session");
|
||||
state.session = payload.identity;
|
||||
document.getElementById("account-session").textContent = `${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`;
|
||||
document.getElementById("account-admin-link").hidden = !payload.identity.admin;
|
||||
}
|
||||
|
||||
function securityRecord(title, detail, actions = []) {
|
||||
const record = element("article", "admin-record account-security-record");
|
||||
const heading = element("div", "admin-record-heading");
|
||||
heading.append(element("strong", "", title));
|
||||
const buttons = element("div", "admin-actions");
|
||||
actions.forEach((action) => buttons.append(action));
|
||||
heading.append(buttons);
|
||||
record.append(heading, element("p", "admin-help", detail));
|
||||
return record;
|
||||
}
|
||||
|
||||
async function mutateAccount(path, body = {}) {
|
||||
return request(path, {method: "POST", body: JSON.stringify(body)});
|
||||
}
|
||||
|
||||
function renderSecurityList(id, entries, emptyText, renderer) {
|
||||
const container = document.getElementById(id);
|
||||
container.replaceChildren();
|
||||
if (!entries.length) container.append(element("div", "empty-state", emptyText));
|
||||
entries.forEach((entry) => container.append(renderer(entry)));
|
||||
}
|
||||
|
||||
function renderAccount() {
|
||||
const {profile, security} = state.identity;
|
||||
const form = document.getElementById("account-profile-form");
|
||||
form.elements.namedItem("name").value = profile.name || profile.username;
|
||||
form.elements.namedItem("email").value = profile.email || "";
|
||||
document.getElementById("account-username").value = profile.username;
|
||||
document.getElementById("account-avatar-name").textContent = profile.name || profile.username;
|
||||
document.getElementById("account-avatar-source").textContent = "浏览器直连 Gravatar → Libravatar → 本地字母";
|
||||
loadAccountAvatar(profile);
|
||||
|
||||
renderSecurityList("account-totp", security.totp, "尚未配置 TOTP。", (device) => securityRecord(
|
||||
device.name,
|
||||
"Authenticator TOTP",
|
||||
[
|
||||
actionButton("重命名", async () => {
|
||||
const name = window.prompt("TOTP 设备名称", device.name);
|
||||
if (name) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/rename`, {name}); await loadAccount(); });
|
||||
}),
|
||||
actionButton("删除", async () => {
|
||||
if (window.confirm(`删除 TOTP 设备 ${device.name}?`)) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/delete`); await loadAccount(); });
|
||||
}, true)
|
||||
]
|
||||
));
|
||||
|
||||
renderSecurityList("account-passkeys", security.passkeys, "尚未注册 Passkey。", (device) => securityRecord(
|
||||
device.name,
|
||||
`${device.deviceType || "WebAuthn"} · 注册 ${formatTime(device.createdAt)} · ${device.aaguid || "AAGUID 未知"}`,
|
||||
[
|
||||
actionButton("重命名", async () => {
|
||||
const name = window.prompt("Passkey 名称", device.name);
|
||||
if (name) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/rename`, {name}); await loadAccount(); });
|
||||
}),
|
||||
actionButton("删除", async () => {
|
||||
if (window.confirm(`删除 Passkey ${device.name}?请确认仍有密码或其他 Passkey 可用。`)) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/delete`); await loadAccount(); });
|
||||
}, true)
|
||||
]
|
||||
));
|
||||
|
||||
renderSecurityList("account-sessions", security.sessions, "当前没有可管理的登录会话。", (session) => securityRecord(
|
||||
session.current ? "当前会话" : (session.userAgent || "登录会话"),
|
||||
`${session.ip || "IP 未知"} · 最近使用 ${formatTime(session.lastUsed)} · 到期 ${formatTime(session.expires)}`,
|
||||
[actionButton("注销", async () => {
|
||||
if (!window.confirm(session.current ? "注销当前会话?" : "注销此会话?")) return;
|
||||
await withBusy(`session-${session.id}`, async () => {
|
||||
await mutateAccount(`/api/account/security/sessions/${session.id}/delete`);
|
||||
if (session.current) window.location.reload(); else await loadAccount();
|
||||
});
|
||||
}, true)]
|
||||
));
|
||||
}
|
||||
|
||||
async function loadAccount() {
|
||||
state.identity = await request("/api/account/identity");
|
||||
renderAccount();
|
||||
}
|
||||
|
||||
const featuredProviderGroups = [
|
||||
{title: "SOTA", ids: ["openai", "anthropic"]},
|
||||
{title: "Proxy", ids: ["openrouter", "rust.cat"]},
|
||||
{title: "本地模型", ids: ["llama.cpp", "ollama"]},
|
||||
{title: "其他常用", ids: ["deepseek", "minimax", "moonshot", "zai"]}
|
||||
];
|
||||
|
||||
function fillProviderForm(provider) {
|
||||
const form = document.getElementById("account-provider-form");
|
||||
const fields = form.elements;
|
||||
form.dataset.providerId = provider.id;
|
||||
fields.namedItem("id").value = provider.id;
|
||||
fields.namedItem("name").value = provider.name;
|
||||
fields.namedItem("api").value = provider.api;
|
||||
fields.namedItem("connectionType").value = provider.connection.type;
|
||||
fields.namedItem("baseUrl").value = provider.connection.baseUrl;
|
||||
fields.namedItem("authType").value = provider.auth.type;
|
||||
fields.namedItem("authHeader").value = provider.auth.header || "";
|
||||
fields.namedItem("proxyType").value = provider.connection.proxy?.type || "";
|
||||
fields.namedItem("proxyUrl").value = provider.connection.proxy?.url || "";
|
||||
fields.namedItem("defaultModel").value = provider.defaultModel || "";
|
||||
fields.namedItem("discoveryType").value = provider.discovery?.type || "";
|
||||
fields.namedItem("discoveryUrl").value = provider.discovery?.url || "";
|
||||
fields.namedItem("apiKey").value = "";
|
||||
fields.namedItem("proxyUsername").value = "";
|
||||
fields.namedItem("proxyPassword").value = "";
|
||||
const hasCredential = provider.connection.type === "backend"
|
||||
&& provider.credentials.some((credential) => credential.name === "default");
|
||||
fields.namedItem("apiKey").placeholder = provider.connection.type === "frontend"
|
||||
? "仅用于探测;Frontend Key 需在 Chat 中保存"
|
||||
: hasCredential ? "已保存;留空保持原凭据" : "可选";
|
||||
syncProviderConnectionFields();
|
||||
form.querySelector(".provider-advanced").open = true;
|
||||
form.scrollIntoView({behavior: "smooth", block: "start"});
|
||||
fields.namedItem("name").focus({preventScroll: true});
|
||||
}
|
||||
|
||||
function providerGroup(title, providers) {
|
||||
const section = element("section", "provider-group");
|
||||
section.append(element("h3", "provider-group-title", title));
|
||||
const list = element("div", "admin-list");
|
||||
providers.forEach((provider) => list.append(renderProvider(provider)));
|
||||
section.append(list);
|
||||
return section;
|
||||
}
|
||||
|
||||
function providerHasCredential(provider, name = "default") {
|
||||
return provider.connection.type === "backend"
|
||||
&& provider.credentials.some((credential) => credential.name === name);
|
||||
}
|
||||
|
||||
function providerTestEndpoint(provider) {
|
||||
return provider.discovery.url;
|
||||
}
|
||||
|
||||
function inferredProviderSettings(baseUrl, api = "", authType = "", authHeader = "") {
|
||||
const url = new URL(baseUrl);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const inferredApi = api || (hostname.includes("anthropic")
|
||||
? "anthropic-messages"
|
||||
: hostname.includes("googleapis") ? "google-generative-ai" : "openai-completions");
|
||||
const inferredAuth = authType || (inferredApi === "anthropic-messages" || inferredApi === "google-generative-ai" ? "header" : "bearer");
|
||||
const inferredHeader = inferredAuth === "header"
|
||||
? authHeader || (inferredApi === "google-generative-ai" ? "x-goog-api-key" : "x-api-key")
|
||||
: "";
|
||||
const inferredId = hostname.replace(/^api\./, "").split(".")[0].replace(/[^a-z0-9._-]+/g, "-") || "custom-provider";
|
||||
return {api: inferredApi, authType: inferredAuth, authHeader: inferredHeader, id: inferredId, name: hostname};
|
||||
}
|
||||
|
||||
function inferredProviderDiscovery(baseUrl, api, type = "", discoveryUrl = "") {
|
||||
const discoveryType = type || (api === "anthropic-messages"
|
||||
? "anthropic-models-list"
|
||||
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list");
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
const url = discoveryUrl || (discoveryType === "anthropic-models-list"
|
||||
? `${normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`}/models?limit=200`
|
||||
: discoveryType === "google-models-list" ? `${normalizedBaseUrl}/models?pageSize=200` : `${normalizedBaseUrl}/models`);
|
||||
return {type: discoveryType, url};
|
||||
}
|
||||
|
||||
function providerAccountEndpoints(provider) {
|
||||
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
|
||||
const url = new URL(baseUrl);
|
||||
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
|
||||
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
|
||||
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
|
||||
if (url.hostname === "api.openai.com" || ["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
|
||||
return [];
|
||||
}
|
||||
|
||||
function providerHeaders(provider, secret) {
|
||||
const headers = new Headers({"Accept": "application/json"});
|
||||
Object.entries(provider.headers || {}).forEach(([name, value]) => headers.set(name, value));
|
||||
Object.entries(secret.provider?.headers || {}).forEach(([name, value]) => headers.set(name, value));
|
||||
const apiKey = secret.provider?.apiKey || "";
|
||||
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
|
||||
if (provider.api === "anthropic-messages" && !headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
|
||||
return headers;
|
||||
}
|
||||
|
||||
function modelResults(payload) {
|
||||
const source = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : [];
|
||||
return source.slice(0, 300).map((model) => {
|
||||
const rawId = model?.id || model?.name || model?.model;
|
||||
if (typeof rawId !== "string" || !rawId.trim()) return null;
|
||||
const id = rawId.replace(/^models\//, "");
|
||||
const pricing = Object.fromEntries(Object.entries(model).filter(([key]) => /price|pricing|cost|rate|token/i.test(key)));
|
||||
return {id, name: model.displayName || model.name?.replace?.(/^models\//, "") || id, ...(Object.keys(pricing).length ? {pricing} : {})};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function providerAccountMetadata(payload) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||
const entries = Object.entries(payload).filter(([key]) => /balance|credit|quota|usage|limit|billing|currency/i.test(key));
|
||||
return entries.length ? Object.fromEntries(entries) : null;
|
||||
}
|
||||
|
||||
function providerRateLimits(response) {
|
||||
const entries = [...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name));
|
||||
return entries.length ? Object.fromEntries(entries) : null;
|
||||
}
|
||||
|
||||
function renderProviderTestResult(result) {
|
||||
const target = document.getElementById("account-provider-test-result");
|
||||
target.replaceChildren();
|
||||
const heading = element("div", "provider-test-heading");
|
||||
heading.append(
|
||||
element("strong", "", `连接成功 · HTTP ${result.status} · ${result.latencyMs}ms`),
|
||||
element("code", "", result.endpoint)
|
||||
);
|
||||
target.append(heading);
|
||||
const models = Array.isArray(result.models) ? result.models : [];
|
||||
const modelSection = element("section", "provider-test-section");
|
||||
modelSection.append(element("h4", "", `发现模型(${result.modelCount ?? models.length})`));
|
||||
if (!models.length) {
|
||||
modelSection.append(element("p", "admin-help", "Provider 未返回可识别的模型列表。"));
|
||||
} else {
|
||||
const modelRow = (model) => {
|
||||
const row = element("div", "provider-test-model");
|
||||
const identity = element("div");
|
||||
identity.append(element("strong", "", model.name || model.id), element("code", "", model.id));
|
||||
if (model.pricing) identity.append(element("small", "", `费率:${JSON.stringify(model.pricing)}`));
|
||||
row.append(identity, actionButton("设为默认", () => {
|
||||
document.getElementById("account-provider-form").elements.namedItem("defaultModel").value = model.id;
|
||||
}));
|
||||
return row;
|
||||
};
|
||||
const displayedModels = models.slice(0, 100);
|
||||
const initialModels = displayedModels.slice(0, window.matchMedia("(max-width: 680px)").matches ? 5 : 12);
|
||||
const list = element("div", "provider-test-models");
|
||||
initialModels.forEach((model) => list.append(modelRow(model)));
|
||||
modelSection.append(list);
|
||||
if (displayedModels.length > initialModels.length) {
|
||||
const more = element("details", "provider-test-more");
|
||||
more.append(element("summary", "admin-action", `展开其余 ${displayedModels.length - initialModels.length} 个模型`));
|
||||
const moreList = element("div", "provider-test-models");
|
||||
displayedModels.slice(initialModels.length).forEach((model) => moreList.append(modelRow(model)));
|
||||
more.append(moreList);
|
||||
modelSection.append(more);
|
||||
}
|
||||
if (models.length > 100) modelSection.append(element("p", "admin-help", `仅展示前 100 个模型;共发现 ${models.length} 个。`));
|
||||
}
|
||||
target.append(modelSection);
|
||||
const metadata = [
|
||||
["账户 / 余额", result.account],
|
||||
["账户探测", result.accountProbe],
|
||||
["限流信息", result.rateLimits]
|
||||
].filter((entry) => entry[1]);
|
||||
if (metadata.length) {
|
||||
const details = element("section", "provider-test-section");
|
||||
details.append(element("h4", "", "账户与费率信息"));
|
||||
metadata.forEach(([label, value]) => {
|
||||
const block = element("div", "provider-test-metadata");
|
||||
block.append(element("strong", "", label), element("pre", "", JSON.stringify(value, null, 2)));
|
||||
details.append(block);
|
||||
});
|
||||
target.append(details);
|
||||
}
|
||||
target.className = "provider-test-result success";
|
||||
}
|
||||
|
||||
function renderProviderTestError(error) {
|
||||
const target = document.getElementById("account-provider-test-result");
|
||||
target.className = "provider-test-result error-state";
|
||||
target.textContent = `测试失败:${error.message}`;
|
||||
}
|
||||
|
||||
function frontendProviderFetch(provider, secret) {
|
||||
if (!provider.connection.proxy) return fetch;
|
||||
if (provider.connection.proxy.type !== "relay") throw new Error(`不支持的 Frontend Proxy:${provider.connection.proxy.type}`);
|
||||
return (url, options = {}) => fetch(provider.connection.proxy.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(secret.proxy?.token ? {"Authorization": `Bearer ${secret.proxy.token}`} : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: String(url),
|
||||
method: options.method || "GET",
|
||||
headers: Object.fromEntries(new Headers(options.headers).entries()),
|
||||
body: typeof options.body === "string" ? options.body : null
|
||||
}),
|
||||
signal: options.signal
|
||||
});
|
||||
}
|
||||
|
||||
async function testProviderConnection(provider, credentialName = "default", secretOverride = null) {
|
||||
if (provider.connection.type === "backend") {
|
||||
return request("/api/account/provider-test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId: provider.id, credentialName})
|
||||
});
|
||||
}
|
||||
const secret = secretOverride || {};
|
||||
if (provider.auth.type !== "none" && !secretOverride) throw new Error("请在 Chat 中配置此 Frontend Provider 的本地 Key");
|
||||
const endpoint = providerTestEndpoint(provider);
|
||||
const providerFetch = frontendProviderFetch(provider, secret);
|
||||
const headers = providerHeaders(provider, secret);
|
||||
const startedAt = performance.now();
|
||||
const response = await providerFetch(endpoint, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(text.slice(0, 1000) || `Provider HTTP ${response.status}`);
|
||||
let payload = {};
|
||||
try { payload = text ? JSON.parse(text) : {}; } catch { payload = {preview: text.slice(0, 1000)}; }
|
||||
const models = modelResults(payload);
|
||||
let accountProbe = null;
|
||||
for (const accountEndpoint of providerAccountEndpoints(provider)) {
|
||||
try {
|
||||
const accountResponse = await providerFetch(accountEndpoint, {method: "GET", headers, signal: AbortSignal.timeout(5000)});
|
||||
if (!accountResponse.ok) continue;
|
||||
accountProbe = {endpoint: accountEndpoint, status: accountResponse.status, data: await accountResponse.json()};
|
||||
break;
|
||||
} catch {
|
||||
// Account metadata is optional and must not fail a successful model probe.
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
latencyMs,
|
||||
endpoint,
|
||||
modelCount: models.length,
|
||||
models,
|
||||
account: providerAccountMetadata(payload),
|
||||
accountProbe,
|
||||
rateLimits: providerRateLimits(response)
|
||||
};
|
||||
}
|
||||
function renderProvider(provider) {
|
||||
const record = element("article", "admin-record");
|
||||
const heading = element("div", "admin-record-heading");
|
||||
const identity = element("div");
|
||||
identity.append(element("h3", "", provider.name), element("code", "", provider.id));
|
||||
const badges = element("div", "admin-component-status");
|
||||
const credentialLabel = provider.connection.type === "frontend"
|
||||
? provider.auth.type === "none" ? "无需 Key" : "由 Chat 管理 Key"
|
||||
: providerHasCredential(provider) ? "凭据已设置" : "无凭据";
|
||||
badges.append(
|
||||
pill(provider.builtin ? "内置" : "Custom", provider.builtin ? "" : "monitor-up"),
|
||||
pill(provider.connection.type === "frontend" ? "Frontend" : "Backend"),
|
||||
pill(credentialLabel, provider.connection.type === "frontend" || providerHasCredential(provider) ? "monitor-up" : ""),
|
||||
pill(provider.api)
|
||||
);
|
||||
heading.append(identity, badges);
|
||||
const proxy = provider.connection.proxy ? `${provider.connection.proxy.type} · ${provider.connection.proxy.url}` : "无";
|
||||
const details = element("p", "admin-help", `${provider.connection.baseUrl} · Proxy ${proxy} · 默认模型 ${provider.defaultModel || "未设置"}`);
|
||||
const actions = element("div", "admin-actions");
|
||||
actions.append(actionButton(provider.builtin ? "复制并配置" : "编辑 Provider 与凭据", () => fillProviderForm(provider)));
|
||||
actions.append(actionButton("测试连通性", () => withBusy(`provider-test-${provider.id}`, async () => {
|
||||
try {
|
||||
renderProviderTestResult(await testProviderConnection(provider));
|
||||
document.getElementById("account-provider-test-result").scrollIntoView({behavior: "smooth", block: "nearest"});
|
||||
} catch (error) {
|
||||
renderProviderTestError(error);
|
||||
throw error;
|
||||
}
|
||||
})));
|
||||
if (provider.connection.type === "frontend") {
|
||||
actions.append(actionButton(provider.auth.type === "none" ? "在 Chat 中配置端点" : "在 Chat 中配置本地 Key(需 CORS / Relay)", () => {
|
||||
window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(provider.id)}`;
|
||||
}));
|
||||
}
|
||||
if (!provider.builtin) {
|
||||
actions.append(actionButton("删除 Custom", async () => {
|
||||
if (!window.confirm(`删除 Custom Provider ${provider.id}?`)) return;
|
||||
await withBusy(`provider-${provider.id}`, async () => {
|
||||
await request(`/api/account/vault/providers/${provider.id}/delete`, {method: "POST", body: "{}"});
|
||||
await loadVault();
|
||||
});
|
||||
}, true));
|
||||
}
|
||||
record.append(heading, details, actions);
|
||||
return record;
|
||||
}
|
||||
|
||||
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), element("code", "", credential.id));
|
||||
const badges = element("div", "admin-component-status");
|
||||
badges.append(pill(`sha256:${credential.fingerprint}`));
|
||||
heading.append(identity, badges);
|
||||
const meta = element("p", "admin-help", `创建 ${formatTime(credential.createdAt)} · 更新 ${formatTime(credential.updatedAt)} · 最近读取 ${formatTime(credential.lastAccessedAt)}`);
|
||||
const actions = element("div", "admin-actions");
|
||||
actions.append(actionButton("校验加密完整性", () => runCredentialAction(credential, "verify")));
|
||||
actions.append(actionButton("编辑 Provider 与凭据", () => {
|
||||
const provider = state.providers.find((item) => item.id === credential.providerId);
|
||||
if (provider) fillProviderForm(provider);
|
||||
}));
|
||||
actions.append(actionButton("删除", async () => {
|
||||
if (window.confirm(`永久删除 ${credential.providerId} 的凭据?`)) await runCredentialAction(credential, "delete");
|
||||
}, true));
|
||||
record.append(heading, meta, actions);
|
||||
return record;
|
||||
}
|
||||
|
||||
function renderVault() {
|
||||
const providers = document.getElementById("account-providers");
|
||||
providers.replaceChildren();
|
||||
const customProviders = state.providers.filter((provider) => !provider.builtin);
|
||||
if (customProviders.length) providers.append(providerGroup("我的 Custom Provider", customProviders));
|
||||
const featuredIds = new Set(featuredProviderGroups.flatMap((group) => group.ids));
|
||||
for (const group of featuredProviderGroups) {
|
||||
const entries = group.ids.map((id) => state.providers.find((provider) => provider.builtin && provider.id === id)).filter(Boolean);
|
||||
if (entries.length) providers.append(providerGroup(group.title, entries));
|
||||
}
|
||||
const moreProviders = state.providers.filter((provider) => provider.builtin && !featuredIds.has(provider.id));
|
||||
if (moreProviders.length) {
|
||||
const more = element("details", "provider-more");
|
||||
more.append(element("summary", "admin-action", `更多内置 Provider(${moreProviders.length})`));
|
||||
const list = element("div", "admin-list provider-more-list");
|
||||
moreProviders.forEach((provider) => list.append(renderProvider(provider)));
|
||||
more.append(list);
|
||||
providers.append(more);
|
||||
}
|
||||
|
||||
const credentials = document.getElementById("account-credentials");
|
||||
credentials.replaceChildren();
|
||||
if (state.credentials.length) state.credentials.forEach((credential) => credentials.append(renderCredential(credential)));
|
||||
else credentials.append(element("div", "empty-state", "尚未保存 Backend Credential。"));
|
||||
|
||||
const audit = document.getElementById("account-audit");
|
||||
audit.replaceChildren();
|
||||
if (!state.audit.length) audit.append(element("div", "empty-state", "暂无审计事件。"));
|
||||
for (const event of state.audit) {
|
||||
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"));
|
||||
audit.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVault() {
|
||||
try {
|
||||
const [providers, credentials, audit] = await Promise.all([
|
||||
request("/api/account/vault/providers"),
|
||||
request("/api/account/vault/credentials"),
|
||||
request("/api/account/vault/audit")
|
||||
]);
|
||||
state.providers = providers.providers || [];
|
||||
state.credentials = credentials.credentials || [];
|
||||
state.audit = audit.events || [];
|
||||
renderVault();
|
||||
} catch (error) {
|
||||
document.getElementById("account-providers").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
function buildSecret(apiKey, proxyUsername, proxyPassword, connectionType = "backend") {
|
||||
return {
|
||||
provider: {apiKey},
|
||||
...(proxyUsername || proxyPassword ? {
|
||||
proxy: connectionType === "frontend"
|
||||
? {token: proxyPassword}
|
||||
: {username: proxyUsername, password: proxyPassword}
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
|
||||
function providerFormValue(form) {
|
||||
const data = new FormData(form);
|
||||
const connectionType = String(data.get("connectionType") || "backend");
|
||||
const proxyType = String(data.get("proxyType") || "");
|
||||
const baseUrl = String(data.get("baseUrl") || "").trim();
|
||||
const inferred = inferredProviderSettings(baseUrl, String(data.get("api") || ""), String(data.get("authType") || ""), String(data.get("authHeader") || ""));
|
||||
const discovery = inferredProviderDiscovery(baseUrl, inferred.api, String(data.get("discoveryType") || ""), String(data.get("discoveryUrl") || ""));
|
||||
const providerId = String(data.get("id") || inferred.id).trim().toLowerCase();
|
||||
const apiKey = String(data.get("apiKey") || "");
|
||||
const proxyUsername = proxyType ? String(data.get("proxyUsername") || "") : "";
|
||||
const proxyPassword = proxyType ? String(data.get("proxyPassword") || "") : "";
|
||||
return {
|
||||
providerId,
|
||||
connectionType,
|
||||
apiKey,
|
||||
proxyUsername,
|
||||
proxyPassword,
|
||||
hasSecretInput: Boolean(apiKey || proxyUsername || proxyPassword),
|
||||
secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType),
|
||||
provider: {
|
||||
id: providerId,
|
||||
name: String(data.get("name") || inferred.name).trim(),
|
||||
api: inferred.api,
|
||||
connection: {
|
||||
type: connectionType,
|
||||
baseUrl,
|
||||
proxy: proxyType ? {type: proxyType, url: data.get("proxyUrl")} : null
|
||||
},
|
||||
auth: {
|
||||
type: inferred.authType,
|
||||
...(inferred.authType === "header" ? {header: inferred.authHeader} : {})
|
||||
},
|
||||
headers: {},
|
||||
defaultModel: String(data.get("defaultModel") || "probe-model"),
|
||||
discovery,
|
||||
builtin: false,
|
||||
credentials: [],
|
||||
credentialState: connectionType === "frontend" ? "local" : "missing"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyProviderDetection(result) {
|
||||
const form = document.getElementById("account-provider-form");
|
||||
const fields = form.elements;
|
||||
const detected = result.detected || {};
|
||||
if (!fields.namedItem("id").value) fields.namedItem("id").value = detected.id || "";
|
||||
if (!fields.namedItem("name").value) fields.namedItem("name").value = detected.name || "";
|
||||
if (!fields.namedItem("api").value) fields.namedItem("api").value = detected.api || "";
|
||||
if (!fields.namedItem("authType").value) fields.namedItem("authType").value = detected.auth?.type || "";
|
||||
if (!fields.namedItem("authHeader").value) fields.namedItem("authHeader").value = detected.auth?.header || "";
|
||||
if (!fields.namedItem("discoveryType").value) fields.namedItem("discoveryType").value = detected.discovery?.type || "";
|
||||
if (!fields.namedItem("discoveryUrl").value) fields.namedItem("discoveryUrl").value = detected.discovery?.url || "";
|
||||
if (!fields.namedItem("defaultModel").value && result.models?.[0]?.id) fields.namedItem("defaultModel").value = result.models[0].id;
|
||||
syncProviderConnectionFields();
|
||||
}
|
||||
|
||||
document.getElementById("account-provider-test").addEventListener("click", async () => {
|
||||
const form = document.getElementById("account-provider-form");
|
||||
const baseUrl = form.elements.namedItem("baseUrl");
|
||||
if (!baseUrl.reportValidity()) return;
|
||||
const value = providerFormValue(form);
|
||||
await withBusy("provider-draft-test", async () => {
|
||||
try {
|
||||
const result = value.connectionType === "backend"
|
||||
? await request("/api/account/provider-test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({provider: value.provider, secret: value.secret})
|
||||
})
|
||||
: await testProviderConnection(value.provider, "default", value.hasSecretInput ? value.secret : null);
|
||||
result.detected ||= {
|
||||
id: value.provider.id,
|
||||
name: value.provider.name,
|
||||
api: value.provider.api,
|
||||
auth: value.provider.auth,
|
||||
connection: value.provider.connection,
|
||||
discovery: value.provider.discovery
|
||||
};
|
||||
applyProviderDetection(result);
|
||||
renderProviderTestResult(result);
|
||||
} catch (error) {
|
||||
renderProviderTestError(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function runCredentialAction(credential, action, body = {}) {
|
||||
await withBusy(`credential-${credential.id}`, async () => {
|
||||
const payload = await request(`/api/account/vault/credentials/${credential.id}/${action}`, {method: "POST", body: JSON.stringify(body)});
|
||||
if (action === "verify") window.alert(payload.verified ? "密文完整。" : "密文校验失败,请立即替换或删除。");
|
||||
await loadVault();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("account-refresh").addEventListener("click", loadVault);
|
||||
|
||||
document.getElementById("account-provider-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const value = providerFormValue(form);
|
||||
const {
|
||||
providerId,
|
||||
connectionType,
|
||||
apiKey,
|
||||
proxyUsername,
|
||||
proxyPassword,
|
||||
hasSecretInput,
|
||||
provider: payload
|
||||
} = value;
|
||||
await withBusy("provider-save", async () => {
|
||||
await request("/api/account/vault/providers", {method: "POST", body: JSON.stringify(payload)});
|
||||
if (hasSecretInput && connectionType === "backend") {
|
||||
const existing = state.credentials.find((credential) => credential.providerId === providerId && credential.name === "default")
|
||||
|| state.credentials.find((credential) => credential.providerId === providerId);
|
||||
if (existing) {
|
||||
await request(`/api/account/vault/credentials/${existing.id}/replace`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)})
|
||||
});
|
||||
} else {
|
||||
await request("/api/account/vault/credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId, name: "default", secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)})
|
||||
});
|
||||
}
|
||||
} else if (connectionType === "frontend") {
|
||||
window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(providerId)}`;
|
||||
return;
|
||||
}
|
||||
await loadVault();
|
||||
form.reset();
|
||||
form.querySelector(".provider-advanced").open = false;
|
||||
syncProviderConnectionFields();
|
||||
delete form.dataset.providerId;
|
||||
});
|
||||
});
|
||||
|
||||
function syncProviderConnectionFields() {
|
||||
const form = document.getElementById("account-provider-form");
|
||||
const fields = form.elements;
|
||||
const frontend = fields.namedItem("connectionType").value === "frontend";
|
||||
const proxyType = fields.namedItem("proxyType");
|
||||
for (const option of proxyType.options) {
|
||||
option.hidden = Boolean(option.value) && (frontend ? option.value !== "relay" : option.value === "relay");
|
||||
}
|
||||
if (proxyType.selectedOptions[0]?.hidden) proxyType.value = "";
|
||||
const proxyConfigured = Boolean(proxyType.value);
|
||||
fields.namedItem("proxyUrl").required = proxyConfigured;
|
||||
fields.namedItem("proxyUrl").disabled = !proxyConfigured;
|
||||
fields.namedItem("proxyUsername").disabled = !proxyConfigured || frontend;
|
||||
const headerAuth = fields.namedItem("authType").value === "header";
|
||||
fields.namedItem("authHeader").disabled = !headerAuth;
|
||||
fields.namedItem("authHeader").required = headerAuth;
|
||||
fields.namedItem("proxyPassword").disabled = !proxyConfigured;
|
||||
fields.namedItem("proxyPassword").placeholder = frontend ? "Relay Token(可选)" : "Proxy 密码(可选)";
|
||||
}
|
||||
|
||||
const providerForm = document.getElementById("account-provider-form");
|
||||
providerForm.elements.namedItem("connectionType").addEventListener("change", syncProviderConnectionFields);
|
||||
providerForm.elements.namedItem("proxyType").addEventListener("change", syncProviderConnectionFields);
|
||||
providerForm.elements.namedItem("authType").addEventListener("change", syncProviderConnectionFields);
|
||||
syncProviderConnectionFields();
|
||||
|
||||
document.getElementById("account-security-refresh").addEventListener("click", () => withBusy("account-refresh", loadAccount));
|
||||
|
||||
document.getElementById("account-profile-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
await withBusy("profile", async () => {
|
||||
await mutateAccount("/api/account/profile", {name: data.get("name"), email: data.get("email")});
|
||||
await loadAccount();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("account-avatar-refresh").addEventListener("click", () => {
|
||||
loadAccountAvatar(state.identity.profile, true);
|
||||
});
|
||||
|
||||
document.getElementById("account-password").addEventListener("click", () => withBusy("password", async () => {
|
||||
const payload = await mutateAccount("/api/account/security/password");
|
||||
window.prompt("15 分钟内有效的密码设置链接", payload.result.link);
|
||||
}));
|
||||
|
||||
document.getElementById("account-add-totp").addEventListener("click", () => window.location.assign(state.identity.security.totpSetupUrl));
|
||||
document.getElementById("account-add-passkey").addEventListener("click", () => window.location.assign(state.identity.security.passkeySetupUrl));
|
||||
|
||||
document.getElementById("account-logout-all").addEventListener("click", async () => {
|
||||
if (!window.confirm("注销全部 Authentik 会话?当前页面也会退出。")) return;
|
||||
await withBusy("sessions-all", async () => {
|
||||
await mutateAccount("/api/account/security/sessions/all");
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
try { await loadSession(); await Promise.all([loadAccount(), loadVault()]); } catch (error) { document.getElementById("account-session").textContent = `会话不可用:${error.message}`; }
|
||||
}
|
||||
|
||||
document.getElementById("account-avatar").src = avatarPlaceholder({name: "U", username: "user"});
|
||||
|
||||
initialize();
|
||||
@@ -0,0 +1,144 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#f0c94b">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Portal 管理 / xiteng.site</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/admin.js" defer></script>
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="topbar">
|
||||
<nav class="shell nav" aria-label="管理导航">
|
||||
<a class="brand" href="/">liooil / xiteng.site</a>
|
||||
<div class="nav-links">
|
||||
<a href="#identity">身份</a>
|
||||
<a href="#vault">Key Vault</a>
|
||||
<a href="#components">组件</a>
|
||||
<a href="/account">我的凭据</a>
|
||||
<a href="/">公开主页</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="shell admin-main">
|
||||
<section class="admin-heading">
|
||||
<p class="eyebrow">ADMIN CONTROL PLANE</p>
|
||||
<h1>身份、凭据与组件</h1>
|
||||
<p>
|
||||
Authentik 是唯一身份系统。只有用户名严格等于 <code>liooil</code> 的会话可以访问本页;
|
||||
Key Vault 独立负责 Backend Credential,管理页不会保存或回显密钥明文。
|
||||
</p>
|
||||
<p id="admin-session" class="admin-session" aria-live="polite">正在验证管理员会话…</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-summary" aria-label="管理摘要">
|
||||
<div><strong id="admin-users">—</strong><span>家庭用户</span></div>
|
||||
<div><strong id="admin-credentials">—</strong><span>Vault 凭据</span></div>
|
||||
<div><strong id="admin-total">—</strong><span>动态组件</span></div>
|
||||
<div><strong id="admin-down">—</strong><span>不可用组件</span></div>
|
||||
</section>
|
||||
|
||||
<section id="identity" class="admin-panel">
|
||||
<div class="admin-panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">AUTHENTIK / IDENTITY</p>
|
||||
<h2>用户与访问策略</h2>
|
||||
</div>
|
||||
<button id="identity-refresh" class="admin-action" type="button">刷新</button>
|
||||
</div>
|
||||
<div id="identity-status" class="admin-notice" aria-live="polite">正在连接 Authentik…</div>
|
||||
|
||||
<form id="identity-search-form" class="admin-form admin-form-inline compact">
|
||||
<label>搜索<input name="search" maxlength="150" placeholder="用户名、显示名或邮箱"></label>
|
||||
<button class="admin-action" type="submit">搜索</button>
|
||||
</form>
|
||||
|
||||
<div class="admin-form-grid">
|
||||
<form id="user-create-form" class="admin-form">
|
||||
<h3>新建普通用户</h3>
|
||||
<label>用户名<input name="username" required maxlength="150" autocomplete="off"></label>
|
||||
<label>显示名<input name="name" required maxlength="160" autocomplete="off"></label>
|
||||
<label>邮箱<input name="email" type="email" maxlength="254" autocomplete="off"></label>
|
||||
<button class="admin-action" type="submit">创建并加入 liuhome</button>
|
||||
</form>
|
||||
<form id="group-create-form" class="admin-form">
|
||||
<h3>新建用户组</h3>
|
||||
<label>名称<input name="name" required maxlength="160" autocomplete="off"></label>
|
||||
<button class="admin-action" type="submit">创建用户组</button>
|
||||
<p><code>liuhome</code> 是家庭基础组,不能重命名或删除。</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-subsection">
|
||||
<h3>用户</h3>
|
||||
<div id="identity-users" class="admin-list"><div class="loading-card">正在读取用户…</div></div>
|
||||
</div>
|
||||
<div class="admin-subsection">
|
||||
<h3>用户组</h3>
|
||||
<div id="identity-groups" class="admin-list"><div class="loading-card">正在读取用户组…</div></div>
|
||||
</div>
|
||||
<div class="admin-subsection">
|
||||
<h3>非开放服务权限矩阵</h3>
|
||||
<p class="admin-help">每个服务只允许所选用户组;当前统一使用 <code>liuhome</code>。</p>
|
||||
<div id="identity-applications" class="admin-list"><div class="loading-card">正在读取应用…</div></div>
|
||||
</div>
|
||||
<div class="admin-subsection">
|
||||
<h3>身份操作审计</h3>
|
||||
<div id="identity-audit" class="admin-audit"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="vault" class="admin-panel">
|
||||
<div class="admin-panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">KEY VAULT</p>
|
||||
<h2>Backend Provider 凭据</h2>
|
||||
</div>
|
||||
<button id="vault-refresh" class="admin-action" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="admin-notice">
|
||||
管理员只能管理 Backend Credential;Frontend Credential 始终保留在用户浏览器中。
|
||||
</div>
|
||||
|
||||
<form id="credential-create-form" class="admin-form admin-form-inline">
|
||||
<label>所有者<input value="当前 Authentik 管理员会话" disabled></label>
|
||||
<label>Provider<select name="providerId" id="admin-credential-provider" required></select></label>
|
||||
<label>名称<input name="name" required maxlength="120" value="default"></label>
|
||||
<label>API Key<input name="apiKey" type="password" required maxlength="32768" autocomplete="new-password"></label>
|
||||
<button class="admin-action" type="submit">加密保存</button>
|
||||
</form>
|
||||
|
||||
<div id="vault-credentials" class="admin-list">
|
||||
<div class="loading-card">正在读取凭据元数据…</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-subsection">
|
||||
<h3>审计</h3>
|
||||
<div id="vault-audit" class="admin-audit"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="components" class="admin-panel">
|
||||
<div class="admin-panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">REGISTRY / MONITORING</p>
|
||||
<h2>组件生命周期</h2>
|
||||
</div>
|
||||
<button id="admin-refresh" class="admin-action" type="button">刷新</button>
|
||||
</div>
|
||||
<p id="admin-updated" class="admin-help" aria-live="polite">正在读取 Registry…</p>
|
||||
<div id="admin-components" class="admin-components" aria-live="polite">
|
||||
<div class="loading-card">正在读取组件生命周期…</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import {appendFileSync, readFileSync} from "node:fs";
|
||||
|
||||
const managedApplications = new Map([
|
||||
["xiteng-portal-admin", "Portal"],
|
||||
["xiteng-portal", "Portal Home"],
|
||||
["xiteng-chat", "Xiteng Chat"],
|
||||
["code-server", "Code Server"],
|
||||
["comfyui", "ComfyUI"],
|
||||
["invokeai", "InvokeAI"],
|
||||
["gitea", "Gitea"],
|
||||
["hedgedoc", "HedgeDoc"],
|
||||
["hedgedoc2", "HedgeDoc 2"],
|
||||
["minio", "SeaweedFS Console"],
|
||||
["seaweedfs", "SeaweedFS"],
|
||||
["remark42", "Remark42"]
|
||||
]);
|
||||
|
||||
|
||||
function text(value, maximum = 160) {
|
||||
return typeof value === "string" ? value.trim().slice(0, maximum) : "";
|
||||
}
|
||||
|
||||
function readAudit(path) {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim().split("\n").filter(Boolean).slice(-100).reverse().map(JSON.parse);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function reject(message, statusCode = 400) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
function writeAudit(path, actor, action, target, detail = "") {
|
||||
appendFileSync(path, `${JSON.stringify({createdAt: new Date().toISOString(), actor, action, target, detail})}\n`, {mode: 0o600});
|
||||
}
|
||||
|
||||
export function createAuthentikAdmin({baseUrl, token, auditPath, adminUsername = "liooil", protectedGroup = "liuhome"}) {
|
||||
async function request(pathname, {method = "GET", body} = {}) {
|
||||
if (!token) {
|
||||
const error = new Error("Authentik API token is not configured");
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
const response = await fetch(new URL(`/api/v3${pathname}`, baseUrl), {
|
||||
method,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
...(body === undefined ? {} : {"Content-Type": "application/json"})
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
const payload = response.status === 204 ? null : await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object"
|
||||
? Object.entries(payload).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`).join("; ")
|
||||
: `HTTP ${response.status}`;
|
||||
const error = new Error(detail || `Authentik HTTP ${response.status}`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function page(pathname) {
|
||||
const payload = await request(pathname);
|
||||
return Array.isArray(payload) ? payload : payload?.results || [];
|
||||
}
|
||||
|
||||
async function groupByName(name) {
|
||||
const groups = await page(`/core/groups/?name=${encodeURIComponent(name)}&include_users=true&page_size=100`);
|
||||
return groups.find((group) => group.name === name) || null;
|
||||
}
|
||||
|
||||
async function userByUsername(username) {
|
||||
const users = await page(`/core/users/?username=${encodeURIComponent(username)}&include_groups=true&page_size=10`);
|
||||
const user = users.find((item) => item.username === username && item.type === "internal");
|
||||
if (!user) reject("Authentik 用户不存在", 404);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function securityForUser(user) {
|
||||
const [totpDevices, passkeys, sessions] = await Promise.all([
|
||||
page("/authenticators/admin/totp/?page_size=200"),
|
||||
page("/authenticators/admin/webauthn/?page_size=200"),
|
||||
page(`/core/authenticated_sessions/?user__username=${encodeURIComponent(user.username)}&page_size=100`)
|
||||
]);
|
||||
return {
|
||||
totp: totpDevices.filter((device) => device.user?.pk === user.pk).map((device) => ({id: device.pk, name: device.name})),
|
||||
passkeys: passkeys.filter((device) => device.user?.pk === user.pk).map((device) => ({
|
||||
id: device.pk,
|
||||
name: device.name,
|
||||
createdAt: device.created_on,
|
||||
deviceType: device.device_type,
|
||||
aaguid: device.aaguid
|
||||
})),
|
||||
sessions: sessions.filter((session) => session.user === user.pk).map((session) => ({
|
||||
id: session.uuid,
|
||||
current: session.current,
|
||||
ip: session.last_ip,
|
||||
userAgent: session.last_user_agent,
|
||||
lastUsed: session.last_used,
|
||||
expires: session.expires
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function accountSummary(username) {
|
||||
const user = await userByUsername(username);
|
||||
const security = await securityForUser(user);
|
||||
const next = encodeURIComponent("https://xiteng.site/account");
|
||||
return {
|
||||
profile: {
|
||||
id: user.pk,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
groups: (user.groups_obj || []).map((group) => group.name),
|
||||
},
|
||||
security: {
|
||||
...security,
|
||||
totpSetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-totp-setup/?next=${next}`,
|
||||
passkeySetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-webauthn-setup/?next=${next}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function accountMutate(pathname, body, username) {
|
||||
const user = await userByUsername(username);
|
||||
let result;
|
||||
let action;
|
||||
if (pathname === "/api/account/profile") {
|
||||
result = await request(`/core/users/${user.pk}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}});
|
||||
action = "profile.update";
|
||||
} else if (pathname === "/api/account/security/password") {
|
||||
result = await request(`/core/users/${user.pk}/recovery/`, {method: "POST", body: {token_duration: "minutes=15"}});
|
||||
action = "password.recovery";
|
||||
} else {
|
||||
const deviceAction = pathname.match(/^\/api\/account\/security\/(totp|passkeys)\/(\d+)\/(rename|delete)$/);
|
||||
const sessionAction = pathname.match(/^\/api\/account\/security\/sessions\/([0-9a-f-]+)\/delete$/);
|
||||
if (deviceAction) {
|
||||
const [, kind, id, operation] = deviceAction;
|
||||
const endpoint = kind === "totp" ? "totp" : "webauthn";
|
||||
const device = await request(`/authenticators/admin/${endpoint}/${id}/`);
|
||||
if (device.user?.pk !== user.pk) reject("认证设备不属于当前用户", 404);
|
||||
result = operation === "rename"
|
||||
? await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "PATCH", body: {name: text(body.name, 200)}})
|
||||
: await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "DELETE"});
|
||||
action = `${kind}.${operation}`;
|
||||
} else if (sessionAction) {
|
||||
const session = await request(`/core/authenticated_sessions/${sessionAction[1]}/`);
|
||||
if (session.user !== user.pk) reject("会话不属于当前用户", 404);
|
||||
result = await request(`/core/authenticated_sessions/${sessionAction[1]}/`, {method: "DELETE"});
|
||||
action = "session.delete";
|
||||
} else if (pathname === "/api/account/security/sessions/all") {
|
||||
result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${user.pk}`, {method: "DELETE"});
|
||||
action = "session.delete_all";
|
||||
} else {
|
||||
reject("Account action not found", 404);
|
||||
}
|
||||
}
|
||||
writeAudit(auditPath, username, action, username, "{}");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
async function summary(search = "") {
|
||||
const query = new URLSearchParams({type: "internal", include_groups: "true", page_size: "100"});
|
||||
if (search) query.set("search", search);
|
||||
const [users, groups, applications, bindings, totpDevices, passkeys, health] = await Promise.all([
|
||||
page(`/core/users/?${query}`),
|
||||
page("/core/groups/?include_users=true&page_size=100"),
|
||||
page("/core/applications/?superuser_full_list=true&page_size=100"),
|
||||
page("/policies/bindings/?page_size=200"),
|
||||
page("/authenticators/admin/totp/?page_size=200"),
|
||||
page("/authenticators/admin/webauthn/?page_size=200"),
|
||||
fetch(new URL("/-/health/ready/", baseUrl), {signal: AbortSignal.timeout(3000)}).then((response) => response.ok).catch(() => false)
|
||||
]);
|
||||
const humanUsers = users.filter((user) => user.type === "internal" && user.path === "users" && user.username !== "AnonymousUser" && !user.username.startsWith("ak-"));
|
||||
const ordinaryGroups = groups.filter((group) => !group.is_superuser && !group.name.startsWith("authentik "));
|
||||
const groupNames = new Map(groups.map((group) => [group.pk, group.name]));
|
||||
const applicationBySlug = new Map(applications.map((application) => [application.slug, application]));
|
||||
return {
|
||||
configured: Boolean(token),
|
||||
healthy: health,
|
||||
protectedGroup,
|
||||
users: humanUsers.map((user) => ({
|
||||
id: user.pk,
|
||||
uuid: user.uuid,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
active: user.is_active,
|
||||
administrator: user.is_superuser,
|
||||
lastLogin: user.last_login,
|
||||
createdAt: user.date_joined,
|
||||
groups: (user.groups_obj || []).map((group) => ({id: group.pk, name: group.name})),
|
||||
totpCount: totpDevices.filter((device) => device.user?.pk === user.pk).length,
|
||||
passkeyCount: passkeys.filter((device) => device.user?.pk === user.pk).length
|
||||
})),
|
||||
groups: ordinaryGroups.map((group) => ({
|
||||
id: group.pk,
|
||||
name: group.name,
|
||||
userIds: group.users || [],
|
||||
users: (group.users_obj || []).map((user) => ({id: user.pk, username: user.username, name: user.name}))
|
||||
})),
|
||||
applications: [...managedApplications].map(([slug, fallbackName]) => {
|
||||
const application = applicationBySlug.get(slug);
|
||||
const allowedGroupIds = application
|
||||
? bindings.filter((binding) => binding.target === application.pk && binding.enabled !== false && binding.group).map((binding) => binding.group)
|
||||
: [];
|
||||
return {
|
||||
slug,
|
||||
name: application?.name || fallbackName,
|
||||
available: Boolean(application),
|
||||
allowedGroupIds,
|
||||
allowedGroups: allowedGroupIds.map((id) => groupNames.get(id) || id)
|
||||
};
|
||||
}),
|
||||
audit: readAudit(auditPath)
|
||||
};
|
||||
}
|
||||
|
||||
async function mutate(pathname, body, actor) {
|
||||
let result;
|
||||
let action;
|
||||
let target;
|
||||
|
||||
if (pathname === "/api/admin/identity/users") {
|
||||
const username = text(body.username, 150);
|
||||
if (!/^[A-Za-z0-9@._-]+$/.test(username)) reject("用户名只能包含字母、数字和 @._-");
|
||||
const defaultGroup = await groupByName(protectedGroup);
|
||||
const groups = Array.isArray(body.groupIds) && body.groupIds.length ? body.groupIds : defaultGroup ? [defaultGroup.pk] : [];
|
||||
result = await request("/core/users/", {method: "POST", body: {
|
||||
username,
|
||||
name: text(body.name) || username,
|
||||
email: text(body.email, 254),
|
||||
is_active: true,
|
||||
path: "users",
|
||||
type: "internal",
|
||||
groups
|
||||
}});
|
||||
action = "user.create";
|
||||
target = username;
|
||||
} else {
|
||||
const userAction = pathname.match(/^\/api\/admin\/identity\/users\/(\d+)\/(update|enable|disable|password|recovery|sessions|reset-totp|reset-passkeys)$/);
|
||||
const groupAction = pathname.match(/^\/api\/admin\/identity\/groups\/([0-9a-f-]+)\/(update|delete|members)$/);
|
||||
const appAction = pathname.match(/^\/api\/admin\/identity\/applications\/([a-z0-9-]+)\/groups$/);
|
||||
if (userAction) {
|
||||
const [, id, operation] = userAction;
|
||||
const user = await request(`/core/users/${id}/`);
|
||||
if (user.username === adminUsername && operation === "disable") reject(`不能停用管理员 ${adminUsername}`, 409);
|
||||
if (operation === "update") {
|
||||
result = await request(`/core/users/${id}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}});
|
||||
} else if (["enable", "disable"].includes(operation)) {
|
||||
result = await request(`/core/users/${id}/`, {method: "PATCH", body: {is_active: operation === "enable"}});
|
||||
} else if (operation === "password") {
|
||||
const password = text(body.password, 1024);
|
||||
if (password.length < 12) reject("临时密码至少需要 12 个字符");
|
||||
await request(`/core/users/${id}/set_password/`, {method: "POST", body: {password}});
|
||||
result = {changed: true};
|
||||
} else if (operation === "recovery") {
|
||||
result = await request(`/core/users/${id}/recovery/`, {method: "POST", body: {token_duration: "hours=1"}});
|
||||
} else if (["reset-totp", "reset-passkeys"].includes(operation)) {
|
||||
const endpoint = operation === "reset-totp" ? "totp" : "webauthn";
|
||||
const devices = (await page(`/authenticators/admin/${endpoint}/?page_size=200`)).filter((device) => device.user?.pk === user.pk);
|
||||
await Promise.all(devices.map((device) => request(`/authenticators/admin/${endpoint}/${device.pk}/`, {method: "DELETE"})));
|
||||
result = {deleted: devices.length};
|
||||
} else {
|
||||
result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${id}`, {method: "DELETE"});
|
||||
}
|
||||
action = `user.${operation}`;
|
||||
target = user.username;
|
||||
} else if (pathname === "/api/admin/identity/groups") {
|
||||
const name = text(body.name);
|
||||
if (!name) reject("用户组名称不能为空");
|
||||
result = await request("/core/groups/", {method: "POST", body: {name, is_superuser: false}});
|
||||
action = "group.create";
|
||||
target = name;
|
||||
} else if (groupAction) {
|
||||
const [, id, operation] = groupAction;
|
||||
const group = await request(`/core/groups/${id}/?include_users=true`);
|
||||
if (group.name === protectedGroup && ["update", "delete"].includes(operation)) reject(`不能修改或删除基础用户组 ${protectedGroup}`, 409);
|
||||
if (operation === "update") {
|
||||
result = await request(`/core/groups/${id}/`, {method: "PATCH", body: {name: text(body.name)}});
|
||||
} else if (operation === "delete") {
|
||||
result = await request(`/core/groups/${id}/`, {method: "DELETE"});
|
||||
} else {
|
||||
const userId = Number.parseInt(body.userId, 10);
|
||||
const user = await request(`/core/users/${userId}/`);
|
||||
if (group.name === protectedGroup && [adminUsername, "ziyue"].includes(user.username) && body.member === false) {
|
||||
reject(`${user.username} 必须保留在 ${protectedGroup}`, 409);
|
||||
}
|
||||
await request(`/core/groups/${id}/${body.member === false ? "remove_user" : "add_user"}/`, {method: "POST", body: {pk: userId}});
|
||||
result = {changed: true};
|
||||
}
|
||||
action = `group.${operation}`;
|
||||
target = group.name;
|
||||
} else if (appAction) {
|
||||
const slug = appAction[1];
|
||||
if (!managedApplications.has(slug)) reject("应用不在 Portal 管理范围内", 404);
|
||||
const application = await request(`/core/applications/${slug}/`);
|
||||
const current = await page(`/policies/bindings/?target=${application.pk}&page_size=100`);
|
||||
await Promise.all(current.map((binding) => request(`/policies/bindings/${binding.pk}/`, {method: "DELETE"})));
|
||||
const groupIds = [...new Set(Array.isArray(body.groupIds) ? body.groupIds : [])];
|
||||
await Promise.all(groupIds.map((group) => request("/policies/bindings/", {method: "POST", body: {
|
||||
target: application.pk,
|
||||
group,
|
||||
order: 0,
|
||||
enabled: true,
|
||||
negate: false,
|
||||
failure_result: false
|
||||
}})));
|
||||
result = {allowedGroupIds: groupIds};
|
||||
action = "application.groups";
|
||||
target = slug;
|
||||
} else {
|
||||
const error = new Error("Identity action not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const auditDetail = action === "user.password" ? "{}" : JSON.stringify(body);
|
||||
writeAudit(auditPath, actor, action, target, auditDetail);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {summary, mutate, accountSummary, accountMutate};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {mkdtempSync, readFileSync, rmSync} from "node:fs";
|
||||
import {tmpdir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {createAuthentikAdmin} from "./authentik.mjs";
|
||||
|
||||
const user = {
|
||||
pk: 3,
|
||||
uuid: "user-uuid",
|
||||
username: "liooil",
|
||||
name: "西腾",
|
||||
email: "[email protected]",
|
||||
is_active: true,
|
||||
is_superuser: true,
|
||||
type: "internal",
|
||||
path: "users",
|
||||
groups_obj: [{pk: "group-id", name: "liuhome"}],
|
||||
last_login: null,
|
||||
date_joined: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
const group = {pk: "group-id", name: "liuhome", is_superuser: false, users: [3], users_obj: [{pk: 3, username: "liooil", name: "西腾"}]};
|
||||
const application = {pk: "app-id", slug: "xiteng-chat", name: "Xiteng Chat"};
|
||||
const binding = {pk: "binding-id", target: "app-id", group: "group-id", enabled: true};
|
||||
|
||||
function response(payload, status = 200) {
|
||||
return new Response(payload === null ? null : JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}});
|
||||
}
|
||||
|
||||
test("summarizes only human users and liuhome application access", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "authentik-admin-test-"));
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/-/health/ready/") return response({status: "ok"});
|
||||
if (path === "/api/v3/core/users/") return response({results: [user, {...user, pk: 9, username: "ak-outpost", path: "goauthentik.io/outposts"}]});
|
||||
if (path === "/api/v3/core/groups/") return response({results: [group]});
|
||||
if (path === "/api/v3/core/applications/") return response({results: [application]});
|
||||
if (path === "/api/v3/policies/bindings/") return response({results: [binding]});
|
||||
if (path === "/api/v3/authenticators/admin/totp/") return response({results: [{pk: 1, name: "Phone", user: {pk: 3}}]});
|
||||
if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [{pk: 2, name: "Laptop", user: {pk: 3}}]});
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
try {
|
||||
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: join(directory, "audit.jsonl")});
|
||||
const summary = await admin.summary();
|
||||
assert.deepEqual(summary.users.map((item) => item.username), ["liooil"]);
|
||||
assert.deepEqual(summary.groups.map((item) => item.name), ["liuhome"]);
|
||||
assert.deepEqual(summary.applications.find((item) => item.slug === "xiteng-chat").allowedGroups, ["liuhome"]);
|
||||
assert.equal(summary.users[0].totpCount, 1);
|
||||
assert.equal(summary.users[0].passkeyCount, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
rmSync(directory, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses to disable liooil", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (new URL(url).pathname === "/api/v3/core/users/3/") return response(user);
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
try {
|
||||
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
|
||||
await assert.rejects(() => admin.mutate("/api/admin/identity/users/3/disable", {}, "liooil"), (error) => {
|
||||
assert.equal(error.statusCode, 409);
|
||||
return /不能停用/.test(error.message);
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("does not write temporary passwords to the identity audit", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "authentik-audit-test-"));
|
||||
const auditPath = join(directory, "audit.jsonl");
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/v3/core/users/3/") return response(user);
|
||||
if (path === "/api/v3/core/users/3/set_password/" && options.method === "POST") return response(null, 204);
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
try {
|
||||
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath});
|
||||
await admin.mutate("/api/admin/identity/users/3/password", {password: "Temporary-secret-2026"}, "liooil");
|
||||
const audit = readFileSync(auditPath, "utf8");
|
||||
assert.doesNotMatch(audit, /Temporary-secret-2026/);
|
||||
assert.match(audit, /user\.password/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
rmSync(directory, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns only the current user's authenticators and sessions", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname;
|
||||
if (path === "/api/v3/core/users/") return response({results: [user]});
|
||||
if (path === "/api/v3/authenticators/admin/totp/") return response({results: [
|
||||
{pk: 1, name: "Phone", user: {pk: 3}},
|
||||
{pk: 9, name: "Other", user: {pk: 99}}
|
||||
]});
|
||||
if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [
|
||||
{pk: 2, name: "Laptop", created_on: "2026-01-02T00:00:00Z", device_type: "single_device", aaguid: "aaguid", user: {pk: 3}}
|
||||
]});
|
||||
if (path === "/api/v3/core/authenticated_sessions/") return response({results: [
|
||||
{uuid: "session-id", user: 3, current: true, last_ip: "127.0.0.1", last_user_agent: "Browser", last_used: "2026-01-03T00:00:00Z", expires: "2026-01-04T00:00:00Z"},
|
||||
{uuid: "other-session", user: 99}
|
||||
]});
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
try {
|
||||
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
|
||||
const account = await admin.accountSummary("liooil");
|
||||
assert.equal(account.profile.username, "liooil");
|
||||
assert.deepEqual(account.security.totp.map((device) => device.name), ["Phone"]);
|
||||
assert.deepEqual(account.security.passkeys.map((device) => device.name), ["Laptop"]);
|
||||
assert.deepEqual(account.security.sessions.map((session) => session.id), ["session-id"]);
|
||||
assert.match(account.security.totpSetupUrl, /default-authenticator-totp-setup/);
|
||||
assert.match(account.security.passkeySetupUrl, /default-authenticator-webauthn-setup/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses to mutate another user's authenticator", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/v3/core/users/") return response({results: [user]});
|
||||
if (path === "/api/v3/authenticators/admin/webauthn/9/") return response({pk: 9, name: "Other", user: {pk: 99}});
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
try {
|
||||
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
|
||||
await assert.rejects(() => admin.accountMutate("/api/account/security/passkeys/9/delete", {}, "liooil"), (error) => error.statusCode === 404);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
services:
|
||||
metrics:
|
||||
image: python:3-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc
|
||||
container_name: xiteng-site-metrics
|
||||
restart: unless-stopped
|
||||
command: ["python3", "/app/metrics.py"]
|
||||
environment:
|
||||
- PORT=8092
|
||||
- HOST_PROC=/host/proc
|
||||
- DISK_PATH=/host/disk
|
||||
- CACHE_TTL_SECONDS=2
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=utility
|
||||
volumes:
|
||||
- ./metrics.py:/app/metrics.py:ro
|
||||
- /proc:/host/proc:ro
|
||||
- /etc/hostname:/host/hostname:ro
|
||||
- ./:/host/disk:ro
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8092/healthz', timeout=2)"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- homelab_net
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
- "xiteng.site.component.host-metrics.enabled=true"
|
||||
- "xiteng.site.component.host-metrics.name=Host Metrics"
|
||||
- "xiteng.site.component.host-metrics.description=采集并公开 CPU、内存、磁盘与 GPU 的实时汇总指标。"
|
||||
- "xiteng.site.component.host-metrics.section=infrastructure"
|
||||
- "xiteng.site.component.host-metrics.category=可观测性"
|
||||
- "xiteng.site.component.host-metrics.access=public"
|
||||
- "xiteng.site.component.host-metrics.access-label=公开只读指标"
|
||||
- "xiteng.site.component.host-metrics.icon=HW"
|
||||
- "xiteng.site.component.host-metrics.accent=green"
|
||||
- "xiteng.site.component.host-metrics.order=590"
|
||||
|
||||
registry:
|
||||
image: node:24-alpine
|
||||
container_name: xiteng-site-registry
|
||||
restart: unless-stopped
|
||||
command: ["node", "/app/registry.mjs"]
|
||||
environment:
|
||||
- PORT=8091
|
||||
- DOCKER_SOCKET=/var/run/docker.sock
|
||||
- DATABASE_PATH=/data/registry.db
|
||||
- DISCOVERY_INTERVAL_MS=5000
|
||||
- MISSING_RETENTION_DAYS=30
|
||||
- RAW_RETENTION_DAYS=30
|
||||
- AGGREGATE_RETENTION_DAYS=365
|
||||
- MONITOR_CONCURRENCY=4
|
||||
volumes:
|
||||
- ./registry.mjs:/app/registry.mjs:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./data:/data
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8091/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- homelab_net
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
- "xiteng.site.component.site-registry.enabled=true"
|
||||
- "xiteng.site.component.site-registry.name=Component Registry"
|
||||
- "xiteng.site.component.site-registry.description=只读发现 Docker Label,并向门户输出白名单组件元数据。"
|
||||
- "xiteng.site.component.site-registry.section=infrastructure"
|
||||
- "xiteng.site.component.site-registry.category=门户"
|
||||
- "xiteng.site.component.site-registry.access=internal"
|
||||
- "xiteng.site.component.site-registry.access-label=仅容器网络"
|
||||
- "xiteng.site.component.site-registry.icon=API"
|
||||
- "xiteng.site.component.site-registry.accent=blue"
|
||||
- "xiteng.site.component.site-registry.order=12"
|
||||
|
||||
xiteng-site:
|
||||
image: node:24-alpine
|
||||
container_name: xiteng-site
|
||||
restart: unless-stopped
|
||||
command: ["node", "/app/server.mjs"]
|
||||
environment:
|
||||
- PORT=8080
|
||||
- REGISTRY_URL=http://xiteng-site-registry:8091/components
|
||||
- REGISTRY_ADMIN_URL=http://xiteng-site-registry:8091/admin
|
||||
- METRICS_URL=http://xiteng-site-metrics:8092/metrics
|
||||
- KEY_VAULT_URL=http://ai-gateway:8093
|
||||
- PROVIDER_TEST_URL=http://xiteng-chat:3000/api/provider-test
|
||||
- KEY_VAULT_TOKEN_FILE=/run/secrets/portal_gateway_hmac
|
||||
- ADMIN_USERNAME=liooil
|
||||
- AUTHENTIK_ISSUER=https://auth.xiteng.site
|
||||
- AUTHENTIK_URL=https://auth.xiteng.site
|
||||
- AUTHENTIK_TOKEN_FILE=/run/authentik-secrets/portal_api_token
|
||||
- IDENTITY_AUDIT_PATH=/data/identity-audit.jsonl
|
||||
volumes:
|
||||
- ./index.html:/app/index.html:ro
|
||||
- ./styles.css:/app/styles.css:ro
|
||||
- ./app.js:/app/app.js:ro
|
||||
- ./sw.js:/app/sw.js:ro
|
||||
- ./manifest.webmanifest:/app/manifest.webmanifest:ro
|
||||
- ./favicon.svg:/app/favicon.svg:ro
|
||||
- ./favicon.ico:/app/favicon.ico:ro
|
||||
- ./icons:/app/icons:ro
|
||||
- ./admin.html:/app/admin.html:ro
|
||||
- ./admin.js:/app/admin.js:ro
|
||||
- ./account.html:/app/account.html:ro
|
||||
- ./account.js:/app/account.js:ro
|
||||
- ./authentik.mjs:/app/authentik.mjs:ro
|
||||
- ./server.mjs:/app/server.mjs:ro
|
||||
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro
|
||||
- ../authentik/secrets/portal_api_token:/run/authentik-secrets/portal_api_token:ro
|
||||
- ./data:/data
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
registry:
|
||||
condition: service_healthy
|
||||
metrics:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- homelab_net
|
||||
labels:
|
||||
# Traefik
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.xiteng-site.rule=Host(`xiteng.site`)"
|
||||
- "traefik.http.routers.xiteng-site.entrypoints=websecure"
|
||||
- "traefik.http.routers.xiteng-site.tls=true"
|
||||
- "traefik.http.routers.xiteng-site.tls.certresolver=cfresolver"
|
||||
- "traefik.http.routers.xiteng-site.service=xiteng-site"
|
||||
- "traefik.http.routers.xiteng-site.middlewares=xiteng-site-nocache"
|
||||
- "traefik.http.services.xiteng-site.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.xiteng-site-http.rule=Host(`xiteng.site`)"
|
||||
- "traefik.http.routers.xiteng-site-http.service=xiteng-site"
|
||||
- "traefik.http.routers.xiteng-site-http.entrypoints=web"
|
||||
- "traefik.http.routers.xiteng-site-http.middlewares=xiteng-site-nocache"
|
||||
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Cache-Control=no-store, no-cache, must-revalidate"
|
||||
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Pragma=no-cache"
|
||||
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Expires=0"
|
||||
# Authentik ForwardAuth protects all Portal administration and account routes.
|
||||
- "traefik.http.middlewares.xiteng-admin-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
|
||||
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid"
|
||||
- "traefik.http.middlewares.xiteng-admin-context.headers.customrequestheaders.X-Portal-Authenticated=1"
|
||||
- "traefik.http.routers.xiteng-site-admin.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))"
|
||||
- "traefik.http.routers.xiteng-site-admin.entrypoints=websecure"
|
||||
- "traefik.http.routers.xiteng-site-admin.tls=true"
|
||||
- "traefik.http.routers.xiteng-site-admin.tls.certresolver=cfresolver"
|
||||
- "traefik.http.routers.xiteng-site-admin.service=xiteng-site"
|
||||
- "traefik.http.routers.xiteng-site-admin.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache"
|
||||
- "traefik.http.routers.xiteng-site-admin.priority=200"
|
||||
- "traefik.http.routers.xiteng-site-admin-http.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))"
|
||||
- "traefik.http.routers.xiteng-site-admin-http.entrypoints=web"
|
||||
- "traefik.http.routers.xiteng-site-admin-http.service=xiteng-site"
|
||||
- "traefik.http.routers.xiteng-site-admin-http.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache"
|
||||
- "traefik.http.routers.xiteng-site-admin-http.priority=200"
|
||||
# The native Authentik admin UI is hidden; login, OAuth/OIDC and API endpoints remain available.
|
||||
- "traefik.http.routers.authentik-admin-hidden.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)"
|
||||
- "traefik.http.routers.authentik-admin-hidden.entrypoints=websecure"
|
||||
- "traefik.http.routers.authentik-admin-hidden.tls=true"
|
||||
- "traefik.http.routers.authentik-admin-hidden.tls.certresolver=cfresolver"
|
||||
- "traefik.http.routers.authentik-admin-hidden.service=xiteng-site"
|
||||
- "traefik.http.routers.authentik-admin-hidden.priority=5000"
|
||||
- "traefik.http.routers.authentik-admin-hidden-http.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)"
|
||||
- "traefik.http.routers.authentik-admin-hidden-http.entrypoints=web"
|
||||
- "traefik.http.routers.authentik-admin-hidden-http.service=xiteng-site"
|
||||
- "traefik.http.routers.authentik-admin-hidden-http.priority=5000"
|
||||
- "xiteng.site.cache.portal-static.enabled=true"
|
||||
- "xiteng.site.cache.portal-static.routers=xiteng-site,xiteng-site-http"
|
||||
- "xiteng.site.cache.portal-static.paths=/icons/"
|
||||
- "xiteng.site.cache.portal-static.edge-ttl=604800"
|
||||
- "xiteng.site.cache.portal-static.stale-while-revalidate=86400"
|
||||
# Dynamic catalog
|
||||
- "xiteng.site.component.portal.enabled=true"
|
||||
- "xiteng.site.component.portal.name=Xiteng Portal"
|
||||
- "xiteng.site.component.portal.description=唯一公开入口、个人主页与 Authentik 保护的 Homelab 管理控制面。"
|
||||
- "xiteng.site.component.portal.section=infrastructure"
|
||||
- "xiteng.site.component.portal.category=门户"
|
||||
- "xiteng.site.component.portal.url=https://xiteng.site"
|
||||
- "xiteng.site.component.portal.access=public"
|
||||
- "xiteng.site.component.portal.access-label=公开目录"
|
||||
- "xiteng.site.component.portal.icon=XT"
|
||||
- "xiteng.site.component.portal.icon-url=https://xiteng.site/favicon.svg"
|
||||
- "xiteng.site.component.portal.accent=red"
|
||||
- "xiteng.site.component.portal.order=10"
|
||||
- "xiteng.site.component.portal.monitor.enabled=true"
|
||||
- "xiteng.site.component.portal.monitor.url=http://xiteng-site:8080/healthz"
|
||||
- "xiteng.site.component.portal.monitor.interval=60"
|
||||
- "xiteng.site.component.portal.monitor.failures=3"
|
||||
- "xiteng.site.component.portal.monitor.timeout=10"
|
||||
- "xiteng.site.component.portal.monitor.accept=200-299"
|
||||
|
||||
networks:
|
||||
homelab_net:
|
||||
external: true
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
|
||||
<title id="title">Xiteng Portal</title>
|
||||
<desc id="description">A red geometric X on a cream tile with a yellow background.</desc>
|
||||
<rect width="512" height="512" rx="104" fill="#f0c94b"/>
|
||||
<rect x="48" y="48" width="416" height="416" rx="80" fill="#fffaf0" stroke="#1c1712" stroke-width="16"/>
|
||||
<path d="M150 146h70l36 65 36-65h70l-66 110 66 110h-70l-36-65-36 65h-70l66-110z" fill="#d83b21" stroke="#1c1712" stroke-width="14" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,142 @@
|
||||
import {DatabaseSync} from "node:sqlite";
|
||||
|
||||
const sourcePath = process.env.KUMA_DATABASE || "/migration/kuma.db";
|
||||
const targetPath = process.env.REGISTRY_DATABASE || "/data/registry.db";
|
||||
const rawRetentionDays = Number.parseInt(process.env.RAW_RETENTION_DAYS || "30", 10);
|
||||
const aggregateRetentionDays = Number.parseInt(process.env.AGGREGATE_RETENTION_DAYS || "365", 10);
|
||||
|
||||
const source = new DatabaseSync(sourcePath, {readOnly: true});
|
||||
const target = new DatabaseSync(targetPath);
|
||||
target.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
||||
|
||||
const components = target.prepare(`
|
||||
SELECT id, monitor_json FROM registry_component WHERE monitor_json IS NOT NULL
|
||||
`).all();
|
||||
const sourceMonitors = source.prepare(`
|
||||
SELECT m.id, m.name, m.url, COUNT(h.id) AS checks
|
||||
FROM monitor m
|
||||
LEFT JOIN heartbeat h ON h.monitor_id = m.id
|
||||
GROUP BY m.id
|
||||
ORDER BY checks DESC, m.id ASC
|
||||
`).all();
|
||||
const heartbeats = source.prepare(`
|
||||
SELECT status, time, ping, msg
|
||||
FROM heartbeat
|
||||
WHERE monitor_id = ? AND time >= ?
|
||||
ORDER BY time ASC
|
||||
`);
|
||||
const dailyStats = source.prepare(`
|
||||
SELECT timestamp, ping, up, down
|
||||
FROM stat_daily
|
||||
WHERE monitor_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
`);
|
||||
|
||||
const insertCheck = target.prepare(`
|
||||
INSERT OR IGNORE INTO monitor_check (
|
||||
component_id, checked_at, ok, status_code, latency_ms, error
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const upsertHourly = target.prepare(`
|
||||
INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(component_id, bucket) DO UPDATE SET
|
||||
total = total + 1,
|
||||
successful = successful + excluded.successful,
|
||||
latency_sum = latency_sum + excluded.latency_sum
|
||||
`);
|
||||
const upsertDaily = target.prepare(`
|
||||
INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(component_id, bucket) DO NOTHING
|
||||
`);
|
||||
|
||||
function isoFromKuma(value) {
|
||||
return new Date(value.endsWith("Z") ? value : `${value}Z`).toISOString();
|
||||
}
|
||||
|
||||
function statusCode(message) {
|
||||
const match = typeof message === "string" ? message.match(/\b(\d{3})\b/) : null;
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
function safeError(message, ok) {
|
||||
if (ok || typeof message !== "string") {
|
||||
return null;
|
||||
}
|
||||
return message.replace(/[\r\n\t]+/g, " ").slice(0, 300);
|
||||
}
|
||||
|
||||
function normalizedUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return url.href.replace(/\/$/, "");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000);
|
||||
const kumaRawCutoff = rawCutoff.toISOString().replace("T", " ").replace("Z", "");
|
||||
const aggregateCutoff = Math.floor((Date.now() - aggregateRetentionDays * 86400000) / 1000);
|
||||
const imported = [];
|
||||
|
||||
target.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const component of components) {
|
||||
const monitor = JSON.parse(component.monitor_json);
|
||||
const candidates = sourceMonitors.filter((candidate) => normalizedUrl(candidate.url) === normalizedUrl(monitor.url));
|
||||
if (!candidates.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonical = candidates[0];
|
||||
let rawChecks = 0;
|
||||
for (const heartbeat of heartbeats.all(canonical.id, kumaRawCutoff)) {
|
||||
const checkedAt = isoFromKuma(heartbeat.time);
|
||||
const ok = heartbeat.status === 1;
|
||||
const result = insertCheck.run(
|
||||
component.id,
|
||||
checkedAt,
|
||||
ok ? 1 : 0,
|
||||
statusCode(heartbeat.msg),
|
||||
Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : null,
|
||||
safeError(heartbeat.msg, ok)
|
||||
);
|
||||
if (result.changes > 0) {
|
||||
const latency = Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : 0;
|
||||
upsertHourly.run(component.id, `${checkedAt.slice(0, 13)}:00:00.000Z`, ok ? 1 : 0, latency);
|
||||
rawChecks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let dailyRows = 0;
|
||||
for (const daily of dailyStats.all(canonical.id, aggregateCutoff)) {
|
||||
const total = daily.up + daily.down;
|
||||
const bucket = new Date(daily.timestamp * 1000).toISOString().slice(0, 10) + "T00:00:00.000Z";
|
||||
const result = upsertDaily.run(
|
||||
component.id,
|
||||
bucket,
|
||||
total,
|
||||
daily.up,
|
||||
Math.max(0, Math.round((daily.ping || 0) * total))
|
||||
);
|
||||
dailyRows += result.changes;
|
||||
}
|
||||
|
||||
imported.push({
|
||||
component: component.id,
|
||||
monitorId: canonical.id,
|
||||
monitorName: canonical.name,
|
||||
rawChecks,
|
||||
dailyRows
|
||||
});
|
||||
}
|
||||
target.exec("COMMIT");
|
||||
} catch (error) {
|
||||
target.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({imported}, null, 2));
|
||||
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="liooil 的个人主页与 Homelab 动态服务目录。">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#f0c94b">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Xiteng">
|
||||
<title>liooil / xiteng.site</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/app.js?v=7" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<nav class="shell nav" aria-label="主导航">
|
||||
<a class="brand" href="#top">liooil / xiteng.site</a>
|
||||
<div class="nav-links">
|
||||
<a href="#services">服务</a>
|
||||
<a href="#infrastructure">基础设施</a>
|
||||
<a href="#now">Now</a>
|
||||
<a href="/account">我的凭据</a>
|
||||
<a href="/admin">管理</a>
|
||||
<a href="https://github.com/liooil" rel="me">GitHub</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero shell">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">PERSONAL INDEX / HOMELAB CATALOG</p>
|
||||
<h1>一个入口,<br>完整呈现。</h1>
|
||||
<p class="intro">
|
||||
服务是否存在与谁能使用它是两件事。这里公开展示全部应用与基础设施;
|
||||
登录、密钥和内网边界仍由各组件自己负责。
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button primary" href="#services">浏览全部服务</a>
|
||||
<a class="button" href="#infrastructure">查看系统组成</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-panel" aria-label="动态目录状态">
|
||||
<div class="hero-panel-head">
|
||||
<span>LIVE CATALOG</span>
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
</div>
|
||||
<dl class="hero-stats">
|
||||
<div>
|
||||
<dt id="component-count">—</dt>
|
||||
<dd>已声明组件</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt id="running-count">—</dt>
|
||||
<dd>服务可用</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt id="restricted-count">—</dt>
|
||||
<dd>受控入口</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p id="catalog-updated" class="catalog-updated" aria-live="polite">正在读取 Docker Label…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="device-status" aria-labelledby="device-heading">
|
||||
<div class="shell">
|
||||
<div class="device-heading-row">
|
||||
<div>
|
||||
<p class="section-kicker">DEVICE STATUS / <span id="device-hostname">HOMELAB</span></p>
|
||||
<h2 id="device-heading">设备状态</h2>
|
||||
</div>
|
||||
<p id="metrics-updated" class="metrics-updated" aria-live="polite">正在读取主机指标…</p>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card metric-cpu">
|
||||
<div class="metric-card-head"><span>CPU</span><span>PROCESSOR</span></div>
|
||||
<strong id="cpu-value" class="metric-value">—</strong>
|
||||
<progress id="cpu-progress" max="100" value="0">0%</progress>
|
||||
<p id="cpu-detail" class="metric-detail">正在读取处理器状态…</p>
|
||||
<p id="cpu-secondary" class="metric-secondary">LOAD —</p>
|
||||
</article>
|
||||
|
||||
<article class="metric-card metric-memory">
|
||||
<div class="metric-card-head"><span>MEM</span><span>MEMORY</span></div>
|
||||
<strong id="memory-value" class="metric-value">—</strong>
|
||||
<progress id="memory-progress" max="100" value="0">0%</progress>
|
||||
<p id="memory-detail" class="metric-detail">正在读取内存状态…</p>
|
||||
<p id="memory-secondary" class="metric-secondary">AVAILABLE —</p>
|
||||
</article>
|
||||
|
||||
<article class="metric-card metric-disk">
|
||||
<div class="metric-card-head"><span>DISK</span><span>ROOT FS</span></div>
|
||||
<strong id="disk-value" class="metric-value">—</strong>
|
||||
<progress id="disk-progress" max="100" value="0">0%</progress>
|
||||
<p id="disk-detail" class="metric-detail">正在读取磁盘状态…</p>
|
||||
<p id="disk-secondary" class="metric-secondary">/</p>
|
||||
</article>
|
||||
|
||||
<article class="metric-card metric-gpu">
|
||||
<div class="metric-card-head"><span>GPU</span><span>NVIDIA</span></div>
|
||||
<strong id="gpu-value" class="metric-value">—</strong>
|
||||
<progress id="gpu-progress" max="100" value="0">0%</progress>
|
||||
<p id="gpu-detail" class="metric-detail">正在读取显卡状态…</p>
|
||||
<p id="gpu-secondary" class="metric-secondary">VRAM —</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="principles">
|
||||
<div class="shell principle-grid">
|
||||
<article>
|
||||
<span class="principle-number">01</span>
|
||||
<strong>存在是公开信息</strong>
|
||||
<p>组件名称、用途、技术栈、入口和运行状态对所有访客可见。</p>
|
||||
</article>
|
||||
<article>
|
||||
<span class="principle-number">02</span>
|
||||
<strong>授权保护数据与操作</strong>
|
||||
<p>SSO、Access Key、SSH Key 或内网边界在进入组件时生效。</p>
|
||||
</article>
|
||||
<article>
|
||||
<span class="principle-number">03</span>
|
||||
<strong>Label 决定目录内容</strong>
|
||||
<p>容器自行声明展示信息;页面不维护硬编码服务清单。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="services" class="catalog-section shell">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">APPLICATIONS / ENDPOINTS</p>
|
||||
<h2>服务目录</h2>
|
||||
</div>
|
||||
<p>所有可使用的应用和协议入口。需要认证的服务会在卡片上明确标注。</p>
|
||||
</div>
|
||||
|
||||
<div class="filters" role="group" aria-label="筛选组件">
|
||||
<button class="filter active" type="button" data-filter="all">全部</button>
|
||||
<button class="filter" type="button" data-filter="public">无需登录</button>
|
||||
<button class="filter" type="button" data-filter="restricted">受控访问</button>
|
||||
<button class="filter" type="button" data-filter="online">服务可用</button>
|
||||
</div>
|
||||
|
||||
<div id="services-catalog" class="catalog" aria-live="polite">
|
||||
<div class="loading-card">正在发现服务…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="infrastructure" class="catalog-section infrastructure-section">
|
||||
<div class="shell">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">EDGE / IDENTITY / DATA / OPS</p>
|
||||
<h2>基础设施</h2>
|
||||
</div>
|
||||
<p>公开系统的组成与职责;不公开密码、Token、私钥和其他秘密值。</p>
|
||||
</div>
|
||||
<div id="infrastructure-catalog" class="catalog" aria-live="polite">
|
||||
<div class="loading-card">正在发现基础设施…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="now" class="now-section shell">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">OPERATING MODEL</p>
|
||||
<h2>Now</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-grid">
|
||||
<article class="now-card accent-yellow">
|
||||
<span>DISCOVERY</span>
|
||||
<h3>统一入口</h3>
|
||||
<p><code>xiteng.site</code> 是唯一目录;组件由 Docker Label 自动加入或移除。</p>
|
||||
</article>
|
||||
<article class="now-card accent-blue">
|
||||
<span>ACCESS</span>
|
||||
<h3>按服务授权</h3>
|
||||
<p>门户保持公开,受控服务在点击后交给 Authentik 或对应凭据系统。</p>
|
||||
</article>
|
||||
<article class="now-card accent-green">
|
||||
<span>TRANSPARENCY</span>
|
||||
<h3>架构默认公开</h3>
|
||||
<p>公开组件、镜像与状态;环境变量、挂载、原始 Label 和秘密值不进入目录 API。</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="shell footer-inner">
|
||||
<span>xiteng.site · <span id="year">2026</span> · liooil</span>
|
||||
<span>Docker labels → catalog + monitoring</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Xiteng Portal",
|
||||
"short_name": "Xiteng",
|
||||
"description": "动态 Homelab 服务目录、设备状态与健康监控。",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#f7efe0",
|
||||
"theme_color": "#f0c94b",
|
||||
"categories": ["utilities", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "服务目录",
|
||||
"short_name": "服务",
|
||||
"url": "/#services",
|
||||
"icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}]
|
||||
},
|
||||
{
|
||||
"name": "基础设施",
|
||||
"short_name": "设施",
|
||||
"url": "/#infrastructure",
|
||||
"icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose a small, sanitized snapshot of host CPU, memory, disk, and NVIDIA GPU usage."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PORT = int(os.environ.get("PORT", "8092"))
|
||||
HOST_PROC = Path(os.environ.get("HOST_PROC", "/host/proc"))
|
||||
DISK_PATH = Path(os.environ.get("DISK_PATH", "/host/disk"))
|
||||
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "2"))
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
cached_snapshot = None
|
||||
cached_at = 0.0
|
||||
previous_cpu = None
|
||||
|
||||
|
||||
def read_text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def read_cpu_times():
|
||||
first_line = read_text(HOST_PROC / "stat").splitlines()[0]
|
||||
values = [int(value) for value in first_line.split()[1:]]
|
||||
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
||||
return sum(values), idle
|
||||
|
||||
|
||||
def cpu_usage_percent():
|
||||
global previous_cpu
|
||||
|
||||
current = read_cpu_times()
|
||||
if previous_cpu is None:
|
||||
previous_cpu = current
|
||||
time.sleep(0.12)
|
||||
current = read_cpu_times()
|
||||
|
||||
total_delta = current[0] - previous_cpu[0]
|
||||
idle_delta = current[1] - previous_cpu[1]
|
||||
previous_cpu = current
|
||||
if total_delta <= 0:
|
||||
return 0.0
|
||||
return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1)
|
||||
|
||||
|
||||
def cpu_info():
|
||||
cpuinfo = read_text(HOST_PROC / "cpuinfo")
|
||||
model = "Unknown CPU"
|
||||
logical_cores = 0
|
||||
for line in cpuinfo.splitlines():
|
||||
if line.startswith("processor"):
|
||||
logical_cores += 1
|
||||
elif line.startswith("model name") and model == "Unknown CPU":
|
||||
model = line.split(":", 1)[1].strip()
|
||||
|
||||
load_values = read_text(HOST_PROC / "loadavg").split()
|
||||
return {
|
||||
"usagePercent": cpu_usage_percent(),
|
||||
"logicalCores": logical_cores,
|
||||
"model": model,
|
||||
"load1": float(load_values[0]),
|
||||
"load5": float(load_values[1]),
|
||||
"load15": float(load_values[2]),
|
||||
}
|
||||
|
||||
|
||||
def memory_info():
|
||||
values = {}
|
||||
for line in read_text(HOST_PROC / "meminfo").splitlines():
|
||||
key, raw_value = line.split(":", 1)
|
||||
values[key] = int(raw_value.strip().split()[0]) * 1024
|
||||
|
||||
total = values["MemTotal"]
|
||||
available = values["MemAvailable"]
|
||||
used = total - available
|
||||
return {
|
||||
"usedBytes": used,
|
||||
"availableBytes": available,
|
||||
"totalBytes": total,
|
||||
"usagePercent": round(used / total * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def root_device():
|
||||
for line in read_text(HOST_PROC / "1" / "mountinfo").splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 10 or fields[4] != "/" or "-" not in fields:
|
||||
continue
|
||||
separator = fields.index("-")
|
||||
if len(fields) > separator + 2:
|
||||
return fields[separator + 2]
|
||||
return "root filesystem"
|
||||
|
||||
|
||||
def disk_info():
|
||||
stats = os.statvfs(DISK_PATH)
|
||||
total = stats.f_blocks * stats.f_frsize
|
||||
free = stats.f_bfree * stats.f_frsize
|
||||
available = stats.f_bavail * stats.f_frsize
|
||||
used = total - free
|
||||
return {
|
||||
"device": root_device(),
|
||||
"mount": "/",
|
||||
"usedBytes": used,
|
||||
"availableBytes": available,
|
||||
"totalBytes": total,
|
||||
"usagePercent": round(used / total * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def optional_float(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def gpu_info():
|
||||
query = ",".join([
|
||||
"index",
|
||||
"name",
|
||||
"utilization.gpu",
|
||||
"memory.used",
|
||||
"memory.total",
|
||||
"temperature.gpu",
|
||||
"power.draw",
|
||||
"power.limit",
|
||||
])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
f"--query-gpu={query}",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError):
|
||||
return {"available": False, "devices": []}
|
||||
|
||||
devices = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) != 8:
|
||||
continue
|
||||
devices.append({
|
||||
"index": int(fields[0]),
|
||||
"name": fields[1],
|
||||
"utilizationPercent": optional_float(fields[2]),
|
||||
"memoryUsedMiB": optional_float(fields[3]),
|
||||
"memoryTotalMiB": optional_float(fields[4]),
|
||||
"temperatureCelsius": optional_float(fields[5]),
|
||||
"powerDrawWatts": optional_float(fields[6]),
|
||||
"powerLimitWatts": optional_float(fields[7]),
|
||||
})
|
||||
|
||||
return {"available": bool(devices), "devices": devices}
|
||||
|
||||
|
||||
def host_name():
|
||||
hostname_path = Path("/host/hostname")
|
||||
if hostname_path.exists():
|
||||
return read_text(hostname_path).strip()
|
||||
return "homelab"
|
||||
|
||||
|
||||
def collect_snapshot():
|
||||
global cached_snapshot, cached_at
|
||||
|
||||
now = time.monotonic()
|
||||
with cache_lock:
|
||||
if cached_snapshot is not None and now - cached_at < CACHE_TTL_SECONDS:
|
||||
return cached_snapshot
|
||||
|
||||
cached_snapshot = {
|
||||
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"hostname": host_name(),
|
||||
"cpu": cpu_info(),
|
||||
"memory": memory_info(),
|
||||
"disk": disk_info(),
|
||||
"gpu": gpu_info(),
|
||||
}
|
||||
cached_at = time.monotonic()
|
||||
return cached_snapshot
|
||||
|
||||
|
||||
class MetricsHandler(BaseHTTPRequestHandler):
|
||||
def send_json(self, status_code, payload):
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
self.send_response(status_code)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
self.send_json(200, {"status": "ok"})
|
||||
return
|
||||
if self.path != "/metrics":
|
||||
self.send_json(404, {"error": "Not found"})
|
||||
return
|
||||
|
||||
try:
|
||||
self.send_json(200, collect_snapshot())
|
||||
except Exception as error:
|
||||
print(f"metrics collection failed: {error}", flush=True)
|
||||
self.send_json(503, {"error": "Metrics unavailable"})
|
||||
|
||||
def log_message(self, message_format, *args):
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), MetricsHandler).serve_forever()
|
||||
@@ -0,0 +1,852 @@
|
||||
import http from "node:http";
|
||||
import {mkdirSync} from "node:fs";
|
||||
import {DatabaseSync} from "node:sqlite";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT || "8091", 10);
|
||||
const dockerSocket = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
|
||||
const databasePath = process.env.DATABASE_PATH || "/data/registry.db";
|
||||
const discoveryIntervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000);
|
||||
const missingRetentionDays = boundedInteger(process.env.MISSING_RETENTION_DAYS, 30, 1, 3650);
|
||||
const rawRetentionDays = boundedInteger(process.env.RAW_RETENTION_DAYS, 30, 1, 3650);
|
||||
const aggregateRetentionDays = boundedInteger(process.env.AGGREGATE_RETENTION_DAYS, 365, 30, 3650);
|
||||
const monitorConcurrency = boundedInteger(process.env.MONITOR_CONCURRENCY, 4, 1, 32);
|
||||
const adminProxyHeader = process.env.ADMIN_PROXY_HEADER || "X-Portal-Admin";
|
||||
const labelPrefix = "xiteng.site.component.";
|
||||
|
||||
const allowedFields = new Set([
|
||||
"enabled",
|
||||
"name",
|
||||
"description",
|
||||
"section",
|
||||
"category",
|
||||
"url",
|
||||
"endpoint",
|
||||
"access",
|
||||
"access-label",
|
||||
"icon",
|
||||
"icon-url",
|
||||
"navigation",
|
||||
"portal-link",
|
||||
"accent",
|
||||
"order",
|
||||
"monitor.enabled",
|
||||
"monitor.url",
|
||||
"monitor.interval",
|
||||
"monitor.failures",
|
||||
"monitor.timeout",
|
||||
"monitor.accept"
|
||||
]);
|
||||
|
||||
const allowedSections = new Set(["services", "infrastructure"]);
|
||||
const allowedAccents = new Set(["red", "green", "yellow", "blue", "ink"]);
|
||||
const activeChecks = new Set();
|
||||
let discoveryRunning = false;
|
||||
let lastDiscoveryAt = null;
|
||||
|
||||
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS registry_component (
|
||||
id TEXT PRIMARY KEY,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
lifecycle TEXT NOT NULL DEFAULT 'active',
|
||||
missing_since TEXT,
|
||||
archived_at TEXT,
|
||||
archive_reason TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
monitor_json TEXT,
|
||||
monitor_state TEXT NOT NULL DEFAULT 'unmonitored',
|
||||
monitor_paused INTEGER NOT NULL DEFAULT 0,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at TEXT,
|
||||
last_status_code INTEGER,
|
||||
last_latency_ms INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_check (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
|
||||
checked_at TEXT NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
status_code INTEGER,
|
||||
latency_ms INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS monitor_check_component_time
|
||||
ON monitor_check(component_id, checked_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS monitor_check_unique
|
||||
ON monitor_check(component_id, checked_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_incident (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
cause TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS monitor_incident_open
|
||||
ON monitor_incident(component_id) WHERE ended_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_hourly (
|
||||
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
|
||||
bucket TEXT NOT NULL,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
successful INTEGER NOT NULL DEFAULT 0,
|
||||
latency_sum INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (component_id, bucket)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_daily (
|
||||
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
|
||||
bucket TEXT NOT NULL,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
successful INTEGER NOT NULL DEFAULT 0,
|
||||
latency_sum INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (component_id, bucket)
|
||||
);
|
||||
`);
|
||||
|
||||
const selectComponent = database.prepare("SELECT * FROM registry_component WHERE id = ?");
|
||||
const selectComponents = database.prepare("SELECT * FROM registry_component ORDER BY id");
|
||||
const insertComponent = database.prepare(`
|
||||
INSERT INTO registry_component (
|
||||
id, first_seen, last_seen, lifecycle, metadata_json, monitor_json, monitor_state
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?)
|
||||
`);
|
||||
const updateDiscoveredComponent = database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET last_seen = ?,
|
||||
lifecycle = ?,
|
||||
missing_since = NULL,
|
||||
archived_at = CASE WHEN archive_reason = 'manual' THEN archived_at ELSE NULL END,
|
||||
archive_reason = CASE WHEN archive_reason = 'manual' THEN archive_reason ELSE NULL END,
|
||||
metadata_json = ?,
|
||||
monitor_json = ?,
|
||||
monitor_state = ?,
|
||||
monitor_paused = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
const markMissing = database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET lifecycle = 'missing', missing_since = ?
|
||||
WHERE id = ? AND lifecycle = 'active'
|
||||
`);
|
||||
const markArchivedByRetention = database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET lifecycle = 'archived', archived_at = ?, archive_reason = 'retention',
|
||||
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END,
|
||||
monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END
|
||||
WHERE id = ? AND lifecycle = 'missing'
|
||||
`);
|
||||
const insertCheck = database.prepare(`
|
||||
INSERT INTO monitor_check (component_id, checked_at, ok, status_code, latency_ms, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const upsertHourly = database.prepare(`
|
||||
INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(component_id, bucket) DO UPDATE SET
|
||||
total = total + 1,
|
||||
successful = successful + excluded.successful,
|
||||
latency_sum = latency_sum + excluded.latency_sum
|
||||
`);
|
||||
const upsertDaily = database.prepare(`
|
||||
INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(component_id, bucket) DO UPDATE SET
|
||||
total = total + 1,
|
||||
successful = successful + excluded.successful,
|
||||
latency_sum = latency_sum + excluded.latency_sum
|
||||
`);
|
||||
const uptimeSince = database.prepare(`
|
||||
SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS successful
|
||||
FROM monitor_check
|
||||
WHERE component_id = ? AND checked_at >= ?
|
||||
`);
|
||||
const openIncident = database.prepare(`
|
||||
SELECT started_at FROM monitor_incident
|
||||
WHERE component_id = ? AND ended_at IS NULL
|
||||
`);
|
||||
|
||||
function boundedInteger(value, fallback, minimum, maximum) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(minimum, Math.min(maximum, parsed));
|
||||
}
|
||||
|
||||
function dockerGet(pathname) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({
|
||||
socketPath: dockerSocket,
|
||||
path: pathname,
|
||||
method: "GET",
|
||||
headers: {"Accept": "application/json"}
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
if ((response.statusCode || 500) >= 400) {
|
||||
reject(new Error(`Docker API ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.setTimeout(3000, () => request.destroy(new Error("Docker API timeout")));
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function limitedString(value, fallback = "", limit = 500) {
|
||||
return typeof value === "string" ? value.trim().slice(0, limit) : fallback;
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
const candidate = limitedString(value);
|
||||
if (!candidate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.username || url.password) {
|
||||
return "";
|
||||
}
|
||||
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function healthFromStatus(status) {
|
||||
if (status.includes("(unhealthy)")) {
|
||||
return "unhealthy";
|
||||
}
|
||||
if (status.includes("(healthy)")) {
|
||||
return "healthy";
|
||||
}
|
||||
if (status.includes("(health: starting)")) {
|
||||
return "starting";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
function instanceState(container) {
|
||||
const state = limitedString(container.State, "unknown").toLowerCase();
|
||||
const health = healthFromStatus(limitedString(container.Status));
|
||||
return health === "unhealthy" ? "unhealthy" : state;
|
||||
}
|
||||
|
||||
function aggregateStatus(instances) {
|
||||
const states = instances.map((instance) => instance.state);
|
||||
if (states.length > 0 && states.every((state) => state === "running")) {
|
||||
return "running";
|
||||
}
|
||||
if (states.includes("unhealthy")) {
|
||||
return "unhealthy";
|
||||
}
|
||||
if (states.includes("restarting")) {
|
||||
return "restarting";
|
||||
}
|
||||
if (states.includes("running")) {
|
||||
return "degraded";
|
||||
}
|
||||
for (const state of ["dead", "exited", "paused", "created"]) {
|
||||
if (states.includes(state)) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function definitionsFromLabels(labels) {
|
||||
const definitions = new Map();
|
||||
|
||||
for (const [key, value] of Object.entries(labels || {})) {
|
||||
if (!key.startsWith(labelPrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainder = key.slice(labelPrefix.length);
|
||||
const separator = remainder.indexOf(".");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = remainder.slice(0, separator);
|
||||
const field = remainder.slice(separator + 1);
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!definitions.has(id)) {
|
||||
definitions.set(id, {});
|
||||
}
|
||||
definitions.get(id)[field] = limitedString(value);
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function publicDefinition(id, definition) {
|
||||
const order = Number.parseInt(definition.order || "999", 10);
|
||||
return {
|
||||
id,
|
||||
name: limitedString(definition.name, id),
|
||||
description: limitedString(definition.description),
|
||||
section: allowedSections.has(definition.section) ? definition.section : "infrastructure",
|
||||
category: limitedString(definition.category, "其他"),
|
||||
url: safeUrl(definition.url),
|
||||
endpoint: limitedString(definition.endpoint, "", 160),
|
||||
access: limitedString(definition.access, "internal", 40),
|
||||
accessLabel: limitedString(definition["access-label"], "受控访问", 100),
|
||||
icon: limitedString(definition.icon, id.slice(0, 2).toUpperCase(), 8),
|
||||
iconUrl: safeUrl(definition["icon-url"]),
|
||||
navigation: ["new-tab", "same-tab", "endpoint"].includes(definition.navigation) ? definition.navigation : (definition.url ? "new-tab" : "endpoint"),
|
||||
portalLink: ["embedded", "native", "none"].includes(definition["portal-link"]) ? definition["portal-link"] : "none",
|
||||
accent: allowedAccents.has(definition.accent) ? definition.accent : "ink",
|
||||
order: Number.isFinite(order) ? order : 999
|
||||
};
|
||||
}
|
||||
|
||||
function monitorDefinition(definition) {
|
||||
if (definition["monitor.enabled"]?.toLowerCase() !== "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = safeUrl(definition["monitor.url"]);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
url,
|
||||
interval: boundedInteger(definition["monitor.interval"], 60, 10, 86400),
|
||||
failures: boundedInteger(definition["monitor.failures"], 3, 1, 20),
|
||||
timeout: boundedInteger(definition["monitor.timeout"], 10, 1, 60),
|
||||
accept: limitedString(definition["monitor.accept"], "200-299", 80)
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return value ? JSON.parse(value) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverDockerComponents() {
|
||||
const containers = await dockerGet("/containers/json?all=true");
|
||||
const components = new Map();
|
||||
|
||||
for (const container of containers) {
|
||||
const labels = container.Labels || {};
|
||||
for (const [id, definition] of definitionsFromLabels(labels)) {
|
||||
if (definition.enabled?.toLowerCase() !== "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = publicDefinition(id, definition);
|
||||
const instance = {
|
||||
container: limitedString(container.Names?.[0] || "", "", 160).replace(/^\//, ""),
|
||||
service: limitedString(labels["com.docker.compose.service"], "docker", 160),
|
||||
project: limitedString(labels["com.docker.compose.project"], "", 160),
|
||||
image: limitedString(container.Image, "", 300),
|
||||
state: instanceState(container),
|
||||
health: healthFromStatus(limitedString(container.Status))
|
||||
};
|
||||
|
||||
if (!components.has(id)) {
|
||||
components.set(id, {...metadata, monitor: monitorDefinition(definition), instances: []});
|
||||
}
|
||||
components.get(id).instances.push(instance);
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of components.values()) {
|
||||
const primary = component.instances[0];
|
||||
component.status = aggregateStatus(component.instances);
|
||||
component.instanceCount = component.instances.length;
|
||||
component.container = primary.container;
|
||||
component.service = primary.service;
|
||||
component.project = primary.project;
|
||||
component.image = primary.image;
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function reconcileComponent(id, discovered, now) {
|
||||
const existing = selectComponent.get(id);
|
||||
const metadata = {...discovered};
|
||||
delete metadata.monitor;
|
||||
delete metadata.instances;
|
||||
const metadataJson = JSON.stringify(metadata);
|
||||
const monitorJson = discovered.monitor ? JSON.stringify(discovered.monitor) : null;
|
||||
|
||||
if (!existing) {
|
||||
insertComponent.run(
|
||||
id,
|
||||
now,
|
||||
now,
|
||||
metadataJson,
|
||||
monitorJson,
|
||||
monitorJson ? "pending" : "unmonitored"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manuallyArchived = existing.lifecycle === "archived" && existing.archive_reason === "manual";
|
||||
const monitorChanged = existing.monitor_json !== monitorJson;
|
||||
let monitorState = existing.monitor_state;
|
||||
let monitorPaused = existing.monitor_paused;
|
||||
|
||||
if (!monitorJson) {
|
||||
monitorState = "unmonitored";
|
||||
monitorPaused = 0;
|
||||
} else if (manuallyArchived) {
|
||||
monitorState = "paused";
|
||||
monitorPaused = 1;
|
||||
} else if (monitorChanged || existing.lifecycle === "archived") {
|
||||
monitorState = "pending";
|
||||
monitorPaused = 0;
|
||||
}
|
||||
|
||||
updateDiscoveredComponent.run(
|
||||
now,
|
||||
manuallyArchived ? "archived" : "active",
|
||||
metadataJson,
|
||||
monitorJson,
|
||||
monitorState,
|
||||
monitorPaused,
|
||||
id
|
||||
);
|
||||
|
||||
if (monitorChanged) {
|
||||
database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET consecutive_failures = 0, last_checked_at = NULL, last_status_code = NULL,
|
||||
last_latency_ms = NULL, last_error = NULL
|
||||
WHERE id = ?
|
||||
`).run(id);
|
||||
database.prepare(`
|
||||
UPDATE monitor_incident SET ended_at = ?
|
||||
WHERE component_id = ? AND ended_at IS NULL
|
||||
`).run(now, id);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileDiscovery() {
|
||||
if (discoveryRunning) {
|
||||
return;
|
||||
}
|
||||
discoveryRunning = true;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const discovered = await discoverDockerComponents();
|
||||
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const [id, component] of discovered) {
|
||||
reconcileComponent(id, component, now);
|
||||
}
|
||||
|
||||
for (const existing of selectComponents.all()) {
|
||||
if (discovered.has(existing.id) || existing.lifecycle === "archived") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.lifecycle === "active") {
|
||||
markMissing.run(now, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.lifecycle === "missing" && existing.missing_since) {
|
||||
const missingForMs = Date.now() - new Date(existing.missing_since).getTime();
|
||||
if (missingForMs >= missingRetentionDays * 86400000) {
|
||||
markArchivedByRetention.run(now, existing.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastDiscoveryAt = now;
|
||||
} catch (error) {
|
||||
console.error("Docker discovery failed", error.message);
|
||||
} finally {
|
||||
discoveryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusAccepted(statusCode, expression) {
|
||||
for (const part of expression.split(",")) {
|
||||
const match = part.trim().match(/^(\d{3})(?:-(\d{3}))?$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const minimum = Number.parseInt(match[1], 10);
|
||||
const maximum = Number.parseInt(match[2] || match[1], 10);
|
||||
if (statusCode >= minimum && statusCode <= maximum) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return limitedString(message.replace(/[\r\n\t]+/g, " "), "probe failed", 300);
|
||||
}
|
||||
|
||||
function updateAggregate(componentId, checkedAt, ok, latencyMs) {
|
||||
const hourBucket = `${checkedAt.slice(0, 13)}:00:00.000Z`;
|
||||
const dayBucket = `${checkedAt.slice(0, 10)}T00:00:00.000Z`;
|
||||
upsertHourly.run(componentId, hourBucket, ok ? 1 : 0, latencyMs || 0);
|
||||
upsertDaily.run(componentId, dayBucket, ok ? 1 : 0, latencyMs || 0);
|
||||
}
|
||||
|
||||
function recordCheck(componentId, monitor, result) {
|
||||
const existing = selectComponent.get(componentId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFailures = result.ok ? 0 : existing.consecutive_failures + 1;
|
||||
const nextState = result.ok ? "up" : nextFailures >= monitor.failures ? "down" : "degraded";
|
||||
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
insertCheck.run(
|
||||
componentId,
|
||||
result.checkedAt,
|
||||
result.ok ? 1 : 0,
|
||||
result.statusCode,
|
||||
result.latencyMs,
|
||||
result.error
|
||||
);
|
||||
updateAggregate(componentId, result.checkedAt, result.ok, result.latencyMs);
|
||||
|
||||
database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET monitor_state = ?, consecutive_failures = ?, last_checked_at = ?,
|
||||
last_status_code = ?, last_latency_ms = ?, last_error = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
nextState,
|
||||
nextFailures,
|
||||
result.checkedAt,
|
||||
result.statusCode,
|
||||
result.latencyMs,
|
||||
result.error,
|
||||
componentId
|
||||
);
|
||||
|
||||
if (nextState === "down" && existing.monitor_state !== "down") {
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO monitor_incident (component_id, started_at, cause)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(componentId, result.checkedAt, result.error || `HTTP ${result.statusCode || "error"}`);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
database.prepare(`
|
||||
UPDATE monitor_incident SET ended_at = ?
|
||||
WHERE component_id = ? AND ended_at IS NULL
|
||||
`).run(result.checkedAt, componentId);
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkComponent(row, monitor) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const startedAt = performance.now();
|
||||
let statusCode = null;
|
||||
let ok = false;
|
||||
let errorMessage = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(monitor.url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"Accept": "text/html,application/json;q=0.9,*/*;q=0.1",
|
||||
"User-Agent": "Xiteng-Portal-Monitor/1.0"
|
||||
},
|
||||
signal: AbortSignal.timeout(monitor.timeout * 1000)
|
||||
});
|
||||
statusCode = response.status;
|
||||
ok = statusAccepted(statusCode, monitor.accept);
|
||||
if (!ok) {
|
||||
errorMessage = `HTTP ${statusCode}`;
|
||||
}
|
||||
if (response.body) {
|
||||
await response.body.cancel();
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = safeError(error);
|
||||
}
|
||||
|
||||
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
|
||||
recordCheck(row.id, monitor, {
|
||||
checkedAt,
|
||||
ok,
|
||||
statusCode,
|
||||
latencyMs,
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleDueChecks() {
|
||||
if (activeChecks.size >= monitorConcurrency) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const candidates = selectComponents.all().filter((row) => {
|
||||
if (!row.monitor_json || row.monitor_paused || !["active", "missing"].includes(row.lifecycle)) {
|
||||
return false;
|
||||
}
|
||||
const monitor = parseJson(row.monitor_json, null);
|
||||
if (!monitor) {
|
||||
return false;
|
||||
}
|
||||
const lastChecked = row.last_checked_at ? new Date(row.last_checked_at).getTime() : 0;
|
||||
return !activeChecks.has(row.id) && now - lastChecked >= monitor.interval * 1000;
|
||||
});
|
||||
|
||||
for (const row of candidates.slice(0, monitorConcurrency - activeChecks.size)) {
|
||||
const monitor = parseJson(row.monitor_json, null);
|
||||
activeChecks.add(row.id);
|
||||
checkComponent(row, monitor)
|
||||
.catch((error) => console.error(`Monitor ${row.id} failed`, error.message))
|
||||
.finally(() => activeChecks.delete(row.id));
|
||||
}
|
||||
}
|
||||
|
||||
function availability(componentId, hours) {
|
||||
const since = new Date(Date.now() - hours * 3600000).toISOString();
|
||||
const result = uptimeSince.get(componentId, since);
|
||||
if (!result || result.total === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((result.successful / result.total) * 10000) / 100;
|
||||
}
|
||||
|
||||
function publicComponent(row, includeAdmin = false) {
|
||||
const metadata = parseJson(row.metadata_json, {id: row.id, name: row.id});
|
||||
const monitor = parseJson(row.monitor_json, null);
|
||||
const incident = openIncident.get(row.id);
|
||||
const component = {
|
||||
...metadata,
|
||||
lifecycle: row.lifecycle,
|
||||
status: row.lifecycle === "active" ? metadata.status : row.lifecycle,
|
||||
monitor: {
|
||||
enabled: Boolean(monitor),
|
||||
status: monitor
|
||||
? row.monitor_paused ? "paused" : row.monitor_state
|
||||
: "unmonitored",
|
||||
checkedAt: row.last_checked_at,
|
||||
responseTimeMs: row.last_latency_ms,
|
||||
statusCode: row.last_status_code,
|
||||
consecutiveFailures: row.consecutive_failures,
|
||||
uptime24h: monitor ? availability(row.id, 24) : null,
|
||||
incidentSince: incident?.started_at || null
|
||||
}
|
||||
};
|
||||
|
||||
if (includeAdmin) {
|
||||
component.firstSeen = row.first_seen;
|
||||
component.lastSeen = row.last_seen;
|
||||
component.missingSince = row.missing_since;
|
||||
component.archivedAt = row.archived_at;
|
||||
component.archiveReason = row.archive_reason;
|
||||
component.monitor.paused = Boolean(row.monitor_paused);
|
||||
component.monitor.error = row.last_error;
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
function componentPayload(includeArchived = false, includeAdmin = false) {
|
||||
const components = selectComponents.all()
|
||||
.filter((row) => includeArchived || row.lifecycle !== "archived")
|
||||
.map((row) => publicComponent(row, includeAdmin))
|
||||
.sort((left, right) => {
|
||||
if (left.section !== right.section) {
|
||||
return left.section.localeCompare(right.section);
|
||||
}
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
return left.name.localeCompare(right.name, "zh-CN");
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
discoveredAt: lastDiscoveryAt,
|
||||
components
|
||||
};
|
||||
}
|
||||
|
||||
function pruneHistory() {
|
||||
const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000).toISOString();
|
||||
const aggregateCutoff = new Date(Date.now() - aggregateRetentionDays * 86400000).toISOString();
|
||||
database.prepare("DELETE FROM monitor_check WHERE checked_at < ?").run(rawCutoff);
|
||||
database.prepare("DELETE FROM monitor_hourly WHERE bucket < ?").run(aggregateCutoff);
|
||||
database.prepare("DELETE FROM monitor_daily WHERE bucket < ?").run(aggregateCutoff);
|
||||
}
|
||||
|
||||
function isAdminProxy(request) {
|
||||
return request.headers[adminProxyHeader.toLowerCase()] === "1";
|
||||
}
|
||||
|
||||
function adminAction(id, action) {
|
||||
const row = selectComponent.get(id);
|
||||
if (!row) {
|
||||
return {statusCode: 404, payload: {error: "Component not found"}};
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (action === "archive") {
|
||||
database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET lifecycle = 'archived', archived_at = ?, archive_reason = 'manual',
|
||||
monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END,
|
||||
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END
|
||||
WHERE id = ?
|
||||
`).run(now, id);
|
||||
} else if (action === "restore") {
|
||||
const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3;
|
||||
database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET lifecycle = ?, missing_since = ?, archived_at = NULL, archive_reason = NULL,
|
||||
monitor_paused = 0,
|
||||
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'pending' END
|
||||
WHERE id = ?
|
||||
`).run(recentlySeen ? "active" : "missing", recentlySeen ? null : now, id);
|
||||
} else if (action === "pause") {
|
||||
if (!row.monitor_json) {
|
||||
return {statusCode: 409, payload: {error: "Monitoring is not configured"}};
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE registry_component SET monitor_paused = 1, monitor_state = 'paused' WHERE id = ?
|
||||
`).run(id);
|
||||
} else if (action === "resume") {
|
||||
if (!row.monitor_json) {
|
||||
return {statusCode: 409, payload: {error: "Monitoring is not configured"}};
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE registry_component
|
||||
SET monitor_paused = 0, monitor_state = 'pending', consecutive_failures = 0
|
||||
WHERE id = ?
|
||||
`).run(id);
|
||||
} else if (action === "purge") {
|
||||
const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3;
|
||||
if (row.lifecycle !== "missing" && recentlySeen) {
|
||||
return {statusCode: 409, payload: {error: "Remove or disable the component Label before purging"}};
|
||||
}
|
||||
database.prepare("DELETE FROM registry_component WHERE id = ?").run(id);
|
||||
return {statusCode: 200, payload: {status: "purged", id}};
|
||||
} else {
|
||||
return {statusCode: 404, payload: {error: "Unknown action"}};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: {status: "ok", component: publicComponent(selectComponent.get(id), true)}
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload, extraHeaders = {}) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(statusCode, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...extraHeaders
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const pathname = new URL(request.url || "/", "http://localhost").pathname;
|
||||
|
||||
if (request.method === "GET" && pathname === "/healthz") {
|
||||
sendJson(response, 200, {
|
||||
status: "ok",
|
||||
discovery: lastDiscoveryAt ? "ready" : "starting",
|
||||
activeChecks: activeChecks.size
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && pathname === "/components") {
|
||||
sendJson(response, 200, componentPayload(false, false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/admin/") && !isAdminProxy(request)) {
|
||||
sendJson(response, 403, {error: "Forbidden"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && pathname === "/admin/components") {
|
||||
sendJson(response, 200, componentPayload(true, true));
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = pathname.match(/^\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/);
|
||||
if (request.method === "POST" && actionMatch) {
|
||||
const result = adminAction(actionMatch[1], actionMatch[2]);
|
||||
sendJson(response, result.statusCode, result.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["GET", "POST"].includes(request.method || "")) {
|
||||
sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, POST"});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, {error: "Not found"});
|
||||
});
|
||||
|
||||
await reconcileDiscovery();
|
||||
pruneHistory();
|
||||
setInterval(reconcileDiscovery, discoveryIntervalMs);
|
||||
setInterval(scheduleDueChecks, 2000);
|
||||
setInterval(pruneHistory, 6 * 3600000);
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`component registry and monitor listening on :${port}`);
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import {createAuthentikAdmin} from "./authentik.mjs";
|
||||
import {createReadStream, readFileSync} from "node:fs";
|
||||
import {stat} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT || "8080", 10);
|
||||
const registryUrl = process.env.REGISTRY_URL || "http://xiteng-site-registry:8091/components";
|
||||
const registryAdminUrl = process.env.REGISTRY_ADMIN_URL || "http://xiteng-site-registry:8091/admin";
|
||||
const metricsUrl = process.env.METRICS_URL || "http://xiteng-site-metrics:8092/metrics";
|
||||
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
|
||||
const providerTestUrl = process.env.PROVIDER_TEST_URL || "http://xiteng-chat:3000/api/provider-test";
|
||||
const keyVaultTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
|
||||
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
|
||||
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
|
||||
const authentikUrl = process.env.AUTHENTIK_URL || "https://auth.xiteng.site";
|
||||
const authentikTokenFile = process.env.AUTHENTIK_TOKEN_FILE || "/run/authentik-secrets/portal_api_token";
|
||||
const identityAuditPath = process.env.IDENTITY_AUDIT_PATH || "/data/identity-audit.jsonl";
|
||||
|
||||
const staticFiles = new Map([
|
||||
["/", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
|
||||
["/index.html", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
|
||||
["/styles.css", {path: "/app/styles.css", type: "text/css; charset=utf-8"}],
|
||||
["/app.js", {path: "/app/app.js", type: "text/javascript; charset=utf-8"}],
|
||||
["/sw.js", {path: "/app/sw.js", type: "text/javascript; charset=utf-8", cacheControl: "no-cache"}],
|
||||
["/manifest.webmanifest", {path: "/app/manifest.webmanifest", type: "application/manifest+json; charset=utf-8"}],
|
||||
["/favicon.svg", {path: "/app/favicon.svg", type: "image/svg+xml"}],
|
||||
["/favicon.ico", {path: "/app/favicon.ico", type: "image/x-icon"}],
|
||||
["/icons/favicon-32.png", {path: "/app/icons/favicon-32.png", type: "image/png"}],
|
||||
["/icons/apple-touch-icon.png", {path: "/app/icons/apple-touch-icon.png", type: "image/png"}],
|
||||
["/icons/icon-192.png", {path: "/app/icons/icon-192.png", type: "image/png"}],
|
||||
["/icons/icon-512.png", {path: "/app/icons/icon-512.png", type: "image/png"}],
|
||||
["/icons/icon-maskable-512.png", {path: "/app/icons/icon-maskable-512.png", type: "image/png"}],
|
||||
["/icons/services/chat.svg", {path: "/app/icons/services/chat.svg", type: "image/svg+xml"}],
|
||||
["/admin", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
|
||||
["/admin/", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
|
||||
["/admin.js", {path: "/app/admin.js", type: "text/javascript; charset=utf-8"}],
|
||||
["/account", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
|
||||
["/account/", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
|
||||
["/account.js", {path: "/app/account.js", type: "text/javascript; charset=utf-8"}]
|
||||
]);
|
||||
|
||||
const securityHeaders = {
|
||||
"Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data: https://www.gravatar.com https://seccdn.libravatar.org https://cdn.jsdelivr.net https://cdn.simpleicons.org; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY"
|
||||
};
|
||||
|
||||
function sendJson(response, statusCode, payload, extraHeaders = {}) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(statusCode, {
|
||||
...securityHeaders,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
...extraHeaders
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
|
||||
function readOptionalSecret(path) {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const keyVaultToken = readOptionalSecret(keyVaultTokenFile);
|
||||
const authentikAdmin = createAuthentikAdmin({
|
||||
baseUrl: authentikUrl,
|
||||
token: readOptionalSecret(authentikTokenFile),
|
||||
auditPath: identityAuditPath,
|
||||
adminUsername
|
||||
});
|
||||
|
||||
async function readBody(request, maximum = 65536) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maximum) {
|
||||
const error = new Error("Request body too large");
|
||||
error.statusCode = 413;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function parseJson(body) {
|
||||
try {
|
||||
return body.length ? JSON.parse(body.toString("utf8")) : {};
|
||||
} catch {
|
||||
const error = new Error("Invalid JSON body");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyJson(request, response, upstreamUrl, unavailableMessage, headers = {}, timeoutMs = 5000) {
|
||||
try {
|
||||
const method = request.method === "POST" ? "POST" : "GET";
|
||||
const body = method === "POST" ? await readBody(request) : undefined;
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
method,
|
||||
body: body?.length ? body : undefined,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
...(body?.length ? {"Content-Type": "application/json"} : {}),
|
||||
...headers
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
const upstreamBody = await upstream.text();
|
||||
|
||||
response.writeHead(upstream.status, {
|
||||
...securityHeaders,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(upstreamBody)
|
||||
});
|
||||
response.end(upstreamBody);
|
||||
} catch (error) {
|
||||
console.error(unavailableMessage, error.message);
|
||||
sendJson(response, 503, {error: unavailableMessage});
|
||||
}
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, file) {
|
||||
try {
|
||||
const metadata = await stat(file.path);
|
||||
response.writeHead(200, {
|
||||
...securityHeaders,
|
||||
"Cache-Control": file.cacheControl || "no-cache",
|
||||
"Content-Type": file.type,
|
||||
"Content-Length": metadata.size
|
||||
});
|
||||
|
||||
if (request.method === "HEAD") {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
createReadStream(file.path).pipe(response);
|
||||
} catch {
|
||||
sendJson(response, 404, {error: "Not found"});
|
||||
}
|
||||
}
|
||||
|
||||
function header(request, name) {
|
||||
const value = request.headers[name];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function identityFromRequest(request) {
|
||||
const username = header(request, "x-authentik-username");
|
||||
return {
|
||||
issuer: authentikIssuer,
|
||||
sub: header(request, "x-authentik-uid") || username,
|
||||
username,
|
||||
email: header(request, "x-authentik-email"),
|
||||
admin: username === adminUsername,
|
||||
provider: "authentik"
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthenticated(request) {
|
||||
return header(request, "x-portal-authenticated") === "1" && Boolean(identityFromRequest(request).username);
|
||||
}
|
||||
|
||||
function validOrigin(request) {
|
||||
return request.headers.origin === "https://xiteng.site";
|
||||
}
|
||||
|
||||
function requireMutationOrigin(request, response) {
|
||||
if (!validOrigin(request)) {
|
||||
sendJson(response, 403, {error: "Invalid request origin"});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function proxyKeyVault(request, response, upstreamPath) {
|
||||
if (!keyVaultToken) {
|
||||
sendJson(response, 503, {error: "Key Vault is not initialized"});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = request.method === "POST" ? await readBody(request, 1048576) : Buffer.alloc(0);
|
||||
const actor = identityFromRequest(request);
|
||||
const upstream = await fetch(new URL(upstreamPath, keyVaultUrl), {
|
||||
method: request.method,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Bearer ${keyVaultToken}`,
|
||||
...(body.length ? {"Content-Type": "application/json"} : {}),
|
||||
"X-Key-Vault-Actor-Issuer": actor.issuer,
|
||||
"X-Key-Vault-Actor-Sub": actor.sub,
|
||||
"X-Key-Vault-Actor-Username": actor.username
|
||||
},
|
||||
body: body.length ? body : undefined,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
const upstreamBody = await upstream.text();
|
||||
response.writeHead(upstream.status, {
|
||||
...securityHeaders,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(upstreamBody)
|
||||
});
|
||||
response.end(upstreamBody);
|
||||
} catch (error) {
|
||||
console.error("Key Vault unavailable", error.message);
|
||||
sendJson(response, 503, {error: "Key Vault unavailable"});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function vaultUpstreamPath(pathname, admin, searchParams) {
|
||||
const prefix = admin ? "/api/admin/vault" : "/api/account/vault";
|
||||
const suffix = pathname.slice(prefix.length);
|
||||
if (suffix === "/providers") return "/v1/providers";
|
||||
if (/^\/providers\/[a-z0-9._-]+\/delete$/.test(suffix)) return `/v1${suffix}`;
|
||||
if (suffix === "/credentials") {
|
||||
return `/v1/credentials${admin && searchParams.get("scope") !== "own" ? "?scope=all" : ""}`;
|
||||
}
|
||||
if (suffix === "/audit") return `/v1/audit${admin ? "?scope=all&limit=200" : "?limit=100"}`;
|
||||
if (/^\/credentials\/[0-9a-f-]+\/(replace|verify|delete)$/.test(suffix)) return `/v1${suffix}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
if (!request.url) {
|
||||
sendJson(response, 400, {error: "Bad request"});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (request.method === "GET" && pathname === "/healthz") {
|
||||
sendJson(response, 200, {status: "ok"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/components") {
|
||||
await proxyJson(request, response, registryUrl, "Component registry unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/metrics") {
|
||||
await proxyJson(request, response, metricsUrl, "Host metrics unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const isAdminPath = pathname === "/admin"
|
||||
|| pathname === "/admin/"
|
||||
|| pathname === "/admin.js"
|
||||
|| pathname.startsWith("/api/admin/");
|
||||
const isAccountPath = pathname === "/account"
|
||||
|| pathname === "/account/"
|
||||
|| pathname === "/account.js"
|
||||
|| pathname.startsWith("/api/account/");
|
||||
|
||||
if ((isAdminPath || isAccountPath) && !isAuthenticated(request)) {
|
||||
sendJson(response, 403, {error: "Authenticated user required"});
|
||||
return;
|
||||
}
|
||||
if (isAdminPath && !identityFromRequest(request).admin) {
|
||||
sendJson(response, 403, {error: `Administrator ${adminUsername} required`});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && ["/api/admin/session", "/api/account/session"].includes(pathname)) {
|
||||
sendJson(response, 200, {identity: identityFromRequest(request), administrator: adminUsername});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/admin/identity" && request.method === "GET") {
|
||||
sendJson(response, 200, await authentikAdmin.summary(url.searchParams.get("search") || ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/admin/identity/") && request.method === "POST") {
|
||||
if (!requireMutationOrigin(request, response)) {
|
||||
return;
|
||||
}
|
||||
const result = await authentikAdmin.mutate(
|
||||
pathname,
|
||||
parseJson(await readBody(request)),
|
||||
identityFromRequest(request).username
|
||||
);
|
||||
sendJson(response, 200, {result});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/account/identity" && request.method === "GET") {
|
||||
sendJson(response, 200, await authentikAdmin.accountSummary(identityFromRequest(request).username));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/account/provider-test" && request.method === "POST") {
|
||||
if (!requireMutationOrigin(request, response)) return;
|
||||
const identity = identityFromRequest(request);
|
||||
await proxyJson(request, response, providerTestUrl, "Provider connectivity test unavailable", {
|
||||
"X-Authentik-Username": identity.username,
|
||||
"X-Authentik-Uid": identity.sub,
|
||||
"X-Authentik-Email": identity.email
|
||||
}, 20000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/account/") && request.method === "POST" && !pathname.startsWith("/api/account/vault")) {
|
||||
if (!requireMutationOrigin(request, response)) return;
|
||||
const result = await authentikAdmin.accountMutate(
|
||||
pathname,
|
||||
parseJson(await readBody(request)),
|
||||
identityFromRequest(request).username
|
||||
);
|
||||
sendJson(response, 200, {result});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/admin/vault") || pathname.startsWith("/api/account/vault")) {
|
||||
const admin = pathname.startsWith("/api/admin/vault");
|
||||
const upstreamPath = vaultUpstreamPath(pathname, admin, url.searchParams);
|
||||
if (!upstreamPath) {
|
||||
sendJson(response, 404, {error: "Vault action not found"});
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && !requireMutationOrigin(request, response)) {
|
||||
return;
|
||||
}
|
||||
if (!["GET", "POST"].includes(request.method || "")) {
|
||||
sendJson(response, 405, {error: "Method not allowed"});
|
||||
return;
|
||||
}
|
||||
await proxyKeyVault(request, response, upstreamPath);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (pathname === "/api/admin/components" && request.method === "GET") {
|
||||
await proxyJson(
|
||||
request,
|
||||
response,
|
||||
`${registryAdminUrl}/components`,
|
||||
"Registry administration unavailable",
|
||||
{"X-Portal-Admin": "1"}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const adminAction = pathname.match(/^\/api\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/);
|
||||
if (adminAction && request.method === "POST") {
|
||||
if (!requireMutationOrigin(request, response)) {
|
||||
return;
|
||||
}
|
||||
await proxyJson(
|
||||
request,
|
||||
response,
|
||||
`${registryAdminUrl}/components/${adminAction[1]}/${adminAction[2]}`,
|
||||
"Registry administration unavailable",
|
||||
{"X-Portal-Admin": "1"}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = staticFiles.get(pathname);
|
||||
if (file && ["GET", "HEAD"].includes(request.method || "")) {
|
||||
await serveStatic(request, response, file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["GET", "HEAD", "POST"].includes(request.method || "")) {
|
||||
sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, HEAD, POST"});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, {error: "Not found"});
|
||||
} catch (error) {
|
||||
console.error("Portal request failed", error.message);
|
||||
sendJson(response, error.statusCode || 500, {error: error.message || "Request failed"});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`xiteng.site listening on :${port}; administrator=${adminUsername}`);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
const cacheName = "xiteng-portal-v8";
|
||||
const appShell = [
|
||||
"/",
|
||||
"/styles.css",
|
||||
"/app.js?v=7",
|
||||
"/manifest.webmanifest",
|
||||
"/favicon.svg",
|
||||
"/favicon.ico",
|
||||
"/icons/favicon-32.png",
|
||||
"/icons/apple-touch-icon.png",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/icon-maskable-512.png"
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(cacheName)
|
||||
.then((cache) => cache.addAll(appShell))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => new Response(
|
||||
JSON.stringify({error: "Network unavailable"}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
}
|
||||
}
|
||||
))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/admin")
|
||||
|| url.pathname.startsWith("/account")
|
||||
|| url.pathname === "/admin.js"
|
||||
|| url.pathname === "/account.js"
|
||||
|| url.pathname.startsWith("/oauth2/")
|
||||
|| url.pathname.startsWith("/outpost.goauthentik.io/")) {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && ["/", "/index.html"].includes(url.pathname)) {
|
||||
const copy = response.clone();
|
||||
caches.open(cacheName).then((cache) => cache.put("/", copy));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match("/"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const network = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(cacheName).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
if (cached) {
|
||||
network.catch(() => undefined);
|
||||
return cached;
|
||||
}
|
||||
return network;
|
||||
})
|
||||
);
|
||||
});
|
||||