diff --git a/client/src/app/actions/page.tsx b/client/src/app/actions/page.tsx new file mode 100644 index 000000000..a2e9dbb0b --- /dev/null +++ b/client/src/app/actions/page.tsx @@ -0,0 +1,683 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { + Play, + Plus, + ChevronRight, + Clock, + CheckCircle2, + XCircle, + ArrowLeft, + Loader2, + Workflow, + AlertTriangle, + Hash, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useQuery, fetchR } from '@/lib/query'; +import { useToast } from '@/hooks/use-toast'; +import { formatTimeAgo } from '@/lib/utils/time-format'; + +interface Action { + id: string; + name: string; + description: string; + instructions: string; + trigger_type: 'on_incident' | 'manual' | 'on_schedule'; + trigger_config: Record; + mode: 'agent' | 'ask'; + enabled: boolean; + run_count: number; + last_run_at: string | null; + last_run_status: 'success' | 'error' | 'running' | null; +} + +interface ActionRun { + id: string; + status: 'success' | 'error' | 'running' | 'pending'; + trigger_context: Record; + started_at: string; + completed_at: string | null; + duration_ms?: number; + chat_session_id: string | null; + incident_id: string | null; + error: string | null; +} + +interface ActionDetail extends Action { + created_at: string; + updated_at: string; +} + +const actionsFetcher = async (key: string, signal: AbortSignal) => { + const res = await fetch(key, { credentials: 'include', signal }); + if (!res.ok) throw new Error(`Failed to load actions: ${res.status}`); + const data = await res.json(); + return data.actions || []; +}; + +const actionDetailFetcher = async (key: string, signal: AbortSignal) => { + const res = await fetch(key, { credentials: 'include', signal }); + if (!res.ok) throw new Error(`Failed to load action: ${res.status}`); + return res.json(); +}; + +// -- Shared style primitives (matching monitor page) -- + +function getTriggerDescription(type: string): string { + if (type === 'on_incident') return 'Fires automatically when a new incident comes in.'; + if (type === 'on_schedule') return 'Runs automatically on a recurring interval.'; + return 'Only runs when triggered from the Actions page or Incident Detail page.'; +} + +function StatCard({ label, value, sub, icon: Icon }: { + readonly label: string; + readonly value: string; + readonly sub?: string; + readonly icon?: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ {Icon && } + {label} +
+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +function Panel({ title, subtitle, children }: { + readonly title: string; + readonly subtitle?: string; + readonly children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ); +} + +function TriggerBadge({ type }: { readonly type: Action['trigger_type'] }) { + const styles: Record = { + on_incident: 'bg-blue-500/10 text-blue-400', + manual: 'bg-zinc-500/10 text-zinc-400', + on_schedule: 'bg-purple-500/10 text-purple-400', + }; + const labels: Record = { + on_incident: 'On Incident', + manual: 'Manual', + on_schedule: 'Scheduled', + }; + return ( + + {labels[type] || type} + + ); +} + +function ModeBadge({ mode }: { readonly mode: Action['mode'] }) { + return mode === 'agent' + ? Read-Write + : Read-Only; +} + +function StatusDot({ status }: { readonly status: ActionRun['status'] }) { + switch (status) { + case 'success': return ; + case 'error': return ; + case 'running': return ; + default: return ; + } +} + +// -- List view -- + +function ActionsListView({ actions, onSelect, onCreate }: { + readonly actions: Action[]; + readonly onSelect: (a: Action) => void; + readonly onCreate: () => void; +}) { + const active = actions.filter(a => a.enabled).length; + const totalRuns = actions.reduce((s, a) => s + (a.run_count || 0), 0); + + return ( +
+
+ + + +
+ + + {actions.length === 0 ? ( +
+ +

No actions yet

+

Create your first action to automate SRE workflows

+ +
+ ) : ( +
+ + + + + + + + + + + + + {actions.map((action) => ( + onSelect(action)} + className="border-b border-zinc-800/40 hover:bg-zinc-800/20 transition-colors duration-150 cursor-pointer" + > + + + + + + + + ))} + +
NameTriggerModeRunsLast Run
+
+ {action.name} + {!action.enabled && ( + OFF + )} +
+ {action.description && ( +

{action.description}

+ )} +
+ {action.run_count || 0} + + {action.last_run_at ? ( +
+ + {formatTimeAgo(action.last_run_at)} +
+ ) : ( + Never + )} +
+ +
+
+ )} +
+
+ ); +} + +// -- Detail view -- + +function ActionDetailView({ actionId, onBack, onEdit }: { readonly actionId: string; readonly onBack: () => void; readonly onEdit: () => void }) { + const { toast } = useToast(); + const { data, mutate } = useQuery<{ action: ActionDetail; recent_runs: ActionRun[] }>( + `/api/actions/${actionId}`, actionDetailFetcher, { staleTime: 5_000 } + ); + + const action = data?.action; + const runs = data?.recent_runs || []; + + const hasActiveRuns = useMemo( + () => runs.some(r => r.status === 'pending' || r.status === 'running'), + [runs], + ); + + // Poll while runs are in-flight so the UI updates when they finish + useQuery<{ action: ActionDetail; recent_runs: ActionRun[] }>( + hasActiveRuns ? `/api/actions/${actionId}` : null, + actionDetailFetcher, + { refreshInterval: 3_000, staleTime: 2_000 }, + ); + + const handleToggle = useCallback(async (enabled: boolean) => { + try { + await fetchR(`/api/actions/${actionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + mutate(); + } catch { + toast({ title: 'Failed to update action', variant: 'destructive' }); + } + }, [actionId, mutate, toast]); + + const handleRunNow = useCallback(async () => { + try { + await fetchR(`/api/actions/${actionId}/run`, { method: 'POST' }); + toast({ title: 'Action triggered', description: 'Background task started.' }); + mutate(); + } catch { + toast({ title: 'Failed to trigger action', variant: 'destructive' }); + } + }, [actionId, mutate, toast]); + + const handleDelete = useCallback(async () => { + if (!confirm('Delete this action? This cannot be undone.')) return; + try { + await fetchR(`/api/actions/${actionId}`, { method: 'DELETE' }); + globalThis.dispatchEvent(new Event('actionsStateChanged')); + onBack(); + } catch { + toast({ title: 'Failed to delete action', variant: 'destructive' }); + } + }, [actionId, onBack, toast]); + + if (!action) { + return
Loading...
; + } + + const succeeded = runs.filter(r => r.status === 'success').length; + const failed = runs.filter(r => r.status === 'error').length; + + return ( +
+
+ +
+
+

{action.name}

+ {action.description &&

{action.description}

} +
+
+
+ Enabled + +
+ + + +
+
+
+ +
+ + + +
+ + +
+
+ + +
+
+

Instructions

+
+ {action.instructions} +
+
+ {action.mode === 'agent' && ( +
+ + Agent mode: Aurora can execute commands, modify Terraform, and open PRs. All actions are logged. +
+ )} +
+
+ + + {runs.length === 0 ? ( +

No runs yet

+ ) : ( +
+ + + + + + + + + + + {runs.map((run) => ( + + + + + + + ))} + +
StatusStartedDuration
+
+ + {run.status} +
+ {run.error &&

{run.error}

} +
+ {run.started_at ? formatTimeAgo(run.started_at) : '-'} + + {run.duration_ms !== null && run.duration_ms !== undefined ? `${(run.duration_ms / 1000).toFixed(1)}s` : '-'} + + {run.chat_session_id && (run.status === 'success' || run.status === 'error') && ( + + View Chat + + )} +
+
+ )} +
+
+ ); +} + +// -- Form view (create + edit) -- + +function ActionFormView({ onBack, onSaved, action }: { + readonly onBack: () => void; + readonly onSaved: () => void; + readonly action?: ActionDetail; +}) { + const isEdit = !!action; + const submitLabel = isEdit ? 'Save Changes' : 'Create Action'; + const submittingLabel = isEdit ? 'Saving...' : 'Creating...'; + const [name, setName] = useState(action?.name || ''); + const [description, setDescription] = useState(action?.description || ''); + const [instructions, setInstructions] = useState(action?.instructions || ''); + const [triggerType, setTriggerType] = useState(action?.trigger_type || 'manual'); + const [mode, setMode] = useState(action?.mode || 'agent'); + const [incidentTiming, setIncidentTiming] = useState<'immediate' | 'after_rca'>( + () => (action?.trigger_config?.timing as 'immediate' | 'after_rca') || 'after_rca' + ); + const [intervalValue, setIntervalValue] = useState(() => { + const s = Number(action?.trigger_config?.interval_seconds || 3600); + if (s >= 86400 && s % 86400 === 0) return s / 86400; + if (s >= 3600 && s % 3600 === 0) return s / 3600; + return s / 60; + }); + const [intervalUnit, setIntervalUnit] = useState<'minutes' | 'hours' | 'days'>(() => { + const s = Number(action?.trigger_config?.interval_seconds || 3600); + if (s >= 86400 && s % 86400 === 0) return 'days'; + if (s >= 3600 && s % 3600 === 0) return 'hours'; + return 'minutes'; + }); + const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); + + const getIntervalSeconds = () => { + const multipliers = { minutes: 60, hours: 3600, days: 86400 }; + return Math.round(intervalValue * multipliers[intervalUnit]); + }; + const intervalTooLow = triggerType === 'on_schedule' && getIntervalSeconds() < 300; + + const handleSubmit = async () => { + setSubmitting(true); + try { + const body: Record = { + name, description: description || undefined, instructions, + trigger_type: triggerType, mode, + }; + if (triggerType === 'on_schedule') { + body.trigger_config = { interval_seconds: getIntervalSeconds() }; + } else if (triggerType === 'on_incident') { + body.trigger_config = { timing: incidentTiming }; + } else { + body.trigger_config = {}; + } + + const url = isEdit ? `/api/actions/${action.id}` : '/api/actions'; + const method = isEdit ? 'PUT' : 'POST'; + const res = await fetchR(url, { + method, headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: `Failed to ${isEdit ? 'update' : 'create'} action`, description: err.error || 'Unknown error', variant: 'destructive' }); + return; + } + toast({ title: isEdit ? 'Action updated' : 'Action created' }); + globalThis.dispatchEvent(new Event('actionsStateChanged')); + onSaved(); + } catch { + toast({ title: `Failed to ${isEdit ? 'update' : 'create'} action`, variant: 'destructive' }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ +

{isEdit ? 'Edit Action' : 'Create Action'}

+

Define natural language instructions that Aurora executes as a background agent task.

+
+ +
+
+ +
+
+ + setName(e.target.value)} placeholder="e.g. Mute noisy Datadog alerts via Terraform" /> +
+
+ + setDescription(e.target.value)} placeholder="Short summary of what this action does" /> +
+
+
+ + +
+