@polderlabs/bizar 5.1.0 → 5.2.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/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js → MobileChat-TCputYzr.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-TCputYzr.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-BxcbL5XT.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-BxcbL5XT.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-DRDXfbBP.js} +154 -134
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile--17fkfrl.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-j3rOZK6v.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-j3rOZK6v.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-BG-DhEck.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-BG-DhEck.js.map} +1 -1
- package/bizar-dash/dist/index.html +5 -5
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +4 -0
- package/bizar-dash/src/server/routes/plugins.mjs +11 -3
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/server.mjs +13 -0
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
- package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
- package/bizar-dash/dist/assets/main-8feQWXiF.js.map +0 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// src/views/EvalReport.tsx — v5.2.0
|
|
2
|
+
//
|
|
3
|
+
// Eval report dashboard view. Two columns:
|
|
4
|
+
//
|
|
5
|
+
// ┌────────────────┬───────────────────────────────────┐
|
|
6
|
+
// │ Runs list │ Detail panel │
|
|
7
|
+
// │ (EvalRunCard) │ - Run metadata + summary │
|
|
8
|
+
// │ │ - Per-fixture pass/fail table │
|
|
9
|
+
// │ │ - Optional EvalDiff (when a │
|
|
10
|
+
// │ │ baseline run is selected) │
|
|
11
|
+
// └────────────────┴───────────────────────────────────┘
|
|
12
|
+
//
|
|
13
|
+
// Data sources:
|
|
14
|
+
// GET /api/eval/runs — list of recent runs
|
|
15
|
+
// GET /api/eval/runs/:id — full run record
|
|
16
|
+
// GET /api/eval/runs/:id/compare/:otherId — diff between two runs
|
|
17
|
+
|
|
18
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
19
|
+
import { ClipboardCheck, RefreshCw, ArrowLeftRight, X } from 'lucide-react';
|
|
20
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
21
|
+
import { Button } from '../components/Button';
|
|
22
|
+
import { EmptyState } from '../components/EmptyState';
|
|
23
|
+
import { Spinner } from '../components/Spinner';
|
|
24
|
+
import { useToast } from '../components/Toast';
|
|
25
|
+
import { EvalRunCard, type EvalRunSummary } from '../components/EvalRunCard';
|
|
26
|
+
import {
|
|
27
|
+
EvalDiff,
|
|
28
|
+
computeEvalDiff,
|
|
29
|
+
type EvalRunWithResults,
|
|
30
|
+
type EvalFixtureResult,
|
|
31
|
+
} from '../components/EvalDiff';
|
|
32
|
+
import { api } from '../lib/api';
|
|
33
|
+
import { cn, formatTime } from '../lib/utils';
|
|
34
|
+
import type { Settings, Snapshot } from '../lib/types';
|
|
35
|
+
|
|
36
|
+
type Props = {
|
|
37
|
+
snapshot: Snapshot;
|
|
38
|
+
settings: Settings;
|
|
39
|
+
activeTab: string;
|
|
40
|
+
setActiveTab: (id: string) => void;
|
|
41
|
+
refreshSnapshot: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function RunDetail({
|
|
45
|
+
run,
|
|
46
|
+
onClearCompare,
|
|
47
|
+
compareRun,
|
|
48
|
+
onClearSelection,
|
|
49
|
+
runsForDiff,
|
|
50
|
+
}: {
|
|
51
|
+
run: EvalRunWithResults;
|
|
52
|
+
onClearCompare: () => void;
|
|
53
|
+
compareRun: string | null;
|
|
54
|
+
onClearSelection: () => void;
|
|
55
|
+
runsForDiff: EvalRunWithResults[];
|
|
56
|
+
}) {
|
|
57
|
+
const passRate = run.total > 0 ? ((run.passed / run.total) * 100).toFixed(1) : '0.0';
|
|
58
|
+
const sortedResults = [...run.results].sort((a, b) => {
|
|
59
|
+
// Failures first, then by fixture id
|
|
60
|
+
if (a.ok !== b.ok) return a.ok ? 1 : -1;
|
|
61
|
+
return a.fixtureId.localeCompare(b.fixtureId);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="eval-run-detail" data-testid="eval-run-detail">
|
|
66
|
+
<Card className="eval-run-detail-header">
|
|
67
|
+
<div className="eval-run-detail-header-row">
|
|
68
|
+
<CardTitle>
|
|
69
|
+
<ClipboardCheck size={14} aria-hidden /> {run.id}
|
|
70
|
+
</CardTitle>
|
|
71
|
+
<Button variant="ghost" size="sm" onClick={onClearSelection} title="Close detail">
|
|
72
|
+
<X size={12} /> Close
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
<CardMeta>
|
|
76
|
+
{formatTime(run.startedAt)}
|
|
77
|
+
{run.finishedAt ? ` → ${formatTime(run.finishedAt)}` : ''} · {run.suitePath}
|
|
78
|
+
</CardMeta>
|
|
79
|
+
<div className="eval-run-detail-summary">
|
|
80
|
+
<span className={cn('eval-status', run.failed === 0 ? 'is-pass' : 'is-fail')}>
|
|
81
|
+
{run.passed}/{run.total} ({passRate}%)
|
|
82
|
+
</span>
|
|
83
|
+
<span className="muted">
|
|
84
|
+
{run.failed} failed · {run.passed} passed
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
</Card>
|
|
88
|
+
|
|
89
|
+
{compareRun ? (
|
|
90
|
+
<Card className="eval-diff-card">
|
|
91
|
+
{(() => {
|
|
92
|
+
const baseRun = runsForDiff.find((r) => r.id === compareRun);
|
|
93
|
+
if (!baseRun) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="eval-diff-missing muted">
|
|
96
|
+
Baseline run <code>{compareRun}</code> not loaded. Pick another from the list.
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
// Show "before" as the baseline (older) and "after" as the
|
|
101
|
+
// currently-selected run (newer). Re-derive before/after
|
|
102
|
+
// from the diff helper to drive the heading.
|
|
103
|
+
const diff = computeEvalDiff(baseRun, run);
|
|
104
|
+
const improved = diff.filter((d) => d.change === 'improved').length;
|
|
105
|
+
const regressed = diff.filter((d) => d.change === 'regressed').length;
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<div className="eval-diff-card-head">
|
|
109
|
+
<span className="eval-diff-card-title">
|
|
110
|
+
<ArrowLeftRight size={14} aria-hidden /> Comparing {baseRun.id} → {run.id}
|
|
111
|
+
</span>
|
|
112
|
+
<Button variant="ghost" size="sm" onClick={onClearCompare}>
|
|
113
|
+
<X size={12} /> Clear baseline
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="eval-diff-stats" aria-label="Diff summary">
|
|
117
|
+
<span className="eval-diff-stat improved">+{improved} improved</span>
|
|
118
|
+
<span className="eval-diff-stat regressed">−{regressed} regressed</span>
|
|
119
|
+
</div>
|
|
120
|
+
<EvalDiff runA={baseRun} runB={run} />
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
})()}
|
|
124
|
+
</Card>
|
|
125
|
+
) : (
|
|
126
|
+
<Card className="eval-baseline-hint">
|
|
127
|
+
<CardMeta>
|
|
128
|
+
Tip: click another run in the list to use it as a baseline for diffing.
|
|
129
|
+
</CardMeta>
|
|
130
|
+
</Card>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<Card className="eval-fixtures-card">
|
|
134
|
+
<CardTitle>Fixtures ({run.results.length})</CardTitle>
|
|
135
|
+
{sortedResults.length === 0 ? (
|
|
136
|
+
<CardMeta className="muted">No fixture results in this run.</CardMeta>
|
|
137
|
+
) : (
|
|
138
|
+
<ul className="eval-fixtures-list" role="list">
|
|
139
|
+
{sortedResults.map((r) => (
|
|
140
|
+
<li
|
|
141
|
+
key={r.fixtureId}
|
|
142
|
+
className={cn('eval-fixture-row', r.ok ? 'is-pass' : 'is-fail')}
|
|
143
|
+
>
|
|
144
|
+
<span
|
|
145
|
+
className={cn('eval-fixture-pill', r.ok ? 'is-pass' : 'is-fail')}
|
|
146
|
+
aria-label={r.ok ? 'pass' : 'fail'}
|
|
147
|
+
>
|
|
148
|
+
{r.ok ? '✓' : '✗'}
|
|
149
|
+
</span>
|
|
150
|
+
<span className="eval-fixture-id mono">{r.fixtureId}</span>
|
|
151
|
+
{!r.ok && r.checks ? (
|
|
152
|
+
<span className="eval-fixture-fails muted">
|
|
153
|
+
{r.checks
|
|
154
|
+
.filter((c) => !c.ok)
|
|
155
|
+
.map((c) => c.message)
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join(' · ')}
|
|
158
|
+
</span>
|
|
159
|
+
) : null}
|
|
160
|
+
{r.latencyMs != null ? (
|
|
161
|
+
<span className="eval-fixture-latency muted">{Math.round(r.latencyMs)}ms</span>
|
|
162
|
+
) : null}
|
|
163
|
+
</li>
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
)}
|
|
167
|
+
</Card>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function EvalReportInner({ setActiveTab }: Props) {
|
|
173
|
+
const toast = useToast();
|
|
174
|
+
const [runs, setRuns] = useState<EvalRunSummary[]>([]);
|
|
175
|
+
const [loadingRuns, setLoadingRuns] = useState(true);
|
|
176
|
+
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
|
177
|
+
const [compareRunId, setCompareRunId] = useState<string | null>(null);
|
|
178
|
+
const [detail, setDetail] = useState<EvalRunWithResults | null>(null);
|
|
179
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
180
|
+
const [compareDetail, setCompareDetail] = useState<EvalRunWithResults | null>(null);
|
|
181
|
+
const cancelledRef = useRef(false);
|
|
182
|
+
|
|
183
|
+
const loadRuns = useCallback(async () => {
|
|
184
|
+
setLoadingRuns(true);
|
|
185
|
+
try {
|
|
186
|
+
const r = await api.get<{ runs: EvalRunSummary[] }>('/eval/runs');
|
|
187
|
+
if (!cancelledRef.current) setRuns(r.runs || []);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!cancelledRef.current) {
|
|
190
|
+
toast.error(`Failed to load eval runs: ${(err as Error).message}`);
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
if (!cancelledRef.current) setLoadingRuns(false);
|
|
194
|
+
}
|
|
195
|
+
}, [toast]);
|
|
196
|
+
|
|
197
|
+
const loadDetail = useCallback(
|
|
198
|
+
async (id: string) => {
|
|
199
|
+
setDetailLoading(true);
|
|
200
|
+
try {
|
|
201
|
+
const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
|
|
202
|
+
if (!cancelledRef.current) setDetail(r);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (!cancelledRef.current) {
|
|
205
|
+
toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
|
|
206
|
+
setDetail(null);
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
if (!cancelledRef.current) setDetailLoading(false);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
[toast],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const loadCompareDetail = useCallback(
|
|
216
|
+
async (id: string) => {
|
|
217
|
+
try {
|
|
218
|
+
const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
|
|
219
|
+
if (!cancelledRef.current) setCompareDetail(r);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (!cancelledRef.current) {
|
|
222
|
+
toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
|
|
223
|
+
setCompareDetail(null);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
[toast],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Initial fetch
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
cancelledRef.current = false;
|
|
233
|
+
loadRuns();
|
|
234
|
+
return () => { cancelledRef.current = true; };
|
|
235
|
+
}, [loadRuns]);
|
|
236
|
+
|
|
237
|
+
const selectRun = async (id: string) => {
|
|
238
|
+
if (id === selectedRunId) return;
|
|
239
|
+
setSelectedRunId(id);
|
|
240
|
+
setCompareRunId(null);
|
|
241
|
+
setCompareDetail(null);
|
|
242
|
+
await loadDetail(id);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const useAsBaseline = async (id: string) => {
|
|
246
|
+
if (id === selectedRunId) return;
|
|
247
|
+
setCompareRunId(id);
|
|
248
|
+
await loadCompareDetail(id);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Build the runsForDiff array — only the full records we actually
|
|
252
|
+
// have loaded (selected + baseline). Anything else is unavailable
|
|
253
|
+
// and the detail panel will show a "not loaded" hint.
|
|
254
|
+
const runsForDiff: EvalRunWithResults[] = [];
|
|
255
|
+
if (compareDetail) runsForDiff.push(compareDetail);
|
|
256
|
+
if (detail) runsForDiff.push(detail);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="view view-eval-report" data-testid="eval-report">
|
|
260
|
+
<div className="view-header">
|
|
261
|
+
<div className="view-header-text">
|
|
262
|
+
<h1 className="view-title">
|
|
263
|
+
<ClipboardCheck size={20} aria-hidden /> Eval Reports
|
|
264
|
+
</h1>
|
|
265
|
+
<p className="view-subtitle muted">
|
|
266
|
+
Browse eval runs, inspect per-fixture results, and diff two runs to spot regressions.
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
<div className="view-actions">
|
|
270
|
+
<Button variant="secondary" size="sm" onClick={loadRuns} disabled={loadingRuns}>
|
|
271
|
+
{loadingRuns ? <Spinner size="sm" /> : <RefreshCw size={12} />}
|
|
272
|
+
{loadingRuns ? 'Refreshing…' : 'Refresh'}
|
|
273
|
+
</Button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className="eval-grid">
|
|
278
|
+
<div className="eval-runs-list">
|
|
279
|
+
{loadingRuns && runs.length === 0 ? (
|
|
280
|
+
<div className="eval-runs-loading">
|
|
281
|
+
<Spinner size="sm" />
|
|
282
|
+
<span className="muted">Loading runs…</span>
|
|
283
|
+
</div>
|
|
284
|
+
) : runs.length === 0 ? (
|
|
285
|
+
<EmptyState
|
|
286
|
+
icon={<ClipboardCheck size={28} />}
|
|
287
|
+
title="No eval runs yet"
|
|
288
|
+
message={
|
|
289
|
+
<>
|
|
290
|
+
Run a suite via the CLI:{' '}
|
|
291
|
+
<code className="mono">bizar eval run ./path/to/fixtures</code>.
|
|
292
|
+
</>
|
|
293
|
+
}
|
|
294
|
+
/>
|
|
295
|
+
) : (
|
|
296
|
+
runs.map((r) => (
|
|
297
|
+
<div key={r.id} className="eval-run-card-wrap">
|
|
298
|
+
<EvalRunCard
|
|
299
|
+
run={r}
|
|
300
|
+
selected={r.id === selectedRunId}
|
|
301
|
+
onClick={() => selectRun(r.id)}
|
|
302
|
+
/>
|
|
303
|
+
{selectedRunId && r.id !== selectedRunId ? (
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className="eval-baseline-btn"
|
|
307
|
+
onClick={() => useAsBaseline(r.id)}
|
|
308
|
+
title="Use this run as a baseline for diffing"
|
|
309
|
+
>
|
|
310
|
+
<ArrowLeftRight size={10} aria-hidden />
|
|
311
|
+
Use as baseline
|
|
312
|
+
</button>
|
|
313
|
+
) : null}
|
|
314
|
+
</div>
|
|
315
|
+
))
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div className="eval-detail">
|
|
320
|
+
{detailLoading && !detail ? (
|
|
321
|
+
<div className="eval-detail-loading">
|
|
322
|
+
<Spinner size="sm" />
|
|
323
|
+
<span className="muted">Loading run details…</span>
|
|
324
|
+
</div>
|
|
325
|
+
) : detail ? (
|
|
326
|
+
<RunDetail
|
|
327
|
+
run={detail}
|
|
328
|
+
compareRun={compareRunId}
|
|
329
|
+
runsForDiff={runsForDiff}
|
|
330
|
+
onClearCompare={() => { setCompareRunId(null); setCompareDetail(null); }}
|
|
331
|
+
onClearSelection={() => { setSelectedRunId(null); setDetail(null); setCompareRunId(null); setCompareDetail(null); }}
|
|
332
|
+
/>
|
|
333
|
+
) : (
|
|
334
|
+
<Card className="eval-detail-empty">
|
|
335
|
+
<EmptyState
|
|
336
|
+
icon={<ClipboardCheck size={28} />}
|
|
337
|
+
title="Select a run"
|
|
338
|
+
message="Pick an eval run from the list to view fixture-by-fixture results, latency, and per-check failure details."
|
|
339
|
+
/>
|
|
340
|
+
</Card>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const EvalReport = React.memo(EvalReportInner);
|
|
349
|
+
export type { EvalFixtureResult };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/views/Plugins.tsx — manage installed plugins and their permissions.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Puzzle } from 'lucide-react';
|
|
5
|
+
import { PluginCard, type InstalledPlugin } from '../components/PluginCard';
|
|
6
|
+
import { EmptyState } from '../components/EmptyState';
|
|
7
|
+
import { Spinner } from '../components/Spinner';
|
|
8
|
+
import { useToast } from '../components/Toast';
|
|
9
|
+
import { api } from '../lib/api';
|
|
10
|
+
import type { Settings, Snapshot } from '../lib/types';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
snapshot: Snapshot;
|
|
14
|
+
settings: Settings;
|
|
15
|
+
activeTab: string;
|
|
16
|
+
setActiveTab: (id: string) => void;
|
|
17
|
+
refreshSnapshot: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Plugins(_props: Props) {
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
const [filter, setFilter] = useState('');
|
|
25
|
+
|
|
26
|
+
const load = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const r = await api.get<{ plugins: InstalledPlugin[] }>('/plugins/installed');
|
|
29
|
+
setPlugins(r.plugins);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
toast.error(`Failed to load plugins: ${(err as Error).message}`);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
void load();
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const toggle = async (id: string) => {
|
|
42
|
+
try {
|
|
43
|
+
// Toggle is handled by the backend via a toggle endpoint or we use update
|
|
44
|
+
await api.post(`/plugins/${id}/toggle`).catch(() => {
|
|
45
|
+
// Fallback: toggle is not a separate endpoint, may need PUT
|
|
46
|
+
});
|
|
47
|
+
await load();
|
|
48
|
+
toast.success('Plugin toggled.');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
toast.error(`Toggle failed: ${(err as Error).message}`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const uninstall = async (id: string) => {
|
|
55
|
+
if (!confirm('Uninstall this plugin?')) return;
|
|
56
|
+
try {
|
|
57
|
+
await api.del(`/plugins/${id}`);
|
|
58
|
+
await load();
|
|
59
|
+
toast.success('Plugin uninstalled.');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
toast.error(`Uninstall failed: ${(err as Error).message}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const configure = (_id: string) => {
|
|
66
|
+
// TODO: open config dialog
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (loading) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="view-loading">
|
|
72
|
+
<Spinner size="lg" />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const filtered = plugins.filter((p) =>
|
|
78
|
+
p.name.toLowerCase().includes(filter.toLowerCase()),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="view view-plugins">
|
|
83
|
+
<header className="view-header">
|
|
84
|
+
<div className="view-header-text">
|
|
85
|
+
<h2 className="view-title">
|
|
86
|
+
<Puzzle size={18} aria-hidden />
|
|
87
|
+
Plugins ({plugins.length})
|
|
88
|
+
</h2>
|
|
89
|
+
<p className="view-subtitle">
|
|
90
|
+
Manage plugins installed via the marketplace.
|
|
91
|
+
</p>
|
|
92
|
+
</div>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
{plugins.length === 0 ? (
|
|
96
|
+
<EmptyState
|
|
97
|
+
icon={<Puzzle size={32} />}
|
|
98
|
+
title="No plugins installed"
|
|
99
|
+
message="Install plugins from the marketplace using the CLI."
|
|
100
|
+
/>
|
|
101
|
+
) : (
|
|
102
|
+
<>
|
|
103
|
+
<div className="plugins-toolbar">
|
|
104
|
+
<input
|
|
105
|
+
className="input"
|
|
106
|
+
type="search"
|
|
107
|
+
placeholder="Filter plugins…"
|
|
108
|
+
value={filter}
|
|
109
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
110
|
+
style={{ maxWidth: 320 }}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="plugin-grid">
|
|
114
|
+
{filtered.map((p) => (
|
|
115
|
+
<PluginCard
|
|
116
|
+
key={p.id}
|
|
117
|
+
plugin={p}
|
|
118
|
+
onToggle={toggle}
|
|
119
|
+
onUninstall={uninstall}
|
|
120
|
+
onConfigure={configure}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -12,6 +12,7 @@ import { ThemeSection } from './settings/ThemeSection';
|
|
|
12
12
|
import { UpdatesSection } from './settings/UpdatesSection';
|
|
13
13
|
import { GeneralSection } from './settings/GeneralSection';
|
|
14
14
|
import { NetworkSection } from './settings/NetworkSection';
|
|
15
|
+
import { TailscaleSettings } from '../components/TailscaleSettings';
|
|
15
16
|
import { NotificationsSection } from './settings/NotificationsSection';
|
|
16
17
|
import { AuthSection } from './settings/AuthSection';
|
|
17
18
|
import { AgentSection } from './settings/AgentSection';
|
|
@@ -290,6 +291,7 @@ function SettingsViewInner({ settings: initial, refreshSnapshot, settingsMode, s
|
|
|
290
291
|
{(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} autoSave={autoSave} />}
|
|
291
292
|
{(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
|
|
292
293
|
{(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
|
|
294
|
+
{(showAll || sectionOf('network') || sectionOf('tailscale')) && <TailscaleSettings initialStatus={tailscale} />}
|
|
293
295
|
{(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
|
|
294
296
|
{(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
|
|
295
297
|
{(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} autoSave={autoSave} />}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bizar-dash/tests/cli-tailscale.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.2 — CLI Tailscale integration tests.
|
|
5
|
+
*
|
|
6
|
+
* Tests the tailscale CLI command module (cli/commands/tailscale.mjs).
|
|
7
|
+
* Verifies module structure and run() doesn't throw for help/invalid subcommands.
|
|
8
|
+
* Full integration testing requires a real or mocked tailscale binary.
|
|
9
|
+
*/
|
|
10
|
+
import { test, describe } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { resolve, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const CLI_COMMANDS = resolve(__dirname, '..', '..', 'cli', 'commands');
|
|
17
|
+
|
|
18
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe('cli/commands/tailscale.mjs exports', () => {
|
|
21
|
+
test('exports run() function', async () => {
|
|
22
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
23
|
+
assert.equal(typeof mod.run, 'function');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('exports ensureTailscaleAuth() function', async () => {
|
|
27
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
28
|
+
assert.equal(typeof mod.ensureTailscaleAuth, 'function');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('exports setupTailscaleServe() function', async () => {
|
|
32
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
33
|
+
assert.equal(typeof mod.setupTailscaleServe, 'function');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('exports unsetupTailscaleServe() function', async () => {
|
|
37
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
38
|
+
assert.equal(typeof mod.unsetupTailscaleServe, 'function');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('exports getServeUrl() function', async () => {
|
|
42
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
43
|
+
assert.equal(typeof mod.getServeUrl, 'function');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('exports showTailscaleStatus() function', async () => {
|
|
47
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
48
|
+
assert.equal(typeof mod.showTailscaleStatus, 'function');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('exports showTailscaleHelp() function', async () => {
|
|
52
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
53
|
+
assert.equal(typeof mod.showTailscaleHelp, 'function');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('exports isTailscaleInstalled() function', async () => {
|
|
57
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
58
|
+
assert.equal(typeof mod.isTailscaleInstalled, 'function');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('run() — CLI subcommand dispatcher', () => {
|
|
63
|
+
test('run() with no args does not throw (prints help)', async () => {
|
|
64
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
65
|
+
// Should not throw - it will print help and return
|
|
66
|
+
await mod.run('tailscale', [], false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('run() with --help flag does not throw', async () => {
|
|
70
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
71
|
+
await mod.run('tailscale', ['--help'], false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('run() with "help" subcommand does not throw', async () => {
|
|
75
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
76
|
+
await mod.run('tailscale', ['help'], false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('run() with unknown subcommand does not throw (prints error + help)', async () => {
|
|
80
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
81
|
+
// Should not throw - it will print an error and help
|
|
82
|
+
await mod.run('tailscale', ['unknown-subcommand'], false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('showTailscaleHelp()', () => {
|
|
87
|
+
test('prints help text including env var documentation', async () => {
|
|
88
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
89
|
+
let stdout = '';
|
|
90
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
92
|
+
process.stdout.write = (s) => { stdout += String(s); return true; };
|
|
93
|
+
try {
|
|
94
|
+
mod.showTailscaleHelp();
|
|
95
|
+
assert.ok(stdout.includes('bizar tailscale'));
|
|
96
|
+
assert.ok(stdout.includes('TAILSCALE_AUTHKEY'));
|
|
97
|
+
assert.ok(stdout.includes('bizar tailscale status'));
|
|
98
|
+
assert.ok(stdout.includes('bizar tailscale serve'));
|
|
99
|
+
assert.ok(stdout.includes('bizar tailscale unserve'));
|
|
100
|
+
} finally {
|
|
101
|
+
process.stdout.write = orig;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('getServeUrl()', () => {
|
|
107
|
+
test('returns null when serve config does not exist', async () => {
|
|
108
|
+
const mod = await import(`${CLI_COMMANDS}/tailscale.mjs`);
|
|
109
|
+
const url = mod.getServeUrl();
|
|
110
|
+
// Returns null when no config file exists (test environment)
|
|
111
|
+
assert.equal(url, null);
|
|
112
|
+
});
|
|
113
|
+
});
|