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:
2026-05-28 05:56:21 +00:00
commit 897ba5fae8
22 changed files with 3852 additions and 0 deletions
+13
View File
@@ -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"
}
}
+140
View File
@@ -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}`));
+17
View File
@@ -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',
};