@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,78 @@
|
|
|
1
|
+
// src/components/EvalRunCard.tsx — v5.2.0
|
|
2
|
+
//
|
|
3
|
+
// Compact card summarizing a single eval run. Used in the EvalReport's
|
|
4
|
+
// left rail to show the recent run history. Click selects the run.
|
|
5
|
+
//
|
|
6
|
+
// Status rules (per brief):
|
|
7
|
+
// failed === 0 → pass
|
|
8
|
+
// passed/total > 0.8 → warn
|
|
9
|
+
// otherwise → fail
|
|
10
|
+
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
13
|
+
import { cn, formatTime } from '../lib/utils';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Summary of an eval run. The fields here are the subset returned by
|
|
17
|
+
* GET /api/eval/runs (the list endpoint) — `results` lives on the full
|
|
18
|
+
* run record returned by GET /api/eval/runs/:id and is not present here.
|
|
19
|
+
*/
|
|
20
|
+
export type EvalRunSummary = {
|
|
21
|
+
id: string;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
finishedAt?: string;
|
|
24
|
+
suitePath: string;
|
|
25
|
+
total: number;
|
|
26
|
+
passed: number;
|
|
27
|
+
failed: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type EvalRunCardProps = {
|
|
31
|
+
run: EvalRunSummary;
|
|
32
|
+
onClick?: () => void;
|
|
33
|
+
/** v5.2.0 — Marks the card as the currently selected run. */
|
|
34
|
+
selected?: boolean;
|
|
35
|
+
/** Optional aria-label override; falls back to the run id. */
|
|
36
|
+
ariaLabel?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function pickStatus(run: EvalRunSummary): 'pass' | 'warn' | 'fail' {
|
|
40
|
+
if (run.total <= 0) return 'fail';
|
|
41
|
+
if (run.failed === 0) return 'pass';
|
|
42
|
+
if (run.passed / run.total > 0.8) return 'warn';
|
|
43
|
+
return 'fail';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function EvalRunCard({ run, onClick, selected, ariaLabel }: EvalRunCardProps): ReactNode {
|
|
47
|
+
const status = pickStatus(run);
|
|
48
|
+
// Pass-rate as a fixed-percent string. toFixed guards against locale
|
|
49
|
+
// issues (e.g. some locales use "," as the decimal separator).
|
|
50
|
+
const passRate = run.total > 0
|
|
51
|
+
? ((run.passed / run.total) * 100).toFixed(1)
|
|
52
|
+
: '0.0';
|
|
53
|
+
const interactive = Boolean(onClick);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card
|
|
57
|
+
onClick={onClick}
|
|
58
|
+
interactive={interactive}
|
|
59
|
+
aria-label={ariaLabel ?? `Eval run ${run.id}`}
|
|
60
|
+
aria-pressed={interactive ? selected : undefined}
|
|
61
|
+
className={cn(
|
|
62
|
+
'eval-run-card',
|
|
63
|
+
selected && 'eval-run-card-selected',
|
|
64
|
+
`eval-run-${status}`,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div className="eval-run-head">
|
|
68
|
+
<CardTitle className="eval-run-title">{run.id}</CardTitle>
|
|
69
|
+
<span className={cn('eval-status', `is-${status}`)}>
|
|
70
|
+
{run.passed}/{run.total} ({passRate}%)
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
<CardMeta className="eval-run-meta">
|
|
74
|
+
{formatTime(run.startedAt)} · {run.suitePath}
|
|
75
|
+
</CardMeta>
|
|
76
|
+
</Card>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/web/components/PluginCard.tsx — card for one installed plugin with toggle, configure, and uninstall.
|
|
2
|
+
|
|
3
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { Toggle } from './Toggle';
|
|
6
|
+
import { PluginPermissions } from './PluginPermissions';
|
|
7
|
+
|
|
8
|
+
export type InstalledPlugin = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
permissions?: string[];
|
|
14
|
+
config?: Record<string, unknown>;
|
|
15
|
+
methodCount?: number;
|
|
16
|
+
invocations?: number;
|
|
17
|
+
lastInvokedAt?: string | null;
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PluginCardProps = {
|
|
22
|
+
plugin: InstalledPlugin;
|
|
23
|
+
onToggle: (id: string) => void;
|
|
24
|
+
onUninstall: (id: string) => void;
|
|
25
|
+
onConfigure: (id: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function PluginCard({ plugin, onToggle, onUninstall, onConfigure }: PluginCardProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Card>
|
|
31
|
+
<div className="plugin-card-head">
|
|
32
|
+
<div>
|
|
33
|
+
<CardTitle>{plugin.name}</CardTitle>
|
|
34
|
+
<CardMeta>
|
|
35
|
+
v{plugin.version} · {plugin.id}
|
|
36
|
+
</CardMeta>
|
|
37
|
+
</div>
|
|
38
|
+
<Toggle
|
|
39
|
+
checked={plugin.enabled ?? true}
|
|
40
|
+
onChange={() => onToggle(plugin.id)}
|
|
41
|
+
aria-label={`Toggle ${plugin.name} enabled`}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
{plugin.description && (
|
|
45
|
+
<p className="plugin-description">{plugin.description}</p>
|
|
46
|
+
)}
|
|
47
|
+
<PluginPermissions permissions={plugin.permissions ?? []} />
|
|
48
|
+
<div className="plugin-card-actions">
|
|
49
|
+
<Button size="sm" onClick={() => onConfigure(plugin.id)} aria-label={`Configure ${plugin.name}`}>
|
|
50
|
+
Configure
|
|
51
|
+
</Button>
|
|
52
|
+
<Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)} aria-label={`Uninstall ${plugin.name}`}>
|
|
53
|
+
Uninstall
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="plugin-card-stats">
|
|
57
|
+
{plugin.invocations ?? 0} invocations
|
|
58
|
+
{plugin.lastInvokedAt
|
|
59
|
+
? ` · last used ${plugin.lastInvokedAt}`
|
|
60
|
+
: ' · never used'}
|
|
61
|
+
</div>
|
|
62
|
+
</Card>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/web/components/PluginPermissions.tsx — renders permission chips for a plugin.
|
|
2
|
+
|
|
3
|
+
import { Globe, FileText, Settings, Terminal, Shield, type LucideIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const PERMISSION_LABELS: Record<string, { label: string; icon: LucideIcon; color: string; description: string }> = {
|
|
6
|
+
net: { label: 'Network access', icon: Globe, color: 'blue', description: 'Can make HTTP requests to external services' },
|
|
7
|
+
fs: { label: 'Filesystem', icon: FileText, color: 'yellow', description: 'Can read and write files' },
|
|
8
|
+
config: { label: 'Config access', icon: Settings, color: 'gray', description: 'Can read and write Bizar config' },
|
|
9
|
+
log: { label: 'Logging', icon: FileText, color: 'gray', description: 'Can write to the structured log' },
|
|
10
|
+
exec: { label: 'Shell execution', icon: Terminal, color: 'red', description: 'Can run shell commands' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PluginPermissionsProps = {
|
|
14
|
+
permissions: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function PluginPermissions({ permissions }: PluginPermissionsProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="plugin-permissions" aria-label="Required permissions">
|
|
20
|
+
{permissions.map((perm) => {
|
|
21
|
+
const info = PERMISSION_LABELS[perm] ?? { label: perm, icon: Shield, color: 'gray', description: '' };
|
|
22
|
+
const Icon = info.icon;
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
key={perm}
|
|
26
|
+
className={`permission-chip is-${info.color}`}
|
|
27
|
+
title={info.description}
|
|
28
|
+
>
|
|
29
|
+
<Icon size={12} aria-hidden />
|
|
30
|
+
<span>{info.label}</span>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/web/components/TailscaleSettings.tsx
|
|
2
|
+
//
|
|
3
|
+
// v5.2 — Tailscale auth key integration UI.
|
|
4
|
+
//
|
|
5
|
+
// Allows users to authenticate with Tailscale using an auth key and
|
|
6
|
+
// set up tailscale serve for the dashboard without using the CLI.
|
|
7
|
+
import React, { useEffect, useState } from 'react';
|
|
8
|
+
import { CheckCircle, Link as LinkIcon, Plug } from 'lucide-react';
|
|
9
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
10
|
+
import { Button } from './Button';
|
|
11
|
+
import { api } from '../lib/api';
|
|
12
|
+
import type { TailscaleStatus } from '../lib/types';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
/** Existing tailscale status from Settings-level fetch. If provided, used as initial value. */
|
|
16
|
+
initialStatus?: TailscaleStatus | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function TailscaleSettings({ initialStatus }: Props) {
|
|
20
|
+
const [status, setStatus] = useState<TailscaleStatus | null>(initialStatus ?? null);
|
|
21
|
+
const [authKey, setAuthKey] = useState('');
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (status === null) {
|
|
27
|
+
api.get<TailscaleStatus>('/tailscale/status')
|
|
28
|
+
.then((r) => setStatus(r))
|
|
29
|
+
.catch(() => setStatus(null));
|
|
30
|
+
}
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const loadStatus = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const r = await api.get<TailscaleStatus>('/tailscale/status');
|
|
36
|
+
setStatus(r);
|
|
37
|
+
} catch {
|
|
38
|
+
setError('Failed to load Tailscale status');
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleAuthenticate = async () => {
|
|
43
|
+
if (!authKey.trim()) return;
|
|
44
|
+
setLoading(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
try {
|
|
47
|
+
await api.post('/tailscale/setup', { authKey: authKey.trim() });
|
|
48
|
+
await loadStatus();
|
|
49
|
+
setAuthKey('');
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError((err as Error).message || 'Authentication failed');
|
|
52
|
+
} finally {
|
|
53
|
+
setLoading(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleSetupServe = async () => {
|
|
58
|
+
if (!status) return;
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
try {
|
|
62
|
+
await api.post('/tailscale/setup', {
|
|
63
|
+
port: status.settings?.port || 4321,
|
|
64
|
+
https: status.settings?.https !== false,
|
|
65
|
+
hostname: status.settings?.hostname || '',
|
|
66
|
+
});
|
|
67
|
+
await loadStatus();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
setError((err as Error).message || 'Setup failed');
|
|
70
|
+
} finally {
|
|
71
|
+
setLoading(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handleRemoveServe = async () => {
|
|
76
|
+
setLoading(true);
|
|
77
|
+
setError(null);
|
|
78
|
+
try {
|
|
79
|
+
await api.post('/tailscale/unserve');
|
|
80
|
+
await loadStatus();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError((err as Error).message || 'Remove failed');
|
|
83
|
+
} finally {
|
|
84
|
+
setLoading(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isInstalled = status?.installed ?? false;
|
|
89
|
+
const isAuthenticated = status?.authenticated ?? false;
|
|
90
|
+
const isServeEnabled = status?.settings?.enabled ?? false;
|
|
91
|
+
const serveUrl = isServeEnabled ? `https://${status?.hostname || 'bizar-dash'}` : null;
|
|
92
|
+
|
|
93
|
+
if (!isInstalled) {
|
|
94
|
+
return (
|
|
95
|
+
<Card id="settings-tailscale-auth" data-section="tailscale">
|
|
96
|
+
<CardTitle><Plug size={14} /> Tailscale Integration</CardTitle>
|
|
97
|
+
<CardMeta>Tailscale is not installed on this machine.</CardMeta>
|
|
98
|
+
<p className="muted">
|
|
99
|
+
Install from{' '}
|
|
100
|
+
<a href="https://tailscale.com/download" target="_blank" rel="noreferrer">
|
|
101
|
+
tailscale.com/download
|
|
102
|
+
</a>
|
|
103
|
+
</p>
|
|
104
|
+
</Card>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Card id="settings-tailscale-auth" data-section="tailscale">
|
|
110
|
+
<CardTitle><Plug size={14} /> Tailscale Integration</CardTitle>
|
|
111
|
+
<CardMeta>Expose the dashboard over your Tailscale network using an auth key.</CardMeta>
|
|
112
|
+
|
|
113
|
+
{error && (
|
|
114
|
+
<p style={{ color: 'var(--color-error, #f85149)', fontSize: '0.85rem' }}>{error}</p>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{isAuthenticated ? (
|
|
118
|
+
<>
|
|
119
|
+
<div className="status-row" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
|
120
|
+
<CheckCircle size={16} style={{ color: 'var(--color-success, #3fb950)' }} />
|
|
121
|
+
<span>Authenticated as <strong>{status?.hostname || 'unknown'}</strong></span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{isServeEnabled && serveUrl ? (
|
|
125
|
+
<>
|
|
126
|
+
<div className="status-row" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
|
127
|
+
<LinkIcon size={16} />
|
|
128
|
+
<a href={serveUrl} target="_blank" rel="noreferrer">{serveUrl}</a>
|
|
129
|
+
</div>
|
|
130
|
+
<Button variant="secondary" size="sm" onClick={handleRemoveServe} disabled={loading}>
|
|
131
|
+
Remove serve
|
|
132
|
+
</Button>
|
|
133
|
+
</>
|
|
134
|
+
) : (
|
|
135
|
+
<Button variant="primary" size="sm" onClick={handleSetupServe} disabled={loading}>
|
|
136
|
+
Set up Tailscale serve
|
|
137
|
+
</Button>
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
) : (
|
|
141
|
+
<>
|
|
142
|
+
<p className="muted" style={{ marginBottom: '0.75rem' }}>Not authenticated with Tailscale.</p>
|
|
143
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
144
|
+
<input
|
|
145
|
+
type="text"
|
|
146
|
+
className="input"
|
|
147
|
+
placeholder="tskey-..."
|
|
148
|
+
value={authKey}
|
|
149
|
+
onChange={(e) => setAuthKey(e.target.value)}
|
|
150
|
+
style={{ flex: 1, minWidth: '200px' }}
|
|
151
|
+
disabled={loading}
|
|
152
|
+
/>
|
|
153
|
+
<Button variant="primary" size="sm" onClick={handleAuthenticate} disabled={loading || !authKey.trim()}>
|
|
154
|
+
Authenticate
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
</Card>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/components/Toggle.tsx — toggle switch control.
|
|
2
|
+
|
|
3
|
+
import { type InputHTMLAttributes, forwardRef } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export type ToggleProps = InputHTMLAttributes<HTMLInputElement> & {
|
|
7
|
+
/** Called with the new checked value on change. */
|
|
8
|
+
onChange?: (checked: boolean) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const Toggle = forwardRef<HTMLInputElement, ToggleProps>(
|
|
12
|
+
function Toggle({ className, checked, onChange, onClick, ...rest }, ref) {
|
|
13
|
+
return (
|
|
14
|
+
<label className={cn('toggle', className)}>
|
|
15
|
+
<input
|
|
16
|
+
ref={ref}
|
|
17
|
+
type="checkbox"
|
|
18
|
+
role="switch"
|
|
19
|
+
aria-checked={checked}
|
|
20
|
+
checked={checked}
|
|
21
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
22
|
+
onClick={(e) => {
|
|
23
|
+
onClick?.(e as unknown as React.MouseEvent<HTMLInputElement>);
|
|
24
|
+
}}
|
|
25
|
+
{...rest}
|
|
26
|
+
/>
|
|
27
|
+
<span className="toggle-slider" aria-hidden />
|
|
28
|
+
</label>
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
Coins,
|
|
25
25
|
Brain,
|
|
26
26
|
Stethoscope,
|
|
27
|
+
ClipboardCheck,
|
|
28
|
+
Store,
|
|
27
29
|
type LucideIcon,
|
|
28
30
|
} from 'lucide-react';
|
|
29
31
|
import { cn } from '../lib/utils';
|
|
@@ -50,9 +52,16 @@ export const TABS: TabDef[] = [
|
|
|
50
52
|
{ id: 'skills', label: 'Skills', icon: Sparkles },
|
|
51
53
|
{ id: 'memory', label: 'Memory', icon: Brain },
|
|
52
54
|
{ id: 'mods', label: 'Mods', icon: Puzzle },
|
|
55
|
+
// v5.3.0 — Plugin marketplace browse + install UI.
|
|
56
|
+
{ id: 'marketplace', label: 'Marketplace', icon: Store },
|
|
53
57
|
{ id: 'schedules', label: 'Schedules', icon: Clock },
|
|
58
|
+
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
|
|
54
59
|
{ id: 'history', label: 'History', icon: HistoryIcon },
|
|
55
60
|
{ id: 'minimax', label: 'Usage', icon: Coins },
|
|
61
|
+
// v5.2.0 — Eval framework UI. Lives next to Doctor (both are
|
|
62
|
+
// operator-quality surfaces) so the "did the last eval pass?"
|
|
63
|
+
// question is one click from the home screen.
|
|
64
|
+
{ id: 'eval', label: 'Eval', icon: ClipboardCheck },
|
|
56
65
|
// v6.0.0 — Doctor page. Lives between Overview and Settings so the
|
|
57
66
|
// "is everything healthy?" question is always one click from the
|
|
58
67
|
// home screen and from the settings surface (where an operator
|
|
@@ -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) => {
|