import { useCallback, useEffect, useMemo, useState } from "react" import { CalendarClock, CheckCircle2, Clock, History, Pause, Play, Plus, RefreshCw, Trash2, Zap, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select" import { Switch } from "~/components/ui/switch" import { Textarea } from "~/components/ui/textarea" import { createScheduledTask, deleteScheduledTask, disableScheduledTask, enableScheduledTask, listScheduledTasks, listTaskRuns, triggerScheduledTask, updateScheduledTask, type ScheduledTask, type ScheduledTaskAction, type ScheduledTaskInput, type TaskRun, } from "~/lib/arcadia/scheduled-tasks" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Scheduled tasks") type EditorState = | { mode: "create" } | { mode: "edit"; task: ScheduledTask } | null export default function ScheduledTasksRoute() { const session = useSession() const arcadia = useArcadiaClient() const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [search, setSearch] = useState("") const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [runsFor, setRunsFor] = useState(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { setTasks(await listScheduledTasks(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load scheduled tasks.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) const columns = useMemo[]>( () => [ { id: "name", header: "Name", accessor: "name", sortable: true, cell: (t) => (
{t.name} {t.description ? ( {t.description} ) : null}
), }, { id: "cron", header: "Schedule", accessor: "cron_expression", sortable: true, cell: (t) => (
{t.cron_expression} {t.timezone}
), }, { id: "action", header: "Action", accessor: "action_type", sortable: true, cell: (t) => ( {t.action_type} ), }, { id: "status", header: "Status", accessor: "enabled", sortable: true, cell: (t) => ( ), }, { id: "last", header: "Last run", accessor: "last_run_at", sortable: true, cell: (t) => t.last_run_at ? ( ) : ( never ), }, { id: "next", header: "Next run", accessor: "next_run_at", sortable: true, cell: (t) => t.enabled && t.next_run_at ? ( ) : ( ), }, { id: "actions", header: "", align: "right", cell: (t) => ( ), }, ], [arcadia, refresh], ) const summary = useMemo( () => ({ total: tasks.length, enabled: tasks.filter((t) => t.enabled).length, byAction: countBy(tasks, (t) => t.action_type), tasks: tasks.map((t) => ({ name: t.name, cron: t.cron_expression, timezone: t.timezone, action_type: t.action_type, enabled: t.enabled, last_run_at: t.last_run_at, next_run_at: t.next_run_at, })), }), [tasks], ) useRegisterContext("scheduled_tasks", summary) const table = useTable({ data: tasks, columns, getRowId: (t) => t.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Scheduled tasks

Cron-driven jobs run by arcadia. Trigger a task manually to test it without waiting for the next scheduled run.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null}
{table.total} of {tasks.length}
{table.total === 0 && !loading ? ( } title={search ? "No tasks match." : "No scheduled tasks yet."} description={ search ? "Try a different search." : "Schedule a recurring webhook or platform event." } className="py-12" /> ) : ( <> t.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && tasks.length > 0} stickyHeader /> )}
!o && setPendingDelete(null)} title="Delete scheduled task?" description={ pendingDelete ? `${pendingDelete.name} will be permanently removed. Run history is retained.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteScheduledTask(arcadia, pendingDelete.id) setPendingDelete(null) setInfo("Task deleted.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> setEditor(null)} onSaved={async () => { setEditor(null) await refresh() }} onError={setError} /> setRunsFor(null)} onError={setError} />
) } function rowActions( t: ScheduledTask, ctx: { arcadia: ReturnType refresh: () => Promise setEditor: (s: EditorState) => void setPendingDelete: (t: ScheduledTask | null) => void setRunsFor: (t: ScheduledTask | null) => void setError: (m: string | null) => void setInfo: (m: string | null) => void }, ): ActionItem[] { const { arcadia, refresh, setEditor, setPendingDelete, setRunsFor, setError, setInfo } = ctx const items: ActionItem[] = [] items.push({ id: "trigger", label: "Run now", icon: , dataAction: `task-${t.id}-trigger`, onSelect: async () => { try { await triggerScheduledTask(arcadia, t.id) setInfo(`${t.name} triggered. Check the run log for status.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Trigger failed.") } }, }) items.push({ id: "edit", label: "Edit", dataAction: `task-${t.id}-edit`, onSelect: () => setEditor({ mode: "edit", task: t }), }) items.push({ id: "runs", label: "View runs", icon: , dataAction: `task-${t.id}-runs`, onSelect: () => setRunsFor(t), }) if (t.enabled) { items.push({ id: "disable", label: "Disable", icon: , dataAction: `task-${t.id}-disable`, onSelect: async () => { try { await disableScheduledTask(arcadia, t.id) setInfo(`${t.name} disabled.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Disable failed.") } }, }) } else { items.push({ id: "enable", label: "Enable", icon: , dataAction: `task-${t.id}-enable`, onSelect: async () => { try { await enableScheduledTask(arcadia, t.id) setInfo(`${t.name} enabled.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Enable failed.") } }, }) } items.push({ id: "delete", label: "Delete", icon: , destructive: true, dataAction: `task-${t.id}-delete`, onSelect: () => setPendingDelete(t), }) return items } function TaskEditorDialog({ state, onClose, onSaved, onError, }: { state: EditorState onClose: () => void onSaved: () => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.mode === "edit" const initial = isEdit ? state.task : null const [name, setName] = useState("") const [description, setDescription] = useState("") const [cron, setCron] = useState("") const [timezone, setTimezone] = useState("UTC") const [actionType, setActionType] = useState("event") const [configText, setConfigText] = useState("{}") const [tagsText, setTagsText] = useState("") const [enabled, setEnabled] = useState(true) const [maxRetries, setMaxRetries] = useState("3") const [timeoutSeconds, setTimeoutSeconds] = useState("30") const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setName(initial.name) setDescription(initial.description ?? "") setCron(initial.cron_expression) setTimezone(initial.timezone) setActionType(initial.action_type) setConfigText( initial.action_config ? JSON.stringify(initial.action_config, null, 2) : "{}", ) setTagsText(initial.tags.join(", ")) setEnabled(initial.enabled) setMaxRetries(String(initial.max_retries)) setTimeoutSeconds(String(initial.timeout_seconds)) } else { setName("") setDescription("") setCron("0 * * * *") setTimezone("UTC") setActionType("event") setConfigText('{\n "event": "platform.heartbeat"\n}') setTagsText("") setEnabled(true) setMaxRetries("3") setTimeoutSeconds("30") } }, [open, initial]) const submit = async () => { onError(null) setSaving(true) try { let parsedConfig: Record try { parsedConfig = configText.trim() === "" ? {} : JSON.parse(configText) } catch { throw new Error("Action config must be valid JSON.") } const tags = tagsText .split(",") .map((s) => s.trim()) .filter(Boolean) const input: ScheduledTaskInput = { name, description: description || null, cron_expression: cron, timezone, action_type: actionType, action_config: parsedConfig, tags, enabled, max_retries: Math.max(0, Number(maxRetries) || 0), timeout_seconds: Math.max(1, Number(timeoutSeconds) || 30), } if (isEdit && initial) await updateScheduledTask(arcadia, initial.id, input) else await createScheduledTask(arcadia, input) await onSaved() } catch (err) { onError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit scheduled task" : "New scheduled task"} Cron uses standard 5-field syntax (minute hour dom month dow). Tasks run in the specified timezone.
setName(e.target.value)} placeholder="Daily cleanup" data-action="task-form-name" />
setDescription(e.target.value)} data-action="task-form-description" />
setCron(e.target.value)} placeholder="0 2 * * *" data-action="task-form-cron" spellCheck={false} className="font-mono" />
setTimezone(e.target.value)} placeholder="UTC" data-action="task-form-timezone" />
setTagsText(e.target.value)} placeholder="cleanup, nightly" data-action="task-form-tags" />