import { getErrorMessage } from "@/lib/error-message.js"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { ArrowLeft, Copy, FlaskConical, Loader2, Play, Plus, Trash2, Workflow, } from "lucide-react"; import { Button } from "@/components/button"; import { Badge } from "@/components/badge"; import { createAutomation, deleteAutomation, listAutomationRunSteps, listAutomationRuns, listAutomations, runAutomation, updateAutomation, type AutomationRow, type AutomationRunRow, type AutomationRunStepRow, } from "./automations-api"; import { deleteNotificationChannel, getNotificationChannels, testNotificationChannel, type NotificationChannel, } from "@/api/alerts-api"; import { NotificationChannelDialog } from "@/sidebar/NotificationChannelDialog"; import { getSnippets } from "@/api/snippets-api"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import { listFleets } from "@/api/fleets-api"; import { AutomationEditor, emptyDraft, type AutomationDraft, } from "./automations/AutomationEditor"; import { EMPTY_EDITOR_OPTIONS, type AutomationEditorOptions, } from "./automations/editor-types"; type PanelTab = "automations" | "runs" | "channels"; const STATUS_CLASS: Record = { success: "text-green-500", failed: "text-destructive", timeout: "text-destructive", running: "text-foreground", skipped: "text-muted-foreground", cancelled: "text-muted-foreground", }; function timeAgo(iso: string | null): string { if (!iso) return ""; const ms = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(ms)) return ""; const sec = Math.floor(ms / 1000); if (sec < 60) return `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; return `${Math.floor(hr / 24)}d ago`; } export function AutomationsPanel({ active = true, onEditingChange, }: { active?: boolean; onEditingChange?: (editing: boolean) => void; }) { const { t } = useTranslation(); const base = "newUi.sidebar.automations"; const [tab, setTab] = useState("automations"); const [automations, setAutomations] = useState([]); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(false); const [options, setOptions] = useState(EMPTY_EDITOR_OPTIONS); const [editorOpen, setEditorOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(emptyDraft()); const [saving, setSaving] = useState(false); const [webhookToken, setWebhookToken] = useState(null); const [expandedRun, setExpandedRun] = useState(null); const [runSteps, setRunSteps] = useState([]); const [channels, setChannels] = useState([]); const [channelDialogOpen, setChannelDialogOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); const loadAutomations = useCallback(async () => { setLoading(true); try { setAutomations(await listAutomations()); } catch { // A failed load leaves the previous list on screen. } finally { setLoading(false); } }, []); const loadRuns = useCallback(async () => { try { setRuns(await listAutomationRuns({ limit: 100 })); } catch { // Non-fatal. } }, []); const loadOptions = useCallback(async () => { const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([ getSSHHosts({ includeStatus: false }), getSnippets(), getNotificationChannels(), listFleets(), ]); if (channelsResult.status === "fulfilled") { setChannels(channelsResult.value); } setOptions({ hosts: hosts.status === "fulfilled" ? hosts.value.map((host) => ({ id: host.id, name: host.name || host.ip, })) : [], snippets: snippets.status === "fulfilled" ? snippets.value.map((s) => ({ id: s.id, // SnippetRow only types its id; the rest is narrowed by callers. name: typeof s.name === "string" ? s.name : `#${s.id}`, })) : [], channels: channelsResult.status === "fulfilled" ? channelsResult.value.map((c) => ({ id: c.id, name: c.name })) : [], fleets: fleets.status === "fulfilled" ? fleets.value.map((f) => ({ id: f.id, name: f.name })) : [], }); }, []); useEffect(() => { if (!active) return; void loadAutomations(); void loadOptions(); }, [active, loadAutomations, loadOptions]); useEffect(() => { if (active && tab === "runs") void loadRuns(); }, [active, tab, loadRuns]); // Widens the sidebar while the editor is open, and releases it again on the // way out or when the user switches to another rail destination. useEffect(() => { if (active) onEditingChange?.(editorOpen); }, [active, editorOpen, onEditingChange]); useEffect(() => { return () => onEditingChange?.(false); }, [onEditingChange]); function closeEditor() { setWebhookToken(null); setEditorOpen(false); } function openCreate() { setEditingId(null); setDraft(emptyDraft()); setWebhookToken(null); setEditorOpen(true); } function openEdit(row: AutomationRow) { setEditingId(row.id); setDraft({ name: row.name, description: row.description ?? "", enabled: !!row.enabled, concurrencyPolicy: row.concurrency_policy ?? "skip", definition: row.definition ?? emptyDraft().definition, }); setWebhookToken(null); setEditorOpen(true); } async function save() { if (!draft.name.trim()) { toast.error(t(`${base}.nameRequired`)); return; } setSaving(true); try { if (editingId === null) { const created = await createAutomation({ name: draft.name.trim(), description: draft.description || null, enabled: draft.enabled, concurrencyPolicy: draft.concurrencyPolicy, definition: draft.definition, }); toast.success(t(`${base}.created`)); if (created.webhookToken) { setWebhookToken(created.webhookToken); } else { setEditorOpen(false); } } else { await updateAutomation(editingId, { name: draft.name.trim(), description: draft.description || null, enabled: draft.enabled, concurrencyPolicy: draft.concurrencyPolicy, definition: draft.definition, }); toast.success(t(`${base}.updated`)); setEditorOpen(false); } await loadAutomations(); } catch (error) { toast.error(getErrorMessage(error, String(error))); } finally { setSaving(false); } } async function remove(row: AutomationRow) { if (!confirm(t(`${base}.deleteConfirm`))) return; try { await deleteAutomation(row.id); toast.success(t(`${base}.deleted`)); await loadAutomations(); } catch (error) { toast.error(getErrorMessage(error, String(error))); } } async function run(row: AutomationRow, dryRun: boolean) { try { const outcome = await runAutomation(row.id, { dryRun }); toast.success(t(`${base}.runFinished`, { status: outcome.status })); await loadAutomations(); if (tab === "runs") await loadRuns(); } catch (error) { toast.error(getErrorMessage(error, String(error))); } } async function testChannel(channel: NotificationChannel) { try { // Throws with the reason when delivery fails. await testNotificationChannel(channel.id); toast.success(t(`${base}.channelTestSent`)); } catch (error) { toast.error(getErrorMessage(error, String(error))); } } async function removeChannel(channel: NotificationChannel) { if (!confirm(t(`${base}.deleteChannelConfirm`))) return; try { await deleteNotificationChannel(channel.id); toast.success(t(`${base}.channelDeleted`)); await loadOptions(); } catch (error) { toast.error(getErrorMessage(error, String(error))); } } async function toggleRun(runId: number) { if (expandedRun === runId) { setExpandedRun(null); return; } setExpandedRun(runId); try { setRunSteps(await listAutomationRunSteps(runId)); } catch { setRunSteps([]); } } // Editing takes over the whole panel and widens the sidebar, the same way the // host manager does, rather than opening a dialog over the app. if (editorOpen) { const webhookUrl = webhookToken ? `${window.location.origin}/automations/webhook/${webhookToken}` : ""; return (
{webhookToken ? (

{t(`${base}.webhookTokenTitle`)}

{t(`${base}.webhookTokenDescription`)}

{webhookUrl}
) : ( )}
{webhookToken ? ( ) : ( <> )}
); } return (
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => ( ))} {t("hosts.docsLink")}
{tab === "automations" && ( <> {loading && automations.length === 0 && (
)} {!loading && automations.length === 0 && (

{t(`${base}.empty`)}

)}
{automations.map((row) => (
{!row.enabled && ( off )}
{row.definition ? t( `${base}.triggerKinds.${row.definition.trigger.kind}`, ) : ""} {row.last_run_status ? ` · ${timeAgo(row.last_run_at)}` : ""}
))}
)} {tab === "runs" && (
{runs.length === 0 && (

{t(`${base}.emptyRuns`)}

)} {runs.map((entry) => (
{expandedRun === entry.id && (
{runSteps.map((step) => (
{step.step_index + 1}. {t(`${base}.stepTypes.${step.step_type}`, { defaultValue: step.step_type, })} {step.status}
{step.output && (
                            {step.output}
                          
)} {step.error && (
{step.error}
)}
))}
)}
))}
)} {tab === "channels" && ( <> {channels.length === 0 && (

{t(`${base}.emptyChannels`)}

)}
{channels.map((channel) => (
{channel.type}
))}
)}
{ setChannelDialogOpen(false); void loadOptions(); }} />
); } export default AutomationsPanel;