@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.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. 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 &amp;&amp; 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 };