diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx new file mode 100644 index 000000000..fa1caff96 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx @@ -0,0 +1,101 @@ +"use client"; +import { ShieldAlert } from "lucide-react"; +import { useAutomations } from "@/hooks/use-automations"; +import { AutomationsEmptyState } from "./components/automations-empty-state"; +import { AutomationsHeader } from "./components/automations-header"; +import { AutomationsTable } from "./components/automations-table"; +import { useAutomationPermissions } from "./hooks/use-automation-permissions"; + +interface AutomationsContentProps { + searchSpaceId: number; +} + +/** + * Client orchestrator for the automations list page. Pulls the active + * search space's first page (via ``useAutomations`` → ``automationsListAtom``) + * and the user's permissions, then decides between empty / loading / table. + * + * Read access is mandatory; anything else is hidden behind RBAC. The + * permissions hook is co-located in this slice so adding/removing + * surfaces is a one-file change. + */ +export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) { + const { automations, total, loading, error } = useAutomations(); + const perms = useAutomationPermissions(); + + if (perms.loading) { + // Permissions gate the entire page; defer everything until we know. + return ( + <> + + + + ); + } + + if (!perms.canRead) { + return ( +
+ +

Access denied

+

+ You don't have permission to view automations in this search space. +

+
+ ); + } + + if (error) { + return ( + <> + +
+

Couldn't load automations. {error.message}

+
+ + ); + } + + if (!loading && automations.length === 0) { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx new file mode 100644 index 000000000..229a417dc --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx @@ -0,0 +1,98 @@ +"use client"; +import { useAtomValue } from "jotai"; +import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { AutomationSummary } from "@/contracts/types/automation.types"; +import { DeleteAutomationDialog } from "./delete-automation-dialog"; + +interface AutomationRowActionsProps { + automation: AutomationSummary; + searchSpaceId: number; + canUpdate: boolean; + canDelete: boolean; +} + +/** + * Three-dot menu on each row: pause/resume (if updatable) and delete + * (if deletable). The menu itself is hidden when the user has neither + * permission so we don't render an empty trigger. + */ +export function AutomationRowActions({ + automation, + searchSpaceId, + canUpdate, + canDelete, +}: AutomationRowActionsProps) { + const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue( + updateAutomationMutationAtom + ); + const [deleteOpen, setDeleteOpen] = useState(false); + + if (!canUpdate && !canDelete) return null; + + const nextStatus = automation.status === "active" ? "paused" : "active"; + const pauseLabel = automation.status === "active" ? "Pause" : "Resume"; + const PauseIcon = automation.status === "active" ? Pause : Play; + const canToggle = canUpdate && automation.status !== "archived"; + + async function handleTogglePause() { + await updateAutomation({ + automationId: automation.id, + patch: { status: nextStatus }, + }); + } + + return ( + <> + + + + + + {canToggle && ( + + + {pauseLabel} + + )} + {canToggle && canDelete && } + {canDelete && ( + setDeleteOpen(true)} + className="text-destructive focus:text-destructive" + > + + Delete + + )} + + + + {canDelete && ( + + )} + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx new file mode 100644 index 000000000..a59fb4527 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx @@ -0,0 +1,61 @@ +"use client"; +import Link from "next/link"; +import { TableCell, TableRow } from "@/components/ui/table"; +import type { AutomationSummary } from "@/contracts/types/automation.types"; +import { formatRelativeDate } from "@/lib/format-date"; +import { AutomationRowActions } from "./automation-row-actions"; +import { AutomationStatusBadge } from "./automation-status-badge"; + +interface AutomationRowProps { + automation: AutomationSummary; + searchSpaceId: number; + canUpdate: boolean; + canDelete: boolean; +} + +/** + * One row in the automations table. The name links to the detail page; + * actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary + * is intentionally left to the detail page — list responses don't + * include triggers and we want to avoid N+1 detail fetches. + */ +export function AutomationRow({ + automation, + searchSpaceId, + canUpdate, + canDelete, +}: AutomationRowProps) { + return ( + + +
+ + {automation.name} + + {automation.description && ( + + {automation.description} + + )} +
+
+ + + + + {formatRelativeDate(automation.updated_at)} + + + + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx new file mode 100644 index 000000000..ecf171e78 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx @@ -0,0 +1,49 @@ +"use client"; +import { Archive, CircleDot, Pause } from "lucide-react"; +import type { AutomationStatus } from "@/contracts/types/automation.types"; +import { cn } from "@/lib/utils"; + +interface AutomationStatusBadgeProps { + status: AutomationStatus; + className?: string; +} + +// Color + icon per status. Active = green, paused = amber, archived = muted. +const STATUS_STYLES: Record< + AutomationStatus, + { label: string; icon: typeof CircleDot; classes: string } +> = { + active: { + label: "Active", + icon: CircleDot, + classes: + "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50", + }, + paused: { + label: "Paused", + icon: Pause, + classes: + "bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50", + }, + archived: { + label: "Archived", + icon: Archive, + classes: "bg-muted text-muted-foreground border border-border/60", + }, +}; + +export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) { + const { label, icon: Icon, classes } = STATUS_STYLES[status]; + return ( + + + {label} + + ); +} 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 new file mode 100644 index 000000000..ac27b01e2 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx @@ -0,0 +1,120 @@ +"use client"; +import { CalendarClock, Pause } from "lucide-react"; +import type { Trigger } from "@/contracts/types/automation.types"; + +interface AutomationTriggersSummaryProps { + triggers: Trigger[]; +} + +/** + * One-line summary of an automation's triggers for the list view. + * + * v1 only registers ``schedule`` so this stays compact: + * - 0 triggers → "No triggers" + * - 1 schedule trigger → "Mon–Fri at 09:00 · UTC" + disabled badge if off + * - >1 → "N triggers" + * + * The detail page renders the full per-trigger editor. + */ +export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) { + if (triggers.length === 0) { + return No triggers; + } + + if (triggers.length > 1) { + return {triggers.length} triggers; + } + + const [trigger] = triggers; + + if (trigger.type === "schedule") { + 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) : "Schedule"; + + return ( + + + {human} + · {tz} + {!trigger.enabled && ( + + + Off + + )} + + ); + } + + 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/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx new file mode 100644 index 000000000..4004cce9b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx @@ -0,0 +1,42 @@ +"use client"; +import { MessageSquarePlus, Workflow } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface AutomationsEmptyStateProps { + searchSpaceId: number; + canCreate: boolean; +} + +/** + * Zero-state for the automations list. The primary CTA points to a new + * chat — creation happens via the ``create_automation`` HITL tool, not a + * "new automation" form. We surface the chat path explicitly so users + * don't go hunting for an "add" button that doesn't exist. + */ +export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) { + return ( +
+
+ +
+

No automations yet

+

+ Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and + SurfSense drafts the automation for your approval. +

+ {canCreate ? ( + + ) : ( +

+ You don't have permission to create automations in this search space. +

+ )} +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx new file mode 100644 index 000000000..b938825a6 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx @@ -0,0 +1,44 @@ +"use client"; +import { MessageSquarePlus } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +interface AutomationsHeaderProps { + searchSpaceId: number; + total: number; + loading: boolean; + canCreate: boolean; +} + +/** + * Page header: title + count + "Create via chat" CTA. Creation is intent-driven + * (the create_automation tool runs inside chat with a HITL approval card), so + * the CTA links to a new chat rather than opening a form. + */ +export function AutomationsHeader({ + searchSpaceId, + total, + loading, + canCreate, +}: AutomationsHeaderProps) { + return ( +
+
+

Automations

+ {!loading && ( + + {total} {total === 1 ? "automation" : "automations"} + + )} +
+ {canCreate && ( + + )} +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx new file mode 100644 index 000000000..1156be3f6 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx @@ -0,0 +1,36 @@ +"use client"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TableCell, TableRow } from "@/components/ui/table"; + +const ROW_KEYS = ["sk-1", "sk-2", "sk-3"]; + +/** + * Skeleton rows for the automations table. Number of rows is fixed since + * we don't know the count ahead of time and three placeholders is enough + * to communicate "loading" without flashing too much chrome. + */ +export function AutomationsLoadingRows() { + return ( + <> + {ROW_KEYS.map((key) => ( + + +
+ + +
+
+ + + + + + + + + +
+ ))} + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx new file mode 100644 index 000000000..ec3aeeef5 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx @@ -0,0 +1,73 @@ +"use client"; +import { Activity, CalendarDays, Workflow } from "lucide-react"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { AutomationSummary } from "@/contracts/types/automation.types"; +import { AutomationRow } from "./automation-row"; +import { AutomationsLoadingRows } from "./automations-loading"; + +interface AutomationsTableProps { + automations: AutomationSummary[]; + searchSpaceId: number; + loading: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +/** + * Table shell + header. Rows render below — loading state renders skeleton + * rows in the same shell so the layout doesn't shift on data arrival. + */ +export function AutomationsTable({ + automations, + searchSpaceId, + loading, + canUpdate, + canDelete, +}: AutomationsTableProps) { + return ( +
+ + + + + + + Name + + + + + + Status + + + + + + Updated + + + + Actions + + + + + {loading ? ( + + ) : ( + automations.map((automation) => ( + + )) + )} + +
+
+ ); +} 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 new file mode 100644 index 000000000..db73ddad5 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx @@ -0,0 +1,80 @@ +"use client"; +import { useAtomValue } from "jotai"; +import { useState } from "react"; +import { deleteAutomationMutationAtom } 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 DeleteAutomationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + automationId: number; + automationName: string; + searchSpaceId: number; +} + +/** + * Confirm + delete one automation. FK cascade on the backend wipes attached + * triggers and runs, so we mention it explicitly. List re-fetch is handled + * by the mutation atom's onSuccess. + */ +export function DeleteAutomationDialog({ + open, + onOpenChange, + automationId, + automationName, + searchSpaceId, +}: DeleteAutomationDialogProps) { + const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom); + const [submitting, setSubmitting] = useState(false); + + async function handleConfirm() { + setSubmitting(true); + try { + await deleteAutomation({ automationId, searchSpaceId }); + onOpenChange(false); + } finally { + setSubmitting(false); + } + } + + return ( + + + + Delete this automation? + + {automationName} and all of its + triggers and run history will be removed. This cannot be undone. + + + + Cancel + + {submitting ? ( + + + Deleting… + + ) : ( + "Delete" + )} + + + + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts b/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts new file mode 100644 index 000000000..293688710 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts @@ -0,0 +1,37 @@ +"use client"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms"; + +/** + * Centralized RBAC gates for the automations slice. Co-located with the + * route so adding/removing surfaces stays a one-file change. Backed by + * the same ``myAccessAtom`` the rest of the app uses; owners short-circuit + * to ``true`` for every action. + * + * Mirrors backend permissions in ``app.db.permissions`` (automations:*). + */ +export interface AutomationPermissions { + loading: boolean; + canCreate: boolean; + canRead: boolean; + canUpdate: boolean; + canDelete: boolean; + canExecute: boolean; +} + +export function useAutomationPermissions(): AutomationPermissions { + const { data: access, isLoading } = useAtomValue(myAccessAtom); + + return useMemo( + () => ({ + loading: isLoading, + canCreate: canPerform(access, "automations:create"), + canRead: canPerform(access, "automations:read"), + canUpdate: canPerform(access, "automations:update"), + canDelete: canPerform(access, "automations:delete"), + canExecute: canPerform(access, "automations:execute"), + }), + [access, isLoading] + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx new file mode 100644 index 000000000..b77cb20f4 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx @@ -0,0 +1,15 @@ +import { AutomationsContent } from "./automations-content"; + +export default async function AutomationsPage({ + params, +}: { + params: Promise<{ search_space_id: string }>; +}) { + const { search_space_id } = await params; + + return ( +
+ +
+ ); +}