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
+11
View File
@@ -0,0 +1,11 @@
# @uncaged/cli-dashboard
CLI tools for UWF Worker Dashboard.
## Commands
### `urec <command> [args...]`
Run a command and record its output to `~/.uwf-dashboard/records/`.
### `uconn [--url <ws-url>]`
Connect to the dashboard server and sync records. Defaults to `wss://dashboard.shazhou.work/ws/worker`.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@uncaged/cli-dashboard",
"version": "1.0.0",
"type": "module",
"bin": {
"urec": "./src/urec.mjs",
"uconn": "./src/uconn.mjs"
},
"dependencies": {
"ws": "^8.18.0",
"chokidar": "^4.0.0",
"commander": "^13.1.0",
"@uncaged/dashboard-server": "*"
}
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
import { WebSocket } from 'ws';
import { watch } from 'chokidar';
import { readdir, readFile, writeFile, unlink, stat, mkdir } from 'fs/promises';
import { hostname, homedir } from 'os';
import { join } from 'path';
import { program } from 'commander';
import { MSG } from '@uncaged/dashboard-server/protocol';
program
.option('--url <url>', 'WebSocket URL', 'wss://dashboard.shazhou.work/ws/worker')
.parse();
const opts = program.opts();
const WS_URL = opts.url;
const DEVICE = hostname();
const RECORDS_DIR = join(homedir(), '.uwf-dashboard/records');
const SYNCED_FILE = join(homedir(), '.uwf-dashboard/.synced');
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000;
await mkdir(RECORDS_DIR, { recursive: true });
let synced = new Set();
let ws = null;
let pendingRecords = [];
let backoff = 1000;
const MAX_BACKOFF = 30000;
async function loadSynced() {
try {
const data = await readFile(SYNCED_FILE, 'utf8');
synced = new Set(data.split('\n').filter(Boolean));
} catch { synced = new Set(); }
}
async function saveSynced() {
await writeFile(SYNCED_FILE, [...synced].join('\n'));
}
async function cleanOldRecords() {
try {
const files = await readdir(RECORDS_DIR);
const now = Date.now();
for (const f of files) {
if (!f.endsWith('.json')) continue;
const fp = join(RECORDS_DIR, f);
const s = await stat(fp);
if (now - s.mtimeMs > THREE_DAYS) {
await unlink(fp).catch(() => {});
synced.delete(f.replace('.json', ''));
}
}
await saveSynced();
} catch {}
}
async function sendRecord(filePath) {
try {
const data = await readFile(filePath, 'utf8');
const record = JSON.parse(data);
if (synced.has(record.id)) return;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: MSG.RECORD, record }));
synced.add(record.id);
await saveSynced();
} else {
pendingRecords.push(filePath);
}
} catch {}
}
async function syncAll() {
try {
const files = await readdir(RECORDS_DIR);
for (const f of files) {
if (!f.endsWith('.json')) continue;
await sendRecord(join(RECORDS_DIR, f));
}
} catch {}
}
function connect() {
console.log(`Connecting to ${WS_URL}...`);
ws = new WebSocket(WS_URL);
ws.on('open', () => {
console.log('Connected to dashboard server');
backoff = 1000;
ws.send(JSON.stringify({ type: MSG.REGISTER, device: DEVICE }));
syncAll();
const pending = [...pendingRecords];
pendingRecords = [];
pending.forEach(f => sendRecord(f));
});
ws.on('close', () => {
console.log(`Disconnected. Reconnecting in ${backoff / 1000}s...`);
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, MAX_BACKOFF);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
ws.close();
});
}
await loadSynced();
await cleanOldRecords();
setInterval(cleanOldRecords, 60 * 60 * 1000);
connect();
const watcher = watch(RECORDS_DIR, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 300 } });
watcher.on('add', (fp) => { if (fp.endsWith('.json')) sendRecord(fp); });
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import { spawn } from 'child_process';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { homedir, hostname } from 'os';
import { randomUUID } from 'crypto';
const RECORDS_DIR = join(homedir(), '.uwf-dashboard/records');
await mkdir(RECORDS_DIR, { recursive: true });
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: urec <command> [args...]');
process.exit(1);
}
const command = args.join(' ');
const id = randomUUID();
const device = hostname();
const startedAt = new Date().toISOString();
let stdoutBuf = '';
let stderrBuf = '';
const child = spawn(args[0], args.slice(1), { stdio: ['inherit', 'pipe', 'pipe'] });
child.stdout.on('data', (d) => { const s = d.toString(); process.stdout.write(s); stdoutBuf += s; });
child.stderr.on('data', (d) => { const s = d.toString(); process.stderr.write(s); stderrBuf += s; });
const signals = ['SIGINT', 'SIGTERM'];
signals.forEach(sig => process.on(sig, () => child.kill(sig)));
child.on('close', async (exitCode) => {
const finishedAt = new Date().toISOString();
const durationMs = new Date(finishedAt) - new Date(startedAt);
const record = { id, device, command, args: args.slice(1), stdout: stdoutBuf, stderr: stderrBuf, exitCode: exitCode ?? 1, startedAt, finishedAt, durationMs };
await writeFile(join(RECORDS_DIR, `${id}.json`), JSON.stringify(record, null, 2));
process.exit(exitCode ?? 1);
});
+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>UWF Dashboard</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@uncaged/dashboard-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.7.0",
"vite": "^6.3.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.container { max-width: 960px; margin: 0 auto; padding: 20px; }
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid #2a2a4a; margin-bottom: 20px; }
h1 { font-size: 1.4rem; color: #fff; }
.back-link { color: #7c8aff; text-decoration: none; font-size: 0.9rem; }
.workers { display: flex; gap: 10px; align-items: center; }
.worker-badge { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; background: #2a2a4a; padding: 4px 10px; border-radius: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.online { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
.muted { color: #666; font-size: 0.85rem; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.filters button { background: #2a2a4a; color: #aaa; border: 1px solid #3a3a5a; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.filters button.active { background: #7c8aff; color: #fff; border-color: #7c8aff; }
.records { display: flex; flex-direction: column; gap: 6px; }
.record-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #22223a; border: 1px solid #2a2a4a; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
.record-row:hover { background: #2a2a4a; }
.device-badge { background: #3a3a6a; color: #a0a8ff; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
.command { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.exit-code { font-size: 0.8rem; font-weight: 700; }
.exit-code.success { color: #4ade80; }
.exit-code.error { color: #f87171; }
.duration, .time { font-size: 0.8rem; color: #888; white-space: nowrap; }
.empty { text-align: center; color: #666; padding: 60px 0; }
.detail { background: #22223a; border: 1px solid #2a2a4a; border-radius: 10px; padding: 24px; }
.detail-header { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.detail-command { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 1.1rem; color: #fff; background: #1a1a2e; padding: 12px; border-radius: 6px; margin-bottom: 16px; }
.detail-meta { display: flex; gap: 20px; font-size: 0.85rem; color: #888; margin-bottom: 20px; flex-wrap: wrap; }
.output-section h3 { font-size: 0.85rem; color: #888; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.output-section pre { background: #0f0f1a; padding: 16px; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.82rem; overflow-x: auto; max-height: 500px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; }
.output-section pre.stderr { border-left: 3px solid #f87171; }
.output-section { margin-bottom: 16px; }
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface Record { id: string; device: string; command: string; exitCode: number; startedAt: string; finishedAt: string; durationMs: number; }
interface Worker { id: string; device: string; connectedAt: string; }
function timeAgo(date: string) {
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s/60)}m ago`;
if (s < 86400) return `${Math.floor(s/3600)}h ago`;
return `${Math.floor(s/86400)}d ago`;
}
function fmtDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms/1000).toFixed(1)}s`;
}
export default function App() {
const [records, setRecords] = useState<Record[]>([]);
const [workers, setWorkers] = useState<Worker[]>([]);
const [devices, setDevices] = useState<{name:string;recordCount:number;online:boolean}[]>([]);
const [filter, setFilter] = useState('');
const nav = useNavigate();
const wsRef = useRef<WebSocket|null>(null);
useEffect(() => {
fetch(`/api/records${filter ? `?device=${filter}` : ''}`).then(r => r.json()).then(setRecords);
fetch('/api/devices').then(r => r.json()).then(setDevices);
}, [filter]);
useEffect(() => {
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard`);
wsRef.current = ws;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'newRecord') {
const rec = msg.record;
if (!filter || rec.device === filter) {
setRecords(prev => [rec, ...prev]);
}
setDevices(prev => {
const existing = prev.find(d => d.name === rec.device);
if (existing) return prev.map(d => d.name === rec.device ? {...d, recordCount: d.recordCount+1} : d);
return [...prev, {name: rec.device, recordCount: 1, online: true}];
});
}
if (msg.type === 'workers') setWorkers(msg.workers);
};
ws.onclose = () => setTimeout(connect, 3000);
}
connect();
return () => wsRef.current?.close();
}, [filter]);
return (
<div className="container">
<header>
<h1> UWF Dashboard</h1>
<div className="workers">
{workers.length > 0 ? workers.map(w => (
<span key={w.id} className="worker-badge"><span className="dot online" />{w.device}</span>
)) : <span className="muted">No workers online</span>}
</div>
</header>
<div className="filters">
<button className={!filter ? 'active' : ''} onClick={() => setFilter('')}>All</button>
{devices.map(d => (
<button key={d.name} className={filter === d.name ? 'active' : ''} onClick={() => setFilter(d.name)}>
{d.name} ({d.recordCount})
</button>
))}
</div>
<div className="records">
{records.length === 0 && <div className="empty">No records yet</div>}
{records.map(r => (
<div key={r.id} className="record-row" onClick={() => nav(`/record/${r.id}`)}>
<span className="device-badge">{r.device}</span>
<span className="command">{r.command}</span>
<span className={`exit-code ${r.exitCode === 0 ? 'success' : 'error'}`}>{r.exitCode === 0 ? '✓' : `${r.exitCode}`}</span>
<span className="duration">{fmtDuration(r.durationMs)}</span>
<span className="time">{timeAgo(r.startedAt)}</span>
</div>
))}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
interface FullRecord { id: string; device: string; command: string; stdout: string; stderr: string; exitCode: number; startedAt: string; finishedAt: string; durationMs: number; }
export default function RecordDetail() {
const { id } = useParams();
const [record, setRecord] = useState<FullRecord|null>(null);
const [error, setError] = useState('');
useEffect(() => {
fetch(`/api/records/${id}`).then(r => { if (!r.ok) throw new Error('Not found'); return r.json(); }).then(setRecord).catch(e => setError(e.message));
}, [id]);
if (error) return <div className="container"><header><Link to="/"> Back</Link></header><div className="empty">{error}</div></div>;
if (!record) return <div className="container"><div className="empty">Loading...</div></div>;
return (
<div className="container">
<header>
<Link to="/" className="back-link"> Back to Dashboard</Link>
</header>
<div className="detail">
<div className="detail-header">
<span className="device-badge">{record.device}</span>
<span className={`exit-code ${record.exitCode === 0 ? 'success' : 'error'}`}>Exit: {record.exitCode}</span>
</div>
<div className="detail-command">{record.command}</div>
<div className="detail-meta">
<span>Started: {new Date(record.startedAt).toLocaleString()}</span>
<span>Finished: {new Date(record.finishedAt).toLocaleString()}</span>
<span>Duration: {record.durationMs}ms</span>
</div>
{record.stdout && (
<div className="output-section">
<h3>stdout</h3>
<pre>{record.stdout}</pre>
</div>
)}
{record.stderr && (
<div className="output-section">
<h3>stderr</h3>
<pre className="stderr">{record.stderr}</pre>
</div>
)}
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import App from './App';
import RecordDetail from './RecordDetail';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/record/:id" element={<RecordDetail />} />
</Routes>
</BrowserRouter>
);
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3800',
'/ws': { target: 'ws://localhost:3800', ws: true }
}
}
});
+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',
};