@polderlabs/bizar 5.2.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-TCputYzr.js → MobileChat-OidrxXR9.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js.map → MobileChat-OidrxXR9.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js → MobileSettings-B91gVFii.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js.map → MobileSettings-B91gVFii.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-DRDXfbBP.js → icons-Bd8piSb2.js} +18 -13
- package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
- package/bizar-dash/dist/assets/{main-xFpWMd32.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-j3rOZK6v.js → mobile-CSsp30lr.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js.map → mobile-CSsp30lr.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js → mobile-DtwK7DOV.js} +1 -1
- package/bizar-dash/dist/assets/{mobile--17fkfrl.js.map → mobile-DtwK7DOV.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js → useSlashCommands-DEkxEvOO.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.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/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 +44 -4
- package/bizar-dash/src/server/schedules-runner.mjs +61 -0
- package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -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 +2 -2
- package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
- package/bizar-dash/src/web/components/Topbar.tsx +3 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
- package/bizar-dash/src/web/styles/main.css +139 -0
- package/bizar-dash/src/web/views/Doctor.tsx +3 -3
- package/bizar-dash/src/web/views/Eval.tsx +225 -25
- package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
- package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
- package/bizar-dash/src/web/views/Plugins.tsx +9 -3
- package/bizar-dash/src/web/views/Schedules.tsx +1 -1
- 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/components/marketplace-plugin-card.test.tsx +144 -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/plugins-permissions.test.mjs +525 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/web/components/InstallConfirmDialog.tsx — confirmation dialog for installing a marketplace plugin.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { AlertTriangle } from 'lucide-react';
|
|
5
|
+
import { useModal } from './Modal';
|
|
6
|
+
import { Button } from './Button';
|
|
7
|
+
import { PluginPermissions } from './PluginPermissions';
|
|
8
|
+
import type { MarketplacePlugin } from './MarketplacePluginCard';
|
|
9
|
+
|
|
10
|
+
export type InstallConfirmDialogProps = {
|
|
11
|
+
plugin: MarketplacePlugin;
|
|
12
|
+
onConfirm: () => void | Promise<void>;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
installing: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function InstallConfirmDialog({ plugin, onConfirm, onCancel, installing }: InstallConfirmDialogProps) {
|
|
18
|
+
const modal = useModal();
|
|
19
|
+
// Track the plugin id we've opened the modal for, so we don't re-open on every render.
|
|
20
|
+
const openedForRef = useRef<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!plugin) return;
|
|
24
|
+
// Only open if we haven't already opened for this plugin.
|
|
25
|
+
if (openedForRef.current === plugin.id) return;
|
|
26
|
+
openedForRef.current = plugin.id;
|
|
27
|
+
|
|
28
|
+
modal.open({
|
|
29
|
+
title: `Install ${plugin.name}?`,
|
|
30
|
+
width: 480,
|
|
31
|
+
onClose: () => {
|
|
32
|
+
openedForRef.current = null;
|
|
33
|
+
onCancel();
|
|
34
|
+
},
|
|
35
|
+
children: (
|
|
36
|
+
<div className="install-confirm">
|
|
37
|
+
<h3>{plugin.name} v{plugin.version}</h3>
|
|
38
|
+
{plugin.description && <p>{plugin.description}</p>}
|
|
39
|
+
|
|
40
|
+
<div className="install-warning">
|
|
41
|
+
<AlertTriangle size={16} />
|
|
42
|
+
<p>This plugin will run code on your machine. It requests these permissions:</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<PluginPermissions permissions={plugin.permissions ?? []} />
|
|
46
|
+
|
|
47
|
+
<div className="install-confirm-actions">
|
|
48
|
+
<Button onClick={onCancel} disabled={installing}>Cancel</Button>
|
|
49
|
+
<Button variant="primary" onClick={onConfirm} disabled={installing}>
|
|
50
|
+
{installing ? 'Installing...' : 'Install'}
|
|
51
|
+
</Button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
}, [plugin, installing]);
|
|
58
|
+
|
|
59
|
+
// This component manages its own modal state via useModal.
|
|
60
|
+
// Render nothing in the React tree — the modal is rendered via portal.
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/web/components/MarketplacePluginCard.tsx — card for a plugin listed in the marketplace.
|
|
2
|
+
|
|
3
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { PluginPermissions } from './PluginPermissions';
|
|
6
|
+
|
|
7
|
+
export type MarketplacePlugin = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
author: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
category: string;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
permissions?: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MarketplacePluginCardProps = {
|
|
19
|
+
plugin: MarketplacePlugin;
|
|
20
|
+
onInstall: () => void;
|
|
21
|
+
onView: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function MarketplacePluginCard({ plugin, onInstall, onView }: MarketplacePluginCardProps) {
|
|
25
|
+
return (
|
|
26
|
+
<Card className="marketplace-plugin-card">
|
|
27
|
+
<div className="marketplace-plugin-head">
|
|
28
|
+
<div>
|
|
29
|
+
<CardTitle>{plugin.name}</CardTitle>
|
|
30
|
+
<CardMeta>v{plugin.version} · {plugin.category}</CardMeta>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="marketplace-plugin-author">{plugin.author}</div>
|
|
33
|
+
</div>
|
|
34
|
+
{plugin.description && (
|
|
35
|
+
<p className="marketplace-plugin-description">{plugin.description}</p>
|
|
36
|
+
)}
|
|
37
|
+
{plugin.tags && plugin.tags.length > 0 && (
|
|
38
|
+
<div className="marketplace-plugin-tags">
|
|
39
|
+
{plugin.tags.map((tag) => (
|
|
40
|
+
<span key={tag} className="tag">{tag}</span>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
<PluginPermissions permissions={plugin.permissions ?? []} />
|
|
45
|
+
<div className="marketplace-plugin-actions">
|
|
46
|
+
<Button onClick={onView}>Details</Button>
|
|
47
|
+
<Button variant="primary" onClick={onInstall}>Install</Button>
|
|
48
|
+
</div>
|
|
49
|
+
</Card>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -46,10 +46,10 @@ export function PluginCard({ plugin, onToggle, onUninstall, onConfigure }: Plugi
|
|
|
46
46
|
)}
|
|
47
47
|
<PluginPermissions permissions={plugin.permissions ?? []} />
|
|
48
48
|
<div className="plugin-card-actions">
|
|
49
|
-
<Button size="sm" onClick={() => onConfigure(plugin.id)}>
|
|
49
|
+
<Button size="sm" onClick={() => onConfigure(plugin.id)} aria-label={`Configure ${plugin.name}`}>
|
|
50
50
|
Configure
|
|
51
51
|
</Button>
|
|
52
|
-
<Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)}>
|
|
52
|
+
<Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)} aria-label={`Uninstall ${plugin.name}`}>
|
|
53
53
|
Uninstall
|
|
54
54
|
</Button>
|
|
55
55
|
</div>
|
|
@@ -16,7 +16,7 @@ export type PluginPermissionsProps = {
|
|
|
16
16
|
|
|
17
17
|
export function PluginPermissions({ permissions }: PluginPermissionsProps) {
|
|
18
18
|
return (
|
|
19
|
-
<div className="plugin-permissions">
|
|
19
|
+
<div className="plugin-permissions" aria-label="Required permissions">
|
|
20
20
|
{permissions.map((perm) => {
|
|
21
21
|
const info = PERMISSION_LABELS[perm] ?? { label: perm, icon: Shield, color: 'gray', description: '' };
|
|
22
22
|
const Icon = info.icon;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
Brain,
|
|
26
26
|
Stethoscope,
|
|
27
27
|
ClipboardCheck,
|
|
28
|
+
Store,
|
|
28
29
|
type LucideIcon,
|
|
29
30
|
} from 'lucide-react';
|
|
30
31
|
import { cn } from '../lib/utils';
|
|
@@ -51,6 +52,8 @@ export const TABS: TabDef[] = [
|
|
|
51
52
|
{ id: 'skills', label: 'Skills', icon: Sparkles },
|
|
52
53
|
{ id: 'memory', label: 'Memory', icon: Brain },
|
|
53
54
|
{ id: 'mods', label: 'Mods', icon: Puzzle },
|
|
55
|
+
// v5.3.0 — Plugin marketplace browse + install UI.
|
|
56
|
+
{ id: 'marketplace', label: 'Marketplace', icon: Store },
|
|
54
57
|
{ id: 'schedules', label: 'Schedules', icon: Clock },
|
|
55
58
|
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
|
|
56
59
|
{ id: 'history', label: 'History', icon: HistoryIcon },
|
|
@@ -120,6 +120,34 @@ export function VoiceNotesPanel({ vaultPath, refreshKey = 0 }: Props) {
|
|
|
120
120
|
|
|
121
121
|
useEffect(() => {
|
|
122
122
|
loadNotes();
|
|
123
|
+
|
|
124
|
+
// v5.2.0 — Listen for transcription worker updates
|
|
125
|
+
const handleMessage = (e: MessageEvent) => {
|
|
126
|
+
try {
|
|
127
|
+
const msg = JSON.parse(e.data);
|
|
128
|
+
if (msg.type === 'voice:updated' && msg.noteId) {
|
|
129
|
+
// Update the matching note's transcript in place
|
|
130
|
+
setNotes((prev) => prev.map((n) =>
|
|
131
|
+
n.id === msg.noteId
|
|
132
|
+
? { ...n, transcript: msg.patch?.transcript ?? n.transcript }
|
|
133
|
+
: n
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
} catch { /* ignore parse errors */ }
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Connect to WS for real-time transcript updates
|
|
140
|
+
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
|
141
|
+
let ws: WebSocket | null = null;
|
|
142
|
+
try {
|
|
143
|
+
ws = new WebSocket(wsUrl);
|
|
144
|
+
ws.addEventListener('message', handleMessage);
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
ws?.removeEventListener('message', handleMessage);
|
|
149
|
+
ws?.close();
|
|
150
|
+
};
|
|
123
151
|
}, [vaultPath, refreshKey]);
|
|
124
152
|
|
|
125
153
|
const handleDelete = async (id: string) => {
|
|
@@ -9162,6 +9162,120 @@ select:focus-visible,
|
|
|
9162
9162
|
margin-top: var(--space-2);
|
|
9163
9163
|
}
|
|
9164
9164
|
|
|
9165
|
+
/* ─── v5.3.0 — Plugin marketplace ──────────────────────────────── */
|
|
9166
|
+
.marketplace-grid {
|
|
9167
|
+
display: grid;
|
|
9168
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
9169
|
+
gap: 16px;
|
|
9170
|
+
margin-top: 24px;
|
|
9171
|
+
}
|
|
9172
|
+
|
|
9173
|
+
.marketplace-plugin-card {
|
|
9174
|
+
display: flex;
|
|
9175
|
+
flex-direction: column;
|
|
9176
|
+
}
|
|
9177
|
+
|
|
9178
|
+
.marketplace-plugin-head {
|
|
9179
|
+
display: flex;
|
|
9180
|
+
align-items: flex-start;
|
|
9181
|
+
justify-content: space-between;
|
|
9182
|
+
gap: var(--space-3);
|
|
9183
|
+
}
|
|
9184
|
+
|
|
9185
|
+
.marketplace-plugin-description {
|
|
9186
|
+
color: var(--text-dim);
|
|
9187
|
+
margin: var(--space-2) 0;
|
|
9188
|
+
font-size: 13px;
|
|
9189
|
+
line-height: 1.5;
|
|
9190
|
+
}
|
|
9191
|
+
|
|
9192
|
+
.marketplace-plugin-tags {
|
|
9193
|
+
display: flex;
|
|
9194
|
+
flex-wrap: wrap;
|
|
9195
|
+
gap: 4px;
|
|
9196
|
+
margin: 8px 0;
|
|
9197
|
+
}
|
|
9198
|
+
|
|
9199
|
+
.marketplace-plugin-tags .tag {
|
|
9200
|
+
background: var(--surface-3, rgba(255, 255, 255, 0.05));
|
|
9201
|
+
color: var(--text-dim, #8b949e);
|
|
9202
|
+
padding: 2px 6px;
|
|
9203
|
+
border-radius: 8px;
|
|
9204
|
+
font-size: 10px;
|
|
9205
|
+
}
|
|
9206
|
+
|
|
9207
|
+
.marketplace-plugin-actions {
|
|
9208
|
+
display: flex;
|
|
9209
|
+
gap: var(--space-2);
|
|
9210
|
+
flex-wrap: wrap;
|
|
9211
|
+
margin-top: auto;
|
|
9212
|
+
padding-top: var(--space-2);
|
|
9213
|
+
}
|
|
9214
|
+
|
|
9215
|
+
.marketplace-plugin-author {
|
|
9216
|
+
font-size: 12px;
|
|
9217
|
+
color: var(--text-dim);
|
|
9218
|
+
}
|
|
9219
|
+
|
|
9220
|
+
.marketplace-search-row {
|
|
9221
|
+
margin-bottom: 16px;
|
|
9222
|
+
}
|
|
9223
|
+
|
|
9224
|
+
.category-chips {
|
|
9225
|
+
display: flex;
|
|
9226
|
+
gap: 8px;
|
|
9227
|
+
margin: 16px 0;
|
|
9228
|
+
flex-wrap: wrap;
|
|
9229
|
+
}
|
|
9230
|
+
|
|
9231
|
+
.category-chips button {
|
|
9232
|
+
padding: 6px 12px;
|
|
9233
|
+
border: 1px solid var(--border, #30363d);
|
|
9234
|
+
border-radius: 14px;
|
|
9235
|
+
background: transparent;
|
|
9236
|
+
color: var(--text-dim);
|
|
9237
|
+
cursor: pointer;
|
|
9238
|
+
font-size: 13px;
|
|
9239
|
+
}
|
|
9240
|
+
|
|
9241
|
+
.category-chips button:hover {
|
|
9242
|
+
background: var(--surface-2);
|
|
9243
|
+
}
|
|
9244
|
+
|
|
9245
|
+
.category-chips button.active {
|
|
9246
|
+
background: var(--accent, #8b5cf6);
|
|
9247
|
+
color: white;
|
|
9248
|
+
border-color: var(--accent, #8b5cf6);
|
|
9249
|
+
}
|
|
9250
|
+
|
|
9251
|
+
.install-confirm h3 {
|
|
9252
|
+
margin: 0 0 var(--space-2);
|
|
9253
|
+
font-size: 16px;
|
|
9254
|
+
}
|
|
9255
|
+
|
|
9256
|
+
.install-warning {
|
|
9257
|
+
display: flex;
|
|
9258
|
+
gap: 8px;
|
|
9259
|
+
background: rgba(245, 158, 11, 0.1);
|
|
9260
|
+
padding: 12px;
|
|
9261
|
+
border-radius: 6px;
|
|
9262
|
+
margin: 12px 0;
|
|
9263
|
+
color: #d29922;
|
|
9264
|
+
font-size: 13px;
|
|
9265
|
+
}
|
|
9266
|
+
|
|
9267
|
+
.install-warning p {
|
|
9268
|
+
margin: 0;
|
|
9269
|
+
color: var(--text);
|
|
9270
|
+
}
|
|
9271
|
+
|
|
9272
|
+
.install-confirm-actions {
|
|
9273
|
+
display: flex;
|
|
9274
|
+
gap: var(--space-2);
|
|
9275
|
+
justify-content: flex-end;
|
|
9276
|
+
margin-top: var(--space-4);
|
|
9277
|
+
}
|
|
9278
|
+
|
|
9165
9279
|
/* ─── Plugin permissions ──────────────────────────────────────────── */
|
|
9166
9280
|
.plugin-permissions {
|
|
9167
9281
|
display: flex;
|
|
@@ -9610,3 +9724,28 @@ select:focus-visible,
|
|
|
9610
9724
|
display: flex;
|
|
9611
9725
|
flex-direction: column;
|
|
9612
9726
|
}
|
|
9727
|
+
|
|
9728
|
+
/* ─── High-contrast mode (prefers-contrast: more) ──────────────────── */
|
|
9729
|
+
@media (prefers-contrast: more) {
|
|
9730
|
+
:root {
|
|
9731
|
+
--text-dim: var(--text);
|
|
9732
|
+
--text-muted: var(--text);
|
|
9733
|
+
}
|
|
9734
|
+
|
|
9735
|
+
[data-theme='light'] {
|
|
9736
|
+
--text-dim: #374151;
|
|
9737
|
+
--text-muted: #374151;
|
|
9738
|
+
}
|
|
9739
|
+
|
|
9740
|
+
:focus-visible,
|
|
9741
|
+
button:focus-visible,
|
|
9742
|
+
[role='button']:focus-visible,
|
|
9743
|
+
a:focus-visible,
|
|
9744
|
+
input:focus-visible,
|
|
9745
|
+
textarea:focus-visible,
|
|
9746
|
+
select:focus-visible,
|
|
9747
|
+
[tabindex]:focus-visible {
|
|
9748
|
+
outline-width: 3px;
|
|
9749
|
+
outline-style: solid;
|
|
9750
|
+
}
|
|
9751
|
+
}
|
|
@@ -208,7 +208,7 @@ function DoctorInner({
|
|
|
208
208
|
</div>
|
|
209
209
|
<div className="doctor-header-meta muted">
|
|
210
210
|
<Clock size={12} aria-hidden />
|
|
211
|
-
<span>
|
|
211
|
+
<span aria-live="polite" aria-atomic="true">
|
|
212
212
|
v{data.bizarVersion} · {data.platform}/{data.arch} ·{' '}
|
|
213
213
|
up {Math.floor(data.uptime)}s
|
|
214
214
|
{lastFetched ? ` · refreshed ${formatTime(new Date(lastFetched))}` : ''}
|
|
@@ -266,7 +266,7 @@ function DoctorInner({
|
|
|
266
266
|
</CardTitle>
|
|
267
267
|
<CardMeta>Run a check, view logs, or export the snapshot.</CardMeta>
|
|
268
268
|
<div className="doctor-actions-row">
|
|
269
|
-
<Button variant="primary" onClick={fetchSnapshot} disabled={refreshing}>
|
|
269
|
+
<Button variant="primary" onClick={fetchSnapshot} disabled={refreshing} aria-label="Refresh diagnostics">
|
|
270
270
|
{refreshing ? <Spinner size="sm" /> : <RefreshCw size={12} />}
|
|
271
271
|
{refreshing ? 'Running…' : 'Run Health Check Now'}
|
|
272
272
|
</Button>
|
|
@@ -279,7 +279,7 @@ function DoctorInner({
|
|
|
279
279
|
<Button
|
|
280
280
|
variant="ghost"
|
|
281
281
|
onClick={() => setAutoRefresh((v) => !v)}
|
|
282
|
-
|
|
282
|
+
aria-label={autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
|
|
283
283
|
>
|
|
284
284
|
{autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
|
|
285
285
|
</Button>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// overview pattern (counts at top, recent history, call to action).
|
|
11
11
|
|
|
12
12
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
-
import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink } from 'lucide-react';
|
|
13
|
+
import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink, Clock, Plus, Trash2 } from 'lucide-react';
|
|
14
14
|
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
15
15
|
import { Button } from '../components/Button';
|
|
16
16
|
import { EmptyState } from '../components/EmptyState';
|
|
@@ -21,6 +21,14 @@ import { api } from '../lib/api';
|
|
|
21
21
|
import { cn } from '../lib/utils';
|
|
22
22
|
import type { Settings, Snapshot } from '../lib/types';
|
|
23
23
|
|
|
24
|
+
type EvalSchedule = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
suitePath: string;
|
|
28
|
+
cron: string;
|
|
29
|
+
agent: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
24
32
|
type Props = {
|
|
25
33
|
snapshot: Snapshot;
|
|
26
34
|
settings: Settings;
|
|
@@ -31,10 +39,103 @@ type Props = {
|
|
|
31
39
|
|
|
32
40
|
const RECENT_RUNS_LIMIT = 8;
|
|
33
41
|
|
|
42
|
+
function AddScheduleForm({
|
|
43
|
+
onAdded,
|
|
44
|
+
onCancel,
|
|
45
|
+
}: {
|
|
46
|
+
onAdded: (schedule: EvalSchedule) => void;
|
|
47
|
+
onCancel: () => void;
|
|
48
|
+
}) {
|
|
49
|
+
const toast = useToast();
|
|
50
|
+
const [name, setName] = useState('');
|
|
51
|
+
const [suitePath, setSuitePath] = useState('');
|
|
52
|
+
const [cron, setCron] = useState('');
|
|
53
|
+
const [agent, setAgent] = useState('thor');
|
|
54
|
+
const [submitting, setSubmitting] = useState(false);
|
|
55
|
+
|
|
56
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (!name || !suitePath || !cron) {
|
|
59
|
+
toast.error('name, suitePath, and cron are required');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setSubmitting(true);
|
|
63
|
+
try {
|
|
64
|
+
const result = await api.post<EvalSchedule>('/eval/schedules', { name, suitePath, cron, agent });
|
|
65
|
+
onAdded(result);
|
|
66
|
+
toast.success('Schedule created');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
toast.error(`Failed to create schedule: ${(err as Error).message}`);
|
|
69
|
+
} finally {
|
|
70
|
+
setSubmitting(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<form className="eval-schedule-form" onSubmit={handleSubmit}>
|
|
76
|
+
<div className="form-row">
|
|
77
|
+
<label htmlFor="sched-name">Name</label>
|
|
78
|
+
<input
|
|
79
|
+
id="sched-name"
|
|
80
|
+
type="text"
|
|
81
|
+
value={name}
|
|
82
|
+
onChange={(e) => setName(e.target.value)}
|
|
83
|
+
placeholder="Daily fixture check"
|
|
84
|
+
required
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="form-row">
|
|
88
|
+
<label htmlFor="sched-suite">Suite path</label>
|
|
89
|
+
<input
|
|
90
|
+
id="sched-suite"
|
|
91
|
+
type="text"
|
|
92
|
+
value={suitePath}
|
|
93
|
+
onChange={(e) => setSuitePath(e.target.value)}
|
|
94
|
+
placeholder="./fixtures"
|
|
95
|
+
required
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="form-row">
|
|
99
|
+
<label htmlFor="sched-cron">Cron</label>
|
|
100
|
+
<input
|
|
101
|
+
id="sched-cron"
|
|
102
|
+
type="text"
|
|
103
|
+
value={cron}
|
|
104
|
+
onChange={(e) => setCron(e.target.value)}
|
|
105
|
+
placeholder="0 8 * * *"
|
|
106
|
+
required
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="form-row">
|
|
110
|
+
<label htmlFor="sched-agent">Agent</label>
|
|
111
|
+
<input
|
|
112
|
+
id="sched-agent"
|
|
113
|
+
type="text"
|
|
114
|
+
value={agent}
|
|
115
|
+
onChange={(e) => setAgent(e.target.value)}
|
|
116
|
+
placeholder="thor"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="form-actions">
|
|
120
|
+
<Button type="submit" variant="primary" size="sm" disabled={submitting}>
|
|
121
|
+
{submitting ? 'Creating…' : 'Create schedule'}
|
|
122
|
+
</Button>
|
|
123
|
+
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
|
124
|
+
Cancel
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
</form>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
34
131
|
function EvalInner({ setActiveTab }: Props) {
|
|
35
132
|
const toast = useToast();
|
|
36
133
|
const [runs, setRuns] = useState<EvalRunSummary[]>([]);
|
|
37
134
|
const [loading, setLoading] = useState(true);
|
|
135
|
+
const [subTab, setSubTab] = useState<'runs' | 'schedules'>('runs');
|
|
136
|
+
const [schedules, setSchedules] = useState<EvalSchedule[]>([]);
|
|
137
|
+
const [loadingSchedules, setLoadingSchedules] = useState(false);
|
|
138
|
+
const [showAddSchedule, setShowAddSchedule] = useState(false);
|
|
38
139
|
const cancelledRef = useRef(false);
|
|
39
140
|
|
|
40
141
|
const load = useCallback(async () => {
|
|
@@ -51,11 +152,36 @@ function EvalInner({ setActiveTab }: Props) {
|
|
|
51
152
|
}
|
|
52
153
|
}, [toast]);
|
|
53
154
|
|
|
155
|
+
const loadSchedules = useCallback(async () => {
|
|
156
|
+
setLoadingSchedules(true);
|
|
157
|
+
try {
|
|
158
|
+
const r = await api.get<{ schedules: EvalSchedule[] }>('/eval/schedules');
|
|
159
|
+
if (!cancelledRef.current) setSchedules(r.schedules || []);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (!cancelledRef.current) {
|
|
162
|
+
toast.error(`Failed to load eval schedules: ${(err as Error).message}`);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
if (!cancelledRef.current) setLoadingSchedules(false);
|
|
166
|
+
}
|
|
167
|
+
}, [toast]);
|
|
168
|
+
|
|
54
169
|
useEffect(() => {
|
|
55
170
|
cancelledRef.current = false;
|
|
56
171
|
load();
|
|
172
|
+
if (subTab === 'schedules') loadSchedules();
|
|
57
173
|
return () => { cancelledRef.current = true; };
|
|
58
|
-
}, [load]);
|
|
174
|
+
}, [load, loadSchedules, subTab]);
|
|
175
|
+
|
|
176
|
+
const deleteSchedule = async (id: string) => {
|
|
177
|
+
try {
|
|
178
|
+
await api.del(`/eval/schedules/${id}`);
|
|
179
|
+
setSchedules((prev) => prev.filter((s) => s.id !== id));
|
|
180
|
+
toast.success('Schedule deleted');
|
|
181
|
+
} catch (err) {
|
|
182
|
+
toast.error(`Failed to delete schedule: ${(err as Error).message}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
59
185
|
|
|
60
186
|
const recent = runs.slice(0, RECENT_RUNS_LIMIT);
|
|
61
187
|
const totals = runs.reduce(
|
|
@@ -139,30 +265,104 @@ function EvalInner({ setActiveTab }: Props) {
|
|
|
139
265
|
</div>
|
|
140
266
|
|
|
141
267
|
<div className="eval-recent-section">
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
268
|
+
<div className="eval-sub-tabs">
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className={cn('eval-sub-tab', subTab === 'runs' ? 'is-active' : '')}
|
|
272
|
+
onClick={() => setSubTab('runs')}
|
|
273
|
+
>
|
|
274
|
+
<ClipboardCheck size={12} aria-hidden /> Runs
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
className={cn('eval-sub-tab', subTab === 'schedules' ? 'is-active' : '')}
|
|
279
|
+
onClick={() => { setSubTab('schedules'); if (schedules.length === 0) loadSchedules(); }}
|
|
280
|
+
>
|
|
281
|
+
<Clock size={12} aria-hidden /> Schedules
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{subTab === 'runs' ? (
|
|
286
|
+
<>
|
|
287
|
+
<h2 className="eval-section-title">Recent runs</h2>
|
|
288
|
+
{loading && recent.length === 0 ? (
|
|
289
|
+
<div className="eval-runs-loading">
|
|
290
|
+
<Spinner size="sm" />
|
|
291
|
+
<span className="muted">Loading runs…</span>
|
|
292
|
+
</div>
|
|
293
|
+
) : recent.length === 0 ? (
|
|
294
|
+
<EmptyState
|
|
295
|
+
icon={<ClipboardCheck size={28} />}
|
|
296
|
+
title="No eval runs yet"
|
|
297
|
+
message={
|
|
298
|
+
<>
|
|
299
|
+
Run a suite via the CLI:{' '}
|
|
300
|
+
<code className="mono">bizar eval run ./path/to/fixtures</code>.
|
|
301
|
+
Results will appear here.
|
|
302
|
+
</>
|
|
303
|
+
}
|
|
304
|
+
/>
|
|
305
|
+
) : (
|
|
306
|
+
<div className="eval-runs-list">
|
|
307
|
+
{recent.map((r) => (
|
|
308
|
+
<EvalRunCard key={r.id} run={r} />
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</>
|
|
160
313
|
) : (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
314
|
+
<>
|
|
315
|
+
<h2 className="eval-section-title">Schedules</h2>
|
|
316
|
+
{loadingSchedules ? (
|
|
317
|
+
<div className="eval-runs-loading">
|
|
318
|
+
<Spinner size="sm" />
|
|
319
|
+
<span className="muted">Loading schedules…</span>
|
|
320
|
+
</div>
|
|
321
|
+
) : schedules.length === 0 ? (
|
|
322
|
+
<EmptyState
|
|
323
|
+
icon={<Clock size={28} />}
|
|
324
|
+
title="No eval schedules"
|
|
325
|
+
message="Schedules let you run eval suites automatically on a cron. Add one below."
|
|
326
|
+
/>
|
|
327
|
+
) : (
|
|
328
|
+
<div className="eval-schedules-list">
|
|
329
|
+
{schedules.map((s) => (
|
|
330
|
+
<Card key={s.id} className="eval-schedule-card">
|
|
331
|
+
<div className="eval-schedule-row">
|
|
332
|
+
<div className="eval-schedule-info">
|
|
333
|
+
<CardTitle>{s.name}</CardTitle>
|
|
334
|
+
<CardMeta className="muted">
|
|
335
|
+
{s.cron} · {s.suitePath} · agent:{s.agent || 'thor'}
|
|
336
|
+
</CardMeta>
|
|
337
|
+
</div>
|
|
338
|
+
<Button
|
|
339
|
+
variant="ghost"
|
|
340
|
+
size="sm"
|
|
341
|
+
onClick={() => deleteSchedule(s.id)}
|
|
342
|
+
title="Delete schedule"
|
|
343
|
+
>
|
|
344
|
+
<Trash2 size={12} aria-hidden />
|
|
345
|
+
</Button>
|
|
346
|
+
</div>
|
|
347
|
+
</Card>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
<div className="eval-schedule-add">
|
|
352
|
+
<AddScheduleForm
|
|
353
|
+
onAdded={(newSchedule) => {
|
|
354
|
+
setSchedules((prev) => [...prev, newSchedule]);
|
|
355
|
+
setShowAddSchedule(false);
|
|
356
|
+
}}
|
|
357
|
+
onCancel={() => setShowAddSchedule(false)}
|
|
358
|
+
/>
|
|
359
|
+
{!showAddSchedule && (
|
|
360
|
+
<Button variant="secondary" size="sm" onClick={() => setShowAddSchedule(true)}>
|
|
361
|
+
<Plus size={12} aria-hidden /> Add schedule
|
|
362
|
+
</Button>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
</>
|
|
166
366
|
)}
|
|
167
367
|
</div>
|
|
168
368
|
</div>
|