5dc6f29ac1
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
778 lines
26 KiB
TypeScript
778 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|