diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx
new file mode 100644
index 000000000..a82887721
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx
@@ -0,0 +1,86 @@
+"use client";
+import { ShieldAlert } from "lucide-react";
+import { useAutomation } from "@/hooks/use-automation";
+import { useAutomationPermissions } from "../hooks/use-automation-permissions";
+import { AutomationDefinitionSection } from "./components/automation-definition-section";
+import { AutomationDetailHeader } from "./components/automation-detail-header";
+import { AutomationDetailLoading } from "./components/automation-detail-loading";
+import { AutomationNotFound } from "./components/automation-not-found";
+import { AutomationTriggersSection } from "./components/automation-triggers-section";
+
+interface AutomationDetailContentProps {
+ searchSpaceId: number;
+ automationId: number;
+}
+
+/**
+ * Client orchestrator for one automation's detail view. Branches:
+ * - permissions loading → skeleton
+ * - no read permission → access denied panel
+ * - bad id (NaN) → not-found panel
+ * - detail fetching → skeleton
+ * - detail error / null → not-found panel (we don't distinguish 404
+ * from 403 in the UI)
+ * - detail loaded → header + definition + triggers
+ *
+ * Each child component is gated independently on the relevant permission
+ * so the orchestrator stays thin.
+ */
+export function AutomationDetailContent({
+ searchSpaceId,
+ automationId,
+}: AutomationDetailContentProps) {
+ const perms = useAutomationPermissions();
+ const validId = Number.isInteger(automationId) && automationId > 0;
+ const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
+
+ if (perms.loading) {
+ return ;
+ }
+
+ if (!perms.canRead) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to view automations in this search space.
+
+
+ );
+ }
+
+ if (!validId) {
+ return ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !automation) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx
new file mode 100644
index 000000000..9545f363b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-definition-section.tsx
@@ -0,0 +1,99 @@
+"use client";
+import { ListOrdered, Settings2, Tag, Target } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { AutomationDefinition } from "@/contracts/types/automation.types";
+import { ExecutionSummary } from "./execution-summary";
+import { InputsSchemaPreview } from "./inputs-schema-preview";
+import { PlanStepCard } from "./plan-step-card";
+
+interface AutomationDefinitionSectionProps {
+ definition: AutomationDefinition;
+}
+
+/**
+ * The Definition card. Read-only in v1 — editing definitions happens via
+ * chat (re-run create_automation with a refined intent) or, later, via
+ * the raw-JSON path. Layout is top-down:
+ * goal → tags → execution defaults → inputs schema (if any) → plan
+ *
+ * The schema_version is rendered as a small badge next to the section
+ * title so it's discoverable but doesn't fight for attention.
+ */
+export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
+ const hasTags = definition.metadata.tags.length > 0;
+ const hasInputs = !!definition.inputs;
+
+ return (
+
+
+ Definition
+
+ v{definition.schema_version}
+
+
+
+ {definition.goal && (
+
+ {definition.goal}
+
+ )}
+
+ {hasTags && (
+
+
+ {definition.metadata.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {hasInputs && (
+
+ {definition.inputs && }
+
+ )}
+
+
+
+ {definition.plan.map((step, idx) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function Field({
+ icon: Icon,
+ label,
+ children,
+}: {
+ icon: typeof Target;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx
new file mode 100644
index 000000000..4cf3efcc1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-header.tsx
@@ -0,0 +1,129 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { ArrowLeft, Pause, Play, Trash2 } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useState } from "react";
+import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import type { Automation } from "@/contracts/types/automation.types";
+import { AutomationStatusBadge } from "../../components/automation-status-badge";
+import { DeleteAutomationDialog } from "../../components/delete-automation-dialog";
+
+interface AutomationDetailHeaderProps {
+ automation: Automation;
+ searchSpaceId: number;
+ canUpdate: boolean;
+ canDelete: boolean;
+}
+
+/**
+ * Title bar for the detail page: back link, name, status badge,
+ * description, and the two destructive-ish primary actions (pause /
+ * resume + delete). Same mutation atoms as the list-row actions to
+ * keep caches coherent.
+ *
+ * Archived automations hide the pause/resume toggle (we don't unarchive
+ * here — that flow comes later if we need it).
+ */
+export function AutomationDetailHeader({
+ automation,
+ searchSpaceId,
+ canUpdate,
+ canDelete,
+}: AutomationDetailHeaderProps) {
+ const router = useRouter();
+ const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
+ updateAutomationMutationAtom
+ );
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const canToggle = canUpdate && automation.status !== "archived";
+ const nextStatus = automation.status === "active" ? "paused" : "active";
+ const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
+ const PauseIcon = automation.status === "active" ? Pause : Play;
+
+ const handleDeleted = useCallback(() => {
+ router.push(`/dashboard/${searchSpaceId}/automations`);
+ }, [router, searchSpaceId]);
+
+ async function handleTogglePause() {
+ await updateAutomation({
+ automationId: automation.id,
+ patch: { status: nextStatus },
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+ Back to automations
+
+
+
+
+
+
+
+ {automation.name}
+
+
+
+ {automation.description && (
+
{automation.description}
+ )}
+
+
+
+ {canToggle && (
+
+ {updating ? (
+
+ ) : (
+
+ )}
+ {pauseLabel}
+
+ )}
+ {canDelete && (
+
setDeleteOpen(true)}
+ className="text-destructive hover:text-destructive hover:bg-destructive/10"
+ >
+
+ Delete
+
+ )}
+
+
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx
new file mode 100644
index 000000000..1d01305ee
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-detail-loading.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * Skeleton for the detail page. Same shell as the loaded view (header +
+ * two stacked cards) so the layout doesn't jump on data arrival.
+ */
+export function AutomationDetailLoading() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx
new file mode 100644
index 000000000..1681caf25
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-not-found.tsx
@@ -0,0 +1,34 @@
+"use client";
+import { ArrowLeft, FileWarning } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+interface AutomationNotFoundProps {
+ searchSpaceId: number;
+ error?: Error | null;
+}
+
+/**
+ * Rendered when the detail fetch fails (404 / 403 / network) or the id
+ * is not a number. We don't distinguish "missing" from "forbidden" in the
+ * UI on purpose — leaking that an id exists you can't read is worse than
+ * a vague message.
+ */
+export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) {
+ return (
+
+
+
Automation not found
+
+ This automation doesn't exist or you don't have access to it.
+ {error?.message ? ` (${error.message})` : null}
+
+
+
+
+ Back to automations
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
new file mode 100644
index 000000000..8cc62f5c8
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
@@ -0,0 +1,75 @@
+"use client";
+import { CalendarClock, MessageSquarePlus } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { Trigger } from "@/contracts/types/automation.types";
+import { TriggerCard } from "./trigger-card";
+
+interface AutomationTriggersSectionProps {
+ triggers: Trigger[];
+ automationId: number;
+ searchSpaceId: number;
+ canUpdate: boolean;
+ canDelete: boolean;
+ canCreate: boolean;
+}
+
+/**
+ * The Triggers card. Lists each attached trigger with its own enable
+ * toggle and remove button. Adding a new trigger is intent-driven (via
+ * chat) for v1 — same philosophy as creating an automation, so the
+ * empty/add CTA links to a new chat rather than opening a form.
+ */
+export function AutomationTriggersSection({
+ triggers,
+ automationId,
+ searchSpaceId,
+ canUpdate,
+ canDelete,
+ canCreate,
+}: AutomationTriggersSectionProps) {
+ return (
+
+
+
+
Triggers
+
+ When this automation fires. v1 supports scheduled triggers only.
+
+
+ {canCreate && (
+
+
+
+ Add via chat
+
+
+ )}
+
+
+ {triggers.length === 0 ? (
+
+
+
No triggers attached
+
+ This automation can still be invoked, but nothing will fire it on its own.
+
+
+ ) : (
+
+ {triggers.map((trigger) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
new file mode 100644
index 000000000..71e905724
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
@@ -0,0 +1,80 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { removeTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Spinner } from "@/components/ui/spinner";
+
+interface DeleteTriggerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ automationId: number;
+ triggerId: number;
+ triggerLabel: string;
+}
+
+/**
+ * Confirm + detach one trigger from its automation. The automation itself
+ * is untouched; only this trigger row is removed. The mutation atom
+ * invalidates the parent automation detail so the page rerenders.
+ */
+export function DeleteTriggerDialog({
+ open,
+ onOpenChange,
+ automationId,
+ triggerId,
+ triggerLabel,
+}: DeleteTriggerDialogProps) {
+ const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function handleConfirm() {
+ setSubmitting(true);
+ try {
+ await removeTrigger({ automationId, triggerId });
+ onOpenChange(false);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ Remove this trigger?
+
+ {triggerLabel} will be detached.
+ The automation itself stays, but it won't fire on this trigger anymore.
+
+
+
+ Cancel
+
+ {submitting ? (
+
+
+ Removing…
+
+ ) : (
+ "Remove"
+ )}
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
new file mode 100644
index 000000000..5c4dc381c
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
@@ -0,0 +1,37 @@
+"use client";
+import type { Execution } from "@/contracts/types/automation.types";
+
+interface ExecutionSummaryProps {
+ execution: Execution;
+}
+
+/**
+ * Compact view of an automation's execution defaults (wall-clock cap,
+ * retries, backoff, concurrency, on_failure presence). Per-step overrides
+ * are shown inside each PlanStepCard, not here.
+ */
+export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
+ return (
+
+
+
+
+
+ {execution.on_failure.length > 0 && (
+
+ )}
+
+ );
+}
+
+function Item({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
new file mode 100644
index 000000000..bf2db8986
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
@@ -0,0 +1,20 @@
+"use client";
+import type { Inputs } from "@/contracts/types/automation.types";
+
+interface InputsSchemaPreviewProps {
+ inputs: Inputs;
+}
+
+/**
+ * Read-only JSON preview of an automation's accepted-inputs schema.
+ * Most automations don't define inputs (defaults are baked into the
+ * trigger's static_inputs), so the parent skips rendering this card
+ * when ``inputs`` is null.
+ */
+export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
+ return (
+
+ {JSON.stringify(inputs.schema, null, 2)}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
new file mode 100644
index 000000000..3feb77712
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
+import type { PlanStep } from "@/contracts/types/automation.types";
+
+interface PlanStepCardProps {
+ step: PlanStep;
+ index: number;
+}
+
+/**
+ * Read-only view of one plan step. Renders the step_id + action prominently,
+ * then a definition list of the per-step knobs, and finally the params as
+ * formatted JSON. Editable mode is out of scope here — definition edits live
+ * on the (future) raw-JSON path.
+ */
+export function PlanStepCard({ step, index }: PlanStepCardProps) {
+ return (
+
+
+
+ {index + 1}
+
+
{step.step_id}
+
+
{step.action}
+
+
+
+ {(step.when ||
+ step.output_as ||
+ step.max_retries != null ||
+ step.timeout_seconds != null) && (
+
+ {step.when && (
+ {step.when}} />
+ )}
+ {step.output_as && (
+ {step.output_as}}
+ />
+ )}
+ {step.max_retries != null && (
+
+ )}
+ {step.timeout_seconds != null && (
+
+ )}
+
+ )}
+
+
+
+
+ Params
+
+
+ {JSON.stringify(step.params, null, 2)}
+
+
+
+
+ );
+}
+
+function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+
{label}:
+ {value}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
new file mode 100644
index 000000000..0caaf968f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
@@ -0,0 +1,152 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { CalendarClock, Clock, Trash2 } from "lucide-react";
+import { useState } from "react";
+import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import type { Trigger } from "@/contracts/types/automation.types";
+import { formatRelativeDate } from "@/lib/format-date";
+import { describeCron } from "../../lib/describe-cron";
+import { DeleteTriggerDialog } from "./delete-trigger-dialog";
+
+interface TriggerCardProps {
+ trigger: Trigger;
+ automationId: number;
+ canUpdate: boolean;
+ canDelete: boolean;
+}
+
+/**
+ * One trigger row in the Triggers section of the detail page. Renders:
+ * - type icon + human-readable schedule + timezone
+ * - last_fired_at / next_fire_at hints
+ * - static_inputs as formatted JSON (when present)
+ * - enable toggle + remove button (each gated independently)
+ *
+ * Editing params (cron, timezone, static_inputs) lives behind the future
+ * raw-JSON path; this card stays read-only-except-for-toggle for v1.
+ */
+export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
+ const { mutateAsync: updateTrigger, isPending: updating } =
+ useAtomValue(updateTriggerMutationAtom);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
+ const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
+ const human = cron ? describeCron(cron) : trigger.type;
+ const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
+ const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
+
+ async function handleToggle(checked: boolean) {
+ await updateTrigger({
+ automationId,
+ triggerId: trigger.id,
+ patch: { enabled: checked },
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {human}
+ · {tz}
+
+ {cron &&
{cron}}
+
+
+
+
+ {canUpdate && (
+
+
+ {trigger.enabled ? "Enabled" : "Off"}
+
+
+
+ )}
+ {canDelete && (
+
setDeleteOpen(true)}
+ aria-label="Remove trigger"
+ >
+
+
+ )}
+
+
+
+
+ {(trigger.last_fired_at || trigger.next_fire_at) && (
+
+ {trigger.next_fire_at && (
+
+ )}
+ {trigger.last_fired_at && }
+
+ )}
+
+ {hasStaticInputs && (
+
+
Static inputs
+
+ {JSON.stringify(trigger.static_inputs, null, 2)}
+
+
+ )}
+
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
+
+function TimeRow({
+ label,
+ iso,
+ highlight = false,
+}: {
+ label: string;
+ iso: string;
+ highlight?: boolean;
+}) {
+ return (
+
+
+
+ {label}:
+
+
+ {formatRelativeDate(iso)}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx
new file mode 100644
index 000000000..dbaceecdd
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/page.tsx
@@ -0,0 +1,18 @@
+import { AutomationDetailContent } from "./automation-detail-content";
+
+export default async function AutomationDetailPage({
+ params,
+}: {
+ params: Promise<{ search_space_id: string; automation_id: string }>;
+}) {
+ const { search_space_id, automation_id } = await params;
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
index ac27b01e2..8b61a1e02 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
@@ -1,6 +1,7 @@
"use client";
import { CalendarClock, Pause } from "lucide-react";
import type { Trigger } from "@/contracts/types/automation.types";
+import { describeCron } from "../lib/describe-cron";
interface AutomationTriggersSummaryProps {
triggers: Trigger[];
@@ -49,72 +50,3 @@ export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummar
return {trigger.type} ;
}
-
-// ----------------------------------------------------------------------------
-// Minimal cron describer for the common 5-field patterns SurfSense automations
-// surface today. Falls back to the raw expression when unrecognized so the user
-// still sees something honest instead of a guess.
-//
-// Kept inline (not a library) because:
-// - v1 only needs to recognize a small set of patterns produced by the
-// drafter LLM (hourly/daily/weekdays/weekly/monthly).
-// - All current consumers live in this slice. If reuse grows, lift to
-// ``lib/cron-describe.ts``.
-// ----------------------------------------------------------------------------
-
-const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
-
-function describeCron(cron: string): string {
- const parts = cron.trim().split(/\s+/);
- if (parts.length !== 5) return cron;
-
- const [minute, hour, dom, month, dow] = parts;
-
- // Daily at H:MM (matches the very common "0 9 * * *")
- if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
- return `Daily at ${formatTime(hour, minute)}`;
- }
-
- // Weekdays at H:MM ("0 9 * * 1-5")
- if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
- return `Mon–Fri at ${formatTime(hour, minute)}`;
- }
-
- // Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
- if (
- month === "*" &&
- dom === "*" &&
- /^\d+$/.test(minute) &&
- /^\d+$/.test(hour) &&
- /^[\d,]+$/.test(dow)
- ) {
- const days = dow
- .split(",")
- .map((d) => DAY_NAMES[Number(d) % 7])
- .filter(Boolean)
- .join(", ");
- if (days) return `${days} at ${formatTime(hour, minute)}`;
- }
-
- // Monthly on day N ("0 9 1 * *")
- if (
- month === "*" &&
- dow === "*" &&
- /^\d+$/.test(dom) &&
- /^\d+$/.test(hour) &&
- /^\d+$/.test(minute)
- ) {
- return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
- }
-
- // Hourly ("0 * * * *")
- if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
- return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
- }
-
- return cron;
-}
-
-function formatTime(hour: string, minute: string): string {
- return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
index db73ddad5..23fc522ca 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
@@ -20,6 +20,12 @@ interface DeleteAutomationDialogProps {
automationId: number;
automationName: string;
searchSpaceId: number;
+ /**
+ * Fired after a successful delete, before the dialog closes. The detail
+ * page uses this to navigate back to the list (the row simply vanishes
+ * on the list page so no callback is needed there).
+ */
+ onDeleted?: () => void;
}
/**
@@ -33,6 +39,7 @@ export function DeleteAutomationDialog({
automationId,
automationName,
searchSpaceId,
+ onDeleted,
}: DeleteAutomationDialogProps) {
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
const [submitting, setSubmitting] = useState(false);
@@ -41,6 +48,7 @@ export function DeleteAutomationDialog({
setSubmitting(true);
try {
await deleteAutomation({ automationId, searchSpaceId });
+ onDeleted?.();
onOpenChange(false);
} finally {
setSubmitting(false);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts b/surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts
new file mode 100644
index 000000000..e10a99a44
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts
@@ -0,0 +1,66 @@
+/**
+ * Minimal cron describer for the 5-field patterns the SurfSense drafter LLM
+ * actually produces (daily, weekdays, weekly, monthly, hourly). Falls back
+ * to the raw expression when unrecognized so the user still sees something
+ * honest instead of a guess.
+ *
+ * Lives in the automations slice because it's a UI display concern with no
+ * consumers outside it. If reuse grows, lift to ``lib/cron-describe.ts``.
+ */
+
+const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+export function describeCron(cron: string): string {
+ const parts = cron.trim().split(/\s+/);
+ if (parts.length !== 5) return cron;
+
+ const [minute, hour, dom, month, dow] = parts;
+
+ // Daily at H:MM ("0 9 * * *")
+ if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
+ return `Daily at ${formatTime(hour, minute)}`;
+ }
+
+ // Weekdays at H:MM ("0 9 * * 1-5")
+ if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
+ return `Mon–Fri at ${formatTime(hour, minute)}`;
+ }
+
+ // Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
+ if (
+ month === "*" &&
+ dom === "*" &&
+ /^\d+$/.test(minute) &&
+ /^\d+$/.test(hour) &&
+ /^[\d,]+$/.test(dow)
+ ) {
+ const days = dow
+ .split(",")
+ .map((d) => DAY_NAMES[Number(d) % 7])
+ .filter(Boolean)
+ .join(", ");
+ if (days) return `${days} at ${formatTime(hour, minute)}`;
+ }
+
+ // Monthly on day N ("0 9 1 * *")
+ if (
+ month === "*" &&
+ dow === "*" &&
+ /^\d+$/.test(dom) &&
+ /^\d+$/.test(hour) &&
+ /^\d+$/.test(minute)
+ ) {
+ return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
+ }
+
+ // Hourly ("0 * * * *")
+ if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
+ return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
+ }
+
+ return cron;
+}
+
+function formatTime(hour: string, minute: string): string {
+ return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
+}