Files
AgentsMeeting/gateway/scripts/templates/dashboard.html
T
hmo fefdc29d06 feat: OpenCode Go usage monitor — hybrid collector + 246-centric dashboard
- usage_collector.py v3.0: HTTP+stored cookies (primary) + CDP (bonus)
  - Cookie keepalive: captures Set-Cookie from responses, saves back to JSON
  - Per-metric regex for SSR parsing (handles nested \[N]={...} structure)
  - Cache merge: preserves other accounts' data on partial collection
- extract_cookies.py: one-time CDP /cookies endpoint → per-account JSON
- dashboard.py: /api/usage reads local file (no Windows bot dependency)
  - /api/usage/refresh: local subprocess collector
  - Auto-timer: 5min background thread (cookie keepalive + fresh data)
- xmpp_agent_core.py: /usage endpoint + auto-timer (for local testing)
- dashboard.html: usage-monitor section (4 cards × 3 metrics + alerts)
- accounts.json: 4 accounts configured (key1-4, gitignored)
- .gitignore: cookies/, accounts.json, usage_stats.json
2026-07-15 22:30:20 +08:00

399 lines
49 KiB
HTML

<!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}
.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:inline-flex;align-items:center;justify-content:center;width:20px;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}
.help-btn:hover{border-color:var(--accent);color:var(--accent)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow)}
.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)}
.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}
.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)}
.mono{font-family:"Cascadia Code",Consolas,monospace;font-size:18px}
.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": "git", "label": "A 源码"}, {"id": "svc", "label": "B 服务"}, {"id": "mon", "label": "C 监控"}, {"id": "todos", "label": "D 修复"}, {"id": "meta", "label": "E 成长"}, {"id": "exp", "label": "F 期望"},{"id":"spec","label":"G 规范"},{"id":"prd","label":"H 需求"},{"id":"tree","label":"I 功能树"},{"id":"topo","label":"J 拓扑"},{"id":"tests","label":"K 测试"}]}];
var at=localStorage.getItem('t')||'agents',as=localStorage.getItem('s')||'git';
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')||'git'}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='<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]||'';
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):'')+'</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 fG(){try{var r=await fetch('/api/git'),d=await r.json();var h='';for(var i=0;i<d.log.length;i++){h+='<div class="log-line">'+esc(d.log[i])+'</div>';}if(!h)h='<span class="dim">No commits</span>';fill('git',h)}catch(e){}}
async function fS(){try{var r=await fetch('/api/services'),d=await r.json(),ok=0;var h='<table><tr><th>Service</th><th>Port</th><th>Health</th><th>WD</th><th>Type</th></tr>';for(var i=0;i<d.services.length;i++){var s=d.services[i],a=s.health&&s.health.ok;if(a)ok++;h+='<tr><td>'+esc(s.name)+'</td><td>'+(s.port||'-')+'</td><td>'+(a?'OK':'DOWN')+'</td><td>'+(s.watchdog?'Y':'N')+'</td><td>'+esc(s.type)+'</td></tr>';}h+='</table>';fill('svc',h)}catch(e){}}
async function fM(){try{var r=await fetch('/api/monitor'),d=await r.json();var h='';if(d.tier1&&d.tier1.summary)h+='<div class="section" style=margin-bottom:12px><div class=stat-c><div class=n>'+d.tier1.summary.ok+'/'+d.tier1.summary.total+'</div><div class=l>T1 Health</div></div>'+(d.tier1.status==='no_data'?'<div style=margin-top:8px;color:var(--yellow)>⏸️ 监控报告未部署到本机 (246)</div>':'')+'</div>';if(d.tier2&&d.tier2.summary)h+='<div class="section"><div class=stat-c><div class=n>'+d.tier2.summary.ok+'/'+d.tier2.summary.total+'</div><div class=l>T2 Daily Health</div></div>'+(d.tier2.status==='no_data'?'<div style=margin-top:8px;color:var(--yellow)>⏸️ 日报监控未部署到本机 (246)</div>':'')+'</div>';if(d.tasks&&d.tasks.length){h+='<div style=margin-top:16px;font-size:17px;font-weight:600>定时任务</div>';for(var i=0;i<d.tasks.length;i++){var t=d.tasks[i];h+='<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-top:8px"><strong>'+esc(t.name)+'</strong> <span class="tag '+(t.status==='ok'||t.status==='timer_ok'?'g':t.status==='not_deployed'?'y':'r')+'">'+esc(t.status)+'</span></div>';}}if(!h)h='<span class=dim>No monitoring data</span>';fill('mon',h)}catch(e){fill('mon','<span class=tag r>Error: '+esc(e.message)+'</span>')}}
async function fT(){try{var r=await fetch('/api/todos'),d=await r.json();fill('todos','<span>'+(d.todos.length)+' records</span>')}catch(e){}}
async function fE(){try{var r=await fetch('/api/metagrowth'),d=await r.json();fill('meta','fixes: '+(d.total_fixes||0)+' commits: '+(d.commit_count||0))}catch(e){}}
async function fF(){try{var r=await fetch('/api/expected'),d=await r.json();var ok=0;for(var i=0;i<d.expected.length;i++){var e=d.expected[i],a=d.actual[e.name]||'',cmp=(e.expected.indexOf('running')===0&&a==='running')||(e.expected.indexOf('scheduled')===0&&(a==='scheduled'||a==='running'));if(cmp)ok++}fill('exp',ok+'/'+d.expected.length+' compliant')}catch(e){}}
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','<div style=text-align:center;padding:40px;color:var(--dim)>No tasks</div>');return}
var html='<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;
/* --- 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</h2><div class="psv">';
for(var i=0;i<d.length;i++){h+='<div class="i"><span class="d '+(d[i].status==='running'?'ok':'stopped')+'"></span>'+esc(d[i].name)+'</div>';}
h+='</div></div>';
}catch(e){}
try{var r2=await fetch('/api/ejabberd'),e=await r2.json();var ea=e.alive||e.xmpp_bot_connected;
h+='<div class="ps"><h2>Ejabberd</h2><div class="psv"><div class="i"><span class="d '+(ea?'ok':'stopped')+'"></span>'+(ea?'ALIVE':'DOWN')+'</div></div></div>';
}catch(e2){}
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>OpenCode Go Usage<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;
lu.textContent=ts?('上次采集: '+ts.replace('T',' ').replace(/\+00:00$/,' UTC')):'—';
}
// Render cards via innerHTML rebuild (simple data cards, no flicker risk)
var html='';
var accts=e5.accounts||[];
for(var i=0;i<accts.length;i++){
var a=accts[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{
html+='<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>';
}
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 el=document.getElementById('ct-prd');var sp=el?el.querySelector('div')?el.querySelector('div').scrollTop:0:0;fill('prd','<div style=padding:8px 0>'+mdRender(d.content)+'</div>');var el2=document.getElementById('ct-prd');if(el2&&sp){setTimeout(function(){(el2.querySelector('div')||el2).scrollTop=sp},0)}}catch(e){fill('prd','Error')}}async function fTree(){try{var r=await fetch('/api/services'),d=await r.json();if(!d.services||!d.services.length){fill('tree','No data');return}
var tree={name:'System',children:[]};var map={};d.services.forEach(function(s){map[s.name]=s;if(!s.depends_on||!s.depends_on.length){tree.children.push(s)}else{s._parents=s.depends_on}});
var h='<div style=font-size:19px;line-height:1.8>';
function renderNode(svc,depth){if(!svc)return;var indent='padding-left:'+(depth*20)+'px';var dot=svc.health&&svc.health.ok?'<span class=sd g></span>':'<span class=sd r></span>';var wd=svc.watchdog?'<span class=tag g style=font-size:18px>WD</span>':'<span class=tag r style=font-size:18px>noWD</span>';h+='<div style="'+indent+';display:flex;align-items:center;gap:8px;padding:3px 0">'+dot+'<span style=font-weight:'+(depth===0?'700':'400')+'>'+esc(svc.name)+'</span><span class=tag d style=font-size:18px>'+esc(svc.type||'')+'</span>'+wd+'<span style=color:var(--dim);font-size:19px>'+(svc.port?':'+svc.port:'')+'</span></div>';
if(svc.children){for(var i=0;i<svc.children.length;i++){renderNode(svc.children[i],depth+1)}}}
// Build tree by connecting children to parents
d.services.forEach(function(s){if(s._parents){s._parents.forEach(function(p){var parent=d.services.find(function(x){return x.name===p});if(parent){if(!parent.children)parent.children=[];parent.children.push(s)}})}});
renderNode(tree,0);
h+='</div>';var allOk=d.services.filter(function(s){return s.health&&s.health.ok}).length;var allWatched=d.services.filter(function(s){return s.watchdog}).length;h+='<div style=margin-top:10px;padding:8px;background:var(--bg);border-radius:6px;color:var(--dim);font-size:19px>Health: '+allOk+'/'+d.services.length+' | Watchdog: '+allWatched+'/'+d.services.length+'</div>';fill('tree',h)}catch(e){fill('tree','<span class=tag r>Error</span>')}}async function fTopo(){try{var r=await fetch('/api/services'),d=await r.json();if(!d.services||!d.services.length){fill('topo','No data');return}
var h='<div style=font-size:19px;line-height:2>';
var types={'core':'核心服务','bridge':'桥接服务','mgmt':'管理服务','infra':'基础设施'};
d.services.forEach(function(s){
var ok=s.health&&s.health.ok;
var dot=ok?'<span class=sd g></span>':'<span class=sd r></span>';
var wd=s.watchdog?'<span class=tag g style=font-size:18px>WD</span>':'<span class=tag r style=font-size:18px>noWD</span>';
h+='<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:8px">';
h+='<div style=display:flex;align-items:center;gap:8px>'+dot+'<strong>'+esc(s.name)+'</strong><span class=tag d style=font-size:18px>'+esc(types[s.type]||s.type)+'</span>'+wd+'<span style=color:var(--dim);font-size:19px>'+(s.port?':'+s.port:'')+'</span></div>';
// Dependencies
if(s.depends_on&&s.depends_on.length){h+='<div style=margin-top:4px;padding-left:20px;font-size:18px;color:var(--dim)>依赖: ';s.depends_on.forEach(function(dp,i){if(i>0)h+=' → ';h+='<span style=color:var(--accent)>'+esc(dp)+'</span>'});h+='</div>'}
// Connections from other services to this one
var deps=d.services.filter(function(x){return x.depends_on&&x.depends_on.indexOf(s.name)>=0});
if(deps.length){h+='<div style=margin-top:2px;padding-left:20px;font-size:18px;color:var(--dim)>被依赖: ';deps.forEach(function(dp,i){if(i>0)h+=' ← ';h+='<span style=color:var(--yellow)>'+esc(dp.name)+'</span>'});h+='</div>'}
h+='</div>';});
h+='<div style=margin-top:10px;padding:8px;background:var(--bg);border-radius:6px;color:var(--dim);font-size:19px>箭头方向: 依赖 → 被依赖 | 健康: '+d.services.filter(function(s){return s.health&&s.health.ok}).length+'/'+d.services.length+'</div>';
fill('topo',h)}catch(e){fill('topo','<span class=tag r>Error</span>')}}async function fTests(){try{var r=await fetch('/api/tests'),d=await r.json();var h='';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+='<table><tr><th>Test</th><th>Result</th><th>Detail</th></tr>';
d.tests.forEach(function(t){h+='<tr><td>'+esc(t.name)+'</td><td>'+(t.ok?'<span class=tag g>PASS</span>':'<span class=tag r>FAIL</span>')+'</td><td class=mono style=font-size:19px;color:var(--dim)>'+esc(t.detail||'')+'</td></tr>';});
h+='</table>';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>')}}async function fTests(){try{var r=await fetch('/api/tests'),d=await r.json();var h='';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(--red)>❌ Failed ('+s.failed+')</div>';
d.tests.filter(function(t){return!t.ok}).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>')}}async function fTests(){try{var r=await fetch('/api/tests'),d=await r.json();var h='';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,git:fG,svc:fS,mon:fM,todos:fT,meta:fE,exp:fF,spec:fS2,prd:fH,tree:fTree,topo:fTopo,tests:fTests,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','tree','topo'];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 el=document.getElementById('ct-spec');var sp=el?el.querySelector('div')?el.querySelector('div').scrollTop:0:0;fill('spec','<div style=padding:8px 0>'+mdRender(d.content)+'</div>');var el2=document.getElementById('ct-spec');if(el2&&sp){setTimeout(function(){(el2.querySelector('div')||el2).scrollTop=sp},0)}}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);
setInterval(fE,10000);setInterval(fR,10000);
</script></body></html>