feat(web): run history section on automations detail page

Recent runs card under triggers. Each row expands lazily to fetch the
full run (step results, output, artifacts, error). 20-row cap for now;
real pagination lands if usage demands it.
This commit is contained in:
CREDO23 2026-05-28 01:35:48 +02:00
parent 2e572d7818
commit 4625bd937e
6 changed files with 341 additions and 0 deletions

View file

@ -6,6 +6,7 @@ import { AutomationDefinitionSection } from "./components/automation-definition-
import { AutomationDetailHeader } from "./components/automation-detail-header"; import { AutomationDetailHeader } from "./components/automation-detail-header";
import { AutomationDetailLoading } from "./components/automation-detail-loading"; import { AutomationDetailLoading } from "./components/automation-detail-loading";
import { AutomationNotFound } from "./components/automation-not-found"; import { AutomationNotFound } from "./components/automation-not-found";
import { AutomationRunsSection } from "./components/automation-runs-section";
import { AutomationTriggersSection } from "./components/automation-triggers-section"; import { AutomationTriggersSection } from "./components/automation-triggers-section";
interface AutomationDetailContentProps { interface AutomationDetailContentProps {
@ -81,6 +82,8 @@ export function AutomationDetailContent({
canDelete={perms.canDelete} canDelete={perms.canDelete}
canCreate={perms.canCreate} canCreate={perms.canCreate}
/> />
<AutomationRunsSection automationId={automation.id} />
</> </>
); );
} }

View file

@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="space-y-1">
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
Recent runs
</CardTitle>
<p className="text-xs text-muted-foreground">
Most recent first. Click a row to inspect step results, output and artifacts.
</p>
</div>
{!isLoading && !error && data && (
<span className="text-xs text-muted-foreground">{data.total} total</span>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<RunsLoading />
) : error ? (
<p className="text-sm text-muted-foreground">
Couldn't load runs{error.message ? `: ${error.message}` : "."}
</p>
) : runs.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm font-medium text-foreground">No runs yet</p>
<p className="mt-1 text-xs text-muted-foreground">
This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs
will appear here.
</p>
</div>
) : (
<div className="space-y-2">
{runs.map((run) => (
<RunRow key={run.id} run={run} automationId={automationId} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="space-y-3 p-4 bg-muted/20 border-t border-border/60">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (error || !run) {
return (
<div className="p-4 bg-muted/20 border-t border-border/60 text-xs text-muted-foreground">
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
</div>
);
}
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 (
<div className="space-y-4 p-4 bg-muted/20 border-t border-border/60">
{hasError && (
<Section icon={AlertCircle} label="Error" tone="destructive">
<JsonBlock value={run.error} />
</Section>
)}
{hasOutput && (
<Section icon={FileOutput} label="Output">
<JsonBlock value={run.output} />
</Section>
)}
<Section icon={GitCommitHorizontal} label={`Step results · ${run.step_results.length}`}>
{run.step_results.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps recorded.</p>
) : (
<JsonBlock value={run.step_results} />
)}
</Section>
{run.artifacts.length > 0 && (
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
<JsonBlock value={run.artifacts} />
</Section>
)}
{hasInputs && (
<Section icon={Settings2} label="Resolved inputs">
<JsonBlock value={run.inputs} />
</Section>
)}
</div>
);
}
function Section({
icon: Icon,
label,
tone = "default",
children,
}: {
icon: typeof AlertCircle;
label: string;
tone?: "default" | "destructive";
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div
className={
tone === "destructive"
? "flex items-center gap-1.5 text-[11px] font-medium text-destructive uppercase tracking-wider"
: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider"
}
>
<Icon className="h-3 w-3" aria-hidden />
{label}
</div>
{children}
</div>
);
}
function JsonBlock({ value }: { value: unknown }) {
return (
<pre className="rounded-md bg-background/60 px-3 py-2 text-[11px] font-mono text-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-64">
{JSON.stringify(value, null, 2)}
</pre>
);
}

View file

@ -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 (
<div className="rounded-md border border-border/60 bg-background overflow-hidden">
<button
type="button"
onClick={() => 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}
>
<div className="flex items-center gap-3 min-w-0">
{open ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
)}
<RunStatusBadge status={run.status} />
<span className="text-xs text-muted-foreground truncate">{startedLabel}</span>
</div>
<div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
{duration && <span className="font-mono">{duration}</span>}
<TriggerSource triggerId={run.trigger_id ?? null} />
</div>
</button>
{open && <RunDetailsPanel automationId={automationId} runId={run.id} />}
</div>
);
}
function TriggerSource({ triggerId }: { triggerId: number | null }) {
if (triggerId == null) {
return (
<span className="inline-flex items-center gap-1">
<Hand className="h-3 w-3" aria-hidden />
Manual
</span>
);
}
return <span>via trigger #{triggerId}</span>;
}
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`;
}

View file

@ -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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
classes,
className
)}
>
<Icon className={cn("h-3 w-3", spin && "animate-spin")} aria-hidden />
{label}
</span>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
const ROW_KEYS = ["a", "b", "c"] as const;
export function RunsLoading() {
return (
<div className="space-y-2">
{ROW_KEYS.map((key) => (
<div
key={key}
className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/50 px-4 py-3"
>
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}