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 index f9c6fbb5a..164f156e5 100644 --- 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 @@ -1,8 +1,24 @@ "use client"; -import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react"; +import { + AlertCircle, + ChevronDown, + FileOutput, + GitCommitHorizontal, + Package, + Settings2, +} from "lucide-react"; +import { useState } from "react"; import { JsonView } from "@/components/json-view"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import type { RunStepResult } from "@/contracts/types/automation.types"; import { useAutomationRun } from "@/hooks/use-automation-runs"; +import { cn } from "@/lib/utils"; +import { RunStepResultCard } from "./run-step-result-card"; interface RunDetailsPanelProps { automationId: number; @@ -13,10 +29,11 @@ interface RunDetailsPanelProps { * 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 + * We surface the run outcome readably: a run-level error first (when + * present), then per-step cards that render the agent's markdown + * ``final_message`` directly, and finally the structural 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) { @@ -24,7 +41,7 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) { if (isLoading) { return ( -
+
@@ -33,53 +50,104 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) { if (error || !run) { return ( -
+
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
); } - const hasError = run.error && Object.keys(run.error).length > 0; + const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null; const hasOutput = run.output && Object.keys(run.output).length > 0; const hasInputs = Object.keys(run.inputs ?? {}).length > 0; + const steps = run.step_results as RunStepResult[]; + const hasDiagnostics = run.artifacts.length > 0 || hasInputs; return ( -
- {hasError && ( -
- -
- )} +
+ {runError ? : null} - {hasOutput && ( + {hasOutput ? (
- )} + ) : null} -
- {run.step_results.length === 0 ? ( +
+ {steps.length === 0 ? (

No steps recorded.

) : ( - +
+ {steps.map((step, index) => ( + + ))} +
)}
- {run.artifacts.length > 0 && ( + {hasDiagnostics ? : null} + + {run.artifacts.length > 0 ? (
- )} + ) : null} - {hasInputs && ( + {hasInputs ? (
- )} + ) : null}
); } +/** + * Run-level error: a readable destructive alert when a message is present, + * with the full structured error available behind a raw toggle. + */ +function RunErrorSection({ error }: { error: Record }) { + const [rawOpen, setRawOpen] = useState(false); + const message = typeof error.message === "string" ? error.message : null; + const type = typeof error.type === "string" ? error.type : "Run failed"; + + return ( +
+ {message ? ( + + + {type} + {message} + + ) : null} + + + + + + + + + + +
+ ); +} + function Section({ icon: Icon, label, @@ -92,15 +160,14 @@ function Section({ children: React.ReactNode; }) { return ( -
+
- + {label}
{children} @@ -110,8 +177,8 @@ function Section({ function JsonBlock({ value }: { value: unknown }) { return ( -
+ -
+ ); } 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 index 02ca0569c..3f6a39c35 100644 --- 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 @@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, Hand } from "lucide-react"; import { useState } from "react"; import type { RunSummary } from "@/contracts/types/automation.types"; +import { formatDuration } from "@/lib/automations/run-duration"; import { formatRelativeDate } from "@/lib/format-date"; import { RunDetailsPanel } from "./run-details-panel"; import { RunStatusBadge } from "./run-status-badge"; @@ -18,7 +19,7 @@ interface RunRowProps { */ export function RunRow({ run, automationId }: RunRowProps) { const [open, setOpen] = useState(false); - const duration = computeDuration(run.started_at, run.finished_at); + const duration = formatDuration(run.started_at, run.finished_at); const startedLabel = run.started_at ? formatRelativeDate(run.started_at) : formatRelativeDate(run.created_at); @@ -62,14 +63,3 @@ function TriggerSource({ triggerId }: { triggerId: number | null }) { } 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-step-result-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx new file mode 100644 index 000000000..eef572300 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-step-result-card.tsx @@ -0,0 +1,123 @@ +"use client"; +import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react"; +import { memo, useState } from "react"; +import { JsonView } from "@/components/json-view"; +import { MarkdownViewer } from "@/components/markdown-viewer"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { RunStepResult } from "@/contracts/types/automation.types"; +import { formatDuration } from "@/lib/automations/run-duration"; +import { cn } from "@/lib/utils"; + +type BadgeVariant = React.ComponentProps["variant"]; + +const STATUS_BADGE: Record< + string, + { label: string; variant: BadgeVariant; icon: typeof CheckCircle2 } +> = { + succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 }, + failed: { label: "Failed", variant: "destructive", icon: XCircle }, + skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle }, +}; + +function StepStatusBadge({ status }: { status: string }) { + const meta = STATUS_BADGE[status] ?? { + label: status, + variant: "outline" as const, + icon: MinusCircle, + }; + const Icon = meta.icon; + return ( + + + {meta.label} + + ); +} + +/** + * One step from a run's ``step_results``. Surfaces the agent's markdown + * ``final_message`` first-class (rendered, not raw), shows step errors as a + * readable alert, and keeps the full structured payload behind a "View raw" + * collapsible escape hatch. + */ +export const RunStepResultCard = memo(function RunStepResultCard({ + step, +}: { + step: RunStepResult; +}) { + const [rawOpen, setRawOpen] = useState(false); + + const duration = formatDuration(step.started_at, step.finished_at); + const attempts = step.attempts ?? 0; + const finalMessage = + typeof step.result?.final_message === "string" ? step.result.final_message : null; + const errorMessage = step.error?.message; + const hasMeta = Boolean(duration) || attempts > 1; + + return ( + + +
+
+ {step.action} + {step.step_id} +
+ +
+ {hasMeta ? ( +
+ {duration ? {duration} : null} + {attempts > 1 ? {attempts} attempts : null} +
+ ) : null} +
+ + + {errorMessage ? ( + + + {step.error?.type ?? "Error"} + {errorMessage} + + ) : null} + + {finalMessage ? ( +
+ +
+ ) : null} + + + + + + + + + + + +
+
+ ); +}); diff --git a/surfsense_web/contracts/types/automation.types.ts b/surfsense_web/contracts/types/automation.types.ts index a1f2bd382..b4ea39856 100644 --- a/surfsense_web/contracts/types/automation.types.ts +++ b/surfsense_web/contracts/types/automation.types.ts @@ -190,6 +190,31 @@ export const run = runSummary.extend({ }); export type Run = z.infer; +/** + * Typed view over a single entry in {@link Run.step_results}. The Zod schema + * keeps step results as opaque records (the backend emits action-specific + * payloads), so this interface exists purely for safe field access in the UI + * and does not perform runtime validation. + * + * Mirrors `_result()` in + * `surfsense_backend/app/automations/runtime/step.py`. For the `agent_task` + * action, `result` carries the markdown `final_message` produced by the agent. + */ +export interface RunStepResult { + step_id: string; + action: string; + status: "succeeded" | "failed" | "skipped" | string; + started_at?: string; + finished_at?: string; + attempts?: number; + result?: { + final_message?: string; + agent_session_id?: string; + resumes?: unknown; + } & Record; + error?: { message?: string; type?: string }; +} + export const runListResponse = z.object({ items: z.array(runSummary), total: z.number(), diff --git a/surfsense_web/lib/automations/run-duration.ts b/surfsense_web/lib/automations/run-duration.ts new file mode 100644 index 000000000..8242ee7cb --- /dev/null +++ b/surfsense_web/lib/automations/run-duration.ts @@ -0,0 +1,19 @@ +/** + * Format the wall-clock duration between a run/step's start and finish + * timestamps into a compact, human-readable label (e.g. `850ms`, `4.2s`, + * `1m 30s`). Returns `null` when either bound is missing or the delta is + * negative/non-finite, so callers can simply omit the label. + */ +export function formatDuration( + started: string | null | undefined, + finished: string | null | undefined +): string | null { + 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`; +}