feat(routing): add route health admin view

This commit is contained in:
phamnazage-jpg
2026-05-29 13:37:43 +08:00
parent dbfd2ba11c
commit 2896e62071
10 changed files with 1380 additions and 0 deletions

View File

@@ -373,6 +373,7 @@
<nav class="topnav" aria-label="Admin Navigation"> <nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a> <a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a> <a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a> <a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a> <a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a> <a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -251,6 +251,7 @@
<nav class="topnav" aria-label="Admin Navigation"> <nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/" class="is-current">管理首页</a> <a href="/portal/admin/" class="is-current">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a> <a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a> <a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a> <a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a> <a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
@@ -285,6 +286,10 @@
<div class="metric-label">Provider 目录</div> <div class="metric-label">Provider 目录</div>
<div class="metric-value">/providers</div> <div class="metric-value">/providers</div>
</div> </div>
<div class="metric">
<div class="metric-label">Route Health</div>
<div class="metric-value">/route-health</div>
</div>
<div class="metric"> <div class="metric">
<div class="metric-label">Batch Import</div> <div class="metric-label">Batch Import</div>
<div class="metric-value">/batch-import</div> <div class="metric-value">/batch-import</div>
@@ -338,6 +343,27 @@
</ul> </ul>
</article> </article>
<article class="card panel">
<h2>Route 健康视图</h2>
<p>
这页专门给运营看 route 当前运行状态,聚合 <code>routefail</code><code>routecool</code>
最近一次选路与 failover 事件。首版只做只读健康视图,不在这里直接改 route。
</p>
<div class="cta-row">
<a class="cta primary" href="/portal/admin/route-health.html">打开健康页</a>
</div>
<ul class="list">
<li>
<strong>适用动作</strong>
查看 <code>healthy / cooldown / failing / disabled</code>,确认 sticky、failover 与最近错误是否一致。
</li>
<li>
<strong>默认 API Base</strong>
<code>https://sub.tksea.top/portal-admin-api</code>
</li>
</ul>
</article>
<article class="card panel"> <article class="card panel">
<h2>导入供应商帐号</h2> <h2>导入供应商帐号</h2>
<p> <p>

View File

@@ -369,6 +369,7 @@
<nav class="topnav" aria-label="Admin Navigation"> <nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a> <a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a> <a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a> <a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a> <a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a> <a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -360,6 +360,7 @@
<nav class="topnav" aria-label="Admin Navigation"> <nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a> <a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a> <a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a> <a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a> <a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a> <a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -0,0 +1,865 @@
<!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("&", "&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>

View File

@@ -51,6 +51,7 @@ type ActionSet struct {
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error) ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error) AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error) ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error) ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error) RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error) ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
@@ -401,6 +402,9 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
mux.Handle("GET /api/routing/logs/sticky-audit", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("GET /api/routing/logs/sticky-audit", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListRouteStickyAudit(w, r, actions.ListRouteStickyAudit) handleListRouteStickyAudit(w, r, actions.ListRouteStickyAudit)
}))) })))
mux.Handle("GET /api/routing/routes/health", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListRouteHealth(w, r, actions.ListRouteHealth)
})))
mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleResolveRoute(w, r, actions.ResolveRoute) handleResolveRoute(w, r, actions.ResolveRoute)
}))) })))
@@ -1266,6 +1270,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN), ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN), AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN), ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
ResolveRoute: resolveRoute, ResolveRoute: resolveRoute,
RouteChatCompletions: routeChatCompletions, RouteChatCompletions: routeChatCompletions,
ProxyRouteChatCompletions: proxyRouteChatCompletions, ProxyRouteChatCompletions: proxyRouteChatCompletions,

View File

@@ -0,0 +1,295 @@
package app
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"sub2api-cn-relay-manager/internal/routing"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
const (
routeRuntimeStatusHealthy = "healthy"
routeRuntimeStatusCooldown = "cooldown"
routeRuntimeStatusFailing = "failing"
routeRuntimeStatusDisabled = "disabled"
defaultRouteHealthFailoverLimit = 20
)
type ListRouteHealthRequest struct {
LogicalGroupID string
RouteID string
Status string
}
type RouteHealthInfo struct {
Backend string `json:"backend"`
RouteID string `json:"route_id"`
RouteName string `json:"route_name,omitempty"`
LogicalGroupID string `json:"logical_group_id"`
LogicalGroupDisplayName string `json:"logical_group_display_name,omitempty"`
LogicalGroupStatus string `json:"logical_group_status,omitempty"`
ConfiguredStatus string `json:"configured_status,omitempty"`
ShadowHostID string `json:"shadow_host_id"`
ShadowGroupID string `json:"shadow_group_id"`
Priority int `json:"priority"`
Weight int `json:"weight,omitempty"`
RuntimeStatus string `json:"runtime_status"`
FailureCount int `json:"failure_count"`
CooldownUntil string `json:"cooldown_until,omitempty"`
CooldownReason string `json:"cooldown_reason,omitempty"`
LastErrorClass string `json:"last_error_class,omitempty"`
LastSelectedAt string `json:"last_selected_at,omitempty"`
LastUpstreamStatus int `json:"last_upstream_status,omitempty"`
LastRequestID string `json:"last_request_id,omitempty"`
LastPublicModel string `json:"last_public_model,omitempty"`
RecentFailoverCount int `json:"recent_failover_count"`
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func handleListRouteHealth(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)) {
if fn == nil {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-health action is not configured"})
return
}
req, err := decodeListRouteHealthRequest(r)
if err != nil {
writeHTTPError(w, err)
return
}
items, actionErr := fn(r.Context(), req)
if actionErr != nil {
writeHTTPError(w, classifyError(actionErr))
return
}
writeJSON(w, http.StatusOK, map[string]any{"route_health": items})
}
func buildListRouteHealthAction(sqliteDSN string, stickyRuntime stickyStoreRuntime) func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error) {
return func(ctx context.Context, req ListRouteHealthRequest) ([]RouteHealthInfo, error) {
store, err := sqlite.Open(ctx, sqliteDSN)
if err != nil {
return nil, err
}
defer store.Close()
groups, routes, err := loadRouteHealthScope(ctx, store, req)
if err != nil {
return nil, err
}
items := make([]RouteHealthInfo, 0, len(routes))
for _, route := range routes {
group, ok := groups[route.LogicalGroupID]
if !ok {
return nil, fmt.Errorf("logical group %q not found for route %q", route.LogicalGroupID, route.RouteID)
}
item, err := buildRouteHealthInfo(ctx, store, stickyRuntime, group, route)
if err != nil {
return nil, err
}
if req.Status != "" && !strings.EqualFold(item.RuntimeStatus, req.Status) {
continue
}
items = append(items, item)
}
return items, nil
}
}
func decodeListRouteHealthRequest(r *http.Request) (ListRouteHealthRequest, *httpError) {
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && !isSupportedRouteHealthStatus(status) {
return ListRouteHealthRequest{}, &httpError{
StatusCode: http.StatusBadRequest,
Code: "bad_request",
Message: fmt.Sprintf("unsupported route health status %q", status),
}
}
return ListRouteHealthRequest{
LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")),
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
Status: strings.ToLower(status),
}, nil
}
func loadRouteHealthScope(ctx context.Context, store *sqlite.DB, req ListRouteHealthRequest) (map[string]sqlite.LogicalGroup, []sqlite.LogicalGroupRoute, error) {
if req.RouteID != "" {
route, err := store.LogicalGroupRoutes().GetByRouteID(ctx, req.RouteID)
if err != nil {
if err == sql.ErrNoRows {
return map[string]sqlite.LogicalGroup{}, nil, nil
}
return nil, nil, err
}
if req.LogicalGroupID != "" && route.LogicalGroupID != req.LogicalGroupID {
return map[string]sqlite.LogicalGroup{}, nil, nil
}
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, route.LogicalGroupID)
if err != nil {
return nil, nil, err
}
return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, []sqlite.LogicalGroupRoute{route}, nil
}
if req.LogicalGroupID != "" {
group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, req.LogicalGroupID)
if err != nil {
return nil, nil, err
}
routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, req.LogicalGroupID)
if err != nil {
return nil, nil, err
}
return map[string]sqlite.LogicalGroup{group.LogicalGroupID: group}, routes, nil
}
groupRows, err := store.LogicalGroups().List(ctx)
if err != nil {
return nil, nil, err
}
groupMap := make(map[string]sqlite.LogicalGroup, len(groupRows))
routes := make([]sqlite.LogicalGroupRoute, 0)
for _, group := range groupRows {
groupMap[group.LogicalGroupID] = group
groupRoutes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID)
if err != nil {
return nil, nil, err
}
routes = append(routes, groupRoutes...)
}
return groupMap, routes, nil
}
func buildRouteHealthInfo(ctx context.Context, store *sqlite.DB, stickyRuntime stickyStoreRuntime, group sqlite.LogicalGroup, route sqlite.LogicalGroupRoute) (RouteHealthInfo, error) {
failureState, hasFailure, err := stickyRuntime.store.GetRouteFailure(ctx, route.RouteID)
if err != nil {
return RouteHealthInfo{}, err
}
cooldownState, hasCooldown, err := stickyRuntime.store.GetCooldown(ctx, route.RouteID)
if err != nil {
return RouteHealthInfo{}, err
}
// 兼容 route 表上的手工 cooldown 标记,避免健康页漏掉已配置的禁用窗口。
if !hasCooldown && !routeExitsCooldown(route.CooldownUntil) {
hasCooldown = true
cooldownState = routing.RouteCooldownState{
RouteID: route.RouteID,
Reason: "configured_cooldown",
Until: route.CooldownUntil,
}
}
decisionRows, err := store.RouteDecisionLogs().ListRecent(ctx, sqlite.RouteDecisionLogFilter{
SelectedRouteID: route.RouteID,
Limit: 1,
})
if err != nil {
return RouteHealthInfo{}, err
}
failoverFrom, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{
FromRouteID: route.RouteID,
Limit: defaultRouteHealthFailoverLimit,
})
if err != nil {
return RouteHealthInfo{}, err
}
failoverTo, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{
ToRouteID: route.RouteID,
Limit: defaultRouteHealthFailoverLimit,
})
if err != nil {
return RouteHealthInfo{}, err
}
item := RouteHealthInfo{
Backend: stickyRuntime.backend,
RouteID: route.RouteID,
RouteName: route.Name,
LogicalGroupID: route.LogicalGroupID,
LogicalGroupDisplayName: group.DisplayName,
LogicalGroupStatus: group.Status,
ConfiguredStatus: route.Status,
ShadowHostID: route.ShadowHostID,
ShadowGroupID: route.ShadowGroupID,
Priority: route.Priority,
Weight: route.Weight,
RuntimeStatus: deriveRouteRuntimeStatus(group.Status, route.Status, hasCooldown, failureCountFromState(failureState, hasFailure)),
FailureCount: failureCountFromState(failureState, hasFailure),
CooldownUntil: cooldownUntilFromState(cooldownState, hasCooldown),
CooldownReason: cooldownReasonFromState(cooldownState, hasCooldown),
LastErrorClass: lastErrorClassFromStates(failureState, hasFailure, decisionRows),
RecentFailoverCount: len(failoverFrom) + len(failoverTo),
UpstreamBaseURLHint: route.UpstreamBaseURLHint,
UpdatedAt: route.UpdatedAt,
}
if len(decisionRows) > 0 {
item.LastSelectedAt = decisionRows[0].CreatedAt
item.LastUpstreamStatus = decisionRows[0].UpstreamStatus
item.LastRequestID = decisionRows[0].RequestID
item.LastPublicModel = decisionRows[0].PublicModel
}
return item, nil
}
func deriveRouteRuntimeStatus(groupStatus, routeStatus string, hasCooldown bool, failureCount int) string {
if !isActiveStatus(groupStatus) || !isActiveStatus(routeStatus) {
return routeRuntimeStatusDisabled
}
if hasCooldown {
return routeRuntimeStatusCooldown
}
if failureCount > 0 {
return routeRuntimeStatusFailing
}
return routeRuntimeStatusHealthy
}
func failureCountFromState(state routing.RouteFailureState, ok bool) int {
if !ok {
return 0
}
return state.FailureCount
}
func cooldownUntilFromState(state routing.RouteCooldownState, ok bool) string {
if !ok {
return ""
}
return strings.TrimSpace(state.Until)
}
func cooldownReasonFromState(state routing.RouteCooldownState, ok bool) string {
if !ok {
return ""
}
return strings.TrimSpace(state.Reason)
}
func lastErrorClassFromStates(state routing.RouteFailureState, hasFailure bool, decisions []sqlite.RouteDecisionLog) string {
if hasFailure && strings.TrimSpace(state.LastErrorClass) != "" {
return strings.TrimSpace(state.LastErrorClass)
}
if len(decisions) == 0 {
return ""
}
return strings.TrimSpace(decisions[0].ErrorClass)
}
func isSupportedRouteHealthStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case routeRuntimeStatusHealthy, routeRuntimeStatusCooldown, routeRuntimeStatusFailing, routeRuntimeStatusDisabled:
return true
default:
return false
}
}

View File

@@ -0,0 +1,160 @@
package app
import (
"context"
"net/http"
"path/filepath"
"testing"
)
func TestAPIListRouteHealthRejectsInvalidStatus(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
ListRouteHealth: func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error) {
t.Fatal("ListRouteHealth should not be called")
return nil, nil
},
})
request := httptestRequest(t, http.MethodGet, "/api/routing/routes/health?status=broken", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusBadRequest)
assertJSONContains(t, response.Body().Bytes(), "error.code", "bad_request")
}
func TestNewActionSetRouteHealthFlow(t *testing.T) {
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-health.db")) + "?_busy_timeout=5000"
actions := NewActionSet(dsn)
ctx := context.Background()
if _, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
RoutePolicy: "priority",
StickyMode: "conversation_preferred",
ConversationTTLSeconds: 1200,
UserModelTTLSeconds: 600,
FailoverThreshold: 2,
CooldownSeconds: 300,
}); err != nil {
t.Fatalf("CreateLogicalGroup(gpt-shared) error = %v", err)
}
createRoute := func(routeID, name, status, shadowGroup string, priority int) {
t.Helper()
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
LogicalGroupID: "gpt-shared",
RouteID: routeID,
Name: name,
Status: status,
Priority: priority,
ShadowGroupID: shadowGroup,
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://example.com/v1",
}); err != nil {
t.Fatalf("CreateLogicalGroupRoute(%s) error = %v", routeID, err)
}
}
createRoute("healthy-route", "Healthy", "active", "shadow-healthy", 10)
createRoute("failing-route", "Failing", "active", "shadow-failing", 20)
createRoute("cooldown-route", "Cooldown", "active", "shadow-cooldown", 30)
createRoute("disabled-route", "Disabled", "disabled", "shadow-disabled", 40)
if _, err := actions.AppendRouteDecisionLog(ctx, AppendRouteDecisionLogRequest{
RequestID: "req-healthy",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
SelectedRouteID: "healthy-route",
SelectedShadowGroupID: "shadow-healthy",
UpstreamStatus: 200,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteDecisionLog(healthy) error = %v", err)
}
if _, err := actions.AppendRouteDecisionLog(ctx, AppendRouteDecisionLogRequest{
RequestID: "req-failing",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
SelectedRouteID: "failing-route",
SelectedShadowGroupID: "shadow-failing",
ErrorClass: "upstream_5xx",
UpstreamStatus: 503,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteDecisionLog(failing) error = %v", err)
}
if _, err := actions.AppendRouteFailoverEvent(ctx, AppendRouteFailoverEventRequest{
RequestID: "req-failover",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
FromRouteID: "failing-route",
ToRouteID: "healthy-route",
Reason: "failure_threshold_exceeded:timeout",
FailureCount: 2,
Sync: true,
}); err != nil {
t.Fatalf("AppendRouteFailoverEvent() error = %v", err)
}
if _, err := actions.SetRouteFailure(ctx, SetRouteFailureRequest{
RouteID: "failing-route",
FailureCount: 2,
LastErrorClass: "timeout",
TTLSeconds: 600,
}); err != nil {
t.Fatalf("SetRouteFailure() error = %v", err)
}
if _, err := actions.SetRouteCooldown(ctx, SetRouteCooldownRequest{
RouteID: "cooldown-route",
Reason: "degraded",
TTLSeconds: 600,
}); err != nil {
t.Fatalf("SetRouteCooldown() error = %v", err)
}
items, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{})
if err != nil {
t.Fatalf("ListRouteHealth() error = %v", err)
}
if len(items) != 4 {
t.Fatalf("ListRouteHealth() len = %d, want 4", len(items))
}
byRoute := make(map[string]RouteHealthInfo, len(items))
for _, item := range items {
byRoute[item.RouteID] = item
}
if got := byRoute["healthy-route"]; got.RuntimeStatus != routeRuntimeStatusHealthy || got.LastUpstreamStatus != 200 {
t.Fatalf("healthy-route = %+v, want healthy with upstream 200", got)
}
if got := byRoute["failing-route"]; got.RuntimeStatus != routeRuntimeStatusFailing || got.FailureCount != 2 || got.LastErrorClass != "timeout" || got.RecentFailoverCount != 1 || got.LastUpstreamStatus != 503 {
t.Fatalf("failing-route = %+v, want failing with failure_count=2 recent_failover_count=1 upstream=503", got)
}
if got := byRoute["cooldown-route"]; got.RuntimeStatus != routeRuntimeStatusCooldown || got.CooldownReason != "degraded" || got.CooldownUntil == "" {
t.Fatalf("cooldown-route = %+v, want cooldown with reason degraded", got)
}
if got := byRoute["disabled-route"]; got.RuntimeStatus != routeRuntimeStatusDisabled {
t.Fatalf("disabled-route = %+v, want disabled", got)
}
failingOnly, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{Status: routeRuntimeStatusFailing})
if err != nil {
t.Fatalf("ListRouteHealth(failing) error = %v", err)
}
if len(failingOnly) != 1 || failingOnly[0].RouteID != "failing-route" {
t.Fatalf("ListRouteHealth(failing) = %+v, want only failing-route", failingOnly)
}
oneRoute, err := actions.ListRouteHealth(ctx, ListRouteHealthRequest{RouteID: "cooldown-route"})
if err != nil {
t.Fatalf("ListRouteHealth(route_id) error = %v", err)
}
if len(oneRoute) != 1 || oneRoute[0].RouteID != "cooldown-route" {
t.Fatalf("ListRouteHealth(route_id) = %+v, want only cooldown-route", oneRoute)
}
}

View File

@@ -170,6 +170,7 @@ remote: ${REMOTE}
portal url: https://sub.tksea.top/portal/ portal url: https://sub.tksea.top/portal/
portal admin home url: https://sub.tksea.top/portal/admin/ portal admin home url: https://sub.tksea.top/portal/admin/
logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html
route health admin url: https://sub.tksea.top/portal/admin/route-health.html
provider admin url: https://sub.tksea.top/portal/admin/providers.html provider admin url: https://sub.tksea.top/portal/admin/providers.html
batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html
batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html

View File

@@ -6,6 +6,7 @@ HTML_FILE="$ROOT_DIR/deploy/tksea-portal/index.html"
ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html" ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html" ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html"
ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html" ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html"
ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html"
ADMIN_PROVIDERS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/providers.html" ADMIN_PROVIDERS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/providers.html"
ADMIN_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html" ADMIN_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html"
NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example" NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example"
@@ -28,6 +29,7 @@ assert_contains_file() {
[[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE" [[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE"
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE" [[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE"
[[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE" [[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE"
[[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE"
[[ -f "$ADMIN_PROVIDERS_FILE" ]] || fail "missing $ADMIN_PROVIDERS_FILE" [[ -f "$ADMIN_PROVIDERS_FILE" ]] || fail "missing $ADMIN_PROVIDERS_FILE"
[[ -f "$ADMIN_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_FILE" [[ -f "$ADMIN_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_FILE"
[[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE" [[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE"
@@ -69,15 +71,18 @@ assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal" assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html" assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html" assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM" assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由" assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由"
assert_contains_file "$ADMIN_HOME_FILE" "Route 健康视图"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login"
@@ -91,9 +96,27 @@ assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "首版页面只覆盖新增
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" 'credentials: "include"' assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/routing/routes/health"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "healthy"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "cooldown"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "failing"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin" assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录" assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
@@ -121,6 +144,7 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html" assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录" assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login" assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout" assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session" assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
@@ -137,6 +161,7 @@ assert_contains_file "$NGINX_FILE" "location /kimi-portal-proxy/"
assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/" assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/"
assert_contains_file "$DEPLOY_SCRIPT" "portal admin home url: https://sub.tksea.top/portal/admin/" assert_contains_file "$DEPLOY_SCRIPT" "portal admin home url: https://sub.tksea.top/portal/admin/"
assert_contains_file "$DEPLOY_SCRIPT" "logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html" assert_contains_file "$DEPLOY_SCRIPT" "logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html"
assert_contains_file "$DEPLOY_SCRIPT" "route health admin url: https://sub.tksea.top/portal/admin/route-health.html"
assert_contains_file "$DEPLOY_SCRIPT" "provider admin url: https://sub.tksea.top/portal/admin/providers.html" assert_contains_file "$DEPLOY_SCRIPT" "provider admin url: https://sub.tksea.top/portal/admin/providers.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html" assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html" assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html"