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,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`.
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
Executable
+112
@@ -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); });
|
||||
Executable
+38
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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