diff --git a/src/routes/v2/pages/RunView/RunViewV2.tsx b/src/routes/v2/pages/RunView/RunViewV2.tsx index 5110f10cd4..a0730efd1c 100644 --- a/src/routes/v2/pages/RunView/RunViewV2.tsx +++ b/src/routes/v2/pages/RunView/RunViewV2.tsx @@ -200,13 +200,13 @@ const RunViewLayout = observer(function RunViewLayout({ showModeToggle={timingEnabled} /> -
+
{mode === "timing" ? ( ) : ( @@ -240,7 +240,7 @@ export function RunViewV2() { : undefined; return ( -
+
{ + it("supports task filtering, critical-path filtering, and refresh", () => { + const onTaskFilterChange = vi.fn(); + const onCriticalPathOnlyChange = vi.fn(); + const onRefresh = vi.fn(); + + render( + , + ); + + fireEvent.change( + screen.getByRole("searchbox", { name: "Search timing tasks" }), + { + target: { value: "train" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Critical path only" })); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + + expect(onTaskFilterChange).toHaveBeenCalledWith("train"); + expect(onCriticalPathOnlyChange).toHaveBeenCalledWith(true); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(screen.getByRole("link", { name: /Give feedback/ })).toHaveAttribute( + "target", + "_blank", + ); + }); + + it("announces active filters and refresh progress", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: "Critical path only" }), + ).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Refreshing" })).toBeDisabled(); + }); +}); diff --git a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingToolbar.tsx b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingToolbar.tsx new file mode 100644 index 0000000000..81c6e85126 --- /dev/null +++ b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingToolbar.tsx @@ -0,0 +1,94 @@ +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { Input, InputGroup } from "@/components/ui/input"; +import { InlineStack } from "@/components/ui/layout"; +import { Link } from "@/components/ui/link"; +import { GIVE_FEEDBACK_URL } from "@/utils/constants"; +import { tracking } from "@/utils/tracking"; + +interface RunTimingToolbarProps { + taskFilter: string; + criticalPathOnly: boolean; + refreshing: boolean; + onTaskFilterChange: (value: string) => void; + onCriticalPathOnlyChange: (value: boolean) => void; + onRefresh: () => void; +} + +export function RunTimingToolbar({ + taskFilter, + criticalPathOnly, + refreshing, + onTaskFilterChange, + onCriticalPathOnlyChange, + onRefresh, +}: RunTimingToolbarProps) { + return ( + + + + + + + + ); +} diff --git a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.test.tsx b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.test.tsx new file mode 100644 index 0000000000..84d36681be --- /dev/null +++ b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RunTimingData } from "./runTiming.types"; +import { RunTimingView } from "./RunTimingView"; + +const mocks = vi.hoisted(() => ({ + editor: { selectNode: vi.fn() }, + navigation: { + rootSpec: { name: "Pipeline" }, + navigateToPath: vi.fn(), + }, + refetch: vi.fn(), +})); + +const timingData: RunTimingData = { + tasks: [ + { + executionId: "exec-a", + parentExecutionId: "root-exec", + taskId: "task-a", + taskName: "task-a", + navigationPath: ["Pipeline"], + depth: 0, + dependencyExecutionIds: [], + isSubgraph: false, + status: "SUCCEEDED", + phases: [ + { + name: "runtime", + startAt: 1_000, + endAt: 2_000, + durationMs: 1_000, + }, + ], + startAt: 1_000, + endAt: 2_000, + durationMs: 1_000, + cacheState: "unknown", + timingQuality: "partial", + }, + ], + truncated: false, + rangeStart: 1_000, + rangeEnd: 2_000, + criticalPathExecutionIds: new Set(["exec-a"]), + metrics: { + wallClockDurationMs: 1_000, + totalTaskCount: 1, + cachedTaskCount: 0, + startupCoverage: 0, + busyRuntimeMs: 1_000, + busyPercent: 100, + criticalPathDurationMs: 1_000, + }, +}; + +vi.mock("@/providers/ExecutionDataProvider", () => ({ + useExecutionData: () => ({ + rootDetails: { id: "root-exec" }, + rootState: { child_execution_status_stats: {} }, + metadata: { created_at: "2026-07-14T10:00:00Z" }, + }), +})); + +vi.mock("@/routes/v2/shared/store/SharedStoreContext", () => ({ + useSharedStores: () => ({ + editor: mocks.editor, + navigation: mocks.navigation, + }), +})); + +vi.mock("./useRunTimingData", () => ({ + useRunTimingData: () => ({ + data: timingData, + isFetching: false, + isLoading: false, + refetch: mocks.refetch, + }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("RunTimingView", () => { + it("navigates to a task's graph context before opening its properties", () => { + mocks.navigation.navigateToPath.mockReturnValue({ + tasks: [{ $id: "model-task-a", name: "task-a" }], + }); + + render(); + + expect(screen.getByTestId("run-timing-view")).toHaveClass( + "h-full", + "min-h-0", + "w-full", + "min-w-0", + "max-w-full", + "overflow-hidden", + ); + + fireEvent.click( + screen.getByRole("button", { name: "Open task-a task details" }), + ); + + expect(mocks.navigation.navigateToPath).toHaveBeenCalledWith(["Pipeline"]); + expect(mocks.editor.selectNode).toHaveBeenCalledWith( + "model-task-a", + "task", + { entityId: "model-task-a" }, + ); + }); +}); diff --git a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.tsx b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.tsx index ec19419020..e37a36587e 100644 --- a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.tsx +++ b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingView.tsx @@ -1,31 +1,60 @@ +import { useState } from "react"; + import { InfoBox } from "@/components/shared/InfoBox"; import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { RemoteAuthErrorView } from "@/components/shared/RemoteAuthErrorView"; +import { Badge } from "@/components/ui/badge"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph } from "@/components/ui/typography"; import { useExecutionData } from "@/providers/ExecutionDataProvider"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { flattenExecutionStatusStats, isExecutionComplete, } from "@/utils/executionStatus"; +import { RemoteAuthError } from "@/utils/fetchWithErrorHandling"; +import type { RunTimingTask } from "./runTiming.types"; import { RunTimingChart, RunTimingChartLegend } from "./RunTimingChart"; import { RunTimingSummary } from "./RunTimingSummary"; +import { RunTimingToolbar } from "./RunTimingToolbar"; import { useRunTimingData } from "./useRunTimingData"; export function RunTimingView() { + const [taskFilter, setTaskFilter] = useState(""); + const [criticalPathOnly, setCriticalPathOnly] = useState(false); + const { editor, navigation } = useSharedStores(); const { rootDetails, rootState, metadata } = useExecutionData(); const runComplete = isExecutionComplete( flattenExecutionStatusStats(rootState?.child_execution_status_stats), ); - const { data, error, isLoading } = useRunTimingData({ + const { data, error, isFetching, isLoading, refetch } = useRunTimingData({ rootDetails, runCreatedAt: metadata?.created_at, runComplete, }); + const handleTaskSelect = (task: RunTimingTask) => { + const rootName = navigation.rootSpec?.name; + if (!rootName) return; + + const targetSpec = navigation.navigateToPath([ + rootName, + ...task.navigationPath.slice(1), + ]); + const targetTask = targetSpec?.tasks.find( + (candidate) => candidate.name === task.taskId, + ); + if (!targetTask) return; + + editor.selectNode(targetTask.$id, "task", { entityId: targetTask.$id }); + }; + if (isLoading) return ; if (error) { + if (error instanceof RemoteAuthError) return ; + return ( @@ -45,21 +74,45 @@ export function RunTimingView() { - Run timing + + Run timing + + Beta + + Explore where this run spent time across task phases. + {data.truncated && ( + + This beta view shows the first 250 task executions. Timing totals + may be incomplete. + + )} + void refetch()} + /> - + ); diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.test.tsx b/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.test.tsx index 5231c5dced..0d654e4961 100644 --- a/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.test.tsx +++ b/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.test.tsx @@ -87,6 +87,15 @@ class MockNavigationStore { const renderSyncHook = () => renderHook(() => useRunViewSubgraphUrlSync()); +function expectNavigationToPreserveSearch(to: string) { + const options = routerMocks.navigate.mock.calls[0][0]; + expect(options).toMatchObject({ to, search: expect.any(Function) }); + expect(options.search({ view: "timing", debug: "true" })).toEqual({ + view: "timing", + debug: "true", + }); +} + describe("useRunViewSubgraphUrlSync", () => { beforeEach(() => { vi.clearAllMocks(); @@ -118,9 +127,7 @@ describe("useRunViewSubgraphUrlSync", () => { act(() => navigation.navigateToSubgraph("sub-task")); expect(routerMocks.navigate).toHaveBeenCalledTimes(1); - expect(routerMocks.navigate).toHaveBeenCalledWith({ - to: "/runs-v2/run-1/exec-sub", - }); + expectNavigationToPreserveSearch("/runs-v2/run-1/exec-sub"); }); it("does not navigate when the child execution id cannot be resolved", () => { @@ -143,7 +150,7 @@ describe("useRunViewSubgraphUrlSync", () => { act(() => navigation.navigateToLevel(0)); expect(routerMocks.navigate).toHaveBeenCalledTimes(1); - expect(routerMocks.navigate).toHaveBeenCalledWith({ to: "/runs-v2/run-1" }); + expectNavigationToPreserveSearch("/runs-v2/run-1"); }); it("resolves the target execution id from breadcrumb segments when going shallower", () => { @@ -161,9 +168,7 @@ describe("useRunViewSubgraphUrlSync", () => { act(() => navigation.navigateToLevel(1)); expect(routerMocks.navigate).toHaveBeenCalledTimes(1); - expect(routerMocks.navigate).toHaveBeenCalledWith({ - to: "/runs-v2/run-1/exec-a", - }); + expectNavigationToPreserveSearch("/runs-v2/run-1/exec-a"); }); it("syncs the navigation store from an external subgraph URL without pushing back", () => { diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.ts b/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.ts index 0f15b96ea4..4cfae7f72c 100644 --- a/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.ts +++ b/src/routes/v2/pages/RunView/hooks/useRunViewSubgraphUrlSync.ts @@ -87,7 +87,7 @@ export function useRunViewSubgraphUrlSync() { if (window.location.pathname === target) return; lastPushedExecutionId.current = executionId; - navigateRef.current({ to: target }); + navigateRef.current({ to: target, search: (previous) => previous }); }, );