Files
oc-monitor/public/index.html

321 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OpenClaw Mission Control</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#060a10;--card:#0d1520;--card2:#111c2e;--border:#1a2740;--txt:#c8d6e5;--dim:#5a6f88;--neon:#00e5ff;--green:#00ff88;--warn:#ffb020;--err:#ff4466;--purple:#b8a9ff;--peach:#ffb088}
[data-theme="light"]{--bg:#f0f2f5;--card:#fff;--card2:#f5f7fa;--border:#d8dde6;--txt:#1a1a2e;--dim:#6b7b8d;--neon:#0088cc;--green:#00a856;--warn:#e69500;--err:#d63050;--purple:#7c5cbf;--peach:#d07040}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--txt);padding:20px}
.wrap{max-width:1200px;margin:0 auto}
.hdr{margin-bottom:24px;padding:20px 0 16px;border-bottom:1px solid var(--border)}
.hdr h1{font-size:1.8em;font-weight:800;background:linear-gradient(135deg,var(--neon),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;display:inline}
.hdr-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.hdr-links{display:flex;gap:10px;align-items:center;margin-left:auto}
.hdr-links a,.hdr-links span{font-size:.8em;color:var(--dim);text-decoration:none;cursor:pointer}
.hdr-links a:hover,.hdr-links span:hover{color:var(--neon)}
.sub{color:var(--dim);font-size:.78em;margin-top:6px;letter-spacing:.3px}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:24px}
.st{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;text-align:center}
.st .n{font-size:1.6em;font-weight:800;font-family:'SF Mono',Menlo,monospace}.st .l{font-size:.72em;color:var(--dim);margin-top:4px;font-weight:500}
.s1 .n{color:var(--neon)}.s2 .n{color:var(--purple)}.s3 .n{color:var(--peach)}
.s4 .n{color:var(--green)}.s5 .n{color:var(--warn)}.s6 .n{color:var(--err)}
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid var(--border)}
.tab{padding:10px 24px;cursor:pointer;color:var(--dim);font-size:.88em;font-weight:500;border-bottom:2px solid transparent;transition:color .2s}
.tab.on{color:var(--neon);border-bottom-color:var(--neon)}
.tab:hover{color:var(--txt)}
.tp{display:none}.tp.on{display:block}
.nodes{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
.nd{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;display:flex;flex-direction:column}
.nd.offline{border-color:rgba(255,68,102,.3)}
.nd-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.nd-nm{font-size:1.05em;font-weight:600;cursor:pointer}.nd-nm:hover{color:var(--neon)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px}
.dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}.dot.off{background:var(--err)}
.tg{display:flex;gap:4px;flex-wrap:nowrap;margin-bottom:8px;overflow:hidden}
.tg span{font-size:.65em;padding:2px 8px;border-radius:10px;background:var(--card2);color:var(--dim);border:1px solid var(--border);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
.tg .ms{color:var(--neon);border-color:rgba(0,229,255,.3)}
.tg .wk{color:var(--purple);border-color:rgba(184,169,255,.3)}
.hb{height:28px;display:flex;align-items:end;gap:1px;overflow:hidden;margin-bottom:8px}
.hb i{width:3px;border-radius:1px}
.sec{font-size:.72em;color:var(--dim);margin-bottom:4px}
.pv-wrap{flex:1;min-height:0;overflow-y:auto;margin-bottom:8px}
.pv{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:.78em}
.pv-l{display:flex;gap:5px;align-items:center}
.pv-default{color:var(--warn)}
.pm{color:var(--dim);font-size:.85em}
.ok{color:var(--green)}.er{color:var(--err)}
.gs{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:10px 0}
.g{font-size:.72em}.g-l{color:var(--dim);font-weight:500}.g-t{height:5px;background:var(--card2);border-radius:3px;margin:3px 0}
.g-f{height:100%;border-radius:2px;transition:width .8s ease,background .5s}.fg{background:var(--green)}.fb{background:var(--neon)}.fw{background:var(--warn)}.fr{background:var(--err)}
.g-n{font-weight:600}
.tks{display:flex;gap:10px;margin:10px 0}
.tk{flex:1;text-align:center;background:var(--card2);border-radius:8px;padding:8px 5px}
.tk-l{font-size:.65em;color:var(--dim);font-weight:500}.tk-v{font-size:.9em;font-weight:700;color:var(--neon);font-family:'SF Mono',Menlo,monospace}
.nd-f{display:flex;gap:10px;font-size:.7em;color:var(--dim);flex-wrap:wrap;margin-top:auto;padding-top:8px;border-top:1px solid var(--border)}
.mx{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
.mx-h{font-size:.9em;font-weight:600;margin-bottom:10px;color:var(--neon)}
table{width:100%;border-collapse:collapse;font-size:.8em}
th{text-align:left;padding:8px 10px;color:var(--dim);border-bottom:1px solid var(--border);font-weight:600}
td{padding:8px 10px;border-bottom:1px solid rgba(26,39,64,.5)}
.lt{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px}
.lt-h{font-size:.95em;font-weight:600;margin-bottom:12px;color:var(--purple)}
.lf{display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.lf select{background:var(--card2);border:1px solid var(--border);color:var(--txt);padding:6px 12px;border-radius:8px;font-size:.8em;font-family:inherit}
.pager{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:14px;font-size:.78em}
.pager button{background:var(--card2);border:1px solid var(--border);color:var(--txt);padding:5px 14px;border-radius:8px;cursor:pointer;font-family:inherit;transition:border-color .2s}
.pager button:disabled{opacity:.3;cursor:default}
.pager button.pg-on{border-color:var(--neon);color:var(--neon)}
.pager span{color:var(--dim)}
.provs{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
.pcard{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px}
.pcard-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
.pcard-nm{font-size:1.1em;font-weight:700;color:var(--neon)}
.pcard-cnt{font-size:.7em;color:var(--dim)}
.pcard-row{display:flex;justify-content:space-between;align-items:center;padding:5px 0;font-size:.8em;border-bottom:1px solid rgba(26,39,64,.3)}
.pcard-row:last-child{border-bottom:none}
.pcard-node{color:var(--dim)}
.pcard-model{color:var(--purple);font-size:.68em;line-height:1.4}
.pcard-models{margin-top:2px}
.pcard-ms{color:var(--dim);font-size:.75em}
@media(max-width:768px){.stats{grid-template-columns:repeat(3,1fr)}.nodes{grid-template-columns:1fr}.provs{grid-template-columns:1fr}.hdr h1{font-size:1.4em}}
.login-mask{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:999}
.login-box{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px;text-align:center;max-width:360px;width:90%}
.login-box h2{font-size:1.4em;font-weight:800;background:linear-gradient(135deg,var(--neon),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:8px}
.login-box p{color:var(--dim);font-size:.82em;margin-bottom:20px}
.login-box input{width:100%;padding:10px 14px;background:var(--card2);border:1px solid var(--border);border-radius:8px;color:var(--txt);font-size:.9em;font-family:inherit;outline:none}
.login-box input:focus{border-color:var(--neon)}
.login-box button{width:100%;margin-top:12px;padding:10px;background:linear-gradient(135deg,var(--neon),var(--purple));border:none;border-radius:8px;color:#fff;font-weight:600;font-size:.9em;cursor:pointer}
.login-err{color:var(--err);font-size:.78em;margin-top:8px;display:none}
</style>
</head>
<body>
<div class="login-mask" id="loginMask" style="display:none">
<div class="login-box"><h2>🔓 解锁完整视图</h2><p>输入令牌查看 IP 等敏感信息</p>
<input id="tokenInput" type="password" placeholder="Token" onkeydown="if(event.key==='Enter')doLogin()">
<button onclick="doLogin()">解锁</button>
<div class="login-err" id="loginErr">令牌无效</div></div></div>
<div class="wrap" id="mainWrap">
<div class="hdr">
<div class="hdr-row"><h1>🐾 OpenClaw Mission Control</h1>
<div class="hdr-links"><a href="/admin.html">⚙️ Admin</a><span id="lockBtn" onclick="$('#loginMask').style.display='flex'" style="cursor:pointer">🔒</span><span id="themeBtn" onclick="toggleTheme()">🌙</span></div></div>
<div class="sub" id="subtitle">Loading...</div>
</div>
<div class="stats" id="stats"></div>
<div class="tabs">
<div class="tab on" onclick="sw('nodes')">🖥 节点</div>
<div class="tab" onclick="sw('matrix')">📊 供应商矩阵</div>
<div class="tab" onclick="sw('logs')">📋 请求日志</div>
</div>
<div class="tp on" id="t-nodes"><div class="nodes" id="nodeGrid"></div></div>
<div class="tp" id="t-matrix"><div class="provs" id="provGrid"></div></div>
<div class="tp" id="t-logs"><div class="lt"><div class="lt-h">请求日志</div>
<div class="lf"><select id="fNode" onchange="renderLogs()"><option value="">全部节点</option></select><select id="fUp" onchange="renderLogs()"><option value="">全部供应商</option></select><select id="fRes" onchange="renderLogs()"><option value="">全部结果</option><option value="1">✓ 成功</option><option value="0">✗ 失败</option></select></div>
<table id="logTable"></table><div class="pager" id="pager"></div></div></div>
</div>
<script>
let DATA={nodes:[],stats:{},requests:[]};
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('oc-token')||'';
const authHdr=()=>({headers:{'Authorization':'Bearer '+TOKEN}});
function doLogin(){
const t=$('#tokenInput').value.trim();if(!t)return;
fetch('/api/dashboard',{headers:{'Authorization':'Bearer '+t}}).then(r=>r.json()).then(d=>{
if(!d.authed){$('#loginErr').style.display='block';return}
TOKEN=t;localStorage.setItem('oc-token',t);
$('#loginMask').style.display='none';$('#lockBtn').textContent='🔓';
DATA=d;render();
});
}
function sw(t){document.querySelectorAll('.tab').forEach(e=>e.classList.remove('on'));document.querySelectorAll('.tp').forEach(e=>e.classList.remove('on'));document.querySelector(`.tab[onclick*="${t}"]`).classList.add('on');document.getElementById('t-'+t).classList.add('on');if(t==='logs')loadLogs(logPage)}
function fmtTok(n){return n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':n}
function fmtUp(s){if(!s)return'0s';return s>=1000?(s/1000).toFixed(1)+'s':s+'ms'}
function fmtAge(ts){const d=Math.floor(Date.now()/1000)-ts;if(d<60)return d+'s';if(d<3600)return Math.floor(d/60)+'m';if(d<86400)return Math.floor(d/3600)+'h';return Math.floor(d/86400)+'d '+Math.floor((d%86400)/3600)+'h'}
function gaugeColor(v){return v>85?'fr':v>60?'fw':v>40?'fb':'fg'}
function renderStats(){
const s=DATA.stats||{},ns=DATA.nodes||[];
const online=ns.filter(n=>Date.now()/1000-n.last_seen<120).length;
const todayTok=ns.reduce((a,n)=>a+(n.tok_today||0),0);
const weekTok=ns.reduce((a,n)=>a+(n.tok_week||0),0);
const monthTok=ns.reduce((a,n)=>a+(n.tok_month||0),0);
const totalSess=ns.reduce((a,n)=>a+(n.sessions||0),0);
const totalProvs=ns.reduce((a,n)=>a+JSON.parse(n.providers||'[]').length,0);
$('#stats').innerHTML=[
['s1',online+'/'+ns.length,'在线节点'],
['s2',fmtTok(todayTok),'今日用量'],
['s3',fmtTok(weekTok),'本周用量'],
['s4',fmtTok(monthTok),'本月用量'],
['s5',totalSess,'会话数'],
['s6',totalProvs,'供应商']
].map(([c,n,l])=>`<div class="st ${c}"><div class="n">${n}</div><div class="l">${l}</div></div>`).join('');
}
function renderNodes(){
const now=Date.now()/1000;
$('#nodeGrid').innerHTML=DATA.nodes.map(n=>{
const on=now-n.last_seen<120;
const provs=JSON.parse(n.providers||'[]');
const hbBars=Array.from({length:50},()=>{
const h=on?Math.random()*20+4:0;
return `<i style="height:${h}px;background:var(--${on?'neon':'err'});opacity:${on?.3+Math.random()*.5:.2}"></i>`;
}).join('');
return `<div class="nd${on?'':' offline'}" data-id="${n.id}">
<div class="nd-h"><div><span class="dot ${on?'on':'off'}"></span><span class="nd-nm" title="点击改名">${n.name}</span></div><span style="font-size:.7em;color:var(--${on?'dim':'err'})">${n.host}</span></div>
<div class="tg"><span class="${n.role==='master'?'ms':'wk'}">${n.role}</span><span>OC ${n.oc_version}</span><span>${n.os}</span></div>
<div class="hb">${hbBars}</div>
<div class="sec">供应商</div>
<div class="pv-wrap">${provs.sort((a,b)=>b.default-a.default||(a.name>b.name?1:-1)).map(p=>`<div class="pv"><div class="pv-l">${p.default?'<span class="dot on" style="width:6px;height:6px"></span>':''}<span${p.default?' class="pv-default"':''}>${p.name}</span></div><div>${p.status==='ok'?'<span class="ok">✓</span> <span class="pm">'+p.ms+'ms</span>':'<span class="er">✗</span> <span class="pm">'+(p.err||'离线')+'</span>'}</div></div>`).join('')}</div>
<div class="gs">
${[['cpu',n.cpu],['mem',n.mem],['disk',n.disk],['swap',n.swap]].map(([l,v])=>`<div class="g" data-g="${l}"><span class="g-l">${l}</span><div class="g-t"><div class="g-f ${gaugeColor(v)}" style="width:${v}%"></div></div><span class="g-n">${on?v+'%':'—'}</span></div>`).join('')}
</div>
<div class="tks"><div class="tk"><div class="tk-l">今日</div><div class="tk-v">${fmtTok(n.tok_today)}</div></div><div class="tk"><div class="tk-l">本周</div><div class="tk-v">${fmtTok(n.tok_week)}</div></div><div class="tk"><div class="tk-l">本月</div><div class="tk-v">${fmtTok(n.tok_month)}</div></div></div>
<div class="nd-f"><span>⏱ ${fmtAge(now-n.uptime)}</span><span>📡 ${n.sessions} 会话</span><span>⚡ 网关 ${n.gw_ok?'✓':'✗'}</span><span>🐾 守护 ${n.daemon_ok?'✓':'✗'}</span></div>
</div>`;
}).join('');
}
function renderMatrix(){
const ns=DATA.nodes,provMap=new Map();
ns.forEach(n=>JSON.parse(n.providers||'[]').forEach(p=>{
if(!provMap.has(p.name))provMap.set(p.name,{name:p.name,nodes:[]});
provMap.get(p.name).nodes.push({node:n.name,model:p.model,status:p.status,ms:p.ms,err:p.err,def:p.default});
}));
$('#provGrid').innerHTML=[...provMap.values()].sort((a,b)=>b.nodes.length-a.nodes.length).map(p=>{
const ok=p.nodes.filter(n=>n.status==='ok').length;
return `<div class="pcard">
<div class="pcard-h"><span class="pcard-nm">${p.name}</span><span class="pcard-cnt">${ok}/${p.nodes.length} 在线</span></div>
${p.nodes.sort((a,b)=>b.def-a.def).map(n=>`<div class="pcard-row"><div><span${n.def?' style="color:var(--warn)"':''}>${n.node}</span><div class="pcard-models">${n.model.split(' | ').map(m=>'<div class="pcard-model">'+m.replace(/claude-/g,'c-').replace(/-2025\d{4}/g,'')+'</div>').join('')}</div></div><div>${n.status==='ok'?'<span class="ok">✓</span> <span class="pcard-ms">'+n.ms+'ms</span>':'<span class="er">✗</span> <span class="pcard-ms">'+(n.err||'离线')+'</span>'}</div></div>`).join('')}
</div>`;
}).join('');
}
let logPage=1,logPages=1,logTotal=0;
async function loadLogs(page){
page=page||1;
try{
const r=await fetch('/api/requests?page='+page+'&size=50',authHdr());
const d=await r.json();
DATA.requests=d.requests||[];logPage=d.page;logPages=d.pages;logTotal=d.total;
}catch(e){console.error(e)}
renderLogs();
}
function renderLogs(){
const reqs=DATA.requests||[];
const nodes=(DATA.nodes||[]).map(n=>n.name).sort();
const ups=[...new Set((DATA.nodes||[]).flatMap(n=>JSON.parse(n.providers||'[]').map(p=>p.name)))].sort();
const fN=$('#fNode'),fU=$('#fUp');
const nv=fN.value,uv=fU.value;
fN.innerHTML='<option value="">全部节点</option>'+nodes.map(n=>`<option${n===nv?' selected':''}>${n}</option>`).join('');
fU.innerHTML='<option value="">全部供应商</option>'+ups.map(u=>`<option${u===uv?' selected':''}>${u}</option>`).join('');
const fR=$('#fRes').value;
const filtered=reqs.filter(r=>{
if(nv&&(r.node_name||r.node_id)!==nv)return false;
if(uv&&r.upstream!==uv)return false;
if(fR!==''&&String(r.success?1:0)!==fR)return false;
return true;
});
let h='<thead><tr><th>时间</th><th>节点</th><th>供应商</th><th>模型</th><th>结果</th><th>输入</th><th>输出</th><th>缓存读</th><th>缓存写</th></tr></thead><tbody>';
filtered.forEach(r=>{
const t=new Date(r.ts*1000).toLocaleTimeString('zh-CN');
h+='<tr><td>'+t+'</td><td>'+(r.node_name||r.node_id)+'</td><td>'+r.upstream+'</td><td>'+r.model+'</td>';
h+='<td class="'+(r.success?'ok':'er')+'">'+(r.success?'✓':'✗')+'</td>';
h+='<td>'+fmtTok(r.input_tokens)+'</td><td>'+fmtTok(r.output_tokens)+'</td>';
h+='<td style="color:var(--purple)">'+fmtTok(r.cache_read||0)+'</td><td style="color:var(--peach)">'+fmtTok(r.cache_write||0)+'</td></tr>';
});
$('#logTable').innerHTML=h+'</tbody>';
// pager
let pg='<button onclick="loadLogs(1)"'+(logPage<=1?' disabled':'')+'>«</button>';
pg+='<button onclick="loadLogs('+(logPage-1)+')"'+(logPage<=1?' disabled':'')+'></button>';
const start=Math.max(1,logPage-2),end=Math.min(logPages,logPage+2);
for(let i=start;i<=end;i++)pg+='<button class="'+(i===logPage?'pg-on':'')+'" onclick="loadLogs('+i+')">'+i+'</button>';
pg+='<button onclick="loadLogs('+(logPage+1)+')"'+(logPage>=logPages?' disabled':'')+'></button>';
pg+='<button onclick="loadLogs('+logPages+')"'+(logPage>=logPages?' disabled':'')+'>»</button>';
pg+='<span>共 '+logTotal+' 条</span>';
$('#pager').innerHTML=pg;
}
function animateNum(el,to,suffix=''){
el._real=to;
el.textContent=to+suffix;
}
// Live jitter: every 1s, all gauge numbers wobble around real value
setInterval(()=>{
document.querySelectorAll('.g-n').forEach(el=>{
if(el._real==null||el.textContent==='—')return;
const r=el._real,jit=r*(0.02*Math.random()-0.01);
el.textContent=Math.max(0,r+jit).toFixed(1)+'%';
});
},1000);
function updateNodeCard(n){
const el=document.querySelector(`.nd[data-id="${n.id}"]`);
if(!el)return false;
const on=Date.now()/1000-n.last_seen<120;
el.className='nd'+(on?'':' offline');
// gauges
[['cpu',n.cpu],['mem',n.mem],['disk',n.disk],['swap',n.swap]].forEach(([k,v])=>{
const g=el.querySelector(`[data-g="${k}"]`);
if(g){g.querySelector('.g-f').style.width=v+'%';g.querySelector('.g-f').className='g-f '+gaugeColor(v);const gn=g.querySelector('.g-n');if(on)animateNum(gn,v,'%');else gn.textContent='—';}
});
// tokens
const tv=el.querySelectorAll('.tk-v');
[[tv[0],n.tok_today],[tv[1],n.tok_week],[tv[2],n.tok_month]].forEach(([e,v])=>{if(e){const s=fmtTok(v);if(e.textContent!==s)e.textContent=s;}});
// footer
const fs=el.querySelectorAll('.nd-f span');
if(fs[0])fs[0].textContent='⏱ '+fmtAge(Date.now()/1000-n.uptime);
if(fs[1])fs[1].textContent='📡 '+n.sessions+' 会话';
if(fs[2])fs[2].textContent='⚡ 网关 '+(n.gw_ok?'✓':'✗');
if(fs[3])fs[3].textContent='🐾 守护 '+(n.daemon_ok?'✓':'✗');
return true;
}
function render(){renderStats();renderNodes();renderMatrix();renderLogs();
$('#subtitle').textContent=DATA.nodes.length+' 个节点 · 更新于 '+new Date().toLocaleString('zh-CN');
}
async function load(){
try{const r=await fetch('/api/dashboard',authHdr());if(r.status===401)return;DATA=await r.json();render();}catch(e){console.error(e)}
}
function connectWS(){
const proto=location.protocol==='https:'?'wss:':'ws:';
const ws=new WebSocket(proto+'//'+location.host);
ws.onmessage=function(e){
const d=JSON.parse(e.data);
if(d.type==='heartbeat'){
const i=DATA.nodes.findIndex(function(n){return n.id===d.node.id});
if(i>=0)Object.assign(DATA.nodes[i],d.node);else DATA.nodes.push(d.node);
if(!updateNodeCard(DATA.nodes[i>=0?i:DATA.nodes.length-1]))renderNodes();
renderStats();
}
if(d.type==='request'){const rq=d.request;if(!rq.node_name){const nd=DATA.nodes.find(n=>n.id===rq.node_id);if(nd)rq.node_name=nd.name;}DATA.requests.unshift(rq);if(DATA.requests.length>100)DATA.requests.pop();render();}
if(d.type==='rename'){var n=DATA.nodes.find(function(x){return x.id===d.id});if(n)n.name=d.name;render();}
if(d.type==='delete'){DATA.nodes=DATA.nodes.filter(function(n){return n.id!==d.id});render();}
};
ws.onclose=function(){setTimeout(connectWS,3000)};
}
function toggleTheme(){
const d=document.documentElement,light=d.getAttribute('data-theme')==='light';
d.setAttribute('data-theme',light?'':'light');
$('#themeBtn').textContent=light?'🌙':'☀️';
localStorage.setItem('theme',light?'dark':'light');
}
(function(){const t=localStorage.getItem('theme');if(t==='light'){document.documentElement.setAttribute('data-theme','light');document.getElementById('themeBtn').textContent='☀️';}})();
if(TOKEN){$('#lockBtn').textContent='🔓';}
load();connectWS();setInterval(load,60000);
</script>
</body></html>