From c5aaabdb4ec46c19cbcf5717be4a4d2999da2f44 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 19 Mar 2024 12:13:48 +0000 Subject: [PATCH] The run timeline updates on the client if there's no new data (#955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use “@v3” instead of “@latest” for the npx commands for v3 * Split the Timeline into a component so it can live refresh without re-rendering everything * Timeline now live refreshes on the client every 500ms when run is executing * The timeline bars now animate their position/width when it changes * Export some more types from TreeView --- apps/webapp/app/components/SetupCommands.tsx | 13 +- .../primitives/TreeView/TreeView.tsx | 5 +- .../app/presenters/v3/RunPresenter.server.ts | 24 +- .../route.tsx | 463 +++++++++++------- 4 files changed, 301 insertions(+), 204 deletions(-) diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index fe73742b8..8d94aa629 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -131,6 +131,7 @@ export function TriggerDevStep({ extra }: { extra?: string }) { } // Trigger.dev version 3 setup commands +const v3PackageTag = "v3"; export function InitCommandV3() { const project = useProject(); @@ -147,7 +148,7 @@ export function InitCommandV3() { variant="primary/medium" iconButton className="mb-4" - value={`npx trigger.dev@latest init -p ${projectRef}`} + value={`npx trigger.dev@${v3PackageTag} init -p ${projectRef}`} /> @@ -155,7 +156,7 @@ export function InitCommandV3() { variant="primary/medium" iconButton className="mb-4" - value={`pnpm dlx trigger.dev@latest init -p ${projectRef}`} + value={`pnpm dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`} /> @@ -163,7 +164,7 @@ export function InitCommandV3() { variant="primary/medium" iconButton className="mb-4" - value={`yarn dlx trigger.dev@latest init -p ${projectRef}`} + value={`yarn dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`} /> @@ -183,7 +184,7 @@ export function TriggerDevStepV3() { variant="primary/medium" iconButton className="mb-4" - value={`npx trigger.dev@latest dev`} + value={`npx trigger.dev@${v3PackageTag} dev`} /> @@ -191,7 +192,7 @@ export function TriggerDevStepV3() { variant="primary/medium" iconButton className="mb-4" - value={`pnpm dlx trigger.dev@latest dev`} + value={`pnpm dlx trigger.dev@${v3PackageTag} dev`} /> @@ -199,7 +200,7 @@ export function TriggerDevStepV3() { variant="primary/medium" iconButton className="mb-4" - value={`yarn dlx trigger.dev@latest dev`} + value={`yarn dlx trigger.dev@${v3PackageTag} dev`} /> diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index 69b437e13..b35917d54 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -23,6 +23,9 @@ export type TreeViewProps = { onScroll?: (scrollTop: number) => void; } & Pick; +export type GetTreePropsFn = UseTreeStateOutput["getTreeProps"]; +export type GetNodePropsFn = UseTreeStateOutput["getNodeProps"]; + export function TreeView({ tree, renderNode, @@ -144,7 +147,7 @@ type HTMLAttributes = Omit< "onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag" >; -type UseTreeStateOutput = { +export type UseTreeStateOutput = { selected: string | undefined; nodes: NodesState; virtualizer: Virtualizer; diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index c55ddc4b2..34d5c87b1 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -79,25 +79,22 @@ export class RunPresenter { n.data.startTime.getTime() - treeRootStartTimeMs ); totalDuration = Math.max(totalDuration, offset + n.data.duration); - return { ...n, data: { ...n.data, offset, isRoot: n.id === traceSummary.rootSpan.id } }; + return { + ...n, + data: { + ...n.data, + //set partial nodes to null duration + duration: n.data.isPartial ? null : n.data.duration, + offset, + isRoot: n.id === traceSummary.rootSpan.id, + }, + }; }) : []; - //if any elements are partial we want the total duration to represent all of the time until now - totalDuration = events.some((e) => e.data.isPartial) - ? millisecondsToNanoseconds(Date.now() - treeRootStartTimeMs) - : totalDuration; - //total duration should be a minimum of 1ms totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1)); - //we need to adjust any partial nodes so they run the full duration - for (const event of events) { - if (event.data.isPartial) { - event.data.duration = totalDuration - event.data.offset; - } - } - let rootSpanStatus: "executing" | "completed" | "failed" = "executing"; if (events[0]) { if (events[0].data.isError) { @@ -123,6 +120,7 @@ export class RunPresenter { parentRunFriendlyId: tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId, duration: totalDuration, + rootStartedAt: tree?.data.startTime, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx index 4ec9abbdb..b9323eed1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam/route.tsx @@ -4,9 +4,16 @@ import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, } from "@heroicons/react/20/solid"; +import { Time } from "@internationalized/date"; import { Link, Outlet, useNavigate, useParams, useRevalidator } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { formatDurationMilliseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3"; +import { Virtualizer } from "@tanstack/react-virtual"; +import { + formatDurationMilliseconds, + millisecondsToNanoseconds, + nanosecondsToMilliseconds, +} from "@trigger.dev/core/v3"; +import { motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon"; @@ -26,7 +33,15 @@ import { import { Slider } from "~/components/primitives/Slider"; import { Switch } from "~/components/primitives/Switch"; import * as Timeline from "~/components/primitives/Timeline"; -import { TreeView, useTree } from "~/components/primitives/TreeView/TreeView"; +import { + GetNodePropsFn, + GetTreePropsFn, + TreeView, + TreeViewProps, + UseTreeStateOutput, + useTree, +} from "~/components/primitives/TreeView/TreeView"; +import { NodesState } from "~/components/primitives/TreeView/reducer"; import { RunIcon } from "~/components/runs/v3/RunIcon"; import { SpanTitle, eventBackgroundClassName } from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; @@ -55,7 +70,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params); const presenter = new RunPresenter(); - const { run, events, parentRunFriendlyId, duration, rootSpanStatus } = await presenter.call({ + const result = await presenter.call({ userId, organizationSlug, projectSlug: projectParam, @@ -66,12 +81,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const resizeSettings = await getResizableRunSettings(request); return typedjson({ - run, - events, - parentRunFriendlyId, + ...result, resizeSettings, - duration, - rootSpanStatus, }); }; @@ -82,8 +93,15 @@ function getSpanId(path: string): string | undefined { } export default function Page() { - const { run, events, parentRunFriendlyId, resizeSettings, duration, rootSpanStatus } = - useTypedLoaderData(); + const { + run, + events, + parentRunFriendlyId, + resizeSettings, + duration, + rootSpanStatus, + rootStartedAt, + } = useTypedLoaderData(); const navigate = useNavigate(); const organization = useOrganization(); const pathName = usePathName(); @@ -142,6 +160,7 @@ export default function Page() { }} totalDuration={duration} rootSpanStatus={rootSpanStatus} + rootStartedAt={rootStartedAt} /> ) : ( @@ -183,7 +203,15 @@ export default function Page() { ); } -const tickCount = 5; +type TasksTreeViewProps = { + events: RunEvent[]; + selectedId?: string; + parentRunFriendlyId?: string; + onSelectedIdChanged: (selectedId: string | undefined) => void; + totalDuration: number; + rootSpanStatus: "executing" | "completed" | "failed"; + rootStartedAt: Date | undefined; +}; function TasksTreeView({ events, @@ -192,14 +220,8 @@ function TasksTreeView({ onSelectedIdChanged, totalDuration, rootSpanStatus, -}: { - events: RunEvent[]; - selectedId?: string; - parentRunFriendlyId?: string; - onSelectedIdChanged: (selectedId: string | undefined) => void; - totalDuration: number; - rootSpanStatus: "executing" | "completed" | "failed"; -}) { + rootStartedAt, +}: TasksTreeViewProps) { const [filterText, setFilterText] = useState(""); const [errorsOnly, setErrorsOnly] = useState(false); const [showDurations, setShowDurations] = useState(false); @@ -207,8 +229,6 @@ function TasksTreeView({ const parentRef = useRef(null); const treeScrollRef = useRef(null); const timelineScrollRef = useRef(null); - const timelineContainerRef = useRef(null); - const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); const { nodes, @@ -238,9 +258,6 @@ function TasksTreeView({ }, }); - const minTimelineWidth = initialTimelineDimensions?.width ?? 300; - const maxTimelineWidth = minTimelineWidth * 10; - return (
@@ -378,170 +395,247 @@ function TasksTreeView({ {/* Timeline */} -
- - {/* 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" && ( - +type TimelineViewProps = Pick< + TasksTreeViewProps, + "totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" +> & { + 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, + parentRef, + timelineScrollRef, + virtualizer, + events, + nodes, + getNodeProps, + getTreeProps, + toggleNodeSelection, + showDurations, + treeScrollRef, +}: TimelineViewProps) { + const timelineContainerRef = useRef(null); + const initialTimelineDimensions = useInitialDimensions(timelineContainerRef); + const minTimelineWidth = initialTimelineDimensions?.width ?? 300; + const maxTimelineWidth = minTimelineWidth * 10; + + //we want to live-update the duration if the root span is still executing + const [duration, setDuration] = useState(totalDuration); + useEffect(() => { + if (rootSpanStatus !== "executing" || !rootStartedAt) { + setDuration(totalDuration); + return; + } + + const interval = setInterval(() => { + setDuration(millisecondsToNanoseconds(Date.now() - rootStartedAt.getTime())); + }, 500); + + return () => clearInterval(interval); + }, [totalDuration, rootSpanStatus]); + + 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" && ( + + )} + { + return ( + console.log(`hover ${index}`)} + onClick={(e) => { + toggleNodeSelection(node.id); + }} + > + {node.data.level === "TRACE" ? ( + + ) : ( + {(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" && ( - - )} - { - return ( - console.log(`hover ${index}`)} - onClick={(e) => { - toggleNodeSelection(node.id); - }} - > - {node.data.level === "TRACE" ? ( - - ) : ( - - )} - - ); - }} - onScroll={(scrollTop) => { - //sync the scroll to the tree - if (treeScrollRef.current && treeScrollRef.current.scrollTop !== scrollTop) { - treeScrollRef.current.scrollTop = scrollTop; - } - }} - /> - -
-
-
-
- + ); + }} + onScroll={(scrollTop) => { + //sync the scroll to the tree + if (treeScrollRef.current && treeScrollRef.current.scrollTop !== scrollTop) { + treeScrollRef.current.scrollTop = scrollTop; + } + }} + /> + + +
); } @@ -659,11 +753,12 @@ function SpanWithDuration({ }: Timeline.SpanProps & { node: RunEvent; showDuration: boolean }) { return ( -
{node.data.isPartial && (
-
+
); }