@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,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
|
+
}
|