@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,683 @@
1
+ // src/views/Config.tsx — v3: collapsible Advanced section + Diagnostics + Providers + MCPs.
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ Settings2,
5
+ RefreshCw,
6
+ Save,
7
+ FileCode2,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Stethoscope,
11
+ Server as ServerIcon,
12
+ Plug,
13
+ Plus,
14
+ Trash2,
15
+ Download,
16
+ Activity,
17
+ AlertCircle,
18
+ } from 'lucide-react';
19
+ import { Button } from '../components/Button';
20
+ import { Card, CardTitle, CardMeta } from '../components/Card';
21
+ import { useToast } from '../components/Toast';
22
+ import { useModal } from '../components/Modal';
23
+ import { api } from '../lib/api';
24
+ import { cn, debounce, hashText, formatRelative } from '../lib/utils';
25
+ import { JsonHighlight } from '../lib/markdown';
26
+ import type {
27
+ ConfigResponse,
28
+ Diagnostics,
29
+ McpServer,
30
+ Provider,
31
+ Settings,
32
+ Snapshot,
33
+ } from '../lib/types';
34
+
35
+ type Props = {
36
+ snapshot: Snapshot;
37
+ settings: Settings;
38
+ activeTab: string;
39
+ setActiveTab: (id: string) => void;
40
+ refreshSnapshot: () => Promise<void>;
41
+ };
42
+
43
+ export function Config({ snapshot, refreshSnapshot }: Props) {
44
+ const toast = useToast();
45
+ const modal = useModal();
46
+ const initial: ConfigResponse | undefined = snapshot.config;
47
+ const [original, setOriginal] = useState<string>(
48
+ initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
49
+ );
50
+ const [parsed, setParsed] = useState({
51
+ raw: initial?.raw || '',
52
+ data: initial?.data ?? null,
53
+ error: null as string | null,
54
+ });
55
+ const [dirty, setDirty] = useState(false);
56
+ const [saving, setSaving] = useState(false);
57
+ const [path, setPath] = useState<string>(initial?.path || '');
58
+ const [advancedOpen, setAdvancedOpen] = useState(false);
59
+ const [diagnostics, setDiagnostics] = useState<Diagnostics | null>(null);
60
+ const [providers, setProviders] = useState<Provider[]>(snapshot.providers || []);
61
+ const [mcps, setMcps] = useState<McpServer[]>(snapshot.mcps || []);
62
+ const [activeAdvTab, setActiveAdvTab] = useState<string>('config');
63
+ const taRef = useRef<HTMLTextAreaElement>(null);
64
+
65
+ const originalHash = useMemo(() => hashText(original), [original]);
66
+
67
+ useEffect(() => {
68
+ if (snapshot.providers) setProviders(snapshot.providers);
69
+ if (snapshot.mcps) setMcps(snapshot.mcps);
70
+ }, [snapshot.providers, snapshot.mcps]);
71
+
72
+ const reload = async () => {
73
+ try {
74
+ const d = await api.post<ConfigResponse>('/config/reload');
75
+ const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
76
+ setOriginal(raw);
77
+ setParsed({ raw, data: d.data ?? null, error: null });
78
+ setDirty(false);
79
+ setPath(d.path || '');
80
+ toast.info('Config reloaded.', 1500);
81
+ } catch (err) {
82
+ toast.error(`Reload failed: ${(err as Error).message}`);
83
+ }
84
+ };
85
+
86
+ const onChange = (val: string) => {
87
+ setParsed((cur) => {
88
+ const next: typeof parsed = { raw: val, data: cur.data, error: null };
89
+ try {
90
+ next.data = JSON.parse(val);
91
+ next.error = null;
92
+ } catch (e) {
93
+ next.data = null;
94
+ next.error = (e as Error).message;
95
+ }
96
+ return next;
97
+ });
98
+ };
99
+
100
+ const onChangeDebounced = useMemo(
101
+ () =>
102
+ debounce((val: string) => {
103
+ setDirty(hashText(val) !== originalHash);
104
+ }, 100),
105
+ [originalHash],
106
+ );
107
+
108
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
109
+ const v = e.target.value;
110
+ onChange(v);
111
+ onChangeDebounced(v);
112
+ };
113
+
114
+ const save = async () => {
115
+ if (!parsed.data || parsed.error || saving) return;
116
+ setSaving(true);
117
+ try {
118
+ const result = await api.put<ConfigResponse>('/config', parsed.data);
119
+ const raw = result.raw || JSON.stringify(result.data, null, 2);
120
+ setOriginal(raw);
121
+ setParsed({ raw, data: result.data ?? null, error: null });
122
+ setDirty(false);
123
+ toast.success('Config saved.');
124
+ await refreshSnapshot();
125
+ } catch (err) {
126
+ toast.error(`Save failed: ${(err as Error).message}`);
127
+ } finally {
128
+ setSaving(false);
129
+ }
130
+ };
131
+
132
+ const loadDiagnostics = async () => {
133
+ try {
134
+ const d = await api.get<Diagnostics>('/diagnostics');
135
+ setDiagnostics(d);
136
+ } catch (err) {
137
+ toast.error(`Diagnostics load failed: ${(err as Error).message}`);
138
+ }
139
+ };
140
+
141
+ useEffect(() => {
142
+ if (advancedOpen && !diagnostics) {
143
+ loadDiagnostics();
144
+ }
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [advancedOpen]);
147
+
148
+ const onDownloadDiagnostics = async () => {
149
+ // Build a small bundle on the client
150
+ const bundle = {
151
+ generatedAt: new Date().toISOString(),
152
+ diagnostics,
153
+ config: parsed.data,
154
+ opencodeJsonPath: path,
155
+ };
156
+ const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
157
+ const url = URL.createObjectURL(blob);
158
+ const a = document.createElement('a');
159
+ a.href = url;
160
+ a.download = `bizar-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
161
+ a.click();
162
+ URL.revokeObjectURL(url);
163
+ };
164
+
165
+ return (
166
+ <div className="view view-config">
167
+ <header className="view-header">
168
+ <div className="view-header-text">
169
+ <h2 className="view-title">
170
+ <Settings2 size={18} /> Config
171
+ </h2>
172
+ <p className="view-subtitle">
173
+ Diagnostic snapshot below. The raw <code>opencode.json</code> editor
174
+ is in the Advanced section.
175
+ </p>
176
+ </div>
177
+ <div className="view-actions">
178
+ <Button variant="secondary" size="sm" onClick={loadDiagnostics}>
179
+ <Stethoscope size={14} /> Run diagnostics
180
+ </Button>
181
+ <Button variant="secondary" size="sm" onClick={onDownloadDiagnostics}>
182
+ <Download size={14} /> Download bundle
183
+ </Button>
184
+ </div>
185
+ </header>
186
+
187
+ <DiagnosticsPanel diagnostics={diagnostics} loading={!diagnostics} onReload={loadDiagnostics} />
188
+
189
+ <Card className="config-advanced">
190
+ <button
191
+ type="button"
192
+ className="config-advanced-head"
193
+ onClick={() => setAdvancedOpen((v) => !v)}
194
+ >
195
+ {advancedOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
196
+ <strong>Advanced</strong>
197
+ <span className="muted">— opencode.json editor · providers · MCPs · debug log</span>
198
+ </button>
199
+ {advancedOpen && (
200
+ <div className="config-advanced-body">
201
+ <div className="config-advanced-tabs">
202
+ <button
203
+ type="button"
204
+ className={cn('tab', activeAdvTab === 'config' && 'tab-active')}
205
+ onClick={() => setActiveAdvTab('config')}
206
+ >
207
+ <FileCode2 size={12} /> OpenCode config
208
+ </button>
209
+ <button
210
+ type="button"
211
+ className={cn('tab', activeAdvTab === 'providers' && 'tab-active')}
212
+ onClick={() => setActiveAdvTab('providers')}
213
+ >
214
+ <ServerIcon size={12} /> Providers
215
+ </button>
216
+ <button
217
+ type="button"
218
+ className={cn('tab', activeAdvTab === 'mcps' && 'tab-active')}
219
+ onClick={() => setActiveAdvTab('mcps')}
220
+ >
221
+ <Plug size={12} /> MCPs
222
+ </button>
223
+ <button
224
+ type="button"
225
+ className={cn('tab', activeAdvTab === 'log' && 'tab-active')}
226
+ onClick={() => setActiveAdvTab('log')}
227
+ >
228
+ <Activity size={12} /> Debug log
229
+ </button>
230
+ </div>
231
+
232
+ {activeAdvTab === 'config' && (
233
+ <div>
234
+ <div className="view-actions" style={{ marginBottom: 12 }}>
235
+ <Button variant="secondary" size="sm" onClick={reload}>
236
+ <RefreshCw size={14} /> Reload from disk
237
+ </Button>
238
+ <Button variant="primary" size="sm" disabled={!parsed.data || !!parsed.error || !dirty} onClick={save}>
239
+ {saving ? <span className="btn-spinner" /> : <Save size={14} />}
240
+ Save
241
+ </Button>
242
+ </div>
243
+ <div className="config-grid">
244
+ <Card>
245
+ <CardTitle>JSON tree</CardTitle>
246
+ <CardMeta>Parsed from current editor</CardMeta>
247
+ <div className="json-tree">
248
+ {parsed.data != null ? (
249
+ <JsonHighlight value={parsed.data} />
250
+ ) : (
251
+ <span className="muted">{parsed.error ? 'Invalid JSON' : 'No data'}</span>
252
+ )}
253
+ </div>
254
+ </Card>
255
+ <Card>
256
+ <CardTitle>Raw JSON</CardTitle>
257
+ <CardMeta>
258
+ {parsed.error ? (
259
+ <span className="text-error">{parsed.error}</span>
260
+ ) : (
261
+ <span className="muted">Live validation as you type</span>
262
+ )}
263
+ </CardMeta>
264
+ <textarea
265
+ ref={taRef}
266
+ className={cn('textarea config-textarea', parsed.error && 'invalid')}
267
+ spellCheck={false}
268
+ value={parsed.raw}
269
+ onChange={handleInput}
270
+ />
271
+ </Card>
272
+ </div>
273
+ </div>
274
+ )}
275
+
276
+ {activeAdvTab === 'providers' && (
277
+ <ProvidersPanel
278
+ providers={providers}
279
+ onChange={setProviders}
280
+ />
281
+ )}
282
+
283
+ {activeAdvTab === 'mcps' && (
284
+ <McpsPanel
285
+ mcps={mcps}
286
+ onChange={setMcps}
287
+ />
288
+ )}
289
+
290
+ {activeAdvTab === 'log' && (
291
+ <DebugLogPanel />
292
+ )}
293
+ </div>
294
+ )}
295
+ </Card>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ function DiagnosticsPanel({
301
+ diagnostics,
302
+ loading,
303
+ onReload,
304
+ }: {
305
+ diagnostics: Diagnostics | null;
306
+ loading: boolean;
307
+ onReload: () => void;
308
+ }) {
309
+ return (
310
+ <Card className="diagnostics-card">
311
+ <CardTitle><Stethoscope size={14} /> Diagnostics</CardTitle>
312
+ <CardMeta>
313
+ System health, file counts, recent errors.{' '}
314
+ <button type="button" className="link-btn" onClick={onReload}>Refresh</button>
315
+ </CardMeta>
316
+ {loading && <p className="muted">Loading diagnostics…</p>}
317
+ {diagnostics && (
318
+ <>
319
+ <div className="diagnostics-grid">
320
+ <div className="diagnostic-tile">
321
+ <div className="diagnostic-tile-label">Version</div>
322
+ <div className="diagnostic-tile-value mono">{diagnostics.version}</div>
323
+ </div>
324
+ <div className="diagnostic-tile">
325
+ <div className="diagnostic-tile-label">Uptime</div>
326
+ <div className="diagnostic-tile-value mono">{diagnostics.uptime}s</div>
327
+ </div>
328
+ <div className="diagnostic-tile">
329
+ <div className="diagnostic-tile-label">Node</div>
330
+ <div className="diagnostic-tile-value mono">{diagnostics.nodeVersion}</div>
331
+ </div>
332
+ <div className="diagnostic-tile">
333
+ <div className="diagnostic-tile-label">Platform</div>
334
+ <div className="diagnostic-tile-value mono">{diagnostics.platform}</div>
335
+ </div>
336
+ <div className="diagnostic-tile">
337
+ <div className="diagnostic-tile-label">Memory (heap)</div>
338
+ <div className="diagnostic-tile-value mono">
339
+ {Math.round(diagnostics.memory.heapUsed / 1024 / 1024)}MB
340
+ </div>
341
+ </div>
342
+ <div className="diagnostic-tile">
343
+ <div className="diagnostic-tile-label">Memory (RSS)</div>
344
+ <div className="diagnostic-tile-value mono">
345
+ {Math.round(diagnostics.memory.rss / 1024 / 1024)}MB
346
+ </div>
347
+ </div>
348
+ <div className="diagnostic-tile">
349
+ <div className="diagnostic-tile-label">Service</div>
350
+ <div className="diagnostic-tile-value">
351
+ {diagnostics.service.running ? (
352
+ <span className="tag tag-success">running (pid {diagnostics.service.pid})</span>
353
+ ) : (
354
+ <span className="tag tag-neutral">stopped</span>
355
+ )}
356
+ </div>
357
+ </div>
358
+ <div className="diagnostic-tile">
359
+ <div className="diagnostic-tile-label">Active project</div>
360
+ <div className="diagnostic-tile-value mono">
361
+ {diagnostics.counts.activeProject || '—'}
362
+ </div>
363
+ </div>
364
+ </div>
365
+ <div className="diagnostic-counts">
366
+ <span>agents: <strong>{diagnostics.counts.agents}</strong></span>
367
+ <span>projects: <strong>{diagnostics.counts.projects}</strong></span>
368
+ <span>mods: <strong>{diagnostics.counts.mods}</strong></span>
369
+ <span>schedules: <strong>{diagnostics.counts.schedules}</strong></span>
370
+ <span>tasks: <strong>{diagnostics.counts.tasks}</strong></span>
371
+ <span>providers: <strong>{diagnostics.counts.providers}</strong></span>
372
+ <span>mcps: <strong>{diagnostics.counts.mcps}</strong></span>
373
+ </div>
374
+ <div className="diagnostic-errors">
375
+ <div className="muted">Last {diagnostics.errors.length} errors from service.log</div>
376
+ {diagnostics.errors.length === 0 ? (
377
+ <p className="muted">No errors recorded.</p>
378
+ ) : (
379
+ <ul>
380
+ {diagnostics.errors.map((e, i) => (
381
+ <li key={i} className="mono">
382
+ {e.ts && <span className="muted">[{e.ts}] </span>}
383
+ {e.line}
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ )}
388
+ </div>
389
+ </>
390
+ )}
391
+ </Card>
392
+ );
393
+ }
394
+
395
+ function ProvidersPanel({
396
+ providers,
397
+ onChange,
398
+ }: {
399
+ providers: Provider[];
400
+ onChange: (p: Provider[]) => void;
401
+ }) {
402
+ const toast = useToast();
403
+ const onAdd = () => {
404
+ let idEl: HTMLInputElement | null = null;
405
+ let nameEl: HTMLInputElement | null = null;
406
+ let baseEl: HTMLInputElement | null = null;
407
+ let keyEl: HTMLInputElement | null = null;
408
+ let modelsEl: HTMLInputElement | null = null;
409
+ // Use a modal from useModal — but for simplicity inline a confirm-prompt
410
+ const id = (prompt('Provider id (a-z, 0-9, dashes):') || '').trim();
411
+ if (!id) return;
412
+ if (providers.find((p) => p.id === id)) {
413
+ toast.error(`Provider "${id}" exists.`);
414
+ return;
415
+ }
416
+ const name = prompt('Display name:', id) || id;
417
+ const baseURL = prompt('Base URL:', '') || '';
418
+ const apiKey = prompt('API key:', '') || '';
419
+ const modelsRaw = prompt('Models (comma-separated):', '') || '';
420
+ const models = modelsRaw.split(',').map((m) => m.trim()).filter(Boolean);
421
+ api
422
+ .post<Provider>('/config/providers', { id, name, baseURL, apiKey, models, enabled: true })
423
+ .then((p) => {
424
+ onChange([...providers, p]);
425
+ toast.success('Provider added.');
426
+ })
427
+ .catch((err) => toast.error(`Add failed: ${(err as Error).message}`));
428
+ };
429
+
430
+ const onRemove = async (id: string) => {
431
+ if (!confirm(`Remove provider "${id}"?`)) return;
432
+ try {
433
+ await api.del(`/config/providers/${encodeURIComponent(id)}`);
434
+ onChange(providers.filter((p) => p.id !== id));
435
+ toast.success('Provider removed.');
436
+ } catch (err) {
437
+ toast.error(`Remove failed: ${(err as Error).message}`);
438
+ }
439
+ };
440
+
441
+ return (
442
+ <div>
443
+ <div className="view-actions" style={{ marginBottom: 12 }}>
444
+ <Button variant="primary" size="sm" onClick={onAdd}>
445
+ <Plus size={14} /> Add provider
446
+ </Button>
447
+ </div>
448
+ {providers.length === 0 ? (
449
+ <p className="muted">No providers configured. Add one to register an AI provider.</p>
450
+ ) : (
451
+ <div className="provider-list">
452
+ {providers.map((p) => (
453
+ <Card key={p.id} className="provider-row">
454
+ <div className="provider-row-head">
455
+ <div>
456
+ <div className="provider-name">{p.name}</div>
457
+ <div className="provider-id mono">{p.id}</div>
458
+ </div>
459
+ <div className="provider-actions">
460
+ <button
461
+ type="button"
462
+ className="icon-btn icon-btn-danger"
463
+ aria-label="Remove"
464
+ title="Remove"
465
+ onClick={() => onRemove(p.id)}
466
+ >
467
+ <Trash2 size={12} />
468
+ </button>
469
+ </div>
470
+ </div>
471
+ <div className="provider-meta">
472
+ <div><span className="muted">Base URL:</span> <code>{p.baseURL || '—'}</code></div>
473
+ <div><span className="muted">API key:</span> <code>{p.apiKey || '—'}</code></div>
474
+ <div><span className="muted">Models:</span> {(p.models || []).join(', ') || '—'}</div>
475
+ </div>
476
+ </Card>
477
+ ))}
478
+ </div>
479
+ )}
480
+ </div>
481
+ );
482
+ }
483
+
484
+ function McpsPanel({
485
+ mcps,
486
+ onChange,
487
+ }: {
488
+ mcps: McpServer[];
489
+ onChange: (m: McpServer[]) => void;
490
+ }) {
491
+ const toast = useToast();
492
+ const onAdd = () => {
493
+ const id = (prompt('MCP id (a-z, 0-9, dashes):') || '').trim();
494
+ if (!id) return;
495
+ if (mcps.find((m) => m.id === id)) {
496
+ toast.error(`MCP "${id}" exists.`);
497
+ return;
498
+ }
499
+ const command = prompt('Command (e.g. npx):', 'npx') || '';
500
+ const argsRaw = prompt('Args (space-separated):', '') || '';
501
+ const args = argsRaw.split(/\s+/).filter(Boolean);
502
+ api
503
+ .post<McpServer>('/config/mcps', { id, command, args, env: {}, enabled: true })
504
+ .then((m) => {
505
+ onChange([...mcps, m]);
506
+ toast.success('MCP added.');
507
+ })
508
+ .catch((err) => toast.error(`Add failed: ${(err as Error).message}`));
509
+ };
510
+
511
+ const onRemove = async (id: string) => {
512
+ if (!confirm(`Remove MCP "${id}"?`)) return;
513
+ try {
514
+ await api.del(`/config/mcps/${encodeURIComponent(id)}`);
515
+ onChange(mcps.filter((m) => m.id !== id));
516
+ toast.success('MCP removed.');
517
+ } catch (err) {
518
+ toast.error(`Remove failed: ${(err as Error).message}`);
519
+ }
520
+ };
521
+
522
+ return (
523
+ <div>
524
+ <div className="view-actions" style={{ marginBottom: 12 }}>
525
+ <Button variant="primary" size="sm" onClick={onAdd}>
526
+ <Plus size={14} /> Add MCP
527
+ </Button>
528
+ </div>
529
+ {mcps.length === 0 ? (
530
+ <p className="muted">No MCPs configured.</p>
531
+ ) : (
532
+ <div className="mcp-list">
533
+ {mcps.map((m) => (
534
+ <Card key={m.id} className="mcp-row">
535
+ <div className="mcp-row-head">
536
+ <div className="mcp-name">{m.id}</div>
537
+ <button
538
+ type="button"
539
+ className="icon-btn icon-btn-danger"
540
+ aria-label="Remove"
541
+ title="Remove"
542
+ onClick={() => onRemove(m.id)}
543
+ >
544
+ <Trash2 size={12} />
545
+ </button>
546
+ </div>
547
+ <div className="mcp-meta">
548
+ <code>{m.command} {(m.args || []).join(' ')}</code>
549
+ </div>
550
+ </Card>
551
+ ))}
552
+ </div>
553
+ )}
554
+ </div>
555
+ );
556
+ }
557
+
558
+ function DebugLogPanel() {
559
+ const [lines, setLines] = useState<string[]>([]);
560
+ const [tail, setTail] = useState(100);
561
+ const [autoScroll, setAutoScroll] = useState(true);
562
+ const [connected, setConnected] = useState(false);
563
+ const [logFile, setLogFile] = useState<string | null>(null);
564
+ const [displayLines, setDisplayLines] = useState<string[]>([]);
565
+ const listRef = useRef<HTMLDivElement>(null);
566
+ const wsRef = useRef<WebSocket | null>(null);
567
+
568
+ const loadLogs = async (n: number) => {
569
+ try {
570
+ const r = await api.get<{ lines: string[]; file: string | null }>(`/diagnostics/logs?tail=${n}`);
571
+ setLines(r.lines || []);
572
+ setLogFile(r.file);
573
+ setDisplayLines(r.lines || []);
574
+ } catch {
575
+ setLines(['(failed to load logs)']);
576
+ setDisplayLines(['(failed to load logs)']);
577
+ }
578
+ };
579
+
580
+ const connectWs = () => {
581
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
582
+ const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`);
583
+ wsRef.current = ws;
584
+ ws.onopen = () => setConnected(true);
585
+ ws.onclose = () => {
586
+ setConnected(false);
587
+ // Reconnect after 3s
588
+ setTimeout(connectWs, 3000);
589
+ };
590
+ ws.onerror = () => {
591
+ setConnected(false);
592
+ };
593
+ ws.onmessage = (evt) => {
594
+ try {
595
+ const msg = JSON.parse(evt.data);
596
+ if (msg.type === 'log init') {
597
+ setLines(msg.lines || []);
598
+ setDisplayLines(msg.lines || []);
599
+ setLogFile(msg.file);
600
+ } else if (msg.type === 'log line') {
601
+ setLines((prev) => [...prev.slice(-(tail * 2)), msg.line]);
602
+ setDisplayLines((prev) => [...prev.slice(-(tail * 2)), msg.line]);
603
+ }
604
+ } catch {
605
+ /* ignore */
606
+ }
607
+ };
608
+ };
609
+
610
+ useEffect(() => {
611
+ loadLogs(tail);
612
+ connectWs();
613
+ return () => {
614
+ wsRef.current?.close();
615
+ };
616
+ // eslint-disable-next-line react-hooks/exhaustive-deps
617
+ }, []);
618
+
619
+ // Auto-scroll
620
+ useEffect(() => {
621
+ if (autoScroll && listRef.current) {
622
+ listRef.current.scrollTop = listRef.current.scrollHeight;
623
+ }
624
+ }, [displayLines, autoScroll]);
625
+
626
+ const handleClear = () => {
627
+ setDisplayLines([]);
628
+ };
629
+
630
+ const TAIL_OPTIONS = [50, 100, 500, 1000];
631
+
632
+ return (
633
+ <div className="debug-log-panel">
634
+ <div className="debug-log-toolbar">
635
+ <span className="muted" style={{ fontSize: 11 }}>
636
+ {connected ? (
637
+ <span className="tag tag-success" style={{ fontSize: 10 }}>live</span>
638
+ ) : (
639
+ <span className="tag tag-neutral" style={{ fontSize: 10 }}>disconnected</span>
640
+ )}
641
+ {' '}log: <code>{logFile ? logFile.split('/').pop() : 'none'}</code>
642
+ </span>
643
+ <div className="debug-log-controls">
644
+ <span className="muted" style={{ fontSize: 11 }}>Lines:</span>
645
+ {TAIL_OPTIONS.map((n) => (
646
+ <button
647
+ key={n}
648
+ type="button"
649
+ className={cn('tag', tail === n && 'tag-active')}
650
+ onClick={() => { setTail(n); loadLogs(n); }}
651
+ >
652
+ {n}
653
+ </button>
654
+ ))}
655
+ <button
656
+ type="button"
657
+ className={cn('tag', autoScroll && 'tag-active')}
658
+ onClick={() => setAutoScroll((v) => !v)}
659
+ >
660
+ Auto-scroll
661
+ </button>
662
+ <Button variant="ghost" size="sm" onClick={handleClear}>
663
+ Clear
664
+ </Button>
665
+ <Button variant="secondary" size="sm" onClick={() => loadLogs(tail)}>
666
+ <RefreshCw size={12} /> Reload
667
+ </Button>
668
+ </div>
669
+ </div>
670
+ <div className="debug-log-list" ref={listRef}>
671
+ {displayLines.length === 0 ? (
672
+ <p className="muted" style={{ padding: 12 }}>
673
+ {logFile ? '(log is empty)' : 'No log file found. The service log is at ~/.config/bizar/service.log'}
674
+ </p>
675
+ ) : (
676
+ displayLines.map((line, i) => (
677
+ <div key={i} className="debug-log-line mono">{line}</div>
678
+ ))
679
+ )}
680
+ </div>
681
+ </div>
682
+ );
683
+ }