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 index a82887721..253d6ae67 100644 --- 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 @@ -6,6 +6,7 @@ import { AutomationDefinitionSection } from "./components/automation-definition- import { AutomationDetailHeader } from "./components/automation-detail-header"; import { AutomationDetailLoading } from "./components/automation-detail-loading"; import { AutomationNotFound } from "./components/automation-not-found"; +import { AutomationRunsSection } from "./components/automation-runs-section"; import { AutomationTriggersSection } from "./components/automation-triggers-section"; interface AutomationDetailContentProps { @@ -81,6 +82,8 @@ export function AutomationDetailContent({ canDelete={perms.canDelete} canCreate={perms.canCreate} /> + + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx new file mode 100644 index 000000000..b6158cab2 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-runs-section.tsx @@ -0,0 +1,67 @@ +"use client"; +import { History } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useAutomationRuns } from "@/hooks/use-automation-runs"; +import { RunRow } from "./run-row"; +import { RunsLoading } from "./runs-loading"; + +interface AutomationRunsSectionProps { + automationId: number; +} + +const LIMIT = 20; + +/** + * Run history card. Shows the most recent ``LIMIT`` runs; pagination is + * intentionally deferred — for the foreseeable v1 surface (one-trigger + * automations firing daily), 20 covers ~3 weeks of history which is + * enough to tell whether things are working. Real "load more" lands if + * we see usage spike past that. + */ +export function AutomationRunsSection({ automationId }: AutomationRunsSectionProps) { + const { data, isLoading, error } = useAutomationRuns(automationId, { limit: LIMIT }); + const runs = data?.items ?? []; + + return ( + + +
+ + + Recent runs + +

+ Most recent first. Click a row to inspect step results, output and artifacts. +

+
+ {!isLoading && !error && data && ( + {data.total} total + )} +
+ + {isLoading ? ( + + ) : error ? ( +

+ Couldn't load runs{error.message ? `: ${error.message}` : "."} +

+ ) : runs.length === 0 ? ( +
+ +

No runs yet

+

+ This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs + will appear here. +

+
+ ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx new file mode 100644 index 000000000..d1d46900a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx @@ -0,0 +1,116 @@ +"use client"; +import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAutomationRun } from "@/hooks/use-automation-runs"; + +interface RunDetailsPanelProps { + automationId: number; + runId: number; +} + +/** + * Expanded view of a single run. Fetches lazily — the parent only renders + * this once the row is opened, so the list view stays cheap. + * + * We surface the four most actionable sections (error first when present, + * then output, step results, artifacts, inputs). The full + * ``definition_snapshot`` is omitted because it usually mirrors the live + * definition — surfacing it would dominate the panel without informing + * what the user is trying to learn ("did this work? what did it do?"). + */ +export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) { + const { data: run, isLoading, error } = useAutomationRun(automationId, runId); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error || !run) { + return ( +
+ Couldn't load run details{error?.message ? `: ${error.message}` : "."} +
+ ); + } + + const hasError = run.error && Object.keys(run.error).length > 0; + const hasOutput = run.output && Object.keys(run.output).length > 0; + const hasInputs = Object.keys(run.inputs ?? {}).length > 0; + + return ( +
+ {hasError && ( +
+ +
+ )} + + {hasOutput && ( +
+ +
+ )} + +
+ {run.step_results.length === 0 ? ( +

No steps recorded.

+ ) : ( + + )} +
+ + {run.artifacts.length > 0 && ( +
+ +
+ )} + + {hasInputs && ( +
+ +
+ )} +
+ ); +} + +function Section({ + icon: Icon, + label, + tone = "default", + children, +}: { + icon: typeof AlertCircle; + label: string; + tone?: "default" | "destructive"; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} +
+ {children} +
+ ); +} + +function JsonBlock({ value }: { value: unknown }) { + return ( +
+			{JSON.stringify(value, null, 2)}
+		
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx new file mode 100644 index 000000000..b8d2bcc8b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-row.tsx @@ -0,0 +1,75 @@ +"use client"; +import { ChevronDown, ChevronRight, Hand } from "lucide-react"; +import { useState } from "react"; +import type { RunSummary } from "@/contracts/types/automation.types"; +import { formatRelativeDate } from "@/lib/format-date"; +import { RunDetailsPanel } from "./run-details-panel"; +import { RunStatusBadge } from "./run-status-badge"; + +interface RunRowProps { + run: RunSummary; + automationId: number; +} + +/** + * One run row. Click to expand → fetches the full run and shows the + * details panel inline. State is local to each row so multiple panels + * can be open at once (or none). + */ +export function RunRow({ run, automationId }: RunRowProps) { + const [open, setOpen] = useState(false); + const duration = computeDuration(run.started_at, run.finished_at); + const startedLabel = run.started_at + ? formatRelativeDate(run.started_at) + : formatRelativeDate(run.created_at); + + return ( +
+ + + {open && } +
+ ); +} + +function TriggerSource({ triggerId }: { triggerId: number | null }) { + if (triggerId == null) { + return ( + + + Manual + + ); + } + return via trigger #{triggerId}; +} + +function computeDuration(started: string | null | undefined, finished: string | null | undefined) { + if (!started || !finished) return null; + const ms = new Date(finished).getTime() - new Date(started).getTime(); + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + return `${minutes}m ${seconds}s`; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx new file mode 100644 index 000000000..e5532a500 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-status-badge.tsx @@ -0,0 +1,57 @@ +"use client"; +import { AlertCircle, CheckCircle2, Clock, Loader2, TimerOff, XCircle } from "lucide-react"; +import type { RunStatus } from "@/contracts/types/automation.types"; +import { cn } from "@/lib/utils"; + +const STATUS_STYLES: Record< + RunStatus, + { label: string; icon: typeof CheckCircle2; classes: string; spin?: boolean } +> = { + pending: { + label: "Pending", + icon: Clock, + classes: "bg-muted text-muted-foreground border-border/60", + }, + running: { + label: "Running", + icon: Loader2, + classes: "bg-blue-500/10 text-blue-600 border-blue-500/20", + spin: true, + }, + succeeded: { + label: "Succeeded", + icon: CheckCircle2, + classes: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20", + }, + failed: { + label: "Failed", + icon: XCircle, + classes: "bg-destructive/10 text-destructive border-destructive/20", + }, + cancelled: { + label: "Cancelled", + icon: AlertCircle, + classes: "bg-muted text-muted-foreground border-border/60", + }, + timed_out: { + label: "Timed out", + icon: TimerOff, + classes: "bg-amber-500/10 text-amber-600 border-amber-500/20", + }, +}; + +export function RunStatusBadge({ status, className }: { status: RunStatus; className?: string }) { + const { label, icon: Icon, classes, spin } = STATUS_STYLES[status]; + return ( + + + {label} + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx new file mode 100644 index 000000000..5cab18f4c --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/runs-loading.tsx @@ -0,0 +1,23 @@ +"use client"; +import { Skeleton } from "@/components/ui/skeleton"; + +const ROW_KEYS = ["a", "b", "c"] as const; + +export function RunsLoading() { + return ( +
+ {ROW_KEYS.map((key) => ( +
+
+ + +
+ +
+ ))} +
+ ); +}