Files
AgentsMeeting/gateway/scripts/templates/dashboard.html
T
hmo 3e2a74d4d9 fix: SenseNova卡片不再混入OpenCode Go组
- accounts.json移除key8/9/10(它们是同一SenseNova账户的3个模型视角,非OCG账号)
  否则OCG采集器会把它们当OCG账户采集,前端归到OpenCode Go组
- /api/keys: 注册表外的用量条目(sensenova)以独立provider追加,不进OCG
- 前端provider名映射补sensenova
2026-07-23 22:52:53 +08:00

545 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>AgentsMeeting Dashboard</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--dim:#8b949e;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--yellow:#d29922}
*{margin:0;padding:0;box-sizing:border-box}body{background:var(--bg);color:var(--text);font:20px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:28px}
h1{font-size:32px;font-weight:600;color:var(--accent);margin-bottom:4px}.subtitle{color:var(--dim);font-size:18px;margin-bottom:20px}
.tabs{display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.tab-btn{padding:12px 24px;cursor:pointer;font-size:19px;color:var(--dim);border:none;background:none;border-bottom:2px solid transparent}
.tab-btn:hover{color:var(--text)}.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-pane{display:none}.tab-pane.active{display:block}
.stats{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap}
.stat-c{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;min-width:120px}
.stat-c .n{font-size:42px;font-weight:700}.stat-c .l{font-size:18px;color:var(--dim)}
.stat-c.g .n{color:var(--green)}.stat-c.r .n{color:var(--red)}
.agents{display:flex;flex-direction:column;gap:12px}
.ac{background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden}
.ac.online{border-left:3px solid var(--green)}.ac.degraded{border-left:3px solid var(--yellow)}
.ac.offline{border-left:3px solid var(--red);opacity:.7}
.ah{display:flex;align-items:center;padding:14px 16px;gap:12px;cursor:pointer}
.ah:hover{background:rgba(255,255,255,.02)}
.sd{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.sd.g{background:var(--green);box-shadow:0 0 6px var(--green)}
.sd.y{background:var(--yellow)}.sd.r{background:var(--red)}
.ai{flex:1;min-width:0}.an{font-size:19px;font-weight:600}
.am{font-size:18px;color:var(--dim);margin-top:2px}
.b{display:inline-block;padding:2px 10px;border-radius:10px;font-size:18px;font-weight:600;margin-left:8px}
.b.w{background:#0078d420;color:#60cdff}.b.l{background:#fcc62420;color:#fcc624}.b.m{background:#99999920;color:#ccc}
.as{display:flex;gap:16px;align-items:center;padding-right:8px}
.as .v{font-size:19px;font-weight:600}.as .l{font-size:18px;color:var(--dim)}
.aa{display:flex;gap:4px}
.btn{padding:6px 14px;border:1px solid var(--border);border-radius:5px;background:#21262d;color:var(--text);cursor:pointer;font-size:19px}
.btn:hover{background:#30363d}
.btn.s{border-color:var(--green);color:var(--green)}
.btn.x{border-color:var(--red);color:var(--red)}
.btn.r{border-color:var(--yellow);color:var(--yellow)}
.btn.l{border-color:var(--accent);color:var(--accent)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.svcs{display:flex;gap:8px;padding:0 16px 10px;flex-wrap:wrap}
.st{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:12px;font-size:19px;background:#0d1117;border:1px solid var(--border)}
.st .d{width:6px;height:6px;border-radius:50%}.st .d.running{background:var(--green)}.st .d.stopped{background:var(--dim)}
.st .p{color:var(--accent);font-family:monospace;font-size:18px;margin-left:4px}
.i{display:flex;align-items:center;gap:6px;margin:4px 0}
.sn{flex:1;min-width:0}
.lp{display:none;border-top:1px solid var(--border);padding:12px 16px;background:#0a0e14}
.lp.open{display:block}.lh{font-size:18px;color:var(--dim);margin-bottom:8px;display:flex;justify-content:space-between}
.lc{font:11px/1.6 "Cascadia Code",Consolas,monospace;color:var(--dim);max-height:300px;overflow-y:auto;background:#06080c;border:1px solid var(--border);border-radius:6px;padding:10px;white-space:pre-wrap}
.help-btn{display:flex;align-items:center;justify-content:center;width:20px;height:20px;min-width:20px;max-width:20px;min-height:20px;max-height:20px;border-radius:50%;border:1px solid var(--border);background:var(--card);color:var(--dim);cursor:pointer;font-size:14px;line-height:1;margin-left:8px;flex-shrink:0;overflow:hidden}
.i .help-btn{margin-left:0}
.help-btn:hover{border-color:var(--accent);color:var(--accent)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow);font-size:12px}
.help-btn.ai:hover{background:var(--yellow);color:#000}
.spec-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:1000;display:none;align-items:center;justify-content:center}
.spec-modal.show{display:flex}
.spec-modal .modal-box{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;max-height:80vh;overflow-y:auto;padding:28px;margin:20px}
.spec-modal .modal-box h3{font-size:22px;color:var(--accent);margin-bottom:12px}
.spec-modal .modal-box .close{float:right;cursor:pointer;color:var(--dim);font-size:22px}
.spec-modal .modal-box .close:hover{color:var(--text)}
.spec-modal .modal-box h4{font-size:19px;color:var(--accent);margin:16px 0 6px}
.spec-modal .modal-box p{font-size:18px;color:var(--text);margin:4px 0}
.spec-modal .modal-box ul{margin:4px 0 4px 20px}
.spec-modal .modal-box li{font-size:18px;color:var(--text);margin:2px 0}
.spec-modal .modal-box pre{background:var(--bg);padding:12px;border-radius:6px;font-size:15px;overflow-x:auto;margin:8px 0}
.toast{position:fixed;top:16px;right:16px;padding:10px 16px;border-radius:6px;font-size:19px;z-index:999;opacity:0;pointer-events:none}
.toast.show{opacity:1}.toast.ok{background:#238636;color:#fff}.toast.err{background:#da3633;color:#fff}
.ps{margin-top:24px}.ps h2{font-size:18px;color:var(--dim);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;align-items:center}
.psv{display:flex;gap:8px;flex-wrap:wrap}
.psv .i{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:8px 12px;display:flex;align-items:center;gap:8px;font-size:18px}
h2{display:flex;align-items:center}
.psv .i .d{width:6px;height:6px;border-radius:50%}.psv .i .d.ok{background:var(--green)}.psv .i .d.stopped{background:var(--dim)}
.section{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}
.section h2{font-size:19px;font-weight:600;color:var(--accent);display:flex;align-items:center}
.mono{font-family:"Cascadia Code",Consolas,monospace;font-size:18px}
.ar{font-size:16px;color:var(--yellow);margin-top:4px}
.tag{display:inline-block;padding:1px 8px;border-radius:8px;font-size:19px;font-weight:600}
.tag.g{background:#3fb95020;color:var(--green)}.tag.r{background:#f8514920;color:var(--red)}
.tag.y{background:#d2992220;color:var(--yellow)}.tag.d{background:#8b949e20;color:var(--dim)}
table{width:100%;border-collapse:collapse;font-size:18px}th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--border)}
th{color:var(--dim);font-weight:600;font-size:19px}
.log-line{font:16px/1.6 "Cascadia Code",Consolas,monospace;color:var(--dim);padding:2px 0}
.sr{display:flex;align-items:center;gap:10px;padding:5px 0}
.sub-bar{display:flex;gap:2px;margin-bottom:10px;flex-wrap:wrap}
.sub-btn{padding:4px 12px;font-size:18px;background:0 0;border:1px solid transparent;border-radius:12px;color:var(--dim);cursor:pointer}
.sub-btn:hover{color:var(--text);border-color:var(--border)}.sub-btn.active{color:var(--accent);border-color:var(--accent);background:var(--bg)}
@media(max-width:768px){.g2{grid-template-columns:1fr}}
.section{font-size:18px;line-height:1.8}.section p,.section li{font-size:18px;line-height:1.8}.section code{font-size:18px}.section pre{font-size:18px;line-height:1.5}
</style></head><body>
<h1>AgentsMeeting</h1><div class="subtitle" id="subtitle">Dashboard</div>
<div class="tabs" id="tabs"></div><div id="panes"></div><div id="toast" class="toast"></div>
<div id="spec-modal" class="spec-modal"><div class="modal-box" id="spec-modal-box"></div></div>
<script>
var T=[{"id":"agents","label":"Agents"},{"id":"kanban","label":"Kanban"},{"id":"infra","label":"Infrastructure"},{"id":"principles","label":"开发原则","children":[{"id":"spec","label":"G 规范"},{"id":"tests","label":"K 测试"},{"id":"health","label":"F 健康"},{"id":"prd","label":"H 需求"}]}];
var at=localStorage.getItem('t')||'agents',as=localStorage.getItem('s')||'spec';
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>')}
function qs(id){return document.getElementById(id)}
function toast(m,t){var e=qs('toast');e.textContent=m;e.className='toast show '+(t||'ok');setTimeout(function(){e.className='toast'},2500)}
var _specCache={};
function showModuleHelp(module,type){
var modal=qs('spec-modal');var box=qs('spec-modal-box');
if(_specCache[module]){
_renderSpecModal(_specCache[module],type);modal.classList.add('show');return;
}
fetch('/api/module-spec/'+module).then(function(r){return r.json()}).then(function(spec){
_specCache[module]=spec;_renderSpecModal(spec,type);modal.classList.add('show');
}).catch(function(){
box.innerHTML='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span><h3>Spec not found</h3><p>Module: '+esc(module)+'</p>';modal.classList.add('show');
});
}
function _renderSpecModal(spec,type){
var box=qs('spec-modal-box');var h='';
h+='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span>';
if(type==='human'){
var hh=spec.human_help||{};
h+='<h3>'+esc(hh.title||spec.module||'Help')+'</h3>';
if(hh.description){h+='<h4>说明</h4><ul>';hh.description.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(hh.usage){h+='<h4>使用方法</h4><ul>';hh.usage.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(hh.troubleshooting){h+='<h4>常见问题</h4><ul>';hh.troubleshooting.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(hh.related){h+='<h4>关联模块</h4><p>'+esc(hh.related)+'</p>';}
}else{
var ai=spec.ai_spec||{};
h+='<h3>§ AI Spec — '+esc(spec.module||'')+'</h3>';
h+='<p style="color:var(--dim)">'+esc(spec.purpose||'')+'</p>';
if(ai.apis){h+='<h4>API 端点</h4><ul>';ai.apis.forEach(function(a){h+='<li><code>'+esc(a.method)+' '+esc(a.path)+'</code> — '+esc(a.returns||'')+(a.proxied_to?' <span style=color:var(--dim)>→ '+esc(a.proxied_to)+'</span>':'')+'</li>'});h+='</ul>';}
if(ai.dependencies){h+='<h4>依赖</h4><ul>';ai.dependencies.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>';}
if(ai.constraints){h+='<h4>关键约束</h4><ul>';ai.constraints.forEach(function(d){h+='<li style="color:var(--yellow)">'+esc(d)+'</li>'});h+='</ul>';}
if(ai.must_not){h+='<h4>禁止行为</h4><ul>';ai.must_not.forEach(function(d){h+='<li style="color:var(--red)">'+esc(d)+'</li>'});h+='</ul>';}
if(ai.tests){h+='<h4>测试用例</h4><ul>';ai.tests.forEach(function(t){h+='<li><code>'+esc(t.id)+'</code> '+esc(t.name)+'</li>'});h+='</ul>';}
if(ai.related_files){h+='<h4>相关文件</h4><ul>';ai.related_files.forEach(function(f){h+='<li><code>'+esc(f)+'</code></li>'});h+='</ul>';}
if(ai.related_modules&&ai.related_modules.length){h+='<h4>关联模块</h4><ul>';ai.related_modules.forEach(function(m){h+='<li><span class="help-btn" style="cursor:pointer;margin-right:6px" onclick="showModuleHelp(\''+esc(m.module)+'\',\'ai\')">§</span><code>'+esc(m.module)+'</code> — '+esc(m.relation)+'</li>'});h+='</ul>';}
}
box.innerHTML=h;
}
function init(){var b='',p='';for(var i=0;i<T.length;i++){var t=T[i],a=t.id===at||(t.children&&t.children.some(function(c){return c.id===at}));
b+='<button class="tab-btn'+(a?' active':'')+'" onclick="sw(\''+t.id+'\')">'+t.label+'</button>';
if(t.children){p+='<div class="tab-pane'+(a?' active':'')+'" id="pn-'+t.id+'"><div class="sub-bar">';
for(var j=0;j<t.children.length;j++){var c=t.children[j];p+='<button class="sub-btn'+(c.id===as?' active':'')+'" onclick="sws(\''+c.id+'\')">'+c.label+'</button>';}
p+='</div>';
for(var j=0;j<t.children.length;j++){var c=t.children[j];p+='<div id="sp-'+c.id+'"'+(c.id===as?'':' style="display:none"')+'><div class="section"><div id="ct-'+c.id+'"><span class="dim">Loading...</span></div></div></div>';}
p+='</div>';}else{p+='<div class="tab-pane'+(a?' active':'')+'" id="pn-'+t.id+'"><div id="ct-'+t.id+'"><span class="dim">Loading...</span></div></div>';}}
qs('tabs').innerHTML=b;qs('panes').innerHTML=p;}
function sw(id){if(id==='principles'){at='principles';as=localStorage.getItem('s')||'spec'}else{at=id;localStorage.setItem('t',id)}
document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T.find(function(t){return t.id===at||(t.children&&t.children.some(function(c){return c.id===at}))}).label)});
document.querySelectorAll('.tab-pane').forEach(function(p){p.classList.toggle('active',p.id==='pn-'+id)});
if(id==='principles'){document.querySelectorAll('.sub-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T[3].children.find(function(c){return c.id===as}).label)});document.querySelectorAll('[id^="sp-"]').forEach(function(p){p.style.display=p.id==='sp-'+as?'':'none'});(FETCHES[as]||function(){})()}}
function sws(id){as=id;localStorage.setItem('s',id);document.querySelectorAll('.sub-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T[3].children.find(function(c){return c.id===as}).label)});document.querySelectorAll('[id^="sp-"]').forEach(function(p){p.style.display=p.id==='sp-'+id?'':'none'});(FETCHES[id]||function(){})()}
function fill(id,h){var e=qs('ct-'+id);if(e)e.innerHTML=h}
var L=new Set();
async function fa(){try{var r=await fetch('/api/agents'),d=await r.json(),on=d.filter(function(a){return a.status==='online'}).length,er=d.reduce(function(s,a){return s+(a.errors||0)},0);
var h='<h2>Agents 总览<span class="help-btn" onclick="showModuleHelp(\'agents\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'agents\',\'ai\')" title="AI Spec">§</span></h2><div class="stats"><div class="stat-c g"><div class="n">'+on+'/'+d.length+'</div><div class="l">Agents Online</div></div><div class="stat-c '+(er>0?'y':'g')+'"><div class="n">'+(er||0)+'</div><div class="l">Errors</div></div><div class="stat-c g"><div class="n">'+d.length+'</div><div class="l">Total</div></div></div><div class="agents">';
for(var i=0;i<d.length;i++){var a=d[i];var st=a.status==='online'?'online':a.status==='degraded'?'degraded':'offline';var pb={windows:'w',linux:'l',mac:'m'}[a.platform]||'';
var rs=a.status_reason&&a.status_reason!=='OK'?'<div class="ar">'+esc(a.status_reason)+'</div>':'';
h+='<div class="ac '+st+'"><div class="ah" onclick="tgL(\''+a.id+'\')"><span class="sd '+(a.status==='online'?'g':a.status==='degraded'?'y':'r')+'"></span><div class="ai"><div class="an">'+esc(a.display_name||a.name)+(pb?' <span class="b '+pb+'">'+esc(a.platform)+'</span>':'')+'</div><div class="am">'+esc(a.jid||'')+(a.host?' &middot; '+esc(a.host):'')+rs+'</div></div><div class="as"><div class="v">'+(a.message_count_5min||0)+'</div><div class="l">msg/5m</div></div><div class="aa"><button class="btn s" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'start\')" '+(a.status!=='offline'||!a.restartable?'disabled':'')+'>Start</button><button class="btn x" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'stop\')" '+(a.status==='offline'||!a.restartable?'disabled':'')+'>Stop</button><button class="btn r" onclick="event.stopPropagation();doAct(\''+a.id+'\',\'restart\')" '+(!a.restartable?'disabled':'')+'>Restart</button><button class="btn l" onclick="event.stopPropagation();tgL(\''+a.id+'\')">Logs</button></div></div><div class="svcs">';
if(a.services){for(var k=0;k<a.services.length;k++){var s=a.services[k];h+='<span class="st"><span class="d '+(s.status||'stopped')+'"></span>'+esc(s.type)+(s.port?':<span class="p">'+s.port+'</span>':'')+'</span>';}}
h+='</div><div class="lp'+(L.has(a.id)?' open':'')+'" id="lp-'+a.id+'"><div class="lh"><span>Logs</span><button class="btn l" onclick="event.stopPropagation();fL(\''+a.id+'\')">Refresh</button></div><div class="lc" id="lc-'+a.id+'">Click Logs to load</div></div></div>';}
h+='</div>';qs('ct-agents').innerHTML=h;}catch(e){}}
async function doAct(id,act){try{var r=await fetch('/api/agents/'+id+'/'+act,{method:'POST'}),d=await r.json();toast(act+(d.ok?' OK':' FAILED'),d.ok?'ok':'err');setTimeout(fa,1500)}catch(e){toast('Error: '+e.message,'err')}}
function tgL(id){if(L.has(id))L.delete(id);else L.add(id);var lp=qs('lp-'+id);if(lp)lp.className='lp'+(L.has(id)?' open':'');if(L.has(id))fL(id)}
async function fL(id){var el=qs('lc-'+id);if(!el||!L.has(id))return;try{var r=await fetch('/api/agents/'+id+'/logs?lines=50'),d=await r.json();el.innerHTML=(d.lines||[]).map(function(l){return'<div>'+esc(l)+'</div>'}).join('')||'<span class="dim">no log data</span>';el.scrollTop=el.scrollHeight}catch(e){el.innerHTML='<span class="red">Failed</span>'}}
async function fHealth(){try{
// Helpers
function isHealthy(st){return st==='running'||st==='online'||(st&&st.indexOf('remote')===0);}
// Service metadata: label / layer / impact (per-instance key → metadata)
var M={};
M['agent-001:xmpp_bot']={n:'研发助理-XMPP Bot',l:'通信层',i:'小小莫(WindowsXMPP 消息收发中断',spec:'xmpp_bot'};
M['agent-002:hermes_gateway']={n:'Hermes网关-莫荷',l:'AI网关',i:'莫荷智能体(246 Linux)不可用',spec:'hermes_gateway'};
M['agent-003:xmpp_bot']={n:'本地推理-XMPP Bot',l:'通信层',i:'小果(MacXMPP 消息收发中断',spec:'xmpp_bot'};
M['agent-003:omlx_server']={n:'OMLX推理服务(小果)',l:'AI网关',i:'小果(Mac)本地推理服务不可用',spec:'omlx_server'};
M['agent-004:hermes_gateway']={n:'Hermes网关-知微',l:'AI网关',i:'知微智能体(246 Linux)不可用',spec:'hermes_gateway'};
M.wechat_bridge={n:'莫荷微信桥接',l:'通信层',i:'微信消息收发中断',spec:'wechat_bridge'};
M.article_processor={n:'文章抓取服务',l:'辅助服务',i:'微信长文章无法读取',spec:'article_processor'};
// 兼容层:/api/services 返回的旧名称仍然映射到友好标签
M.xmpp_bot={n:'XMPP消息收发',l:'通信层',i:'消息收发中断',spec:'xmpp_bot'};
M.hermes_gateway={n:'Hermes网关',l:'AI网关',i:'Hermes Agent不可用',spec:'hermes_gateway'};
M.ejabberd={n:'XMPP服务器',l:'通信层',i:'所有XMPP连接不可用'};
M.gateway_mohe={n:'Gateway-莫荷',l:'AI网关',i:'莫荷智能体不可用',spec:'hermes_gateway'};
M.gateway_zhiwei={n:'Gateway-知微',l:'AI网关',i:'知微智能体不可用',spec:'hermes_gateway'};
M.dashboard={n:'管理门户',l:'辅助服务',i:'Dashboard无法访问',spec:'dashboard'};
M.watchdog={n:'进程看门狗',l:'辅助服务',i:'异常进程无自动恢复'};
M.ejabberd={n:'XMPP服务器',l:'通信层',i:'所有XMPP连接不可用',spec:'ejabberd'};
M['agents-health-check']={n:'健康检查(5min)',l:'定时任务',i:'5分钟级健康检测未运行',spec:'health_check'};
M['agents-daily-health']={n:'日报健康检查(每日)',l:'定时任务',i:'日报统计未运行',spec:'daily_health'};
M['agents-todo-executor']={n:'TODO自修复(10min)',l:'定时任务',i:'服务异常无法自动恢复',spec:'todo_executor'};
var layerOrder=['通信层','AI网关','辅助服务','定时任务'];
var h='<h2>F 系统健康<span class="help-btn" onclick="showModuleHelp(\'health\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'health\',\'ai\')" title="AI Spec">§</span></h2>';
var all=await Promise.all([
fetch('/api/services').then(function(r){return r.json()}).catch(function(){return{}}),
fetch('/api/expected').then(function(r){return r.json()}).catch(function(){return{}}),
fetch('/api/monitor').then(function(r){return r.json()}).catch(function(){return{}})
]);
var sD=all[0]||{},eD=all[1]||{},mD=all[2]||{};
var svcs=sD.services||[],exp=eD.expected||[],act=eD.actual||{};
// --- Layer 1: 系统概览 ---
var hCount=svcs.filter(function(s){return s.health&&s.health.ok}).length;
var cDown=exp.filter(function(e){return e.critical&&e.expected.indexOf('running')===0&&!isHealthy(act[e.name])}).length;
var st=cDown>0?'critical':hCount<svcs.length?'degraded':'good';
var stL={good:'正常',degraded:'部分异常',critical:'严重异常'};
var stC={good:'var(--green)',degraded:'var(--yellow)',critical:'var(--red)'};
h+='<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">';
h+='<div style="background:'+stC[st]+';color:#000;border-radius:8px;padding:12px 20px;flex:1;min-width:160px;text-align:center"><div style=font-size:28px;font-weight:700>'+stL[st]+'</div><div style=font-size:17px;margin-top:4px;opacity:.8>'+hCount+'/'+svcs.length+' 服务健康</div></div>';
h+='<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;min-width:120px;text-align:center"><div style=font-size:28px;font-weight:700;color:'+stC[st]+'>'+cDown+'</div><div style=font-size:17px;color:var(--dim)>关键异常</div></div>';
if(mD.tier1&&mD.tier1.summary){var t1=mD.tier1.summary;var t1c=t1.ok===t1.total?'var(--green)':'var(--red)';
h+='<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;min-width:120px;text-align:center"><div style=font-size:28px;font-weight:700;color:'+t1c+'>'+t1.ok+'/'+t1.total+'</div><div style=font-size:17px;color:var(--dim)>T1监控</div></div>';}
h+='</div>';
// --- Layer 2: 异常服务 ---
var issues=[];
for(var i=0;i<exp.length;i++){var e=exp[i];
if(e.critical&&e.expected.indexOf('running')===0&&!isHealthy(act[e.name])){
var ma=M[e.key||e.name]||{n:e.name,i:'未知影响'};
issues.push({name:e.name,label:ma.n,impact:ma.i,status:act[e.name]||'stopped'});}}
for(var i=0;i<svcs.length;i++){var s=svcs[i];
if(!s.health||!s.health.ok){
if(!issues.some(function(x){return x.name===s.name})){
var m=M[s.name]||{n:s.name,i:'服务不可用'};
issues.push({name:s.name,label:m.n,impact:m.i,status:'down'});}}}
if(issues.length){
h+='<div class=section><h2 style="color:var(--red);margin-bottom:8px">异常服务</h2>';
for(var i=0;i<issues.length;i++){var iv=issues[i];
h+='<div style="background:var(--card);border:1px solid var(--red);border-radius:8px;padding:10px 14px;margin-bottom:6px">';
h+='<div style=display:flex;align-items:center;gap:8px><span class="sd r"></span><strong>'+esc(iv.label)+'</strong><span class="tag r" style=font-size:18px>'+esc(iv.status)+'</span></div>';
h+='<div style=margin-top:4px;font-size:18px;color:var(--dim)>'+esc(iv.impact)+'</div></div>';}
h+='</div>';}
// --- Layer 3: 全部服务 (merge expected + services) ---
var layers={};
var added={};
for(var i=0;i<exp.length;i++){var e=exp[i];
if(e.check==='scheduled')continue; // 定时任务由下方单独区块渲染,此处跳过
var ma=M[e.key||e.name]||{n:e.name,l:'辅助服务',i:'',spec:''};
var ly=ma.l||'其他';
if(!layers[ly])layers[ly]=[];
added[e.name]=true;
if(e.key) added[e.key]=true; // 同时标记 key,防止 services 同名重复
layers[ly].push({name:e.name,label:ma.n,port:e.port,status:isHealthy(act[e.name])?'running':act[e.name],critical:e.critical,spec:ma.spec||e.spec||''});}
for(var i=0;i<svcs.length;i++){var s=svcs[i];
if(!added[s.name]){
var rawL=(M[s.name]||{}).l||(s.type==='infra'?'通信层':s.type==='gateway'?'AI网关':'辅助服务');
var rawN=(M[s.name]||{}).n||s.name;
if(!layers[rawL])layers[rawL]=[];
var hStatus=s.health&&s.health.ok?'running':'down';
var mSpec=(M[s.name]||{}).spec||'';
layers[rawL].push({name:s.name,label:rawN,port:s.port,status:hStatus,critical:false,spec:mSpec});}}
h+='<div class=section><h2 style=margin-bottom:8px>全部服务</h2>';
for(var li=0;li<layerOrder.length;li++){var ln=layerOrder[li];
var items=layers[ln];
if(!items||!items.length)continue;
h+='<div style=margin:8px 0 4px><strong style=font-size:19px;color:var(--accent)>'+esc(ln)+'</strong></div>';
for(var j=0;j<items.length;j++){var it=items[j];
var run=it.status==='running';
h+='<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;background:var(--bg);border-radius:4px;margin:2px 0">';
h+='<span class="sd '+(run?'g':'r')+'"></span>';
h+='<span style=flex:1>'+esc(it.label)+'</span>';
if(it.port)h+='<span style=color:var(--dim);font-size:18px>:'+it.port+'</span>';
if(it.spec){h+='<span class="help-btn" onclick="showModuleHelp(\''+it.spec+'\',\'human\')" title="使用说明">?</span>';
h+='<span class="help-btn ai" onclick="showModuleHelp(\''+it.spec+'\',\'ai\')" title="AI Spec">§</span>';}
h+='<span class="tag '+(run?'g':'r')+'">'+(run?'运行中':esc(it.status))+'</span>';
h+='</div>';}
}
h+='</div>';
// --- 定时任务状态 ---
if(mD.tasks&&mD.tasks.length){
h+='<div class=section><h2 style=margin-bottom:8px>定时任务</h2>';
for(var i=0;i<mD.tasks.length;i++){var t=mD.tasks[i];
var m=M[t.name]||{n:t.name};
var ms=m.spec||'';
var tk=t.status==='ok'||t.status==='timer_ok'||t.status==='cron_ok'||t.status==='schtasks_ok';
var tn=t.status==='not_deployed';
h+='<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;margin:2px 0">';
h+='<span class="sd '+(tk?'g':tn?'y':'r')+'"></span>';
h+='<span style=flex:1>'+esc(m.n)+'</span>';
if(ms){h+='<span class="help-btn" onclick="showModuleHelp(\''+ms+'\',\'human\')" title="使用说明">?</span>';
h+='<span class="help-btn ai" onclick="showModuleHelp(\''+ms+'\',\'ai\')" title="AI Spec">§</span>';}
h+='<span class="tag '+(tk?'g':tn?'y':'r')+'">'+(tk?'正常':tn?'未部署':'异常')+'</span></div>';}
h+='</div>';}
// --- 自动修复记录 ---
try{var ahR=await fetch('/api/autoheal');var ahD=await ahR.json();
if(ahD&&ahD.time&&ahD.actions_taken&&ahD.actions_taken.length>0){
h+='<div class=section><h2 style=margin-bottom:8px>自动修复记录 <span style=font-size:18px;color:var(--dim)>'+esc(ahD.time.slice(0,16).replace('T',' '))+'</span></h2>';
for(var ai=0;ai<ahD.actions_taken.length;ai++){var a=ahD.actions_taken[ai];
var isOK=a.detail&&a.detail.indexOf('OK')>=0;
h+='<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;margin:2px 0;background:var(--card);border-radius:4px">';
h+='<span class="sd '+(isOK?'g':'r')+'"></span>';
h+='<span style=flex:1>'+esc(a.action||'修复')+'</span>';
h+='<span style=color:var(--dim);font-size:18px>'+esc(a.service||'')+'</span>';
h+='<span class="tag '+(isOK?'g':'r')+'">'+(isOK?'成功':esc(a.detail||'失败'))+'</span></div>';}
h+='</div>';}}
catch(ahE){/* autoheal fetch failed, skip */}
if(!h)h='<span class=dim>No data</span>';
fill('health',h)}catch(e){fill('health','<span class=tag r>Error: '+esc(e.message)+'</span>')}}
async function fK(){try{var r=await fetch('/api/kanban'),d=await r.json();var tasks=d.tasks||[];var s=document.getElementById('kf-status');var a=document.getElementById('kf-assignee');var fs=s?s.value:'';var fa=a?a.value:'';
if(tasks.length==0){fill('kanban','<h2>Kanban 看板<span class="help-btn" onclick="showModuleHelp(\'kanban\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'kanban\',\'ai\')" title="AI Spec">§</span></h2><div style=text-align:center;padding:40px;color:var(--dim)>No tasks</div>');return}
var html='<h2>Kanban 看板<span class="help-btn" onclick="showModuleHelp(\'kanban\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'kanban\',\'ai\')" title="AI Spec">§</span></h2><div style=display:flex;gap:8px;margin-bottom:16px>';html+='<select id=kf-status style=background:var(--card);border:1px solid var(--border);border-radius:5px;padding:6px 10px;color:var(--text);font-size:18px><option value="">All Status</option><option value=ready>Ready</option><option value=progress>In Progress</option><option value=blocked>Blocked</option><option value=done>Done</option></select>';
html+='<select id=kf-assignee style=background:var(--card);border:1px solid var(--border);border-radius:5px;padding:6px 10px;color:var(--text);font-size:18px><option value="">All</option><option value=mohe>mohe</option><option value=zhiwei>zhiwei</option><option value=xxm>xxm</option><option value=xiaoguo>xiaoguo</option></select>';
html+='<span style=color:var(--dim);font-size:18px;align-self:center>'+tasks.length+' tasks</span></div>';
var filtered=tasks;if(fs)filtered=filtered.filter(function(t){return t.status===fs});if(fa)filtered=filtered.filter(function(t){return t.assignee===fa});
var colors={ready:'var(--green)',progress:'var(--yellow)',blocked:'var(--red)',done:'var(--dim)'};var labels={ready:'Ready',progress:'In Progress',blocked:'Blocked',done:'Done'};
html+=filtered.map(function(t){return'<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;border-left:3px solid '+(colors[t.status]||'var(--dim)')+'" id=kc-'+t.id+'"><div style=font-size:18px;font-weight:600>'+esc(t.title)+'</div><div style=display:flex;gap:12px;font-size:19px;color:var(--dim);margin-bottom:6px>'
+'<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:18px;font-weight:600;background:'+(colors[t.status]||'var(--dim)')+';color:#000">'+(labels[t.status]||t.status)+'</span>'
+'<span>'+(t.assignee?'@'+esc(t.assignee):'')+'</span>'
+'<span>'+(t.created_at?new Date(t.created_at*1000).toLocaleDateString():'')+'</span></div>'
+(t.body?'<div style=font-size:18px;color:var(--dim);white-space:pre-wrap;max-height:60px;overflow:hidden;cursor:pointer onclick="this.classList.toggle(\'expanded\');if(this.style.maxHeight!==\'none\')this.style.maxHeight=\'none\';else this.style.maxHeight=\'60px\'">'+esc(t.body)+'</div>':'')
+'</div>'}).join('');
fill('kanban',html);
var es=document.getElementById('kf-status');var ea=document.getElementById('kf-assignee');
if(es&&!s)es.value=fs;if(ea&&!a)ea.value=fa;
if(es)es.onchange=fK;if(ea)ea.onchange=fK;}catch(e){}}
async function fI(){
try{
var ci=document.getElementById('ct-infra');if(!ci)return;
var ls=ci.querySelector('span.dim');if(ls)ls.remove();
/* --- Stable wrapper for Platform+Ejabberd (rebuilt each cycle, no flicker since no state to preserve) --- */
var pe=document.getElementById('infra-pe');
if(!pe){pe=document.createElement('div');pe.id='infra-pe';ci.appendChild(pe);}
var h='';
try{var r=await fetch('/api/platform'),d=await r.json();
h+='<div class="ps"><h2>Platform<span class="help-btn" onclick="showModuleHelp(\'infra\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'infra\',\'ai\')" title="AI Spec">§</span></h2><div class="psv">';
for(var i=0;i<d.length;i++){var s=d[i];h+='<div class="i"><span class="d '+(s.status==='running'?'ok':'stopped')+'"></span><span class="sn">'+esc(s.name)+'</span><span class="help-btn" onclick="showModuleHelp(\''+s.id+'\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\''+s.id+'\',\'ai\')" title="AI Spec">§</span></div>';}
try{var r2=await fetch('/api/ejabberd'),ej=await r2.json();var ejA=ej.alive||ej.xmpp_bot_connected;
h+='<div class="i"><span class="d '+(ejA?'ok':'stopped')+'"></span><span class="sn">Ejabberd XMPP</span><span class="help-btn" onclick="showModuleHelp(\'ejabberd\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'ejabberd\',\'ai\')" title="AI Spec">§</span></div>';
}catch(ejErr){}
h+='</div></div>';
}catch(e){}
pe.innerHTML=h;
/* --- EasyTier section: create once, update state each cycle (no flicker) --- */
var et=document.getElementById('easytier-section');
if(!et){
et=document.createElement('div');et.id='easytier-section';et.className='ps';et.style.marginTop='16px';
et.innerHTML='<h2>EasyTier VPN<span class="help-btn" onclick="showModuleHelp(\'easytier\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'easytier\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=et-w></span>Windows <span id=et-w-ip></span></div><div class=i><span class="d" id=et-246></span>246 <span id=et-246-ip></span></div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-et-on>Turn On</button><button class="btn x" id=btn-et-off>Turn Off</button><span id=et-status style="font-size:12px;color:var(--dim)">-</span></div>';
ci.appendChild(et);
document.getElementById('btn-et-on').onclick=function(){fetch('/api/easytier/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('ET Fail','err')})};
document.getElementById('btn-et-off').onclick=function(){fetch('/api/easytier/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('ET Fail','err')})};
}
try{var e3=await fetch('/api/easytier').then(function(r){return r.json()});var s=e3.status||{};
var e1=document.getElementById('et-w');if(e1)e1.className='d '+(s.windows==='running'?'ok':'stopped');
var e2=document.getElementById('et-246');if(e2)e2.className='d '+(s['246']==='running'?'ok':'stopped');
if(e3.virtual_ips){var w=document.getElementById('et-w-ip');if(w)w.textContent=e3.virtual_ips.windows||'';var t=document.getElementById('et-246-ip');if(t)t.textContent=e3.virtual_ips['246']||'';}
var allRun=Object.values(s).every(function(v){return v==='running'||v==='pending'});
document.getElementById('btn-et-on').disabled=allRun;
document.getElementById('btn-et-off').disabled=!allRun;
document.getElementById('et-status').textContent=allRun?'Connected':'Off';
}catch(e3){}
/* --- RDP section: create once, update state each cycle (no flicker) --- */
var rd=document.getElementById('rdp-section');
if(!rd){
rd=document.createElement('div');rd.id='rdp-section';rd.className='ps';rd.style.marginTop='16px';
rd.innerHTML='<h2>RDP Remote Desktop<span class="help-btn" onclick="showModuleHelp(\'rdp\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'rdp\',\'ai\')" title="AI Spec">§</span></h2><div class=psv><div class=i><span class="d" id=rdp-st></span>RDP <span id=rdp-info></span></div><div class=i><span class="d" id=rdp-tun></span>Tunnel</div></div><div style="margin-top:8px;display:flex;gap:8px;align-items:center"><button class="btn s" id=btn-rdp-on>Enable</button><button class="btn x" id=btn-rdp-off>Disable</button><span id=rdp-status-txt style="font-size:12px;color:var(--dim)">-</span></div>';
ci.appendChild(rd);
document.getElementById('btn-rdp-on').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'start'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};
document.getElementById('btn-rdp-off').onclick=function(){fetch('/api/rdp/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'stop'})}).then(function(){fI()}).catch(function(){toast('RDP Fail','err')})};
}
try{var e4=await fetch('/api/rdp').then(function(r){return r.json()});
document.getElementById('rdp-st').className='d '+(e4.rdp_enabled?'ok':'stopped');
document.getElementById('rdp-tun').className='d '+(e4.tunnel_running?'ok':'stopped');
document.getElementById('rdp-info').textContent=e4.public_endpoint||'';
var fOn=e4.rdp_enabled&&e4.tunnel_running;
document.getElementById('btn-rdp-on').disabled=fOn;
document.getElementById('btn-rdp-off').disabled=!fOn;
document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_enabled?'Tunnel pending':'Disconnected';
}catch(e4){}
/* --- OpenCode Go Usage section: create once, update state each cycle (no flicker) --- */
var us=document.getElementById('usage-section');
if(!us){
us=document.createElement('div');us.id='usage-section';us.className='ps';us.style.marginTop='16px';
us.innerHTML='<h2>API 用量<span class="help-btn" onclick="showModuleHelp(\'usage_monitor\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'usage_monitor\',\'ai\')" title="AI Spec">§</span></h2>'
+'<div id="usage-cards" style="display:flex;flex-wrap:wrap;gap:10px;"></div>'
+'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'
+'<button class="btn s" id="btn-usage-refresh">Refresh Now</button>'
+'<span id="usage-last" style="font-size:12px;color:var(--dim)">-</span>'
+'</div>';
ci.appendChild(us);
document.getElementById('btn-usage-refresh').onclick=function(){
var btn=this;btn.disabled=true;
document.getElementById('usage-last').textContent='采集中…';
fetch('/api/usage/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){toast('采集已触发,10-15s 后自动刷新');}
else{toast('Refresh 失败: '+(d.error||'unknown'),'err');}
setTimeout(function(){btn.disabled=false;fI()},8000);
})
.catch(function(e){toast('Network error','err');btn.disabled=false;});
};
}
try{
var e5=await fetch('/api/usage').then(function(r){return r.json()});
var cards=document.getElementById('usage-cards');
var lu=document.getElementById('usage-last');
if(!e5||!e5.ok){
if(lu)lu.textContent='—'+(e5&&e5.error?' '+e5.error:'');
if(cards)cards.innerHTML='<div style="color:var(--dim);padding:8px">'+esc(e5&&e5.error||'未采集')+'</span>';
}else{
// Update last refresh time
if(lu){
var ts=e5.last_refresh_iso;
var d = new Date(ts);
var pad=function(n){return String(n).padStart(2,'0')};
lu.textContent='上次采集: '+d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())
+' '+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+' CST';
}
// Render cards grouped by provider
var html='';
var accts=e5.accounts||[];
// Group by provider
var groups={},order=[];
for(var i=0;i<accts.length;i++){
var a=accts[i];
var p=a.provider||'other';
if(!groups[p]){groups[p]=[];order.push(p);}
groups[p].push(a);
}
// Provider names
var pn={opencode_go:'OpenCode Go',kimi:'Kimi',sensenova:'SenseNova'};
for(var gi=0;gi<order.length;gi++){
var p=order[gi];
if(groups[p].length>0){
html+='<div style="font-size:19px;color:var(--accent);margin:8px 0 4px;font-weight:600">'+esc(pn[p]||p)+'</div>';
html+='<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
var ag=groups[p];
for(var i=0;i<ag.length;i++){var a=ag[i];
var maxPct=0;
if(a.rolling&&a.rolling.usage_percent!=null)maxPct=Math.max(maxPct,a.rolling.usage_percent);
if(a.weekly&&a.weekly.usage_percent!=null)maxPct=Math.max(maxPct,a.weekly.usage_percent);
if(a.monthly&&a.monthly.usage_percent!=null)maxPct=Math.max(maxPct,a.monthly.usage_percent);
var edge='var(--border)';
var barColor='var(--accent)';
if(maxPct>=95){edge='#e53935';barColor='#e53935';}
else if(maxPct>=80){edge='#fb8c00';barColor='#fb8c00';}
html+='<div style="background:var(--card);border:1px solid '+edge+';border-radius:8px;padding:10px 12px;min-width:200px;flex:1;max-width:260px">'
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
+'<strong style="font-size:14px">'+esc(a.label||a.key_id||a.workspace_id||'?')+'</strong>';
if(a.session_expired){
html+='<span style="font-size:11px;color:#e53935;background:rgba(229,57,53,.1);padding:2px 6px;border-radius:4px">SESSION EXPIRED</span>';
}else if(a.error){
html+='<span style="font-size:11px;color:var(--dim)" title="'+esc(a.error)+'">⚠</span>';
}else{
var statusTags='';
if(a.subscribed===false){
statusTags+='<span style="font-size:11px;color:#e53935;background:rgba(229,57,53,.1);padding:2px 6px;border-radius:4px;margin-right:4px">UNSUBSCRIBED</span>';
}else if(a.renewal==='cancelled'){
statusTags+='<span style="font-size:11px;color:#fb8c00;background:rgba(251,140,0,.1);padding:2px 6px;border-radius:4px;margin-right:4px" title="续订已取消·当前周期仍有效">⏳ 续订取消</span>';
}
html+=statusTags+'<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>';
}
html+='</div>';
if(a.session_expired||a.error){
html+='<div style="font-size:12px;color:var(--dim);padding:4px 0">'+esc(a.error||'请重新登录此账号')+'</div>';
}else{
html+=_renderUsageMetric('5h rolling',a.rolling,barColor);
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
}
html+='</div>';}
html+='</div>';}}
if(!accts.length){html='<div style="color:var(--dim);padding:8px">未配置账号 (accounts.json 未就绪,或 cookies 未提取)</div>';}
if(cards)cards.innerHTML=html;
}
}catch(e5){}
}catch(e){}
}
function _fmtCountdown(sec){
if(sec==null)return '—';
if(sec<=0)return '即将重置';
sec=Math.floor(sec);
var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60),s=sec%60;
if(d>0)return d+'d '+h+'h';
if(h>0)return h+'h '+m+'m';
if(m>0)return m+'m '+s+'s';
return s+'s';
}
function _renderUsageMetric(label,m,barColor){
if(!m||m.usage_percent==null){
return '<div style="margin-bottom:6px"><div style="font-size:11px;color:var(--dim);display:flex;justify-content:space-between"><span>'+esc(label)+'</span></div><div style="background:var(--bg);border-radius:4px;height:6px;margin-top:2px"></div><div style="font-size:10px;color:var(--dim);margin-top:1px">—</div></div>';
}
var pct=m.usage_percent;
var txt=pct+'% · '+_fmtCountdown(m.reset_in_sec);
return '<div style="margin-bottom:6px">'
+'<div style="font-size:11px;color:var(--dim);display:flex;justify-content:space-between"><span>'+esc(label)+'</span><span>'+(pct>=95?'CRITICAL':pct>=80?'WARNING':'OK')+'</span></div>'
+'<div style="background:var(--bg);border-radius:4px;height:6px;margin-top:2px;overflow:hidden">'
+'<div style="background:'+barColor+';height:100%;width:'+pct+'%;transition:width .3s"></div>'
+'</div>'
+'<div style="font-size:10px;color:var(--dim);margin-top:1px">'+esc(txt)+'</div>'
+'</div>';
}
async function fH(){try{
var r=await fetch('/api/prd'),d=await r.json();
if(!d.ok||!d.content){fill('prd','Failed');return}
var h='<h2>H 产品需求<span class="help-btn" onclick="showModuleHelp(\'prd\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'prd\',\'ai\')" title="AI Spec">§</span></h2><div style=padding:8px 0>'+mdRender(d.content)+'</div>';
try{var rh=await fetch('/api/prd/history'),dh=await rh.json();
if(dh.ok&&dh.log&&dh.log.length){
h+='<div class=section style=margin-top:16px><h2 style=font-size:17px;cursor:pointer onclick="var e=document.getElementById(\'prd-history\');e.style.display=e.style.display===\'none\'?\'block\':\'none\'">历史版本 <span style=color:var(--dim);font-size:18px>('+dh.count+' commits)</span></h2><div id=prd-history style=display:none;margin-top:8px>';
for(var i=0;i<dh.log.length;i++){h+='<div class=log-line>'+esc(dh.log[i])+'</div>';}
h+='</div></div>';}
}catch(e){}
fill('prd',h)}catch(e){fill('prd','Error')}}
async function fTests(){try{var r=await fetch('/api/tests'),d=await r.json();var h='<h2>K 测试报告<span class="help-btn" onclick="showModuleHelp(\'tests\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'tests\',\'ai\')" title="AI Spec">§</span></h2>';if(!d.ok||!d.tests){fill('tests','<span class=tag r>Error</span>');return}
var s=d.summary;h+='<div style=display:flex;gap:12px;margin-bottom:16px><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;text-align:center"><div style=font-size:32px;font-weight:700;color:var(--accent)>'+s.total+'</div><div style=color:var(--dim);font-size:19px>Total</div></div><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;text-align:center"><div style=font-size:32px;font-weight:700;color:var(--green)>'+s.passed+'</div><div style=color:var(--dim);font-size:19px>Passed</div></div><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;text-align:center"><div style=font-size:32px;font-weight:700;color:var(--red)>'+s.failed+'</div><div style=color:var(--dim);font-size:19px>Failed</div></div></div>';
h+='<div style=margin-bottom:8px;font-weight:600;color:var(--yellow)>Expected Fail ('+s.failed+')</div>';
d.tests.filter(function(t){return!t.ok&&t.expected}).forEach(function(t){h+='<div style="background:var(--card);border:1px solid var(--yellow);border-radius:6px;padding:6px 12px;margin-bottom:4px"><span style="background:var(--yellow);color:#000;padding:1px 6px;border-radius:3px;font-weight:700;font-size:18px">EXPECTED</span> <span style=font-size:19px>'+esc(t.name)+'</span> <span style=font-size:19px;color:var(--dim)>'+esc(t.detail||'')+'</span></div>';});
var realFails=d.tests.filter(function(t){return!t.ok&&!t.expected});
if(realFails.length){h+='<div style=margin-bottom:8px;font-weight:600;color:var(--red)>Real Fail ('+realFails.length+')</div>';realFails.forEach(function(t){h+='<div style="background:var(--card);border:1px solid var(--red);border-radius:6px;padding:8px 12px;margin-bottom:6px"><div style=display:flex;align-items:center;gap:8px><span style="background:var(--red);color:#fff;padding:2px 8px;border-radius:4px;font-weight:700;font-size:19px">FAIL</span><strong>'+esc(t.name)+'</strong></div><div style=margin-top:4px;font-size:18px;color:var(--dim)>'+esc(t.detail||'')+'</div></div>';});}
h+='<div style=margin-bottom:8px;font-weight:600;color:var(--green)>Passed ('+s.passed+')</div>';
d.tests.filter(function(t){return t.ok}).forEach(function(t){h+='<div style="background:var(--card);border:1px solid var(--border);border-radius:6px;padding:6px 12px;margin-bottom:4px"><span style="background:var(--green);color:#fff;padding:1px 6px;border-radius:3px;font-weight:700;font-size:18px">PASS</span> <span style=font-size:19px>'+esc(t.name)+'</span> <span style=font-size:19px;color:var(--dim)>'+esc(t.detail||'')+'</span></div>';});
h+='<div style=margin-top:8px;color:var(--dim);font-size:19px>Last run: '+esc(d.time||'')+'</div>';
fill('tests',h)}catch(e){fill('tests','<span class=tag r>Error: '+esc(e.message)+'</span>')}}
var FETCHES={agents:fa,spec:fS2,tests:fTests,health:fHealth,prd:fH,kanban:fK,infra:fI,easytier:fE2,rdp:fR,principles:function(){return(FETCHES[as]||function(){})()}};
async function loadAll(){var d=new Date();qs('subtitle').textContent=d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate()+' '+d.getHours()+':'+d.getMinutes();var skip=['spec','prd','health'];await Promise.all(Object.keys(FETCHES).filter(function(k){return skip.indexOf(k)<0}).map(function(k){return FETCHES[k]()}));}
async function fE2(){try{var r=await fetch('/api/easytier'),d=await r.json(),s=d.status||{};var e1=document.getElementById('et-w');if(e1){e1.className='d '+(s.windows==='running'?'ok':'stopped')}var e2=document.getElementById('et-246');if(e2){e2.className='d '+(s['246']==='running'?'ok':'stopped')}if(d.virtual_ips){var w=document.getElementById('et-w-ip');if(w)w.textContent=d.virtual_ips.windows||'';var t=document.getElementById('et-246-ip');if(t)t.textContent=d.virtual_ips['246']||''}}catch(e){}}
async function fR(){try{var r=await fetch('/api/rdp'),d=await r.json();var e1=document.getElementById('rdp-st');if(e1){e1.className='d '+(d.rdp_enabled?'ok':'stopped')}var e2=document.getElementById('rdp-tun');if(e2){e2.className='d '+(d.tunnel_running?'ok':'stopped')}var info=document.getElementById('rdp-info');if(info)info.textContent=d.public_endpoint||''}catch(e){}}
var fS2_c=null;var fS2_last='';async function fS2(){try{
var r=await fetch('/api/spec'),d=await r.json();
if(!d.ok||!d.content){fill('spec','Failed');return}
var h='<h2>G 开发规范<span class="help-btn" onclick="showModuleHelp(\'dev_spec\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'dev_spec\',\'ai\')" title="AI Spec">§</span></h2><div style=padding:8px 0>'+mdRender(d.content)+'</div>';
try{var rh=await fetch('/api/spec/history'),dh=await rh.json();
if(dh.ok&&dh.log&&dh.log.length){
h+='<div class=section style=margin-top:16px><h2 style=font-size:17px;cursor:pointer onclick="var e=document.getElementById(\'spec-history\');e.style.display=e.style.display===\'none\'?\'block\':\'none\'">历史版本 <span style=color:var(--dim);font-size:18px>('+dh.count+' commits)</span></h2><div id=spec-history style=display:none;margin-top:8px>';
for(var i=0;i<dh.log.length;i++){h+='<div class=log-line>'+esc(dh.log[i])+'</div>';}
h+='</div></div>';}
}catch(e){}
fill('spec',h)}catch(e){fill('spec','Error')}}
function mdRender(md){if(!md)return'';var h='';var blocks=md.split('\n\n');for(var b=0;b<blocks.length;b++){var block=blocks[b].trim();if(!block)continue;
if(block.startsWith('```')){var code=block.replace(/^```[\s\S]*?\n/,'').replace(/```$/,'').trim();h+='<pre class=mono style=background:var(--bg);padding:12px;border-radius:6px;overflow-x:auto>'+esc(code)+'</pre>';continue;}
if(block.startsWith('### ')){var txt=block.slice(4);var anno=txt.match(/<!--(.*?)-->/);if(anno){var status=anno[1].trim();txt=txt.replace(/<!--.*?-->/,'').trim();var badge=status.indexOf('✅')>=0?'<span class=tag g style=margin-left:8px>'+esc(status)+'</span>':status.indexOf('🔄')>=0?'<span class=tag y style=margin-left:8px>'+esc(status)+'</span>':'<span class=tag r style=margin-left:8px>'+esc(status)+'</span>';h+='<h3 style=color:var(--accent);margin:16px 0 6px;font-size:19px>'+esc(txt)+badge+'</h3>'}else{h+='<h3 style=color:var(--accent);margin:16px 0 6px;font-size:19px>'+esc(txt)+'</h3>'}continue;}
if(block.startsWith('## ')){var txt=block.slice(3);var anno=txt.match(/<!--(.*?)-->/);if(anno){var status=anno[1].trim();txt=txt.replace(/<!--.*?-->/,'').trim();var badge=status.indexOf('✅')>=0?'<span class=tag g style=margin-left:8px>'+esc(status)+'</span>':status.indexOf('🔄')>=0?'<span class=tag y style=margin-left:8px>'+esc(status)+'</span>':'<span class=tag r style=margin-left:8px>'+esc(status)+'</span>';h+='<h2 style=color:var(--accent);margin:20px 0 8px;font-size:19px;border-bottom:1px solid var(--border);padding-bottom:4px>'+esc(txt)+badge+'</h2>'}else{h+='<h2 style=color:var(--accent);margin:20px 0 8px;font-size:19px;border-bottom:1px solid var(--border);padding-bottom:4px>'+esc(block.slice(3))+'</h2>'}continue;}
if(block.startsWith('# ')){h+='<h1 style=color:var(--accent);margin:24px 0 10px;font-size:24px>'+esc(block.slice(2))+'</h1>';continue;}
if(block.startsWith('> ')){h+='<blockquote style="border-left:3px solid var(--accent);padding:8px 16px;margin:8px 0;color:var(--dim);background:var(--bg);border-radius:4px">'+esc(block.slice(2))+'</blockquote>';continue;}
if(block.startsWith('---')||block.startsWith('***')){h+='<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">';continue;}
if(block.startsWith('- ')||block.startsWith('* ')){var items=block.split('\n'),inList=false;for(var i=0;i<items.length;i++){var li=items[i].replace(/^[-*]\s*/,'').trim();if(li){if(!inList){h+='<ul style=margin:4px 0;padding-left:20px>';inList=true}h+='<li style=margin:2px 0>'+inMd(li)+'</li>'}}if(inList)h+='</ul>';continue;}
if(/^\d+\.\s/.test(block)){var items=block.split('\n'),inList=false;for(var i=0;i<items.length;i++){var li=items[i].replace(/^\d+\.\s*/,'').trim();if(li){if(!inList){h+='<ol style=margin:4px 0;padding-left:20px>';inList=true}h+='<li style=margin:2px 0>'+inMd(li)+'</li>'}}if(inList)h+='</ol>';continue;}
if(block.indexOf('|')>=0&&block.indexOf('\n|')>=0){var rows=block.split('\n'),inTable=false;for(var i=0;i<rows.length;i++){if(rows[i].indexOf('---')>=0)continue;var cells=rows[i].split('|').filter(function(c){return c.trim()});if(cells.length<2)continue;if(!inTable){h+='<table style=margin:8px 0;font-size:18px><tr>';for(var c=0;c<cells.length;c++){h+='<th style="padding:4px 8px;border:1px solid var(--border);color:var(--dim);font-weight:600;background:var(--bg)">'+inMd(cells[c].trim())+'</th>'}h+='</tr>';inTable=true}else{h+='<tr>';for(var c=0;c<cells.length;c++){h+='<td style="padding:4px 8px;border:1px solid var(--border)">'+inMd(cells[c].trim())+'</td>'}h+='</tr>'}}if(inTable)h+='</table>';continue;}
h+='<p style="line-height:1.7;margin:6px 0">'+inMd(block)+'</p>';}
return h;
function inMd(t){return t.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>').replace(/\*([^*]+)\*/g,'<em>$1</em>').replace(/`([^`]+)`/g,'<code style="background:var(--bg);padding:1px 4px;border-radius:3px;font-size:0.9em">$1</code>').replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" style="color:var(--accent)" target=_blank>$1</a>')}}
init();loadAll();setInterval(loadAll,10000);setInterval(fK,15000);
</script></body></html>