From 3913e57ef4e362b4e186900e36fff3459628ee07 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 22 Apr 2024 13:46:08 +0100 Subject: [PATCH] Trace view fixes and improvements (#1046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Query param for span using history.replaceState is working * When clicking again on a node, don’t collapse it * Close the span view using the same replacing of the search param * Conditional rendering of the resize panels was causing the tree view re-rendering and collapsing… * Live reloading moved to where the parent label is * Span action bar is now deeper * WIP on trace view navigation changes with shortcuts * Shortcuts for expanding and collapsing en masse * Number keys expand/collapse levels * Changed duration toggle to a shortcut key * Option + click expands/collapse at that level * Option/alt left/right expands/collapse at that level * Removed unused imports * Link from the runs table to the specific span * Latest lockfile * Sorted imports * When doing a test link directly to a span * Replay links to the span * CLI log links go directly to a span * Keyboard shortcuts are in a popover if the width is narrow * If holding alt only collapse level * Don’t expand the individual node if you’re holding alt --- .../app/components/primitives/ShortcutKey.tsx | 24 +- .../primitives/TreeView/TreeView.tsx | 76 ++++- .../components/primitives/TreeView/reducer.ts | 148 +++++++++ .../app/components/runs/v3/TaskRunsTable.tsx | 19 +- apps/webapp/app/hooks/useReplaceLocation.ts | 32 ++ .../presenters/v3/RunListPresenter.server.ts | 3 + .../route.tsx | 300 ++++++++++++------ .../route.tsx | 10 +- .../projects.v3.$projectRef.runs.$runParam.ts | 15 +- .../route.tsx | 66 +++- .../resources.taskruns.$runParam.replay.ts | 7 +- apps/webapp/app/utils/pathBuilder.ts | 4 +- 12 files changed, 574 insertions(+), 130 deletions(-) create mode 100644 apps/webapp/app/hooks/useReplaceLocation.ts rename apps/webapp/app/routes/{_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam => resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam}/route.tsx (88%) diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index 1bf0e3cbce..df422eaf2c 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -2,8 +2,14 @@ import { Fragment } from "react"; import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { useOperatingSystem } from "./OperatingSystemProvider"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, +} from "@heroicons/react/20/solid"; -const variants = { +export const variants = { small: "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase", medium: @@ -23,7 +29,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) const isMac = platform === "mac"; let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut; const modifiers = relevantShortcut.modifiers ?? []; - const character = keyString(relevantShortcut.key, isMac); + const character = keyString(relevantShortcut.key, isMac, variant); return ( @@ -35,10 +41,22 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) ); } -function keyString(key: String, isMac: boolean) { +function keyString(key: String, isMac: boolean, size: "small" | "medium") { + key = key.toLowerCase(); + + const className = size === "small" ? "w-2.5 h-4" : "w-3 h-5"; + switch (key) { case "enter": return isMac ? "↵" : key; + case "arrowdown": + return ; + case "arrowup": + return ; + case "arrowleft": + return ; + case "arrowright": + return ; default: return key; } diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index e97be55302..e07bbe8899 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -1,10 +1,10 @@ import { VirtualItem, Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; +import { motion } from "framer-motion"; import { MutableRefObject, RefObject, useCallback, useEffect, useReducer, useRef } from "react"; import { UnmountClosed } from "react-collapse"; import { cn } from "~/utils/cn"; import { NodeState, NodesState, reducer } from "./reducer"; import { applyFilterToState, concreteStateFromInput, selectedIdFromState } from "./utils"; -import { motion } from "framer-motion"; export type TreeViewProps = { tree: FlatTree; @@ -165,6 +165,11 @@ export type UseTreeStateOutput = { expandNode: (id: string, scrollToNode?: boolean) => void; collapseNode: (id: string) => void; toggleExpandNode: (id: string, scrollToNode?: boolean) => void; + expandAllBelowDepth: (depth: number) => void; + collapseAllBelowDepth: (depth: number) => void; + expandLevel: (level: number) => void; + collapseLevel: (level: number) => void; + toggleExpandLevel: (level: number) => void; selectFirstVisibleNode: (scrollToNode?: boolean) => void; selectLastVisibleNode: (scrollToNode?: boolean) => void; selectNextVisibleNode: (scrollToNode?: boolean) => void; @@ -333,6 +338,41 @@ export function useTree({ [state] ); + const expandAllBelowDepth = useCallback( + (depth: number) => { + dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { tree, depth } }); + }, + [state] + ); + + const collapseAllBelowDepth = useCallback( + (depth: number) => { + dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { tree, depth } }); + }, + [state] + ); + + const expandLevel = useCallback( + (level: number) => { + dispatch({ type: "EXPAND_LEVEL", payload: { tree, level } }); + }, + [state] + ); + + const collapseLevel = useCallback( + (level: number) => { + dispatch({ type: "COLLAPSE_LEVEL", payload: { tree, level } }); + }, + [state] + ); + + const toggleExpandLevel = useCallback( + (level: number) => { + dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { tree, level } }); + }, + [state] + ); + const getTreeProps = useCallback(() => { return { role: "tree", @@ -368,25 +408,48 @@ export function useTree({ } case "Left": case "ArrowLeft": { + e.preventDefault(); + const selected = selectedIdFromState(state.nodes); if (selected) { const treeNode = tree.find((node) => node.id === selected); - if (treeNode && treeNode.hasChildren && state.nodes[selected].expanded) { + + if (e.altKey) { + if (treeNode && treeNode.hasChildren) { + collapseLevel(treeNode.level); + } + break; + } + + const shouldCollapse = + treeNode && treeNode.hasChildren && state.nodes[selected].expanded; + if (shouldCollapse) { collapseNode(selected); } else { selectParentNode(true); } } - e.preventDefault(); + break; } case "Right": case "ArrowRight": { + e.preventDefault(); + const selected = selectedIdFromState(state.nodes); + if (selected) { + const treeNode = tree.find((node) => node.id === selected); + + if (e.altKey) { + if (treeNode && treeNode.hasChildren) { + expandLevel(treeNode.level); + } + break; + } + expandNode(selected, true); } - e.preventDefault(); break; } case "Escape": { @@ -427,6 +490,11 @@ export function useTree({ expandNode, collapseNode, toggleExpandNode, + expandAllBelowDepth, + collapseAllBelowDepth, + expandLevel, + collapseLevel, + toggleExpandLevel, selectFirstVisibleNode, selectLastVisibleNode, selectNextVisibleNode, diff --git a/apps/webapp/app/components/primitives/TreeView/reducer.ts b/apps/webapp/app/components/primitives/TreeView/reducer.ts index 027b024794..4a8180d4cb 100644 --- a/apps/webapp/app/components/primitives/TreeView/reducer.ts +++ b/apps/webapp/app/components/primitives/TreeView/reducer.ts @@ -91,6 +91,46 @@ type ToggleExpandNodeAction = { } & WithScrollToNode; }; +type ExpandAllBelowDepthAction = { + type: "EXPAND_ALL_BELOW_DEPTH"; + payload: { + depth: number; + tree: FlatTree; + }; +}; + +type CollapseAllBelowDepthAction = { + type: "COLLAPSE_ALL_BELOW_DEPTH"; + payload: { + depth: number; + tree: FlatTree; + }; +}; + +type ExpandLevelAction = { + type: "EXPAND_LEVEL"; + payload: { + level: number; + tree: FlatTree; + }; +}; + +type CollapseLevelAction = { + type: "COLLAPSE_LEVEL"; + payload: { + level: number; + tree: FlatTree; + }; +}; + +type ToggleExpandLevelAction = { + type: "TOGGLE_EXPAND_LEVEL"; + payload: { + level: number; + tree: FlatTree; + }; +}; + type SelectFirstVisibleNodeAction = { type: "SELECT_FIRST_VISIBLE_NODE"; payload: { @@ -135,6 +175,11 @@ export type Action = | ExpandNodeAction | CollapseNodeAction | ToggleExpandNodeAction + | ExpandAllBelowDepthAction + | CollapseAllBelowDepthAction + | ExpandLevelAction + | CollapseLevelAction + | ToggleExpandLevelAction | SelectFirstVisibleNodeAction | SelectLastVisibleNodeAction | SelectNextVisibleNodeAction @@ -229,6 +274,109 @@ export function reducer(state: TreeState, action: Action): TreeState { }); } } + case "EXPAND_ALL_BELOW_DEPTH": { + const nodesToExpand = action.payload.tree.filter( + (n) => n.level >= action.payload.depth && n.hasChildren + ); + + const newNodes = Object.fromEntries( + Object.entries(state.nodes).map(([key, value]) => [ + key, + { + ...value, + expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded, + }, + ]) + ); + + const visibleNodes = applyVisibility(action.payload.tree, newNodes); + return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) }; + } + case "COLLAPSE_ALL_BELOW_DEPTH": { + const nodesToCollapse = action.payload.tree.filter( + (n) => n.level >= action.payload.depth && n.hasChildren + ); + + const newNodes = Object.fromEntries( + Object.entries(state.nodes).map(([key, value]) => [ + key, + { + ...value, + expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded, + }, + ]) + ); + + const visibleNodes = applyVisibility(action.payload.tree, newNodes); + return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) }; + } + case "EXPAND_LEVEL": { + const nodesToExpand = action.payload.tree.filter( + (n) => n.level <= action.payload.level && n.hasChildren + ); + + const newNodes = Object.fromEntries( + Object.entries(state.nodes).map(([key, value]) => [ + key, + { + ...value, + expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded, + }, + ]) + ); + + const visibleNodes = applyVisibility(action.payload.tree, newNodes); + return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) }; + } + case "COLLAPSE_LEVEL": { + const nodesToCollapse = action.payload.tree.filter( + (n) => n.level === action.payload.level && n.hasChildren + ); + + const newNodes = Object.fromEntries( + Object.entries(state.nodes).map(([key, value]) => [ + key, + { + ...value, + expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded, + }, + ]) + ); + + const visibleNodes = applyVisibility(action.payload.tree, newNodes); + return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) }; + } + case "TOGGLE_EXPAND_LEVEL": { + //first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level + //if it is collapsed, expand all nodes at that level + const nodesAtLevel = action.payload.tree.filter( + (n) => n.level === action.payload.level && n.hasChildren + ); + const firstNode = nodesAtLevel[0]; + if (!firstNode) { + return state; + } + + const currentlyExpanded = state.nodes[firstNode.id]?.expanded ?? true; + const currentVisible = state.nodes[firstNode.id]?.visible ?? true; + if (currentlyExpanded && currentVisible) { + return reducer(state, { + type: "COLLAPSE_LEVEL", + payload: { + level: action.payload.level, + tree: action.payload.tree, + }, + }); + } else { + return reducer(state, { + type: "EXPAND_LEVEL", + payload: { + level: action.payload.level, + tree: action.payload.tree, + }, + }); + } + } case "SELECT_FIRST_VISIBLE_NODE": { const node = firstVisibleNode(action.payload.tree, state.nodes); if (node) { diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index bc2c7bf1b6..f53f615699 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -1,10 +1,16 @@ +import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid"; import { StopIcon } from "@heroicons/react/24/outline"; import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; +import { useLocation } from "@remix-run/react"; +import { formatDuration } from "@trigger.dev/core/v3"; import { User } from "@trigger.dev/database"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; +import { useEnvironments } from "~/hooks/useEnvironments"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server"; -import { docsPath, v3RunPath, v3TestPath } from "~/utils/pathBuilder"; +import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder"; import { EnvironmentLabel } from "../../environments/EnvironmentLabel"; import { DateTime } from "../../primitives/DateTime"; import { Paragraph } from "../../primitives/Paragraph"; @@ -14,21 +20,14 @@ import { TableBlankRow, TableBody, TableCell, - TableCellChevron, TableCellMenu, TableHeader, TableHeaderCell, TableRow, } from "../../primitives/Table"; -import { formatDuration } from "@trigger.dev/core/v3"; -import { TaskRunStatusCombo } from "./TaskRunStatus"; -import { useEnvironments } from "~/hooks/useEnvironments"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid"; -import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { CancelRunDialog } from "./CancelRunDialog"; -import { useLocation } from "@remix-run/react"; import { ReplayRunDialog } from "./ReplayRunDialog"; +import { TaskRunStatusCombo } from "./TaskRunStatus"; type RunsTableProps = { total: number; @@ -78,7 +77,7 @@ export function TaskRunsTable({ ) : ( runs.map((run) => { - const path = v3RunPath(organization, project, run); + const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId }); const usernameForEnv = currentUser.id !== run.environment.userId ? run.environment.userName : undefined; return ( diff --git a/apps/webapp/app/hooks/useReplaceLocation.ts b/apps/webapp/app/hooks/useReplaceLocation.ts new file mode 100644 index 0000000000..0221ff8f5e --- /dev/null +++ b/apps/webapp/app/hooks/useReplaceLocation.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from "react"; +import { useOptimisticLocation } from "./useOptimisticLocation"; +import type { Location } from "@remix-run/react"; + +export function useReplaceLocation() { + const optimisticLocation = useOptimisticLocation(); + const [location, setLocation] = useState(optimisticLocation); + + const replaceLocation = useCallback((location: Location) => { + const fullPath = location.pathname + location.search + location.hash; + //replace the URL in the browser + history.replaceState(null, "", fullPath); + //update the state (new object in case the same location ref was modified) + const newLocation = { ...location }; + setLocation(newLocation); + }, []); + + const replaceSearchParam = useCallback( + (key: string, value?: string) => { + const searchParams = new URLSearchParams(location.search); + if (value) { + searchParams.set(key, value); + } else { + searchParams.delete(key); + } + replaceLocation({ ...optimisticLocation, search: "?" + searchParams.toString() }); + }, + [optimisticLocation, replaceLocation] + ); + + return { location, replaceLocation, replaceSearchParam }; +} diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 9eb2f8fd52..d44678b7e6 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -106,6 +106,7 @@ export class RunListPresenter { lockedAt: Date | null; completedAt: Date | null; isTest: boolean; + spanId: string; attempts: BigInt; }[] >` @@ -121,6 +122,7 @@ export class RunListPresenter { tr."lockedAt" AS "lockedAt", tra."completedAt" AS "completedAt", tr."isTest" AS "isTest", + tr."spanId" AS "spanId", COUNT(tra.id) AS attempts FROM ${sqlDatabaseSchema}."TaskRun" tr @@ -225,6 +227,7 @@ export class RunListPresenter { status: run.status, version: run.version, taskIdentifier: run.taskIdentifier, + spanId: run.spanId, attempts: Number(run.attempts), isReplayable: true, isCancellable: CANCELLABLE_STATUSES.includes(run.status), 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 1133ff7e17..e24696e376 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 @@ -1,10 +1,13 @@ import { + ArrowsPointingInIcon, + ArrowsPointingOutIcon, ChevronDownIcon, ChevronRightIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, } from "@heroicons/react/20/solid"; -import { Outlet, useNavigate, useParams, useRevalidator } from "@remix-run/react"; +import type { Location } from "@remix-run/react"; +import { useParams, useRevalidator } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Virtualizer } from "@tanstack/react-virtual"; import { @@ -23,7 +26,7 @@ import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Input } from "~/components/primitives/Input"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -33,6 +36,7 @@ import { ResizablePanel, ResizablePanelGroup, } 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"; @@ -45,8 +49,9 @@ import { useDebounce } from "~/hooks/useDebounce"; import { useEventSource } from "~/hooks/useEventSource"; import { useInitialDimensions } from "~/hooks/useInitialDimensions"; import { useOrganization } from "~/hooks/useOrganizations"; -import { usePathName } from "~/hooks/usePathName"; import { useProject } from "~/hooks/useProject"; +import { useReplaceLocation } from "~/hooks/useReplaceLocation"; +import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useUser } from "~/hooks/useUser"; import { RunEvent, RunPresenter } from "~/presenters/v3/RunPresenter.server"; import { getResizableRunSettings, setResizableRunSettings } from "~/services/resizablePanel"; @@ -60,6 +65,11 @@ import { v3RunStreamingPath, v3RunsPath, } from "~/utils/pathBuilder"; +import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route"; +import { number } from "zod"; +import { useHotkeys } from "react-hotkeys-hook"; +import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; +import { Header3 } from "~/components/primitives/Headers"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -82,19 +92,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); }; -function getSpanId(path: string): string | undefined { - const regex = /spans\/([^\/]*)/; - const match = path.match(regex); - return match ? match[1] : undefined; +function getSpanId(location: Location): string | undefined { + const search = new URLSearchParams(location.search); + return search.get("span") ?? undefined; } export default function Page() { const { run, trace, resizeSettings } = useTypedLoaderData(); - const navigate = useNavigate(); const organization = useOrganization(); - const pathName = usePathName(); const project = useProject(); const user = useUser(); + const { location, replaceSearchParam } = useReplaceLocation(); + const selectedSpanId = getSpanId(location); const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined; @@ -133,10 +142,8 @@ export default function Page() { const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt } = trace; - const selectedSpanId = getSpanId(pathName); - const changeToSpan = useDebounce((selectedSpan: string) => { - navigate(v3RunSpanPath(organization, project, run, { spanId: selectedSpan })); + replaceSearchParam("span", selectedSpan); }, 250); const revalidator = useRevalidator(); @@ -166,62 +173,47 @@ export default function Page() {
- {selectedSpanId === undefined ? ( - { - //instantly close the panel if no span is selected - if (!selectedSpan) { - navigate(v3RunPath(organization, project, run)); - return; - } - - changeToSpan(selectedSpan); - }} - totalDuration={duration} - rootSpanStatus={rootSpanStatus} - rootStartedAt={rootStartedAt} - environmentType={run.environment.type} - /> - ) : ( - { - if (layout.length !== 2) return; - setResizableRunSettings(document, layout); - }} - > - - { - //instantly close the panel if no span is selected - if (!selectedSpan) { - navigate(v3RunPath(organization, project, run)); - return; - } - - changeToSpan(selectedSpan); - }} - totalDuration={duration} - rootSpanStatus={rootSpanStatus} - rootStartedAt={rootStartedAt} - environmentType={run.environment.type} - /> - - + { + if (layout.length !== 2) return; + if (!selectedSpanId) return; + setResizableRunSettings(document, layout); + }} + > + + { + //instantly close the panel if no span is selected + if (!selectedSpan) { + replaceSearchParam("span"); + return; + } + + changeToSpan(selectedSpan); + }} + totalDuration={duration} + rootSpanStatus={rootSpanStatus} + rootStartedAt={rootStartedAt} + environmentType={run.environment.type} + /> + + + {selectedSpanId && ( - + replaceSearchParam("span")} + /> - - )} + )} +
@@ -263,6 +255,9 @@ function TasksTreeView({ getNodeProps, toggleNodeSelection, toggleExpandNode, + expandAllBelowDepth, + toggleExpandLevel, + collapseAllBelowDepth, selectNode, scrollToNode, virtualizer, @@ -286,7 +281,7 @@ function TasksTreeView({ }); return ( -
+
setFilterText(e.target.value)} />
- setErrorsOnly(e.valueOf())} /> - setShowDurations(e.valueOf())} - /> - setScale(value[0])} - min={0} - max={1} - step={0.05} - />
-
+
{parentRunFriendlyId ? ( ) : ( - + This is the root task )} +
{ - toggleNodeSelection(node.id); + selectNode(node.id); }} >
@@ -379,7 +357,15 @@ function TasksTreeView({ )} onClick={(e) => { e.stopPropagation(); - toggleExpandNode(node.id); + if (e.altKey) { + if (state.expanded) { + collapseAllBelowDepth(node.level); + } else { + expandAllBelowDepth(node.level); + } + } else { + toggleExpandNode(node.id); + } scrollToNode(node.id); }} > @@ -445,6 +431,50 @@ function TasksTreeView({ /> +
+
+
+ +
+
+ + Shortcuts + + Keyboard shortcuts +
+ +
+
+
+
+
+
+ setScale(value[0])} + min={0} + max={1} + step={0.05} + /> +
+
); } @@ -738,6 +768,7 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) { fullWidth textAlignLeft shortcut={{ key: "p" }} + className="flex-1" > {mouseOver ? ( @@ -884,3 +915,92 @@ function ConnectedDevWarning() {
); } + +function KeyboardShortcuts({ + expandAllBelowDepth, + collapseAllBelowDepth, + toggleExpandLevel, + setShowDurations, +}: { + 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)} /> + setShowDurations((d) => !d)} + title="Toggle durations" + /> + + ); +} + +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 + +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx index d5275630d2..7e2c506e28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx @@ -6,7 +6,6 @@ import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runt import { TaskRunStatus } from "@trigger.dev/database"; import { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; import { JSONEditor } from "~/components/code/JSONEditor"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; import { Button } from "~/components/primitives/Buttons"; @@ -37,7 +36,7 @@ import { TestTaskPresenter, } from "~/presenters/v3/TestTaskPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { docsPath, v3RunPath, v3TaskParamsSchema } from "~/utils/pathBuilder"; +import { docsPath, v3RunSpanPath, v3TaskParamsSchema } from "~/utils/pathBuilder"; import { TestTaskService } from "~/v3/services/testTask.server"; import { TestTaskData } from "~/v3/testTask"; @@ -77,7 +76,12 @@ export const action: ActionFunction = async ({ request, params }) => { } return redirectWithSuccessMessage( - v3RunPath({ slug: organizationSlug }, { slug: projectParam }, { friendlyId: run.friendlyId }), + v3RunSpanPath( + { slug: organizationSlug }, + { slug: projectParam }, + { friendlyId: run.friendlyId }, + { spanId: run.spanId } + ), request, "Test run created" ); diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts b/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts index 47a28920d9..57edac645d 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts @@ -2,6 +2,7 @@ import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; +import { v3RunSpanPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -33,8 +34,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return new Response("Not found", { status: 404 }); } + const run = await prisma.taskRun.findUnique({ + where: { + friendlyId: validatedParams.runParam, + }, + }); + + if (!run) { + throw new Response("Not found", { status: 404 }); + } + // Redirect to the project's runs page return redirect( - `/orgs/${project.organization.slug}/projects/v3/${project.slug}/runs/${validatedParams.runParam}` + v3RunSpanPath({ slug: project.organization.slug }, { slug: project.slug }, run, { + spanId: run.spanId, + }) ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx similarity index 88% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx index cc919167f2..d28e5ee3b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx @@ -4,10 +4,11 @@ import { QueueListIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; -import { useParams } from "@remix-run/react"; +import { useFetcher, useParams } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useEffect } from "react"; +import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { CodeBlock } from "~/components/code/CodeBlock"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; @@ -17,6 +18,7 @@ import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Property, PropertyTable } from "~/components/primitives/PropertyTable"; +import { Spinner } from "~/components/primitives/Spinner"; import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog"; import { LiveTimer } from "~/components/runs/v3/LiveTimer"; import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog"; @@ -58,19 +60,57 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ span }); }; -export default function Page() { - const { - span: { event }, - } = useTypedLoaderData(); +export function SpanView({ + runParam, + spanId, + closePanel, +}: { + runParam: string; + spanId: string | undefined; + closePanel: () => void; +}) { const organization = useOrganization(); const project = useProject(); - const { runParam } = useParams(); + const fetcher = useTypedFetcher(); + + useEffect(() => { + if (spanId === undefined) return; + fetcher.load( + `/resources/orgs/${organization.slug}/projects/v3/${project.slug}/runs/${runParam}/spans/${spanId}` + ); + }, [organization.slug, project.slug, runParam, spanId]); + + if (spanId === undefined) { + return null; + } + + if (fetcher.state !== "idle" || fetcher.data === undefined) { + return ( +
+
+
+
+
+
+ +
+
+ ); + } + + const { + span: { event }, + } = fetcher.data; return (
@@ -85,8 +125,8 @@ export default function Page() {
{runParam && ( - @@ -216,7 +256,7 @@ function RunActionButtons({ span }: { span: Span }) { return ( - @@ -236,7 +276,7 @@ function RunActionButtons({ span }: { span: Span }) { return ( - diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 19caf4a070..0abd29f143 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { v3RunPath } from "~/utils/pathBuilder"; +import { v3RunSpanPath } from "~/utils/pathBuilder"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; const FormSchema = z.object({ @@ -54,12 +54,13 @@ export const action: ActionFunction = async ({ request, params }) => { ); } - const runPath = v3RunPath( + const runPath = v3RunSpanPath( { slug: taskRun.project.organization.slug, }, { slug: taskRun.project.slug }, - { friendlyId: newRun.friendlyId } + { friendlyId: newRun.friendlyId }, + { spanId: newRun.spanId } ); return redirectWithSuccessMessage(runPath, request, `Replaying run`); diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 1a7a729a9a..6e5b3fc993 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -14,8 +14,6 @@ import { Job } from "~/models/job.server"; import type { Organization } from "~/models/organization.server"; import type { Project } from "~/models/project.server"; import { objectToSearchParams } from "./searchParams"; -import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters"; -import { useLocation } from "@remix-run/react"; export type OrgForPath = Pick; export type ProjectForPath = Pick; @@ -368,7 +366,7 @@ export function v3RunSpanPath( run: v3RunForPath, span: v3SpanForPath ) { - return `${v3RunPath(organization, project, run)}/spans/${span.spanId}`; + return `${v3RunPath(organization, project, run)}?span=${span.spanId}`; } export function v3TraceSpanPath(