@@ -8,62 +8,101 @@
* { margin : 0 ; padding : 0 ; box-sizing : border-box }
* { margin : 0 ; padding : 0 ; box-sizing : border-box }
: root { --bg : #060a10 ; --card : #0d1520 ; --card2 : #111c2e ; --border : #1a2740 ; --txt : #c8d6e5 ; --dim : #5a6f88 ; --neon : #00e5ff ; --green : #00ff88 ; --warn : #ffb020 ; --err : #ff4466 ; --purple : #b8a9ff ; --peach : #ffb088 }
: root { --bg : #060a10 ; --card : #0d1520 ; --card2 : #111c2e ; --border : #1a2740 ; --txt : #c8d6e5 ; --dim : #5a6f88 ; --neon : #00e5ff ; --green : #00ff88 ; --warn : #ffb020 ; --err : #ff4466 ; --purple : #b8a9ff ; --peach : #ffb088 }
[ data-theme = "light" ] { --bg : #f0f2f5 ; --card : #fff ; --card2 : #f5f7fa ; --border : #d8dde6 ; --txt : #1a1a2e ; --dim : #6b7b8d ; --neon : #0088cc ; --green : #00a856 ; --warn : #e69500 ; --err : #d63050 ; --purple : #7c5cbf ; --peach : #d07040 }
[ data-theme = "light" ] { --bg : #f0f2f5 ; --card : #fff ; --card2 : #f5f7fa ; --border : #d8dde6 ; --txt : #1a1a2e ; --dim : #6b7b8d ; --neon : #0088cc ; --green : #00a856 ; --warn : #e69500 ; --err : #d63050 ; --purple : #7c5cbf ; --peach : #d07040 }
body { font-family : 'SF Mono ', Menl o, 'Courier New ' , monospace ; background : var ( - - bg ) ; color : var ( - - txt ) ; padding : 16 px }
body { font-family : - apple-system , BlinkMacSystemFont , 'Segoe UI ', Robot o, 'Helvetica Neue ' , Arial , sans-serif ; background : var ( - - bg ) ; color : var ( - - txt ) ; padding : 20 px }
. wrap { max-width : 1200 px ; margin : 0 auto }
. wrap { max-width : 1200 px ; margin : 0 auto }
h1 { font-size : 1.3 em ; color : var ( - - neon ) ; margin -bottom: 4 px }
. hdr { margin-bottom : 24 px ; padding : 20 px 0 16 px ; border -bottom: 1 px solid var ( - - border ) }
. sub { color : var ( - - dim ) ; font-size : .75 em ; margin-bottom : 20 px }
. hdr h1 { font-size : 1.8 em ; font-weight : 800 ; background : linear-gradient ( 135 deg , var ( - - neon ) , var ( - - purple ) ) ; -webkit- background-clip : text ; -webkit- text-fill-color : transparent ; background-clip : text ; display : inline }
. stats { display : grid ; grid-template-columns : repeat ( 6 , 1 fr ) ; gap : 10 px ; margin-bottom : 20 px }
. hdr-row { display : flex ; align-items : center ; gap : 12 px ; flex-wrap : wrap }
. st { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; padding : 14 px ; text-align : center }
. hdr-links { display : flex ; gap : 10 px ; align-items : center ; margin-left : auto }
. st . n { font-size : 1.5 em ; font-weight : 700 } . st . l { font-size : .7 em ; color : var ( - - dim ) ; margin-top : 2 px }
. hdr-links a , . hdr-links span { font-size : .8 em ; color : var ( - - dim ) ; text-decoration : none ; cursor : pointer }
. hdr-links a : hover , . hdr-links span : hover { color : var ( - - neon ) }
. sub { color : var ( - - dim ) ; font-size : .78 em ; margin-top : 6 px ; letter-spacing : .3 px }
. stats { display : grid ; grid-template-columns : repeat ( 6 , 1 fr ) ; gap : 10 px ; margin-bottom : 24 px }
. st { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 16 px ; text-align : center }
. st . n { font-size : 1.6 em ; font-weight : 800 ; font-family : 'SF Mono' , Menlo , monospace } . st . l { font-size : .72 em ; color : var ( - - dim ) ; margin-top : 4 px ; font-weight : 500 }
. s1 . n { color : var ( - - neon ) } . s2 . n { color : var ( - - purple ) } . s3 . n { color : var ( - - peach ) }
. s1 . n { color : var ( - - neon ) } . s2 . n { color : var ( - - purple ) } . s3 . n { color : var ( - - peach ) }
. s4 . n { color : var ( - - green ) } . s5 . n { color : var ( - - warn ) } . s6 . n { color : var ( - - err ) }
. s4 . n { color : var ( - - green ) } . s5 . n { color : var ( - - warn ) } . s6 . n { color : var ( - - err ) }
. tabs { display : flex ; gap : 0 ; margin-bottom : 20 px ; border-bottom : 1 px solid var ( - - border ) }
. tabs { display : flex ; gap : 0 ; margin-bottom : 24 px ; border-bottom : 1 px solid var ( - - border ) }
. tab { padding : 8 px 20 px ; cursor : pointer ; color : var ( - - dim ) ; font-size : .85 em ; border-bottom : 2 px solid transparent }
. tab { padding : 10 px 24 px ; cursor : pointer ; color : var ( - - dim ) ; font-size : .88 em ; font-weight : 500 ; border-bottom: 2 px solid transparent ; transition : color .2 s }
. tab . on { color : var ( - - neon ) ; border-bottom-color : var ( - - neon ) }
. tab . on { color : var ( - - neon ) ; border-bottom-color : var ( - - neon ) }
. tab : hover { color : var ( - - txt ) }
. tp { display : none } . tp . on { display : block }
. tp { display : none } . tp . on { display : block }
. nodes { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 34 0 px , 1 fr ) ) ; gap : 14 px }
. nodes { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 36 0 px , 1 fr ) ) ; gap : 14 px }
. nd { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 16 px }
. nd { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; padding : 18 px ; display : flex ; flex-direction : column }
. nd . offline { border-color : rgba ( 255 , 68 , 102 , .3 ) }
. nd . offline { border-color : rgba ( 255 , 68 , 102 , .3 ) }
. nd-h { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 8 px }
. nd-h { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 8 px }
. nd-nm { font-size : 1.05 em ; font-weight : 600 ; cursor : pointer } . nd-nm : hover { color : var ( - - neon ) }
. nd-nm { font-size : 1.05 em ; font-weight : 600 ; cursor : pointer } . nd-nm : hover { color : var ( - - neon ) }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; display : inline-block ; margin-right : 5 px }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; display : inline-block ; margin-right : 5 px }
. dot . on { background : var ( - - green ) ; box-shadow : 0 0 6 px var ( - - green ) } . dot . off { background : var ( - - err ) }
. dot . on { background : var ( - - green ) ; box-shadow : 0 0 6 px var ( - - green ) } . dot . off { background : var ( - - err ) }
. tg { display : flex ; gap : 5 px ; flex-wrap : wrap ; margin-bottom : 8 px }
. tg { display : flex ; gap : 4 px ; flex-wrap : no wrap; margin-bottom : 8 px ; overflow : hidden }
. tg span { font-size : .63 em ; padding : 2 px 7 px ; border-radius : 10 px ; background : var ( - - card2 ) ; color : var ( - - dim ) ; border : 1 px solid var ( - - border ) }
. tg span { font-size : .65 em ; padding : 2 px 8 px ; border-radius : 10 px ; background : var ( - - card2 ) ; color : var ( - - dim ) ; border : 1 px solid var ( - - border ) ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; max-width : 120 px }
. tg . ms { color : var ( - - neon ) ; border-color : rgba ( 0 , 229 , 255 , .3 ) }
. tg . ms { color : var ( - - neon ) ; border-color : rgba ( 0 , 229 , 255 , .3 ) }
. tg . wk { color : var ( - - purple ) ; border-color : rgba ( 184 , 169 , 255 , .3 ) }
. tg . wk { color : var ( - - purple ) ; border-color : rgba ( 184 , 169 , 255 , .3 ) }
. hb { height : 28 px ; display : flex ; align-items : end ; gap : 1 px ; overflow : hidden ; margin-bottom : 8 px }
. hb { height : 28 px ; display : flex ; align-items : end ; gap : 1 px ; overflow : hidden ; margin-bottom : 8 px }
. hb i { width : 3 px ; border-radius : 1 px }
. hb i { width : 3 px ; border-radius : 1 px }
. sec { font-size : .72 em ; color : var ( - - dim ) ; margin-bottom : 4 px }
. sec { font-size : .72 em ; color : var ( - - dim ) ; margin-bottom : 4 px }
. pv{ display : flex ; justify-content : space-between ; padding : 3 px 0 ; font-size : .7 8em }
. pv-wrap { flex : 1 ; min-height : 0 ; overflow-y : auto ; margin-bottom : 8px }
. pv { display : flex ; justify-content : space-between ; align-items : center ; padding : 4 px 0 ; font-size : .78 em }
. pv-l { display : flex ; gap : 5 px ; align-items : center }
. pv-l { display : flex ; gap : 5 px ; align-items : center }
. pv-default { color : var ( - - warn ) }
. pv-default { color : var ( - - warn ) }
. pm { color : var ( - - dim ) ; font-size : .85 em }
. pm { color : var ( - - dim ) ; font-size : .85 em }
. ok { color : var ( - - green ) } . er { color : var ( - - err ) }
. ok { color : var ( - - green ) } . er { color : var ( - - err ) }
. gs { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ) ; gap : 8 px ; margin : 8 px 0 }
. gs { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ) ; gap : 10 px ; margin : 10 px 0 }
. g { font-size : .7 em } . g-l { color : var ( - - dim ) } . g-t { height : 4 px ; background : var ( - - card2 ) ; border-radius : 2 px ; margin : 2 px 0 }
. g { font-size : .72 em } . g-l { color : var ( - - dim ) ; font-weight : 500 }. g-t { height : 5 px ; background : var ( - - card2 ) ; border-radius : 3 px ; margin : 3 px 0 }
. g-f { height : 100 % ; border-radius : 2 px ; transition : width .8 s ease , background .5 s } . fg { background : var ( - - green ) } . fb { background : var ( - - neon ) } . fw { background : var ( - - warn ) } . fr { background : var ( - - err ) }
. g-f { height : 100 % ; border-radius : 2 px ; transition : width .8 s ease , background .5 s } . fg { background : var ( - - green ) } . fb { background : var ( - - neon ) } . fw { background : var ( - - warn ) } . fr { background : var ( - - err ) }
. g-n { font-weight : 600 }
. g-n { font-weight : 600 }
. tks { display : flex ; gap : 10 px ; margin : 8 px 0 }
. tks { display : flex ; gap : 10 px ; margin : 10 px 0 }
. tk { flex : 1 ; text-align : center ; background : var ( - - card2 ) ; border-radius : 6 px ; padding : 5 px }
. tk { flex : 1 ; text-align : center ; background : var ( - - card2 ) ; border-radius : 8 px ; padding : 8 px 5 px }
. tk-l { font-size : .63 em ; color : var ( - - dim ) } . tk-v { font-size : .85 em ; font-weight : 6 00; color : var ( - - neon ) }
. tk-l { font-size : .65 em ; color : var ( - - dim ) ; font-weight : 500 }. tk-v { font-size : .9 em ; font-weight : 7 00; color : var ( - - neon ) ; font-family : 'SF Mono' , Menlo , monospace }
. nd-f { display : flex ; gap : 8 px ; font-size : .68 em ; color : var ( - - dim ) ; flex-wrap : wrap }
. nd-f { display : flex ; gap : 10 px ; font-size : .7 em ; color : var ( - - dim ) ; flex-wrap : wrap ; margin-top : auto ; padding-top : 8 px ; border-top : 1 px solid var ( - - border ) }
. mx { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 16 px }
. mx { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 16 px }
. mx-h { font-size : .9 em ; font-weight : 600 ; margin-bottom : 10 px ; color : var ( - - neon ) }
. mx-h { font-size : .9 em ; font-weight : 600 ; margin-bottom : 10 px ; color : var ( - - neon ) }
table { width : 100 % ; border-collapse : collapse ; font-size : .7 8 em }
table { width : 100 % ; border-collapse : collapse ; font-size : .8 em }
th { text-align : left ; padding : 6 px 10 px ; color : var ( - - dim ) ; border-bottom : 1 px solid var ( - - border ) ; font-weight : 5 00}
th { text-align : left ; padding : 8 px 10 px ; color : var ( - - dim ) ; border-bottom : 1 px solid var ( - - border ) ; font-weight : 6 00}
td { padding : 6 px 10 px ; border-bottom : 1 px solid rgba ( 26 , 39 , 64 , .5 ) }
td { padding : 8 px 10 px ; border-bottom : 1 px solid rgba ( 26 , 39 , 64 , .5 ) }
. lt { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 16 px }
. lt { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; padding : 18 px }
. lt-h { font-size : .9 em ; font-weight : 600 ; margin-bottom : 10 px ; color : var ( - - purple ) }
. lt-h { font-size : .95 em ; font-weight : 600 ; margin-bottom : 12 px ; color : var ( - - purple ) }
. lf { display : flex ; gap : 10 px ; margin-bottom : 12 px ; flex-wrap : wrap }
. lf { display : flex ; gap : 10 px ; margin-bottom : 12 px ; flex-wrap : wrap }
. lf select { background : var ( - - card2 ) ; border : 1 px solid var ( - - border ) ; color : var ( - - txt ) ; padding : 4 px 10 px ; border-radius : 6 px ; font-size : .7 8 em ; font-family : inherit }
. lf select { background : var ( - - card2 ) ; border : 1 px solid var ( - - border ) ; color : var ( - - txt ) ; padding : 6 px 12 px ; border-radius : 8 px ; font-size : .8 em ; font-family : inherit }
@ media ( max-width : 768px ) { . stats { grid-template-columns : repeat ( 3 , 1 fr ) } . nodes { grid-template-columns : 1 fr } }
. pager { display : flex ; justify-content : center ; align-items : center ; gap : 8 px ; margin-top : 14 px ; font-size : .78 em }
. pager button { background : var ( - - card2 ) ; border : 1 px solid var ( - - border ) ; color : var ( - - txt ) ; padding : 5 px 14 px ; border-radius : 8 px ; cursor : pointer ; font-family : inherit ; transition : border-color .2 s }
. pager button : disabled { opacity : .3 ; cursor : default }
. pager button . pg-on { border-color : var ( - - neon ) ; color : var ( - - neon ) }
. pager span { color : var ( - - dim ) }
. provs { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 360 px , 1 fr ) ) ; gap : 14 px }
. pcard { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; padding : 18 px }
. pcard-h { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 12 px ; padding-bottom : 8 px ; border-bottom : 1 px solid var ( - - border ) }
. pcard-nm { font-size : 1.1 em ; font-weight : 700 ; color : var ( - - neon ) }
. pcard-cnt { font-size : .7 em ; color : var ( - - dim ) }
. pcard-row { display : flex ; justify-content : space-between ; align-items : center ; padding : 5 px 0 ; font-size : .8 em ; border-bottom : 1 px solid rgba ( 26 , 39 , 64 , .3 ) }
. pcard-row : last-child { border-bottom : none }
. pcard-node { color : var ( - - dim ) }
. pcard-model { color : var ( - - purple ) ; font-size : .68 em ; line-height : 1.4 }
. pcard-models { margin-top : 2 px }
. pcard-ms { color : var ( - - dim ) ; font-size : .75 em }
@ media ( max-width : 768px ) { . stats { grid-template-columns : repeat ( 3 , 1 fr ) } . nodes { grid-template-columns : 1 fr } . provs { grid-template-columns : 1 fr } . hdr h1 { font-size : 1.4 em } }
. login-mask { position : fixed ; inset : 0 ; background : var ( - - bg ) ; display : flex ; align-items : center ; justify-content : center ; z-index : 999 }
. login-box { background : var ( - - card ) ; border : 1 px solid var ( - - border ) ; border-radius : 16 px ; padding : 40 px ; text-align : center ; max-width : 360 px ; width : 90 % }
. login-box h2 { font-size : 1.4 em ; font-weight : 800 ; background : linear-gradient ( 135 deg , var ( - - neon ) , var ( - - purple ) ) ; -webkit- background-clip : text ; -webkit- text-fill-color : transparent ; background-clip : text ; margin-bottom : 8 px }
. login-box p { color : var ( - - dim ) ; font-size : .82 em ; margin-bottom : 20 px }
. login-box input { width : 100 % ; padding : 10 px 14 px ; background : var ( - - card2 ) ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; color : var ( - - txt ) ; font-size : .9 em ; font-family : inherit ; outline : none }
. login-box input : focus { border-color : var ( - - neon ) }
. login-box button { width : 100 % ; margin-top : 12 px ; padding : 10 px ; background : linear-gradient ( 135 deg , var ( - - neon ) , var ( - - purple ) ) ; border : none ; border-radius : 8 px ; color : #fff ; font-weight : 600 ; font-size : .9 em ; cursor : pointer }
. login-err { color : var ( - - err ) ; font-size : .78 em ; margin-top : 8 px ; display : none }
< / style >
< / style >
< / head >
< / head >
< body >
< body >
< div class = "wrap " >
< div class = "login-mask" id = "loginMask" style = "display:none " >
< h1 > 🐾 OpenClaw Mission Control < a href = "/admin.html" style = "font-size:.5em;color:var(--dim);text-decoration:none;margin-left:8px" > ⚙️ Admin< / a > < span id = "themeBtn" onclick = "toggleTheme()" style = "font-size:.5em;cursor:pointer;margin-left:8px" > 🌙 < / span > < / h1 >
< div class = "login-box" > < h2 > 🔓 解锁完整视图< / h2 > < p > 输入令牌查看 IP 等敏感信息< / p >
< input id = "tokenInput" type = "password" placeholder = "Token" onkeydown = "if(event.key==='Enter')doLogin()" >
< button onclick = "doLogin()" > 解锁< / button >
< div class = "login-err" id = "loginErr" > 令牌无效< / div > < / div > < / div >
< div class = "wrap" id = "mainWrap" >
< div class = "hdr" >
< div class = "hdr-row" > < h1 > 🐾 OpenClaw Mission Control< / h1 >
< div class = "hdr-links" > < a href = "/admin.html" > ⚙️ Admin< / a > < span id = "lockBtn" onclick = "$('#loginMask').style.display='flex'" style = "cursor:pointer" > 🔒< / span > < span id = "themeBtn" onclick = "toggleTheme()" > 🌙< / span > < / div > < / div >
< div class = "sub" id = "subtitle" > Loading...< / div >
< div class = "sub" id = "subtitle" > Loading...< / div >
< / div >
< div class = "stats" id = "stats" > < / div >
< div class = "stats" id = "stats" > < / div >
< div class = "tabs" >
< div class = "tabs" >
@@ -73,17 +112,29 @@ td{padding:6px 10px;border-bottom:1px solid rgba(26,39,64,.5)}
< / div >
< / div >
< div class = "tp on" id = "t-nodes" > < div class = "nodes" id = "nodeGrid" > < / div > < / div >
< div class = "tp on" id = "t-nodes" > < div class = "nodes" id = "nodeGrid" > < / div > < / div >
< div class = "tp" id = "t-matrix" > < div class = "mx" > < div class = "mx-h" > 供应商 × 节点 矩阵< / div > < table id = "matrixTable" > < / table > < / div > < / div >
< div class = "tp" id = "t-matrix" > < div class = "provs" id = "provGrid" > < / div > < / div >
< div class = "tp" id = "t-logs" > < div class = "lt" > < div class = "lt-h" > 请求日志< / div >
< div class = "tp" id = "t-logs" > < div class = "lt" > < div class = "lt-h" > 请求日志< / div >
< div class = "lf" > < select id = "fNode" onchange = "renderLogs()" > < option value = "" > 全部节点< / option > < / select > < select id = "fUp" onchange = "renderLogs()" > < option value = "" > 全部供应商< / option > < / select > < select id = "fRes" onchange = "renderLogs()" > < option value = "" > 全部结果< / option > < option value = "1" > ✓ 成功< / option > < option value = "0" > ✗ 失败< / option > < / select > < / div >
< div class = "lf" > < select id = "fNode" onchange = "renderLogs()" > < option value = "" > 全部节点< / option > < / select > < select id = "fUp" onchange = "renderLogs()" > < option value = "" > 全部供应商< / option > < / select > < select id = "fRes" onchange = "renderLogs()" > < option value = "" > 全部结果< / option > < option value = "1" > ✓ 成功< / option > < option value = "0" > ✗ 失败< / option > < / select > < / div >
< table id = "logTable" > < / table > < / div > < / div >
< table id = "logTable" > < / table > < div class = "pager" id = "pager" > < / div > < / div > < / div >
< / div >
< / div >
< script >
< script >
let DATA = { nodes : [ ] , stats : { } , requests : [ ] } ;
let DATA = { nodes : [ ] , stats : { } , requests : [ ] } ;
const $ = s => document . querySelector ( s ) ;
const $ = s => document . querySelector ( s ) ;
let TOKEN = localStorage . getItem ( 'oc-token' ) || '' ;
const authHdr = ( ) => ( { headers : { 'Authorization' : 'Bearer ' + TOKEN } } ) ;
function sw ( t ) { document . querySelectorAll ( '.tab' ) . forEach ( e => e . classList . remove ( 'on' ) ) ; document . querySelectorAll ( '.tp' ) . forEach ( e => e . classList . remove ( 'on' ) ) ; document . querySelector ( ` .tab[onclick*=" ${ t } "] ` ) . classList . add ( 'on' ) ; document . getElementById ( 't-' + t ) . classList . add ( 'on' ) ; if ( t === 'logs' ) renderLogs ( ) }
function doLogin ( ) {
const t = $ ( '#tokenInput' ) . value . trim ( ) ; if ( ! t ) return ;
fetch ( '/api/dashboard' , { headers : { 'Authorization' : 'Bearer ' + t } } ) . then ( r => r . json ( ) ) . then ( d => {
if ( ! d . authed ) { $ ( '#loginErr' ) . style . display = 'block' ; return }
TOKEN = t ; localStorage . setItem ( 'oc-token' , t ) ;
$ ( '#loginMask' ) . style . display = 'none' ; $ ( '#lockBtn' ) . textContent = '🔓' ;
DATA = d ; render ( ) ;
} ) ;
}
function sw ( t ) { document . querySelectorAll ( '.tab' ) . forEach ( e => e . classList . remove ( 'on' ) ) ; document . querySelectorAll ( '.tp' ) . forEach ( e => e . classList . remove ( 'on' ) ) ; document . querySelector ( ` .tab[onclick*=" ${ t } "] ` ) . classList . add ( 'on' ) ; document . getElementById ( 't-' + t ) . classList . add ( 'on' ) ; if ( t === 'logs' ) loadLogs ( logPage ) }
function fmtTok ( n ) { return n >= 1e6 ? ( n / 1e6 ) . toFixed ( 1 ) + 'M' : n >= 1e3 ? ( n / 1e3 ) . toFixed ( 1 ) + 'K' : n }
function fmtTok ( n ) { return n >= 1e6 ? ( n / 1e6 ) . toFixed ( 1 ) + 'M' : n >= 1e3 ? ( n / 1e3 ) . toFixed ( 1 ) + 'K' : n }
function fmtUp ( s ) { if ( ! s ) return '0s' ; return s >= 1000 ? ( s / 1000 ) . toFixed ( 1 ) + 's' : s + 'ms' }
function fmtUp ( s ) { if ( ! s ) return '0s' ; return s >= 1000 ? ( s / 1000 ) . toFixed ( 1 ) + 's' : s + 'ms' }
@@ -123,7 +174,7 @@ function renderNodes(){
<div class="tg"><span class=" ${ n . role === 'master' ? 'ms' : 'wk' } "> ${ n . role } </span><span>OC ${ n . oc _version } </span><span> ${ n . os } </span></div>
<div class="tg"><span class=" ${ n . role === 'master' ? 'ms' : 'wk' } "> ${ n . role } </span><span>OC ${ n . oc _version } </span><span> ${ n . os } </span></div>
<div class="hb"> ${ hbBars } </div>
<div class="hb"> ${ hbBars } </div>
<div class="sec">供应商</div>
<div class="sec">供应商</div>
${ provs . sort ( ( a , b ) => b . default - a . default || ( a . name > b . name ? 1 : - 1 ) ) . map ( p => ` <div class="pv"><div class="pv-l"> ${ p . default ? '<span class="dot on" style="width:6px;height:6px"></span>' : '' } <span ${ p . default ? ' class="pv-default"' : '' } > ${ p . name } </span><div> ${ p . model . split ( ' | ' ) . map ( m => '<span class="pm">' + m + '</span>' ) . join ( '<br>' ) } </div>< /div><div> ${ p . status === 'ok' ? '<span class="ok">✓</span> <span class="pm">' + p . ms + 'ms</span>' : '<span class="er">✗</span> <span class="pm">' + ( p . err || '离线' ) + '</span>' } </div></div> ` ) . join ( '' ) }
<div class="pv-wrap"> ${ provs . sort ( ( a , b ) => b . default - a . default || ( a . name > b . name ? 1 : - 1 ) ) . map ( p => ` <div class="pv"><div class="pv-l"> ${ p . default ? '<span class="dot on" style="width:6px;height:6px"></span>' : '' } <span ${ p . default ? ' class="pv-default"' : '' } > ${ p . name } </span></div><div> ${ p . status === 'ok' ? '<span class="ok">✓</span> <span class="pm">' + p . ms + 'ms</span>' : '<span class="er">✗</span> <span class="pm">' + ( p . err || '离线' ) + '</span>' } </div></div> ` ) . join ( '' ) } </div>
<div class="gs">
<div class="gs">
${ [ [ 'cpu' , n . cpu ] , [ 'mem' , n . mem ] , [ 'disk' , n . disk ] , [ 'swap' , n . swap ] ] . map ( ( [ l , v ] ) => ` <div class="g" data-g=" ${ l } "><span class="g-l"> ${ l } </span><div class="g-t"><div class="g-f ${ gaugeColor ( v ) } " style="width: ${ v } %"></div></div><span class="g-n"> ${ on ? v + '%' : '—' } </span></div> ` ) . join ( '' ) }
${ [ [ 'cpu' , n . cpu ] , [ 'mem' , n . mem ] , [ 'disk' , n . disk ] , [ 'swap' , n . swap ] ] . map ( ( [ l , v ] ) => ` <div class="g" data-g=" ${ l } "><span class="g-l"> ${ l } </span><div class="g-t"><div class="g-f ${ gaugeColor ( v ) } " style="width: ${ v } %"></div></div><span class="g-n"> ${ on ? v + '%' : '—' } </span></div> ` ) . join ( '' ) }
</div>
</div>
@@ -136,31 +187,40 @@ ${[['cpu',n.cpu],['mem',n.mem],['disk',n.disk],['swap',n.swap]].map(([l,v])=>`<d
function renderMatrix ( ) {
function renderMatrix ( ) {
const ns = DATA . nodes , allProvs = new Map ( ) ;
const ns = DATA . nodes , provMap = new Map ( ) ;
ns . forEach ( n => JSON . parse ( n . providers || '[]' ) . forEach ( p => {
ns . forEach ( n => JSON . parse ( n . providers || '[]' ) . forEach ( p => {
const k = p. name + ' (' + p . model + ')' ; if ( ! allProvs . has ( k ) ) allProvs . set ( k , new Set ( ) ) ;
if ( ! provMap . has ( p . name ) ) provMap . set ( p . name , { name : p . name , nodes : [ ] } ) ;
allProvs . get ( k ) . add ( n . name ) ;
provMap . get ( p . name ) . nodes . push ( { node : n . name , model : p . model , status : p . status , ms : p . ms , err : p . err , def : p . default } ) ;
} ) ) ;
} ) ) ;
let h = '<thead><tr><th>Provider</th>' + ns . map ( n => '<th>' + n . name + '</th>' ) . join ( '' ) + '</tr></thead><tbody>' ;
$ ( '#provGrid' ) . innerHTML = [ ... provMap . values ( ) ] . sort ( ( a , b ) => b . nodes . length - a . nodes . length ) . map ( p => {
for ( const [ prov , set ] of allProvs ) {
const ok = p . nodes . filter ( n => n . status === 'ok' ) . length ;
h += '<tr><td>' + prov + '</td>' ;
return ` <div class="pcard">
ns . forEach ( n => { const ps = JSON . parse ( n . providers || '[]' ) ; const f = ps . find ( x => x . name === prov . split ( ' (' ) [ 0 ] ) ;
<div class="pcard-h"><span class="pcard-nm"> ${ p . name } </span><span class="pcard-cnt"> ${ ok } / ${ p . nodes . length } 在线</span></div>
h += f ? ( f . status === 'ok' ? ` <t d class="ok">✓ ${ f . ms } ms</td> ` : ` <td class="er">✗ ${ f . err || '离线' } </td> ` ) : '<td style="color:var(--dim) ">— </td>' ; } ) ;
${ p . nodes . sort ( ( a , b ) => b . def - a . de f) . map ( n => ` <div class="pcard-row"><div><span ${ n . def ? ' style="color:var(--warn)"' : '' } > ${ n . node } </span><div class="pcard-models"> ${ n . model . split ( ' | ' ) . map ( m => '<div class="pcard-model">' + m . replace ( /claude-/g , 'c-' ) . replace ( /-2025\d{4}/g , '' ) + '</div>' ) . join ( '' ) } </div></div><div> ${ n . status === 'ok' ? '<span class="ok">✓</span> <span class="pcard-ms">' + n . ms + 'ms</span>' : '<span class="er ">✗ </span> <span class="pcard-ms">' + ( n . err || '离线' ) + '</span>' } </div></div> ` ) . join ( '' ) }
h += '</tr>' ;
</div> ` ;
}
} ) . join ( '' ) ;
$ ( '#matrixTable' ) . innerHTML = h + '</tbody>' ;
}
let logPage = 1 , logPages = 1 , logTotal = 0 ;
async function loadLogs ( page ) {
page = page || 1 ;
try {
const r = await fetch ( '/api/requests?page=' + page + '&size=50' , authHdr ( ) ) ;
const d = await r . json ( ) ;
DATA . requests = d . requests || [ ] ; logPage = d . page ; logPages = d . pages ; logTotal = d . total ;
} catch ( e ) { console . error ( e ) }
renderLogs ( ) ;
}
}
function renderLogs ( ) {
function renderLogs ( ) {
const reqs = DATA . requests || [ ] ;
const reqs = DATA . requests || [ ] ;
// Populate filters from nodes (not requests)
const nodes = ( DATA . nodes || [ ] ) . map ( n => n . name ) . sort ( ) ;
const nodes = ( DATA . nodes || [ ] ) . map ( n => n . name ) . sort ( ) ;
const ups = [ ... new Set ( reqs . m ap( r => r . upstream ) ) ] . sort ( ) ;
const ups = [ ... new Set ( ( DATA . nodes || [ ] ) . flatM ap( n => JSON . parse ( n . providers || '[]' ) . map ( p => p . name ) ) ) ] . sort ( ) ;
const fN = $ ( '#fNode' ) , fU = $ ( '#fUp' ) ;
const fN = $ ( '#fNode' ) , fU = $ ( '#fUp' ) ;
const nv = fN . value , uv = fU . value ;
const nv = fN . value , uv = fU . value ;
fN . innerHTML = '<option value="">全部节点</option>' + nodes . map ( n => ` <option ${ n === nv ? ' selected' : '' } > ${ n } </option> ` ) . join ( '' ) ;
fN . innerHTML = '<option value="">全部节点</option>' + nodes . map ( n => ` <option ${ n === nv ? ' selected' : '' } > ${ n } </option> ` ) . join ( '' ) ;
fU . innerHTML = '<option value="">全部供应商</option>' + ups . map ( u => ` <option ${ u === uv ? ' selected' : '' } > ${ u } </option> ` ) . join ( '' ) ;
fU . innerHTML = '<option value="">全部供应商</option>' + ups . map ( u => ` <option ${ u === uv ? ' selected' : '' } > ${ u } </option> ` ) . join ( '' ) ;
// Filter
const fR = $ ( '#fRes' ) . value ;
const fR = $ ( '#fRes' ) . value ;
const filtered = reqs . filter ( r => {
const filtered = reqs . filter ( r => {
if ( nv && ( r . node _name || r . node _id ) !== nv ) return false ;
if ( nv && ( r . node _name || r . node _id ) !== nv ) return false ;
@@ -168,15 +228,24 @@ function renderLogs(){
if ( fR !== '' && String ( r . success ? 1 : 0 ) !== fR ) return false ;
if ( fR !== '' && String ( r . success ? 1 : 0 ) !== fR ) return false ;
return true ;
return true ;
} ) ;
} ) ;
let h = '<thead><tr><th>时间</th><th>节点</th><th>供应商</th><th>模型</th><th>结果</th><th>状态</th><th> 输入</th><th>输出</th><th>首字 </th><th>总耗时 </th></tr></thead><tbody>' ;
let h = '<thead><tr><th>时间</th><th>节点</th><th>供应商</th><th>模型</th><th>结果</th><th>输入</th><th>输出</th><th>缓存读 </th><th>缓存写 </th></tr></thead><tbody>' ;
filtered . forEach ( r => {
filtered . forEach ( r => {
const t = new Date ( r . ts * 1000 ) . toLocaleTimeString ( 'zh-CN' ) ;
const t = new Date ( r . ts * 1000 ) . toLocaleTimeString ( 'zh-CN' ) ;
h += '<tr><td>' + t + '</td><td>' + ( r . node _name || r . node _id ) + '</td><td>' + r . upstream + '</td><td>' + r . model + '</td>' ;
h += '<tr><td>' + t + '</td><td>' + ( r . node _name || r . node _id ) + '</td><td>' + r . upstream + '</td><td>' + r . model + '</td>' ;
h += '<td class="' + ( r . success ? 'ok' : 'er' ) + '">' + ( r . success ? '✓' : '✗' ) + '</td><td>' + r . status + '</td> ' ;
h += '<td class="' + ( r . success ? 'ok' : 'er' ) + '">' + ( r . success ? '✓' : '✗' ) + '</td>' ;
h += '<td>' + fmtTok ( r . input _tokens ) + '</td><td>' + r . output _tokens + '</td>' ;
h += '<td>' + fmtTok ( r . input _tokens ) + '</td><td>' + fmtTok ( r. output _tokens ) + '</td>' ;
h += '<td style="color:var(--warn )">' + r . ttft _ms + 'ms </td><td>' + fmtUp ( r . total _ms ) + '</td></tr>' ;
h += '<td style="color:var(--purple )">' + fmtTok ( r . cache _read || 0 ) + '</td><td style="color:var(--peach)" >' + fmtTok ( r . cache _write || 0 ) + '</td></tr>' ;
} ) ;
} ) ;
$ ( '#logTable' ) . innerHTML = h + '</tbody>' ;
$ ( '#logTable' ) . innerHTML = h + '</tbody>' ;
// pager
let pg = '<button onclick="loadLogs(1)"' + ( logPage <= 1 ? ' disabled' : '' ) + '>«</button>' ;
pg += '<button onclick="loadLogs(' + ( logPage - 1 ) + ')"' + ( logPage <= 1 ? ' disabled' : '' ) + '>‹ </button>' ;
const start = Math . max ( 1 , logPage - 2 ) , end = Math . min ( logPages , logPage + 2 ) ;
for ( let i = start ; i <= end ; i ++ ) pg += '<button class="' + ( i === logPage ? 'pg-on' : '' ) + '" onclick="loadLogs(' + i + ')">' + i + '</button>' ;
pg += '<button onclick="loadLogs(' + ( logPage + 1 ) + ')"' + ( logPage >= logPages ? ' disabled' : '' ) + '>› </button>' ;
pg += '<button onclick="loadLogs(' + logPages + ')"' + ( logPage >= logPages ? ' disabled' : '' ) + '>»</button>' ;
pg += '<span>共 ' + logTotal + ' 条</span>' ;
$ ( '#pager' ) . innerHTML = pg ;
}
}
function animateNum ( el , to , suffix = '' ) {
function animateNum ( el , to , suffix = '' ) {
@@ -217,7 +286,7 @@ function render(){renderStats();renderNodes();renderMatrix();renderLogs();
}
}
async function load ( ) {
async function load ( ) {
try { const r = await fetch ( '/api/dashboard' ) ; DATA = await r . json ( ) ; render ( ) ; } catch ( e ) { console . error ( e ) }
try { const r = await fetch ( '/api/dashboard' , authHdr ( ) ) ; if ( r . status === 401 ) return ; DATA = await r . json ( ) ; render ( ) ; } catch ( e ) { console . error ( e ) }
}
}
function connectWS ( ) {
function connectWS ( ) {
@@ -231,7 +300,7 @@ function connectWS(){
if ( ! updateNodeCard ( DATA . nodes [ i >= 0 ? i : DATA . nodes . length - 1 ] ) ) renderNodes ( ) ;
if ( ! updateNodeCard ( DATA . nodes [ i >= 0 ? i : DATA . nodes . length - 1 ] ) ) renderNodes ( ) ;
renderStats ( ) ;
renderStats ( ) ;
}
}
if ( d . type === 'request' ) { DATA . requests . unsh ift ( d . request ) ; if ( DATA . requests . length > 100 ) DATA . requests . pop ( ) ; render ( ) ; }
if ( d . type === 'request' ) { const rq = d . request ; if ( ! rq . node _name ) { const nd = DATA . nodes . find ( n => n . id === rq . node _id ) ; if( n d) rq . node _name = nd . name ; } DATA . requests . unshift ( rq ) ; if ( DATA . requests . length > 100 ) DATA . requests . pop ( ) ; render ( ) ; }
if ( d . type === 'rename' ) { var n = DATA . nodes . find ( function ( x ) { return x . id === d . id } ) ; if ( n ) n . name = d . name ; render ( ) ; }
if ( d . type === 'rename' ) { var n = DATA . nodes . find ( function ( x ) { return x . id === d . id } ) ; if ( n ) n . name = d . name ; render ( ) ; }
if ( d . type === 'delete' ) { DATA . nodes = DATA . nodes . filter ( function ( n ) { return n . id !== d . id } ) ; render ( ) ; }
if ( d . type === 'delete' ) { DATA . nodes = DATA . nodes . filter ( function ( n ) { return n . id !== d . id } ) ; render ( ) ; }
} ;
} ;
@@ -245,6 +314,7 @@ function toggleTheme(){
localStorage . setItem ( 'theme' , light ? 'dark' : 'light' ) ;
localStorage . setItem ( 'theme' , light ? 'dark' : 'light' ) ;
}
}
( function ( ) { const t = localStorage . getItem ( 'theme' ) ; if ( t === 'light' ) { document . documentElement . setAttribute ( 'data-theme' , 'light' ) ; document . getElementById ( 'themeBtn' ) . textContent = '☀️' ; } } ) ( ) ;
( function ( ) { const t = localStorage . getItem ( 'theme' ) ; if ( t === 'light' ) { document . documentElement . setAttribute ( 'data-theme' , 'light' ) ; document . getElementById ( 'themeBtn' ) . textContent = '☀️' ; } } ) ( ) ;
if ( TOKEN ) { $ ( '#lockBtn' ) . textContent = '🔓' ; }
load ( ) ; connectWS ( ) ; setInterval ( load , 60000 ) ;
load ( ) ; connectWS ( ) ; setInterval ( load , 60000 ) ;
< / script >
< / script >
< / body > < / html >
< / body > < / html >