@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.
Files changed (72) hide show
  1. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js → MobileChat-OidrxXR9.js} +1 -1
  2. package/bizar-dash/dist/assets/{MobileChat-BVojK0n5.js.map → MobileChat-OidrxXR9.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js → MobileSettings-B91gVFii.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileSettings-C85ApWKZ.js.map → MobileSettings-B91gVFii.js.map} +1 -1
  5. package/bizar-dash/dist/assets/{icons-Clz0NR6Y.js → icons-Bd8piSb2.js} +159 -134
  6. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-3G6mOhiK.css} +1 -1
  8. package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
  9. package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
  10. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js → mobile-CSsp30lr.js} +2 -2
  11. package/bizar-dash/dist/assets/{mobile-IaZ47uKC.js.map → mobile-CSsp30lr.js.map} +1 -1
  12. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js → mobile-DtwK7DOV.js} +1 -1
  13. package/bizar-dash/dist/assets/{mobile-D2pc-iNh.js.map → mobile-DtwK7DOV.js.map} +1 -1
  14. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js → useSlashCommands-DEkxEvOO.js} +2 -2
  15. package/bizar-dash/dist/assets/{useSlashCommands-DjEwHl4n.js.map → useSlashCommands-DEkxEvOO.js.map} +1 -1
  16. package/bizar-dash/dist/index.html +5 -5
  17. package/bizar-dash/dist/mobile.html +3 -3
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/src/server/api.mjs +4 -0
  20. package/bizar-dash/src/server/eval-store.mjs +55 -0
  21. package/bizar-dash/src/server/eval.mjs +63 -0
  22. package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
  23. package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
  24. package/bizar-dash/src/server/plugins/store.mjs +4 -0
  25. package/bizar-dash/src/server/routes/eval.mjs +42 -2
  26. package/bizar-dash/src/server/routes/plugins.mjs +55 -7
  27. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  28. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  29. package/bizar-dash/src/server/schedules-runner.mjs +61 -0
  30. package/bizar-dash/src/server/server.mjs +13 -0
  31. package/bizar-dash/src/server/voice-store.mjs +27 -0
  32. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  33. package/bizar-dash/src/web/App.tsx +9 -0
  34. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  35. package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
  36. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  37. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
  38. package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -0
  39. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  40. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  41. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
  42. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  43. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +9 -0
  45. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
  46. package/bizar-dash/src/web/styles/main.css +684 -0
  47. package/bizar-dash/src/web/views/Doctor.tsx +3 -3
  48. package/bizar-dash/src/web/views/Eval.tsx +372 -0
  49. package/bizar-dash/src/web/views/EvalReport.tsx +353 -0
  50. package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
  51. package/bizar-dash/src/web/views/Plugins.tsx +134 -0
  52. package/bizar-dash/src/web/views/Schedules.tsx +1 -1
  53. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  54. package/bizar-dash/tests/a11y/components.test.tsx +149 -0
  55. package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
  56. package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
  57. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  58. package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
  59. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  60. package/bizar-dash/tests/eval-csv.test.mjs +67 -0
  61. package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
  62. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  63. package/bizar-dash/tests/plugins-permissions.test.mjs +525 -0
  64. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  65. package/cli/bin.mjs +14 -0
  66. package/cli/commands/dash.mjs +19 -1
  67. package/cli/commands/service.mjs +7 -0
  68. package/cli/commands/tailscale.mjs +251 -0
  69. package/package.json +1 -1
  70. package/bizar-dash/dist/assets/icons-Clz0NR6Y.js.map +0 -1
  71. package/bizar-dash/dist/assets/main-8feQWXiF.js +0 -19
  72. 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} />}