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 ( + <> +
+ + +
+
+
+

+ {automation.name} +

+ +
+ {automation.description && ( +

{automation.description}

+ )} +
+ +
+ {canToggle && ( + + )} + {canDelete && ( + + )} +
+
+
+ + {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} +

+ +
+ ); +} 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 && ( + + )} +
+ + {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 && ( + + )} +
+
+ +
+ {(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")}`; +}