import { ArrowUturnLeftIcon, BoltSlashIcon, BookOpenIcon, ChevronDownIcon, ChevronRightIcon, InformationCircleIcon, LockOpenIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; import { useLoaderData, useRevalidator } from "@remix-run/react"; import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime"; import { type Virtualizer } from "@tanstack/react-virtual"; import { formatDurationMilliseconds, millisecondsToNanoseconds, nanosecondsToMilliseconds, tryCatch, } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown"; import { ChevronExtraSmallUp } from "~/assets/icons/ChevronExtraSmallUp"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; import { MoveUpIcon } from "~/assets/icons/MoveUpIcon"; import tileBgPath from "~/assets/images/error-banner-tile@2x.png"; import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence"; import { WarmStartIconWithTooltip } from "~/components/WarmStarts"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { PageBody } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTimeShort } from "~/components/primitives/DateTime"; import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Header3 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { SearchInput } from "~/components/primitives/SearchInput"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, type ResizableSnapshot, collapsibleHandleClassName, useFrozenValue, } from "~/components/primitives/Resizable"; import { ShortcutKey, variants } from "~/components/primitives/ShortcutKey"; import { Slider } from "~/components/primitives/Slider"; import { Switch } from "~/components/primitives/Switch"; import * as Timeline from "~/components/primitives/Timeline"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { TreeView, type UseTreeStateOutput, useTree, } from "~/components/primitives/TreeView/TreeView"; import { type NodesState } from "~/components/primitives/TreeView/reducer"; import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog"; import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog"; import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; import { RunIcon } from "~/components/runs/v3/RunIcon"; import { SpanTitle, eventBackgroundClassName, eventBorderClassName, } from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; import { $replica } from "~/db.server"; import { useDebounce } from "~/hooks/useDebounce"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useEventSource } from "~/hooks/useEventSource"; import { useInitialDimensions } from "~/hooks/useInitialDimensions"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams"; import { useSearchParams } from "~/hooks/useSearchParam"; import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useHasAdminAccess } from "~/hooks/useUser"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { RunEnvironmentMismatchError, RunNotInPgError, RunPresenter, } from "~/presenters/v3/RunPresenter.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; import { buildSyntheticRunHeader } from "~/v3/mollifier/syntheticRunHeader.server"; import { buildSyntheticTraceForBufferedRun } from "~/v3/mollifier/syntheticTrace.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getImpersonationId } from "~/services/impersonation.server"; import { logger } from "~/services/logger.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; import { rbac } from "~/services/rbac.server"; import { cn } from "~/utils/cn"; import { lerp } from "~/utils/lerp"; import { docsPath, v3BillingPath, v3RunParamsSchema, v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, v3RunsPath, } from "~/utils/pathBuilder"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route"; const resizableSettings = { parent: { autosaveId: "panel-run-parent-v3", handleId: "parent-handle", main: { id: "run", min: "100px" as const, }, inspector: { id: "inspector", default: "500px" as const, min: "250px" as const, }, }, tree: { autosaveId: "panel-run-tree", handleId: "tree-handle", tree: { id: "tree", default: "50%" as const, min: "50px" as const, }, timeline: { id: "timeline", default: "50%" as const, min: "50px" as const, }, }, }; type TraceEvent = NonNullable["trace"]>["events"][0]; type RunsListNavigation = { runs: Array<{ friendlyId: string; spanId: string }>; pagination: { next?: string; previous?: string }; prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string }; nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string }; }; async function getRunsListFromTableState({ tableStateParam, organizationSlug, projectParam, envParam, runParam, userId, }: { tableStateParam: string | null; organizationSlug: string; projectParam: string; envParam: string; runParam: string; userId: string; }): Promise { if (!tableStateParam) { return null; } try { const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); const filters = getRunFiltersFromSearchParams(tableStateSearchParams); const project = await findProjectBySlug(organizationSlug, projectParam, userId); const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); if (!project || !environment) { return null; } const clickhouse = await clickhouseFactory.getClickhouseForOrganization( project.organizationId, "standard" ); const runsListPresenter = new NextRunListPresenter($replica, clickhouse); const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { userId, projectId: project.id, ...filters, pageSize: 25, // Load enough runs to provide navigation context }); const runsList: RunsListNavigation = { runs: currentPageResult.runs, pagination: currentPageResult.pagination, }; const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); if (currentRunIndex === 0 && currentPageResult.pagination.previous) { const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { userId, projectId: project.id, ...filters, cursor: currentPageResult.pagination.previous, direction: "backward", pageSize: 1, // We only need the last run from the previous page }); if (prevPageResult.runs.length > 0) { runsList.prevPageLastRun = { friendlyId: prevPageResult.runs[0].friendlyId, spanId: prevPageResult.runs[0].spanId, cursor: currentPageResult.pagination.previous, }; } } if ( currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next ) { const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { userId, projectId: project.id, ...filters, cursor: currentPageResult.pagination.next, direction: "forward", pageSize: 1, // We only need the first run from the next page }); if (nextPageResult.runs.length > 0) { runsList.nextPageFirstRun = { friendlyId: nextPageResult.runs[0].friendlyId, spanId: nextPageResult.runs[0].spanId, cursor: currentPageResult.pagination.next, }; } } return runsList; } catch (error) { logger.error("Error loading runs list from tableState:", { error }); return null; } } // Display-only write:runs flags for the Replay/Cancel controls. The cancel // and replay action routes enforce write:runs independently; this mirrors the // result so the buttons disable for roles that lack it. Permissive in OSS. async function runWritePermissions(request: Request, userId: string, organizationId: string) { const auth = await rbac.authenticateSession(request, { userId, organizationId }); const canWriteRun = auth.ok ? auth.ability.can("write", { type: "runs" }) : true; return { canReplayRun: canWriteRun, canCancelRun: canWriteRun }; } export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const impersonationId = await getImpersonationId(request); const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params); const url = new URL(request.url); const showDebug = url.searchParams.get("showDebug") === "true"; const presenter = new RunPresenter(); const [error, result] = await tryCatch( presenter.call({ userId, showDeletedLogs: !!impersonationId, projectSlug: projectParam, runFriendlyId: runParam, environmentSlug: envParam, showDebug, }) ); if (error) { if (error instanceof RunEnvironmentMismatchError) { throw redirect( v3RunRedirectPath( { slug: organizationSlug }, { slug: projectParam }, { friendlyId: runParam } ) ); } // Only fall back to the mollifier buffer on a genuine PG miss. Any // other error (DB timeout during trace queries, event-repository // failure, etc.) means the run WAS in PG but a downstream lookup // failed — falling back to the buffer here would either return a // stale synth entry if one happens to exist in the brief drainer- // materialisation race window, or quietly mask the real failure. // `RunNotInPgError` is the typed signal RunPresenter throws for the // route loader's specific case (`RunPresenter.server.ts:130`). if (!(error instanceof RunNotInPgError)) { throw error; } // PG miss → try the mollifier buffer. When the gate diverts a trigger // the run sits in Redis until the drainer materialises it; without // this fallback the run-detail page 404s for the brief buffered window // even though the API has accepted the trigger and returned an id. const buffered = await tryMollifiedRunFallback({ runFriendlyId: runParam, organizationSlug, projectSlug: projectParam, envSlug: envParam, userId, }); if (buffered) { // Preselect the root span on the initial page load when the URL // doesn't already carry `?span=`. The sibling redirect routes // (runs.$runParam.ts, @.runs.$runParam.ts, // projects.v3.$projectRef.runs.$runParam.ts) all do this, but // direct navigation to the canonical project-scoped URL never // hit those redirects — leaving the right detail panel collapsed. // Skip on `_data` requests (Remix data fetches): they're // client-driven follow-ups and the client URL is what matters, // not the loader's view of it. if (!url.searchParams.has("span") && !url.searchParams.has("_data") && buffered.run.spanId) { url.searchParams.set("span", buffered.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); return json({ run: buffered.run, trace: buffered.trace, maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS, resizable: { parent, tree }, runsList: null, ...(await runWritePermissions(request, userId, buffered.run.environment.organizationId)), }); } throw error; } // Preselect the root span on the initial page load when the URL // doesn't already carry `?span=`. See the comment on the equivalent // block in the buffered fallback above — the sibling redirect routes // do this, but direct navigation to the canonical project-scoped URL // never hits them, leaving the right detail panel collapsed. if (!url.searchParams.has("span") && !url.searchParams.has("_data") && result.run.spanId) { url.searchParams.set("span", result.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } //resizable settings const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); const runsList = await getRunsListFromTableState({ tableStateParam: url.searchParams.get("tableState"), organizationSlug, projectParam, envParam, runParam, userId, }); return json({ run: result.run, trace: result.trace, maximumLiveReloadingSetting: result.maximumLiveReloadingSetting, resizable: { parent, tree, }, runsList, ...(await runWritePermissions(request, userId, result.run.environment.organizationId)), }); }; async function tryMollifiedRunFallback(args: { runFriendlyId: string; organizationSlug: string; projectSlug: string; envSlug: string; userId: string; }) { const project = await findProjectBySlug(args.organizationSlug, args.projectSlug, args.userId); if (!project) return null; const environment = await findEnvironmentBySlug(project.id, args.envSlug, args.userId); if (!environment) return null; const buffered = await findRunByIdWithMollifierFallback({ runId: args.runFriendlyId, environmentId: environment.id, organizationId: project.organizationId, }); if (!buffered) return null; return { run: buildSyntheticRunHeader({ run: buffered, environment: { id: environment.id, organizationId: project.organizationId, type: environment.type, slug: environment.slug, }, }), trace: buildSyntheticTraceForBufferedRun(buffered), }; } type LoaderData = SerializeFrom; export default function Page() { const { run, trace, maximumLiveReloadingSetting, runsList, resizable, canReplayRun, canCancelRun, } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const isConnected = useCrossEngineIsConnected({ logCount: trace?.events.length ?? 0, isCompleted: run.completedAt !== null, }); const { value } = useSearchParams(); const tableState = decodeURIComponent(value("tableState") ?? ""); const tableStateSearchParams = new URLSearchParams(tableState); const filters = getRunFiltersFromSearchParams(tableStateSearchParams); const tabParam = value("tab") ?? undefined; const spanParam = value("span") ?? undefined; const [previousRunPath, nextRunPath] = useAdjacentRunPaths({ organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam, }); return ( <> {tableState && (
)} } /> {environment.type === "DEVELOPMENT" && } ID {run.id} Trace ID {run.traceId} Env ID {run.environment.id} Org ID {run.environment.organizationId} Run docs {run.isFinished ? null : ( )}
{trace ? ( ) : ( )} ); } function shouldLiveReload({ events, maximumLiveReloadingSetting, run, }: { events: TraceEvent[]; maximumLiveReloadingSetting: number; run: { completedAt: string | null }; }): boolean { // We don't live reload if there are a ton of spans/logs if (events.length > maximumLiveReloadingSetting) return false; // If the run was completed a while ago, we don't need to live reload anymore if (run.completedAt && new Date(run.completedAt).getTime() < Date.now() - 30_000) return false; return true; } function TraceView({ run, trace, maximumLiveReloadingSetting, resizable, }: Pick) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const { searchParams, replaceSearchParam } = useReplaceSearchParams(); const selectedSpanId = searchParams.get("span") ?? undefined; const frozenSpanId = useFrozenValue(selectedSpanId); const displaySpanId = selectedSpanId ?? frozenSpanId; if (!trace) { return <>; } const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId, isTruncated = false, missingAnchor = false, } = trace; const changeToSpan = useDebounce((selectedSpan: string) => { replaceSearchParam("span", selectedSpan, { replace: true }); }, 250); const isLiveReloading = shouldLiveReload({ events, maximumLiveReloadingSetting, run }); const revalidator = useRevalidator(); const streamedEvents = useEventSource( v3RunStreamingPath(organization, project, environment, run), { event: "message", disabled: !isLiveReloading, } ); useEffect(() => { if (streamedEvents !== null) { revalidator.revalidate(); } // WARNING Don't put the revalidator in the useEffect deps array or bad things will happen }, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined; const frozenSpanOverrides = useFrozenValue(spanOverrides); const displaySpanOverrides = selectedSpanId ? spanOverrides : frozenSpanOverrides; // Get the linked run ID for cached spans (map built during RunPresenter walk) const { linkedRunIdBySpanId } = trace; const selectedSpanLinkedRunId = selectedSpanId ? linkedRunIdBySpanId?.[selectedSpanId] : undefined; const frozenLinkedRunId = useFrozenValue(selectedSpanLinkedRunId); const displayLinkedRunId = (selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId) ?? undefined; return (
{isTruncated && (
{missingAnchor ? "Trace too large to display completely." : "This run's trace is partially displayed because it exceeds the view limit."}
)}
{ //instantly close the panel if no span is selected if (!selectedSpan) { replaceSearchParam("span"); return; } changeToSpan(selectedSpan); }} totalDuration={duration} rootSpanStatus={rootSpanStatus} rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined} queuedDuration={queuedDuration} environmentType={run.environment.type} shouldLiveReload={isLiveReloading} maximumLiveReloadingSetting={maximumLiveReloadingSetting} rootRun={run.rootTaskRun} parentRun={run.parentTaskRun} isCompleted={run.completedAt !== null} treeSnapshot={resizable.tree as ResizableSnapshot} />
{}} collapsedSize="0px" collapseAnimation={RESIZABLE_PANEL_ANIMATION} >
{displaySpanId && ( replaceSearchParam("span")} linkedRunId={displayLinkedRunId} /> )}
); } // Controlled wrapper around the cancel dialog. Owns the Radix open state // so the dialog closes itself once the cancel action transitions through // submission. We can't ``-wrap the submit button // because Radix's onClick handler swallows the button's name=value pair // that the form action depends on for `redirectUrl`. function ControlledCancelRunDialog({ runFriendlyId, redirectPath, canCancel, }: { runFriendlyId: string; redirectPath: string; canCancel: boolean; }) { const [open, setOpen] = useState(false); return ( setOpen(false)} /> ); } function NoLogsView({ run, resizable }: Pick) { const plan = useCurrentPlan(); const organization = useOrganization(); const logRetention = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30; const completedAt = run.completedAt ? new Date(run.completedAt) : undefined; const now = new Date(); const daysSinceCompleted = completedAt ? Math.floor((now.getTime() - completedAt.getTime()) / (1000 * 60 * 60 * 24)) : undefined; const isWithinLogRetention = daysSinceCompleted !== undefined && daysSinceCompleted <= logRetention; return (
{daysSinceCompleted === undefined ? ( // NoLogsView only renders when the loader returns no trace. // Buffered runs always carry a synthetic trace (see // buildSyntheticTraceForBufferedRun) so they never reach // this branch — the message here is the pre-mollifier // copy for runs with no completedAt and no logs. We tidy up older logs to keep things running smoothly. ) : isWithinLogRetention ? ( Your log retention is {logRetention} days but these logs had already been deleted. From now on only logs from runs that completed {logRetention} days ago will be deleted. ) : daysSinceCompleted <= 30 ? ( Upgrade } > The logs for this run have been deleted because the run completed{" "} {daysSinceCompleted} days ago. Upgrade your plan to keep logs for longer. ) : ( We tidy up older logs to keep things running smoothly. )}
); } type TasksTreeViewProps = { events: TraceEvent[]; selectedId?: string; onSelectedIdChanged: (selectedId: string | undefined) => void; totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; rootStartedAt: Date | undefined; queuedDuration: number | undefined; environmentType: RuntimeEnvironmentType; shouldLiveReload: boolean; maximumLiveReloadingSetting: number; rootRun: { friendlyId: string; spanId: string; } | null; parentRun: { friendlyId: string; spanId: string; } | null; isCompleted: boolean; treeSnapshot?: ResizableSnapshot; }; function TasksTreeView({ events, selectedId, onSelectedIdChanged, totalDuration, rootSpanStatus, rootStartedAt, queuedDuration, environmentType, shouldLiveReload, maximumLiveReloadingSetting, rootRun, parentRun, isCompleted, treeSnapshot, }: TasksTreeViewProps) { const isAdmin = useHasAdminAccess(); const [filterText, setFilterText] = useState(""); const [errorsOnly, setErrorsOnly] = useState(false); const [showDurations, setShowDurations] = useState(true); const [showQueueTime, setShowQueueTime] = useState(false); const [scale, setScale] = useState(0); const parentRef = useRef(null); const treeScrollRef = useRef(null); const timelineScrollRef = useRef(null); const { value, replace } = useSearchParams(); const searchValue = value("showDebug"); const showDebug = searchValue !== undefined ? searchValue === "true" : false; const displayEvents = events; const queuedTime = showQueueTime ? undefined : queuedDuration; const { nodes, getTreeProps, getNodeProps, toggleNodeSelection, toggleExpandNode, expandAllBelowDepth, toggleExpandLevel, collapseAllBelowDepth, selectNode, scrollToNode, virtualizer, } = useTree({ tree: displayEvents, selectedId, // collapsedIds, onSelectedIdChanged, estimatedRowHeight: () => 32, parentRef, filter: { value: { text: filterText, errorsOnly }, fn: (value, node) => { const nodePassesErrorTest = (value.errorsOnly && node.data.isError) || !value.errorsOnly; if (!nodePassesErrorTest) return false; if (value.text === "") return true; if (node.data.message.toLowerCase().includes(value.text.toLowerCase())) { return true; } return false; }, }, }); return (
{isAdmin && ( { replace({ showDebug: checked ? "true" : "false", }); }} /> )} setShowQueueTime(e.valueOf())} shortcut={{ key: "Q" }} /> setErrorsOnly(e.valueOf())} />
{/* Tree list */}
{rootRun || parentRun ? ( ) : ( This is the root task )}
( <>
{ selectNode(node.id); }} >
{Array.from({ length: node.level }).map((_, index) => ( ))}
{ e.stopPropagation(); if (e.altKey) { if (state.expanded) { collapseAllBelowDepth(node.level); } else { expandAllBelowDepth(node.level); } } else { toggleExpandNode(node.id); } scrollToNode(node.id); }} > {node.hasChildren ? ( state.expanded ? ( ) : ( ) ) : (
)}
{node.data.isRoot && !rootRun && Root}
)} onScroll={(scrollTop) => { //sync the scroll to the tree if (timelineScrollRef.current) { timelineScrollRef.current.scrollTop = scrollTop; } }} />
{/* Timeline */}
Shortcuts Keyboard shortcuts
setScale(value[0])} min={0} max={1} step={0.05} />
); } type TimelineViewProps = Pick< TasksTreeViewProps, "totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration" > & { scale: number; parentRef: React.RefObject; timelineScrollRef: React.RefObject; virtualizer: Virtualizer; nodes: NodesState; getNodeProps: UseTreeStateOutput["getNodeProps"]; getTreeProps: UseTreeStateOutput["getTreeProps"]; toggleNodeSelection: UseTreeStateOutput["toggleNodeSelection"]; showDurations: boolean; treeScrollRef: React.RefObject; }; const tickCount = 5; function TimelineView({ totalDuration, scale, rootSpanStatus, rootStartedAt, timelineScrollRef, virtualizer, events, nodes, getNodeProps, getTreeProps, toggleNodeSelection, showDurations, treeScrollRef, queuedDuration, }: TimelineViewProps) { const timelineContainerRef = useRef(null); const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); const minTimelineWidth = initialTimelineDimensions?.width ?? 300; const maxTimelineWidth = minTimelineWidth * 10; const disableSpansAnimations = rootSpanStatus !== "executing"; //we want to live-update the duration if the root span is still executing const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration)); useEffect(() => { if (rootSpanStatus !== "executing" || !rootStartedAt) { setDuration(queueAdjustedNs(totalDuration, queuedDuration)); return; } const interval = setInterval(() => { setDuration( queueAdjustedNs( millisecondsToNanoseconds(Date.now() - rootStartedAt.getTime()), queuedDuration ) ); }, 500); return () => clearInterval(interval); }, [totalDuration, rootSpanStatus, queuedDuration, rootStartedAt]); return (
{/* Follows the cursor */} {/* The duration labels */} {(ms: number, index: number) => { if (index === tickCount - 1) return null; return ( {(ms) => (
{formatDurationMilliseconds(ms, { style: "short", maxDecimalPoints: ms < 1000 ? 0 : 1, })}
)}
); }}
{rootSpanStatus !== "executing" && ( {(ms) => (
{formatDurationMilliseconds(ms, { style: "short", maxDecimalPoints: ms < 1000 ? 0 : 1, })}
)}
)}
{(ms: number, index: number) => { if (index === 0 || index === tickCount - 1) return null; return ( ); }}
{/* Main timeline body */} {/* The vertical tick lines */} {(ms: number, index: number) => { if (index === 0) return null; return ; }} {/* The completed line */} {rootSpanStatus !== "executing" && ( )} { const isTopSpan = node.id === events[0]?.id; return ( console.log(`hover ${index}`)} onClick={(e) => { toggleNodeSelection(node.id); }} > {node.data.level === "TRACE" ? ( <> {/* Add a span for the line, Make the vertical line the first one with 1px wide, and full height */} {node.data.timelineEvents.map((event, eventIndex) => eventIndex === 0 ? ( {(ms) => ( )} ) : ( {(ms) => ( )} ) )} {node.data.timelineEvents && node.data.timelineEvents[0] && node.data.timelineEvents[0].offset < node.data.offset ? ( ) : null} ) : ( {(ms) => ( )} )} ); }} onScroll={(scrollTop) => { //sync the scroll to the tree if (treeScrollRef.current) { treeScrollRef.current.scrollTop = scrollTop; } }} />
); } function queueAdjustedNs(timeNs: number, queuedDurationNs: number | undefined) { if (queuedDurationNs) { return timeNs - queuedDurationNs; } return timeNs; } function NodeText({ node }: { node: TraceEvent }) { const className = "truncate"; // Only mark task-level spans as agent so the agents colour applies to // the task row itself, not unrelated sub-spans (wait/log/etc.) that // live underneath an agent run. const isAgentTaskRow = node.data.isAgentRun && (node.data.style?.icon === "task" || node.data.style?.icon === "task-cached"); return ( ); } function NodeStatusIcon({ node }: { node: TraceEvent }) { if (node.data.level !== "TRACE") return null; if (!node.data.style.variant) return null; if (node.data.style.variant === "warm") { return ; } else if (node.data.style.variant === "cold") { return ; } if (node.data.isCancelled) { return ( <> Canceled ); } if (node.data.isError) { return ; } if (node.data.isPartial) { return ; } return ; } function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boolean }) { return
; } function ShowParentOrRootLinks({ relationships, }: { relationships: { root?: { friendlyId: string; spanId: string; isParent?: boolean; }; parent?: { friendlyId: string; spanId: string; }; }; }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); // Case 1: Root is also the parent if (relationships.root?.isParent === true) { return ( Jump to root and parent run
} className="text-xs" > Root/parent ); } // Case 2: Root and Parent are different runs return (
{relationships.root && ( Jump to root run
} className="text-xs" > Root )} {relationships.parent && ( Jump to parent run
} className="text-xs" > Parent )} ); } function LiveReloadingStatus({ rootSpanCompleted, isLiveReloading, settingValue, }: { rootSpanCompleted: boolean; isLiveReloading: boolean; settingValue: number; }) { if (rootSpanCompleted) return null; return ( <> {isLiveReloading ? (
Live reloading
) : ( Live reloading disabled } > )} ); } function PulsingDot() { return ( ); } function SpanWithDuration({ showDuration, node, fadeLeft, disableAnimations, ...props }: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean; disableAnimations?: boolean; }) { return ( {node.data.isPartial && (
)} {formatDurationMilliseconds(props.durationMs, { style: "short", maxDecimalPoints: props.durationMs < 1000 ? 0 : 1, })} ); } const edgeBoundary = 0.17; function CurrentTimeIndicator({ totalDuration, rootStartedAt, queuedDurationNs, }: { totalDuration: number; rootStartedAt: Date | undefined; queuedDurationNs: number | undefined; }) { return ( {(ms) => { const ratio = ms / nanosecondsToMilliseconds(totalDuration); let offset = 0.5; if (ratio < edgeBoundary) { offset = lerp(0, 0.5, ratio / edgeBoundary); } else if (ratio > 1 - edgeBoundary) { offset = lerp(0.5, 1, (ratio - (1 - edgeBoundary)) / edgeBoundary); } const currentTime = rootStartedAt ? new Date( rootStartedAt.getTime() + ms + nanosecondsToMilliseconds(queuedDurationNs ?? 0) ) : undefined; const currentTimeComponent = currentTime ? : <>; return (
{currentTimeComponent ? ( {formatDurationMilliseconds(ms, { style: "short", maxDecimalPoints: ms < 1000 ? 0 : 1, })} {currentTimeComponent} ) : ( <> {formatDurationMilliseconds(ms, { style: "short", maxDecimalPoints: ms < 1000 ? 0 : 1, })} )}
); }} ); } function KeyboardShortcuts({ expandAllBelowDepth, collapseAllBelowDepth, toggleExpandLevel, }: { expandAllBelowDepth: (depth: number) => void; collapseAllBelowDepth: (depth: number) => void; toggleExpandLevel: (depth: number) => void; setShowDurations?: (show: (show: boolean) => boolean) => void; }) { return ( <> expandAllBelowDepth(0)} title="Expand all" /> collapseAllBelowDepth(1)} title="Collapse all" /> toggleExpandLevel(number)} /> {}} /> ); } function AdjacentRunsShortcuts() { return (
Next/previous run
); } function ArrowKeyShortcuts() { return (
Navigate
); } function ShortcutWithAction({ shortcut, title, action, }: { shortcut: Shortcut; title: string; action: () => void; }) { useShortcutKeys({ shortcut, action, }); return (
{title}
); } function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void }) { useHotkeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], (event, hotkeysEvent) => { toggleLevel(Number(event.key)); }); return (
0 9 Toggle level
); } function SearchField({ onChange }: { onChange: (value: string) => void }) { const [value, setValue] = useState(""); const updateFilterText = useDebounce((text: string) => { onChange(text); }, 250); const updateValue = useCallback((next: string) => { setValue(next); updateFilterText(next); }, []); return ; } function useAdjacentRunPaths({ organization, project, environment, tableState, run, runsList, tabParam, useSpan, }: { organization: { slug: string }; project: { slug: string }; environment: { slug: string }; tableState: string; run: { friendlyId: string; spanId: string }; runsList: RunsListNavigation | null; tabParam?: string; useSpan?: boolean; }): [string | null, string | null] { if (!runsList || runsList.runs.length === 0) { return [null, null]; } const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId); if (currentIndex === -1) { return [null, null]; } // Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list let previousRun: { friendlyId: string; spanId: string } | null = null; const previousRunTableState = new URLSearchParams(tableState); if (currentIndex > 0) { previousRun = runsList.runs[currentIndex - 1]; } else if (runsList.prevPageLastRun) { previousRun = runsList.prevPageLastRun; // Update tableState with the new cursor for the previous page previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor); previousRunTableState.set("direction", "backward"); } // Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list let nextRun: { friendlyId: string; spanId: string } | null = null; const nextRunTableState = new URLSearchParams(tableState); if (currentIndex < runsList.runs.length - 1) { nextRun = runsList.runs[currentIndex + 1]; } else if (runsList.nextPageFirstRun) { nextRun = runsList.nextPageFirstRun; // Update tableState with the new cursor for the next page nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor); nextRunTableState.set("direction", "forward"); } const previousURLSearchParams = new URLSearchParams(); previousURLSearchParams.set("tableState", previousRunTableState.toString()); if (previousRun && useSpan) { previousURLSearchParams.set("span", previousRun.spanId); } if (tabParam && useSpan) { previousURLSearchParams.set("tab", tabParam); } const previousRunPath = previousRun ? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams) : null; const nextURLSearchParams = new URLSearchParams(); nextURLSearchParams.set("tableState", nextRunTableState.toString()); if (nextRun && useSpan) { nextURLSearchParams.set("span", nextRun.spanId); } if (tabParam && useSpan) { nextURLSearchParams.set("tab", tabParam); } const nextRunPath = nextRun ? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams) : null; return [previousRunPath, nextRunPath]; } function PreviousRunButton({ to }: { to: string | null }) { return (
!to && e.preventDefault()} shortcut={{ key: "j" }} tooltip="Previous Run" disabled={!to} replace />
); } function NextRunButton({ to }: { to: string | null }) { return (
!to && e.preventDefault()} shortcut={{ key: "k" }} tooltip="Next Run" disabled={!to} replace />
); }