673 lines
19 KiB
TypeScript
673 lines
19 KiB
TypeScript
import { ClockIcon } from "@heroicons/react/20/solid";
|
|
import { formatDuration, nanosecondsToMilliseconds } from "@trigger.dev/core/v3/utils/durations";
|
|
import type { ReactNode } from "react";
|
|
import { Fragment } from "react";
|
|
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
|
import { cn } from "~/utils/cn";
|
|
import type { TimelineSpanEvent } from "~/utils/timelineSpanEvents";
|
|
import { getHelpTextForEvent } from "~/utils/timelineSpanEvents";
|
|
import { DateTime, DateTimeAccurate } from "../primitives/DateTime";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
|
import { LiveTimer } from "../runs/v3/LiveTimer";
|
|
|
|
// Types for the RunTimeline component
|
|
export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed";
|
|
|
|
type TimelineLineVariant = "light" | "normal";
|
|
|
|
type TimelineStyle = "normal" | "diminished";
|
|
|
|
type TimelineEventVariant =
|
|
| "start-cap"
|
|
| "dot-hollow"
|
|
| "dot-solid"
|
|
| "start-cap-thick"
|
|
| "end-cap-thick"
|
|
| "end-cap";
|
|
|
|
// Timeline item type definitions
|
|
export type TimelineEventDefinition = {
|
|
type: "event";
|
|
id: string;
|
|
title: string;
|
|
date?: Date;
|
|
previousDate: Date | undefined;
|
|
state?: TimelineEventState;
|
|
shouldRender: boolean;
|
|
variant: TimelineEventVariant;
|
|
helpText?: string;
|
|
};
|
|
|
|
export type TimelineLineDefinition = {
|
|
type: "line";
|
|
id: string;
|
|
title: React.ReactNode;
|
|
state?: TimelineEventState;
|
|
shouldRender: boolean;
|
|
variant: TimelineLineVariant;
|
|
};
|
|
|
|
export type TimelineItem = TimelineEventDefinition | TimelineLineDefinition;
|
|
|
|
/**
|
|
* TimelineSpanRun represents the minimal set of run properties needed
|
|
* to render the RunTimeline component.
|
|
*/
|
|
export type TimelineSpanRun = {
|
|
// Core timestamps
|
|
createdAt: Date; // When the run was created/triggered
|
|
startedAt?: Date | null; // When the run was dequeued
|
|
executedAt?: Date | null; // When the run actually started executing
|
|
updatedAt: Date; // Last update timestamp (used for finish time)
|
|
expiredAt?: Date | null; // When the run expired (if applicable)
|
|
completedAt?: Date | null; // When the run completed
|
|
|
|
// Delay information
|
|
delayUntil?: Date | null; // If the run is delayed, when it will be processed
|
|
ttl?: string | null; // Time-to-live value if applicable
|
|
|
|
// Status flags
|
|
isFinished: boolean; // Whether the run has completed
|
|
isError: boolean; // Whether the run ended with an error
|
|
};
|
|
|
|
export function RunTimeline({ run }: { run: TimelineSpanRun }) {
|
|
// Build timeline items based on the run state
|
|
const timelineItems = buildTimelineItems(run);
|
|
|
|
// Filter out items that shouldn't be rendered
|
|
const visibleItems = timelineItems.filter((item) => item.shouldRender);
|
|
|
|
return (
|
|
<div className="min-w-fit max-w-80">
|
|
{visibleItems.map((item) => {
|
|
if (item.type === "event") {
|
|
return (
|
|
<RunTimelineEvent
|
|
key={item.id}
|
|
title={item.title}
|
|
subtitle={
|
|
item.date ? (
|
|
<DateTimeAccurate date={item.date} previousDate={item.previousDate} />
|
|
) : null
|
|
}
|
|
state={item.state as "complete" | "error"}
|
|
variant={item.variant}
|
|
helpText={item.helpText}
|
|
/>
|
|
);
|
|
} else {
|
|
return (
|
|
<RunTimelineLine
|
|
key={item.id}
|
|
title={item.title}
|
|
state={item.state}
|
|
variant={item.variant}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Centralized function to build all timeline items
|
|
function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] {
|
|
let state: TimelineEventState;
|
|
if (run.isError) {
|
|
state = "error";
|
|
} else if (run.expiredAt) {
|
|
state = "error";
|
|
} else if (run.isFinished) {
|
|
state = "complete";
|
|
} else {
|
|
state = "inprogress";
|
|
}
|
|
|
|
const items: TimelineItem[] = [];
|
|
|
|
// 1. Triggered Event
|
|
items.push({
|
|
type: "event",
|
|
id: "triggered",
|
|
title: "Triggered",
|
|
date: run.createdAt,
|
|
previousDate: undefined,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "start-cap",
|
|
helpText: getHelpTextForEvent("Triggered"),
|
|
});
|
|
|
|
// 2. Waiting to dequeue line
|
|
if (run.delayUntil && !run.startedAt && !run.expiredAt) {
|
|
// Delayed, not yet started
|
|
items.push({
|
|
type: "line",
|
|
id: "waiting-to-dequeue",
|
|
title: (
|
|
<span className="flex items-center gap-1">
|
|
<ClockIcon className="size-4" />
|
|
<span>
|
|
Delayed until <DateTime date={run.delayUntil} /> {run.ttl && <>(TTL {run.ttl})</>}
|
|
</span>
|
|
</span>
|
|
),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
} else if (run.startedAt) {
|
|
// Already dequeued - show the waiting duration
|
|
items.push({
|
|
type: "line",
|
|
id: "waiting-to-dequeue",
|
|
title: formatDuration(run.createdAt, run.startedAt),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
} else if (run.expiredAt) {
|
|
// Expired before dequeuing
|
|
items.push({
|
|
type: "line",
|
|
id: "waiting-to-dequeue",
|
|
title: formatDuration(run.createdAt, run.expiredAt),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
} else {
|
|
// Still waiting to be dequeued
|
|
items.push({
|
|
type: "line",
|
|
id: "waiting-to-dequeue",
|
|
title: (
|
|
<>
|
|
<LiveTimer
|
|
startTime={run.createdAt}
|
|
endTime={run.startedAt ?? run.expiredAt ?? undefined}
|
|
/>{" "}
|
|
{run.ttl && <>(TTL {run.ttl})</>}
|
|
</>
|
|
),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
}
|
|
|
|
// 3. Dequeued Event (if applicable)
|
|
if (run.startedAt) {
|
|
items.push({
|
|
type: "event",
|
|
id: "dequeued",
|
|
title: "Dequeued",
|
|
date: run.startedAt,
|
|
previousDate: run.createdAt,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "dot-hollow",
|
|
helpText: getHelpTextForEvent("Dequeued"),
|
|
});
|
|
}
|
|
|
|
// 4. Handle the case based on whether executedAt exists
|
|
if (run.startedAt && !run.expiredAt) {
|
|
if (run.executedAt) {
|
|
// New behavior: Run has executedAt timestamp
|
|
|
|
// 4a. Show waiting to execute line
|
|
items.push({
|
|
type: "line",
|
|
id: "waiting-to-execute",
|
|
title: formatDuration(run.startedAt, run.executedAt),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
|
|
// 4b. Show Started event
|
|
items.push({
|
|
type: "event",
|
|
id: "started",
|
|
title: "Started",
|
|
date: run.executedAt,
|
|
previousDate: run.startedAt,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "start-cap-thick",
|
|
helpText: getHelpTextForEvent("Started"),
|
|
});
|
|
|
|
// 4c. Show executing line if applicable
|
|
if (run.isFinished) {
|
|
items.push({
|
|
type: "line",
|
|
id: "executing",
|
|
title: formatDuration(run.executedAt, run.completedAt ?? run.updatedAt),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "normal",
|
|
});
|
|
} else {
|
|
items.push({
|
|
type: "line",
|
|
id: "executing",
|
|
title: <LiveTimer startTime={run.executedAt} />,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "normal",
|
|
});
|
|
}
|
|
} else {
|
|
// Legacy behavior: Run doesn't have executedAt timestamp
|
|
|
|
// If the run is finished, show a line directly from Dequeued to Finished
|
|
if (run.isFinished) {
|
|
items.push({
|
|
type: "line",
|
|
id: "legacy-executing",
|
|
title: formatDuration(run.startedAt, run.completedAt ?? run.updatedAt),
|
|
state,
|
|
shouldRender: true,
|
|
variant: "normal",
|
|
});
|
|
} else {
|
|
// Still waiting to start or execute (can't distinguish without executedAt)
|
|
items.push({
|
|
type: "line",
|
|
id: "legacy-waiting-or-executing",
|
|
title: <LiveTimer startTime={run.startedAt} />,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "light",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Finished Event (if applicable)
|
|
if (run.isFinished && !run.expiredAt) {
|
|
items.push({
|
|
type: "event",
|
|
id: "finished",
|
|
title: "Finished",
|
|
date: run.completedAt ?? run.updatedAt,
|
|
previousDate: run.executedAt ?? run.startedAt ?? undefined,
|
|
state,
|
|
shouldRender: true,
|
|
variant: "end-cap-thick",
|
|
helpText: getHelpTextForEvent("Finished"),
|
|
});
|
|
}
|
|
|
|
// 6. Expired Event (if applicable)
|
|
if (run.expiredAt) {
|
|
items.push({
|
|
type: "event",
|
|
id: "expired",
|
|
title: "Expired",
|
|
date: run.expiredAt,
|
|
previousDate: run.createdAt,
|
|
state: "error",
|
|
shouldRender: true,
|
|
variant: "dot-solid",
|
|
helpText: getHelpTextForEvent("Expired"),
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
export type RunTimelineEventProps = {
|
|
title: ReactNode;
|
|
subtitle?: ReactNode;
|
|
state?: "complete" | "error" | "inprogress";
|
|
variant?: TimelineEventVariant;
|
|
helpText?: string;
|
|
style?: TimelineStyle;
|
|
};
|
|
|
|
export function RunTimelineEvent({
|
|
title,
|
|
subtitle,
|
|
state,
|
|
variant = "dot-hollow",
|
|
helpText,
|
|
style = "normal",
|
|
}: RunTimelineEventProps) {
|
|
return (
|
|
<div className="grid h-5 grid-cols-[1.125rem_1fr] gap-1 text-sm">
|
|
<div className="relative flex flex-col items-center justify-center">
|
|
<EventMarker variant={variant} state={state} style={style} />
|
|
</div>
|
|
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
|
<TooltipProvider disableHoverableContent>
|
|
<Tooltip>
|
|
<TooltipTrigger className="min-w-0 max-w-full cursor-default text-left">
|
|
<div className="truncate font-medium text-text-bright">{title}</div>
|
|
</TooltipTrigger>
|
|
{helpText && (
|
|
<TooltipContent className="flex items-center gap-1 text-xs">
|
|
{helpText}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{subtitle ? (
|
|
<span className="whitespace-nowrap text-xs tabular-nums text-text-dimmed">
|
|
{subtitle}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventMarker({
|
|
variant,
|
|
state,
|
|
style,
|
|
}: {
|
|
variant: TimelineEventVariant;
|
|
state?: TimelineEventState;
|
|
style?: TimelineStyle;
|
|
}) {
|
|
let bgClass = "bg-text-dimmed";
|
|
switch (state) {
|
|
case "complete":
|
|
bgClass = "bg-success";
|
|
break;
|
|
case "error":
|
|
bgClass = "bg-error";
|
|
break;
|
|
case "delayed":
|
|
bgClass = "bg-text-dimmed";
|
|
break;
|
|
case "inprogress":
|
|
bgClass = style === "normal" ? "bg-pending" : "bg-text-dimmed";
|
|
break;
|
|
}
|
|
|
|
let borderClass = "border-text-dimmed";
|
|
switch (state) {
|
|
case "complete":
|
|
borderClass = "border-success";
|
|
break;
|
|
case "error":
|
|
borderClass = "border-error";
|
|
break;
|
|
case "delayed":
|
|
borderClass = "border-text-dimmed";
|
|
break;
|
|
case "inprogress":
|
|
borderClass = style === "normal" ? "border-pending" : "border-text-dimmed";
|
|
break;
|
|
default:
|
|
borderClass = "border-text-dimmed";
|
|
break;
|
|
}
|
|
|
|
switch (variant) {
|
|
case "start-cap":
|
|
return (
|
|
<>
|
|
<div className={cn("h-full w-1.75 border-b", borderClass)} />
|
|
<div className={cn("relative h-full w-px", bgClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-30"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
case "dot-hollow":
|
|
return (
|
|
<>
|
|
<div className={cn("relative h-full w-px", bgClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={cn("size-1.25 min-h-1.25 rounded-full border", borderClass)} />
|
|
<div className={cn("relative h-full w-px", bgClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
case "dot-solid":
|
|
return <div className={cn("size-1.25 rounded-full", bgClass)} />;
|
|
case "start-cap-thick":
|
|
return (
|
|
<div className={cn("relative h-full w-1.75 rounded-t-xs", bgClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
case "end-cap-thick":
|
|
return <div className={cn("h-full w-1.75 rounded-b-xs", bgClass)} />;
|
|
default:
|
|
return <div className={cn("size-1.25 rounded-full bg-yellow-500")} />;
|
|
}
|
|
}
|
|
|
|
export type RunTimelineLineProps = {
|
|
title: ReactNode;
|
|
state?: TimelineEventState;
|
|
variant?: TimelineLineVariant;
|
|
style?: TimelineStyle;
|
|
};
|
|
|
|
export function RunTimelineLine({
|
|
title,
|
|
state,
|
|
variant = "normal",
|
|
style = "normal",
|
|
}: RunTimelineLineProps) {
|
|
return (
|
|
<div className="grid h-6 grid-cols-[1.125rem_1fr] gap-1 text-xs">
|
|
<div className="flex items-stretch justify-center">
|
|
<LineMarker state={state} variant={variant} style={style} />
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-text-dimmed">{title}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LineMarker({
|
|
state,
|
|
variant,
|
|
style,
|
|
}: {
|
|
state?: TimelineEventState;
|
|
variant: TimelineLineVariant;
|
|
style?: TimelineStyle;
|
|
}) {
|
|
let containerClass = "bg-text-dimmed";
|
|
switch (state) {
|
|
case "complete":
|
|
containerClass = "bg-success";
|
|
break;
|
|
case "error":
|
|
containerClass = "bg-error";
|
|
break;
|
|
case "delayed":
|
|
containerClass = "bg-text-dimmed";
|
|
break;
|
|
case "inprogress":
|
|
containerClass =
|
|
style === "normal" ? "rounded-b-xs bg-pending" : "rounded-b-xs bg-text-dimmed";
|
|
break;
|
|
}
|
|
|
|
switch (variant) {
|
|
case "normal":
|
|
return (
|
|
<div className={cn("relative w-1.75", containerClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-30"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
case "light":
|
|
return (
|
|
<div className={cn("relative w-px", containerClass)}>
|
|
{state === "inprogress" && (
|
|
<div
|
|
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-50"
|
|
style={{
|
|
backgroundImage: `url(${tileBgPath})`,
|
|
backgroundSize: "8px 8px",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
default:
|
|
return <div className="w-px rounded-xs bg-text-dimmed" />;
|
|
}
|
|
}
|
|
|
|
export type SpanTimelineProps = {
|
|
startTime: Date;
|
|
duration: number;
|
|
inProgress: boolean;
|
|
isError: boolean;
|
|
events?: TimelineSpanEvent[];
|
|
style?: TimelineStyle;
|
|
};
|
|
|
|
export type SpanTimelineState = "error" | "pending" | "complete";
|
|
|
|
export function SpanTimeline({
|
|
startTime,
|
|
duration,
|
|
inProgress,
|
|
isError,
|
|
events,
|
|
style = "diminished",
|
|
}: SpanTimelineProps) {
|
|
const state = isError ? "error" : inProgress ? "inprogress" : undefined;
|
|
|
|
const visibleEvents = events ?? [];
|
|
|
|
return (
|
|
<>
|
|
<div className="min-w-fit max-w-80">
|
|
{visibleEvents.map((event, index) => {
|
|
// Store previous date to compare
|
|
const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp;
|
|
|
|
return (
|
|
<Fragment key={index}>
|
|
<RunTimelineEvent
|
|
title={event.name}
|
|
subtitle={<DateTimeAccurate date={event.timestamp} previousDate={prevDate} />}
|
|
variant={event.markerVariant}
|
|
state={state}
|
|
helpText={event.helpText}
|
|
style={style}
|
|
/>
|
|
<RunTimelineLine
|
|
title={
|
|
index === visibleEvents.length - 1
|
|
? // Last event - calculate duration until span start time
|
|
formatDuration(event.timestamp, startTime)
|
|
: // Calculate duration until next event
|
|
formatDuration(event.timestamp, visibleEvents[index + 1].timestamp)
|
|
}
|
|
variant={event.lineVariant}
|
|
state={state}
|
|
style={style}
|
|
/>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
<RunTimelineEvent
|
|
title="Started"
|
|
subtitle={
|
|
<DateTimeAccurate
|
|
date={startTime}
|
|
previousDate={
|
|
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null
|
|
}
|
|
/>
|
|
}
|
|
variant={"start-cap-thick"}
|
|
state={state}
|
|
helpText={getHelpTextForEvent("Started")}
|
|
style={style}
|
|
/>
|
|
{state === "inprogress" ? (
|
|
<RunTimelineLine
|
|
title={<LiveTimer startTime={startTime} />}
|
|
state={state}
|
|
variant="normal"
|
|
style={style}
|
|
/>
|
|
) : (
|
|
<>
|
|
<RunTimelineLine
|
|
title={formatDuration(
|
|
startTime,
|
|
new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))
|
|
)}
|
|
state={isError ? "error" : undefined}
|
|
variant="normal"
|
|
style={style}
|
|
/>
|
|
<RunTimelineEvent
|
|
title="Finished"
|
|
subtitle={
|
|
<DateTimeAccurate
|
|
date={new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))}
|
|
previousDate={startTime}
|
|
/>
|
|
}
|
|
state={isError ? "error" : undefined}
|
|
variant="end-cap-thick"
|
|
helpText={getHelpTextForEvent("Finished")}
|
|
style={style}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|