first commit

This commit is contained in:
Geek66666
2026-04-13 11:34:17 +08:00
commit 6bf23eabf8
17 changed files with 3377 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NVIDIA NIM 管理后台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Noto+Sans+SC:wght@400;500;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="admin-body">
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="admin-shell">
<aside class="admin-sidebar">
<div class="brand-block">
<span class="hero-badge">NVIDIA NIM</span>
<h1>运营控制台</h1>
<p>管理模型目录、API Key、健康巡检和公开看板的数据来源。</p>
</div>
<h3>功能导航</h3>
<button class="sidebar-btn active" data-panel="overview">总览</button>
<button class="sidebar-btn" data-panel="models">模型管理</button>
<button class="sidebar-btn" data-panel="keys">Key 管理</button>
<button class="sidebar-btn" data-panel="health">巡检记录</button>
<button class="sidebar-btn" data-panel="settings">调度设置</button>
</aside>
<section class="admin-content">
<div class="glass-panel" data-panel="overview">
<div class="panel-headline">
<div>
<span class="section-tag">控制中心</span>
<h2>网关运行总览</h2>
</div>
<p class="status-text">统一查看模型、Key 和近几次健康探测结果。</p>
</div>
<div class="section-grid compact-grid" id="overview-metrics"></div>
<div class="glass-panel sub-panel">
<div class="panel-headline compact">
<div>
<span class="section-tag">最近活动</span>
<h3>最近巡检</h3>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>时间</th>
<th>模型</th>
<th>状态</th>
<th>时延</th>
</tr>
</thead>
<tbody id="recent-checks"></tbody>
</table>
</div>
</div>
<div class="glass-panel hidden" data-panel="models">
<div class="panel-headline">
<div>
<span class="section-tag">目录配置</span>
<h2>模型管理</h2>
</div>
<div class="inline-actions panel-actions">
<button class="secondary-btn" id="test-all-models" type="button">测试全部模型</button>
</div>
</div>
<p class="status-text">添加、删除、连通性测试,以及使用与巡检统计。</p>
<div class="section-grid compact-grid">
<div class="metric-card">
<h3>模型总数</h3>
<strong id="model-count">-</strong>
</div>
<div class="metric-card">
<h3>当前健康</h3>
<strong id="model-healthy">-</strong>
</div>
</div>
<div class="form-grid spaced-top">
<input id="model-id" placeholder="模型 ID,例如 z-ai/glm5" />
<input id="model-display-name" placeholder="展示名称,例如 GLM-5" />
<textarea id="model-description" placeholder="模型说明,将出现在后台管理视图中"></textarea>
<button id="model-add" type="button">新增或更新模型</button>
</div>
<table class="table spaced-top">
<thead>
<tr>
<th>模型</th>
<th>状态</th>
<th>调用次数</th>
<th>巡检统计</th>
<th>操作</th>
</tr>
</thead>
<tbody id="model-table"></tbody>
</table>
</div>
<div class="glass-panel hidden" data-panel="keys">
<div class="panel-headline">
<div>
<span class="section-tag">凭据配置</span>
<h2>NVIDIA NIM Key 管理</h2>
</div>
<div class="inline-actions panel-actions">
<button class="secondary-btn" id="test-all-keys" type="button">测试全部 Key</button>
</div>
</div>
<p class="status-text">统一维护可用 Key,并统计请求和巡检使用情况。</p>
<div class="form-grid compact-grid">
<input id="key-label" placeholder="Key 名称,例如 主生产 Key" />
<input id="key-value" placeholder="输入 NVIDIA NIM Key" />
<button id="key-add" type="button">保存 Key</button>
</div>
<table class="table spaced-top">
<thead>
<tr>
<th>名称</th>
<th>脱敏值</th>
<th>请求次数</th>
<th>最近测试</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="key-table"></tbody>
</table>
</div>
<div class="glass-panel hidden" data-panel="health">
<div class="panel-headline">
<div>
<span class="section-tag">健康巡检</span>
<h2>巡检记录</h2>
</div>
<button id="run-healthcheck" type="button">立即巡检全部模型</button>
</div>
<p class="status-text">手动触发的巡检结果会立刻写入数据库,并同步更新到公开健康页。</p>
<div class="section-grid" id="health-grid"></div>
</div>
<div class="glass-panel hidden" data-panel="settings">
<div class="panel-headline">
<div>
<span class="section-tag">计划任务</span>
<h2>调度设置</h2>
</div>
<p class="status-text">设置巡检开关、时间间隔、公开页保留时长和巡检提示词。</p>
</div>
<div class="form-grid settings-grid">
<label class="checkbox-row field-span-full">
<input id="healthcheck-enabled" type="checkbox" />
<span>启用定时健康巡检</span>
</label>
<input id="healthcheck-interval" type="number" min="5" step="5" placeholder="巡检间隔,单位分钟" />
<input id="public-history-hours" type="number" min="1" step="1" placeholder="公开页保留时长,单位小时" />
<textarea id="healthcheck-prompt" placeholder="用于健康巡检的提示词"></textarea>
<div class="inline-actions settings-actions field-span-full">
<button id="settings-save" type="button">保存设置</button>
<button class="secondary-btn" id="refresh-now" type="button">重新加载面板</button>
</div>
</div>
<p class="status-text" id="settings-status"></p>
</div>
</section>
</div>
<div class="login-overlay" id="login-overlay">
<div class="login-card">
<span class="section-tag">管理员登录</span>
<h2>进入后台</h2>
<p class="status-text">请输入环境变量 PASSWORD 的值。</p>
<label for="admin-password">后台密码</label>
<input type="password" id="admin-password" autocomplete="current-password" />
<button id="login-btn">解锁控制台</button>
<p class="status-text" id="login-status"></p>
</div>
</div>
<script src="/static/admin.js" charset="utf-8" defer></script>
</body>
</html>
+343
View File
@@ -0,0 +1,343 @@
const PANEL_ATTR = "data-panel";
const sidebarButtons = document.querySelectorAll(".sidebar-btn");
const panels = document.querySelectorAll(`.glass-panel[${PANEL_ATTR}]`);
const loginOverlay = document.getElementById("login-overlay");
const loginBtn = document.getElementById("login-btn");
const loginStatus = document.getElementById("login-status");
const overviewMetrics = document.getElementById("overview-metrics");
const recentChecks = document.getElementById("recent-checks");
const modelTable = document.getElementById("model-table");
const keyTable = document.getElementById("key-table");
const healthGrid = document.getElementById("health-grid");
const modelCount = document.getElementById("model-count");
const modelHealthy = document.getElementById("model-healthy");
const settingsStatus = document.getElementById("settings-status");
const testAllModelsBtn = document.getElementById("test-all-models");
const testAllKeysBtn = document.getElementById("test-all-keys");
const runHealthcheckBtn = document.getElementById("run-healthcheck");
const state = {
token: sessionStorage.getItem("nim_token"),
panel: "overview",
};
const STATUS_LABELS = {
healthy: "正常",
degraded: "波动",
down: "异常",
unknown: "未巡检",
};
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
function formatDateTime(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return dateTimeFormatter.format(date);
}
function showPanel(name) {
panels.forEach((panel) => panel.classList.toggle("hidden", panel.getAttribute(PANEL_ATTR) !== name));
sidebarButtons.forEach((button) => button.classList.toggle("active", button.dataset.panel === name));
state.panel = name;
}
sidebarButtons.forEach((button) => button.addEventListener("click", () => showPanel(button.dataset.panel)));
async function apiRequest(endpoint, opts = {}) {
const headers = { "Content-Type": "application/json", Accept: "application/json" };
if (state.token) headers.Authorization = `Bearer ${state.token}`;
const response = await fetch(`/admin/api/${endpoint}`, {
...opts,
headers: { ...headers, ...(opts.headers || {}) },
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.message || payload.detail || payload.error?.message || "请求失败");
}
return response.json();
}
function metricCard(label, value, detail = "") {
const div = document.createElement("div");
div.className = "metric-card";
div.innerHTML = `<h3>${label}</h3><strong>${value}</strong><p>${detail}</p>`;
return div;
}
function pill(status) {
const normalized = status || "unknown";
return `<span class="pill ${normalized}">${STATUS_LABELS[normalized] || normalized}</span>`;
}
async function renderOverview() {
const payload = await apiRequest("overview");
const totals = payload.totals || {};
overviewMetrics.innerHTML = "";
overviewMetrics.appendChild(metricCard("启用模型", totals.enabled_models ?? "--", `总数 ${totals.total_models ?? "--"}`));
overviewMetrics.appendChild(metricCard("启用 Key", totals.enabled_keys ?? "--", `总数 ${totals.total_keys ?? "--"}`));
overviewMetrics.appendChild(metricCard("代理请求", totals.total_requests ?? "--", `成功 ${totals.total_success ?? 0}`));
overviewMetrics.appendChild(metricCard("失败次数", totals.total_failures ?? "--", "累计转发失败或上游返回错误"));
recentChecks.innerHTML = "";
(payload.recent_checks || []).forEach((check) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatDateTime(check.time)}</td>
<td>${check.model}</td>
<td>${pill(check.status)}</td>
<td>${check.latency ? `${check.latency} ms` : "--"}</td>
`;
recentChecks.appendChild(row);
});
}
async function renderModels() {
const payload = await apiRequest("models");
const items = payload.items || [];
modelCount.textContent = items.length;
modelHealthy.textContent = items.filter((item) => item.status === "healthy").length;
modelTable.innerHTML = "";
items.forEach((item) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>
<strong>${item.display_name || item.model_id}</strong><br />
<span class="status-text mono">${item.model_id}</span>
</td>
<td>${pill(item.status)}</td>
<td>${item.request_count}</td>
<td>${item.healthcheck_success_count}/${item.healthcheck_count}</td>
<td>
<div class="inline-actions">
<button class="secondary-btn" data-action="test-model" data-id="${item.model_id}">测试</button>
<button class="secondary-btn danger-btn" data-action="remove-model" data-id="${item.model_id}">删除</button>
</div>
</td>
`;
modelTable.appendChild(row);
});
}
async function renderKeys() {
const payload = await apiRequest("keys");
const items = payload.items || [];
keyTable.innerHTML = "";
items.forEach((item) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${item.label}</td>
<td class="mono">${item.masked_key}</td>
<td>${item.request_count}</td>
<td>${formatDateTime(item.last_tested)}</td>
<td>${pill(item.status)}</td>
<td>
<div class="inline-actions">
<button class="secondary-btn" data-action="test-key" data-id="${item.name}">测试</button>
<button class="secondary-btn danger-btn" data-action="remove-key" data-id="${item.name}">删除</button>
</div>
</td>
`;
keyTable.appendChild(row);
});
}
async function renderHealth() {
const payload = await apiRequest("healthchecks");
const items = payload.items || [];
healthGrid.innerHTML = "";
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-card";
empty.textContent = "暂无巡检记录,执行一次巡检后这里会显示最新结果。";
healthGrid.appendChild(empty);
return;
}
items.slice(0, 12).forEach((item) => {
const card = document.createElement("article");
card.className = "health-record";
card.innerHTML = `
<div class="toolbar-row">
<div>
<h4>${item.model}</h4>
<span class="status-text mono">${item.model_id}</span>
</div>
${pill(item.status)}
</div>
<p class="status-text">${item.detail || "暂无详情"}</p>
<div class="record-meta">
<span>Key: ${item.api_key || "未记录"}</span>
<span>时延: ${item.latency ? `${item.latency} ms` : "--"}</span>
<span>时间: ${formatDateTime(item.checked_at)}</span>
</div>
`;
healthGrid.appendChild(card);
});
}
async function renderSettings() {
const payload = await apiRequest("settings");
document.getElementById("healthcheck-enabled").checked = Boolean(payload.healthcheck_enabled);
document.getElementById("healthcheck-interval").value = payload.healthcheck_interval_minutes || 60;
document.getElementById("public-history-hours").value = payload.public_history_hours || 48;
document.getElementById("healthcheck-prompt").value = payload.healthcheck_prompt || "请只回复 OK。";
}
async function loadAll() {
await Promise.all([renderOverview(), renderModels(), renderKeys(), renderHealth(), renderSettings()]);
}
async function runAllModelChecks() {
const payload = await apiRequest("healthchecks/run", { method: "POST", body: JSON.stringify({}) });
const items = payload.items || [];
const success = items.filter((item) => item.status === "healthy").length;
alert(`已完成全部模型巡检,共 ${items.length} 个模型,其中 ${success} 个正常。`);
showPanel("health");
await loadAll();
}
async function runAllKeyChecks() {
const payload = await apiRequest("keys/test-all", { method: "POST", body: JSON.stringify({}) });
const items = payload.items || [];
const success = items.filter((item) => item.status === "healthy").length;
alert(`已完成全部 Key 测试,共 ${items.length} 个 Key,其中 ${success} 个正常。`);
showPanel("keys");
await loadAll();
}
async function testModel(modelId) {
const payload = await apiRequest(`models/${encodeURIComponent(modelId)}/test`, { method: "POST", body: JSON.stringify({}) });
alert(`${payload.display_name || payload.model} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
await loadAll();
}
async function removeModel(modelId) {
await apiRequest("models/remove", { method: "POST", body: JSON.stringify({ value: modelId }) });
await loadAll();
}
async function testKey(keyName) {
const payload = await apiRequest("keys/test", { method: "POST", body: JSON.stringify({ value: keyName }) });
alert(`Key ${payload.api_key} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
await loadAll();
}
async function removeKey(keyName) {
await apiRequest("keys/remove", { method: "POST", body: JSON.stringify({ value: keyName }) });
await loadAll();
}
modelTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
if (button.dataset.action === "test-model") testModel(button.dataset.id);
if (button.dataset.action === "remove-model") removeModel(button.dataset.id);
});
keyTable.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
if (button.dataset.action === "test-key") testKey(button.dataset.id);
if (button.dataset.action === "remove-key") removeKey(button.dataset.id);
});
document.getElementById("model-add")?.addEventListener("click", async () => {
const modelId = document.getElementById("model-id").value.trim();
const displayName = document.getElementById("model-display-name").value.trim();
const description = document.getElementById("model-description").value.trim();
if (!modelId) {
alert("请先填写模型 ID。");
return;
}
await apiRequest("models", {
method: "POST",
body: JSON.stringify({ model_id: modelId, display_name: displayName || modelId, description }),
});
document.getElementById("model-id").value = "";
document.getElementById("model-display-name").value = "";
document.getElementById("model-description").value = "";
await renderModels();
});
document.getElementById("key-add")?.addEventListener("click", async () => {
const name = document.getElementById("key-label").value.trim();
const apiKey = document.getElementById("key-value").value.trim();
if (!name || !apiKey) {
alert("请填写 Key 名称和内容。");
return;
}
await apiRequest("keys", { method: "POST", body: JSON.stringify({ name, api_key: apiKey }) });
document.getElementById("key-label").value = "";
document.getElementById("key-value").value = "";
await renderKeys();
});
testAllModelsBtn?.addEventListener("click", runAllModelChecks);
testAllKeysBtn?.addEventListener("click", runAllKeyChecks);
runHealthcheckBtn?.addEventListener("click", runAllModelChecks);
document.getElementById("settings-save")?.addEventListener("click", async () => {
try {
const payload = {
healthcheck_enabled: document.getElementById("healthcheck-enabled").checked,
healthcheck_interval_minutes: Number(document.getElementById("healthcheck-interval").value || 60),
public_history_hours: Number(document.getElementById("public-history-hours").value || 48),
healthcheck_prompt: document.getElementById("healthcheck-prompt").value.trim(),
};
await apiRequest("settings", { method: "PUT", body: JSON.stringify(payload) });
settingsStatus.textContent = "设置已保存。";
await loadAll();
} catch (error) {
settingsStatus.textContent = error.message;
}
});
document.getElementById("refresh-now")?.addEventListener("click", loadAll);
loginBtn.addEventListener("click", async () => {
const password = document.getElementById("admin-password").value.trim();
if (!password) {
loginStatus.textContent = "请输入后台密码。";
return;
}
try {
loginStatus.textContent = "正在验证身份...";
const response = await fetch("/admin/api/login", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.message || "登录失败");
state.token = payload.access_token || payload.token;
sessionStorage.setItem("nim_token", state.token);
loginOverlay.classList.add("hidden");
await loadAll();
} catch (error) {
loginStatus.textContent = error.message;
}
});
window.addEventListener("DOMContentLoaded", async () => {
showPanel(state.panel);
if (!state.token) return;
loginOverlay.classList.add("hidden");
try {
await loadAll();
setInterval(loadAll, 90 * 1000);
} catch (_error) {
sessionStorage.removeItem("nim_token");
loginOverlay.classList.remove("hidden");
}
});
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NIM Responses Gateway</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Noto+Sans+SC:wght@400;500;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="health-page-body">
<main class="health-page-shell">
<header class="health-topbar">
<div>
<span class="mini-badge">NIM Responses Gateway</span>
<h1>模型健康度</h1>
<p>使用方式:使用自己的NIM api_key作为Codex api_key,base_url为本站网址,请自行在config.toml中设置</p>
</div>
<div class="topbar-actions">
<a class="text-nav-link" href="/model_list">查看官方模型列表</a>
<button class="refresh-link" id="refresh-dashboard" type="button">刷新数据</button>
</div>
</header>
<section class="health-summary-grid" id="overview-cards"></section>
<section class="health-board-panel">
<div class="board-header-row">
<div>
<span class="mini-badge subtle">健康趋势</span>
<h2>最近 10 分钟成功率矩阵</h2>
</div>
<div class="board-meta-box">
<span>最近更新</span>
<strong id="dashboard-updated">--</strong>
</div>
</div>
<div class="timeline-header" id="timeline-header"></div>
<div class="health-list" id="health-grid"></div>
<p class="panel-hint" id="dashboard-empty"></p>
</section>
</main>
<script src="/static/public.js" charset="utf-8" defer></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NIM 官方模型列表</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Noto+Sans+SC:wght@400;500;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="catalog-page-body">
<main class="catalog-page-shell">
<header class="catalog-topbar">
<div>
<span class="mini-badge">官方模型目录</span>
<h1>NVIDIA NIM 模型列表</h1>
</div>
<div class="topbar-actions">
<a class="text-nav-link" href="/">返回健康度首页</a>
</div>
</header>
<section class="catalog-summary-grid" id="catalog-summary"></section>
<section class="catalog-filter-panel">
<div>
<span class="mini-badge subtle">筛选</span>
<h2>按模型提供商筛选</h2>
</div>
<div class="provider-filter-bar" id="provider-filter-bar"></div>
</section>
<section class="catalog-board-panel">
<div class="board-header-row">
<div>
<span class="mini-badge subtle">模型目录</span>
<h2>提供商卡片列表</h2>
</div>
<div class="board-meta-box">
<span>同步时间</span>
<strong id="catalog-updated">--</strong>
</div>
</div>
<div class="provider-grid" id="provider-grid"></div>
<p class="panel-hint" id="catalog-empty"></p>
</section>
</main>
<script src="/static/models.js" charset="utf-8" defer></script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
const catalogSummary = document.getElementById("catalog-summary");
const providerFilterBar = document.getElementById("provider-filter-bar");
const providerGrid = document.getElementById("provider-grid");
const catalogUpdated = document.getElementById("catalog-updated");
const catalogEmpty = document.getElementById("catalog-empty");
let catalogState = {
providers: [],
activeProvider: "all",
};
const DISPLAY_TIMEZONE = "Asia/Shanghai";
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: DISPLAY_TIMEZONE,
});
function formatDateTime(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return dateTimeFormatter.format(date);
}
function createSummaryCard(label, value, detail = "") {
const card = document.createElement("article");
card.className = "summary-card";
card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
return card;
}
function renderSummary(data) {
catalogSummary.innerHTML = "";
catalogSummary.appendChild(createSummaryCard("官方模型总数", data.total_models ?? 0, "来自 NVIDIA 官方 /v1/models"));
catalogSummary.appendChild(createSummaryCard("提供商数量", data.providers?.length ?? 0, "按模型 ID 中的提供商前缀自动归类"));
}
function renderFilterBar() {
providerFilterBar.innerHTML = "";
const options = [{ provider: "all", count: catalogState.providers.reduce((sum, item) => sum + (item.count || 0), 0), label: "全部" }].concat(
catalogState.providers.map((group) => ({ provider: group.provider, count: group.count, label: group.provider }))
);
options.forEach((option) => {
const button = document.createElement("button");
button.className = `provider-filter-btn ${catalogState.activeProvider === option.provider ? "active" : ""}`;
button.type = "button";
button.textContent = `${option.label} (${option.count})`;
button.addEventListener("click", () => {
catalogState.activeProvider = option.provider;
renderFilterBar();
renderProviders();
});
providerFilterBar.appendChild(button);
});
}
function renderProviders() {
providerGrid.innerHTML = "";
const filtered = catalogState.activeProvider === "all"
? catalogState.providers
: catalogState.providers.filter((group) => group.provider === catalogState.activeProvider);
if (filtered.length === 0) {
catalogEmpty.textContent = "当前筛选条件下没有可展示的模型。";
return;
}
catalogEmpty.textContent = "";
filtered.forEach((group) => {
const card = document.createElement("article");
card.className = "provider-card provider-card-fixed";
card.innerHTML = `
<div class="provider-card-head">
<div>
<h3>${group.provider}</h3>
<p>${group.count} 个模型</p>
</div>
</div>
`;
const body = document.createElement("div");
body.className = "provider-card-body";
const list = document.createElement("div");
list.className = "provider-model-list";
(group.models || []).forEach((model) => {
const chip = document.createElement("div");
chip.className = "provider-model-chip";
chip.title = model.id;
chip.textContent = model.id;
list.appendChild(chip);
});
body.appendChild(list);
card.appendChild(body);
providerGrid.appendChild(card);
});
}
async function loadCatalog() {
const response = await fetch("/api/catalog", { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error("模型列表加载失败");
}
const payload = await response.json();
catalogUpdated.textContent = formatDateTime(payload.synced_at || payload.generated_at);
catalogState.providers = payload.providers || [];
renderSummary(payload);
renderFilterBar();
renderProviders();
}
window.addEventListener("DOMContentLoaded", async () => {
try {
await loadCatalog();
} catch (error) {
catalogEmpty.textContent = error.message;
providerGrid.innerHTML = "";
providerFilterBar.innerHTML = "";
}
});
+152
View File
@@ -0,0 +1,152 @@
const overviewCards = document.getElementById("overview-cards");
const dashboardUpdated = document.getElementById("dashboard-updated");
const timelineHeader = document.getElementById("timeline-header");
const healthGrid = document.getElementById("health-grid");
const dashboardEmpty = document.getElementById("dashboard-empty");
const refreshDashboardBtn = document.getElementById("refresh-dashboard");
const DISPLAY_TIMEZONE = "Asia/Shanghai";
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: DISPLAY_TIMEZONE,
});
const SUMMARY_WIDTH = 360;
const CELL_SIZE = 42;
const CELL_GAP = 8;
function formatDateTime(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return dateTimeFormatter.format(date);
}
function rateMeta(rate) {
if (rate === null || rate === undefined) return { label: "暂无数据", className: "status-cyan", colorClass: "cell-empty" };
if (rate >= 95) return { label: "优秀", className: "status-cyan", colorClass: "cell-cyan" };
if (rate >= 80) return { label: "良好", className: "status-green", colorClass: "cell-green" };
if (rate >= 50) return { label: "告警", className: "status-orange", colorClass: "cell-orange" };
return { label: "异常", className: "status-red", colorClass: "cell-red" };
}
function createSummaryCard(label, value, detail = "") {
const card = document.createElement("article");
card.className = "summary-card";
card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
return card;
}
function applyTimelineGrid(pointsCount) {
const headerColumns = `minmax(${SUMMARY_WIDTH}px, 1fr) repeat(${pointsCount}, ${CELL_SIZE}px)`;
timelineHeader.style.gridTemplateColumns = headerColumns;
}
function renderOverview(data) {
overviewCards.innerHTML = "";
const averageHealth = data.average_health === null || data.average_health === undefined ? "--" : `${data.average_health.toFixed(2)}%`;
const totalCalls = data.total_requests ?? 0;
const healthyModels = (data.models || []).filter((model) => (model.latest_success_rate ?? 0) >= 95).length;
const displayedBuckets = data.models?.[0]?.points?.length || 0;
overviewCards.appendChild(createSummaryCard("总调用次数", totalCalls, "统计来自网关累计成功与失败请求"));
overviewCards.appendChild(createSummaryCard("平均健康度", averageHealth, "按监控模型最近 10 分钟成功率平均值计算"));
overviewCards.appendChild(createSummaryCard("高健康模型数", healthyModels, "最近一档成功率达到 95% 以上的模型数量"));
overviewCards.appendChild(createSummaryCard("统计窗口", `${displayedBuckets * (data.bucket_minutes || 10)} 分钟`, `当前按 ${data.bucket_minutes || 10} 分钟粒度滚动统计`));
dashboardUpdated.textContent = formatDateTime(data.generated_at);
}
function renderTimelineHeader(models) {
timelineHeader.innerHTML = "";
const points = models?.[0]?.points || [];
applyTimelineGrid(points.length);
const emptyCell = document.createElement("div");
emptyCell.className = "timeline-label timeline-left-placeholder";
timelineHeader.appendChild(emptyCell);
points.forEach((point) => {
const cell = document.createElement("div");
cell.className = "timeline-label";
cell.textContent = point.label || formatDateTime(point.bucket_start);
timelineHeader.appendChild(cell);
});
}
function renderHealthRows(models) {
healthGrid.innerHTML = "";
if (!models || models.length === 0) {
dashboardEmpty.textContent = "当前没有可展示的模型健康数据,请确认 MODEL_LIST 配置并等待请求进入统计。";
return;
}
dashboardEmpty.textContent = "";
renderTimelineHeader(models);
models.forEach((model) => {
const latestRate = model.latest_success_rate === null || model.latest_success_rate === undefined ? "--" : `${model.latest_success_rate.toFixed(2)}%`;
const latestMeta = rateMeta(model.latest_success_rate);
const row = document.createElement("article");
row.className = "health-row-card";
row.style.gridTemplateColumns = `minmax(${SUMMARY_WIDTH}px, 1fr) max-content`;
const summary = document.createElement("div");
summary.className = "health-row-summary";
summary.innerHTML = `
<div class="health-row-accent ${latestMeta.className}"></div>
<div class="health-row-copy">
<h3 title="${model.model_id}">${model.model_id}</h3>
<div class="health-meta-inline">
<span class="health-rate-pill ${latestMeta.className}">${latestRate}</span>
<span class="health-rate-pill health-call-pill">调用 ${model.total_calls ?? 0} 次</span>
</div>
</div>
`;
const cells = document.createElement("div");
cells.className = "health-cells";
cells.style.gridTemplateColumns = `repeat(${model.points?.length || 0}, ${CELL_SIZE}px)`;
cells.style.justifySelf = "end";
(model.points || []).forEach((point) => {
const meta = rateMeta(point.success_rate);
const cell = document.createElement("div");
cell.className = `health-cell ${meta.colorClass}`;
cell.title = `${point.label} 成功 ${point.success_count}/${point.total_count}`;
cells.appendChild(cell);
});
row.appendChild(summary);
row.appendChild(cells);
healthGrid.appendChild(row);
});
}
async function loadDashboard() {
const response = await fetch("/api/dashboard", { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error("健康看板数据加载失败");
}
const payload = await response.json();
renderOverview(payload);
renderHealthRows(payload.models || []);
}
async function refreshDashboard() {
try {
await loadDashboard();
} catch (error) {
dashboardEmpty.textContent = error.message;
timelineHeader.innerHTML = "";
healthGrid.innerHTML = "";
}
}
refreshDashboardBtn?.addEventListener("click", refreshDashboard);
window.addEventListener("DOMContentLoaded", () => {
refreshDashboard();
window.setInterval(refreshDashboard, 60 * 1000);
});
+573
View File
@@ -0,0 +1,573 @@
:root {
--bg: #f8fbfd;
--surface: #ffffff;
--surface-soft: #f1f8fb;
--surface-muted: #eef6f9;
--line: #deedf3;
--line-strong: #c8e5ef;
--text: #3d4652;
--muted: #7b8a97;
--cyan: #57c8df;
--cyan-deep: #46bfd8;
--green: #69c36c;
--yellow: #a8cf6d;
--orange: #f0ae46;
--red: #f06d6f;
--shadow: 0 20px 42px rgba(109, 153, 177, 0.12);
--font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(105, 195, 108, 0.08), transparent 22%),
radial-gradient(circle at top right, rgba(87, 200, 223, 0.12), transparent 20%),
linear-gradient(180deg, #fbfdfe 0%, #f6fbfd 100%);
}
.health-page-shell,
.catalog-page-shell {
width: min(1560px, calc(100vw - 56px));
margin: 0 auto;
padding: 26px 0 44px;
}
.health-topbar,
.catalog-topbar,
.health-summary-grid,
.health-board-panel,
.catalog-summary-grid,
.catalog-filter-panel,
.catalog-board-panel,
.summary-card,
.provider-card {
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.health-topbar,
.catalog-topbar,
.health-board-panel,
.catalog-filter-panel,
.catalog-board-panel {
border-radius: 28px;
}
.health-topbar,
.catalog-topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 26px 30px;
}
.health-topbar h1,
.catalog-topbar h1,
.board-header-row h2,
.health-row-copy h3,
.provider-card h3 {
font-family: var(--font-display);
}
.health-topbar h1,
.catalog-topbar h1 {
margin: 12px 0 10px;
font-size: clamp(30px, 2.5vw, 44px);
line-height: 1.1;
}
.health-topbar p,
.catalog-topbar p,
.panel-hint,
.provider-card p,
.summary-card p,
.timeline-label,
.board-meta-box span {
color: var(--muted);
}
.topbar-actions {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.mini-badge {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
background: rgba(87, 200, 223, 0.12);
color: var(--cyan-deep);
border: 1px solid rgba(87, 200, 223, 0.26);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.05em;
}
.mini-badge.subtle {
background: var(--surface-soft);
color: var(--muted);
border-color: var(--line);
}
.text-nav-link,
.refresh-link,
.provider-filter-btn {
appearance: none;
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--text);
text-decoration: none;
border-radius: 999px;
padding: 11px 16px;
font: inherit;
cursor: pointer;
}
.text-nav-link:hover,
.refresh-link:hover,
.provider-filter-btn:hover {
border-color: var(--cyan);
color: var(--cyan-deep);
}
.health-summary-grid,
.catalog-summary-grid {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
background: transparent;
border: none;
box-shadow: none;
}
.summary-card {
border-radius: 22px;
padding: 18px 20px;
}
.summary-card span {
display: block;
color: var(--muted);
font-size: 13px;
}
.summary-card strong {
display: block;
margin: 10px 0 8px;
font-family: var(--font-display);
font-size: 28px;
}
.health-board-panel,
.catalog-filter-panel,
.catalog-board-panel {
margin-top: 18px;
padding: 26px 28px;
}
.board-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.board-header-row h2 {
margin: 12px 0 0;
font-size: clamp(24px, 2vw, 34px);
}
.board-meta-box {
min-width: 200px;
padding: 14px 16px;
border-radius: 20px;
background: var(--surface-soft);
border: 1px solid var(--line);
}
.board-meta-box strong {
display: block;
margin-top: 8px;
font-family: var(--font-display);
font-size: 20px;
}
.timeline-header {
display: grid;
gap: 8px;
align-items: center;
margin: 8px 0 10px;
min-width: max-content;
}
.timeline-label {
text-align: center;
font-size: 12px;
width: 42px;
}
.timeline-left-placeholder {
text-align: left;
width: auto;
}
.health-list {
display: flex;
flex-direction: column;
gap: 12px;
min-width: max-content;
}
.health-row-card {
display: grid;
gap: 20px;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.health-row-card:last-child {
border-bottom: none;
}
.health-row-summary {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
padding-right: 16px;
}
.health-row-accent {
width: 18px;
min-width: 18px;
height: 84px;
border-radius: 999px;
background: var(--cyan);
}
.health-row-copy {
min-width: 0;
max-width: 100%;
}
.health-row-copy h3 {
margin: 0;
font-size: 22px;
line-height: 1.3;
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
word-break: break-word;
}
.health-rate-pill {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 14px;
font-weight: 800;
font-size: 14px;
line-height: 1;
background: var(--surface-soft);
}
.health-meta-inline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.health-call-pill {
color: var(--cyan-deep);
background: rgba(87, 200, 223, 0.14);
border: 1px solid rgba(87, 200, 223, 0.18);
}
.health-cells {
display: grid;
gap: 8px;
align-items: center;
justify-content: end;
width: max-content;
margin-left: auto;
}
.health-cell {
width: 42px;
height: 42px;
border-radius: 12px;
border: 3px solid transparent;
background: var(--surface-muted);
}
.cell-cyan {
border-color: var(--cyan);
background: rgba(87, 200, 223, 0.12);
}
.cell-green {
border-color: var(--green);
background: rgba(105, 195, 108, 0.1);
}
.cell-orange {
border-color: var(--orange);
background: rgba(240, 174, 70, 0.1);
}
.cell-red {
border-color: var(--red);
background: rgba(240, 109, 111, 0.1);
}
.cell-empty {
border-color: transparent;
background: var(--surface-muted);
}
.status-cyan,
.status-cyan.health-row-accent {
color: var(--cyan-deep);
background-color: var(--cyan);
}
.status-green,
.status-green.health-row-accent {
color: var(--green);
background-color: var(--green);
}
.status-orange,
.status-orange.health-row-accent {
color: var(--orange);
background-color: var(--orange);
}
.status-red,
.status-red.health-row-accent {
color: var(--red);
background-color: var(--red);
}
.health-rate-pill.status-cyan { color: var(--cyan-deep); background: rgba(87, 200, 223, 0.14); }
.health-rate-pill.status-green { color: var(--green); background: rgba(105, 195, 108, 0.14); }
.health-rate-pill.status-orange { color: var(--orange); background: rgba(240, 174, 70, 0.14); }
.health-rate-pill.status-red { color: var(--red); background: rgba(240, 109, 111, 0.14); }
.provider-filter-panel {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.provider-filter-panel h2 {
margin: 12px 0 0;
font-family: var(--font-display);
font-size: clamp(22px, 2vw, 30px);
}
.provider-filter-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.provider-filter-btn.active {
background: rgba(87, 200, 223, 0.12);
border-color: rgba(87, 200, 223, 0.3);
color: var(--cyan-deep);
font-weight: 700;
}
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.provider-card {
border-radius: 24px;
padding: 20px;
display: flex;
flex-direction: column;
align-self: stretch;
min-height: 0;
}
.provider-card-fixed {
height: 320px;
min-height: 320px;
max-height: 320px;
overflow: hidden;
}
.provider-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex: 0 0 auto;
}
.provider-card h3 {
margin: 0;
font-size: 24px;
}
.provider-card p {
margin: 10px 0 0;
}
.provider-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.provider-model-list {
margin-top: 18px;
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: stretch;
gap: 10px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
scrollbar-gutter: stable;
}
.provider-model-chip {
display: flex;
align-items: center;
min-height: 46px;
height: 46px;
padding: 0 14px;
border-radius: 16px;
background: var(--surface-soft);
border: 1px solid var(--line);
font-family: var(--font-display);
font-size: 13px;
line-height: 1;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.panel-hint {
margin-top: 16px;
}
@media (max-width: 1200px) {
.timeline-header,
.health-row-card,
.health-cells {
grid-template-columns: 260px repeat(12, minmax(26px, 1fr));
}
}
@media (max-width: 980px) {
.health-page-shell,
.catalog-page-shell {
width: min(100vw - 24px, 1560px);
}
.health-summary-grid,
.catalog-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.timeline-header {
display: none;
}
.health-row-card {
grid-template-columns: 1fr;
gap: 14px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 24px;
background: var(--surface);
}
}
@media (max-width: 720px) {
.health-topbar,
.catalog-topbar,
.health-board-panel,
.catalog-filter-panel,
.catalog-board-panel {
padding: 20px;
}
.health-summary-grid,
.catalog-summary-grid,
.provider-grid {
grid-template-columns: 1fr;
}
.topbar-actions,
.provider-filter-bar {
width: 100%;
}
.text-nav-link,
.refresh-link,
.provider-filter-btn {
width: 100%;
text-align: center;
}
.health-row-summary {
align-items: flex-start;
}
.health-row-accent {
height: 72px;
}
.health-row-copy h3 {
font-size: 19px;
}
.health-cells {
gap: 6px;
}
.health-cell,
.timeline-label {
width: 34px;
height: 34px;
}
}