866 lines
30 KiB
HTML
866 lines
30 KiB
HTML
<!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/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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
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} 条 route:healthy=${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>
|