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 (
{visibleItems.map((item) => { if (item.type === "event") { return ( ) : null } state={item.state as "complete" | "error"} variant={item.variant} helpText={item.helpText} /> ); } else { return ( ); } })}
); } // 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: ( Delayed until {run.ttl && <>(TTL {run.ttl})} ), 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: ( <> {" "} {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: , 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: , 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 (
{title}
{helpText && ( {helpText} )}
{subtitle ? ( {subtitle} ) : null}
); } 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 ( <>
{state === "inprogress" && (
)}
); case "dot-hollow": return ( <>
{state === "inprogress" && (
)}
{state === "inprogress" && (
)}
); case "dot-solid": return
; case "start-cap-thick": return (
{state === "inprogress" && (
)}
); case "end-cap-thick": return
; default: return
; } } export type RunTimelineLineProps = { title: ReactNode; state?: TimelineEventState; variant?: TimelineLineVariant; style?: TimelineStyle; }; export function RunTimelineLine({ title, state, variant = "normal", style = "normal", }: RunTimelineLineProps) { return (
{title}
); } 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 (
{state === "inprogress" && (
)}
); case "light": return (
{state === "inprogress" && (
)}
); default: return
; } } 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 ( <>
{visibleEvents.map((event, index) => { // Store previous date to compare const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp; return ( } variant={event.markerVariant} state={state} helpText={event.helpText} style={style} /> ); })} 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null } /> } variant={"start-cap-thick"} state={state} helpText={getHelpTextForEvent("Started")} style={style} /> {state === "inprogress" ? ( } state={state} variant="normal" style={style} /> ) : ( <> } state={isError ? "error" : undefined} variant="end-cap-thick" helpText={getHelpTextForEvent("Finished")} style={style} /> )}
); }