@polderlabs/bizar 5.1.0 → 5.3.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-OidrxXR9.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-OidrxXR9.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-B91gVFii.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-B91gVFii.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-Bd8piSb2.js} +159 -134
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-3G6mOhiK.css} +1 -1
- package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
- package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-CSsp30lr.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-CSsp30lr.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile-DtwK7DOV.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile-DtwK7DOV.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-DEkxEvOO.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-DEkxEvOO.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/eval-store.mjs +55 -0
- package/bizar-dash/src/server/eval.mjs +63 -0
- package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
- package/bizar-dash/src/server/plugins/store.mjs +4 -0
- package/bizar-dash/src/server/routes/eval.mjs +42 -2
- package/bizar-dash/src/server/routes/plugins.mjs +55 -7
- 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/schedules-runner.mjs +61 -0
- 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/EvalExportButton.tsx +27 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
- package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -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/ScheduleTemplateCard.tsx +1 -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 +9 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
- package/bizar-dash/src/web/styles/main.css +684 -0
- package/bizar-dash/src/web/views/Doctor.tsx +3 -3
- package/bizar-dash/src/web/views/Eval.tsx +372 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +353 -0
- package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
- package/bizar-dash/src/web/views/Plugins.tsx +134 -0
- package/bizar-dash/src/web/views/Schedules.tsx +1 -1
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/a11y/components.test.tsx +149 -0
- package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
- package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval-csv.test.mjs +67 -0
- package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/plugins-permissions.test.mjs +525 -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,353 @@
|
|
|
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 { EvalExportButton } from '../components/EvalExportButton';
|
|
33
|
+
import { api } from '../lib/api';
|
|
34
|
+
import { cn, formatTime } from '../lib/utils';
|
|
35
|
+
import type { Settings, Snapshot } from '../lib/types';
|
|
36
|
+
|
|
37
|
+
type Props = {
|
|
38
|
+
snapshot: Snapshot;
|
|
39
|
+
settings: Settings;
|
|
40
|
+
activeTab: string;
|
|
41
|
+
setActiveTab: (id: string) => void;
|
|
42
|
+
refreshSnapshot: () => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function RunDetail({
|
|
46
|
+
run,
|
|
47
|
+
onClearCompare,
|
|
48
|
+
compareRun,
|
|
49
|
+
onClearSelection,
|
|
50
|
+
runsForDiff,
|
|
51
|
+
}: {
|
|
52
|
+
run: EvalRunWithResults;
|
|
53
|
+
onClearCompare: () => void;
|
|
54
|
+
compareRun: string | null;
|
|
55
|
+
onClearSelection: () => void;
|
|
56
|
+
runsForDiff: EvalRunWithResults[];
|
|
57
|
+
}) {
|
|
58
|
+
const passRate = run.total > 0 ? ((run.passed / run.total) * 100).toFixed(1) : '0.0';
|
|
59
|
+
const sortedResults = [...run.results].sort((a, b) => {
|
|
60
|
+
// Failures first, then by fixture id
|
|
61
|
+
if (a.ok !== b.ok) return a.ok ? 1 : -1;
|
|
62
|
+
return a.fixtureId.localeCompare(b.fixtureId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="eval-run-detail" data-testid="eval-run-detail">
|
|
67
|
+
<Card className="eval-run-detail-header">
|
|
68
|
+
<div className="eval-run-detail-header-row">
|
|
69
|
+
<CardTitle>
|
|
70
|
+
<ClipboardCheck size={14} aria-hidden /> {run.id}
|
|
71
|
+
</CardTitle>
|
|
72
|
+
<div className="eval-run-detail-actions">
|
|
73
|
+
<EvalExportButton runId={run.id} />
|
|
74
|
+
<Button variant="ghost" size="sm" onClick={onClearSelection} title="Close detail">
|
|
75
|
+
<X size={12} /> Close
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<CardMeta>
|
|
80
|
+
{formatTime(run.startedAt)}
|
|
81
|
+
{run.finishedAt ? ` → ${formatTime(run.finishedAt)}` : ''} · {run.suitePath}
|
|
82
|
+
</CardMeta>
|
|
83
|
+
<div className="eval-run-detail-summary">
|
|
84
|
+
<span className={cn('eval-status', run.failed === 0 ? 'is-pass' : 'is-fail')}>
|
|
85
|
+
{run.passed}/{run.total} ({passRate}%)
|
|
86
|
+
</span>
|
|
87
|
+
<span className="muted">
|
|
88
|
+
{run.failed} failed · {run.passed} passed
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
</Card>
|
|
92
|
+
|
|
93
|
+
{compareRun ? (
|
|
94
|
+
<Card className="eval-diff-card">
|
|
95
|
+
{(() => {
|
|
96
|
+
const baseRun = runsForDiff.find((r) => r.id === compareRun);
|
|
97
|
+
if (!baseRun) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="eval-diff-missing muted">
|
|
100
|
+
Baseline run <code>{compareRun}</code> not loaded. Pick another from the list.
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// Show "before" as the baseline (older) and "after" as the
|
|
105
|
+
// currently-selected run (newer). Re-derive before/after
|
|
106
|
+
// from the diff helper to drive the heading.
|
|
107
|
+
const diff = computeEvalDiff(baseRun, run);
|
|
108
|
+
const improved = diff.filter((d) => d.change === 'improved').length;
|
|
109
|
+
const regressed = diff.filter((d) => d.change === 'regressed').length;
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<div className="eval-diff-card-head">
|
|
113
|
+
<span className="eval-diff-card-title">
|
|
114
|
+
<ArrowLeftRight size={14} aria-hidden /> Comparing {baseRun.id} → {run.id}
|
|
115
|
+
</span>
|
|
116
|
+
<Button variant="ghost" size="sm" onClick={onClearCompare}>
|
|
117
|
+
<X size={12} /> Clear baseline
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="eval-diff-stats" aria-label="Diff summary">
|
|
121
|
+
<span className="eval-diff-stat improved">+{improved} improved</span>
|
|
122
|
+
<span className="eval-diff-stat regressed">−{regressed} regressed</span>
|
|
123
|
+
</div>
|
|
124
|
+
<EvalDiff runA={baseRun} runB={run} />
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
})()}
|
|
128
|
+
</Card>
|
|
129
|
+
) : (
|
|
130
|
+
<Card className="eval-baseline-hint">
|
|
131
|
+
<CardMeta>
|
|
132
|
+
Tip: click another run in the list to use it as a baseline for diffing.
|
|
133
|
+
</CardMeta>
|
|
134
|
+
</Card>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<Card className="eval-fixtures-card">
|
|
138
|
+
<CardTitle>Fixtures ({run.results.length})</CardTitle>
|
|
139
|
+
{sortedResults.length === 0 ? (
|
|
140
|
+
<CardMeta className="muted">No fixture results in this run.</CardMeta>
|
|
141
|
+
) : (
|
|
142
|
+
<ul className="eval-fixtures-list" role="list">
|
|
143
|
+
{sortedResults.map((r) => (
|
|
144
|
+
<li
|
|
145
|
+
key={r.fixtureId}
|
|
146
|
+
className={cn('eval-fixture-row', r.ok ? 'is-pass' : 'is-fail')}
|
|
147
|
+
>
|
|
148
|
+
<span
|
|
149
|
+
className={cn('eval-fixture-pill', r.ok ? 'is-pass' : 'is-fail')}
|
|
150
|
+
aria-label={r.ok ? 'pass' : 'fail'}
|
|
151
|
+
>
|
|
152
|
+
{r.ok ? '✓' : '✗'}
|
|
153
|
+
</span>
|
|
154
|
+
<span className="eval-fixture-id mono">{r.fixtureId}</span>
|
|
155
|
+
{!r.ok && r.checks ? (
|
|
156
|
+
<span className="eval-fixture-fails muted">
|
|
157
|
+
{r.checks
|
|
158
|
+
.filter((c) => !c.ok)
|
|
159
|
+
.map((c) => c.message)
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join(' · ')}
|
|
162
|
+
</span>
|
|
163
|
+
) : null}
|
|
164
|
+
{r.latencyMs != null ? (
|
|
165
|
+
<span className="eval-fixture-latency muted">{Math.round(r.latencyMs)}ms</span>
|
|
166
|
+
) : null}
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
)}
|
|
171
|
+
</Card>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function EvalReportInner({ setActiveTab }: Props) {
|
|
177
|
+
const toast = useToast();
|
|
178
|
+
const [runs, setRuns] = useState<EvalRunSummary[]>([]);
|
|
179
|
+
const [loadingRuns, setLoadingRuns] = useState(true);
|
|
180
|
+
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
|
181
|
+
const [compareRunId, setCompareRunId] = useState<string | null>(null);
|
|
182
|
+
const [detail, setDetail] = useState<EvalRunWithResults | null>(null);
|
|
183
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
184
|
+
const [compareDetail, setCompareDetail] = useState<EvalRunWithResults | null>(null);
|
|
185
|
+
const cancelledRef = useRef(false);
|
|
186
|
+
|
|
187
|
+
const loadRuns = useCallback(async () => {
|
|
188
|
+
setLoadingRuns(true);
|
|
189
|
+
try {
|
|
190
|
+
const r = await api.get<{ runs: EvalRunSummary[] }>('/eval/runs');
|
|
191
|
+
if (!cancelledRef.current) setRuns(r.runs || []);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (!cancelledRef.current) {
|
|
194
|
+
toast.error(`Failed to load eval runs: ${(err as Error).message}`);
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
if (!cancelledRef.current) setLoadingRuns(false);
|
|
198
|
+
}
|
|
199
|
+
}, [toast]);
|
|
200
|
+
|
|
201
|
+
const loadDetail = useCallback(
|
|
202
|
+
async (id: string) => {
|
|
203
|
+
setDetailLoading(true);
|
|
204
|
+
try {
|
|
205
|
+
const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
|
|
206
|
+
if (!cancelledRef.current) setDetail(r);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (!cancelledRef.current) {
|
|
209
|
+
toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
|
|
210
|
+
setDetail(null);
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
if (!cancelledRef.current) setDetailLoading(false);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
[toast],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const loadCompareDetail = useCallback(
|
|
220
|
+
async (id: string) => {
|
|
221
|
+
try {
|
|
222
|
+
const r = await api.get<EvalRunWithResults>(`/eval/runs/${encodeURIComponent(id)}`);
|
|
223
|
+
if (!cancelledRef.current) setCompareDetail(r);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (!cancelledRef.current) {
|
|
226
|
+
toast.error(`Failed to load run ${id}: ${(err as Error).message}`);
|
|
227
|
+
setCompareDetail(null);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
[toast],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Initial fetch
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
cancelledRef.current = false;
|
|
237
|
+
loadRuns();
|
|
238
|
+
return () => { cancelledRef.current = true; };
|
|
239
|
+
}, [loadRuns]);
|
|
240
|
+
|
|
241
|
+
const selectRun = async (id: string) => {
|
|
242
|
+
if (id === selectedRunId) return;
|
|
243
|
+
setSelectedRunId(id);
|
|
244
|
+
setCompareRunId(null);
|
|
245
|
+
setCompareDetail(null);
|
|
246
|
+
await loadDetail(id);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const useAsBaseline = async (id: string) => {
|
|
250
|
+
if (id === selectedRunId) return;
|
|
251
|
+
setCompareRunId(id);
|
|
252
|
+
await loadCompareDetail(id);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Build the runsForDiff array — only the full records we actually
|
|
256
|
+
// have loaded (selected + baseline). Anything else is unavailable
|
|
257
|
+
// and the detail panel will show a "not loaded" hint.
|
|
258
|
+
const runsForDiff: EvalRunWithResults[] = [];
|
|
259
|
+
if (compareDetail) runsForDiff.push(compareDetail);
|
|
260
|
+
if (detail) runsForDiff.push(detail);
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div className="view view-eval-report" data-testid="eval-report">
|
|
264
|
+
<div className="view-header">
|
|
265
|
+
<div className="view-header-text">
|
|
266
|
+
<h1 className="view-title">
|
|
267
|
+
<ClipboardCheck size={20} aria-hidden /> Eval Reports
|
|
268
|
+
</h1>
|
|
269
|
+
<p className="view-subtitle muted">
|
|
270
|
+
Browse eval runs, inspect per-fixture results, and diff two runs to spot regressions.
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="view-actions">
|
|
274
|
+
<Button variant="secondary" size="sm" onClick={loadRuns} disabled={loadingRuns}>
|
|
275
|
+
{loadingRuns ? <Spinner size="sm" /> : <RefreshCw size={12} />}
|
|
276
|
+
{loadingRuns ? 'Refreshing…' : 'Refresh'}
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div className="eval-grid">
|
|
282
|
+
<div className="eval-runs-list">
|
|
283
|
+
{loadingRuns && runs.length === 0 ? (
|
|
284
|
+
<div className="eval-runs-loading">
|
|
285
|
+
<Spinner size="sm" />
|
|
286
|
+
<span className="muted">Loading runs…</span>
|
|
287
|
+
</div>
|
|
288
|
+
) : runs.length === 0 ? (
|
|
289
|
+
<EmptyState
|
|
290
|
+
icon={<ClipboardCheck size={28} />}
|
|
291
|
+
title="No eval runs yet"
|
|
292
|
+
message={
|
|
293
|
+
<>
|
|
294
|
+
Run a suite via the CLI:{' '}
|
|
295
|
+
<code className="mono">bizar eval run ./path/to/fixtures</code>.
|
|
296
|
+
</>
|
|
297
|
+
}
|
|
298
|
+
/>
|
|
299
|
+
) : (
|
|
300
|
+
runs.map((r) => (
|
|
301
|
+
<div key={r.id} className="eval-run-card-wrap">
|
|
302
|
+
<EvalRunCard
|
|
303
|
+
run={r}
|
|
304
|
+
selected={r.id === selectedRunId}
|
|
305
|
+
onClick={() => selectRun(r.id)}
|
|
306
|
+
/>
|
|
307
|
+
{selectedRunId && r.id !== selectedRunId ? (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className="eval-baseline-btn"
|
|
311
|
+
onClick={() => useAsBaseline(r.id)}
|
|
312
|
+
title="Use this run as a baseline for diffing"
|
|
313
|
+
>
|
|
314
|
+
<ArrowLeftRight size={10} aria-hidden />
|
|
315
|
+
Use as baseline
|
|
316
|
+
</button>
|
|
317
|
+
) : null}
|
|
318
|
+
</div>
|
|
319
|
+
))
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div className="eval-detail">
|
|
324
|
+
{detailLoading && !detail ? (
|
|
325
|
+
<div className="eval-detail-loading">
|
|
326
|
+
<Spinner size="sm" />
|
|
327
|
+
<span className="muted">Loading run details…</span>
|
|
328
|
+
</div>
|
|
329
|
+
) : detail ? (
|
|
330
|
+
<RunDetail
|
|
331
|
+
run={detail}
|
|
332
|
+
compareRun={compareRunId}
|
|
333
|
+
runsForDiff={runsForDiff}
|
|
334
|
+
onClearCompare={() => { setCompareRunId(null); setCompareDetail(null); }}
|
|
335
|
+
onClearSelection={() => { setSelectedRunId(null); setDetail(null); setCompareRunId(null); setCompareDetail(null); }}
|
|
336
|
+
/>
|
|
337
|
+
) : (
|
|
338
|
+
<Card className="eval-detail-empty">
|
|
339
|
+
<EmptyState
|
|
340
|
+
icon={<ClipboardCheck size={28} />}
|
|
341
|
+
title="Select a run"
|
|
342
|
+
message="Pick an eval run from the list to view fixture-by-fixture results, latency, and per-check failure details."
|
|
343
|
+
/>
|
|
344
|
+
</Card>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const EvalReport = React.memo(EvalReportInner);
|
|
353
|
+
export type { EvalFixtureResult };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/web/views/Marketplace.tsx — browse and install plugins from the marketplace registry.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Store } from 'lucide-react';
|
|
5
|
+
import { MarketplacePluginCard, type MarketplacePlugin } from '../components/MarketplacePluginCard';
|
|
6
|
+
import { InstallConfirmDialog } from '../components/InstallConfirmDialog';
|
|
7
|
+
import { EmptyState } from '../components/EmptyState';
|
|
8
|
+
import { Spinner } from '../components/Spinner';
|
|
9
|
+
import { useToast } from '../components/Toast';
|
|
10
|
+
import { api } from '../lib/api';
|
|
11
|
+
|
|
12
|
+
type RegistryResponse = {
|
|
13
|
+
plugins: MarketplacePlugin[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function Marketplace() {
|
|
17
|
+
const toast = useToast();
|
|
18
|
+
const [registry, setRegistry] = useState<RegistryResponse | null>(null);
|
|
19
|
+
const [search, setSearch] = useState('');
|
|
20
|
+
const [category, setCategory] = useState<string | null>(null);
|
|
21
|
+
const [installing, setInstalling] = useState<MarketplacePlugin | null>(null);
|
|
22
|
+
const [selected, setSelected] = useState<MarketplacePlugin | null>(null);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
api.get<RegistryResponse>('/plugins/registry')
|
|
27
|
+
.then((r) => setRegistry(r))
|
|
28
|
+
.catch((err) => toast.error(`Failed to load marketplace: ${err.message}`))
|
|
29
|
+
.finally(() => setLoading(false));
|
|
30
|
+
}, [toast]);
|
|
31
|
+
|
|
32
|
+
const install = async (plugin: MarketplacePlugin) => {
|
|
33
|
+
setInstalling(plugin);
|
|
34
|
+
try {
|
|
35
|
+
await api.post('/plugins/install', { pluginId: plugin.id });
|
|
36
|
+
toast.success(`${plugin.name} installed`);
|
|
37
|
+
setInstalling(null);
|
|
38
|
+
setSelected(null);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
toast.error(`Install failed: ${(err as Error).message}`);
|
|
41
|
+
setInstalling(null);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const plugins = registry?.plugins ?? [];
|
|
46
|
+
const filtered = plugins.filter(
|
|
47
|
+
(p) =>
|
|
48
|
+
(!search || p.name.toLowerCase().includes(search.toLowerCase())) &&
|
|
49
|
+
(!category || p.category === category),
|
|
50
|
+
);
|
|
51
|
+
const categories = [...new Set(plugins.map((p) => p.category))];
|
|
52
|
+
|
|
53
|
+
if (loading) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="view-loading">
|
|
56
|
+
<Spinner size="lg" />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="view view-marketplace">
|
|
63
|
+
<header className="view-header">
|
|
64
|
+
<div className="view-header-text">
|
|
65
|
+
<h2 className="view-title">
|
|
66
|
+
<Store size={18} aria-hidden />
|
|
67
|
+
Plugin Marketplace
|
|
68
|
+
</h2>
|
|
69
|
+
<p className="view-subtitle">
|
|
70
|
+
Discover and install plugins from the community registry.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="marketplace-search-row">
|
|
76
|
+
<input
|
|
77
|
+
className="input"
|
|
78
|
+
type="search"
|
|
79
|
+
placeholder="Search plugins..."
|
|
80
|
+
value={search}
|
|
81
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
82
|
+
style={{ maxWidth: 320 }}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="category-chips">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={() => setCategory(null)}
|
|
90
|
+
className={!category ? 'active' : ''}
|
|
91
|
+
>
|
|
92
|
+
All
|
|
93
|
+
</button>
|
|
94
|
+
{categories.map((c) => (
|
|
95
|
+
<button
|
|
96
|
+
key={c}
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => setCategory(c)}
|
|
99
|
+
className={category === c ? 'active' : ''}
|
|
100
|
+
>
|
|
101
|
+
{c}
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{filtered.length === 0 ? (
|
|
107
|
+
<EmptyState
|
|
108
|
+
icon={<Store size={32} />}
|
|
109
|
+
title="No plugins found"
|
|
110
|
+
message={search ? `No plugins match "${search}".` : 'The marketplace is empty.'}
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="marketplace-grid">
|
|
114
|
+
{filtered.map((p) => (
|
|
115
|
+
<MarketplacePluginCard
|
|
116
|
+
key={p.id}
|
|
117
|
+
plugin={p}
|
|
118
|
+
onInstall={() => setSelected(p)}
|
|
119
|
+
onView={() => setSelected(p)}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{selected && (
|
|
126
|
+
<InstallConfirmDialog
|
|
127
|
+
plugin={selected}
|
|
128
|
+
onConfirm={() => install(selected)}
|
|
129
|
+
onCancel={() => setSelected(null)}
|
|
130
|
+
installing={installing?.id === selected.id || false}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// src/views/Plugins.tsx — manage installed plugins and their permissions.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Puzzle, Store } 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 { Button } from '../components/Button';
|
|
9
|
+
import { useToast } from '../components/Toast';
|
|
10
|
+
import { api } from '../lib/api';
|
|
11
|
+
import type { Settings, Snapshot } from '../lib/types';
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
snapshot: Snapshot;
|
|
15
|
+
settings: Settings;
|
|
16
|
+
activeTab: string;
|
|
17
|
+
setActiveTab: (id: string) => void;
|
|
18
|
+
refreshSnapshot: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function Plugins({ setActiveTab }: Props) {
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [filter, setFilter] = useState('');
|
|
26
|
+
|
|
27
|
+
const load = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const r = await api.get<{ plugins: InstalledPlugin[] }>('/plugins/installed');
|
|
30
|
+
setPlugins(r.plugins);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
toast.error(`Failed to load plugins: ${(err as Error).message}`);
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
void load();
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const toggle = async (id: string) => {
|
|
43
|
+
try {
|
|
44
|
+
// Toggle is handled by the backend via a toggle endpoint or we use update
|
|
45
|
+
await api.post(`/plugins/${id}/toggle`).catch(() => {
|
|
46
|
+
// Fallback: toggle is not a separate endpoint, may need PUT
|
|
47
|
+
});
|
|
48
|
+
await load();
|
|
49
|
+
toast.success('Plugin toggled.');
|
|
50
|
+
} catch (err) {
|
|
51
|
+
toast.error(`Toggle failed: ${(err as Error).message}`);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const uninstall = async (id: string) => {
|
|
56
|
+
if (!confirm('Uninstall this plugin?')) return;
|
|
57
|
+
try {
|
|
58
|
+
await api.del(`/plugins/${id}`);
|
|
59
|
+
await load();
|
|
60
|
+
toast.success('Plugin uninstalled.');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
toast.error(`Uninstall failed: ${(err as Error).message}`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const configure = (_id: string) => {
|
|
67
|
+
// TODO: open config dialog
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (loading) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="view-loading">
|
|
73
|
+
<Spinner size="lg" />
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const filtered = plugins.filter((p) =>
|
|
79
|
+
p.name.toLowerCase().includes(filter.toLowerCase()),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="view view-plugins">
|
|
84
|
+
<header className="view-header">
|
|
85
|
+
<div className="view-header-text">
|
|
86
|
+
<h2 className="view-title">
|
|
87
|
+
<Puzzle size={18} aria-hidden />
|
|
88
|
+
Plugins ({plugins.length})
|
|
89
|
+
</h2>
|
|
90
|
+
<p className="view-subtitle">
|
|
91
|
+
Manage plugins installed via the marketplace.
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
<Button variant="secondary" size="sm" onClick={() => setActiveTab('marketplace')}>
|
|
95
|
+
<Store size={14} aria-hidden />
|
|
96
|
+
Browse Marketplace
|
|
97
|
+
</Button>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
{plugins.length === 0 ? (
|
|
101
|
+
<EmptyState
|
|
102
|
+
icon={<Puzzle size={32} />}
|
|
103
|
+
title="No plugins installed"
|
|
104
|
+
message="Browse the marketplace to discover and install plugins."
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<div className="plugins-toolbar">
|
|
109
|
+
<input
|
|
110
|
+
className="input"
|
|
111
|
+
type="search"
|
|
112
|
+
placeholder="Filter plugins…"
|
|
113
|
+
value={filter}
|
|
114
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
115
|
+
aria-label="Filter plugins"
|
|
116
|
+
style={{ maxWidth: 320 }}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="plugin-grid">
|
|
120
|
+
{filtered.map((p) => (
|
|
121
|
+
<PluginCard
|
|
122
|
+
key={p.id}
|
|
123
|
+
plugin={p}
|
|
124
|
+
onToggle={toggle}
|
|
125
|
+
onUninstall={uninstall}
|
|
126
|
+
onConfigure={configure}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -372,7 +372,7 @@ export function Schedules({ snapshot, refreshSnapshot }: Props) {
|
|
|
372
372
|
<div className="view-actions">
|
|
373
373
|
{activeSection === 'schedules' && (
|
|
374
374
|
<>
|
|
375
|
-
<Button variant="secondary" size="sm" onClick={reload}>
|
|
375
|
+
<Button variant="secondary" size="sm" onClick={reload} aria-label="Refresh schedules">
|
|
376
376
|
<RefreshCw size={14} /> Refresh
|
|
377
377
|
</Button>
|
|
378
378
|
<Button variant="primary" size="sm" onClick={() => openEditor()}>
|
|
@@ -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} />}
|