chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user