@auxiora/dashboard 1.0.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 (97) hide show
  1. package/LICENSE +191 -0
  2. package/dist/auth.d.ts +13 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/cloud-types.d.ts +71 -0
  7. package/dist/cloud-types.d.ts.map +1 -0
  8. package/dist/cloud-types.js +2 -0
  9. package/dist/cloud-types.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/router.d.ts +13 -0
  15. package/dist/router.d.ts.map +1 -0
  16. package/dist/router.js +2250 -0
  17. package/dist/router.js.map +1 -0
  18. package/dist/types.d.ts +314 -0
  19. package/dist/types.d.ts.map +1 -0
  20. package/dist/types.js +7 -0
  21. package/dist/types.js.map +1 -0
  22. package/dist-ui/assets/index-BfY0i5jw.css +1 -0
  23. package/dist-ui/assets/index-CXpk9mvw.js +60 -0
  24. package/dist-ui/icon.svg +59 -0
  25. package/dist-ui/index.html +20 -0
  26. package/package.json +32 -0
  27. package/src/auth.ts +83 -0
  28. package/src/cloud-types.ts +63 -0
  29. package/src/index.ts +5 -0
  30. package/src/router.ts +2494 -0
  31. package/src/types.ts +269 -0
  32. package/tests/auth.test.ts +51 -0
  33. package/tests/cloud-router.test.ts +249 -0
  34. package/tests/desktop-router.test.ts +151 -0
  35. package/tests/router.test.ts +388 -0
  36. package/tests/trust-router.test.ts +170 -0
  37. package/tsconfig.json +12 -0
  38. package/tsconfig.tsbuildinfo +1 -0
  39. package/ui/index.html +19 -0
  40. package/ui/node_modules/.bin/browserslist +17 -0
  41. package/ui/node_modules/.bin/tsc +17 -0
  42. package/ui/node_modules/.bin/tsserver +17 -0
  43. package/ui/node_modules/.bin/vite +17 -0
  44. package/ui/package.json +23 -0
  45. package/ui/public/icon.svg +59 -0
  46. package/ui/src/App.tsx +63 -0
  47. package/ui/src/api.ts +238 -0
  48. package/ui/src/components/ActivityFeed.tsx +123 -0
  49. package/ui/src/components/BehaviorHealth.tsx +105 -0
  50. package/ui/src/components/DataTable.tsx +39 -0
  51. package/ui/src/components/Layout.tsx +160 -0
  52. package/ui/src/components/PasswordStrength.tsx +31 -0
  53. package/ui/src/components/SetupProgress.tsx +26 -0
  54. package/ui/src/components/StatusBadge.tsx +12 -0
  55. package/ui/src/components/ThemeSelector.tsx +39 -0
  56. package/ui/src/contexts/ThemeContext.tsx +58 -0
  57. package/ui/src/hooks/useApi.ts +19 -0
  58. package/ui/src/hooks/usePolling.ts +8 -0
  59. package/ui/src/main.tsx +16 -0
  60. package/ui/src/pages/AuditLog.tsx +36 -0
  61. package/ui/src/pages/Behaviors.tsx +426 -0
  62. package/ui/src/pages/Chat.tsx +688 -0
  63. package/ui/src/pages/Login.tsx +64 -0
  64. package/ui/src/pages/Overview.tsx +56 -0
  65. package/ui/src/pages/Sessions.tsx +26 -0
  66. package/ui/src/pages/SettingsAmbient.tsx +185 -0
  67. package/ui/src/pages/SettingsConnections.tsx +201 -0
  68. package/ui/src/pages/SettingsNotifications.tsx +241 -0
  69. package/ui/src/pages/SetupAppearance.tsx +45 -0
  70. package/ui/src/pages/SetupChannels.tsx +143 -0
  71. package/ui/src/pages/SetupComplete.tsx +31 -0
  72. package/ui/src/pages/SetupConnections.tsx +80 -0
  73. package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
  74. package/ui/src/pages/SetupIdentity.tsx +68 -0
  75. package/ui/src/pages/SetupPersonality.tsx +78 -0
  76. package/ui/src/pages/SetupProvider.tsx +65 -0
  77. package/ui/src/pages/SetupVault.tsx +50 -0
  78. package/ui/src/pages/SetupWelcome.tsx +19 -0
  79. package/ui/src/pages/UnlockVault.tsx +56 -0
  80. package/ui/src/pages/Webhooks.tsx +158 -0
  81. package/ui/src/pages/settings/Appearance.tsx +63 -0
  82. package/ui/src/pages/settings/Channels.tsx +138 -0
  83. package/ui/src/pages/settings/Identity.tsx +61 -0
  84. package/ui/src/pages/settings/Personality.tsx +54 -0
  85. package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
  86. package/ui/src/pages/settings/Provider.tsx +537 -0
  87. package/ui/src/pages/settings/Security.tsx +111 -0
  88. package/ui/src/styles/global.css +2308 -0
  89. package/ui/src/styles/themes/index.css +7 -0
  90. package/ui/src/styles/themes/monolith.css +125 -0
  91. package/ui/src/styles/themes/nebula.css +90 -0
  92. package/ui/src/styles/themes/neon.css +149 -0
  93. package/ui/src/styles/themes/polar.css +151 -0
  94. package/ui/src/styles/themes/signal.css +163 -0
  95. package/ui/src/styles/themes/terra.css +146 -0
  96. package/ui/tsconfig.json +14 -0
  97. package/ui/vite.config.ts +20 -0
@@ -0,0 +1,31 @@
1
+ interface PasswordStrengthProps {
2
+ password: string;
3
+ }
4
+
5
+ function getStrength(password: string): { label: string; color: string; width: string } {
6
+ if (password.length === 0) return { label: '', color: 'transparent', width: '0%' };
7
+ if (password.length < 8) return { label: 'Too short', color: 'var(--danger)', width: '25%' };
8
+
9
+ const hasUpper = /[A-Z]/.test(password);
10
+ const hasLower = /[a-z]/.test(password);
11
+ const hasDigit = /[0-9]/.test(password);
12
+ const hasSpecial = /[^A-Za-z0-9]/.test(password);
13
+ const variety = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length;
14
+
15
+ if (password.length >= 12 && variety >= 3) return { label: 'Strong', color: 'var(--success)', width: '100%' };
16
+ return { label: 'Fair', color: 'var(--warning)', width: '60%' };
17
+ }
18
+
19
+ export function PasswordStrength({ password }: PasswordStrengthProps) {
20
+ const strength = getStrength(password);
21
+ if (!password) return null;
22
+
23
+ return (
24
+ <>
25
+ <div className="password-strength">
26
+ <div className="password-strength-bar" style={{ width: strength.width, background: strength.color }} />
27
+ </div>
28
+ <div className="password-strength-label" style={{ color: strength.color }}>{strength.label}</div>
29
+ </>
30
+ );
31
+ }
@@ -0,0 +1,26 @@
1
+ interface SetupProgressProps {
2
+ currentStep: number;
3
+ totalSteps?: number;
4
+ }
5
+
6
+ export function SetupProgress({ currentStep, totalSteps = 8 }: SetupProgressProps) {
7
+ return (
8
+ <div className="setup-progress">
9
+ {Array.from({ length: totalSteps }, (_, i) => {
10
+ const step = i + 1;
11
+ const isCompleted = step < currentStep;
12
+ const isActive = step === currentStep;
13
+ return (
14
+ <div key={step} style={{ display: 'flex', alignItems: 'center' }}>
15
+ {i > 0 && (
16
+ <div className={`setup-progress-line${isCompleted || isActive ? ' completed' : ''}`} />
17
+ )}
18
+ <div className={`setup-progress-step${isActive ? ' active' : ''}${isCompleted ? ' completed' : ''}`}>
19
+ {isCompleted ? '\u2713' : step}
20
+ </div>
21
+ </div>
22
+ );
23
+ })}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,12 @@
1
+ const STATUS_COLORS: Record<string, string> = {
2
+ active: 'badge-green',
3
+ paused: 'badge-yellow',
4
+ deleted: 'badge-red',
5
+ enabled: 'badge-green',
6
+ disabled: 'badge-gray',
7
+ };
8
+
9
+ export function StatusBadge({ status }: { status: string }) {
10
+ const className = STATUS_COLORS[status] || 'badge-gray';
11
+ return <span className={`badge ${className}`}>{status}</span>;
12
+ }
@@ -0,0 +1,39 @@
1
+ import { THEMES, useTheme, type ThemeId } from '../contexts/ThemeContext';
2
+
3
+ interface ThemeSelectorProps {
4
+ selected?: ThemeId;
5
+ onSelect?: (id: ThemeId) => void;
6
+ }
7
+
8
+ export function ThemeSelector({ selected, onSelect }: ThemeSelectorProps) {
9
+ const { theme: currentTheme, setTheme } = useTheme();
10
+ const activeTheme = selected ?? currentTheme;
11
+
12
+ const handleSelect = (id: ThemeId) => {
13
+ setTheme(id);
14
+ onSelect?.(id);
15
+ };
16
+
17
+ return (
18
+ <div className="theme-grid">
19
+ {THEMES.map((t) => (
20
+ <div
21
+ key={t.id}
22
+ className={`theme-card${activeTheme === t.id ? ' selected' : ''}`}
23
+ onClick={() => handleSelect(t.id)}
24
+ >
25
+ <div className="theme-swatches">
26
+ {t.colors.map((c, i) => (
27
+ <div key={i} className="theme-swatch" style={{ background: c }} />
28
+ ))}
29
+ </div>
30
+ <div className="theme-info">
31
+ <h3>{t.name}</h3>
32
+ <span className={`theme-mode-badge ${t.mode}`}>{t.mode}</span>
33
+ </div>
34
+ <p className="theme-description">{t.description}</p>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,58 @@
1
+ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
2
+
3
+ export const THEME_IDS = ['nebula', 'monolith', 'signal', 'polar', 'neon', 'terra'] as const;
4
+ export type ThemeId = (typeof THEME_IDS)[number];
5
+
6
+ export interface ThemeMeta {
7
+ id: ThemeId;
8
+ name: string;
9
+ description: string;
10
+ mode: 'dark' | 'light';
11
+ colors: [string, string, string, string];
12
+ }
13
+
14
+ export const THEMES: ThemeMeta[] = [
15
+ { id: 'nebula', name: 'Nebula', description: 'Glassmorphism command center', mode: 'dark', colors: ['#080b16', '#8b5cf6', '#10b981', '#f0f0f5'] },
16
+ { id: 'monolith', name: 'Monolith', description: 'Cinematic ultra-minimal', mode: 'dark', colors: ['#000000', '#7c3aed', '#ffffff', '#404040'] },
17
+ { id: 'signal', name: 'Signal', description: 'Warm sci-fi terminal', mode: 'dark', colors: ['#0c0c0c', '#f59e0b', '#22c55e', '#e8e4dd'] },
18
+ { id: 'polar', name: 'Polar', description: 'Premium light mode', mode: 'light', colors: ['#ffffff', '#3b82f6', '#059669', '#111827'] },
19
+ { id: 'neon', name: 'Neon', description: 'Cyberpunk vivid', mode: 'dark', colors: ['#09090b', '#06b6d4', '#ec4899', '#22c55e'] },
20
+ { id: 'terra', name: 'Terra', description: 'Warm organic dark', mode: 'dark', colors: ['#1a1612', '#c97b5c', '#8faa7b', '#e8e0d4'] },
21
+ ];
22
+
23
+ interface ThemeContextValue {
24
+ theme: ThemeId;
25
+ setTheme: (id: ThemeId) => void;
26
+ }
27
+
28
+ const ThemeContext = createContext<ThemeContextValue>({
29
+ theme: 'nebula',
30
+ setTheme: () => {},
31
+ });
32
+
33
+ export function ThemeProvider({ children }: { children: ReactNode }) {
34
+ const [theme, setThemeState] = useState<ThemeId>(() => {
35
+ const stored = localStorage.getItem('auxiora-theme') as ThemeId | null;
36
+ return stored && THEME_IDS.includes(stored) ? stored : 'nebula';
37
+ });
38
+
39
+ const setTheme = useCallback((id: ThemeId) => {
40
+ setThemeState(id);
41
+ localStorage.setItem('auxiora-theme', id);
42
+ document.documentElement.setAttribute('data-theme', id);
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ document.documentElement.setAttribute('data-theme', theme);
47
+ }, []);
48
+
49
+ return (
50
+ <ThemeContext.Provider value={{ theme, setTheme }}>
51
+ {children}
52
+ </ThemeContext.Provider>
53
+ );
54
+ }
55
+
56
+ export function useTheme() {
57
+ return useContext(ThemeContext);
58
+ }
@@ -0,0 +1,19 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ export function useApi<T>(fetcher: () => Promise<T>, deps: unknown[] = []) {
4
+ const [data, setData] = useState<T | null>(null);
5
+ const [loading, setLoading] = useState(true);
6
+ const [error, setError] = useState<string | null>(null);
7
+
8
+ const refresh = useCallback(() => {
9
+ setLoading(true);
10
+ fetcher()
11
+ .then((result) => { setData(result); setError(null); })
12
+ .catch((err) => setError(err.message))
13
+ .finally(() => setLoading(false));
14
+ }, deps);
15
+
16
+ useEffect(() => { refresh(); }, [refresh]);
17
+
18
+ return { data, loading, error, refresh };
19
+ }
@@ -0,0 +1,8 @@
1
+ import { useEffect } from 'react';
2
+
3
+ export function usePolling(callback: () => void, intervalMs = 10_000) {
4
+ useEffect(() => {
5
+ const id = setInterval(callback, intervalMs);
6
+ return () => clearInterval(id);
7
+ }, [callback, intervalMs]);
8
+ }
@@ -0,0 +1,16 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import { ThemeProvider } from './contexts/ThemeContext';
5
+ import { App } from './App';
6
+ import './styles/global.css';
7
+
8
+ createRoot(document.getElementById('root')!).render(
9
+ <StrictMode>
10
+ <BrowserRouter basename="/dashboard">
11
+ <ThemeProvider>
12
+ <App />
13
+ </ThemeProvider>
14
+ </BrowserRouter>
15
+ </StrictMode>
16
+ );
@@ -0,0 +1,36 @@
1
+ import { useState } from 'react';
2
+ import { useApi } from '../hooks/useApi';
3
+ import { usePolling } from '../hooks/usePolling';
4
+ import { api } from '../api';
5
+ import { DataTable } from '../components/DataTable';
6
+
7
+ const EVENT_FILTERS = ['', 'behavior.', 'webhook.', 'voice.', 'system.', 'auth.', 'dashboard.'];
8
+
9
+ export function AuditLog() {
10
+ const [typeFilter, setTypeFilter] = useState('');
11
+ const { data, refresh } = useApi(() => api.getAudit({ type: typeFilter || undefined, limit: 200 }), [typeFilter]);
12
+ usePolling(refresh);
13
+
14
+ const entries = data?.data ?? [];
15
+
16
+ const columns = [
17
+ { key: 'timestamp', label: 'Time', render: (e: any) => new Date(e.timestamp).toLocaleString() },
18
+ { key: 'event', label: 'Event' },
19
+ { key: 'details', label: 'Details', render: (e: any) => JSON.stringify(e.details).slice(0, 80) },
20
+ ];
21
+
22
+ return (
23
+ <div className="page">
24
+ <h2>Audit Log</h2>
25
+ <div className="filters">
26
+ <select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
27
+ <option value="">All events</option>
28
+ {EVENT_FILTERS.filter(Boolean).map((f) => (
29
+ <option key={f} value={f}>{f}*</option>
30
+ ))}
31
+ </select>
32
+ </div>
33
+ <DataTable columns={columns} rows={entries} keyField="sequence" />
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,426 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { useApi } from '../hooks/useApi';
3
+ import { usePolling } from '../hooks/usePolling';
4
+ import { api } from '../api';
5
+ import { DataTable } from '../components/DataTable';
6
+ import { StatusBadge } from '../components/StatusBadge';
7
+
8
+ type BehaviorType = 'scheduled' | 'monitor' | 'one-shot';
9
+ type Frequency = 'daily' | 'weekday' | 'hourly' | 'every-n-hours' | 'weekly' | 'custom';
10
+
11
+ const DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
12
+
13
+ const LOCAL_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
14
+
15
+ /** Build a cron expression from the friendly UI inputs. */
16
+ function buildCron(freq: Frequency, time: string, everyNHours: number, weekday: number, customCron: string): string {
17
+ if (freq === 'custom') return customCron;
18
+ if (freq === 'hourly') return '0 * * * *';
19
+ if (freq === 'every-n-hours') return `0 */${everyNHours} * * *`;
20
+
21
+ const [hh, mm] = time.split(':').map(Number);
22
+ const m = isNaN(mm) ? 0 : mm;
23
+ const h = isNaN(hh) ? 8 : hh;
24
+
25
+ if (freq === 'daily') return `${m} ${h} * * *`;
26
+ if (freq === 'weekday') return `${m} ${h} * * 1-5`;
27
+ if (freq === 'weekly') return `${m} ${h} * * ${weekday}`;
28
+ return customCron;
29
+ }
30
+
31
+ /** Try to parse a cron expression back into friendly UI values. */
32
+ function parseCron(cron: string): { freq: Frequency; time: string; everyNHours: number; weekday: number } {
33
+ const parts = cron.trim().split(/\s+/);
34
+ if (parts.length !== 5) return { freq: 'custom', time: '08:00', everyNHours: 2, weekday: 1 };
35
+
36
+ const [min, hour, dom, mon, dow] = parts;
37
+
38
+ // Hourly: 0 * * * *
39
+ if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
40
+ return { freq: 'hourly', time: '08:00', everyNHours: 2, weekday: 1 };
41
+ }
42
+
43
+ // Every N hours: 0 */N * * *
44
+ const everyMatch = hour.match(/^\*\/(\d+)$/);
45
+ if (min === '0' && everyMatch && dom === '*' && mon === '*' && dow === '*') {
46
+ return { freq: 'every-n-hours', time: '08:00', everyNHours: parseInt(everyMatch[1], 10), weekday: 1 };
47
+ }
48
+
49
+ const m = parseInt(min, 10);
50
+ const h = parseInt(hour, 10);
51
+ if (isNaN(m) || isNaN(h)) return { freq: 'custom', time: '08:00', everyNHours: 2, weekday: 1 };
52
+
53
+ const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
54
+
55
+ // Daily: M H * * *
56
+ if (dom === '*' && mon === '*' && dow === '*') {
57
+ return { freq: 'daily', time: timeStr, everyNHours: 2, weekday: 1 };
58
+ }
59
+
60
+ // Weekday: M H * * 1-5
61
+ if (dom === '*' && mon === '*' && dow === '1-5') {
62
+ return { freq: 'weekday', time: timeStr, everyNHours: 2, weekday: 1 };
63
+ }
64
+
65
+ // Weekly: M H * * N
66
+ const dayNum = parseInt(dow, 10);
67
+ if (dom === '*' && mon === '*' && !isNaN(dayNum) && dayNum >= 0 && dayNum <= 6) {
68
+ return { freq: 'weekly', time: timeStr, everyNHours: 2, weekday: dayNum };
69
+ }
70
+
71
+ return { freq: 'custom', time: timeStr, everyNHours: 2, weekday: 1 };
72
+ }
73
+
74
+ /** Format a cron expression into a human-readable string for display. */
75
+ function describeCron(cron: string): string {
76
+ const parsed = parseCron(cron);
77
+ switch (parsed.freq) {
78
+ case 'daily': return `Daily at ${parsed.time}`;
79
+ case 'weekday': return `Weekdays at ${parsed.time}`;
80
+ case 'hourly': return 'Every hour';
81
+ case 'every-n-hours': return `Every ${parsed.everyNHours}h`;
82
+ case 'weekly': return `${DAYS_OF_WEEK[parsed.weekday]}s at ${parsed.time}`;
83
+ default: return cron;
84
+ }
85
+ }
86
+
87
+ export function Behaviors() {
88
+ const { data, refresh } = useApi(() => api.getBehaviors(), []);
89
+ usePolling(refresh);
90
+
91
+ const [formMode, setFormMode] = useState<'closed' | 'create' | 'edit'>('closed');
92
+ const [editingId, setEditingId] = useState<string | null>(null);
93
+ const [type, setType] = useState<BehaviorType>('scheduled');
94
+ const [action, setAction] = useState('');
95
+
96
+ // Friendly schedule state
97
+ const [frequency, setFrequency] = useState<Frequency>('daily');
98
+ const [scheduleTime, setScheduleTime] = useState('08:00');
99
+ const [everyNHours, setEveryNHours] = useState(2);
100
+ const [weekday, setWeekday] = useState(1); // Monday
101
+ const [customCron, setCustomCron] = useState('');
102
+ const [timezone, setTimezone] = useState(LOCAL_TIMEZONE);
103
+
104
+ // Monitor state
105
+ const [intervalMinutes, setIntervalMinutes] = useState(5);
106
+ const [condition, setCondition] = useState('');
107
+
108
+ // One-shot state
109
+ const [runAt, setRunAt] = useState('');
110
+
111
+ const [saving, setSaving] = useState(false);
112
+ const [error, setError] = useState('');
113
+ const [success, setSuccess] = useState('');
114
+
115
+ const behaviors = data?.data ?? [];
116
+
117
+ const cronPreview = useMemo(
118
+ () => buildCron(frequency, scheduleTime, everyNHours, weekday, customCron),
119
+ [frequency, scheduleTime, everyNHours, weekday, customCron],
120
+ );
121
+
122
+ const columns = [
123
+ { key: 'action', label: 'Action', render: (b: any) => b.action?.slice(0, 60) },
124
+ { key: 'type', label: 'Type' },
125
+ {
126
+ key: 'schedule', label: 'Schedule', render: (b: any) => {
127
+ if (b.schedule?.cron) return describeCron(b.schedule.cron);
128
+ if (b.polling) return `Every ${Math.round(b.polling.intervalMs / 60000)}m`;
129
+ if (b.delay?.fireAt) return new Date(b.delay.fireAt).toLocaleString();
130
+ return '-';
131
+ },
132
+ },
133
+ { key: 'status', label: 'Status', render: (b: any) => <StatusBadge status={b.status} /> },
134
+ { key: 'runCount', label: 'Runs' },
135
+ { key: 'failCount', label: 'Fails' },
136
+ { key: 'lastRun', label: 'Last Run', render: (b: any) => b.lastRun ? new Date(b.lastRun).toLocaleString() : '-' },
137
+ ];
138
+
139
+ const handleToggle = async (b: any) => {
140
+ try {
141
+ const newStatus = b.status === 'active' ? 'paused' : 'active';
142
+ await api.patchBehavior(b.id, { status: newStatus });
143
+ refresh();
144
+ } catch (err: any) {
145
+ alert(err.message || 'Failed to update behavior');
146
+ }
147
+ };
148
+
149
+ const handleDelete = async (b: any) => {
150
+ if (!confirm(`Delete behavior "${b.action?.slice(0, 40)}"?`)) return;
151
+ try {
152
+ await api.deleteBehavior(b.id);
153
+ refresh();
154
+ } catch (err: any) {
155
+ alert(err.message || 'Failed to delete behavior');
156
+ }
157
+ };
158
+
159
+ const resetForm = () => {
160
+ setEditingId(null);
161
+ setType('scheduled');
162
+ setAction('');
163
+ setFrequency('daily');
164
+ setScheduleTime('08:00');
165
+ setEveryNHours(2);
166
+ setWeekday(1);
167
+ setCustomCron('');
168
+ setTimezone(LOCAL_TIMEZONE);
169
+ setIntervalMinutes(5);
170
+ setCondition('');
171
+ setRunAt('');
172
+ setError('');
173
+ setSuccess('');
174
+ };
175
+
176
+ const openCreate = () => {
177
+ resetForm();
178
+ setFormMode('create');
179
+ };
180
+
181
+ const openEdit = (b: any) => {
182
+ setEditingId(b.id);
183
+ setType(b.type ?? 'scheduled');
184
+ setAction(b.action ?? '');
185
+
186
+ if (b.schedule?.cron) {
187
+ const parsed = parseCron(b.schedule.cron);
188
+ setFrequency(parsed.freq);
189
+ setScheduleTime(parsed.time);
190
+ setEveryNHours(parsed.everyNHours);
191
+ setWeekday(parsed.weekday);
192
+ setCustomCron(parsed.freq === 'custom' ? b.schedule.cron : '');
193
+ }
194
+ setTimezone(b.schedule?.timezone ?? LOCAL_TIMEZONE);
195
+
196
+ if (b.polling) {
197
+ setIntervalMinutes(Math.round(b.polling.intervalMs / 60000));
198
+ setCondition(b.polling.condition ?? '');
199
+ }
200
+ if (b.delay?.fireAt) {
201
+ const dt = new Date(b.delay.fireAt);
202
+ setRunAt(dt.toISOString().slice(0, 16));
203
+ }
204
+
205
+ setError('');
206
+ setSuccess('');
207
+ setFormMode('edit');
208
+ };
209
+
210
+ const closeForm = () => {
211
+ resetForm();
212
+ setFormMode('closed');
213
+ };
214
+
215
+ const handleSubmit = async (e: React.FormEvent) => {
216
+ e.preventDefault();
217
+ setError('');
218
+ setSuccess('');
219
+ setSaving(true);
220
+
221
+ try {
222
+ const input: Record<string, unknown> = { action };
223
+ if (type === 'scheduled') {
224
+ input.cron = cronPreview;
225
+ input.timezone = timezone;
226
+ } else if (type === 'monitor') {
227
+ input.intervalMinutes = intervalMinutes;
228
+ input.condition = condition;
229
+ } else if (type === 'one-shot') {
230
+ input.runAt = runAt;
231
+ }
232
+
233
+ if (formMode === 'edit' && editingId) {
234
+ await api.patchBehavior(editingId, input);
235
+ setSuccess('Behavior updated');
236
+ } else {
237
+ input.type = type;
238
+ await api.createBehavior(input);
239
+ setSuccess('Behavior created');
240
+ }
241
+
242
+ resetForm();
243
+ setFormMode('closed');
244
+ refresh();
245
+ } catch (err: any) {
246
+ setError(err.message || `Failed to ${formMode === 'edit' ? 'update' : 'create'} behavior`);
247
+ } finally {
248
+ setSaving(false);
249
+ }
250
+ };
251
+
252
+ const showTimePicker = frequency === 'daily' || frequency === 'weekday' || frequency === 'weekly';
253
+
254
+ return (
255
+ <div className="page">
256
+ <h2>Behaviors</h2>
257
+
258
+ <div className="create-form-toggle">
259
+ <button
260
+ className="btn-sm"
261
+ onClick={() => formMode !== 'closed' ? closeForm() : openCreate()}
262
+ >
263
+ {formMode !== 'closed' ? 'Cancel' : 'New Behavior'}
264
+ </button>
265
+ </div>
266
+
267
+ {formMode !== 'closed' && (
268
+ <div className="create-form">
269
+ <form onSubmit={handleSubmit}>
270
+ <h3>{formMode === 'edit' ? 'Edit Behavior' : 'New Behavior'}</h3>
271
+
272
+ {formMode === 'create' && (
273
+ <>
274
+ <label>Type</label>
275
+ <select value={type} onChange={e => setType(e.target.value as BehaviorType)}>
276
+ <option value="scheduled">Scheduled</option>
277
+ <option value="monitor">Monitor (polling)</option>
278
+ <option value="one-shot">One-shot (run once)</option>
279
+ </select>
280
+ </>
281
+ )}
282
+
283
+ <label>Action</label>
284
+ <textarea
285
+ value={action}
286
+ onChange={e => setAction(e.target.value)}
287
+ placeholder="What should the agent do?"
288
+ required
289
+ rows={3}
290
+ />
291
+
292
+ {type === 'scheduled' && (
293
+ <div className="create-form-group">
294
+ <label>How often?</label>
295
+ <select value={frequency} onChange={e => setFrequency(e.target.value as Frequency)}>
296
+ <option value="daily">Every day</option>
297
+ <option value="weekday">Every weekday (Mon-Fri)</option>
298
+ <option value="weekly">Once a week</option>
299
+ <option value="hourly">Every hour</option>
300
+ <option value="every-n-hours">Every few hours</option>
301
+ <option value="custom">Custom (cron)</option>
302
+ </select>
303
+
304
+ {frequency === 'weekly' && (
305
+ <>
306
+ <label>Day of week</label>
307
+ <select value={weekday} onChange={e => setWeekday(Number(e.target.value))}>
308
+ {DAYS_OF_WEEK.map((day, i) => (
309
+ <option key={i} value={i}>{day}</option>
310
+ ))}
311
+ </select>
312
+ </>
313
+ )}
314
+
315
+ {showTimePicker && (
316
+ <>
317
+ <label>Time</label>
318
+ <input
319
+ type="time"
320
+ value={scheduleTime}
321
+ onChange={e => setScheduleTime(e.target.value)}
322
+ required
323
+ />
324
+ </>
325
+ )}
326
+
327
+ {frequency === 'every-n-hours' && (
328
+ <>
329
+ <label>Every how many hours?</label>
330
+ <input
331
+ type="number"
332
+ value={everyNHours}
333
+ onChange={e => setEveryNHours(Number(e.target.value))}
334
+ min={1}
335
+ max={23}
336
+ required
337
+ />
338
+ </>
339
+ )}
340
+
341
+ {frequency === 'custom' && (
342
+ <>
343
+ <label>Cron expression</label>
344
+ <input
345
+ type="text"
346
+ value={customCron}
347
+ onChange={e => setCustomCron(e.target.value)}
348
+ placeholder="0 8 * * *"
349
+ required
350
+ />
351
+ </>
352
+ )}
353
+
354
+ <label>Timezone</label>
355
+ <input
356
+ type="text"
357
+ value={timezone}
358
+ onChange={e => setTimezone(e.target.value)}
359
+ />
360
+
361
+ <div className="form-hint">
362
+ Schedule: <strong>{describeCron(cronPreview)}</strong> ({timezone})
363
+ </div>
364
+ </div>
365
+ )}
366
+
367
+ {type === 'monitor' && (
368
+ <div className="create-form-group">
369
+ <label>Check every (minutes)</label>
370
+ <input
371
+ type="number"
372
+ value={intervalMinutes}
373
+ onChange={e => setIntervalMinutes(Number(e.target.value))}
374
+ min={1}
375
+ required
376
+ />
377
+ <label>Condition</label>
378
+ <textarea
379
+ value={condition}
380
+ onChange={e => setCondition(e.target.value)}
381
+ placeholder="When should this trigger?"
382
+ required
383
+ rows={2}
384
+ />
385
+ </div>
386
+ )}
387
+
388
+ {type === 'one-shot' && (
389
+ <div className="create-form-group">
390
+ <label>Run at</label>
391
+ <input
392
+ type="datetime-local"
393
+ value={runAt}
394
+ onChange={e => setRunAt(e.target.value)}
395
+ required
396
+ />
397
+ </div>
398
+ )}
399
+
400
+ {error && <div className="error">{error}</div>}
401
+ {success && <div className="settings-success">{success}</div>}
402
+
403
+ <button type="submit" className="settings-btn" disabled={saving || !action}>
404
+ {saving ? 'Saving...' : formMode === 'edit' ? 'Save Changes' : 'Create Behavior'}
405
+ </button>
406
+ </form>
407
+ </div>
408
+ )}
409
+
410
+ <DataTable
411
+ columns={columns}
412
+ rows={behaviors}
413
+ keyField="id"
414
+ actions={(b: any) => (
415
+ <>
416
+ <button className="btn-sm" onClick={() => openEdit(b)}>Edit</button>
417
+ <button className="btn-sm" onClick={() => handleToggle(b)}>
418
+ {b.status === 'active' ? 'Pause' : 'Resume'}
419
+ </button>
420
+ <button className="btn-sm btn-danger" onClick={() => handleDelete(b)}>Delete</button>
421
+ </>
422
+ )}
423
+ />
424
+ </div>
425
+ );
426
+ }