Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/route-health.html
2026-05-29 15:50:28 +08:00

867 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Route Health Admin</title>
<style>
:root {
--bg: #edf1e8;
--panel: rgba(253, 255, 250, 0.94);
--ink: #1a2018;
--muted: #5d695a;
--line: rgba(26, 32, 24, 0.12);
--accent: #0f6d71;
--accent-soft: rgba(15, 109, 113, 0.12);
--success: #18704e;
--success-soft: rgba(24, 112, 78, 0.1);
--warn: #9a6419;
--warn-soft: rgba(154, 100, 25, 0.12);
--danger: #b13d2d;
--danger-soft: rgba(177, 61, 45, 0.1);
--neutral: #5f6875;
--neutral-soft: rgba(95, 104, 117, 0.1);
--shadow: 0 26px 72px rgba(43, 53, 38, 0.1);
--radius: 24px;
--radius-sm: 16px;
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: var(--font-sans);
background:
radial-gradient(circle at top left, rgba(15, 109, 113, 0.16), transparent 24rem),
radial-gradient(circle at bottom right, rgba(24, 112, 78, 0.12), transparent 24rem),
linear-gradient(180deg, #f5f8f0 0%, #e8ede3 100%);
}
a { color: inherit; }
code, pre { font-family: var(--font-mono); }
.shell {
max-width: 1480px;
margin: 0 auto;
padding: 34px 20px 64px;
}
.topnav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.topnav a {
text-decoration: none;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
color: var(--muted);
font-size: 13px;
font-weight: 700;
transition: transform 120ms ease, background 120ms ease;
}
.topnav a:hover { transform: translateY(-1px); background: #fff; }
.topnav a.is-current {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-card, .panel { padding: 26px; }
.hero-card {
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
right: -4rem;
bottom: -4rem;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(15, 109, 113, 0.18), rgba(24, 112, 78, 0.06));
filter: blur(10px);
}
.eyebrow {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 10px;
font-size: clamp(32px, 4vw, 46px);
line-height: 1.02;
letter-spacing: -0.05em;
}
h2 {
margin: 0 0 8px;
font-size: 24px;
letter-spacing: -0.04em;
}
h3 {
margin: 0 0 8px;
font-size: 18px;
letter-spacing: -0.03em;
}
.hero-copy, .panel-desc {
color: var(--muted);
line-height: 1.75;
font-size: 15px;
}
.hero-points {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.hero-points li {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
font-size: 13px;
font-weight: 700;
}
.metrics {
display: grid;
gap: 12px;
align-content: start;
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.04em;
word-break: break-word;
}
.layout {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 18px;
}
.stack, .section, .list {
display: grid;
gap: 18px;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
color: var(--ink);
background: #fff;
}
textarea {
min-height: 120px;
resize: vertical;
}
.actions, .mini-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button {
border: 0;
cursor: pointer;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 800;
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
.primary { background: var(--ink); color: #fff; }
.secondary { background: var(--accent-soft); color: var(--accent); }
.ghost {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
}
.statusbar {
margin-top: 16px;
min-height: 54px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
display: flex;
align-items: center;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(24,112,78,0.2); }
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,100,25,0.2); }
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(177,61,45,0.2); }
.catalog {
display: grid;
gap: 12px;
max-height: 34rem;
overflow: auto;
padding-right: 4px;
}
.catalog-item {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.catalog-item:hover {
transform: translateY(-1px);
border-color: rgba(15,109,113,0.22);
}
.catalog-item.is-selected {
background: rgba(15,109,113,0.08);
border-color: rgba(15,109,113,0.22);
}
.catalog-item strong {
display: block;
margin-bottom: 6px;
font-size: 16px;
}
.catalog-meta, .detail-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
.tone-healthy { background: var(--success-soft); color: var(--success); border-color: rgba(24,112,78,0.18); }
.tone-cooldown { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,100,25,0.18); }
.tone-failing { background: var(--danger-soft); color: var(--danger); border-color: rgba(177,61,45,0.18); }
.tone-disabled { background: var(--neutral-soft); color: var(--neutral); border-color: rgba(95,104,117,0.18); }
.grid-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.list-card {
padding: 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.82);
}
.list-card strong {
display: block;
margin-bottom: 6px;
}
.empty {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.inline-code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted);
word-break: break-word;
}
pre {
margin: 0;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(20, 27, 23, 0.96);
color: #e9f3ea;
font-size: 12px;
line-height: 1.65;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1200px) {
.hero, .layout, .grid-columns, .field-grid.two { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html" class="is-current">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
</nav>
<section class="hero">
<article class="card hero-card">
<div class="eyebrow">Route Health</div>
<h1>把 cooldown、failure 与最近一次真实选路收进一个只读健康面</h1>
<p class="hero-copy">
这页聚合 <code>logical_group_routes</code>、运行态 <code>routefail</code> / <code>routecool</code>
最近一次 <code>route_decision_logs</code> 和最近 failover 计数。首版只做读,不直接改 route
目标是让运营能快速判断某条 route 当前是 <code>healthy</code><code>cooldown</code>
<code>failing</code> 还是 <code>disabled</code>
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>优先使用管理员会话,也保留 Bearer token 兜底</li>
<li>页面只读,写 failure / cooldown 仍走现有管理 API</li>
</ul>
</article>
<aside class="card metrics">
<div class="metric">
<div class="metric-label">API Root</div>
<div class="metric-value" id="metric-api-root">-</div>
</div>
<div class="metric">
<div class="metric-label">Routes</div>
<div class="metric-value" id="metric-route-count">0</div>
</div>
<div class="metric">
<div class="metric-label">Healthy / Cooldown</div>
<div class="metric-value" id="metric-health-mix">0 / 0</div>
</div>
<div class="metric">
<div class="metric-label">Failing / Disabled</div>
<div class="metric-value" id="metric-alert-mix">0 / 0</div>
</div>
</aside>
</section>
<section class="layout">
<div class="stack">
<article class="card panel">
<h2>连接与过滤</h2>
<p class="panel-desc">
当前页面主要查看 route 当前运行状态。过滤条件只作用在健康聚合 API不会改动任何 runtime 状态。
</p>
<div class="field-grid">
<label>
API Base
<input id="api-base" type="text" value="/portal-admin-api">
</label>
<label>
Bearer Admin Token可选
<input id="admin-token" type="password" placeholder="未启用 session 时可填">
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<label>
管理员用户名
<input id="admin-username" type="text" placeholder="portal-admin">
</label>
<label>
管理员密码
<input id="admin-password" type="password" placeholder="请输入当前实例管理员密码">
</label>
</div>
<div class="actions" style="margin-top:14px;">
<button class="secondary" id="admin-login-btn" type="button">管理员登录</button>
<button class="ghost" id="admin-logout-btn" type="button">退出会话</button>
<button class="ghost" id="save-config-btn" type="button">保存本地配置</button>
<button class="ghost" id="refresh-health-btn" type="button">刷新健康视图</button>
</div>
<div class="statusbar" id="admin-session-status">正在检查管理员会话…</div>
<div class="field-grid two" style="margin-top:18px;">
<label>
logical_group_id可选
<input id="filter-logical-group-id" type="text" placeholder="例如 gpt-shared">
</label>
<label>
route_id可选
<input id="filter-route-id" type="text" placeholder="例如 asxs-primary">
</label>
</div>
<div class="field-grid" style="margin-top:12px;">
<label>
runtime_status可选
<select id="filter-status">
<option value="">全部状态</option>
<option value="healthy">healthy</option>
<option value="cooldown">cooldown</option>
<option value="failing">failing</option>
<option value="disabled">disabled</option>
</select>
</label>
</div>
<div class="actions" style="margin-top:14px;">
<button class="primary" id="apply-filters-btn" type="button">应用过滤</button>
<button class="ghost" id="clear-filters-btn" type="button">清空过滤</button>
</div>
<div class="statusbar" id="health-status">健康聚合结果会显示在这里。</div>
</article>
<article class="card panel">
<h2>Route 列表</h2>
<p class="panel-desc">
列表按聚合后的 runtime status 展示。选中某条 route 后,右侧会给出 shadow 映射、最近一次选路与 failover 摘要。
</p>
<div class="catalog" id="route-catalog">
<div class="empty">还没有 route 数据。</div>
</div>
</article>
</div>
<div class="section">
<article class="card panel">
<h2>Route 详情</h2>
<p class="panel-desc">
详情区保持只读,重点回答三件事:当前 route 落在哪个 shadow host / group、为什么是当前状态、最近一次真实选路结果是什么。
</p>
<div class="grid-columns">
<section class="section">
<div class="list-card">
<strong>当前 Route</strong>
<div id="detail-heading" class="empty">选择左侧一条 route 后,这里会显示聚合详情。</div>
<div class="detail-grid" id="detail-pills"></div>
</div>
<div class="list-card">
<strong>Shadow 映射</strong>
<div class="list" id="detail-shadow"></div>
</div>
<div class="list-card">
<strong>最近一次选路</strong>
<div class="list" id="detail-runtime"></div>
</div>
</section>
<section class="section">
<div class="list-card">
<strong>最近错误与 failover</strong>
<div class="list" id="detail-errors"></div>
</div>
<div class="list-card">
<strong>聚合 JSON</strong>
<pre id="detail-json">{}</pre>
</div>
</section>
</div>
</article>
</div>
</section>
</main>
<script>
const storageKey = "sub2api-route-health-admin";
const state = {
routes: [],
selectedRouteID: "",
};
const apiBaseInput = document.getElementById("api-base");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const logicalGroupFilterInput = document.getElementById("filter-logical-group-id");
const routeFilterInput = document.getElementById("filter-route-id");
const statusFilterInput = document.getElementById("filter-status");
const adminSessionStatus = document.getElementById("admin-session-status");
const healthStatus = document.getElementById("health-status");
const routeCatalog = document.getElementById("route-catalog");
const metricApiRoot = document.getElementById("metric-api-root");
const metricRouteCount = document.getElementById("metric-route-count");
const metricHealthMix = document.getElementById("metric-health-mix");
const metricAlertMix = document.getElementById("metric-alert-mix");
const detailHeading = document.getElementById("detail-heading");
const detailPills = document.getElementById("detail-pills");
const detailShadow = document.getElementById("detail-shadow");
const detailRuntime = document.getElementById("detail-runtime");
const detailErrors = document.getElementById("detail-errors");
const detailJSON = document.getElementById("detail-json");
function defaultApiBase() {
if (window.location.origin.includes("sub.tksea.top")) {
return `${window.location.origin}/portal-admin-api`;
}
return "/portal-admin-api";
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/$/, "");
}
function authHeaders() {
const token = adminTokenInput.value.trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function setStatus(element, message, tone = "note") {
element.textContent = message;
if (tone === "note") {
element.removeAttribute("data-tone");
} else {
element.setAttribute("data-tone", tone);
}
}
function toneClass(status) {
switch (status) {
case "healthy":
return "tone-healthy";
case "cooldown":
return "tone-cooldown";
case "failing":
return "tone-failing";
case "disabled":
return "tone-disabled";
default:
return "";
}
}
async function requestJSON(path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
const response = await fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (error) {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
function collectFilters() {
const params = new URLSearchParams();
const logicalGroupID = logicalGroupFilterInput.value.trim();
const routeID = routeFilterInput.value.trim();
const status = statusFilterInput.value.trim();
if (logicalGroupID) params.set("logical_group_id", logicalGroupID);
if (routeID) params.set("route_id", routeID);
if (status) params.set("status", status);
return params;
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
logicalGroupID: logicalGroupFilterInput.value.trim(),
routeID: routeFilterInput.value.trim(),
status: statusFilterInput.value.trim(),
selectedRouteID: state.selectedRouteID || "",
}));
syncMetrics();
setStatus(healthStatus, "本地配置已保存。", "success");
}
function restoreConfig() {
const raw = localStorage.getItem(storageKey);
apiBaseInput.value = defaultApiBase();
if (!raw) {
syncMetrics();
return;
}
try {
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
logicalGroupFilterInput.value = payload.logicalGroupID || "";
routeFilterInput.value = payload.routeID || "";
statusFilterInput.value = payload.status || "";
state.selectedRouteID = payload.selectedRouteID || "";
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
syncMetrics();
}
function syncMetrics() {
const healthy = state.routes.filter((item) => item.runtime_status === "healthy").length;
const cooldown = state.routes.filter((item) => item.runtime_status === "cooldown").length;
const failing = state.routes.filter((item) => item.runtime_status === "failing").length;
const disabled = state.routes.filter((item) => item.runtime_status === "disabled").length;
metricApiRoot.textContent = normalizeApiBase();
metricRouteCount.textContent = String(state.routes.length);
metricHealthMix.textContent = `${healthy} / ${cooldown}`;
metricAlertMix.textContent = `${failing} / ${disabled}`;
}
async function refreshAdminSession() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
} else if (payload.login_enabled) {
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "warning");
} else {
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
}
return payload;
} catch (error) {
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
setStatus(adminSessionStatus, "管理员会话已退出。", "warning");
}
function routeByID(routeID) {
return state.routes.find((item) => item.route_id === routeID) || null;
}
function ensureSelectedRoute() {
if (state.selectedRouteID && routeByID(state.selectedRouteID)) {
return;
}
state.selectedRouteID = state.routes[0]?.route_id || "";
}
function renderRouteCatalog() {
if (!state.routes.length) {
routeCatalog.innerHTML = '<div class="empty">当前过滤条件下还没有 route 数据。</div>';
return;
}
routeCatalog.innerHTML = state.routes.map((item) => `
<button type="button" class="catalog-item ${state.selectedRouteID === item.route_id ? "is-selected" : ""}" data-route-id="${escapeHTML(item.route_id)}">
<strong>${escapeHTML(item.route_name || item.route_id)}</strong>
<div class="inline-code">${escapeHTML(item.logical_group_id)} / ${escapeHTML(item.route_id)}</div>
<div class="catalog-meta">
<span class="pill ${toneClass(item.runtime_status)}">${escapeHTML(item.runtime_status)}</span>
<span class="pill">priority: ${escapeHTML(item.priority)}</span>
<span class="pill">failures: ${escapeHTML(item.failure_count || 0)}</span>
<span class="pill">failovers: ${escapeHTML(item.recent_failover_count || 0)}</span>
</div>
</button>
`).join("");
routeCatalog.querySelectorAll("[data-route-id]").forEach((element) => {
element.addEventListener("click", () => {
const routeID = element.getAttribute("data-route-id");
if (!routeID) return;
state.selectedRouteID = routeID;
renderRouteCatalog();
renderDetail();
saveConfig();
});
});
}
function kvRow(label, value) {
return `<div class="list-card"><strong>${escapeHTML(label)}</strong><div class="inline-code">${escapeHTML(value || "-")}</div></div>`;
}
function renderDetail() {
const item = routeByID(state.selectedRouteID);
if (!item) {
detailHeading.innerHTML = '<div class="empty">选择左侧一条 route 后,这里会显示聚合详情。</div>';
detailPills.innerHTML = "";
detailShadow.innerHTML = '<div class="empty">暂无 shadow 映射。</div>';
detailRuntime.innerHTML = '<div class="empty">暂无最近选路记录。</div>';
detailErrors.innerHTML = '<div class="empty">暂无错误摘要。</div>';
detailJSON.textContent = "{}";
return;
}
detailHeading.innerHTML = `
<div><strong>${escapeHTML(item.route_name || item.route_id)}</strong></div>
<div class="inline-code">${escapeHTML(item.logical_group_display_name || item.logical_group_id)} / ${escapeHTML(item.route_id)}</div>
`;
detailPills.innerHTML = `
<span class="pill ${toneClass(item.runtime_status)}">${escapeHTML(item.runtime_status)}</span>
<span class="pill">${escapeHTML(item.configured_status || "unknown")}</span>
<span class="pill">${escapeHTML(item.backend || "unknown")} runtime</span>
`;
detailShadow.innerHTML = [
kvRow("shadow_host_id", item.shadow_host_id),
kvRow("shadow_group_id", item.shadow_group_id),
kvRow("upstream_base_url_hint", item.upstream_base_url_hint),
].join("");
detailRuntime.innerHTML = [
kvRow("last_selected_at", item.last_selected_at),
kvRow("last_public_model", item.last_public_model),
kvRow("last_request_id", item.last_request_id),
kvRow("last_upstream_status", item.last_upstream_status ? String(item.last_upstream_status) : "-"),
].join("");
detailErrors.innerHTML = [
kvRow("failure_count", String(item.failure_count || 0)),
kvRow("cooldown_until", item.cooldown_until),
kvRow("cooldown_reason", item.cooldown_reason),
kvRow("last_error_class", item.last_error_class),
kvRow("recent_failover_count", String(item.recent_failover_count || 0)),
].join("");
detailJSON.textContent = JSON.stringify(item, null, 2);
}
async function refreshHealth() {
const params = collectFilters();
const suffix = params.toString() ? `?${params.toString()}` : "";
const payload = await requestJSON(`/api/routing/routes/health${suffix}`);
state.routes = payload.route_health || [];
ensureSelectedRoute();
syncMetrics();
renderRouteCatalog();
renderDetail();
saveConfig();
if (!state.routes.length) {
setStatus(healthStatus, "当前过滤条件下没有 route 健康数据。", "warning");
return;
}
const summary = state.routes.reduce((acc, item) => {
acc[item.runtime_status] = (acc[item.runtime_status] || 0) + 1;
return acc;
}, {});
setStatus(
healthStatus,
`已加载 ${state.routes.length} 条 routehealthy=${summary.healthy || 0}cooldown=${summary.cooldown || 0}failing=${summary.failing || 0}disabled=${summary.disabled || 0}`,
"success"
);
}
function clearFilters() {
logicalGroupFilterInput.value = "";
routeFilterInput.value = "";
statusFilterInput.value = "";
saveConfig();
}
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("refresh-health-btn").addEventListener("click", () => {
refreshHealth().catch((error) => setStatus(healthStatus, `刷新失败:${error.message}`, "danger"));
});
document.getElementById("apply-filters-btn").addEventListener("click", () => {
refreshHealth().catch((error) => setStatus(healthStatus, `加载失败:${error.message}`, "danger"));
});
document.getElementById("clear-filters-btn").addEventListener("click", () => {
clearFilters();
refreshHealth().catch((error) => setStatus(healthStatus, `刷新失败:${error.message}`, "danger"));
});
document.getElementById("admin-login-btn").addEventListener("click", async () => {
try {
await loginAdminSession();
await refreshHealth();
} catch (error) {
setStatus(adminSessionStatus, `登录失败:${error.message}`, "danger");
}
});
document.getElementById("admin-logout-btn").addEventListener("click", async () => {
try {
await logoutAdminSession();
} catch (error) {
setStatus(adminSessionStatus, `退出失败:${error.message}`, "danger");
}
});
restoreConfig();
refreshAdminSession()
.catch(() => null)
.finally(() => {
refreshHealth().catch((error) => setStatus(healthStatus, `初始化失败:${error.message}`, "danger"));
});
</script>
</body>
</html>