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 (
+
+
setOpen((value) => !value)}
+ className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
+ aria-expanded={open}
+ >
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+ {startedLabel}
+
+
+ {duration && {duration} }
+
+
+
+
+ {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) => (
+
+ ))}
+
+ );
+}