init: oc-monitor dashboard
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.db
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --production
|
||||
COPY server/ server/
|
||||
COPY public/ public/
|
||||
RUN mkdir -p data
|
||||
EXPOSE 3800
|
||||
CMD ["node", "server/index.js"]
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# OpenClaw Monitor
|
||||
|
||||
Multi-node monitoring dashboard for OpenClaw instances.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Server**: Node.js + SQLite + WebSocket (central dashboard)
|
||||
- **Agent**: Bash script on each OpenClaw machine, reports heartbeat + metrics
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Server (Docker)
|
||||
```bash
|
||||
docker run -d --name oc-monitor -p 3800:3800 -v oc-monitor-data:/app/data ghcr.io/mango082888-bit/oc-monitor
|
||||
# Get auth token
|
||||
docker logs oc-monitor 2>&1 | grep "Auth token"
|
||||
```
|
||||
|
||||
### Agent
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mango082888-bit/oc-monitor/main/agent/agent.sh -o agent.sh
|
||||
chmod +x agent.sh
|
||||
./agent.sh -s http://YOUR_SERVER:3800 -t YOUR_TOKEN -n "My Node" -r master
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time node status (CPU/mem/disk/swap)
|
||||
- Provider health matrix across all nodes
|
||||
- API request logging with TTFT/latency tracking
|
||||
- Auto-detect OpenClaw config changes
|
||||
- WebSocket live updates
|
||||
- Dark theme UI
|
||||
|
||||
## API
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/dashboard` | GET | No | Full dashboard data |
|
||||
| `/api/heartbeat` | POST | Yes | Agent heartbeat report |
|
||||
| `/api/request` | POST | Yes | Log API request |
|
||||
| `/api/node/rename` | POST | Yes | Rename a node |
|
||||
| `/api/node/:id` | DELETE | Yes | Remove a node |
|
||||
|
||||
Auth: `Authorization: Bearer <token>`
|
||||
113
agent/agent.sh
Normal file
113
agent/agent.sh
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
# OpenClaw Monitor Agent
|
||||
# Usage: ./agent.sh -s http://server:3800 -t TOKEN [-n name] [-r master|worker]
|
||||
|
||||
set -e
|
||||
|
||||
SERVER="" TOKEN="" NAME="" ROLE="worker" INTERVAL=30
|
||||
|
||||
while getopts "s:t:n:r:i:" opt; do
|
||||
case $opt in
|
||||
s) SERVER="$OPTARG";;
|
||||
t) TOKEN="$OPTARG";;
|
||||
n) NAME="$OPTARG";;
|
||||
r) ROLE="$OPTARG";;
|
||||
i) INTERVAL="$OPTARG";;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -z "$SERVER" ] || [ -z "$TOKEN" ] && echo "Usage: $0 -s SERVER_URL -t TOKEN [-n name] [-r role]" && exit 1
|
||||
|
||||
NODE_ID=$(hostname | md5sum 2>/dev/null | cut -c1-12 || hostname | md5 | cut -c1-12)
|
||||
NAME="${NAME:-$(hostname)}"
|
||||
HOST=$(hostname -I 2>/dev/null | awk '{print $1}' || ipconfig getifaddr en0 2>/dev/null || echo "unknown")
|
||||
OS_INFO=$(uname -s -r -m)
|
||||
|
||||
# Detect OpenClaw config
|
||||
find_oc_config() {
|
||||
for p in "$HOME/.openclaw/openclaw.json" "/root/.openclaw/openclaw.json" "/etc/openclaw/openclaw.json"; do
|
||||
[ -f "$p" ] && echo "$p" && return
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
OC_CONFIG=$(find_oc_config)
|
||||
OC_VERSION="unknown"
|
||||
if command -v openclaw &>/dev/null; then
|
||||
OC_VERSION=$(openclaw --version 2>/dev/null | head -1 || echo "unknown")
|
||||
fi
|
||||
|
||||
# Get providers from config
|
||||
get_providers() {
|
||||
[ -z "$OC_CONFIG" ] && echo "[]" && return
|
||||
python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
c=json.load(open('$OC_CONFIG'))
|
||||
ps=[]
|
||||
for n,p in c.get('models',{}).get('providers',{}).items():
|
||||
if not isinstance(p,dict): continue
|
||||
for m in p.get('models',[]):
|
||||
ps.append({'name':n,'model':m.get('id',''),'api':p.get('api','')})
|
||||
print(json.dumps(ps))
|
||||
except: print('[]')
|
||||
" 2>/dev/null || echo "[]"
|
||||
}
|
||||
|
||||
# System metrics
|
||||
get_cpu() { top -bn1 2>/dev/null | grep 'Cpu' | awk '{print 100-$8}' || echo 0; }
|
||||
get_mem() { free 2>/dev/null | awk '/Mem/{printf "%.1f",$3/$2*100}' || vm_stat 2>/dev/null | awk '/Pages active/{a=$3}/page size/{p=$8}END{printf "%.0f",a*p/1024/1024/1024*100/8}' || echo 0; }
|
||||
get_disk() { df / 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5);print $5}' || echo 0; }
|
||||
get_swap() { free 2>/dev/null | awk '/Swap/{if($2>0)printf "%.1f",$3/$2*100;else print 0}' || echo 0; }
|
||||
get_uptime() { awk '{printf "%d",$1}' /proc/uptime 2>/dev/null || sysctl -n kern.boottime 2>/dev/null | awk -F'[= ,]' '{print systime()-$6}' || echo 0; }
|
||||
|
||||
# Check gateway/daemon
|
||||
check_gw() { pgrep -f "openclaw.*gateway" &>/dev/null && echo true || echo false; }
|
||||
check_daemon() { pgrep -f "openclaw.*daemon\|openclaw-daemon" &>/dev/null && echo true || echo false; }
|
||||
|
||||
# Session count
|
||||
get_sessions() {
|
||||
local sf="$HOME/.openclaw/sessions.json"
|
||||
[ -f "$sf" ] && python3 -c "import json;print(len(json.load(open('$sf'))))" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
CONFIG_MTIME=0
|
||||
|
||||
echo "OC Monitor Agent started: node=$NODE_ID name=$NAME server=$SERVER"
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
# Check config change
|
||||
PROVIDERS="[]"
|
||||
if [ -n "$OC_CONFIG" ] && [ -f "$OC_CONFIG" ]; then
|
||||
MT=$(stat -c %Y "$OC_CONFIG" 2>/dev/null || stat -f %m "$OC_CONFIG" 2>/dev/null || echo 0)
|
||||
if [ "$MT" != "$CONFIG_MTIME" ]; then
|
||||
PROVIDERS=$(get_providers)
|
||||
CONFIG_MTIME="$MT"
|
||||
echo "Config changed, providers updated"
|
||||
else
|
||||
PROVIDERS=$(get_providers)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build payload
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"id":"$NODE_ID","name":"$NAME","host":"$HOST",
|
||||
"os":"$OS_INFO","oc_version":"$OC_VERSION","role":"$ROLE",
|
||||
"providers":$PROVIDERS,
|
||||
"cpu":$(get_cpu),"mem":$(get_mem),"disk":$(get_disk),"swap":$(get_swap),
|
||||
"sessions":$(get_sessions),"gw_ok":$(check_gw),"daemon_ok":$(check_daemon),
|
||||
"uptime":$(get_uptime)
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Send heartbeat
|
||||
curl -sS -X POST "$SERVER/api/heartbeat" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" -m 10 >/dev/null 2>&1 || echo "Heartbeat failed"
|
||||
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "oc-monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenClaw multi-node monitoring dashboard",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
}
|
||||
188
public/index.html
Normal file
188
public/index.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!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}
|
||||
body{font-family:'SF Mono',Menlo,'Courier New',monospace;background:var(--bg);color:var(--txt);padding:16px}
|
||||
.wrap{max-width:1200px;margin:0 auto}
|
||||
h1{font-size:1.3em;color:var(--neon);margin-bottom:4px}
|
||||
.sub{color:var(--dim);font-size:.75em;margin-bottom:20px}
|
||||
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:20px}
|
||||
.st{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center}
|
||||
.st .n{font-size:1.5em;font-weight:700}.st .l{font-size:.7em;color:var(--dim);margin-top:2px}
|
||||
.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:20px;border-bottom:1px solid var(--border)}
|
||||
.tab{padding:8px 20px;cursor:pointer;color:var(--dim);font-size:.85em;border-bottom:2px solid transparent}
|
||||
.tab.on{color:var(--neon);border-bottom-color:var(--neon)}
|
||||
.tp{display:none}.tp.on{display:block}
|
||||
.nodes{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
|
||||
.nd{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.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:5px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.tg span{font-size:.63em;padding:2px 7px;border-radius:10px;background:var(--card2);color:var(--dim);border:1px solid var(--border)}
|
||||
.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{display:flex;justify-content:space-between;padding:3px 0;font-size:.78em}
|
||||
.pv-l{display:flex;gap:5px;align-items:center}
|
||||
.star{color:var(--warn);font-size:.7em}
|
||||
.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:8px;margin:8px 0}
|
||||
.g{font-size:.7em}.g-l{color:var(--dim)}.g-t{height:4px;background:var(--card2);border-radius:2px;margin:2px 0}
|
||||
.g-f{height:100%;border-radius:2px}.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:8px 0}
|
||||
.tk{flex:1;text-align:center;background:var(--card2);border-radius:6px;padding:5px}
|
||||
.tk-l{font-size:.63em;color:var(--dim)}.tk-v{font-size:.85em;font-weight:600;color:var(--neon)}
|
||||
.nd-f{display:flex;gap:8px;font-size:.68em;color:var(--dim);flex-wrap:wrap}
|
||||
.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:.78em}
|
||||
th{text-align:left;padding:6px 10px;color:var(--dim);border-bottom:1px solid var(--border);font-weight:500}
|
||||
td{padding:6px 10px;border-bottom:1px solid rgba(26,39,64,.5)}
|
||||
.lt{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.lt-h{font-size:.9em;font-weight:600;margin-bottom:10px;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:4px 10px;border-radius:6px;font-size:.78em;font-family:inherit}
|
||||
@media(max-width:768px){.stats{grid-template-columns:repeat(3,1fr)}.nodes{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>🐾 OpenClaw Mission Control</h1>
|
||||
<div class="sub" id="subtitle">Loading...</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab on" onclick="sw('nodes')">🖥 Nodes</div>
|
||||
<div class="tab" onclick="sw('matrix')">📊 Provider Matrix</div>
|
||||
<div class="tab" onclick="sw('logs')">📋 Request 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="mx"><div class="mx-h">Provider × Node Matrix</div><table id="matrixTable"></table></div></div>
|
||||
<div class="tp" id="t-logs"><div class="lt"><div class="lt-h">Request Logs</div>
|
||||
<div class="lf"><select id="fNode"><option value="">All Nodes</option></select><select id="fUp"><option value="">All Upstream</option></select><select id="fRes"><option value="">All Results</option><option value="1">✓ Success</option><option value="0">✗ Failed</option></select></div>
|
||||
<table id="logTable"></table></div></div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
let DATA={nodes:[],stats:{},requests:[]};
|
||||
const $=s=>document.querySelector(s);
|
||||
|
||||
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')}
|
||||
|
||||
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 rate=s.total?((s.ok||0)/s.total*100).toFixed(1):0;
|
||||
$('#stats').innerHTML=[
|
||||
['s1',online+'/'+ns.length,'NODES ONLINE'],
|
||||
['s2',fmtTok(s.total||0),'TODAY REQUESTS'],
|
||||
['s3',fmtTok(s.input_tok||0),'INPUT TOKENS'],
|
||||
['s4',rate+'%','SUCCESS RATE'],
|
||||
['s5',fmtUp(Math.round(s.avg_ttft||0)),'AVG TTFT'],
|
||||
['s6',fmtUp(Math.round(s.avg_total||0)),'AVG LATENCY']
|
||||
].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'}">
|
||||
<div class="nd-h"><div><span class="dot ${on?'on':'off'}"></span><span class="nd-nm" title="Click to rename">${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">Providers</div>
|
||||
${provs.map((p,i)=>`<div class="pv"><div class="pv-l">${i===0?'<span class="star">★</span>':''}${p.name} <span class="pm">${p.model}</span></div><div><span class="ok">✓</span></div></div>`).join('')}
|
||||
<div class="gs">
|
||||
${[['cpu',n.cpu],['mem',n.mem],['disk',n.disk],['swap',n.swap]].map(([l,v])=>`<div class="g"><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">Today</div><div class="tk-v">${fmtTok(n.tok_today)}</div></div><div class="tk"><div class="tk-l">Week</div><div class="tk-v">${fmtTok(n.tok_week)}</div></div><div class="tk"><div class="tk-l">Month</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} sessions</span><span>⚡ gw ${n.gw_ok?'✓':'✗'}</span><span>🐾 daemon ${n.daemon_ok?'✓':'✗'}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderMatrix(){
|
||||
const ns=DATA.nodes,allProvs=new Map();
|
||||
ns.forEach(n=>JSON.parse(n.providers||'[]').forEach(p=>{
|
||||
const k=p.name+' ('+p.model+')';if(!allProvs.has(k))allProvs.set(k,new Set());
|
||||
allProvs.get(k).add(n.name);
|
||||
}));
|
||||
let h='<thead><tr><th>Provider</th>'+ns.map(n=>'<th>'+n.name+'</th>').join('')+'</tr></thead><tbody>';
|
||||
for(const[prov,set]of allProvs){
|
||||
h+='<tr><td>'+prov+'</td>';
|
||||
ns.forEach(n=>{h+=set.has(n.name)?'<td class="ok">✓</td>':'<td style="color:var(--dim)">—</td>';});
|
||||
h+='</tr>';
|
||||
}
|
||||
$('#matrixTable').innerHTML=h+'</tbody>';
|
||||
}
|
||||
|
||||
function renderLogs(){
|
||||
const reqs=DATA.requests||[];
|
||||
let h='<thead><tr><th>时间</th><th>Node</th><th>Upstream</th><th>Model</th><th>Result</th><th>Status</th><th>输入</th><th>输出</th><th>TTFT</th><th>总耗时</th></tr></thead><tbody>';
|
||||
reqs.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><td>'+r.status+'</td>';
|
||||
h+='<td>'+fmtTok(r.input_tokens)+'</td><td>'+r.output_tokens+'</td>';
|
||||
h+='<td style="color:var(--warn)">'+r.ttft_ms+'ms</td><td>'+fmtUp(r.total_ms)+'</td></tr>';
|
||||
});
|
||||
$('#logTable').innerHTML=h+'</tbody>';
|
||||
}
|
||||
|
||||
function render(){renderStats();renderNodes();renderMatrix();renderLogs();
|
||||
$('#subtitle').textContent=DATA.nodes.length+' nodes · Updated '+new Date().toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
async function load(){
|
||||
try{const r=await fetch('/api/dashboard');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);
|
||||
render();
|
||||
}
|
||||
if(d.type==='request'){DATA.requests.unshift(d.request);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)};
|
||||
}
|
||||
|
||||
load();connectWS();setInterval(load,60000);
|
||||
</script>
|
||||
</body></html>
|
||||
178
server/index.js
Normal file
178
server/index.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const PORT = process.env.PORT || 3800;
|
||||
const DB_PATH = path.join(__dirname, '..', 'data', 'monitor.db');
|
||||
const PUBLIC = path.join(__dirname, '..', 'public');
|
||||
|
||||
// Ensure data dir
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
host TEXT,
|
||||
os TEXT,
|
||||
oc_version TEXT,
|
||||
role TEXT DEFAULT 'worker',
|
||||
providers TEXT DEFAULT '[]',
|
||||
cpu REAL DEFAULT 0, mem REAL DEFAULT 0, disk REAL DEFAULT 0, swap REAL DEFAULT 0,
|
||||
sessions INTEGER DEFAULT 0, gw_ok INTEGER DEFAULT 1, daemon_ok INTEGER DEFAULT 1,
|
||||
uptime INTEGER DEFAULT 0,
|
||||
tok_today INTEGER DEFAULT 0, tok_week INTEGER DEFAULT 0, tok_month INTEGER DEFAULT 0,
|
||||
last_seen INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT, upstream TEXT, model TEXT, status INTEGER,
|
||||
input_tokens INTEGER, output_tokens INTEGER,
|
||||
ttft_ms INTEGER, total_ms INTEGER, success INTEGER,
|
||||
ts INTEGER DEFAULT (unixepoch()),
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_req_ts ON requests(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_req_node ON requests(node_id);
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id TEXT PRIMARY KEY DEFAULT 'global',
|
||||
token TEXT UNIQUE
|
||||
);
|
||||
INSERT OR IGNORE INTO tokens(id, token) VALUES('global', hex(randomblob(16)));
|
||||
`);
|
||||
|
||||
const AUTH_TOKEN = db.prepare("SELECT token FROM tokens WHERE id='global'").get().token;
|
||||
console.log(`Auth token: ${AUTH_TOKEN}`);
|
||||
|
||||
// Prepared statements
|
||||
const upsertNode = db.prepare(`INSERT INTO nodes(id,name,host,os,oc_version,role,providers,cpu,mem,disk,swap,sessions,gw_ok,daemon_ok,uptime,tok_today,tok_week,tok_month,last_seen)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||||
name=coalesce(excluded.name,name),host=excluded.host,os=excluded.os,oc_version=excluded.oc_version,
|
||||
role=excluded.role,providers=excluded.providers,cpu=excluded.cpu,mem=excluded.mem,disk=excluded.disk,
|
||||
swap=excluded.swap,sessions=excluded.sessions,gw_ok=excluded.gw_ok,daemon_ok=excluded.daemon_ok,
|
||||
uptime=excluded.uptime,tok_today=excluded.tok_today,tok_week=excluded.tok_week,tok_month=excluded.tok_month,
|
||||
last_seen=excluded.last_seen`);
|
||||
const insertReq = db.prepare(`INSERT INTO requests(node_id,upstream,model,status,input_tokens,output_tokens,ttft_ms,total_ms,success,ts) VALUES(?,?,?,?,?,?,?,?,?,?)`);
|
||||
const getNodes = db.prepare("SELECT * FROM nodes ORDER BY role='master' DESC, name");
|
||||
const getReqs = db.prepare("SELECT r.*,n.name as node_name FROM requests r LEFT JOIN nodes n ON r.node_id=n.id ORDER BY r.ts DESC LIMIT ?");
|
||||
const getStats = db.prepare(`SELECT count(*) as total, sum(input_tokens) as input_tok, sum(output_tokens) as output_tok,
|
||||
sum(success) as ok, avg(ttft_ms) as avg_ttft, avg(total_ms) as avg_total
|
||||
FROM requests WHERE ts > ?`);
|
||||
const renameNode = db.prepare("UPDATE nodes SET name=? WHERE id=?");
|
||||
const deleteNode = db.prepare("DELETE FROM nodes WHERE id=?");
|
||||
|
||||
// WebSocket clients
|
||||
const wsClients = new Set();
|
||||
function broadcast(data) {
|
||||
const msg = JSON.stringify(data);
|
||||
for (const c of wsClients) { try { c.send(msg); } catch {} }
|
||||
}
|
||||
|
||||
// MIME types
|
||||
const MIME = { '.html':'text/html','.js':'application/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml' };
|
||||
|
||||
// HTTP server
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const method = req.method;
|
||||
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
||||
if (method === 'OPTIONS') { res.writeHead(204); return res.end(); }
|
||||
|
||||
const json = (code, data) => { res.writeHead(code, {'Content-Type':'application/json'}); res.end(JSON.stringify(data)); };
|
||||
const auth = () => {
|
||||
const t = (req.headers.authorization||'').replace('Bearer ','');
|
||||
if (t !== AUTH_TOKEN) { json(401, {error:'unauthorized'}); return false; }
|
||||
return true;
|
||||
};
|
||||
|
||||
// Static files
|
||||
if (method === 'GET' && !url.pathname.startsWith('/api/')) {
|
||||
let fp = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
|
||||
if (!fs.existsSync(fp)) fp = path.join(PUBLIC, 'index.html');
|
||||
const ext = path.extname(fp);
|
||||
res.writeHead(200, {'Content-Type': MIME[ext]||'text/plain'});
|
||||
return fs.createReadStream(fp).pipe(res);
|
||||
}
|
||||
|
||||
// Body parser helper
|
||||
const readBody = () => new Promise(r => { let d=''; req.on('data',c=>d+=c); req.on('end',()=>r(JSON.parse(d||'{}'))); });
|
||||
|
||||
// API routes
|
||||
(async () => {
|
||||
try {
|
||||
// GET /api/dashboard - public overview
|
||||
if (url.pathname === '/api/dashboard' && method === 'GET') {
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
const today = now - (now % 86400);
|
||||
const nodes = getNodes.all();
|
||||
const stats = getStats.get(today);
|
||||
const reqs = getReqs.all(50);
|
||||
return json(200, { nodes, stats, requests: reqs, token: undefined });
|
||||
}
|
||||
|
||||
// POST /api/heartbeat - agent reports
|
||||
if (url.pathname === '/api/heartbeat' && method === 'POST') {
|
||||
if (!auth()) return;
|
||||
const b = await readBody();
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
upsertNode.run(b.id,b.name,b.host,b.os,b.oc_version,b.role||'worker',
|
||||
JSON.stringify(b.providers||[]),b.cpu||0,b.mem||0,b.disk||0,b.swap||0,
|
||||
b.sessions||0,b.gw_ok?1:0,b.daemon_ok?1:0,b.uptime||0,
|
||||
b.tok_today||0,b.tok_week||0,b.tok_month||0,now);
|
||||
broadcast({ type:'heartbeat', node: { ...b, last_seen: now } });
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
// POST /api/request - agent reports API call
|
||||
if (url.pathname === '/api/request' && method === 'POST') {
|
||||
if (!auth()) return;
|
||||
const b = await readBody();
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
insertReq.run(b.node_id,b.upstream,b.model,b.status||200,
|
||||
b.input_tokens||0,b.output_tokens||0,b.ttft_ms||0,b.total_ms||0,
|
||||
b.success!==false?1:0, b.ts||now);
|
||||
broadcast({ type:'request', request: b });
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
// POST /api/node/rename
|
||||
if (url.pathname === '/api/node/rename' && method === 'POST') {
|
||||
if (!auth()) return;
|
||||
const b = await readBody();
|
||||
renameNode.run(b.name, b.id);
|
||||
broadcast({ type:'rename', id: b.id, name: b.name });
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
// DELETE /api/node/:id
|
||||
if (url.pathname.startsWith('/api/node/') && method === 'DELETE') {
|
||||
if (!auth()) return;
|
||||
const id = url.pathname.split('/').pop();
|
||||
deleteNode.run(id);
|
||||
broadcast({ type:'delete', id });
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
|
||||
json(404, { error: 'not found' });
|
||||
} catch (e) { json(500, { error: e.message }); }
|
||||
})();
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
const wss = new WebSocketServer({ server });
|
||||
wss.on('connection', ws => {
|
||||
wsClients.add(ws);
|
||||
ws.on('close', () => wsClients.delete(ws));
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => console.log(`OC Monitor running on :${PORT}`));
|
||||
Reference in New Issue
Block a user