feat(routing): add route health admin view
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
865
deploy/tksea-portal/admin/route-health.html
Normal file
865
deploy/tksea-portal/admin/route-health.html
Normal 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("&", "&")
|
||||||
|
.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>
|
||||||
@@ -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,
|
||||||
|
|||||||
295
internal/app/route_health_api.go
Normal file
295
internal/app/route_health_api.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
160
internal/app/route_health_api_test.go
Normal file
160
internal/app/route_health_api_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user