2071 lines
73 KiB
TypeScript
2071 lines
73 KiB
TypeScript
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>
|
||
);
|
||
}
|