feat(routing): add route health admin view
This commit is contained in:
@@ -373,6 +373,7 @@
|
||||
<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">Route 健康视图</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/" class="is-current">管理首页</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/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
@@ -285,6 +286,10 @@
|
||||
<div class="metric-label">Provider 目录</div>
|
||||
<div class="metric-value">/providers</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-label">Batch Import</div>
|
||||
<div class="metric-value">/batch-import</div>
|
||||
@@ -338,6 +343,27 @@
|
||||
</ul>
|
||||
</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">
|
||||
<h2>导入供应商帐号</h2>
|
||||
<p>
|
||||
|
||||
@@ -369,6 +369,7 @@
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</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/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
<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">Route 健康视图</a>
|
||||
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</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)
|
||||
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
|
||||
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
|
||||
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
|
||||
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
|
||||
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, 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) {
|
||||
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) {
|
||||
handleResolveRoute(w, r, actions.ResolveRoute)
|
||||
})))
|
||||
@@ -1266,6 +1270,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
|
||||
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
|
||||
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
|
||||
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
|
||||
ResolveRoute: resolveRoute,
|
||||
RouteChatCompletions: routeChatCompletions,
|
||||
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 admin home url: https://sub.tksea.top/portal/admin/
|
||||
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
|
||||
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_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.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_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html"
|
||||
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_HOME_FILE" ]] || fail "missing $ADMIN_HOME_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_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_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" "/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/batch-import.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
|
||||
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" "/portal/admin/"
|
||||
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/batch-import.html"
|
||||
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" "/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" "管理员登录"
|
||||
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/logout"
|
||||
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_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/logout"
|
||||
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 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" "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" "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