@@ -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 26 px 72 px rgba ( 43 , 53 , 38 , 0.1 ) ;
--radius : 24 px ;
--radius-sm : 16 px ;
--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 24 rem ) ,
radial-gradient ( circle at bottom right , rgba ( 24 , 112 , 78 , 0.12 ) , transparent 24 rem ) ,
linear-gradient ( 180 deg , #f5f8f0 0 % , #e8ede3 100 % ) ;
}
a { color : inherit ; }
code , pre { font-family : var ( - - font - mono ) ; }
. shell {
max-width : 1480 px ;
margin : 0 auto ;
padding : 34 px 20 px 64 px ;
}
. topnav {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
margin-bottom : 18 px ;
}
. topnav a {
text-decoration : none ;
padding : 10 px 14 px ;
border-radius : 999 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.78 ) ;
color : var ( - - muted ) ;
font-size : 13 px ;
font-weight : 700 ;
transition : transform 120 ms ease , background 120 ms ease ;
}
. topnav a : hover { transform : translateY ( -1 px ) ; background : #fff ; }
. topnav a . is-current {
background : var ( - - ink ) ;
border-color : var ( - - ink ) ;
color : #fff ;
}
. hero {
display : grid ;
grid-template-columns : 1.2 fr 0.8 fr ;
gap : 18 px ;
margin-bottom : 18 px ;
}
. card {
background : var ( - - panel ) ;
border : 1 px solid var ( - - line ) ;
border-radius : var ( - - radius ) ;
box-shadow : var ( - - shadow ) ;
}
. hero-card , . panel { padding : 26 px ; }
. hero-card {
position : relative ;
overflow : hidden ;
}
. hero-card :: after {
content : "" ;
position : absolute ;
right : -4 rem ;
bottom : -4 rem ;
width : 18 rem ;
height : 18 rem ;
border-radius : 999 px ;
background : linear-gradient ( 135 deg , rgba ( 15 , 109 , 113 , 0.18 ) , rgba ( 24 , 112 , 78 , 0.06 ) ) ;
filter : blur ( 10 px ) ;
}
. eyebrow {
display : inline-flex ;
align-items : center ;
padding : 8 px 12 px ;
border-radius : 999 px ;
background : var ( - - accent - soft ) ;
color : var ( - - accent ) ;
font-size : 12 px ;
font-weight : 800 ;
letter-spacing : 0.06 em ;
text-transform : uppercase ;
}
h1 {
margin : 18 px 0 10 px ;
font-size : clamp ( 32 px , 4 vw , 46 px ) ;
line-height : 1.02 ;
letter-spacing : -0.05 em ;
}
h2 {
margin : 0 0 8 px ;
font-size : 24 px ;
letter-spacing : -0.04 em ;
}
h3 {
margin : 0 0 8 px ;
font-size : 18 px ;
letter-spacing : -0.03 em ;
}
. hero-copy , . panel-desc {
color : var ( - - muted ) ;
line-height : 1.75 ;
font-size : 15 px ;
}
. hero-points {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
margin : 18 px 0 0 ;
padding : 0 ;
list-style : none ;
}
. hero-points li {
padding : 8 px 12 px ;
border-radius : 999 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.78 ) ;
font-size : 13 px ;
font-weight : 700 ;
}
. metrics {
display : grid ;
gap : 12 px ;
align-content : start ;
}
. metric {
border-radius : 20 px ;
border : 1 px solid var ( - - line ) ;
background : #fff ;
padding : 16 px ;
}
. metric-label {
color : var ( - - muted ) ;
font-size : 12 px ;
letter-spacing : 0.05 em ;
text-transform : uppercase ;
}
. metric-value {
margin-top : 8 px ;
font-size : 24 px ;
font-weight : 800 ;
letter-spacing : -0.04 em ;
word-break : break-word ;
}
. layout {
display : grid ;
grid-template-columns : 420 px minmax ( 0 , 1 fr ) ;
gap : 18 px ;
}
. stack , . section , . list {
display : grid ;
gap : 18 px ;
}
. field-grid {
display : grid ;
gap : 12 px ;
}
. field-grid . two {
grid-template-columns : 1 fr 1 fr ;
}
label {
display : grid ;
gap : 7 px ;
color : var ( - - muted ) ;
font-size : 13 px ;
font-weight : 700 ;
}
input , select , textarea {
width : 100 % ;
border : 1 px solid var ( - - line ) ;
border-radius : 14 px ;
padding : 12 px 14 px ;
font : inherit ;
color : var ( - - ink ) ;
background : #fff ;
}
textarea {
min-height : 120 px ;
resize : vertical ;
}
. actions , . mini-actions {
display : flex ;
flex-wrap : wrap ;
gap : 10 px ;
}
button {
border : 0 ;
cursor : pointer ;
border-radius : 999 px ;
padding : 12 px 18 px ;
font : inherit ;
font-weight : 800 ;
transition : transform 120 ms ease , opacity 120 ms ease , background 120 ms ease ;
}
button : hover { transform : translateY ( -1 px ) ; }
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 : 1 px solid var ( - - line ) ;
background : transparent ;
color : var ( - - muted ) ;
}
. statusbar {
margin-top : 16 px ;
min-height : 54 px ;
padding : 14 px 16 px ;
border-radius : 16 px ;
border : 1 px solid var ( - - line ) ;
background : #fff ;
display : flex ;
align-items : center ;
color : var ( - - muted ) ;
font-size : 14 px ;
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 : 12 px ;
max-height : 34 rem ;
overflow : auto ;
padding-right : 4 px ;
}
. catalog-item {
padding : 16 px ;
border-radius : 18 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.84 ) ;
cursor : pointer ;
transition : transform 120 ms ease , border-color 120 ms ease , background 120 ms ease ;
}
. catalog-item : hover {
transform : translateY ( -1 px ) ;
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 : 6 px ;
font-size : 16 px ;
}
. catalog-meta , . detail-grid {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
margin-top : 10 px ;
}
. pill {
display : inline-flex ;
align-items : center ;
padding : 6 px 10 px ;
border-radius : 999 px ;
background : rgba ( 255 , 255 , 255 , 0.72 ) ;
border : 1 px solid var ( - - line ) ;
font-size : 12 px ;
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 : 1 fr 1 fr ;
gap : 18 px ;
}
. list-card {
padding : 16 px ;
border-radius : 16 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 255 , 255 , 255 , 0.82 ) ;
}
. list-card strong {
display : block ;
margin-bottom : 6 px ;
}
. empty {
color : var ( - - muted ) ;
font-size : 13 px ;
line-height : 1.6 ;
}
. inline-code {
font-family : var ( - - font - mono ) ;
font-size : 12 px ;
color : var ( - - muted ) ;
word-break : break-word ;
}
pre {
margin : 0 ;
padding : 16 px ;
border-radius : 16 px ;
border : 1 px solid var ( - - line ) ;
background : rgba ( 20 , 27 , 23 , 0.96 ) ;
color : #e9f3ea ;
font-size : 12 px ;
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 : 1 fr ; }
}
< / 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 >