diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index ab328afde7..b21c556595 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -134,6 +134,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 47c8d3d674..67ba3c0924 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -372,7 +372,7 @@ export const LinkButton = ({ {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 67e01af795..fa02e56472 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -3,59 +3,95 @@ import { useState } from "react"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useCopy } from "~/hooks/useCopy"; import { cn } from "~/utils/cn"; +import { Button } from "./Buttons"; export function CopyableText({ value, copyValue, className, asChild, + variant, }: { value: string; copyValue?: string; className?: string; asChild?: boolean; + variant?: "icon-right" | "text-below"; }) { const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(copyValue ?? value); - return ( - setIsHovered(false)} - > - setIsHovered(true)}>{value} + const resolvedVariant = variant ?? "icon-right"; + + if (resolvedVariant === "icon-right") { + return ( e.stopPropagation()} - className={cn( - "absolute -right-6 top-0 z-10 size-6 font-sans", - isHovered ? "flex" : "hidden" - )} + className={cn("group relative inline-flex h-6 items-center", className)} + onMouseLeave={() => setIsHovered(false)} > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - className="font-sans" - disableHoverableContent - asChild={asChild} - /> + setIsHovered(true)}>{value} + e.stopPropagation()} + className={cn( + "absolute -right-6 top-0 z-10 size-6 font-sans", + isHovered ? "flex" : "hidden" + )} + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + className="font-sans" + disableHoverableContent + asChild={asChild} + /> + - - ); + ); + } + + if (resolvedVariant === "text-below") { + return ( + { + e.stopPropagation(); + copy(); + }} + className={cn( + "cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent", + className + )} + > + {value} + + } + content={copied ? "Copied" : "Click to copy"} + className="font-sans px-2 py-1" + disableHoverableContent + open={isHovered || copied} + onOpenChange={setIsHovered} + /> + ); + } + + return null; } diff --git a/apps/webapp/app/components/primitives/ShortcutKey.tsx b/apps/webapp/app/components/primitives/ShortcutKey.tsx index 04b1f36737..567cf68d61 100644 --- a/apps/webapp/app/components/primitives/ShortcutKey.tsx +++ b/apps/webapp/app/components/primitives/ShortcutKey.tsx @@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider"; import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon"; const medium = - "text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; + "justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase"; export const variants = { small: - "text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", + "justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase", medium: cn(medium, "group-hover:border-charcoal-550"), "medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"), }; @@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps) function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") { key = key.toLowerCase(); - const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5"; + const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5"; switch (key) { case "enter": diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 15dd72894a..5c681927b5 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -6,7 +6,7 @@ import { cn } from "~/utils/cn"; const variantClasses = { basic: "bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", - dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", + dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50" }; type Variant = keyof typeof variantClasses; @@ -64,6 +64,8 @@ function SimpleTooltip({ buttonStyle, asChild = false, sideOffset, + open, + onOpenChange, }: { button: React.ReactNode; content: React.ReactNode; @@ -76,10 +78,12 @@ function SimpleTooltip({ buttonStyle?: React.CSSProperties; asChild?: boolean; sideOffset?: number; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { return ( - + ([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); + const location = useOptimisticLocation(); + const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1"); const showCompute = isManagedCloud; @@ -293,16 +296,20 @@ export function TaskRunsTable({ ) : ( runs.map((run, index) => { + const searchParams = new URLSearchParams(); + if (tableStateParam) { + searchParams.set("tableState", tableStateParam); + } const path = v3RunSpanPath(organization, project, run.environment, run, { spanId: run.spanId, - }); + }, searchParams); return ( {allowSelection && ( { + onChange={() => { toggle(run.friendlyId); }} ref={(r) => { 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 79ab0b8e5b..3763753f2b 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 @@ -2,6 +2,7 @@ import { ArrowUturnLeftIcon, BoltSlashIcon, BookOpenIcon, + ChevronUpIcon, ChevronDownIcon, ChevronRightIcon, InformationCircleIcon, @@ -22,7 +23,7 @@ import { } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { redirect } from "remix-typedjson"; import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon"; @@ -68,7 +69,6 @@ import { eventBorderClassName, } from "~/components/runs/v3/SpanTitle"; import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus"; -import { env } from "~/env.server"; import { useDebounce } from "~/hooks/useDebounce"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useEventSource } from "~/hooks/useEventSource"; @@ -88,6 +88,7 @@ import { docsPath, v3BillingPath, v3RunParamsSchema, + v3RunPath, v3RunRedirectPath, v3RunSpanPath, v3RunStreamingPath, @@ -98,6 +99,12 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types"; +import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { $replica } from "~/db.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; const resizableSettings = { parent: { @@ -169,6 +176,82 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId); const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId); + // Load runs list data from tableState if present + let runsList: { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; + } | null = null; + const tableStateParam = url.searchParams.get("tableState"); + if (tableStateParam) { + try { + const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam)); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId); + + if (project && environment) { + const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient); + const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + pageSize: 25, // Load enough runs to provide navigation context + }); + + runsList = { + runs: currentPageResult.runs, + pagination: currentPageResult.pagination, + }; + + // Check if the current run is at the boundary and preload adjacent page if needed + const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam); + + // If current run is first in list and there's a previous page, load the last run from prev page + if (currentRunIndex === 0 && currentPageResult.pagination.previous) { + const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.previous, + direction: "backward", + pageSize: 1, // We only need the last run from the previous page + }); + if (prevPageResult.runs.length > 0) { + runsList.prevPageLastRun = { + friendlyId: prevPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.previous, + }; + } + } + + // If current run is last in list and there's a next page, load the first run from next page + if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) { + const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + cursor: currentPageResult.pagination.next, + direction: "forward", + pageSize: 1, // We only need the first run from the next page + }); + if (nextPageResult.runs.length > 0) { + runsList.nextPageFirstRun = { + friendlyId: nextPageResult.runs[0].friendlyId, + cursor: currentPageResult.pagination.next, + }; + } + } + } + } catch (error) { + // If there's an error parsing or loading runs list, just ignore it + // and don't include the runsList in the response + console.error("Error loading runs list from tableState:", error); + } + } + return json({ run: result.run, trace: result.trace, @@ -177,13 +260,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { parent, tree, }, + runsList, }); }; type LoaderData = SerializeFrom; export default function Page() { - const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData(); + const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -191,16 +275,28 @@ export default function Page() { logCount: trace?.events.length ?? 0, isCompleted: run.completedAt !== null, }); + const { value } = useSearchParams(); + const tableState = decodeURIComponent(value("tableState") ?? ""); + const tableStateSearchParams = new URLSearchParams(tableState); + const filters = getRunFiltersFromSearchParams(tableStateSearchParams); + + const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList}); return ( <> } + title={<> + + {tableState && (
+ + +
)} + } /> {environment.type === "DEVELOPMENT" && } @@ -277,6 +373,7 @@ export default function Page() { trace={trace} maximumLiveReloadingSetting={maximumLiveReloadingSetting} resizable={resizable} + runsList={runsList} /> ) : ( )} @@ -1432,6 +1530,7 @@ function KeyboardShortcuts({ return ( <> + expandAllBelowDepth(0)} @@ -1448,6 +1547,16 @@ function KeyboardShortcuts({ ); } +function AdjacentRunsShortcuts() { + return (
+ + + + Adjacent runs + +
); +} + function ArrowKeyShortcuts() { return (
@@ -1494,7 +1603,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void return (
0 - + 9 Toggle level @@ -1526,3 +1635,116 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) { /> ); } + +function useAdjacentRunPaths({ + organization, + project, + environment, + tableState, + run, + runsList, +}: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + tableState: string; + run: { friendlyId: string }; + runsList: { + runs: Array<{ friendlyId: string }>; + pagination: { next?: string; previous?: string }; + prevPageLastRun?: { friendlyId: string; cursor: string }; + nextPageFirstRun?: { friendlyId: string; cursor: string }; + } | null; +}): [string | null, string | null] { + return React.useMemo(() => { + if (!runsList || runsList.runs.length === 0) { + return [null, null]; + } + + const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId); + + if (currentIndex === -1) { + return [null, null]; + } + + // Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list + let previousRun: { friendlyId: string } | null = null; + const previousRunTableState = new URLSearchParams(tableState); + if (currentIndex > 0) { + previousRun = runsList.runs[currentIndex - 1]; + } else if (runsList.prevPageLastRun) { + previousRun = runsList.prevPageLastRun; + // Update tableState with the new cursor for the previous page + previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor); + previousRunTableState.set("direction", "backward"); + } + + // Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list + let nextRun: { friendlyId: string } | null = null; + const nextRunTableState = new URLSearchParams(tableState); + if (currentIndex < runsList.runs.length - 1) { + nextRun = runsList.runs[currentIndex + 1]; + } else if (runsList.nextPageFirstRun) { + nextRun = runsList.nextPageFirstRun; + // Update tableState with the new cursor for the next page + nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor); + nextRunTableState.set("direction", "forward"); + } + + const previousURLSearchParams = new URLSearchParams(); + previousURLSearchParams.set("tableState", previousRunTableState.toString()); + const previousRunPath = previousRun + ? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams) + : null; + + const nextURLSearchParams = new URLSearchParams(); + nextURLSearchParams.set("tableState", nextRunTableState.toString()); + const nextRunPath = nextRun + ? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams) + : null; + + return [previousRunPath, nextRunPath]; + }, [organization, project, environment, tableState, run.friendlyId, runsList]); +} + + +function PreviousRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "[" }} + tooltip="Previous Run" + disabled={!to} + /> +
+ ); +} + +function NextRunButton({ to }: { to: string | null }) { + return ( +
+ !to && e.preventDefault()} + shortcut={{ key: "]" }} + tooltip="Next Run" + disabled={!to} + /> +
+ ); +} + diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f82165ae9d..3061082ed9 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -288,15 +288,17 @@ export function v3RunPath( organization: OrgForPath, project: ProjectForPath, environment: EnvironmentForPath, - run: v3RunForPath + run: v3RunForPath, + searchParams?: URLSearchParams ) { - return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`; + const query = searchParams ? `?${searchParams.toString()}` : ""; + return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`; } export function v3RunRedirectPath( organization: OrgForPath, project: ProjectForPath, - run: v3RunForPath + run: v3RunForPath, ) { return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`; } @@ -310,9 +312,12 @@ export function v3RunSpanPath( project: ProjectForPath, environment: EnvironmentForPath, run: v3RunForPath, - span: v3SpanForPath + span: v3SpanForPath, + searchParams?: URLSearchParams ) { - return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`; + searchParams = searchParams ?? new URLSearchParams(); + searchParams.set("span", span.spanId); + return `${v3RunPath(organization, project, environment, run, searchParams)}`; } export function v3RunStreamingPath(