diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx new file mode 100644 index 0000000000..46938fd391 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx @@ -0,0 +1,34 @@ +export function MoveToTopIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx new file mode 100644 index 0000000000..6e5d8a84ba --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx @@ -0,0 +1,41 @@ +export function MoveUpIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 718166b55f..ab328afde7 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -147,6 +147,12 @@ function ShortcutContent() { + + + + + +
Schedules page diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index 38fd1525c5..d0186268c0 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -1,6 +1,10 @@ import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; import { Icon, type RenderIcon } from "./Icon"; +import { useRef } from "react"; +import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { ShortcutKey } from "./ShortcutKey"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; const variations = { primary: @@ -17,6 +21,9 @@ type TextLinkProps = { trailingIconClassName?: string; variant?: keyof typeof variations; children: React.ReactNode; + shortcut?: ShortcutDefinition; + hideShortcutKey?: boolean; + tooltip?: React.ReactNode; } & React.AnchorHTMLAttributes; export function TextLink({ @@ -27,20 +34,61 @@ export function TextLink({ trailingIcon, trailingIconClassName, variant = "primary", + shortcut, + hideShortcutKey, + tooltip, ...props }: TextLinkProps) { + const innerRef = useRef(null); const classes = variations[variant]; - return to ? ( - + + if (shortcut) { + useShortcutKeys({ + shortcut: shortcut, + action: () => { + if (innerRef.current) { + innerRef.current.click(); + } + }, + }); + } + + const renderShortcutKey = () => + shortcut && + !hideShortcutKey && ; + + const linkContent = ( + <> {children}{" "} {trailingIcon && } + {shortcut && !tooltip && renderShortcutKey()} + + ); + + const linkElement = to ? ( + + {linkContent} ) : href ? ( - - {children}{" "} - {trailingIcon && } + + {linkContent} ) : ( Need to define a path or href ); + + if (tooltip) { + return ( + + + {linkElement} + + {tooltip} {shortcut && renderShortcutKey()} + + + + ); + } + + return linkElement; } diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index d3ea209924..400e872a21 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -1,6 +1,6 @@ import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView"; -import { prisma, PrismaClient } from "~/db.server"; +import { prisma, type PrismaClient } from "~/db.server"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { getUsername } from "~/utils/username"; import { eventRepository } from "~/v3/eventRepository.server"; @@ -58,7 +58,13 @@ export class RunPresenter { rootTaskRun: { select: { friendlyId: true, - taskIdentifier: true, + spanId: true, + createdAt: true, + }, + }, + parentTaskRun: { + select: { + friendlyId: true, spanId: true, createdAt: true, }, @@ -111,6 +117,7 @@ export class RunPresenter { completedAt: run.completedAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, + parentTaskRun: run.parentTaskRun, environment: { id: run.runtimeEnvironment.id, organizationId: run.runtimeEnvironment.organizationId, @@ -202,8 +209,6 @@ export class RunPresenter { trace: { rootSpanStatus, events: events, - parentRunFriendlyId: - tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId, duration: totalDuration, rootStartedAt: tree?.data.startTime, startedAt: run.startedAt, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 49a0a70c15..890ed00433 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -11,7 +11,7 @@ import { MagnifyingGlassPlusIcon, StopCircleIcon, } from "@heroicons/react/20/solid"; -import { useLoaderData, useParams, useRevalidator } from "@remix-run/react"; +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 { @@ -25,7 +25,8 @@ import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; -import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon"; +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"; @@ -87,7 +88,6 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, - v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -302,8 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade return <>; } - const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } = - trace; + const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace; const shouldLiveReload = events.length <= maximumLiveReloadingSetting; const changeToSpan = useDebounce((selectedSpan: string) => { @@ -340,7 +339,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade selectedId={selectedSpanId} key={events[0]?.id ?? "-"} events={events} - parentRunFriendlyId={parentRunFriendlyId} onSelectedIdChanged={(selectedSpan) => { //instantly close the panel if no span is selected if (!selectedSpan) { @@ -358,6 +356,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade shouldLiveReload={shouldLiveReload} maximumLiveReloadingSetting={maximumLiveReloadingSetting} rootRun={run.rootTaskRun} + parentRun={run.parentTaskRun} isCompleted={run.completedAt !== null} /> @@ -476,7 +475,6 @@ function NoLogsView({ run, resizable }: LoaderData) { type TasksTreeViewProps = { events: TraceEvent[]; selectedId?: string; - parentRunFriendlyId?: string; onSelectedIdChanged: (selectedId: string | undefined) => void; totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; @@ -487,7 +485,10 @@ type TasksTreeViewProps = { maximumLiveReloadingSetting: number; rootRun: { friendlyId: string; - taskIdentifier: string; + spanId: string; + } | null; + parentRun: { + friendlyId: string; spanId: string; } | null; isCompleted: boolean; @@ -496,7 +497,6 @@ type TasksTreeViewProps = { function TasksTreeView({ events, selectedId, - parentRunFriendlyId, onSelectedIdChanged, totalDuration, rootSpanStatus, @@ -506,6 +506,7 @@ function TasksTreeView({ shouldLiveReload, maximumLiveReloadingSetting, rootRun, + parentRun, isCompleted, }: TasksTreeViewProps) { const isAdmin = useHasAdminAccess(); @@ -596,20 +597,30 @@ function TasksTreeView({ id={resizableSettings.tree.tree.id} default={resizableSettings.tree.tree.default} min={resizableSettings.tree.tree.min} - className="pl-3" >
-
- {rootRun ? ( - + {rootRun || parentRun ? ( + - ) : parentRunFriendlyId ? ( - ) : ( - + This is the root task )} @@ -628,6 +639,7 @@ function TasksTreeView({ nodes={nodes} getNodeProps={getNodeProps} getTreeProps={getTreeProps} + parentClassName="pl-3" renderNode={({ node, state, index }) => ( <>
; } -function ShowParentLink({ - runFriendlyId, - spanId, - isRoot, +function ShowParentOrRootLinks({ + relationships, }: { - runFriendlyId: string; - spanId?: string; - isRoot: boolean; + relationships: { + root?: { + friendlyId: string; + spanId: string; + isParent?: boolean; + }; + parent?: { + friendlyId: string; + spanId: string; + }; + }; }) { - const [mouseOver, setMouseOver] = useState(false); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { spanParam } = useParams(); - const span = spanId ? spanId : spanParam; + // 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 ( - setMouseOver(true)} - onMouseLeave={() => setMouseOver(false)} - fullWidth - textAlignLeft - shortcut={{ key: "p" }} - className="flex-1" - > - {mouseOver ? ( - - ) : ( - +
+ {relationships.root && ( + + Jump to root run + +
+ } + className="text-xs" + > + Root +
)} - - {isRoot ? "Show root run" : "Show parent run"} - - + {relationships.parent && ( + + Jump to parent run + +
+ } + className="text-xs" + > + Parent + + )} +
); } diff --git a/apps/webapp/app/v3/services/completeAttempt.server.ts b/apps/webapp/app/v3/services/completeAttempt.server.ts index 8fe57f040d..b9033a97e9 100644 --- a/apps/webapp/app/v3/services/completeAttempt.server.ts +++ b/apps/webapp/app/v3/services/completeAttempt.server.ts @@ -213,7 +213,7 @@ export class CompleteAttemptService extends BaseService { taskRunAttempt.friendlyId, taskRunAttempt.taskRunId, new Date(), - "Cancelled by user", + "Canceled by user", env ); diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 942c22f6be..b8d5386ac6 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -1236,7 +1236,7 @@ export class RunAttemptSystem { tx?: PrismaClientOrTransaction; }): Promise { const prisma = tx ?? this.$.prisma; - reason = reason ?? "Cancelled by user"; + reason = reason ?? "Canceled by user"; return startSpan(this.$.tracer, "cancelRun", async (span) => { return this.$.runLock.lock("cancelRun", [runId], async () => { diff --git a/references/hello-world/src/trigger/nestedDependencies.ts b/references/hello-world/src/trigger/nestedDependencies.ts new file mode 100644 index 0000000000..afd25a4a27 --- /dev/null +++ b/references/hello-world/src/trigger/nestedDependencies.ts @@ -0,0 +1,74 @@ +import { logger, task, wait } from "@trigger.dev/sdk"; + +export const nestedDependencies = task({ + id: "nested-dependencies", + run: async ({ + depth = 0, + maxDepth = 6, + batchSize = 4, + waitSeconds = 1, + failAttemptChance = 0, + failParents = false, + }: { + depth?: number; + maxDepth?: number; + batchSize?: number; + waitSeconds?: number; + failAttemptChance?: number; + failParents?: boolean; + }) => { + if (depth >= maxDepth) { + return; + } + + logger.log(`Started ${depth}/${maxDepth} depth`); + + const shouldFail = Math.random() < failAttemptChance; + if (shouldFail) { + throw new Error(`Failed at ${depth}/${maxDepth} depth`); + } + + await wait.for({ seconds: waitSeconds }); + + const triggerOrBatch = depth % 2 === 0; + + if (triggerOrBatch) { + for (let i = 0; i < batchSize; i++) { + const result = await nestedDependencies.triggerAndWait({ + depth: depth + 1, + maxDepth, + waitSeconds, + failAttemptChance, + batchSize, + }); + logger.log(`Triggered complete ${i + 1}/${batchSize}`); + + if (!result.ok && failParents) { + throw new Error(`Failed at ${depth}/${maxDepth} depth`); + } + } + } else { + const results = await nestedDependencies.batchTriggerAndWait( + Array.from({ length: batchSize }, (_, i) => ({ + payload: { + depth: depth + 1, + maxDepth, + batchSize, + waitSeconds, + failAttemptChance, + }, + })) + ); + logger.log(`Batch triggered complete`); + + if (results.runs.some((r) => !r.ok) && failParents) { + throw new Error(`Failed at ${depth}/${maxDepth} depth`); + } + } + + logger.log(`Sleep for ${waitSeconds} seconds`); + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + + logger.log(`Finished ${depth}/${maxDepth} depth`); + }, +});