diff --git a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.test.tsx b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.test.tsx new file mode 100644 index 0000000000..96ffe13e2e --- /dev/null +++ b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.test.tsx @@ -0,0 +1,173 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { RunTimingData, RunTimingTask } from "./runTiming.types"; +import { RunTimingChart } from "./RunTimingChart"; + +const START = Date.parse("2026-07-14T10:00:00Z"); +const MINUTE = 60_000; + +function task(overrides: Partial = {}): RunTimingTask { + return { + executionId: "exec-a", + parentExecutionId: "root", + taskId: "prepare-data", + taskName: "prepare-data", + navigationPath: ["Pipeline"], + depth: 0, + dependencyExecutionIds: [], + isSubgraph: false, + status: "SUCCEEDED", + phases: [ + { + name: "startup", + startAt: START, + endAt: START + MINUTE, + durationMs: MINUTE, + }, + { + name: "runtime", + startAt: START + MINUTE, + endAt: START + 3 * MINUTE, + durationMs: 2 * MINUTE, + }, + ], + startAt: START, + endAt: START + 3 * MINUTE, + durationMs: 3 * MINUTE, + cacheState: "unknown", + timingQuality: "partial", + ...overrides, + }; +} + +function timingData(tasks: RunTimingTask[]): RunTimingData { + return { + tasks, + truncated: false, + rangeStart: START, + rangeEnd: START + 3 * MINUTE, + criticalPathExecutionIds: new Set(["exec-a"]), + metrics: { + wallClockDurationMs: 3 * MINUTE, + totalTaskCount: tasks.length, + cachedTaskCount: 0, + startupCoverage: 1, + busyRuntimeMs: 2 * MINUTE, + busyPercent: 67, + criticalPathDurationMs: 3 * MINUTE, + }, + }; +} + +afterEach(cleanup); + +describe("RunTimingChart", () => { + it("renders phase and critical-path information accessibly", () => { + const onTaskSelect = vi.fn(); + const timingTask = task(); + render( + , + ); + + expect(screen.getByRole("table", { name: "Run timing" })).toHaveClass( + "min-w-0", + "w-full", + "max-w-full", + "overflow-auto", + ); + expect(screen.getAllByRole("row")[0]).toHaveStyle({ + gridTemplateColumns: "320px minmax(1200px, 1fr)", + minWidth: "1520px", + }); + expect(screen.getAllByText("3m 0s")[0].parentElement).toHaveStyle({ + right: "12px", + }); + expect(screen.getByText("prepare-data")).toBeInTheDocument(); + expect( + screen.getByRole("img", { name: "startup / queue 1m 0s" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("img", { name: "runtime 2m 0s" }), + ).toBeInTheDocument(); + expect(screen.getByText(/Critical path task/)).toBeInTheDocument(); + expect(screen.getByText(/Status: Succeeded/)).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Open prepare-data task details" }), + ); + expect(onTaskSelect).toHaveBeenCalledWith(timingTask); + }); + + it("resizes the task name column with mouse and keyboard controls", () => { + render( + , + ); + + const resizeHandle = screen.getByRole("separator", { + name: "Resize task name column", + }); + fireEvent.keyDown(resizeHandle, { key: "ArrowRight" }); + + expect(resizeHandle).toHaveAttribute("aria-valuenow", "336"); + expect(screen.getAllByRole("row")[0]).toHaveStyle({ + gridTemplateColumns: "336px minmax(1200px, 1fr)", + minWidth: "1536px", + }); + + fireEvent.mouseDown(resizeHandle, { clientX: 100 }); + fireEvent.mouseMove(document, { clientX: 164 }); + fireEvent.mouseUp(document); + + expect(resizeHandle).toHaveAttribute("aria-valuenow", "400"); + }); + + it("identifies cache hits without showing historical runtime", () => { + render( + , + ); + + expect( + screen.getByRole("img", { + name: "Cache hit; no container runtime in this run", + }), + ).toBeInTheDocument(); + expect(screen.getByText(/Cache hit\./)).toBeInTheDocument(); + }); + + it("shows an empty state before task executions exist", () => { + render(); + + expect( + screen.getByText("This run has no task executions yet."), + ).toBeInTheDocument(); + }); + + it("filters task rows by task or component name", () => { + render( + , + ); + + expect( + screen.getByText("No tasks match the current filters."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.tsx b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.tsx new file mode 100644 index 0000000000..a008bc2c3c --- /dev/null +++ b/src/routes/v2/pages/RunView/components/RunTiming/RunTimingChart.tsx @@ -0,0 +1,475 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + useState, +} from "react"; + +import StatusIcon from "@/components/shared/Status/StatusIcon"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { getExecutionStatusLabel } from "@/utils/executionStatus"; +import { tracking } from "@/utils/tracking"; + +import { formatTimingDuration } from "./runTiming"; +import type { + RunTimingData, + RunTimingPhase, + RunTimingTask, +} from "./runTiming.types"; + +const DEFAULT_TASK_COLUMN_WIDTH = 320; +const MIN_TASK_COLUMN_WIDTH = 240; +const MAX_TASK_COLUMN_WIDTH = 640; +const TASK_COLUMN_RESIZE_STEP = 16; +const TIMELINE_EDGE_PADDING = 12; +const MIN_TIMELINE_WIDTH = 1200; +const TICK_COUNT = 5; + +const PHASE_CLASS: Record = { + startup: "bg-slate-400 dark:bg-slate-500", + runtime: "bg-emerald-600 dark:bg-emerald-500", +}; + +function orderedTasks(tasks: RunTimingTask[]): RunTimingTask[] { + const rootParentId = tasks.find( + (task) => task.depth === 0, + )?.parentExecutionId; + if (!rootParentId) return tasks; + + const childrenByParent = new Map(); + for (const task of tasks) { + const siblings = childrenByParent.get(task.parentExecutionId) ?? []; + siblings.push(task); + childrenByParent.set(task.parentExecutionId, siblings); + } + + const ordered: RunTimingTask[] = []; + const appendChildren = (parentExecutionId: string) => { + for (const task of childrenByParent.get(parentExecutionId) ?? []) { + ordered.push(task); + if (task.isSubgraph) appendChildren(task.executionId); + } + }; + appendChildren(rootParentId); + return ordered; +} + +function percentage(value: number, start: number, duration: number): number { + return ((value - start) / duration) * 100; +} + +function TimelineGridLines({ ticks }: { ticks: number[] }) { + return ( + <> + {ticks.map((_, index) => ( +