restructure: 开发原则 Tab 精简为 G+K+F+H + 历史版本 + F 健康视图

This commit is contained in:
hmo
2026-07-16 18:17:44 +08:00
parent 7c2655dc95
commit 7df8df956e
3 changed files with 134 additions and 191 deletions
+33
View File
@@ -1179,6 +1179,39 @@ def api_spec():
return jsonify({"ok": False, "error": str(e), "content": ""})
@app.route("/api/spec/history")
def api_spec_history():
"""返回 dev-spec.md 的 git 历史版本"""
return _git_history_for(".memory/dev-spec.md")
@app.route("/api/prd/history")
def api_prd_history():
"""返回 PRD.md 的 git 历史版本"""
return _git_history_for("docs/PRD.md")
def _git_history_for(filepath):
"""Helper: run git log for a file and return formatted history"""
project_root = _PROJECT_DIR
try:
cmd = ["git", "log", "--oneline", "--date=short", "--format=%h %ai %s", "--", filepath]
result = subprocess.run(
cmd,
cwd=project_root,
capture_output=True,
text=True,
timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
lines = result.stdout.strip().split("\n")
return jsonify({"ok": True, "log": lines, "count": len(lines)})
else:
return jsonify({"ok": True, "log": [], "count": 0})
except Exception as e:
return jsonify({"ok": True, "log": [], "count": 0, "error": str(e)})
@app.route("/api/prd")
def api_prd():
"""返回产品需求文档 (PRD) 内容"""
+50 -55
View File
@@ -83,8 +83,8 @@ th{color:var(--dim);font-weight:600;font-size:19px}
<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';
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)}
@@ -132,7 +132,7 @@ 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)}
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(){})()}}
@@ -149,12 +149,22 @@ 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 fHealth(){try{
var h='';
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++}
h+='<div class=section><h2>期望矩阵 <span class=tag '+(ok===d.expected.length?'g':'y')+'>'+ok+'/'+d.expected.length+'</span></h2><table><tr><th>服务</th><th>期望</th><th>实际</th><th>状态</th></tr>';
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'));h+='<tr><td>'+esc(e.name)+'</td><td>'+esc(e.expected)+'</td><td>'+esc(a)+'</td><td>'+(cmp?'<span class=tag g>OK</span>':'<span class=tag r>MISMATCH</span>')+'</td></tr>';}
h+='</table></div>';}catch(e){}
try{var r2=await fetch('/api/services'),d2=await r2.json();var ok2=0;h+='<div class=section><h2>服务健康 <span class=tag g>'+d2.services.length+' services</span></h2><table><tr><th>Service</th><th>Type</th><th>Port</th><th>Health</th><th>WD</th></tr>';
for(var i=0;i<d2.services.length;i++){var s=d2.services[i],a=s.health&&s.health.ok;if(a)ok2++;h+='<tr><td>'+esc(s.name)+'</td><td>'+esc(s.type||'-')+'</td><td>'+(s.port||'-')+'</td><td>'+(a?'<span class=tag g>OK</span>':'<span class=tag r>DOWN</span>')+'</td><td>'+(s.watchdog?'<span class=tag g>Y</span>':'<span class=tag r>N</span>')+'</td></tr>';}
h+='</table><div style=margin-top:8px;color:var(--dim)>健康: '+ok2+'/'+d2.services.length+' | 看门狗: '+d2.services.filter(function(s){return s.watchdog}).length+'/'+d2.services.length+'</div></div>';}catch(e){}
try{var r3=await fetch('/api/monitor'),d3=await r3.json();h+='<div class=section><h2>监控数据</h2>';
if(d3.tier1&&d3.tier1.summary)h+='<div class=stat-c><div class=n>'+d3.tier1.summary.ok+'/'+d3.tier1.summary.total+'</div><div class=l>T1 健康</div></div>';
if(d3.tier2&&d3.tier2.summary)h+='<div class=stat-c style=margin-top:8px><div class=n>'+d3.tier2.summary.ok+'/'+d3.tier2.summary.total+'</div><div class=l>T2 日报</div></div>';
if(d3.tasks&&d3.tasks.length){h+='<div style=margin-top:12px><h2>定时任务</h2>';for(var i=0;i<d3.tasks.length;i++){var t=d3.tasks[i];h+='<div style=background:var(--card);border:1px solid var(--border);border-radius:6px;padding:8px 12px;margin:4px 0><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>';}h+='</div>';}
h+='</div>';}catch(e){}
if(!h)h='<span class=dim>No data</span>';
fill('health',h)}catch(e){fill('health','<span class=tag r>Error</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','<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>';
@@ -330,57 +340,45 @@ function _renderUsageMetric(label,m,barColor){
+'<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}
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='<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='';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>';
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>';
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]()}));}
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 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')}}
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='<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;}
@@ -396,7 +394,4 @@ 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>