mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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:
parent
2e572d7818
commit
4625bd937e
6 changed files with 341 additions and 0 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<AutomationRunsSection automationId={automation.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue