@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.
- package/LICENSE +191 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/cloud-types.d.ts +71 -0
- package/dist/cloud-types.d.ts.map +1 -0
- package/dist/cloud-types.js +2 -0
- package/dist/cloud-types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +2250 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +314 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist-ui/assets/index-BfY0i5jw.css +1 -0
- package/dist-ui/assets/index-CXpk9mvw.js +60 -0
- package/dist-ui/icon.svg +59 -0
- package/dist-ui/index.html +20 -0
- package/package.json +32 -0
- package/src/auth.ts +83 -0
- package/src/cloud-types.ts +63 -0
- package/src/index.ts +5 -0
- package/src/router.ts +2494 -0
- package/src/types.ts +269 -0
- package/tests/auth.test.ts +51 -0
- package/tests/cloud-router.test.ts +249 -0
- package/tests/desktop-router.test.ts +151 -0
- package/tests/router.test.ts +388 -0
- package/tests/trust-router.test.ts +170 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/index.html +19 -0
- package/ui/node_modules/.bin/browserslist +17 -0
- package/ui/node_modules/.bin/tsc +17 -0
- package/ui/node_modules/.bin/tsserver +17 -0
- package/ui/node_modules/.bin/vite +17 -0
- package/ui/package.json +23 -0
- package/ui/public/icon.svg +59 -0
- package/ui/src/App.tsx +63 -0
- package/ui/src/api.ts +238 -0
- package/ui/src/components/ActivityFeed.tsx +123 -0
- package/ui/src/components/BehaviorHealth.tsx +105 -0
- package/ui/src/components/DataTable.tsx +39 -0
- package/ui/src/components/Layout.tsx +160 -0
- package/ui/src/components/PasswordStrength.tsx +31 -0
- package/ui/src/components/SetupProgress.tsx +26 -0
- package/ui/src/components/StatusBadge.tsx +12 -0
- package/ui/src/components/ThemeSelector.tsx +39 -0
- package/ui/src/contexts/ThemeContext.tsx +58 -0
- package/ui/src/hooks/useApi.ts +19 -0
- package/ui/src/hooks/usePolling.ts +8 -0
- package/ui/src/main.tsx +16 -0
- package/ui/src/pages/AuditLog.tsx +36 -0
- package/ui/src/pages/Behaviors.tsx +426 -0
- package/ui/src/pages/Chat.tsx +688 -0
- package/ui/src/pages/Login.tsx +64 -0
- package/ui/src/pages/Overview.tsx +56 -0
- package/ui/src/pages/Sessions.tsx +26 -0
- package/ui/src/pages/SettingsAmbient.tsx +185 -0
- package/ui/src/pages/SettingsConnections.tsx +201 -0
- package/ui/src/pages/SettingsNotifications.tsx +241 -0
- package/ui/src/pages/SetupAppearance.tsx +45 -0
- package/ui/src/pages/SetupChannels.tsx +143 -0
- package/ui/src/pages/SetupComplete.tsx +31 -0
- package/ui/src/pages/SetupConnections.tsx +80 -0
- package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
- package/ui/src/pages/SetupIdentity.tsx +68 -0
- package/ui/src/pages/SetupPersonality.tsx +78 -0
- package/ui/src/pages/SetupProvider.tsx +65 -0
- package/ui/src/pages/SetupVault.tsx +50 -0
- package/ui/src/pages/SetupWelcome.tsx +19 -0
- package/ui/src/pages/UnlockVault.tsx +56 -0
- package/ui/src/pages/Webhooks.tsx +158 -0
- package/ui/src/pages/settings/Appearance.tsx +63 -0
- package/ui/src/pages/settings/Channels.tsx +138 -0
- package/ui/src/pages/settings/Identity.tsx +61 -0
- package/ui/src/pages/settings/Personality.tsx +54 -0
- package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
- package/ui/src/pages/settings/Provider.tsx +537 -0
- package/ui/src/pages/settings/Security.tsx +111 -0
- package/ui/src/styles/global.css +2308 -0
- package/ui/src/styles/themes/index.css +7 -0
- package/ui/src/styles/themes/monolith.css +125 -0
- package/ui/src/styles/themes/nebula.css +90 -0
- package/ui/src/styles/themes/neon.css +149 -0
- package/ui/src/styles/themes/polar.css +151 -0
- package/ui/src/styles/themes/signal.css +163 -0
- package/ui/src/styles/themes/terra.css +146 -0
- package/ui/tsconfig.json +14 -0
- 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
|
+
}
|
package/ui/src/main.tsx
ADDED
|
@@ -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
|
+
}
|