Phase 3.5: Automation & Agents Plugin

Neues automation Plugin (app/plugins/builtins/automation/):
- 7 DB-Modelle: AgentDefinition, AgentVersion, AutomationDefinition,
  AutomationVersion, AutomationCronJob, AgentRun, AutomationRun
- Migration 0001_initial.sql mit allen Tabellen + RLS
- PluginManifest erweitert: agent_definitions, automation_templates,
  cron_jobs, heartbeat_configs, miniapps Contribution-Felder
- 21 API-Endpoints: /api/v1/automation (CRUD, execute, dry-run,
  runs, versions, restore, settings, miniapps) + /api/v1/agents
  (CRUD, execute, test-run, runs, versions, restore, tools, send-message)

Backend Features:
- Cron-Scheduler (scheduler.py): ARQ-basiert, liest CronJob-Tabelle,
  enqueued run_agent/run_automation, croniter fuer next_run_at
- Workflow-Timeout-Worker (workflow_timeout.py): prueft abgelaufene
  WorkflowInstances, setzt cancelled, sendet Notification
- Agent Runner (agent_runner.py): LiteLLM + ToolRegistry, proactive/
  reactive mode, Rate-Limiting, Budget-Limit, Infinite-Loop-Detection
- Automation Execution Engine (execution_engine.py): Condition
  evaluation (eq/ne/gt/lt/contains/exists), Actions (api_call/
  notification/workflow_start), Dry-Run mode
- Agent-to-Agent Communication (agent_comm.py): send_agent_message
  tool, kommunikation plugin integration
- Plugin-Beitraege: register/unregister on activate/deactivate,
  Konfliktloesung mit Plugin-Name als Prefix
- Heartbeat-Migration: ai_proactive heartbeat als Cron-Job
- Versionshistorie: Auto-Versioning bei Updates, Restore-Endpoint
- Settings: GET/PATCH /api/v1/automation/settings
- ARQ Worker: 11 functions, 2 cron_jobs (scheduler_tick 30s,
  check_workflow_timeouts 5min)

Frontend:
- AutomationDashboard.tsx: Automation Builder UI mit Trigger,
  Conditions, Actions, Execute, Dry-Run, Run-History, Versions
- AgentDashboard.tsx: Agent Builder UI mit Model, Tools, Prompt,
  Heartbeat, Rate-Limits, Execute, Test-Run, Agent-Chat
- AutomationSettings.tsx: Settings + MiniApp-Builder
- automation.ts: 24 React Query Hooks
- automation.ts types: TypeScript Interfaces
- routes/index.tsx: /automation, /agents, /settings/automation

Tests:
- 22 Frontend-Tests (AutomationDashboard, AgentDashboard, API) — alle bestanden
- Backend-Tests: test_automation.py (CRUD, versions, conditions, dry-run, rate-limiting)
- TSC: keine neuen Errors (nur pre-existing Dms.tsx)
- croniter dependency installiert
This commit is contained in:
Agent Zero
2026-07-23 20:00:37 +02:00
parent fc96a2f86c
commit 5dc6f29ac1
28 changed files with 7397 additions and 0 deletions
+830
View File
@@ -0,0 +1,830 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useAgents,
useCreateAgent,
useUpdateAgent,
useDeleteAgent,
useExecuteAgent,
useTestRunAgent,
useAgentRuns,
useAgentVersions,
useRestoreAgentVersion,
useAgentTools,
useSendAgentMessage,
} from '@/api/automation';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import type { AgentDefinition } from '@/types/automation';
import {
Plus,
Play,
TestTube,
History,
GitBranch,
Trash2,
Settings2,
Bot,
AlertCircle,
Loader2,
Brain,
Activity,
Clock,
DollarSign,
MessageCircle,
Send,
} from 'lucide-react';
const modeOptions = [
{ value: 'reactive', label: 'Reactive' },
{ value: 'proactive', label: 'Proactive' },
];
const commonModels = [
'gpt-4',
'gpt-4-turbo',
'gpt-3.5-turbo',
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
'llama-3-70b',
'llama-3-8b',
'mistral-large',
'mixtral-8x7b',
];
function statusBadgeVariant(status: string): 'success' | 'warning' | 'secondary' {
switch (status) {
case 'active': return 'success';
case 'inactive': return 'warning';
default: return 'secondary';
}
}
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'completed': return 'success';
case 'running': return 'info';
case 'failed': return 'danger';
case 'cancelled': return 'warning';
default: return 'warning';
}
}
interface AgentFormData {
name: string;
description: string;
model: string;
system_prompt: string;
tools: string[];
heartbeat_interval: number;
mode: 'proactive' | 'reactive';
max_executions_per_hour: number;
max_duration: number;
budget_limit: number;
active: boolean;
}
const emptyForm: AgentFormData = {
name: '',
description: '',
model: 'gpt-4',
system_prompt: '',
tools: [],
heartbeat_interval: 0,
mode: 'reactive',
max_executions_per_hour: 100,
max_duration: 300,
budget_limit: 0,
active: true,
};
function AgentForm({
initial,
onSave,
onCancel,
isSaving,
}: {
initial?: AgentDefinition;
onSave: (data: Partial<AgentDefinition>) => void;
onCancel: () => void;
isSaving: boolean;
}) {
const { t } = useTranslation();
const { data: availableTools } = useAgentTools();
const [form, setForm] = useState<AgentFormData>(() => {
if (initial) {
return {
name: initial.name,
description: initial.description || '',
model: initial.model,
system_prompt: initial.system_prompt || '',
tools: initial.tools || [],
heartbeat_interval: initial.heartbeat_interval,
mode: initial.mode,
max_executions_per_hour: initial.max_executions_per_hour,
max_duration: initial.max_duration,
budget_limit: initial.budget_limit,
active: initial.active,
};
}
return { ...emptyForm };
});
const updateField = <K extends keyof AgentFormData>(key: K, value: AgentFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const toggleTool = (toolName: string) => {
setForm((prev) => {
const tools = prev.tools.includes(toolName)
? prev.tools.filter((t) => t !== toolName)
: [...prev.tools, toolName];
return { ...prev, tools };
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name: form.name,
description: form.description || undefined,
model: form.model,
system_prompt: form.system_prompt || undefined,
tools: form.tools,
heartbeat_interval: form.heartbeat_interval,
mode: form.mode,
max_executions_per_hour: form.max_executions_per_hour,
max_duration: form.max_duration,
budget_limit: form.budget_limit,
active: form.active,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name & Description */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.name')} *
</label>
<input
type="text"
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
required
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="My Agent"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.description')}
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optional description"
/>
</div>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.model')}
</label>
<div className="flex gap-2">
<input
type="text"
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
list="model-suggestions"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="gpt-4"
/>
<datalist id="model-suggestions">
{commonModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
{/* System Prompt */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.systemPrompt')}
</label>
<textarea
value={form.system_prompt}
onChange={(e) => updateField('system_prompt', e.target.value)}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="You are a helpful assistant..."
/>
</div>
{/* Mode */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.mode')}
</label>
<Select
options={modeOptions}
value={form.mode}
onChange={(e) => updateField('mode', e.target.value as 'proactive' | 'reactive')}
/>
</div>
{/* Tools */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('agent.tools')}
</label>
{!availableTools || availableTools.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
) : (
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto">
{availableTools.map((tool) => (
<label
key={tool.name}
className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={form.tools.includes(tool.name)}
onChange={() => toggleTool(tool.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{tool.name}</span>
{tool.description && (
<p className="text-xs text-secondary-400">{tool.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Heartbeat */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.heartbeatInterval')}
</label>
<input
type="number"
value={form.heartbeat_interval}
onChange={(e) => updateField('heartbeat_interval', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-secondary-400 mt-1">{t('agent.heartbeatHint')}</p>
</div>
{/* Rate Limits */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.maxExecutions')}
</label>
<input
type="number"
value={form.max_executions_per_hour}
onChange={(e) => updateField('max_executions_per_hour', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.maxDuration')} (s)
</label>
<input
type="number"
value={form.max_duration}
onChange={(e) => updateField('max_duration', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.budgetLimit')}
</label>
<input
type="number"
value={form.budget_limit}
onChange={(e) => updateField('budget_limit', parseFloat(e.target.value) || 0)}
min={0}
step={0.01}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{/* Active Toggle */}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(e) => updateField('active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
{t('agent.active')}
</label>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onCancel} type="button">
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSaving}>
{initial ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
function RunHistoryModal({
agentId,
open,
onClose,
}: {
agentId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { data: runs, isLoading } = useAgentRuns(agentId);
return (
<Modal open={open} onClose={onClose} title={t('agent.runHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !runs || runs.length === 0 ? (
<EmptyState
title={t('agent.noRuns')}
description={t('agent.noRunsDesc')}
icon={<History className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{runs.map((run) => (
<div
key={run.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant={runStatusBadgeVariant(run.status)}>
{run.status}
</Badge>
<span className="text-sm text-secondary-600">
{new Date(run.started_at).toLocaleString()}
</span>
</div>
{run.error && (
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
{run.error}
</span>
)}
</div>
))}
</div>
)}
</Modal>
);
}
function VersionHistoryModal({
agentId,
open,
onClose,
}: {
agentId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const { data: versions, isLoading } = useAgentVersions(agentId);
const restoreMutation = useRestoreAgentVersion();
const handleRestore = async (versionId: string) => {
try {
await restoreMutation.mutateAsync({ id: agentId, versionId });
toast.success(t('agent.versionRestored'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<Modal open={open} onClose={onClose} title={t('agent.versionHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !versions || versions.length === 0 ? (
<EmptyState
title={t('agent.noVersions')}
icon={<GitBranch className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{versions.map((ver) => (
<div
key={ver.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant="primary">v{ver.version}</Badge>
<span className="text-sm text-secondary-600">
{new Date(ver.created_at).toLocaleString()}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRestore(ver.id)}
isLoading={restoreMutation.isPending}
>
{t('common.restore')}
</Button>
</div>
))}
</div>
)}
</Modal>
);
}
export function AgentDashboardPage() {
const { t } = useTranslation();
const toast = useToast();
const { data: agents, isLoading, isError, refetch } = useAgents();
const createMutation = useCreateAgent();
const updateMutation = useUpdateAgent();
const deleteMutation = useDeleteAgent();
const executeMutation = useExecuteAgent();
const testRunMutation = useTestRunAgent();
const sendMessageMutation = useSendAgentMessage();
const [showForm, setShowForm] = useState(false);
const [editingAgent, setEditingAgent] = useState<AgentDefinition | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<AgentDefinition | null>(null);
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
const [showAgentChat, setShowAgentChat] = useState(false);
const [chatTargetAgent, setChatTargetAgent] = useState('');
const [chatMessage, setChatMessage] = useState('');
const handleSave = async (data: Partial<AgentDefinition>) => {
try {
if (editingAgent) {
await updateMutation.mutateAsync({ id: editingAgent.id, data });
toast.success(t('agent.updated'));
} else {
await createMutation.mutateAsync(data);
toast.success(t('agent.created'));
}
setShowForm(false);
setEditingAgent(undefined);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async () => {
if (!confirmDelete) return;
try {
await deleteMutation.mutateAsync(confirmDelete.id);
toast.success(t('agent.deleted'));
setConfirmDelete(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleExecute = async (id: string) => {
try {
await executeMutation.mutateAsync(id);
toast.success(t('agent.executed'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleTestRun = async (id: string) => {
try {
await testRunMutation.mutateAsync(id);
toast.success(t('agent.testRunSuccess'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleSendMessage = async () => {
if (!chatTargetAgent || !chatMessage.trim()) return;
try {
await sendMessageMutation.mutateAsync({
id: chatTargetAgent,
toAgentName: chatTargetAgent,
message: chatMessage,
});
toast.success(t('agent.messageSent'));
setChatMessage('');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const openEdit = (agent: AgentDefinition) => {
setEditingAgent(agent);
setShowForm(true);
};
const openCreate = () => {
setEditingAgent(undefined);
setShowForm(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="max-w-7xl mx-auto p-6" data-testid="agent-dashboard">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-secondary-900">{t('agent.title')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('agent.subtitle')}</p>
</div>
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('agent.create')}
</Button>
</div>
{/* Loading */}
{isLoading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{/* Error */}
{isError && (
<Card className="p-6">
<div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" />
<span>{t('common.errorLoading')}</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}>
{t('common.retry')}
</Button>
</div>
</Card>
)}
{/* Empty State */}
{!isLoading && !isError && (!agents || agents.length === 0) && (
<EmptyState
title={t('agent.noAgents')}
description={t('agent.noAgentsDesc')}
icon={<Bot className="h-8 w-8" />}
action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('agent.createFirst')}
</Button>
}
/>
)}
{/* Agent List */}
{!isLoading && !isError && agents && agents.length > 0 && (
<div className="space-y-4">
{agents.map((agent) => (
<Card key={agent.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-semibold text-secondary-900">{agent.name}</h3>
<Badge variant={statusBadgeVariant(agent.active ? 'active' : 'inactive')}>
{agent.active ? t('agent.active') : t('agent.inactive')}
</Badge>
<Badge variant={agent.mode === 'proactive' ? 'info' : 'secondary'}>
{agent.mode}
</Badge>
</div>
{agent.description && (
<p className="text-sm text-secondary-500 mb-2">{agent.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-secondary-400">
<span className="flex items-center gap-1">
<Brain className="h-3 w-3" />
{agent.model}
</span>
<span className="flex items-center gap-1">
<Activity className="h-3 w-3" />
{agent.tools?.length || 0} tools
</span>
{agent.heartbeat_interval > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{agent.heartbeat_interval}s
</span>
)}
{agent.budget_limit > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{agent.budget_limit}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="ghost"
onClick={() => handleExecute(agent.id)}
isLoading={executeMutation.isPending}
title={t('agent.execute')}
>
<Play className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleTestRun(agent.id)}
isLoading={testRunMutation.isPending}
title={t('agent.testRun')}
>
<TestTube className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setRunHistoryId(agent.id)}
title={t('agent.runHistory')}
>
<History className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setVersionHistoryId(agent.id)}
title={t('agent.versionHistory')}
>
<GitBranch className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(agent)}
title={t('common.edit')}
>
<Settings2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(agent)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4 text-danger-500" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
open={showForm}
onClose={() => { setShowForm(false); setEditingAgent(undefined); }}
title={editingAgent ? t('agent.edit') : t('agent.create')}
size="xl"
>
<AgentForm
initial={editingAgent}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
isSaving={isSaving}
/>
</Modal>
{/* Run History Modal */}
{runHistoryId && (
<RunHistoryModal
agentId={runHistoryId}
open={!!runHistoryId}
onClose={() => setRunHistoryId(null)}
/>
)}
{/* Version History Modal */}
{versionHistoryId && (
<VersionHistoryModal
agentId={versionHistoryId}
open={!!versionHistoryId}
onClose={() => setVersionHistoryId(null)}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
title={t('agent.deleteConfirmTitle')}
message={t('agent.deleteConfirmMessage', { name: confirmDelete?.name })}
confirmLabel={t('common.delete')}
variant="danger"
/>
{/* Agent Chat Section */}
<div className="mt-8 border-t border-secondary-200 pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-secondary-900">{t('agent.agentChat')}</h2>
<p className="text-sm text-secondary-500 mt-1">{t('agent.agentChatSubtitle')}</p>
</div>
<Button
variant={showAgentChat ? 'secondary' : 'primary'}
onClick={() => setShowAgentChat(!showAgentChat)}
icon={<MessageCircle className="h-4 w-4" />}
>
{showAgentChat ? t('common.hide') : t('agent.openChat')}
</Button>
</div>
{showAgentChat && (
<Card className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.targetAgent')}
</label>
<select
value={chatTargetAgent}
onChange={(e) => setChatTargetAgent(e.target.value)}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('agent.selectTargetAgent')}</option>
{agents?.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.message')}
</label>
<textarea
value={chatMessage}
onChange={(e) => setChatMessage(e.target.value)}
rows={3}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agent.messagePlaceholder')}
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSendMessage}
isLoading={sendMessageMutation.isPending}
disabled={!chatTargetAgent || !chatMessage.trim()}
icon={<Send className="h-4 w-4" />}
>
{t('agent.sendMessage')}
</Button>
</div>
</div>
</Card>
)}
</div>
</div>
);
}
+777
View File
@@ -0,0 +1,777 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useAutomations,
useCreateAutomation,
useUpdateAutomation,
useDeleteAutomation,
useExecuteAutomation,
useDryRunAutomation,
useAutomationRuns,
useAutomationVersions,
useRestoreAutomationVersion,
} from '@/api/automation';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import type { AutomationDefinition, AutomationCondition, AutomationAction } from '@/types/automation';
import {
Plus,
Play,
RotateCcw,
History,
GitBranch,
Trash2,
Settings2,
Workflow,
Clock,
Zap,
AlertCircle,
CheckCircle2,
XCircle,
Loader2,
} from 'lucide-react';
const triggerTypeOptions = [
{ value: 'event', label: 'Event' },
{ value: 'schedule', label: 'Schedule' },
{ value: 'manual', label: 'Manual' },
];
const actionTypeOptions = [
{ value: 'api_call', label: 'API Call' },
{ value: 'notification', label: 'Notification' },
{ value: 'workflow_start', label: 'Workflow Start' },
];
const conditionOperatorOptions = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'contains', label: 'Contains' },
{ value: 'greater_than', label: 'Greater Than' },
{ value: 'less_than', label: 'Less Than' },
];
function statusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'secondary' {
switch (status) {
case 'active': return 'success';
case 'inactive': return 'warning';
default: return 'secondary';
}
}
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'completed': return 'success';
case 'running': return 'info';
case 'failed': return 'danger';
case 'cancelled': return 'warning';
default: return 'warning';
}
}
function triggerIcon(type: string) {
switch (type) {
case 'event': return <Zap className="h-4 w-4" />;
case 'schedule': return <Clock className="h-4 w-4" />;
default: return <Play className="h-4 w-4" />;
}
}
interface AutomationFormData {
name: string;
description: string;
trigger_type: 'event' | 'schedule' | 'manual';
trigger_config: Record<string, string>;
conditions: AutomationCondition[];
actions: AutomationAction[];
active: boolean;
dry_run: boolean;
}
const emptyForm: AutomationFormData = {
name: '',
description: '',
trigger_type: 'manual',
trigger_config: {},
conditions: [],
actions: [],
active: true,
dry_run: false,
};
function AutomationForm({
initial,
onSave,
onCancel,
isSaving,
}: {
initial?: AutomationDefinition;
onSave: (data: Partial<AutomationDefinition>) => void;
onCancel: () => void;
isSaving: boolean;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<AutomationFormData>(() => {
if (initial) {
return {
name: initial.name,
description: initial.description || '',
trigger_type: initial.trigger_type,
trigger_config: (initial.trigger_config as Record<string, string>) || {},
conditions: initial.conditions || [],
actions: initial.actions || [],
active: initial.active,
dry_run: initial.dry_run,
};
}
return { ...emptyForm };
});
const updateField = <K extends keyof AutomationFormData>(key: K, value: AutomationFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const addCondition = () => {
setForm((prev) => ({
...prev,
conditions: [...prev.conditions, { field: '', operator: 'equals', value: '' }],
}));
};
const updateCondition = (index: number, field: keyof AutomationCondition, value: string) => {
setForm((prev) => {
const conditions = [...prev.conditions];
conditions[index] = { ...conditions[index], [field]: value };
return { ...prev, conditions };
});
};
const removeCondition = (index: number) => {
setForm((prev) => ({
...prev,
conditions: prev.conditions.filter((_, i) => i !== index),
}));
};
const addAction = () => {
setForm((prev) => ({
...prev,
actions: [...prev.actions, { type: 'api_call', config: {} }],
}));
};
const updateAction = (index: number, field: 'type' | 'config', value: string | Record<string, unknown>) => {
setForm((prev) => {
const actions = [...prev.actions];
if (field === 'type') {
actions[index] = { ...actions[index], type: value as 'api_call' | 'notification' | 'workflow_start' };
} else {
actions[index] = { ...actions[index], config: value as Record<string, unknown> };
}
return { ...prev, actions };
});
};
const removeAction = (index: number) => {
setForm((prev) => ({
...prev,
actions: prev.actions.filter((_, i) => i !== index),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name: form.name,
description: form.description || undefined,
trigger_type: form.trigger_type,
trigger_config: form.trigger_config,
conditions: form.conditions,
actions: form.actions,
active: form.active,
dry_run: form.dry_run,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name & Description */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.name')} *
</label>
<input
type="text"
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
required
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="My Automation"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.description')}
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optional description"
/>
</div>
</div>
{/* Trigger Type */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.triggerType')}
</label>
<Select
options={triggerTypeOptions}
value={form.trigger_type}
onChange={(e) => updateField('trigger_type', e.target.value as 'event' | 'schedule' | 'manual')}
/>
</div>
{/* Trigger Config */}
{form.trigger_type === 'event' && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.eventName')}
</label>
<input
type="text"
value={form.trigger_config.event_name || ''}
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="contact.created"
/>
</div>
)}
{form.trigger_type === 'schedule' && (
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.cronExpression')}
</label>
<input
type="text"
value={form.trigger_config.cron || ''}
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, cron: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="0 9 * * *"
/>
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p>
</div>
)}
{/* Conditions */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('automation.conditions')}
</label>
<Button size="sm" variant="ghost" onClick={addCondition} type="button">
+ {t('common.add')}
</Button>
</div>
{form.conditions.length === 0 && (
<p className="text-sm text-secondary-400 italic">{t('automation.noConditions')}</p>
)}
{form.conditions.map((cond, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<input
type="text"
value={cond.field}
onChange={(e) => updateCondition(i, 'field', e.target.value)}
placeholder="Field"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<Select
options={conditionOperatorOptions}
value={cond.operator}
onChange={(e) => updateCondition(i, 'operator', e.target.value)}
className="w-32"
/>
<input
type="text"
value={cond.value}
onChange={(e) => updateCondition(i, 'value', e.target.value)}
placeholder="Value"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="button"
onClick={() => removeCondition(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Remove condition"
>
<XCircle className="h-4 w-4" />
</button>
</div>
))}
</div>
{/* Actions */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('automation.actions')}
</label>
<Button size="sm" variant="ghost" onClick={addAction} type="button">
+ {t('common.add')}
</Button>
</div>
{form.actions.length === 0 && (
<p className="text-sm text-secondary-400 italic">{t('automation.noActions')}</p>
)}
{form.actions.map((action, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<Select
options={actionTypeOptions}
value={action.type}
onChange={(e) => updateAction(i, 'type', e.target.value)}
className="w-40"
/>
<input
type="text"
value={JSON.stringify(action.config)}
onChange={(e) => {
try {
updateAction(i, 'config', JSON.parse(e.target.value));
} catch {
// ignore invalid JSON while typing
}
}}
placeholder='{"url": "..."}'
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="button"
onClick={() => removeAction(i)}
className="text-danger-500 hover:text-danger-700 p-1"
aria-label="Remove action"
>
<XCircle className="h-4 w-4" />
</button>
</div>
))}
</div>
{/* Toggles */}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(e) => updateField('active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
{t('automation.active')}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.dry_run}
onChange={(e) => updateField('dry_run', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
{t('automation.dryRun')}
</label>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onCancel} type="button">
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSaving}>
{initial ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
function RunHistoryModal({
automationId,
open,
onClose,
}: {
automationId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { data: runs, isLoading } = useAutomationRuns(automationId);
return (
<Modal open={open} onClose={onClose} title={t('automation.runHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !runs || runs.length === 0 ? (
<EmptyState
title={t('automation.noRuns')}
description={t('automation.noRunsDesc')}
icon={<History className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{runs.map((run) => (
<div
key={run.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant={runStatusBadgeVariant(run.status)}>
{run.status}
</Badge>
<span className="text-sm text-secondary-600">
{new Date(run.started_at).toLocaleString()}
</span>
</div>
{run.error && (
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
{run.error}
</span>
)}
</div>
))}
</div>
)}
</Modal>
);
}
function VersionHistoryModal({
automationId,
open,
onClose,
}: {
automationId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const { data: versions, isLoading } = useAutomationVersions(automationId);
const restoreMutation = useRestoreAutomationVersion();
const handleRestore = async (versionId: string) => {
try {
await restoreMutation.mutateAsync({ id: automationId, versionId });
toast.success(t('automation.versionRestored'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<Modal open={open} onClose={onClose} title={t('automation.versionHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !versions || versions.length === 0 ? (
<EmptyState
title={t('automation.noVersions')}
icon={<GitBranch className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{versions.map((ver) => (
<div
key={ver.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant="primary">v{ver.version}</Badge>
<span className="text-sm text-secondary-600">
{new Date(ver.created_at).toLocaleString()}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRestore(ver.id)}
isLoading={restoreMutation.isPending}
>
{t('common.restore')}
</Button>
</div>
))}
</div>
)}
</Modal>
);
}
export function AutomationDashboardPage() {
const { t } = useTranslation();
const toast = useToast();
const { data: automations, isLoading, isError, refetch } = useAutomations();
const createMutation = useCreateAutomation();
const updateMutation = useUpdateAutomation();
const deleteMutation = useDeleteAutomation();
const executeMutation = useExecuteAutomation();
const dryRunMutation = useDryRunAutomation();
const [showForm, setShowForm] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<AutomationDefinition | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<AutomationDefinition | null>(null);
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
const handleSave = async (data: Partial<AutomationDefinition>) => {
try {
if (editingAutomation) {
await updateMutation.mutateAsync({ id: editingAutomation.id, data });
toast.success(t('automation.updated'));
} else {
await createMutation.mutateAsync(data);
toast.success(t('automation.created'));
}
setShowForm(false);
setEditingAutomation(undefined);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async () => {
if (!confirmDelete) return;
try {
await deleteMutation.mutateAsync(confirmDelete.id);
toast.success(t('automation.deleted'));
setConfirmDelete(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleExecute = async (id: string) => {
try {
await executeMutation.mutateAsync(id);
toast.success(t('automation.executed'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDryRun = async (id: string) => {
try {
await dryRunMutation.mutateAsync(id);
toast.success(t('automation.dryRunSuccess'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const openEdit = (automation: AutomationDefinition) => {
setEditingAutomation(automation);
setShowForm(true);
};
const openCreate = () => {
setEditingAutomation(undefined);
setShowForm(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="max-w-7xl mx-auto p-6" data-testid="automation-dashboard">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.title')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('automation.subtitle')}</p>
</div>
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('automation.create')}
</Button>
</div>
{/* Loading */}
{isLoading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{/* Error */}
{isError && (
<Card className="p-6">
<div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" />
<span>{t('common.errorLoading')}</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}>
{t('common.retry')}
</Button>
</div>
</Card>
)}
{/* Empty State */}
{!isLoading && !isError && (!automations || automations.length === 0) && (
<EmptyState
title={t('automation.noAutomations')}
description={t('automation.noAutomationsDesc')}
icon={<Workflow className="h-8 w-8" />}
action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('automation.createFirst')}
</Button>
}
/>
)}
{/* Automation List */}
{!isLoading && !isError && automations && automations.length > 0 && (
<div className="space-y-4">
{automations.map((automation) => (
<Card key={automation.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-semibold text-secondary-900">{automation.name}</h3>
<Badge variant={statusBadgeVariant(automation.active ? 'active' : 'inactive')}>
{automation.active ? t('automation.active') : t('automation.inactive')}
</Badge>
<div className="flex items-center gap-1 text-xs text-secondary-400">
{triggerIcon(automation.trigger_type)}
<span className="capitalize">{automation.trigger_type}</span>
</div>
</div>
{automation.description && (
<p className="text-sm text-secondary-500 mb-2">{automation.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-secondary-400">
<span>{automation.conditions?.length || 0} conditions</span>
<span>{automation.actions?.length || 0} actions</span>
{automation.dry_run && (
<Badge variant="warning" dot>{t('automation.dryRun')}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="ghost"
onClick={() => handleExecute(automation.id)}
isLoading={executeMutation.isPending}
title={t('automation.execute')}
>
<Play className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDryRun(automation.id)}
isLoading={dryRunMutation.isPending}
title={t('automation.dryRun')}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setRunHistoryId(automation.id)}
title={t('automation.runHistory')}
>
<History className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setVersionHistoryId(automation.id)}
title={t('automation.versionHistory')}
>
<GitBranch className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(automation)}
title={t('common.edit')}
>
<Settings2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(automation)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4 text-danger-500" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
open={showForm}
onClose={() => { setShowForm(false); setEditingAutomation(undefined); }}
title={editingAutomation ? t('automation.edit') : t('automation.create')}
size="lg"
>
<AutomationForm
initial={editingAutomation}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAutomation(undefined); }}
isSaving={isSaving}
/>
</Modal>
{/* Run History Modal */}
{runHistoryId && (
<RunHistoryModal
automationId={runHistoryId}
open={!!runHistoryId}
onClose={() => setRunHistoryId(null)}
/>
)}
{/* Version History Modal */}
{versionHistoryId && (
<VersionHistoryModal
automationId={versionHistoryId}
open={!!versionHistoryId}
onClose={() => setVersionHistoryId(null)}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
title={t('automation.deleteConfirmTitle')}
message={t('automation.deleteConfirmMessage', { name: confirmDelete?.name })}
confirmLabel={t('common.delete')}
variant="danger"
/>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
useAutomationSettings,
useUpdateAutomationSettings,
useMiniApps,
useCreateMiniApp,
useDeleteMiniApp,
} from '@/api/automation';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import { Modal } from '@/components/ui/Modal';
import type { AutomationSettings as AutomationSettingsType, MiniAppDef } from '@/types/automation';
import { Save, AlertCircle, Plus, Trash2, AppWindow } from 'lucide-react';
const logLevelOptions = [
{ value: 'debug', label: 'Debug' },
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
];
export function AutomationSettingsPage() {
const { t } = useTranslation();
const toast = useToast();
const { data: settings, isLoading, isError, refetch } = useAutomationSettings();
const updateMutation = useUpdateAutomationSettings();
const [form, setForm] = useState<AutomationSettingsType>({
default_llm_model: '',
heartbeat_default_interval: 0,
max_concurrent_agents: 0,
log_level: 'info',
});
useEffect(() => {
if (settings) {
setForm({
default_llm_model: settings.default_llm_model || '',
heartbeat_default_interval: settings.heartbeat_default_interval || 0,
max_concurrent_agents: settings.max_concurrent_agents || 0,
log_level: settings.log_level || 'info',
});
}
}, [settings]);
const updateField = <K extends keyof AutomationSettingsType>(key: K, value: AutomationSettingsType[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
try {
await updateMutation.mutateAsync(form);
toast.success(t('automation.settingsSaved'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const { data: miniapps, isLoading: miniappsLoading, refetch: refetchMiniapps } = useMiniApps();
const createMiniAppMutation = useCreateMiniApp();
const deleteMiniAppMutation = useDeleteMiniApp();
const [showMiniAppForm, setShowMiniAppForm] = useState(false);
const [miniAppForm, setMiniAppForm] = useState({
app_id: '',
name: '',
icon: 'AppWindow',
description: '',
render_schema: '{}',
});
const handleCreateMiniApp = async () => {
try {
let render_schema = {};
try {
render_schema = JSON.parse(miniAppForm.render_schema);
} catch {
toast.error(t('common.invalidJson'));
return;
}
await createMiniAppMutation.mutateAsync({
app_id: miniAppForm.app_id,
name: miniAppForm.name,
icon: miniAppForm.icon,
description: miniAppForm.description,
render_schema,
});
toast.success(t('automation.miniAppCreated'));
setShowMiniAppForm(false);
setMiniAppForm({ app_id: '', name: '', icon: 'AppWindow', description: '', render_schema: '{}' });
refetchMiniapps();
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDeleteMiniApp = async (appId: string) => {
try {
await deleteMiniAppMutation.mutateAsync(appId);
toast.success(t('automation.miniAppDeleted'));
refetchMiniapps();
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<div className="max-w-3xl mx-auto" data-testid="automation-settings">
<div className="mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('automation.settingsTitle')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('automation.settingsSubtitle')}</p>
</div>
{isLoading && (
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)}
{isError && (
<Card className="p-6">
<div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" />
<span>{t('common.errorLoading')}</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}>
{t('common.retry')}
</Button>
</div>
</Card>
)}
{!isLoading && !isError && (
<Card className="p-6">
<div className="space-y-6">
{/* Default LLM Model */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.defaultLlmModel')}
</label>
<input
type="text"
value={form.default_llm_model}
onChange={(e) => updateField('default_llm_model', e.target.value)}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="gpt-4"
/>
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
</div>
{/* Heartbeat Default Interval */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.heartbeatDefaultInterval')}
</label>
<input
type="number"
value={form.heartbeat_default_interval}
onChange={(e) => updateField('heartbeat_default_interval', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-secondary-400 mt-1">{t('automation.heartbeatDefaultIntervalHint')}</p>
</div>
{/* Max Concurrent Agents */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.maxConcurrentAgents')}
</label>
<input
type="number"
value={form.max_concurrent_agents}
onChange={(e) => updateField('max_concurrent_agents', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-secondary-400 mt-1">{t('automation.maxConcurrentAgentsHint')}</p>
</div>
{/* Log Level */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('automation.logLevel')}
</label>
<Select
options={logLevelOptions}
value={form.log_level}
onChange={(e) => updateField('log_level', e.target.value as 'debug' | 'info' | 'warning' | 'error')}
/>
</div>
{/* Save Button */}
<div className="pt-4 border-t border-secondary-200">
<Button
onClick={handleSave}
isLoading={updateMutation.isPending}
icon={<Save className="h-4 w-4" />}
>
{t('common.save')}
</Button>
</div>
</div>
</Card>
)}
{/* MiniApp Builder Section */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-secondary-900">{t('automation.miniAppsTitle')}</h2>
<p className="text-sm text-secondary-500 mt-1">{t('automation.miniAppsSubtitle')}</p>
</div>
<Button onClick={() => setShowMiniAppForm(true)} icon={<Plus className="h-4 w-4" />}>
{t('automation.createMiniApp')}
</Button>
</div>
{miniappsLoading ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !miniapps || miniapps.length === 0 ? (
<Card className="p-6 text-center">
<AppWindow className="h-8 w-8 mx-auto text-secondary-400 mb-2" />
<p className="text-sm text-secondary-500">{t('automation.noMiniApps')}</p>
</Card>
) : (
<div className="space-y-2">
{miniapps.map((app: MiniAppDef) => (
<Card key={app.app_id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<AppWindow className="h-5 w-5 text-primary-500" />
<div>
<p className="font-medium text-secondary-900">{app.name}</p>
<p className="text-xs text-secondary-400">{app.app_id} {app.description || t('common.noDescription')}</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteMiniApp(app.app_id)}
isLoading={deleteMiniAppMutation.isPending}
>
<Trash2 className="h-4 w-4 text-danger-500" />
</Button>
</Card>
))}
</div>
)}
</div>
{/* Create MiniApp Modal */}
<Modal
open={showMiniAppForm}
onClose={() => setShowMiniAppForm(false)}
title={t('automation.createMiniApp')}
size="md"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppId')}</label>
<input
type="text"
value={miniAppForm.app_id}
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="my_custom_app"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppName')}</label>
<input
type="text"
value={miniAppForm.name}
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="My Custom App"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppIcon')}</label>
<input
type="text"
value={miniAppForm.icon}
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="AppWindow"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppDescription')}</label>
<textarea
value={miniAppForm.description}
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('automation.miniAppSchema')}</label>
<textarea
value={miniAppForm.render_schema}
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
placeholder='{"type": "form", "fields": []}'
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={() => setShowMiniAppForm(false)}>{t('common.cancel')}</Button>
<Button onClick={handleCreateMiniApp} isLoading={createMiniAppMutation.isPending}>{t('common.create')}</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AgentDashboardPage } from '../AgentDashboard';
// Helper to create a mock mutation result
const mockMutation = (overrides = {}) => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
isError: false,
error: null,
data: undefined,
...overrides,
});
// Mock the API hooks
vi.mock('@/api/automation', () => ({
useAgents: vi.fn(),
useCreateAgent: vi.fn(() => mockMutation()),
useUpdateAgent: vi.fn(() => mockMutation()),
useDeleteAgent: vi.fn(() => mockMutation()),
useExecuteAgent: vi.fn(() => mockMutation()),
useTestRunAgent: vi.fn(() => mockMutation()),
useAgentRuns: vi.fn(),
useAgentVersions: vi.fn(),
useRestoreAgentVersion: vi.fn(() => mockMutation()),
useAgentTools: vi.fn(() => ({ data: [] })),
useSendAgentMessage: vi.fn(() => mockMutation()),
}));
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}));
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
toast: { success: vi.fn(), error: vi.fn() },
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Activity: () => <div data-testid="icon-activity" />,
AlertCircle: () => <div data-testid="icon-alert" />,
Bot: () => <div data-testid="icon-bot" />,
Brain: () => <div data-testid="icon-brain" />,
Clock: () => <div data-testid="icon-clock" />,
DollarSign: () => <div data-testid="icon-dollar" />,
GitBranch: () => <div data-testid="icon-git-branch" />,
History: () => <div data-testid="icon-history" />,
Loader2: () => <div data-testid="icon-loader" />,
MessageCircle: () => <div data-testid="icon-message-circle" />,
Play: () => <div data-testid="icon-play" />,
Plus: () => <div data-testid="icon-plus" />,
RefreshCw: () => <div data-testid="icon-refresh" />,
Send: () => <div data-testid="icon-send" />,
Settings2: () => <div data-testid="icon-settings2" />,
TestTube: () => <div data-testid="icon-test-tube" />,
Trash2: () => <div data-testid="icon-trash" />,
Zap: () => <div data-testid="icon-zap" />,
}));
// Mock Modal component
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
}));
// Mock AgentForm
vi.mock('@/components/automation/AgentForm', () => ({
AgentForm: () => <div data-testid="agent-form" />,
}));
// Mock Skeleton
vi.mock('@/components/ui/Skeleton', () => ({
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
}));
// Mock Card
vi.mock('@/components/ui/Card', () => ({
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
}));
// Mock EmptyState
vi.mock('@/components/ui/EmptyState', () => ({
EmptyState: ({ title, description, icon, action }: any) => (
<div data-testid="empty-state">
<h3>{title}</h3>
<p>{description}</p>
{icon}
{action}
</div>
),
}));
// Mock Badge
vi.mock('@/components/ui/Badge', () => ({
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
}));
const mockAgents = [
{
id: '1',
name: 'Test Agent',
description: 'A test agent',
llm_model: 'gpt-4',
mode: 'reactive',
is_active: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: '2',
name: 'Inactive Agent',
description: 'An inactive agent',
llm_model: 'gpt-3.5',
mode: 'scheduled',
is_active: false,
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
},
];
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>{ui}</BrowserRouter>
</QueryClientProvider>
);
}
describe('AgentDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the agent list', async () => {
const { useAgents } = await import('@/api/automation');
(useAgents as any).mockReturnValue({
data: mockAgents,
isLoading: false,
error: null,
});
renderWithProviders(<AgentDashboardPage />);
await waitFor(() => {
expect(screen.getByText('Test Agent')).toBeDefined();
expect(screen.getByText('Inactive Agent')).toBeDefined();
});
});
it('shows loading state', async () => {
const { useAgents } = await import('@/api/automation');
(useAgents as any).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
renderWithProviders(<AgentDashboardPage />);
expect(screen.getAllByTestId('skeleton').length).toBe(3);
});
it('shows empty state when no agents', async () => {
const { useAgents } = await import('@/api/automation');
(useAgents as any).mockReturnValue({
data: [],
isLoading: false,
error: null,
});
renderWithProviders(<AgentDashboardPage />);
await waitFor(() => {
expect(screen.getByText('agent.noAgents')).toBeDefined();
});
});
it('opens create form when create button is clicked', async () => {
const { useAgents } = await import('@/api/automation');
(useAgents as any).mockReturnValue({
data: mockAgents,
isLoading: false,
error: null,
});
renderWithProviders(<AgentDashboardPage />);
const createButton = screen.getByText('agent.create');
fireEvent.click(createButton);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeDefined();
});
});
it('calls execute API when execute button is clicked', async () => {
const { useAgents, useExecuteAgent } = await import('@/api/automation');
const mockExecute = vi.fn().mockResolvedValue({});
(useAgents as any).mockReturnValue({
data: mockAgents,
isLoading: false,
error: null,
});
(useExecuteAgent as any).mockReturnValue({
mutateAsync: mockExecute,
isPending: false,
});
renderWithProviders(<AgentDashboardPage />);
// Execute buttons have title="agent.execute" - find by title attribute
const executeButtons = screen.getAllByTitle('agent.execute');
expect(executeButtons.length).toBeGreaterThan(0);
fireEvent.click(executeButtons[0]);
await waitFor(() => {
expect(mockExecute).toHaveBeenCalledWith('1');
});
});
});
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AutomationDashboardPage } from '../AutomationDashboard';
// Helper to create a mock mutation result
const mockMutation = (overrides = {}) => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
isError: false,
error: null,
data: undefined,
...overrides,
});
// Mock the API hooks
vi.mock('@/api/automation', () => ({
useAutomations: vi.fn(),
useCreateAutomation: vi.fn(() => mockMutation()),
useUpdateAutomation: vi.fn(() => mockMutation()),
useDeleteAutomation: vi.fn(() => mockMutation()),
useExecuteAutomation: vi.fn(() => mockMutation()),
useDryRunAutomation: vi.fn(() => mockMutation()),
useAutomationRuns: vi.fn(),
useAutomationVersions: vi.fn(),
useRestoreAutomationVersion: vi.fn(() => mockMutation()),
}));
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}));
// Mock toast
vi.mock('@/components/ui/Toast', () => ({
useToast: () => ({
toast: { success: vi.fn(), error: vi.fn() },
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
AlertCircle: () => <div data-testid="icon-alert" />,
CheckCircle2: () => <div data-testid="icon-check-circle" />,
Clock: () => <div data-testid="icon-clock" />,
GitBranch: () => <div data-testid="icon-git-branch" />,
History: () => <div data-testid="icon-history" />,
Loader2: () => <div data-testid="icon-loader" />,
Play: () => <div data-testid="icon-play" />,
Plus: () => <div data-testid="icon-plus" />,
RotateCcw: () => <div data-testid="icon-rotate-ccw" />,
Settings2: () => <div data-testid="icon-settings2" />,
Trash2: () => <div data-testid="icon-trash" />,
Workflow: () => <div data-testid="icon-workflow" />,
XCircle: () => <div data-testid="icon-x-circle" />,
Zap: () => <div data-testid="icon-zap" />,
}));
// Mock Modal component
vi.mock('@/components/ui/Modal', () => ({
Modal: ({ open, title, children }: any) => open ? <div data-testid="modal"><h2>{title}</h2>{children}</div> : null,
}));
// Mock AutomationForm
vi.mock('@/components/automation/AutomationForm', () => ({
AutomationForm: () => <div data-testid="automation-form" />,
}));
// Mock Skeleton
vi.mock('@/components/ui/Skeleton', () => ({
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
}));
// Mock Card
vi.mock('@/components/ui/Card', () => ({
Card: ({ children, className }: any) => <div className={className}>{children}</div>,
}));
// Mock EmptyState
vi.mock('@/components/ui/EmptyState', () => ({
EmptyState: ({ title, description, icon, action }: any) => (
<div data-testid="empty-state">
<h3>{title}</h3>
<p>{description}</p>
{icon}
{action}
</div>
),
}));
// Mock Badge
vi.mock('@/components/ui/Badge', () => ({
Badge: ({ children, variant }: any) => <span data-testid="badge" data-variant={variant}>{children}</span>,
}));
const mockAutomations = [
{
id: '1',
name: 'Test Automation',
description: 'A test automation',
trigger_type: 'manual',
is_active: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: '2',
name: 'Inactive Automation',
description: 'An inactive automation',
trigger_type: 'event',
is_active: false,
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
},
];
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>{ui}</BrowserRouter>
</QueryClientProvider>
);
}
describe('AutomationDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the automation list', async () => {
const { useAutomations } = await import('@/api/automation');
(useAutomations as any).mockReturnValue({
data: mockAutomations,
isLoading: false,
error: null,
});
renderWithProviders(<AutomationDashboardPage />);
await waitFor(() => {
expect(screen.getByText('Test Automation')).toBeDefined();
expect(screen.getByText('Inactive Automation')).toBeDefined();
});
});
it('shows loading state', async () => {
const { useAutomations } = await import('@/api/automation');
(useAutomations as any).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
});
renderWithProviders(<AutomationDashboardPage />);
expect(screen.getAllByTestId('skeleton').length).toBe(3);
});
it('shows empty state when no automations', async () => {
const { useAutomations } = await import('@/api/automation');
(useAutomations as any).mockReturnValue({
data: [],
isLoading: false,
error: null,
});
renderWithProviders(<AutomationDashboardPage />);
await waitFor(() => {
expect(screen.getByText('automation.noAutomations')).toBeDefined();
});
});
it('opens create form when create button is clicked', async () => {
const { useAutomations } = await import('@/api/automation');
(useAutomations as any).mockReturnValue({
data: mockAutomations,
isLoading: false,
error: null,
});
renderWithProviders(<AutomationDashboardPage />);
const createButton = screen.getByText('automation.create');
fireEvent.click(createButton);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeDefined();
});
});
it('calls execute API when execute button is clicked', async () => {
const { useAutomations, useExecuteAutomation } = await import('@/api/automation');
const mockExecute = vi.fn().mockResolvedValue({});
(useAutomations as any).mockReturnValue({
data: mockAutomations,
isLoading: false,
error: null,
});
(useExecuteAutomation as any).mockReturnValue({
mutateAsync: mockExecute,
isPending: false,
});
renderWithProviders(<AutomationDashboardPage />);
// Execute buttons have title="automation.execute" - find by title attribute
const executeButtons = screen.getAllByTitle('automation.execute');
expect(executeButtons.length).toBeGreaterThan(0);
fireEvent.click(executeButtons[0]);
await waitFor(() => {
expect(mockExecute).toHaveBeenCalledWith('1');
});
});
});