@polderlabs/bizar-dash 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/server.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.0.0 — Express + WebSocket server for the Bizar dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Wires the v3 API router, the file watcher, the WebSocket layer, and
|
|
7
|
+
* the static frontend (Vite-built React SPA from `dist/`) into a single
|
|
8
|
+
* HTTP + WS pair.
|
|
9
|
+
*/
|
|
10
|
+
import express from 'express';
|
|
11
|
+
import { WebSocketServer } from 'ws';
|
|
12
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
16
|
+
import { dirname as pathDirname } from 'node:path';
|
|
17
|
+
import { createApiRouter } from './api.mjs';
|
|
18
|
+
import { createState } from './state.mjs';
|
|
19
|
+
import { createWatcher } from './watcher.mjs';
|
|
20
|
+
import { modsLoader } from './mods-loader.mjs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
// server.mjs lives at src/server/ — dist/ is at the package root
|
|
25
|
+
const DIST_DIR = join(__dirname, '..', '..', 'dist');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {number} opts.port
|
|
30
|
+
* @param {string} opts.projectRoot
|
|
31
|
+
* @param {string} opts.opencodeConfigDir
|
|
32
|
+
* @param {string} opts.bizarRoot
|
|
33
|
+
*/
|
|
34
|
+
export async function createServer({
|
|
35
|
+
port,
|
|
36
|
+
projectRoot,
|
|
37
|
+
opencodeConfigDir,
|
|
38
|
+
bizarRoot,
|
|
39
|
+
}) {
|
|
40
|
+
const app = express();
|
|
41
|
+
app.use(express.json({ limit: '2mb' }));
|
|
42
|
+
|
|
43
|
+
app.use(
|
|
44
|
+
(
|
|
45
|
+
err,
|
|
46
|
+
_req,
|
|
47
|
+
res,
|
|
48
|
+
_next, // eslint-disable-line no-unused-vars
|
|
49
|
+
) => {
|
|
50
|
+
const status = err?.status || err?.statusCode || 400;
|
|
51
|
+
res.status(status).json({
|
|
52
|
+
error: err?.type || 'bad_request',
|
|
53
|
+
message: err?.message || String(err),
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const state = createState({ projectRoot, opencodeConfigDir, bizarRoot });
|
|
59
|
+
|
|
60
|
+
const watchPaths = [
|
|
61
|
+
state.paths.opencodeJson,
|
|
62
|
+
state.paths.agentsDir,
|
|
63
|
+
state.paths.commandsDir,
|
|
64
|
+
state.paths.bizarDir,
|
|
65
|
+
state.paths.plansDir,
|
|
66
|
+
state.paths.globalPlansDir,
|
|
67
|
+
join(opencodeConfigDir, 'projects.json'),
|
|
68
|
+
].filter((p) => existsSafe(p));
|
|
69
|
+
|
|
70
|
+
const watcher = createWatcher({
|
|
71
|
+
paths: watchPaths,
|
|
72
|
+
onChange: (event, p) => {
|
|
73
|
+
wss.clients.forEach((client) => {
|
|
74
|
+
if (client.readyState === 1) {
|
|
75
|
+
try {
|
|
76
|
+
client.send(JSON.stringify({ type: 'change', event, path: p, ts: Date.now() }));
|
|
77
|
+
} catch {
|
|
78
|
+
/* dropped */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const server = createHttpServer(app);
|
|
86
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
87
|
+
|
|
88
|
+
function broadcast(msg) {
|
|
89
|
+
const payload = JSON.stringify(msg);
|
|
90
|
+
wss.clients.forEach((client) => {
|
|
91
|
+
if (client.readyState === 1) {
|
|
92
|
+
try {
|
|
93
|
+
client.send(payload);
|
|
94
|
+
} catch {
|
|
95
|
+
/* dropped */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const apiRouter = createApiRouter({
|
|
102
|
+
state,
|
|
103
|
+
watcher,
|
|
104
|
+
projectRoot,
|
|
105
|
+
opencodeConfigDir,
|
|
106
|
+
bizarRoot,
|
|
107
|
+
broadcast,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Mod route mounting ──────────────────────────────────────────────
|
|
111
|
+
// Mod routers are mounted BEFORE the apiRouter is registered with app.
|
|
112
|
+
// Mounting directly on app (outside /api prefix) so there are no
|
|
113
|
+
// conflicts with the apiRouter's catch-all handler.
|
|
114
|
+
{
|
|
115
|
+
const modCtx = { broadcast, state, projectRoot, opencodeConfigDir };
|
|
116
|
+
const modRouters = await modsLoader.loadModRouters(modCtx);
|
|
117
|
+
for (const { id, router: modRouter, mountPath } of modRouters) {
|
|
118
|
+
app.use(mountPath, modRouter);
|
|
119
|
+
// eslint-disable-next-line no-console
|
|
120
|
+
console.log(`[mod] mounted ${id} routes at ${mountPath}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// All /api/* routes go through apiRouter (after mod routes are checked)
|
|
125
|
+
app.use('/api', apiRouter);
|
|
126
|
+
|
|
127
|
+
// ── Static frontend (React SPA in dist/) ─────────────────────────
|
|
128
|
+
const distBuilt =
|
|
129
|
+
existsSync(DIST_DIR) && existsSync(join(DIST_DIR, 'index.html'));
|
|
130
|
+
|
|
131
|
+
if (distBuilt) {
|
|
132
|
+
const assetsDir = join(DIST_DIR, 'assets');
|
|
133
|
+
if (existsSync(assetsDir)) {
|
|
134
|
+
app.use(
|
|
135
|
+
'/assets',
|
|
136
|
+
express.static(assetsDir, { maxAge: '1y', immutable: true, index: false }),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
app.use(
|
|
140
|
+
express.static(DIST_DIR, {
|
|
141
|
+
extensions: ['html'],
|
|
142
|
+
setHeaders: (res, filePath) => {
|
|
143
|
+
if (filePath.endsWith('index.html')) {
|
|
144
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
app.get('*', (req, res, next) => {
|
|
150
|
+
if (req.path.startsWith('/api') || req.path === '/ws') return next();
|
|
151
|
+
res.sendFile(join(DIST_DIR, 'index.html'));
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
app.get('/', (_req, res) => res.status(503).type('html').send(renderNotBuiltPage()));
|
|
155
|
+
app.get('*', (_req, res) => {
|
|
156
|
+
if (_req.path.startsWith('/api') || _req.path === '/ws') {
|
|
157
|
+
res.status(404).json({ error: 'not_found', message: `no route for ${_req.method} ${_req.originalUrl}` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
res.status(503).type('html').send(renderNotBuiltPage());
|
|
161
|
+
});
|
|
162
|
+
// eslint-disable-next-line no-console
|
|
163
|
+
console.warn(
|
|
164
|
+
`[dashboard] dist/ not found at ${DIST_DIR}. Run \`npm run build\` to build the React SPA.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
wss.on('connection', (ws, req) => {
|
|
169
|
+
const path = req.url || '';
|
|
170
|
+
|
|
171
|
+
// /ws/logs — stream log file changes
|
|
172
|
+
if (path === '/ws/logs') {
|
|
173
|
+
const HOME = homedir();
|
|
174
|
+
const serviceLog = join(HOME, '.config', 'bizar', 'service.log');
|
|
175
|
+
const dashboardLog = join(HOME, '.config', 'bizar', 'dashboard.log');
|
|
176
|
+
const logFile = existsSync(serviceLog) ? serviceLog : existsSync(dashboardLog) ? dashboardLog : null;
|
|
177
|
+
|
|
178
|
+
let fileSize = logFile && existsSync(logFile) ? statSync(logFile).size : 0;
|
|
179
|
+
|
|
180
|
+
// Send initial tail
|
|
181
|
+
if (logFile) {
|
|
182
|
+
try {
|
|
183
|
+
const text = readFileSync(logFile, 'utf8');
|
|
184
|
+
const lines = text.split(/\r?\n/).filter(Boolean).slice(-100);
|
|
185
|
+
ws.send(JSON.stringify({ type: 'log init', lines, file: logFile }));
|
|
186
|
+
} catch {
|
|
187
|
+
ws.send(JSON.stringify({ type: 'log init', lines: [], file: logFile }));
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
ws.send(JSON.stringify({ type: 'log init', lines: [], file: null }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let destroying = false;
|
|
194
|
+
function sendLogChunk() {
|
|
195
|
+
if (destroying || ws.readyState !== 1) return;
|
|
196
|
+
try {
|
|
197
|
+
const f = logFile;
|
|
198
|
+
if (!f || !existsSync(f)) return;
|
|
199
|
+
const newSize = statSync(f).size;
|
|
200
|
+
if (newSize > fileSize) {
|
|
201
|
+
// Read only the new bytes
|
|
202
|
+
const fd = openSync(f, 'r');
|
|
203
|
+
const buf = Buffer.alloc(newSize - fileSize);
|
|
204
|
+
readSync(fd, buf, 0, buf.length, fileSize);
|
|
205
|
+
closeSync(fd);
|
|
206
|
+
const newText = buf.toString('utf8');
|
|
207
|
+
const newLines = newText.split(/\r?\n/).filter(Boolean);
|
|
208
|
+
for (const line of newLines) {
|
|
209
|
+
ws.send(JSON.stringify({ type: 'log line', line, ts: Date.now() }));
|
|
210
|
+
}
|
|
211
|
+
fileSize = newSize;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
/* ignore */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const interval = setInterval(sendLogChunk, 1000);
|
|
219
|
+
|
|
220
|
+
ws.on('message', (raw) => {
|
|
221
|
+
try {
|
|
222
|
+
const msg = JSON.parse(raw.toString());
|
|
223
|
+
if (msg?.type === 'ping') {
|
|
224
|
+
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ws.on('close', () => {
|
|
232
|
+
destroying = true;
|
|
233
|
+
clearInterval(interval);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
ws.on('error', () => {
|
|
237
|
+
destroying = true;
|
|
238
|
+
clearInterval(interval);
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Default /ws — snapshot + ping/pong
|
|
244
|
+
try {
|
|
245
|
+
ws.send(
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
type: 'snapshot',
|
|
248
|
+
ts: Date.now(),
|
|
249
|
+
data: buildSnapshotSafe(state, opencodeConfigDir),
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
/* ignore */
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
ws.on('message', (raw) => {
|
|
257
|
+
let msg;
|
|
258
|
+
try {
|
|
259
|
+
msg = JSON.parse(raw.toString());
|
|
260
|
+
} catch {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (msg?.type === 'ping') {
|
|
264
|
+
try {
|
|
265
|
+
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
|
|
266
|
+
} catch {
|
|
267
|
+
/* ignore */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
watcher.start();
|
|
274
|
+
|
|
275
|
+
function close() {
|
|
276
|
+
try {
|
|
277
|
+
watcher.stop();
|
|
278
|
+
} catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
wss.clients.forEach((c) => c.terminate());
|
|
283
|
+
wss.close();
|
|
284
|
+
} catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
server.close();
|
|
289
|
+
} catch {
|
|
290
|
+
/* ignore */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { app, server, wss, state, watcher, port, close };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildSnapshotSafe(state, opencodeConfigDir) {
|
|
298
|
+
try {
|
|
299
|
+
return buildSnapshot(state, opencodeConfigDir);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return { error: 'snapshot_failed', message: err.message };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildSnapshot(state, opencodeConfigDir) {
|
|
306
|
+
const cfgFile = join(opencodeConfigDir, 'opencode.json');
|
|
307
|
+
let cfg = null;
|
|
308
|
+
if (existsSync(cfgFile)) {
|
|
309
|
+
try {
|
|
310
|
+
cfg = JSON.parse(readFileSync(cfgFile, 'utf8'));
|
|
311
|
+
} catch {
|
|
312
|
+
cfg = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
overview: state.getOverview(),
|
|
317
|
+
agents: state.getAgents(),
|
|
318
|
+
plans: state.getPlans(),
|
|
319
|
+
projects: state.getProjects(),
|
|
320
|
+
config: {
|
|
321
|
+
path: cfgFile,
|
|
322
|
+
data: cfg,
|
|
323
|
+
raw: cfg ? JSON.stringify(cfg, null, 2) : '',
|
|
324
|
+
exists: existsSync(cfgFile),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function existsSafe(p) {
|
|
330
|
+
try {
|
|
331
|
+
return existsSync(p) || existsSync(dirnameSafe(p));
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function dirnameSafe(p) {
|
|
338
|
+
const idx = p.lastIndexOf('/');
|
|
339
|
+
return idx === -1 ? '.' : p.slice(0, idx);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderNotBuiltPage() {
|
|
343
|
+
return `<!DOCTYPE html>
|
|
344
|
+
<html lang="en">
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="UTF-8" />
|
|
347
|
+
<title>Bizar Dashboard — not built</title>
|
|
348
|
+
<style>
|
|
349
|
+
:root { color-scheme: dark; }
|
|
350
|
+
body {
|
|
351
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center;
|
|
352
|
+
justify-content: center; background: #0b0e14; color: #c9d1d9;
|
|
353
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
354
|
+
padding: 24px;
|
|
355
|
+
}
|
|
356
|
+
.card {
|
|
357
|
+
max-width: 560px; background: #12161f; border: 1px solid #f87171;
|
|
358
|
+
border-radius: 12px; padding: 32px;
|
|
359
|
+
}
|
|
360
|
+
h1 { margin: 0 0 12px; color: #f87171; font-size: 22px; }
|
|
361
|
+
p { margin: 0 0 12px; line-height: 1.6; }
|
|
362
|
+
code {
|
|
363
|
+
font-family: 'JetBrains Mono', monospace; background: #1a1f2b;
|
|
364
|
+
border: 1px solid #232a39; padding: 2px 6px; border-radius: 4px;
|
|
365
|
+
font-size: 13px;
|
|
366
|
+
}
|
|
367
|
+
pre {
|
|
368
|
+
background: #1a1f2b; border: 1px solid #232a39; padding: 12px 16px;
|
|
369
|
+
border-radius: 8px; font-family: 'JetBrains Mono', monospace;
|
|
370
|
+
font-size: 13px; overflow-x: auto;
|
|
371
|
+
}
|
|
372
|
+
</style>
|
|
373
|
+
</head>
|
|
374
|
+
<body>
|
|
375
|
+
<div class="card">
|
|
376
|
+
<h1>Dashboard not built</h1>
|
|
377
|
+
<p>The React SPA has not been built yet. The Bizar dashboard server
|
|
378
|
+
is running, but the frontend bundle is missing.</p>
|
|
379
|
+
<p>Build from the <code>bizar-dash</code> package root:</p>
|
|
380
|
+
<pre><code>cd bizar-dash && npm run build</code></pre>
|
|
381
|
+
<p>The REST API and WebSocket are still live at <code>/api/*</code>
|
|
382
|
+
and <code>/ws</code>.</p>
|
|
383
|
+
</div>
|
|
384
|
+
</body>
|
|
385
|
+
</html>`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export { DIST_DIR, renderNotBuiltPage };
|