feat: worker dashboard monorepo
- packages/cli: @uncaged/cli-dashboard (urec + uconn)
- packages/server: dashboard backend (port 3800)
- packages/frontend: React dark theme UI
- Normalized to bun monorepo conventions (TS, Biome, vitest, changesets, CI)
小橘 🍊(NEKO Team)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@uncaged/dashboard-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./protocol": "./src/protocol.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { MSG, WS } from './protocol.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(__dirname, '../data/records');
|
||||
const FRONTEND_DIR = join(__dirname, '../../frontend/dist');
|
||||
const PORT = 3800;
|
||||
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
await mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
const records = new Map();
|
||||
const workers = new Map();
|
||||
const dashboardClients = new Set();
|
||||
|
||||
async function loadRecords() {
|
||||
try {
|
||||
const files = await readdir(DATA_DIR);
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
try {
|
||||
const data = JSON.parse(await readFile(join(DATA_DIR, f), 'utf8'));
|
||||
records.set(data.id, data);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [id, rec] of records) {
|
||||
if (now - new Date(rec.startedAt).getTime() > THREE_DAYS) {
|
||||
records.delete(id);
|
||||
await unlink(join(DATA_DIR, `${id}.json`)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await loadRecords();
|
||||
await cleanup();
|
||||
setInterval(cleanup, 60 * 60 * 1000);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/records', (req, res) => {
|
||||
let recs = [...records.values()];
|
||||
if (req.query.device) recs = recs.filter(r => r.device === req.query.device);
|
||||
recs.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
|
||||
res.json(recs.map(({ stdout, stderr, ...r }) => r));
|
||||
});
|
||||
|
||||
app.get('/api/records/:id', (req, res) => {
|
||||
const rec = records.get(req.params.id);
|
||||
if (!rec) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rec);
|
||||
});
|
||||
|
||||
app.get('/api/devices', (req, res) => {
|
||||
const devices = new Map();
|
||||
for (const rec of records.values()) {
|
||||
const d = devices.get(rec.device) || { name: rec.device, recordCount: 0, lastSeen: null };
|
||||
d.recordCount++;
|
||||
if (!d.lastSeen || new Date(rec.startedAt) > new Date(d.lastSeen)) d.lastSeen = rec.startedAt;
|
||||
devices.set(rec.device, d);
|
||||
}
|
||||
const result = [...devices.values()].map(d => ({
|
||||
...d,
|
||||
online: [...workers.values()].some(w => w.device === d.name)
|
||||
}));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.use(express.static(FRONTEND_DIR));
|
||||
app.get(/^\/(?!api|ws).*/, (req, res) => {
|
||||
res.sendFile(join(FRONTEND_DIR, 'index.html'));
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
|
||||
const workerWss = new WebSocketServer({ noServer: true });
|
||||
const dashboardWss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (req.url === WS.WORKER) {
|
||||
workerWss.handleUpgrade(req, socket, head, ws => workerWss.emit('connection', ws));
|
||||
} else if (req.url === WS.DASHBOARD) {
|
||||
dashboardWss.handleUpgrade(req, socket, head, ws => dashboardWss.emit('connection', ws));
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function broadcastWorkers() {
|
||||
const workerList = [...workers.values()];
|
||||
for (const client of dashboardClients) {
|
||||
if (client.readyState === 1) client.send(JSON.stringify({ type: MSG.WORKERS, workers: workerList }));
|
||||
}
|
||||
}
|
||||
|
||||
workerWss.on('connection', (ws) => {
|
||||
let workerId = null;
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw);
|
||||
if (msg.type === MSG.REGISTER) {
|
||||
workerId = `${msg.device}-${Date.now()}`;
|
||||
workers.set(workerId, { id: workerId, device: msg.device, connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString() });
|
||||
broadcastWorkers();
|
||||
} else if (msg.type === MSG.RECORD) {
|
||||
const rec = msg.record;
|
||||
records.set(rec.id, rec);
|
||||
await writeFile(join(DATA_DIR, `${rec.id}.json`), JSON.stringify(rec, null, 2));
|
||||
if (workerId) workers.get(workerId).lastSeen = new Date().toISOString();
|
||||
for (const client of dashboardClients) {
|
||||
if (client.readyState === 1) {
|
||||
const { stdout, stderr, ...summary } = rec;
|
||||
client.send(JSON.stringify({ type: MSG.NEW_RECORD, record: summary }));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
if (workerId) { workers.delete(workerId); broadcastWorkers(); }
|
||||
});
|
||||
});
|
||||
|
||||
dashboardWss.on('connection', (ws) => {
|
||||
dashboardClients.add(ws);
|
||||
ws.send(JSON.stringify({ type: MSG.WORKERS, workers: [...workers.values()] }));
|
||||
ws.on('close', () => dashboardClients.delete(ws));
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`Dashboard server on port ${PORT}`));
|
||||
@@ -0,0 +1,17 @@
|
||||
export const MSG = {
|
||||
REGISTER: 'register',
|
||||
RECORD: 'record',
|
||||
WORKERS: 'workers',
|
||||
NEW_RECORD: 'newRecord',
|
||||
};
|
||||
|
||||
export const API = {
|
||||
RECORDS: '/api/records',
|
||||
RECORD: '/api/records/:id',
|
||||
DEVICES: '/api/devices',
|
||||
};
|
||||
|
||||
export const WS = {
|
||||
WORKER: '/ws/worker',
|
||||
DASHBOARD: '/ws/dashboard',
|
||||
};
|
||||
Reference in New Issue
Block a user