Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/routes/v2/pages/RunView/RunViewV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,13 @@ const RunViewLayout = observer(function RunViewLayout({
showModeToggle={timingEnabled}
/>
<InlineStack
className="flex-1 min-h-0 w-full"
className="min-h-0 min-w-0 w-full flex-1 overflow-hidden"
blockAlign="stretch"
wrap="nowrap"
data-testid="run-view-v2"
>
<DockArea side="left" excludedWindowIds={excludedWindowIds} />
<div className="relative flex-1 min-w-0 h-full">
<div className="relative h-full min-h-0 min-w-0 flex-1 overflow-hidden">
{mode === "timing" ? (
<RunTimingView />
) : (
Expand Down Expand Up @@ -240,7 +240,7 @@ export function RunViewV2() {
: undefined;

return (
<div className="h-full w-full flex flex-col bg-slate-100 dark:bg-background select-none">
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden bg-slate-100 select-none dark:bg-background">
<SharedStoreProvider>
<AiChatStoreProvider
createWorker={createRunViewAgentWorker}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { RunTimingToolbar } from "./RunTimingToolbar";

afterEach(cleanup);

describe("RunTimingToolbar", () => {
it("supports task filtering, critical-path filtering, and refresh", () => {
const onTaskFilterChange = vi.fn();
const onCriticalPathOnlyChange = vi.fn();
const onRefresh = vi.fn();

render(
<RunTimingToolbar
taskFilter=""
criticalPathOnly={false}
refreshing={false}
onTaskFilterChange={onTaskFilterChange}
onCriticalPathOnlyChange={onCriticalPathOnlyChange}
onRefresh={onRefresh}
/>,
);

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(
<RunTimingToolbar
taskFilter="train"
criticalPathOnly
refreshing
onTaskFilterChange={vi.fn()}
onCriticalPathOnlyChange={vi.fn()}
onRefresh={vi.fn()}
/>,
);

expect(
screen.getByRole("button", { name: "Critical path only" }),
).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Refreshing" })).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<InlineStack gap="2" wrap="wrap" blockAlign="center">
<label htmlFor="run-timing-task-filter" className="sr-only">
Search timing tasks
</label>
<InputGroup
className="w-56"
prefixElement={
<Icon
name="Search"
size="xs"
className="ml-2 text-muted-foreground"
aria-hidden="true"
/>
}
>
<Input
id="run-timing-task-filter"
type="search"
variant="noBorder"
className="h-8 px-1"
placeholder="Search tasks"
value={taskFilter}
onChange={(event) => onTaskFilterChange(event.target.value)}
onEscape={() => onTaskFilterChange("")}
/>
</InputGroup>
<Button
type="button"
variant={criticalPathOnly ? "secondary" : "outline"}
size="sm"
aria-pressed={criticalPathOnly}
onClick={() => onCriticalPathOnlyChange(!criticalPathOnly)}
{...tracking("v2.run_view.run_timing.critical_path_filter", {
new_value: !criticalPathOnly,
})}
>
<Icon name="Route" size="xs" />
Critical path only
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={refreshing}
onClick={onRefresh}
{...tracking("v2.run_view.run_timing.refresh_button")}
>
<Icon
name="RefreshCw"
size="xs"
className={refreshing ? "animate-spin" : undefined}
/>
{refreshing ? "Refreshing" : "Refresh"}
</Button>
<Button asChild variant="ghost" size="sm">
<Link
href={GIVE_FEEDBACK_URL}
external
variant="block"
size="sm"
{...tracking("v2.run_view.run_timing.feedback_link")}
>
Give feedback
</Link>
</Button>
</InlineStack>
);
}
Original file line number Diff line number Diff line change
@@ -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(<RunTimingView />);

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" },
);
});
});
Loading
Loading