Files
2026-07-13 13:32:57 +08:00

2071 lines
73 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
LockOpenIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
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 {
formatDurationMilliseconds,
millisecondsToNanoseconds,
nanosecondsToMilliseconds,
tryCatch,
} 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 { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown";
import { ChevronExtraSmallUp } from "~/assets/icons/ChevronExtraSmallUp";
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";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTimeShort } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { SearchInput } from "~/components/primitives/SearchInput";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
type ResizableSnapshot,
collapsibleHandleClassName,
useFrozenValue,
} 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";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import {
TreeView,
type UseTreeStateOutput,
useTree,
} from "~/components/primitives/TreeView/TreeView";
import { type NodesState } from "~/components/primitives/TreeView/reducer";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import {
SpanTitle,
eventBackgroundClassName,
eventBorderClassName,
} from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { $replica } from "~/db.server";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useInitialDimensions } from "~/hooks/useInitialDimensions";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess } from "~/hooks/useUser";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import {
RunEnvironmentMismatchError,
RunNotInPgError,
RunPresenter,
} from "~/presenters/v3/RunPresenter.server";
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
import { buildSyntheticRunHeader } from "~/v3/mollifier/syntheticRunHeader.server";
import { buildSyntheticTraceForBufferedRun } from "~/v3/mollifier/syntheticTrace.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { logger } from "~/services/logger.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { cn } from "~/utils/cn";
import { lerp } from "~/utils/lerp";
import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
const resizableSettings = {
parent: {
autosaveId: "panel-run-parent-v3",
handleId: "parent-handle",
main: {
id: "run",
min: "100px" as const,
},
inspector: {
id: "inspector",
default: "500px" as const,
min: "250px" as const,
},
},
tree: {
autosaveId: "panel-run-tree",
handleId: "tree-handle",
tree: {
id: "tree",
default: "50%" as const,
min: "50px" as const,
},
timeline: {
id: "timeline",
default: "50%" as const,
min: "50px" as const,
},
},
};
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
type RunsListNavigation = {
runs: Array<{ friendlyId: string; spanId: string }>;
pagination: { next?: string; previous?: string };
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
};
async function getRunsListFromTableState({
tableStateParam,
organizationSlug,
projectParam,
envParam,
runParam,
userId,
}: {
tableStateParam: string | null;
organizationSlug: string;
projectParam: string;
envParam: string;
runParam: string;
userId: string;
}): Promise<RunsListNavigation | null> {
if (!tableStateParam) {
return null;
}
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) {
return null;
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
pageSize: 25, // Load enough runs to provide navigation context
});
const runsList: RunsListNavigation = {
runs: currentPageResult.runs,
pagination: currentPageResult.pagination,
};
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
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,
spanId: prevPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.previous,
};
}
}
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,
spanId: nextPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.next,
};
}
}
return runsList;
} catch (error) {
logger.error("Error loading runs list from tableState:", { error });
return null;
}
}
// Display-only write:runs flags for the Replay/Cancel controls. The cancel
// and replay action routes enforce write:runs independently; this mirrors the
// result so the buttons disable for roles that lack it. Permissive in OSS.
async function runWritePermissions(request: Request, userId: string, organizationId: string) {
const auth = await rbac.authenticateSession(request, { userId, organizationId });
const canWriteRun = auth.ok ? auth.ability.can("write", { type: "runs" }) : true;
return { canReplayRun: canWriteRun, canCancelRun: canWriteRun };
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params);
const url = new URL(request.url);
const showDebug = url.searchParams.get("showDebug") === "true";
const presenter = new RunPresenter();
const [error, result] = await tryCatch(
presenter.call({
userId,
showDeletedLogs: !!impersonationId,
projectSlug: projectParam,
runFriendlyId: runParam,
environmentSlug: envParam,
showDebug,
})
);
if (error) {
if (error instanceof RunEnvironmentMismatchError) {
throw redirect(
v3RunRedirectPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ friendlyId: runParam }
)
);
}
// Only fall back to the mollifier buffer on a genuine PG miss. Any
// other error (DB timeout during trace queries, event-repository
// failure, etc.) means the run WAS in PG but a downstream lookup
// failed — falling back to the buffer here would either return a
// stale synth entry if one happens to exist in the brief drainer-
// materialisation race window, or quietly mask the real failure.
// `RunNotInPgError` is the typed signal RunPresenter throws for the
// route loader's specific case (`RunPresenter.server.ts:130`).
if (!(error instanceof RunNotInPgError)) {
throw error;
}
// PG miss → try the mollifier buffer. When the gate diverts a trigger
// the run sits in Redis until the drainer materialises it; without
// this fallback the run-detail page 404s for the brief buffered window
// even though the API has accepted the trigger and returned an id.
const buffered = await tryMollifiedRunFallback({
runFriendlyId: runParam,
organizationSlug,
projectSlug: projectParam,
envSlug: envParam,
userId,
});
if (buffered) {
// Preselect the root span on the initial page load when the URL
// doesn't already carry `?span=`. The sibling redirect routes
// (runs.$runParam.ts, @.runs.$runParam.ts,
// projects.v3.$projectRef.runs.$runParam.ts) all do this, but
// direct navigation to the canonical project-scoped URL never
// hit those redirects — leaving the right detail panel collapsed.
// Skip on `_data` requests (Remix data fetches): they're
// client-driven follow-ups and the client URL is what matters,
// not the loader's view of it.
if (!url.searchParams.has("span") && !url.searchParams.has("_data") && buffered.run.spanId) {
url.searchParams.set("span", buffered.run.spanId);
throw redirect(url.pathname + "?" + url.searchParams.toString());
}
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
return json({
run: buffered.run,
trace: buffered.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
resizable: { parent, tree },
runsList: null,
...(await runWritePermissions(request, userId, buffered.run.environment.organizationId)),
});
}
throw error;
}
// Preselect the root span on the initial page load when the URL
// doesn't already carry `?span=`. See the comment on the equivalent
// block in the buffered fallback above — the sibling redirect routes
// do this, but direct navigation to the canonical project-scoped URL
// never hits them, leaving the right detail panel collapsed.
if (!url.searchParams.has("span") && !url.searchParams.has("_data") && result.run.spanId) {
url.searchParams.set("span", result.run.spanId);
throw redirect(url.pathname + "?" + url.searchParams.toString());
}
//resizable settings
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
projectParam,
envParam,
runParam,
userId,
});
return json({
run: result.run,
trace: result.trace,
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
resizable: {
parent,
tree,
},
runsList,
...(await runWritePermissions(request, userId, result.run.environment.organizationId)),
});
};
async function tryMollifiedRunFallback(args: {
runFriendlyId: string;
organizationSlug: string;
projectSlug: string;
envSlug: string;
userId: string;
}) {
const project = await findProjectBySlug(args.organizationSlug, args.projectSlug, args.userId);
if (!project) return null;
const environment = await findEnvironmentBySlug(project.id, args.envSlug, args.userId);
if (!environment) return null;
const buffered = await findRunByIdWithMollifierFallback({
runId: args.runFriendlyId,
environmentId: environment.id,
organizationId: project.organizationId,
});
if (!buffered) return null;
return {
run: buildSyntheticRunHeader({
run: buffered,
environment: {
id: environment.id,
organizationId: project.organizationId,
type: environment.type,
slug: environment.slug,
},
}),
trace: buildSyntheticTraceForBufferedRun(buffered),
};
}
type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const {
run,
trace,
maximumLiveReloadingSetting,
runsList,
resizable,
canReplayRun,
canCancelRun,
} = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const isConnected = useCrossEngineIsConnected({
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 tabParam = value("tab") ?? undefined;
const spanParam = value("span") ?? undefined;
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
tabParam,
useSpan: !!spanParam,
});
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project, environment, filters),
text: "Runs",
}}
title={
<div className="flex items-center gap-x-0">
<CopyableText
value={run.friendlyId}
variant="text-below"
className="-ml-1.75 h-6 px-1.5 font-mono text-xs hover:text-text-bright"
/>
{tableState && (
<div className="flex">
<PreviousRunButton to={previousRunPath} />
<NextRunButton to={nextRunPath} />
</div>
)}
</div>
}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{run.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Trace ID</Property.Label>
<Property.Value>{run.traceId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Env ID</Property.Label>
<Property.Value>{run.environment.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{run.environment.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/runs")}>
Run docs
</LinkButton>
<Dialog key={`replay-${run.friendlyId}`}>
<DialogTrigger asChild>
<Button
variant="secondary/small"
LeadingIcon={ArrowUturnLeftIcon}
shortcut={{ key: "R" }}
className="pr-2"
disabled={!canReplayRun}
tooltip={canReplayRun ? undefined : "You don't have permission to replay runs"}
>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
</Dialog>
{run.isFinished ? null : (
<ControlledCancelRunDialog
key={`cancel-${run.friendlyId}`}
canCancel={canCancelRun}
runFriendlyId={run.friendlyId}
redirectPath={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{trace ? (
<TraceView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
) : (
<NoLogsView run={run} resizable={resizable} />
)}
</PageBody>
</>
);
}
function shouldLiveReload({
events,
maximumLiveReloadingSetting,
run,
}: {
events: TraceEvent[];
maximumLiveReloadingSetting: number;
run: { completedAt: string | null };
}): boolean {
// We don't live reload if there are a ton of spans/logs
if (events.length > maximumLiveReloadingSetting) return false;
// If the run was completed a while ago, we don't need to live reload anymore
if (run.completedAt && new Date(run.completedAt).getTime() < Date.now() - 30_000) return false;
return true;
}
function TraceView({
run,
trace,
maximumLiveReloadingSetting,
resizable,
}: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting" | "resizable">) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { searchParams, replaceSearchParam } = useReplaceSearchParams();
const selectedSpanId = searchParams.get("span") ?? undefined;
const frozenSpanId = useFrozenValue(selectedSpanId);
const displaySpanId = selectedSpanId ?? frozenSpanId;
if (!trace) {
return <></>;
}
const {
events,
duration,
rootSpanStatus,
rootStartedAt,
queuedDuration,
overridesBySpanId,
isTruncated = false,
missingAnchor = false,
} = trace;
const changeToSpan = useDebounce((selectedSpan: string) => {
replaceSearchParam("span", selectedSpan, { replace: true });
}, 250);
const isLiveReloading = shouldLiveReload({ events, maximumLiveReloadingSetting, run });
const revalidator = useRevalidator();
const streamedEvents = useEventSource(
v3RunStreamingPath(organization, project, environment, run),
{
event: "message",
disabled: !isLiveReloading,
}
);
useEffect(() => {
if (streamedEvents !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
const frozenSpanOverrides = useFrozenValue(spanOverrides);
const displaySpanOverrides = selectedSpanId ? spanOverrides : frozenSpanOverrides;
// Get the linked run ID for cached spans (map built during RunPresenter walk)
const { linkedRunIdBySpanId } = trace;
const selectedSpanLinkedRunId = selectedSpanId
? linkedRunIdBySpanId?.[selectedSpanId]
: undefined;
const frozenLinkedRunId = useFrozenValue(selectedSpanLinkedRunId);
const displayLinkedRunId =
(selectedSpanId ? selectedSpanLinkedRunId : frozenLinkedRunId) ?? undefined;
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
autosaveId={resizableSettings.parent.autosaveId}
snapshot={resizable.parent as ResizableSnapshot}
className="h-full max-h-full"
>
<ResizablePanel
id={resizableSettings.parent.main.id}
min={resizableSettings.parent.main.min}
>
<div className="flex h-full flex-col overflow-hidden">
{isTruncated && (
<div className="shrink-0 border-b border-grid-bright px-3 py-2">
<Callout variant="warning" className="text-sm">
{missingAnchor
? "Trace too large to display completely."
: "This run's trace is partially displayed because it exceeds the view limit."}
</Callout>
</div>
)}
<div className="min-h-0 flex-1">
<TasksTreeView
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
onSelectedIdChanged={(selectedSpan) => {
//instantly close the panel if no span is selected
if (!selectedSpan) {
replaceSearchParam("span");
return;
}
changeToSpan(selectedSpan);
}}
totalDuration={duration}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined}
queuedDuration={queuedDuration}
environmentType={run.environment.type}
shouldLiveReload={isLiveReloading}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
parentRun={run.parentTaskRun}
isCompleted={run.completedAt !== null}
treeSnapshot={resizable.tree as ResizableSnapshot}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle
id={resizableSettings.parent.handleId}
className={collapsibleHandleClassName(!!selectedSpanId)}
/>
<ResizablePanel
id={resizableSettings.parent.inspector.id}
default={resizableSettings.parent.inspector.default}
min={resizableSettings.parent.inspector.min}
className="overflow-hidden"
collapsible
collapsed={!selectedSpanId}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div
className="h-full"
style={{ minWidth: parseInt(resizableSettings.parent.inspector.min) }}
>
{displaySpanId && (
<SpanView
runParam={run.friendlyId}
spanId={displaySpanId}
spanOverrides={displaySpanOverrides as SpanOverride | undefined}
closePanel={() => replaceSearchParam("span")}
linkedRunId={displayLinkedRunId}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
// Controlled wrapper around the cancel dialog. Owns the Radix open state
// so the dialog closes itself once the cancel action transitions through
// submission. We can't `<DialogClose asChild>`-wrap the submit button
// because Radix's onClick handler swallows the button's name=value pair
// that the form action depends on for `redirectUrl`.
function ControlledCancelRunDialog({
runFriendlyId,
redirectPath,
canCancel,
}: {
runFriendlyId: string;
redirectPath: string;
canCancel: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="danger/small"
LeadingIcon={StopCircleIcon}
shortcut={{ key: "C" }}
disabled={!canCancel}
tooltip={canCancel ? undefined : "You don't have permission to cancel runs"}
>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={runFriendlyId}
redirectPath={redirectPath}
onCancelSubmitted={() => setOpen(false)}
/>
</Dialog>
);
}
function NoLogsView({ run, resizable }: Pick<LoaderData, "run" | "resizable">) {
const plan = useCurrentPlan();
const organization = useOrganization();
const logRetention = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const completedAt = run.completedAt ? new Date(run.completedAt) : undefined;
const now = new Date();
const daysSinceCompleted = completedAt
? Math.floor((now.getTime() - completedAt.getTime()) / (1000 * 60 * 60 * 24))
: undefined;
const isWithinLogRetention =
daysSinceCompleted !== undefined && daysSinceCompleted <= logRetention;
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
autosaveId={resizableSettings.parent.autosaveId}
snapshot={resizable.parent as ResizableSnapshot}
className="h-full max-h-full"
>
<ResizablePanel
id={resizableSettings.parent.main.id}
min={resizableSettings.parent.main.min}
>
<div className="grid h-full place-items-center">
{daysSinceCompleted === undefined ? (
// NoLogsView only renders when the loader returns no trace.
// Buffered runs always carry a synthetic trace (see
// buildSyntheticTraceForBufferedRun) so they never reach
// this branch — the message here is the pre-mollifier
// copy for runs with no completedAt and no logs.
<InfoPanel variant="info" icon={InformationCircleIcon} title="We delete old logs">
<Paragraph variant="small">
We tidy up older logs to keep things running smoothly.
</Paragraph>
</InfoPanel>
) : isWithinLogRetention ? (
<InfoPanel
variant="info"
icon={InformationCircleIcon}
title="These logs have been deleted"
>
<Paragraph variant="small">
Your log retention is {logRetention} days but these logs had already been deleted.
From now on only logs from runs that completed {logRetention} days ago will be
deleted.
</Paragraph>
</InfoPanel>
) : daysSinceCompleted <= 30 ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock longer log retention"
accessory={
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
}
>
<Paragraph variant="small">
The logs for this run have been deleted because the run completed{" "}
{daysSinceCompleted} days ago.
</Paragraph>
<Paragraph variant="small">Upgrade your plan to keep logs for longer.</Paragraph>
</InfoPanel>
) : (
<InfoPanel
variant="info"
icon={InformationCircleIcon}
title="These logs are more than 30 days old"
>
<Paragraph variant="small">
We tidy up older logs to keep things running smoothly.
</Paragraph>
</InfoPanel>
)}
</div>
</ResizablePanel>
<ResizableHandle id={resizableSettings.parent.handleId} />
<ResizablePanel
id={resizableSettings.parent.inspector.id}
default={resizableSettings.parent.inspector.default}
min={resizableSettings.parent.inspector.min}
isStaticAtRest
>
<SpanView runParam={run.friendlyId} spanId={run.spanId} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
type TasksTreeViewProps = {
events: TraceEvent[];
selectedId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
rootStartedAt: Date | undefined;
queuedDuration: number | undefined;
environmentType: RuntimeEnvironmentType;
shouldLiveReload: boolean;
maximumLiveReloadingSetting: number;
rootRun: {
friendlyId: string;
spanId: string;
} | null;
parentRun: {
friendlyId: string;
spanId: string;
} | null;
isCompleted: boolean;
treeSnapshot?: ResizableSnapshot;
};
function TasksTreeView({
events,
selectedId,
onSelectedIdChanged,
totalDuration,
rootSpanStatus,
rootStartedAt,
queuedDuration,
environmentType,
shouldLiveReload,
maximumLiveReloadingSetting,
rootRun,
parentRun,
isCompleted,
treeSnapshot,
}: TasksTreeViewProps) {
const isAdmin = useHasAdminAccess();
const [filterText, setFilterText] = useState("");
const [errorsOnly, setErrorsOnly] = useState(false);
const [showDurations, setShowDurations] = useState(true);
const [showQueueTime, setShowQueueTime] = useState(false);
const [scale, setScale] = useState(0);
const parentRef = useRef<HTMLDivElement>(null);
const treeScrollRef = useRef<HTMLDivElement>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const { value, replace } = useSearchParams();
const searchValue = value("showDebug");
const showDebug = searchValue !== undefined ? searchValue === "true" : false;
const displayEvents = events;
const queuedTime = showQueueTime ? undefined : queuedDuration;
const {
nodes,
getTreeProps,
getNodeProps,
toggleNodeSelection,
toggleExpandNode,
expandAllBelowDepth,
toggleExpandLevel,
collapseAllBelowDepth,
selectNode,
scrollToNode,
virtualizer,
} = useTree({
tree: displayEvents,
selectedId,
// collapsedIds,
onSelectedIdChanged,
estimatedRowHeight: () => 32,
parentRef,
filter: {
value: { text: filterText, errorsOnly },
fn: (value, node) => {
const nodePassesErrorTest = (value.errorsOnly && node.data.isError) || !value.errorsOnly;
if (!nodePassesErrorTest) return false;
if (value.text === "") return true;
if (node.data.message.toLowerCase().includes(value.text.toLowerCase())) {
return true;
}
return false;
},
},
});
return (
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-1.5">
<SearchField onChange={setFilterText} />
<div className="flex items-center gap-1.5">
{isAdmin && (
<Switch
variant="secondary/small"
label="Debug"
shortcut={{ modifiers: ["shift"], key: "D" }}
checked={showDebug}
onCheckedChange={(checked) => {
replace({
showDebug: checked ? "true" : "false",
});
}}
/>
)}
<Switch
variant="secondary/small"
label="Queue time"
checked={showQueueTime}
onCheckedChange={(e) => setShowQueueTime(e.valueOf())}
shortcut={{ key: "Q" }}
/>
<Switch
variant="secondary/small"
label="Errors only"
checked={errorsOnly}
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
/>
</div>
</div>
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId} snapshot={treeSnapshot}>
{/* Tree list */}
<ResizablePanel
id={resizableSettings.tree.tree.id}
default={resizableSettings.tree.tree.default}
min={resizableSettings.tree.tree.min}
>
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div className="flex items-center justify-between pl-1 pr-2">
{rootRun || parentRun ? (
<ShowParentOrRootLinks
relationships={{
root: rootRun
? {
friendlyId: rootRun.friendlyId,
spanId: rootRun.spanId,
isParent: parentRun ? rootRun.friendlyId === parentRun.friendlyId : true,
}
: undefined,
parent:
parentRun && rootRun?.friendlyId !== parentRun.friendlyId
? {
friendlyId: parentRun.friendlyId,
spanId: "",
}
: undefined,
}}
/>
) : (
<Paragraph variant="extra-small" className="flex-1 pl-3 text-text-faint">
This is the root task
</Paragraph>
)}
<LiveReloadingStatus
rootSpanCompleted={rootSpanStatus !== "executing"}
isLiveReloading={shouldLiveReload}
settingValue={maximumLiveReloadingSetting}
/>
</div>
<TreeView
parentRef={parentRef}
scrollRef={treeScrollRef}
virtualizer={virtualizer}
autoFocus
tree={events}
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
parentClassName="pl-3"
renderNode={({ node, state, index }) => (
<>
<div
className={cn(
"group/spannode 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={() => {
selectNode(node.id);
}}
>
<div className="flex h-8 items-center">
{Array.from({ length: node.level }).map((_, index) => (
<TaskLine
key={index}
isError={node.data.isError}
isSelected={state.selected}
/>
))}
<div
className={cn(
"flex h-8 w-4 items-center",
node.hasChildren && "hover:bg-surface-control"
)}
onClick={(e) => {
e.stopPropagation();
if (e.altKey) {
if (state.expanded) {
collapseAllBelowDepth(node.level);
} else {
expandAllBelowDepth(node.level);
}
} else {
toggleExpandNode(node.id);
}
scrollToNode(node.id);
}}
>
{node.hasChildren ? (
state.expanded ? (
<ChevronDownIcon className="h-4 w-4 text-text-dimmed" />
) : (
<ChevronRightIcon className="h-4 w-4 text-text-dimmed" />
)
) : (
<div className="h-8 w-4" />
)}
</div>
</div>
<div className="flex w-full items-center justify-between gap-2 pl-1">
<div className="flex items-center gap-1.5 overflow-x-hidden">
<RunIcon
name={
node.data.isAgentRun &&
(node.data.style?.icon === "task" ||
node.data.style?.icon === "task-cached")
? "agent"
: node.data.style?.icon
}
spanName={node.data.message}
className="size-5 min-h-5 min-w-5"
/>
<NodeText node={node} />
{node.data.isRoot && !rootRun && <Badge variant="extra-small">Root</Badge>}
</div>
<div className="flex items-center gap-1">
<NodeStatusIcon node={node} />
</div>
</div>
</div>
</>
)}
onScroll={(scrollTop) => {
//sync the scroll to the tree
if (timelineScrollRef.current) {
timelineScrollRef.current.scrollTop = scrollTop;
}
}}
/>
</div>
</ResizablePanel>
<ResizableHandle id={resizableSettings.tree.handleId} />
{/* Timeline */}
<ResizablePanel
id={resizableSettings.tree.timeline.id}
default={resizableSettings.tree.timeline.default}
min={resizableSettings.tree.timeline.min}
>
<TimelineView
totalDuration={totalDuration}
scale={scale}
events={events}
rootSpanStatus={rootSpanStatus}
rootStartedAt={rootStartedAt}
queuedDuration={queuedTime}
parentRef={parentRef}
timelineScrollRef={timelineScrollRef}
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
showDurations={showDurations}
treeScrollRef={treeScrollRef}
virtualizer={virtualizer}
toggleNodeSelection={toggleNodeSelection}
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-4">
<div className="grow @container">
<div className="hidden items-center gap-4 @[48rem]:flex">
<KeyboardShortcuts
expandAllBelowDepth={expandAllBelowDepth}
collapseAllBelowDepth={collapseAllBelowDepth}
toggleExpandLevel={toggleExpandLevel}
setShowDurations={setShowDurations}
/>
</div>
<div className="@[48rem]:hidden">
<Popover>
<PopoverArrowTrigger>Shortcuts</PopoverArrowTrigger>
<PopoverContent
className="min-w-80 overflow-y-auto p-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
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>
);
}
type TimelineViewProps = Pick<
TasksTreeViewProps,
"totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration"
> & {
scale: number;
parentRef: React.RefObject<HTMLDivElement>;
timelineScrollRef: React.RefObject<HTMLDivElement>;
virtualizer: Virtualizer<HTMLElement, Element>;
nodes: NodesState;
getNodeProps: UseTreeStateOutput["getNodeProps"];
getTreeProps: UseTreeStateOutput["getTreeProps"];
toggleNodeSelection: UseTreeStateOutput["toggleNodeSelection"];
showDurations: boolean;
treeScrollRef: React.RefObject<HTMLDivElement>;
};
const tickCount = 5;
function TimelineView({
totalDuration,
scale,
rootSpanStatus,
rootStartedAt,
timelineScrollRef,
virtualizer,
events,
nodes,
getNodeProps,
getTreeProps,
toggleNodeSelection,
showDurations,
treeScrollRef,
queuedDuration,
}: TimelineViewProps) {
const timelineContainerRef = useRef<HTMLDivElement>(null);
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
const maxTimelineWidth = minTimelineWidth * 10;
const disableSpansAnimations = rootSpanStatus !== "executing";
//we want to live-update the duration if the root span is still executing
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
useEffect(() => {
if (rootSpanStatus !== "executing" || !rootStartedAt) {
setDuration(queueAdjustedNs(totalDuration, queuedDuration));
return;
}
const interval = setInterval(() => {
setDuration(
queueAdjustedNs(
millisecondsToNanoseconds(Date.now() - rootStartedAt.getTime()),
queuedDuration
)
);
}, 500);
return () => clearInterval(interval);
}, [totalDuration, rootSpanStatus, queuedDuration, rootStartedAt]);
return (
<div
className="h-full overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
ref={timelineContainerRef}
>
<Timeline.Root
durationMs={nanosecondsToMilliseconds(duration * 1.05)}
scale={scale}
className="h-full overflow-hidden"
minWidth={minTimelineWidth}
maxWidth={maxTimelineWidth}
>
{/* Follows the cursor */}
<CurrentTimeIndicator
totalDuration={duration}
rootStartedAt={rootStartedAt}
queuedDurationNs={queuedDuration}
/>
<Timeline.Row className="grid h-full grid-rows-[2rem_1fr]">
{/* The duration labels */}
<Timeline.Row>
<Timeline.Row className="h-6">
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => {
if (index === tickCount - 1) return null;
return (
<Timeline.Point
ms={ms}
className={"relative bottom-[2px] text-xxs text-text-dimmed"}
>
{(ms) => (
<div
className={cn(
"whitespace-nowrap",
index === 0
? "ml-1"
: index === tickCount - 1
? "-ml-1 -translate-x-full"
: "-translate-x-1/2"
)}
>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
)}
</Timeline.Point>
);
}}
</Timeline.EquallyDistribute>
{rootSpanStatus !== "executing" && (
<Timeline.Point
ms={nanosecondsToMilliseconds(duration)}
className={cn(
"relative bottom-[2px] text-xxs",
rootSpanStatus === "completed" ? "text-success" : "text-error"
)}
>
{(ms) => (
<div className={cn("-translate-x-1/2 whitespace-nowrap")}>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</div>
)}
</Timeline.Point>
)}
</Timeline.Row>
<Timeline.Row className="h-2">
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => {
if (index === 0 || index === tickCount - 1) return null;
return (
<Timeline.Point ms={ms} className={"h-full border-r border-grid-dimmed"} />
);
}}
</Timeline.EquallyDistribute>
<Timeline.Point
ms={nanosecondsToMilliseconds(duration)}
className={cn(
"h-full border-r",
rootSpanStatus === "completed" ? "border-success/30" : "border-error/30"
)}
/>
</Timeline.Row>
</Timeline.Row>
{/* Main timeline body */}
<Timeline.Row className="overflow-hidden">
{/* The vertical tick lines */}
<Timeline.EquallyDistribute count={tickCount}>
{(ms: number, index: number) => {
if (index === 0) return null;
return <Timeline.Point ms={ms} className={"h-full border-r border-grid-dimmed"} />;
}}
</Timeline.EquallyDistribute>
{/* The completed line */}
{rootSpanStatus !== "executing" && (
<Timeline.Point
ms={nanosecondsToMilliseconds(duration)}
className={cn(
"h-full border-r",
rootSpanStatus === "completed" ? "border-success/30" : "border-error/30"
)}
/>
)}
<TreeView
scrollRef={timelineScrollRef}
virtualizer={virtualizer}
tree={events}
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
parentClassName="h-full scrollbar-hide"
renderNode={({ node, state, index, virtualizer, virtualItem }) => {
const isTopSpan = node.id === events[0]?.id;
return (
<Timeline.Row
key={index}
className={cn(
"group flex h-8 items-center",
state.selected
? "bg-grid-dimmed hover:bg-grid-bright"
: "bg-transparent hover:bg-grid-dimmed"
)}
// onMouseOver={() => console.log(`hover ${index}`)}
onClick={(e) => {
toggleNodeSelection(node.id);
}}
>
{node.data.level === "TRACE" ? (
<>
{/* Add a span for the line, Make the vertical line the first one with 1px wide, and full height */}
{node.data.timelineEvents.map((event, eventIndex) =>
eventIndex === 0 ? (
<Timeline.Point
key={eventIndex}
ms={nanosecondsToMilliseconds(
queueAdjustedNs(event.offset, queuedDuration)
)}
>
{(ms) => (
<motion.div
className={cn(
"ml-[-0.5px] h-2.25 w-px rounded-none",
eventBackgroundClassName(node.data)
)}
layoutId={
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
) : (
<Timeline.Point
key={eventIndex}
ms={nanosecondsToMilliseconds(
queueAdjustedNs(event.offset, queuedDuration)
)}
className="z-10"
>
{(ms) => (
<motion.div
className={cn(
"ml-[-0.1562rem] size-1.25 rounded-full border bg-background-bright",
eventBorderClassName(node.data)
)}
layoutId={
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
)
)}
{node.data.timelineEvents &&
node.data.timelineEvents[0] &&
node.data.timelineEvents[0].offset < node.data.offset ? (
<Timeline.Span
startMs={nanosecondsToMilliseconds(
queueAdjustedNs(node.data.timelineEvents[0].offset, queuedDuration)
)}
durationMs={nanosecondsToMilliseconds(
node.data.offset - node.data.timelineEvents[0].offset
)}
>
<motion.div
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
animate={disableSpansAnimations ? false : undefined}
/>
</Timeline.Span>
) : null}
<SpanWithDuration
showDuration={state.selected ? true : showDurations}
startMs={nanosecondsToMilliseconds(
Math.max(queueAdjustedNs(node.data.offset, queuedDuration), 0)
)}
durationMs={
node.data.duration
? //completed
nanosecondsToMilliseconds(Math.min(node.data.duration, duration))
: //in progress
nanosecondsToMilliseconds(
Math.min(
duration + (queuedDuration ?? 0) - node.data.offset,
duration
)
)
}
node={node}
fadeLeft={isTopSpan && queuedDuration !== undefined}
disableAnimations={disableSpansAnimations}
/>
</>
) : (
<Timeline.Point
ms={nanosecondsToMilliseconds(
queueAdjustedNs(node.data.offset, queuedDuration)
)}
>
{(ms) => (
<motion.div
className={cn(
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
eventBackgroundClassName(node.data)
)}
layoutId={disableSpansAnimations ? undefined : node.id}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
)}
</Timeline.Row>
);
}}
onScroll={(scrollTop) => {
//sync the scroll to the tree
if (treeScrollRef.current) {
treeScrollRef.current.scrollTop = scrollTop;
}
}}
/>
</Timeline.Row>
</Timeline.Row>
</Timeline.Root>
</div>
);
}
function queueAdjustedNs(timeNs: number, queuedDurationNs: number | undefined) {
if (queuedDurationNs) {
return timeNs - queuedDurationNs;
}
return timeNs;
}
function NodeText({ node }: { node: TraceEvent }) {
const className = "truncate";
// Only mark task-level spans as agent so the agents colour applies to
// the task row itself, not unrelated sub-spans (wait/log/etc.) that
// live underneath an agent run.
const isAgentTaskRow =
node.data.isAgentRun &&
(node.data.style?.icon === "task" || node.data.style?.icon === "task-cached");
return (
<Paragraph variant="small" className={cn(className)}>
<SpanTitle {...node.data} size="small" isAgentRun={isAgentTaskRow} />
</Paragraph>
);
}
function NodeStatusIcon({ node }: { node: TraceEvent }) {
if (node.data.level !== "TRACE") return null;
if (!node.data.style.variant) return null;
if (node.data.style.variant === "warm") {
return <WarmStartIconWithTooltip isWarmStart={true} className="size-4" />;
} else if (node.data.style.variant === "cold") {
return <WarmStartIconWithTooltip isWarmStart={false} className="size-4" />;
}
if (node.data.isCancelled) {
return (
<>
<Paragraph variant="extra-small" className={runStatusClassNameColor("CANCELED")}>
Canceled
</Paragraph>
<TaskRunStatusIcon status="CANCELED" className={cn("size-4")} />
</>
);
}
if (node.data.isError) {
return <TaskRunStatusIcon status="COMPLETED_WITH_ERRORS" className={cn("size-4")} />;
}
if (node.data.isPartial) {
return <TaskRunStatusIcon status={"EXECUTING"} className={cn("size-4")} />;
}
return <TaskRunStatusIcon status="COMPLETED_SUCCESSFULLY" className={cn("size-4")} />;
}
function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boolean }) {
return <div className={cn("h-8 w-2 border-r border-grid-bright")} />;
}
function ShowParentOrRootLinks({
relationships,
}: {
relationships: {
root?: {
friendlyId: string;
spanId: string;
isParent?: boolean;
};
parent?: {
friendlyId: string;
spanId: string;
};
};
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Case 1: Root is also the parent
if (relationships.root?.isParent === true) {
return (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root and parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
Root/parent
</LinkButton>
);
}
// Case 2: Root and Parent are different runs
return (
<div className="flex items-center">
{relationships.root && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "t" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root run</Paragraph>
<ShortcutKey shortcut={{ key: "t" }} variant="small" />
</div>
}
className="text-xs"
>
Root
</LinkButton>
)}
{relationships.parent && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.parent.friendlyId },
{ spanId: relationships.parent.spanId }
)}
LeadingIcon={MoveUpIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
Parent
</LinkButton>
)}
</div>
);
}
function LiveReloadingStatus({
rootSpanCompleted,
isLiveReloading,
settingValue,
}: {
rootSpanCompleted: boolean;
isLiveReloading: boolean;
settingValue: number;
}) {
if (rootSpanCompleted) return null;
return (
<>
{isLiveReloading ? (
<div className="flex items-center gap-1">
<PulsingDot />
<Paragraph variant="extra-small" className="whitespace-nowrap text-blue-500">
Live reloading
</Paragraph>
</div>
) : (
<SimpleTooltip
content={`Live reloading is disabled because you've exceeded ${settingValue} logs.`}
button={
<div className="flex items-center gap-1">
<BoltSlashIcon className="size-3.5 text-text-dimmed" />
<Paragraph variant="extra-small" className="whitespace-nowrap text-text-dimmed">
Live reloading disabled
</Paragraph>
</div>
}
></SimpleTooltip>
)}
</>
);
}
function PulsingDot() {
return (
<span className="relative flex size-2">
<span
className={`absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000`}
/>
<span className={`size-2 rounded-full bg-blue-500`} />
</span>
);
}
function SpanWithDuration({
showDuration,
node,
fadeLeft,
disableAnimations,
...props
}: Timeline.SpanProps & {
node: TraceEvent;
showDuration: boolean;
fadeLeft: boolean;
disableAnimations?: boolean;
}) {
return (
<Timeline.Span {...props}>
<motion.div
className={cn(
"relative flex h-4 w-full min-w-0.5 items-center",
eventBackgroundClassName(node.data),
fadeLeft ? "rounded-r-sm bg-linear-to-r from-black/50 to-transparent" : "rounded-sm"
)}
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
layoutId={disableAnimations ? undefined : node.id}
animate={disableAnimations ? false : undefined}
>
{node.data.isPartial && (
<div
className="absolute left-0 top-0 h-full w-full animate-tile-scroll rounded-sm opacity-30"
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
/>
)}
<motion.div
className={cn(
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
!showDuration && "opacity-0"
)}
animate={disableAnimations ? false : undefined}
>
<motion.div
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
layout={disableAnimations ? undefined : "position"}
animate={disableAnimations ? false : undefined}
>
{formatDurationMilliseconds(props.durationMs, {
style: "short",
maxDecimalPoints: props.durationMs < 1000 ? 0 : 1,
})}
</motion.div>
</motion.div>
</motion.div>
</Timeline.Span>
);
}
const edgeBoundary = 0.17;
function CurrentTimeIndicator({
totalDuration,
rootStartedAt,
queuedDurationNs,
}: {
totalDuration: number;
rootStartedAt: Date | undefined;
queuedDurationNs: number | undefined;
}) {
return (
<Timeline.FollowCursor>
{(ms) => {
const ratio = ms / nanosecondsToMilliseconds(totalDuration);
let offset = 0.5;
if (ratio < edgeBoundary) {
offset = lerp(0, 0.5, ratio / edgeBoundary);
} else if (ratio > 1 - edgeBoundary) {
offset = lerp(0.5, 1, (ratio - (1 - edgeBoundary)) / edgeBoundary);
}
const currentTime = rootStartedAt
? new Date(
rootStartedAt.getTime() + ms + nanosecondsToMilliseconds(queuedDurationNs ?? 0)
)
: undefined;
const currentTimeComponent = currentTime ? <DateTimeShort date={currentTime} /> : <></>;
return (
<div className="relative z-50 flex h-full flex-col">
<div className="relative flex h-6 items-end">
<div
className="absolute w-fit whitespace-nowrap rounded-sm border border-border-bright bg-background-hover px-1 py-0.5 text-xxs tabular-nums text-text-bright"
style={{
left: `${offset * 100}%`,
transform: `translateX(-${offset * 100}%)`,
}}
>
{currentTimeComponent ? (
<span>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
<span className="mx-1 text-text-dimmed"></span>
{currentTimeComponent}
</span>
) : (
<>
{formatDurationMilliseconds(ms, {
style: "short",
maxDecimalPoints: ms < 1000 ? 0 : 1,
})}
</>
)}
</div>
</div>
<div className="w-px grow border-r border-border-bright" />
</div>
);
}}
</Timeline.FollowCursor>
);
}
function KeyboardShortcuts({
expandAllBelowDepth,
collapseAllBelowDepth,
toggleExpandLevel,
}: {
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
toggleExpandLevel: (depth: number) => void;
setShowDurations?: (show: (show: boolean) => boolean) => void;
}) {
return (
<>
<ArrowKeyShortcuts />
<AdjacentRunsShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
title="Expand all"
/>
<ShortcutWithAction
shortcut={{ key: "w" }}
action={() => collapseAllBelowDepth(1)}
title="Collapse all"
/>
<NumberShortcuts toggleLevel={(number) => toggleExpandLevel(number)} />
<ShortcutWithAction shortcut={{ key: "Q" }} title="Queue time" action={() => {}} />
</>
);
}
function AdjacentRunsShortcuts() {
return (
<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Next/previous run
</Paragraph>
</div>
);
}
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.65rem] 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>
);
}
function SearchField({ onChange }: { onChange: (value: string) => void }) {
const [value, setValue] = useState("");
const updateFilterText = useDebounce((text: string) => {
onChange(text);
}, 250);
const updateValue = useCallback((next: string) => {
setValue(next);
updateFilterText(next);
}, []);
return <SearchInput placeholder="Search logs…" value={value} onValueChange={updateValue} />;
}
function useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
tabParam,
useSpan,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
tableState: string;
run: { friendlyId: string; spanId: string };
runsList: RunsListNavigation | null;
tabParam?: string;
useSpan?: boolean;
}): [string | null, string | null] {
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; spanId: 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; spanId: 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());
if (previousRun && useSpan) {
previousURLSearchParams.set("span", previousRun.spanId);
}
if (tabParam && useSpan) {
previousURLSearchParams.set("tab", tabParam);
}
const previousRunPath = previousRun
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
: null;
const nextURLSearchParams = new URLSearchParams();
nextURLSearchParams.set("tableState", nextRunTableState.toString());
if (nextRun && useSpan) {
nextURLSearchParams.set("span", nextRun.spanId);
}
if (tabParam && useSpan) {
nextURLSearchParams.set("tab", tabParam);
}
const nextRunPath = nextRun
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
: null;
return [previousRunPath, nextRunPath];
}
function PreviousRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : "#"}
variant={"minimal/small"}
LeadingIcon={ChevronExtraSmallUp}
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "j" }}
tooltip="Previous Run"
disabled={!to}
replace
/>
</div>
);
}
function NextRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : "#"}
variant={"minimal/small"}
LeadingIcon={ChevronExtraSmallDown}
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "k" }}
tooltip="Next Run"
disabled={!to}
replace
/>
</div>
);
}