From 2ded3f5f9565ef691eb88634f97c3a9b49b8fbde Mon Sep 17 00:00:00 2001 From: mango Date: Sun, 22 Feb 2026 17:37:11 +0800 Subject: [PATCH] init: oc-monitor dashboard --- .gitignore | 3 + Dockerfile | 9 +++ README.md | 45 +++++++++++ agent/agent.sh | 113 ++++++++++++++++++++++++++++ package.json | 14 ++++ public/index.html | 188 ++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 178 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 agent/agent.sh create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdb899b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ad65c9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a7d746 --- /dev/null +++ b/README.md @@ -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 ` diff --git a/agent/agent.sh b/agent/agent.sh new file mode 100644 index 0000000..5723b61 --- /dev/null +++ b/agent/agent.sh @@ -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 </dev/null 2>&1 || echo "Heartbeat failed" + + sleep "$INTERVAL" +done diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f2c986 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ea013bf --- /dev/null +++ b/public/index.html @@ -0,0 +1,188 @@ + + + + + +OpenClaw Mission Control + + + +
+

🐾 OpenClaw Mission Control

+
Loading...
+
+ +
+
πŸ–₯ Nodes
+
πŸ“Š Provider Matrix
+
πŸ“‹ Request Logs
+
+ +
+
Provider Γ— Node Matrix
+
Request Logs
+
+
+ +
+ + diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..bb8fa8c --- /dev/null +++ b/server/index.js @@ -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}`));