179 lines
7.2 KiB
JavaScript
179 lines
7.2 KiB
JavaScript
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}`));
|