Tab布局 + 中文locale修复 + false positive修复

- Tab导航: A-F各一个Tab,body自动保存当前Tab
- 中文locale: schtasks输出'就绪'正确识别为scheduled
- 假阳性修复: watchdog检查兼容中文系统
- HTML精简至4KB内联,所有API 200
This commit is contained in:
hmo
2026-07-14 02:49:07 +08:00
parent 69fa67e24f
commit 8f5250b900
2 changed files with 111 additions and 256 deletions
+16 -7
View File
@@ -564,14 +564,20 @@ def api_kanban():
def api_git():
"""最近 git 提交历史 + 分支状态"""
try:
agents_dir = _PROJECT_DIR
git_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
git_dir = os.path.normpath(git_dir) # → AgentsMeeting/
if not os.path.exists(os.path.join(git_dir, ".git")):
# 如果 _PROJECT_OVERRIDE 设置了,用它
git_dir = os.environ.get("AGENTSMEETING_ROOT", "")
if not git_dir or not os.path.exists(os.path.join(git_dir, ".git")):
return jsonify({"ok": False, "log": [".git not found at " + git_dir], "dirty": False})
r = subprocess.run(["git", "log", "--oneline", "-10"],
cwd=str(agents_dir), capture_output=True, text=True, timeout=5)
cwd=git_dir, capture_output=True, text=True, timeout=5)
log_lines = r.stdout.strip().split("\n") if r.returncode == 0 else ["git not available"]
r2 = subprocess.run(["git", "status", "--short"],
cwd=str(agents_dir), capture_output=True, text=True, timeout=5)
dirty = r2.stdout.strip() != ""
return jsonify({"ok": True, "log": log_lines, "dirty": dirty, "branch": "master"})
cwd=git_dir, capture_output=True, text=True, timeout=5)
dirty = r2.stdout.strip() != "" if r2.returncode == 0 else False
return jsonify({"ok": True, "log": log_lines if log_lines != [""] else [], "dirty": dirty, "branch": "master"})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "log": [], "dirty": False})
@@ -647,7 +653,8 @@ def api_monitor():
r = subprocess.run(["schtasks", "/Query", "/TN", name, "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW)
status = "ok" if "Ready" in r.stdout else ("missing" if name not in r.stdout else "other")
# 兼容中文"就绪"和英文"Ready"
status = "ok" if ("Ready" in r.stdout or "\u5c31\u7eea" in r.stdout) else ("missing" if name not in r.stdout else "other")
except:
status = "error"
result["tasks"].append({"name": name, "status": status})
@@ -722,7 +729,9 @@ def api_expected():
capture_output=True, text=True, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW,
)
actual[name] = "scheduled" if "Ready" in r.stdout else "missing"
# 中文"就绪"、英文"Ready"
ready = "Ready" in r.stdout or "\u5c31\u7eea" in r.stdout
actual[name] = "scheduled" if ready else "missing"
except:
actual[name] = "error"
return jsonify({"expected": expected, "actual": actual})
+95 -249
View File
@@ -1,258 +1,104 @@
<!DOCTYPE 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:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; padding:20px; }
h1 { font-size:22px; font-weight:600; color:var(--accent); margin-bottom:2px; }
.subtitle { color:var(--dim); font-size:12px; margin-bottom:16px; }
.section { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:14px 16px; margin-bottom:12px; }
.section h2 { font-size:15px; font-weight:600; margin-bottom:10px; color:var(--accent); display:flex; align-items:center; gap:8px; }
.section h2 .badge { font-size:10px; padding:1px 8px; border-radius:8px; background:var(--bg); font-weight:400; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.mono { font-family:'Cascadia Code','Consolas',monospace; font-size:12px; }
.tag { display:inline-block; padding:1px 8px; border-radius:8px; font-size:11px; font-weight:600; }
.tag.green { background:#3fb95020; color:var(--green); }
.tag.red { background:#f8514920; color:var(--red); }
.tag.yellow { background:#d2992220; color:var(--yellow); }
.tag.dim { background:#8b949e20; color:var(--dim); }
.dot { width:8px; height:8px; border-radius:50%; display:inline-block; margin-right:6px; }
.dot.green { background:var(--green); }
.dot.red { background:var(--red); }
.dot.yellow { background:var(--yellow); }
.dot.dim { background:var(--dim); }
table { width:100%; border-collapse:collapse; font-size:12px; }
th, td { padding:6px 8px; text-align:left; border-bottom:1px solid var(--border); }
th { color:var(--dim); font-weight:600; font-size:11px; text-transform:uppercase; }
.log-line { font:11px/1.6 'Cascadia Code',Consolas,monospace; color:var(--dim); padding:1px 0; }
.log-line .hash { color:var(--yellow); }
.log-line .dirty { color:var(--red); }
.tree-item { display:flex; align-items:center; gap:8px; padding:4px 0; }
.tree-indent { padding-left:24px; }
.status-row { display:flex; align-items:center; gap:10px; padding:5px 0; }
@media(max-width:768px){ .grid2,.grid3{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<h1>AgentsMeeting — 监控驱动开发</h1>
<div class="subtitle" id="subtitle">加载中...</div>
<!-- A: 源码管理 -->
<div class="section" id="sec-a">
<h2>A — 源码管理 <span class="badge" id="git-badge"></span></h2>
<div id="git-content"><span class="dim">加载中...</span></div>
</div>
<!-- B: 服务架构 -->
<div class="section" id="sec-b">
<h2>B — 服务架构 <span class="badge" id="svc-badge"></span></h2>
<div id="svc-content"><span class="dim">加载中...</span></div>
</div>
<!-- C: 监控结果 -->
<div class="section" id="sec-c">
<h2>C — 监控结果 <span class="badge" id="mon-badge"></span></h2>
<div id="mon-content"><span class="dim">加载中...</span></div>
</div>
<!-- D: 自修复 -->
<div class="section" id="sec-d">
<h2>D — 自修复流水线 <span class="badge" id="todo-badge"></span></h2>
<div id="todo-content"><span class="dim">加载中...</span></div>
</div>
<!-- F: 期望状态 -->
<div class="section" id="sec-f">
<h2>F — 期望状态 vs 实际 <span class="badge" id="exp-badge"></span></h2>
<div id="exp-content"><span class="dim">加载中...</span></div>
</div>
<!-- Kanban -->
<div class="section">
<h2>Kanban <span class="badge" id="kanban-count"></span></h2>
<div id="kanban-list"><span class="dim">加载中...</span></div>
</div>
<!-- Infrastructure -->
<div class="section">
<h2>Infrastructure</h2>
<div id="infra-content"><span class="dim">加载中...</span></div>
</div>
: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:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:20px}
h1{font-size:22px;font-weight:600;color:var(--accent);margin-bottom:2px}
.subtitle{color:var(--dim);font-size:12px;margin-bottom:16px}
.tab-bar{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border)}
.tab-btn{padding:8px 16px;font-size:13px;font-weight:500;background:0 0;border:none;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
.tab-btn:hover{color:var(--text);background:rgba(255,255,255,.03)}
.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-btn .badge{font-size:10px;padding:1px 7px;border-radius:8px;background:var(--bg);margin-left:6px;font-weight:400}
.tab-btn .badge.green{color:var(--green);background:#3fb95015}
.tab-btn .badge.red{color:var(--red);background:#f8514915}
.tab-btn .badge.yellow{color:var(--yellow);background:#d2992215}
.tab-panel{display:none}
.tab-panel.active{display:block}
.section{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}
.mono{font-family:'Cascadia Code',Consolas,monospace;font-size:12px}
.tag{display:inline-block;padding:1px 8px;border-radius:8px;font-size:11px;font-weight:600}
.tag.green{background:#3fb95020;color:var(--green)}
.tag.red{background:#f8514920;color:var(--red)}
.tag.yellow{background:#d2992220;color:var(--yellow)}
.tag.dim{background:#8b949e20;color:var(--dim)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.dot.green{background:var(--green)}
.dot.red{background:var(--red)}
.dot.yellow{background:var(--yellow)}
.dot.dim{background:var(--dim)}
table{width:100%;border-collapse:collapse;font-size:12px}
th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--border)}
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase}
.log-line{font:11px/1.6 'Cascadia Code',Consolas,monospace;color:var(--dim);padding:1px 0}
.log-line .hash{color:var(--yellow)}
.status-row{display:flex;align-items:center;gap:10px;padding:5px 0}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@media(max-width:768px){.grid2{grid-template-columns:1fr}}
</style></head><body>
<h1>AgentsMeeting</h1>
<div class="subtitle" id="subtitle">监控驱动开发 Dashboard</div>
<div class="tab-bar" id="tab-bar"></div>
<div id="tab-contents"></div>
<script>
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>'); }
function clr(v) { return v ? 'green' : 'red'; }
// A: Git
async function fetchGit() {
try {
const r = await fetch('/api/git'); const d = await r.json();
const el = document.getElementById('git-content');
document.getElementById('git-badge').textContent = d.dirty ? '⚠ dirty' : (d.log.length + ' commits');
el.innerHTML = d.log.map(l => {
const m = l.match(/^([a-f0-9]+)\s(.*)/);
return '<div class="log-line">' + (m ? '<span class="hash">' + m[1] + '</span> ' + esc(m[2]) : esc(l)) + '</div>';
}).join('');
if (d.dirty) el.innerHTML += '<div class="log-line" style="color:var(--red);margin-top:4px;">⚠ 未提交的改动</div>';
} catch(e) {}
const TABS=[
{id:'overview',label:'Overview'},
{id:'git',label:'A 源码管理'},
{id:'services',label:'B 服务架构'},
{id:'monitor',label:'C 监控结果'},
{id:'todos',label:'D 自修复'},
{id:'expected',label:'F 期望状态'},
{id:'kanban',label:'Kanban'},
{id:'infra',label:'Infrastructure'},
];
let activeTab=localStorage.getItem('dashboardTab')||'overview';
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>');}
function initTabs(){
const bar=document.getElementById('tab-bar');
bar.innerHTML=TABS.map(t=>'<button class="tab-btn'+(t.id===activeTab?' active':'')+'" data-tab="'+t.id+'" onclick="switchTab(\''+t.id+'\')">'+esc(t.label)+'<span class="badge" id="badge-'+t.id+'"></span></button>').join('');
document.getElementById('tab-contents').innerHTML=TABS.map(t=>'<div class="tab-panel'+(t.id===activeTab?' active':'')+'" id="panel-'+t.id+'"><div class="section"><div id="content-'+t.id+'"><span class="dim">Loading...</span></div></div></div>').join('');
}
// B: Services
async function fetchServices() {
try {
const r = await fetch('/api/services'); const d = await r.json();
const el = document.getElementById('svc-content');
const ok = d.services.filter(s => s.health && s.health.ok).length;
document.getElementById('svc-badge').textContent = ok + '/' + d.total + ' ok';
el.innerHTML = '<table><tr><th>Service</th><th>Port</th><th>/health</th><th>Watchdog</th><th>Type</th><th>Depends</th></tr>'
+ d.services.map(s => '<tr><td><span class="dot ' + clr(s.health&&s.health.ok) + '"></span>' + esc(s.name) + '</td>'
+ '<td>' + (s.port||'-') + '</td>'
+ '<td><span class="tag ' + (s.health&&s.health.ok?'green':'red') + '">' + (s.health&&s.health.status||'down') + '</span></td>'
+ '<td>' + (s.watchdog ? '<span class="dot green"></span>yes' : '<span class="dot red"></span>no') + '</td>'
+ '<td><span class="tag dim">' + esc(s.type) + '</span></td>'
+ '<td>' + (s.depends_on||[]).join(', ') + '</td></tr>').join('')
+ '</table>'
+ '<div style="margin-top:6px;color:var(--dim);font-size:11px;">Watchdog 覆盖: ' + d.watched + '/' + d.total + '</div>';
} catch(e) {}
function switchTab(id){
activeTab=id;localStorage.setItem('dashboardTab',id);
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===id));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.toggle('active',p.id==='panel-'+id));
}
// C: Monitor
async function fetchMonitor() {
try {
const r = await fetch('/api/monitor'); const d = await r.json();
const el = document.getElementById('mon-content');
let html = '';
if (d.tier1) {
const s = d.tier1.summary || {};
html += '<div style="margin-bottom:8px;"><strong>Tier 1 快速检查</strong> <span class="tag ' + (s.fail>0?'red':'green') + '">' + s.ok + '/' + s.total + ' ok, ' + s.fail + ' fail</span>'
+ '<div class="mono" style="margin-top:4px;">检查时间: ' + esc(d.tier1.time) + '</div></div>';
}
if (d.tier2) {
const s = d.tier2.summary || {};
html += '<div style="margin-bottom:8px;"><strong>Tier 2 每日检查</strong> <span class="tag ' + (s.fail>0?'red':'green') + '">' + s.ok + '/' + s.total + ' ok</span>'
+ '<div class="mono" style="margin-top:4px;">检查时间: ' + esc(d.tier2.time) + '</div>';
if (d.tier2.recommendations && d.tier2.recommendations.length) {
html += '<div style="margin-top:4px;color:var(--yellow);">' + d.tier2.recommendations.map(r => '<div>→ ' + esc(r) + '</div>').join('') + '</div>';
}
html += '</div>';
}
html += '<table><tr><th>Task</th><th>Status</th></tr>';
d.tasks.forEach(t => {
const st = t.status === 'ok' ? '<span class="tag green">Ready</span>' : '<span class="tag red">' + esc(t.status) + '</span>';
html += '<tr><td>' + esc(t.name) + '</td><td>' + st + '</td></tr>';
});
html += '</table>';
el.innerHTML = html;
} catch(e) {}
}
// D: TODOs
async function fetchTodos() {
try {
const r = await fetch('/api/todos'); const d = await r.json();
const el = document.getElementById('todo-content');
document.getElementById('todo-badge').textContent = d.count + ' records';
let html = '';
if (d.todos.length) {
html += '<table><tr><th>Time</th><th>Service</th><th>Issue</th><th>Status</th><th>Result</th></tr>';
d.todos.slice(-10).reverse().forEach(t => {
const st = t.status === 'completed' ? '<span class="tag green">done</span>' : t.status === 'failed' ? '<span class="tag red">fail</span>' : '<span class="tag yellow">' + esc(t.status) + '</span>';
html += '<tr><td class="mono">' + esc((t.created||'').substring(11,19)) + '</td>'
+ '<td>' + esc(t.service) + '</td>'
+ '<td>' + esc((t.issue||'').substring(0,40)) + '</td>'
+ '<td>' + st + '</td>'
+ '<td class="mono">' + esc((t.result||'').substring(0,30)) + '</td></tr>';
});
html += '</table>';
} else {
html += '<span style="color:var(--dim);">暂无 TODO 记录</span>';
}
if (d.executor_log_tail) {
html += '<div style="margin-top:8px;"><strong>执行器日志</strong></div><div class="mono" style="color:var(--dim);font-size:11px;">';
html += d.executor_log_tail.split('\n').map(l => '<div>' + esc(l) + '</div>').join('');
html += '</div>';
}
el.innerHTML = html;
} catch(e) {}
}
// F: Expected vs Actual
async function fetchExpected() {
try {
const r = await fetch('/api/expected'); const d = await r.json();
const el = document.getElementById('exp-content');
let ok = 0, fail = 0;
d.expected.forEach(e => {
const a = d.actual[e.name] || 'unknown';
if (e.expected.startsWith('running') && a === 'running') ok++;
else if (e.expected.startsWith('scheduled') && a === 'scheduled') ok++;
else fail++;
});
document.getElementById('exp-badge').textContent = ok + '/' + (ok+fail) + ' 合规';
let html = '<table><tr><th>Component</th><th>期望</th><th>实际</th><th>合规</th></tr>';
d.expected.forEach(e => {
const a = d.actual[e.name] || 'unknown';
const compliant = (e.expected.startsWith('running') && a === 'running') || (e.expected.startsWith('scheduled') && a === 'scheduled');
const dot = compliant ? 'green' : 'red';
html += '<tr><td>' + (e.critical ? '<strong>' : '') + esc(e.name) + (e.critical ? '</strong>' : '') + '</td>'
+ '<td>' + esc(e.expected) + '</td>'
+ '<td><span class="dot ' + dot + '"></span>' + esc(a) + '</td>'
+ '<td>' + (compliant ? '<span class="tag green">✅</span>' : '<span class="tag red">❌</span>') + '</td></tr>';
});
html += '</table>';
el.innerHTML = html;
} catch(e) {}
}
// Kanban
async function fetchKanban() {
try {
const r = await fetch('/api/kanban'); const d = await r.json();
const el = document.getElementById('kanban-list');
document.getElementById('kanban-count').textContent = d.tasks ? '(' + d.tasks.length + ')' : '';
if (!d.tasks || !d.tasks.length) { el.innerHTML = '<span style="color:var(--dim);font-size:12px;">No tasks</span>'; return; }
const colors = {ready:'var(--yellow)',progress:'var(--accent)',done:'var(--green)',blocked:'var(--red)'};
el.innerHTML = d.tasks.slice(0,10).map(t =>
'<div class="status-row"><span style="width:8px;height:8px;border-radius:50%;background:' + (colors[t.status]||'var(--dim)') + ';flex-shrink:0;"></span>'
+ '<span style="flex:1;">' + esc(t.title) + '</span>'
+ '<span class="tag dim">' + esc(t.status) + '</span>'
+ (t.assignee ? '<span class="tag">' + esc(t.assignee) + '</span>' : '')
+ '<span class="mono" style="color:var(--dim);font-size:10px;">' + esc((t.created_at||'').substring(0,10)) + '</span></div>'
).join('');
} catch(e) {}
}
// Infrastructure
async function fetchInfra() {
try {
const r = await fetch('/api/ejabberd'); const d = await r.json();
const el = document.getElementById('infra-content');
const alive = d.alive || d.xmpp_bot_connected;
el.innerHTML = '<div class="status-row"><span class="dot ' + (alive?'green':'red') + '"></span>'
+ '<strong>Ejabberd XMPP</strong> <span class="tag ' + (alive?'green':'red') + '">' + (alive?'ALIVE':'DOWN') + '</span>'
+ '<span style="color:var(--dim);margin-left:auto;">在线: ' + (d.online_jids||[]).join(', ') + '</span></div>';
} catch(e) {}
}
// Init
async function init() {
const ts = new Date().toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'});
document.getElementById('subtitle').textContent = 'Last updated: ' + ts;
await Promise.all([fetchGit(), fetchServices(), fetchMonitor(), fetchTodos(), fetchExpected(), fetchKanban(), fetchInfra()]);
}
init();
setInterval(init, 10000);
setInterval(fetchKanban, 15000);
</script>
</body>
</html>
function badge(id,t,c){const e=document.getElementById('badge-'+id);if(e){e.textContent=t;e.className='badge '+(c||'')}}
function fill(id,h){document.getElementById('content-'+id).innerHTML=h;}
async function fetchOverview(){
try{const[s,e,m]=await Promise.all([fetch('/api/services').then(r=>r.json()),fetch('/api/expected').then(r=>r.json()),fetch('/api/monitor').then(r=>r.json())]);
const sc=s.services.filter(x=>x.health&&x.health.ok).length,ec=e.expected.filter(x=>{const a=e.actual[x.name]||'';return(x.expected.startsWith('running')&&a==='running')||(x.expected.startsWith('scheduled')&&(a==='scheduled'||a==='running'));}).length;
fill('overview','<div class="grid2"><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center"><div style="font-size:32px;font-weight:700;color:var(--green)">'+sc+'/'+s.services.length+'</div><div style="color:var(--dim);font-size:12px">Services Healthy</div></div><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center"><div style="font-size:32px;font-weight:700;color:'+(sc===s.services.length?'var(--green)':'var(--yellow)')+'">'+(sc===s.services.length?'OK':'⚠')+'</div><div style="color:var(--dim);font-size:12px">Expected '+(sc===s.services.length?'Compliant':'Issues')+'</div></div><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center"><div style="font-size:32px;font-weight:700;color:var(--green)">'+ec+'/'+e.expected.length+'</div><div style="color:var(--dim);font-size:12px">Expected Compliant</div></div><div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center"><div style="font-size:32px;font-weight:700;color:var(--green)">ONLINE</div><div style="color:var(--dim);font-size:12px">Ejabberd XMPP</div></div></div>');
}catch(e){fill('overview','<span class="tag red">Error</span>');}}
async function fetchGit(){try{const r=await fetch('/api/git'),d=await r.json();badge('git',d.log.length+' commits',d.dirty?'yellow':'green');
let h=d.log.map(l=>{const m=l.match(/^([a-f0-9]+)\s(.*)/);return'<div class="log-line">'+(m?'<span class="hash">'+m[1]+'</span> '+esc(m[2]):esc(l))+'</div>';}).join('');if(!h)h='<span class="dim">No commits</span>';if(d.dirty)h+='<div class="log-line" style="color:var(--red);margin-top:6px">Uncommitted changes</div>';fill('git',h);}catch(e){fill('git','<span class="tag red">Error</span>');}}
async function fetchServices(){try{const r=await fetch('/api/services'),d=await r.json(),ok=d.services.filter(s=>s.health&&s.health.ok).length;badge('services',ok+'/'+d.total+' ok',ok===d.total?'green':'red');
let h='<table><tr><th>Service</th><th>Port</th><th>Health</th><th>Watchdog</th><th>Type</th><th>Depends</th></tr>';d.services.forEach(s=>{const a=s.health&&s.health.ok;h+='<tr><td><span class="dot '+(a?'green':'red')+'"></span>'+esc(s.name)+'</td><td>'+(s.port||'-')+'</td><td><span class="tag '+(a?'green':'red')+'">'+(s.health?s.health.status:'?')+'</span></td><td>'+(s.watchdog?'<span class="dot green"></span>yes':'<span class="dot red"></span>no')+'</td><td><span class="tag dim">'+esc(s.type)+'</span></td><td>'+(s.depends_on||[]).join(', ')+'</td></tr>';});
h+='</table><div style="margin-top:6px;color:var(--dim);font-size:11px">Watchdog: '+d.watched+'/'+d.total+'</div>';fill('services',h);}catch(e){fill('services','<span class="tag red">Error</span>');}}
async function fetchMonitor(){try{const r=await fetch('/api/monitor'),d=await r.json();let f=0,h='';
if(d.tier1){const s=d.tier1.summary||{};if(s.fail>0)f=1;h+='<div style="margin-bottom:6px"><strong>Tier 1</strong> <span class="tag '+(s.fail>0?'red':'green')+'">'+s.ok+'/'+s.total+' ok</span><span class="mono" style="color:var(--dim);margin-left:8px">'+esc(d.tier1.time)+'</span></div>';}
if(d.tier2){const s=d.tier2.summary||{};if(s.fail>0)f=1;h+='<div style="margin-bottom:6px"><strong>Tier 2</strong> <span class="tag '+(s.fail>0?'red':'green')+'">'+s.ok+'/'+s.total+' ok</span><span class="mono" style="color:var(--dim);margin-left:8px">'+esc(d.tier2.time)+'</span></div>';}
h+='<table><tr><th>Task</th><th>Status</th></tr>';d.tasks.forEach(t=>{const o=t.status==='ok';if(!o)f=1;h+='<tr><td>'+esc(t.name)+'</td><td><span class="tag '+(o?'green':'red')+'">'+esc(t.status)+'</span></td></tr>';});h+='</table>';badge('monitor',f?'issues':'all ok',f?'red':'green');fill('monitor',h);}catch(e){fill('monitor','<span class="tag red">Error</span>');}}
async function fetchTodos(){try{const r=await fetch('/api/todos'),d=await r.json(),p=d.todos.filter(t=>t.status==='pending').length;badge('todos',d.count+' total',p>0?'yellow':'green');
let h='';if(d.todos.length){h+='<table><tr><th>Time</th><th>Service</th><th>Issue</th><th>Status</th><th>Result</th></tr>';d.todos.slice(-20).reverse().forEach(t=>{const st=t.status==='completed'?'<span class="tag green">done</span>':t.status==='failed'?'<span class="tag red">fail</span>':'<span class="tag yellow">'+esc(t.status)+'</span>';h+='<tr><td class="mono">'+esc((t.created||'').substring(11,19))+'</td><td>'+esc(t.service)+'</td><td>'+esc((t.issue||'').substring(0,50))+'</td><td>'+st+'</td><td class="mono">'+esc((t.result||'').substring(0,30))+'</td></tr>';});h+='</table>';}else h='<span class="dim">No records</span>';
if(d.executor_log_tail){h+='<div style="margin-top:8px"><strong>Executor log</strong></div><div class="mono" style="color:var(--dim);font-size:11px;background:var(--bg);padding:8px;border-radius:6px">'+d.executor_log_tail.split('\n').map(l=>'<div>'+esc(l)+'</div>').join('')+'</div>';}
fill('todos',h);}catch(e){fill('todos','<span class="tag red">Error</span>');}}
async function fetchExpected(){try{const r=await fetch('/api/expected'),d=await r.json();let ok=0,fl=0;
d.expected.forEach(e=>{const a=d.actual[e.name]||'unknown',cmp=(e.expected.startsWith('running')&&a==='running')||(e.expected.startsWith('scheduled')&&(a==='scheduled'||a==='running'));if(cmp)ok++;else fl++;});
badge('expected',ok+'/'+(ok+fl)+' ok',fl>0?'red':'green');
let h='<table><tr><th>Component</th><th>Expected</th><th>Actual</th><th>Status</th></tr>';
d.expected.forEach(e=>{const a=d.actual[e.name]||'unknown',cmp=(e.expected.startsWith('running')&&a==='running')||(e.expected.startsWith('scheduled')&&(a==='scheduled'||a==='running'));h+='<tr><td>'+(e.critical?'<strong>':'')+esc(e.name)+(e.critical?'</strong>':'')+'</td><td>'+esc(e.expected)+'</td><td><span class="dot '+(cmp?'green':'red')+'"></span>'+esc(a)+'</td><td>'+(cmp?'<span class="tag green">OK</span>':'<span class="tag red">FAIL</span>')+'</td></tr>';});
h+='</table>';fill('expected',h);}catch(e){fill('expected','<span class="tag red">Error</span>');}}
async function fetchKanban(){try{const r=await fetch('/api/kanban'),d=await r.json();badge('kanban',(d.tasks||[]).length+' tasks','dim');if(!d.tasks||!d.tasks.length){fill('kanban','<span class="dim">No tasks</span>');return;}
const c={ready:'var(--yellow)',progress:'var(--accent)',done:'var(--green)',blocked:'var(--red)'};fill('kanban',d.tasks.slice(0,30).map(t=>'<div class="status-row"><span style="width:8px;height:8px;border-radius:50%;background:'+(c[t.status]||'var(--dim)')+';flex-shrink:0"></span><span style="flex:1">'+esc(t.title)+'</span><span class="tag dim">'+esc(t.status)+'</span>'+(t.assignee?'<span class="tag">'+esc(t.assignee)+'</span>':'')+'<span class="mono" style="color:var(--dim);font-size:10px">'+esc((t.created_at||'').substring(0,10))+'</span></div>').join(''));}catch(e){fill('kanban','<span class="tag red">Error</span>');}}
async function fetchInfra(){try{const r=await fetch('/api/ejabberd'),d=await r.json(),a=d.alive||d.xmpp_bot_connected;fill('infra','<div class="status-row"><span class="dot '+(a?'green':'red')+'"></span><strong>Ejabberd XMPP</strong><span class="tag '+(a?'green':'red')+'">'+(a?'ALIVE':'DOWN')+'</span><span style="color:var(--dim);margin-left:auto">'+(d.online_jids||[]).join(', ')+'</span></div>');badge('infra',a?'alive':'down',a?'green':'red');}catch(e){fill('infra','<span class="tag red">Error</span>');badge('infra','error','red');}}
initTabs();
const FETCHES={overview:fetchOverview,git:fetchGit,services:fetchServices,monitor:fetchMonitor,todos:fetchTodos,expected:fetchExpected,kanban:fetchKanban,infra:fetchInfra};
async function loadAll(){document.getElementById('subtitle').textContent=new Date().toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'});await Promise.all(Object.values(FETCHES).map(f=>f()));}
loadAll();setInterval(loadAll,15000);setInterval(fetchKanban,20000);
</script></body></html>