Trace view fixes and improvements (#1046)

* 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
This commit is contained in:
Matt Aitken
2024-04-22 13:46:08 +01:00
committed by GitHub
parent 26f310397a
commit 3913e57ef4
12 changed files with 573 additions and 129 deletions
@@ -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 (
<span className={cn(variants[variant], className)}>
@@ -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 <ChevronDownIcon className={className} />;
case "arrowup":
return <ChevronUpIcon className={className} />;
case "arrowleft":
return <ChevronLeftIcon className={className} />;
case "arrowright":
return <ChevronRightIcon className={className} />;
default:
return key;
}
@@ -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<TData> = {
tree: FlatTree<TData>;
@@ -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<TData>({
[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<TData>({
}
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<TData>({
expandNode,
collapseNode,
toggleExpandNode,
expandAllBelowDepth,
collapseAllBelowDepth,
expandLevel,
collapseLevel,
toggleExpandLevel,
selectFirstVisibleNode,
selectLastVisibleNode,
selectNextVisibleNode,
@@ -91,6 +91,46 @@ type ToggleExpandNodeAction = {
} & WithScrollToNode;
};
type ExpandAllBelowDepthAction = {
type: "EXPAND_ALL_BELOW_DEPTH";
payload: {
depth: number;
tree: FlatTree<any>;
};
};
type CollapseAllBelowDepthAction = {
type: "COLLAPSE_ALL_BELOW_DEPTH";
payload: {
depth: number;
tree: FlatTree<any>;
};
};
type ExpandLevelAction = {
type: "EXPAND_LEVEL";
payload: {
level: number;
tree: FlatTree<any>;
};
};
type CollapseLevelAction = {
type: "COLLAPSE_LEVEL";
payload: {
level: number;
tree: FlatTree<any>;
};
};
type ToggleExpandLevelAction = {
type: "TOGGLE_EXPAND_LEVEL";
payload: {
level: number;
tree: FlatTree<any>;
};
};
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) {
@@ -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({
<BlankState isLoading={isLoading} filters={filters} />
) : (
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 (
@@ -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<any>) => {
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 };
}
@@ -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),
@@ -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<any>): string | undefined {
const search = new URLSearchParams(location.search);
return search.get("span") ?? undefined;
}
export default function Page() {
const { run, trace, resizeSettings } = useTypedLoaderData<typeof loader>();
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() {
</NavBar>
<PageBody scrollable={false}>
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
{selectedSpanId === undefined ? (
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//instantly close the panel if no span is selected
if (!selectedSpan) {
navigate(v3RunPath(organization, project, run));
return;
}
<ResizablePanelGroup
direction="horizontal"
className="h-full max-h-full"
onLayout={(layout) => {
if (layout.length !== 2) return;
if (!selectedSpanId) return;
setResizableRunSettings(document, layout);
}}
>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0]}>
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//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}
/>
) : (
<ResizablePanelGroup
direction="horizontal"
className="h-full max-h-full"
onLayout={(layout) => {
if (layout.length !== 2) return;
setResizableRunSettings(document, layout);
}}
>
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0]}>
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//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}
changeToSpan(selectedSpan);
}}
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt}
environmentType={run.environment.type}
/>
</ResizablePanel>
<ResizableHandle withHandle />
{selectedSpanId && (
<ResizablePanel order={2} minSize={30} defaultSize={resizeSettings.layout?.[1]}>
<SpanView
runParam={run.friendlyId}
spanId={selectedSpanId}
closePanel={() => replaceSearchParam("span")}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={30} defaultSize={resizeSettings.layout?.[1]}>
<Outlet key={selectedSpanId} />
</ResizablePanel>
</ResizablePanelGroup>
)}
)}
</ResizablePanelGroup>
</div>
</PageBody>
</>
@@ -263,6 +255,9 @@ function TasksTreeView({
getNodeProps,
toggleNodeSelection,
toggleExpandNode,
expandAllBelowDepth,
toggleExpandLevel,
collapseAllBelowDepth,
selectNode,
scrollToNode,
virtualizer,
@@ -286,7 +281,7 @@ function TasksTreeView({
});
return (
<div className="grid h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Input
placeholder="Search log"
@@ -297,30 +292,12 @@ function TasksTreeView({
onChange={(e) => setFilterText(e.target.value)}
/>
<div className="flex items-center gap-2">
<LiveReloadingStatus rootSpanCompleted={rootSpanStatus !== "executing"} />
<Switch
variant="small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
<Switch
variant="small"
label="Show durations"
checked={showDurations}
onCheckedChange={(e) => setShowDurations(e.valueOf())}
/>
<Slider
variant={"tertiary"}
className="w-20"
LeadingIcon={MagnifyingGlassMinusIcon}
TrailingIcon={MagnifyingGlassPlusIcon}
value={[scale]}
onValueChange={(value) => setScale(value[0])}
min={0}
max={1}
step={0.05}
/>
</div>
</div>
<ResizablePanelGroup
@@ -333,14 +310,15 @@ function TasksTreeView({
{/* Tree list */}
<ResizablePanel order={1} minSize={20} defaultSize={50} className="pl-3">
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div className="flex items-center">
<div className="flex items-center pr-2">
{parentRunFriendlyId ? (
<ShowParentLink runFriendlyId={parentRunFriendlyId} />
) : (
<Paragraph variant="small" className="text-charcoal-500">
<Paragraph variant="small" className="flex-1 text-charcoal-500">
This is the root task
</Paragraph>
)}
<LiveReloadingStatus rootSpanCompleted={rootSpanStatus !== "executing"} />
</div>
<TreeView
parentRef={parentRef}
@@ -355,13 +333,13 @@ function TasksTreeView({
<>
<div
className={cn(
"delay-[25ms] flex h-8 cursor-pointer items-center overflow-hidden rounded-l-sm pr-2 transition-colors",
"flex h-8 cursor-pointer items-center overflow-hidden rounded-l-sm pr-2",
state.selected
? "bg-grid-dimmed hover:bg-grid-bright"
: "bg-transparent hover:bg-grid-dimmed"
)}
onClick={() => {
toggleNodeSelection(node.id);
selectNode(node.id);
}}
>
<div className="flex h-8 items-center">
@@ -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({
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="grow @container">
<div className="hidden items-center gap-4 @[42rem]:flex">
<KeyboardShortcuts
expandAllBelowDepth={expandAllBelowDepth}
collapseAllBelowDepth={collapseAllBelowDepth}
toggleExpandLevel={toggleExpandLevel}
setShowDurations={setShowDurations}
/>
</div>
<div className="@[42rem]:hidden">
<Popover>
<PopoverArrowTrigger>Shortcuts</PopoverArrowTrigger>
<PopoverContent
className="min-w-[20rem] overflow-y-auto p-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<Header3 spacing>Keyboard shortcuts</Header3>
<div className="flex flex-col gap-2">
<KeyboardShortcuts
expandAllBelowDepth={expandAllBelowDepth}
collapseAllBelowDepth={collapseAllBelowDepth}
toggleExpandLevel={toggleExpandLevel}
setShowDurations={setShowDurations}
/>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-4">
<Slider
variant={"tertiary"}
className="w-20"
LeadingIcon={MagnifyingGlassMinusIcon}
TrailingIcon={MagnifyingGlassPlusIcon}
value={[scale]}
onValueChange={(value) => setScale(value[0])}
min={0}
max={1}
step={0.05}
/>
</div>
</div>
</div>
);
}
@@ -738,6 +768,7 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
fullWidth
textAlignLeft
shortcut={{ key: "p" }}
className="flex-1"
>
{mouseOver ? (
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
@@ -884,3 +915,92 @@ function ConnectedDevWarning() {
</div>
);
}
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 (
<>
<ArrowKeyShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
title="Expand all"
/>
<ShortcutWithAction
shortcut={{ key: "c" }}
action={() => collapseAllBelowDepth(1)}
title="Collapse all"
/>
<NumberShortcuts toggleLevel={(number) => toggleExpandLevel(number)} />
<ShortcutWithAction
shortcut={{ key: "d" }}
action={() => setShowDurations((d) => !d)}
title="Toggle durations"
/>
</>
);
}
function ArrowKeyShortcuts() {
return (
<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "arrowup" }} variant="medium" className="ml-0 mr-0" />
<ShortcutKey shortcut={{ key: "arrowdown" }} variant="medium" className="ml-0 mr-0" />
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium" className="ml-0 mr-0" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium" className="ml-0 mr-0" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Navigate
</Paragraph>
</div>
);
}
function ShortcutWithAction({
shortcut,
title,
action,
}: {
shortcut: Shortcut;
title: string;
action: () => void;
}) {
useShortcutKeys({
shortcut,
action,
});
return (
<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={shortcut} variant="medium" className="ml-0 mr-0" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
{title}
</Paragraph>
</div>
);
}
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 (
<div className="flex items-center gap-0.5">
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
<span className="text-[0.75rem] text-text-dimmed"></span>
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Toggle level
</Paragraph>
</div>
);
}
@@ -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"
);
@@ -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,
})
);
}
@@ -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<typeof loader>();
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<typeof loader>();
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 (
<div
className={cn(
"grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright"
)}
>
<div className="mx-3 flex items-center gap-2 overflow-x-hidden border-b border-grid-dimmed">
<div className="size-4 bg-grid-dimmed" />
<div className="h-6 w-[60%] bg-grid-dimmed" />
</div>
<div className="flex items-center justify-center">
<Spinner />
</div>
</div>
);
}
const {
span: { event },
} = fetcher.data;
return (
<div
className={cn(
"grid h-full max-h-full overflow-hidden bg-background-bright",
event.showActionBar ? "grid-rows-[2.5rem_1fr_2.5rem]" : "grid-rows-[2.5rem_1fr]"
event.showActionBar ? "grid-rows-[2.5rem_1fr_3.25rem]" : "grid-rows-[2.5rem_1fr]"
)}
>
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
@@ -85,8 +125,8 @@ export default function Page() {
</Header2>
</div>
{runParam && (
<LinkButton
to={v3RunPath(organization, project, { friendlyId: runParam })}
<Button
onClick={closePanel}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
@@ -188,7 +228,7 @@ export default function Page() {
{ friendlyId: event.runId },
{ spanId: event.spanId }
)}
variant="minimal/small"
variant="minimal/medium"
LeadingIcon={QueueListIcon}
shortcut={{ key: "f" }}
>
@@ -216,7 +256,7 @@ function RunActionButtons({ span }: { span: Span }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon}>
<Button variant="danger/medium" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
@@ -236,7 +276,7 @@ function RunActionButtons({ span }: { span: Span }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ArrowPathIcon}>
<Button variant="tertiary/medium" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
@@ -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`);
+1 -3
View File
@@ -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<Organization, "slug">;
export type ProjectForPath = Pick<Project, "slug">;
@@ -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(