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 { 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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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