diff --git a/src/components/Home/RunSection/RunBulkActionsBar.tsx b/src/components/Home/RunSection/RunBulkActionsBar.tsx new file mode 100644 index 000000000..e680d7f4c --- /dev/null +++ b/src/components/Home/RunSection/RunBulkActionsBar.tsx @@ -0,0 +1,59 @@ +import { useNavigate } from "@tanstack/react-router"; + +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 { APP_ROUTES } from "@/routes/appRoutes"; +import { pluralize } from "@/utils/string"; +import { tracking } from "@/utils/tracking"; + +interface RunBulkActionsBarProps { + selectedRuns: string[]; + onClearSelection: () => void; +} + +const RunBulkActionsBar = ({ + selectedRuns, + onClearSelection, +}: RunBulkActionsBarProps) => { + const navigate = useNavigate(); + + const canCompare = selectedRuns.length === 2; + + const handleCompare = () => { + if (!canCompare) return; + const [a, b] = selectedRuns; + navigate({ to: APP_ROUTES.COMPARE, search: { a, b } }); + }; + + return ( +
+ + + {selectedRuns.length} {pluralize(selectedRuns.length, "run")} selected + + + + + + + + +
+ ); +}; + +export default RunBulkActionsBar; diff --git a/src/components/Home/RunSection/RunRow.tsx b/src/components/Home/RunSection/RunRow.tsx index 78ee0c35f..8dd98be6a 100644 --- a/src/components/Home/RunSection/RunRow.tsx +++ b/src/components/Home/RunSection/RunRow.tsx @@ -9,6 +9,7 @@ import { RunSourceIcon } from "@/components/shared/RunSource"; import { StatusBar, StatusIcon } from "@/components/shared/Status"; import { TagList } from "@/components/shared/Tags/TagList"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { InlineStack } from "@/components/ui/layout"; import { TableCell, TableRow } from "@/components/ui/table"; import { @@ -32,9 +33,18 @@ import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; interface RunRowProps { run: PipelineRunResponse; onFilterByUser?: (createdBy: string) => void; + selectable?: boolean; + isSelected?: boolean; + onToggleSelected?: (runId: string) => void; } -const RunRow = ({ run, onFilterByUser }: RunRowProps) => { +const RunRow = ({ + run, + onFilterByUser, + selectable = false, + isSelected = false, + onToggleSelected, +}: RunRowProps) => { const navigate = useNavigate(); const { backendUrl } = useBackend(); @@ -111,6 +121,20 @@ const RunRow = ({ run, onFilterByUser }: RunRowProps) => { onClick={handleRowClick} className="cursor-pointer text-muted-foreground text-xs h-10" > + {selectable && ( + +
e.stopPropagation()} + className="flex items-center" + > + onToggleSelected?.(runId)} + aria-label={`Select run ${runId}`} + /> +
+
+ )} diff --git a/src/components/Home/RunSection/RunSection.tsx b/src/components/Home/RunSection/RunSection.tsx index 472b31154..5cd81c927 100644 --- a/src/components/Home/RunSection/RunSection.tsx +++ b/src/components/Home/RunSection/RunSection.tsx @@ -6,6 +6,7 @@ import type { ListPipelineJobsResponse } from "@/api/types.gen"; import { InfoBox } from "@/components/shared/InfoBox"; import { useFlagValue } from "@/components/shared/Settings/useFlags"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -29,6 +30,7 @@ import { parseFilterParam, } from "@/utils/pipelineRunFilterUtils"; +import RunBulkActionsBar from "./RunBulkActionsBar"; import RunRow from "./RunRow"; const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/"; @@ -58,9 +60,24 @@ export const RunSection = ({ const { pathname } = useLocation(); const search = useSearch({ strict: false }) as RunSectionSearch; const isCreatedByMeDefault = useFlagValue("created-by-me-default"); + const compareEnabled = useFlagValue("compare-runs"); const { setFilter } = useRunSearchParams(); const dataVersion = useRef(0); + const [selectedRuns, setSelectedRuns] = useState>(new Set()); + + const toggleRun = (runId: string) => { + setSelectedRuns((prev) => { + const next = new Set(prev); + if (next.has(runId)) { + next.delete(runId); + } else { + next.add(runId); + } + return next; + }); + }; + const onFilterByUser = forcedFilter ? undefined : (createdBy: string) => setFilter("created_by", createdBy); @@ -279,12 +296,42 @@ export const RunSection = ({ ); } + const pageRuns = maxItems + ? data.pipeline_runs?.slice(0, maxItems) + : data.pipeline_runs; + + const allPageRunsSelected = + !!pageRuns?.length && + pageRuns.every((run) => selectedRuns.has(`${run.id}`)); + + const toggleSelectAll = () => { + setSelectedRuns((prev) => { + const next = new Set(prev); + const pageRunIds = pageRuns?.map((run) => `${run.id}`) ?? []; + if (allPageRunsSelected) { + pageRunIds.forEach((id) => next.delete(id)); + } else { + pageRunIds.forEach((id) => next.add(id)); + } + return next; + }); + }; + return ( {searchMarkup} + {compareEnabled && ( + + + + )} Name Status Date @@ -293,15 +340,26 @@ export const RunSection = ({ - {(maxItems - ? data.pipeline_runs?.slice(0, maxItems) - : data.pipeline_runs - )?.map((run) => ( - + {pageRuns?.map((run) => ( + ))}
+ {compareEnabled && selectedRuns.size > 0 && ( + setSelectedRuns(new Set())} + /> + )} + {(data.next_page_token || previousPageTokens.length > 0) && ( path.length <= 1; const buildPathKey = (path: string[]) => path.join(PATH_DELIMITER); -const buildTaskExecutionStatusMap = ( - details?: GetExecutionInfoResponse, - state?: GetGraphExecutionStateResponse, -): Map => { - const taskExecutionStatusMap = new Map(); - - if (!details?.child_task_execution_ids) { - return taskExecutionStatusMap; - } - - Object.entries(details.child_task_execution_ids).forEach( - ([taskId, executionId]) => { - const statusStats = state?.child_execution_status_stats?.[executionId]; - const aggregated = getOverallExecutionStatusFromStats(statusStats); - - if (aggregated) { - taskExecutionStatusMap.set(taskId, aggregated); - } - }, - ); - - return taskExecutionStatusMap; -}; - const findExecutionIdAtPath = ( path: string[], runId: string | null | undefined, diff --git a/src/routes/appRoutes.ts b/src/routes/appRoutes.ts index f278ca6a1..e8397a0b1 100644 --- a/src/routes/appRoutes.ts +++ b/src/routes/appRoutes.ts @@ -46,4 +46,5 @@ export const APP_ROUTES = { PIPELINE_FOLDERS: "/pipeline-folders", PLAYGROUND: "/playground", ARTIFACT_PREVIEW: "/artifact/$artifactId", + COMPARE: "/compare", } as const; diff --git a/src/routes/router.ts b/src/routes/router.ts index e5c07d9c1..e7c1655d7 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -44,6 +44,7 @@ import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings"; import { PreferencesSettings } from "./Settings/sections/PreferencesSettings"; import { SecretsSettings } from "./Settings/sections/SecretsSettings"; import { SettingsLayout } from "./Settings/SettingsLayout"; +import { CompareView } from "./v2/pages/CompareView/CompareView"; import { EditorV2 } from "./v2/pages/Editor/EditorV2"; import { PipelineFoldersPage } from "./v2/pages/PipelineFolders/PipelineFoldersPage"; import { RunViewV2 } from "./v2/pages/RunView/RunViewV2"; @@ -363,6 +364,17 @@ const runV2WithSubgraphRoute = createRoute({ }, }); +const compareRoute = createRoute({ + getParentRoute: () => mainLayout, + path: APP_ROUTES.COMPARE, + component: CompareView, + beforeLoad: () => { + if (!isFlagEnabled("compare-runs")) { + throw redirect({ to: APP_ROUTES.DASHBOARD_RUNS }); + } + }, +}); + const pipelineFoldersRoute = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.PIPELINE_FOLDERS, @@ -403,6 +415,7 @@ const appRouteTree = mainLayout.addChildren([ editorV2PipelineRoute, runV2Route, runV2WithSubgraphRoute, + compareRoute, pipelineFoldersRoute, artifactPreviewRoute, tourRoute, diff --git a/src/routes/v2/pages/CompareView/CompareView.tsx b/src/routes/v2/pages/CompareView/CompareView.tsx new file mode 100644 index 000000000..5f58e66c4 --- /dev/null +++ b/src/routes/v2/pages/CompareView/CompareView.tsx @@ -0,0 +1,262 @@ +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { RemoteAuthErrorView } from "@/components/shared/RemoteAuthErrorView"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Heading, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { APP_ROUTES } from "@/routes/appRoutes"; +import { RemoteAuthError } from "@/utils/fetchWithErrorHandling"; +import { tracking } from "@/utils/tracking"; + +import { CompareRunPicker } from "./components/CompareRunPicker"; +import { GraphDiffPlaceholder } from "./components/GraphDiffPlaceholder"; +import { StructuredDiffView } from "./components/StructuredDiffView"; +import { YamlDiffView } from "./components/YamlDiffView"; +import { useRunComparisonSide } from "./hooks/useRunComparisonSide"; +import { buildPipelineComparison } from "./utils/comparePipelines"; + +interface CompareSearch { + a?: string; + b?: string; +} + +const LABEL_A = "A"; +const LABEL_B = "B"; + +export function CompareView() { + const search = useSearch({ strict: false }) as CompareSearch; + const navigate = useNavigate(); + const { track } = useAnalytics(); + + const a = search.a ?? ""; + const b = search.b ?? ""; + + const sideA = useRunComparisonSide(a); + const sideB = useRunComparisonSide(b); + + const bothSelected = Boolean(a && b && a !== b); + + const [activeTab, setActiveTab] = useState("structured"); + const [yamlMounted, setYamlMounted] = useState(false); + + useEffect(() => { + if (bothSelected) { + track("compare_runs.comparison.impression", { run_a: a, run_b: b }); + } + }, [bothSelected, a, b, track]); + + useEffect(() => { + if (activeTab === "yaml") { + setYamlMounted(true); + } + }, [activeTab]); + + const comparison = buildPipelineComparison( + sideA.spec, + sideB.spec, + sideA.taskStatusMap, + sideB.taskStatusMap, + ); + + const setSide = (side: "a" | "b", id: string) => { + navigate({ + to: APP_ROUTES.COMPARE, + search: (prev: CompareSearch) => ({ ...prev, [side]: id }), + }); + }; + + if (!a) { + return ( + + setSide("a", id)} + /> + + ); + } + + if (!b || a === b) { + return ( + + {a === b && ( + + You selected the same run twice. Choose a different second run. + + )} + setSide("b", id)} + /> + + ); + } + + const error = sideA.error ?? sideB.error; + if (error) { + if (error instanceof RemoteAuthError) { + return ; + } + return ( + + + {error.message} + + + ); + } + + if (sideA.isLoading || sideB.isLoading || !sideA.spec || !sideB.spec) { + return ; + } + + const nameA = sideA.spec.name ?? `Run #${a}`; + const nameB = sideB.spec.name ?? `Run #${b}`; + + return ( + + + + Compare runs + + + + + + + + + + + Structured + + + YAML + + + Graph + + + + + + + + + {yamlMounted && ( + + )} + + + + + + + + ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +interface RunLabelProps { + label: string; + name: string; + runId: string; + tone: "a" | "b"; +} + +function RunLabel({ label, name, runId, tone }: RunLabelProps) { + const toneClass = + tone === "a" + ? "border-blue-400 bg-blue-50 text-blue-800 hover:bg-blue-100" + : "border-emerald-400 bg-emerald-50 text-emerald-800 hover:bg-emerald-100"; + + return ( + + + + {label} + + + {name} + + + #{runId} + + + + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx b/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx new file mode 100644 index 000000000..23375142e --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/CompareRunPicker.tsx @@ -0,0 +1,102 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { ListPipelineJobsResponse } from "@/api/types.gen"; +import { InfoBox } from "@/components/shared/InfoBox"; +import { StatusIcon } from "@/components/shared/Status"; +import { Button } from "@/components/ui/button"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Heading, Text } from "@/components/ui/typography"; +import { useBackend } from "@/providers/BackendProvider"; +import { formatDate } from "@/utils/date"; +import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; +import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; +import { tracking } from "@/utils/tracking"; + +const PIPELINE_RUNS_QUERY_URL = "/api/pipeline_runs/"; + +interface CompareRunPickerProps { + title: string; + excludeRunId?: string; + onSelect: (runId: string) => void; +} + +export function CompareRunPicker({ + title, + excludeRunId, + onSelect, +}: CompareRunPickerProps) { + const { backendUrl, configured, available } = useBackend(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["compare-run-picker", backendUrl], + refetchOnWindowFocus: false, + enabled: configured && available, + queryFn: async () => { + const url = new URL(PIPELINE_RUNS_QUERY_URL, backendUrl); + url.searchParams.set("include_pipeline_names", "true"); + url.searchParams.set("include_execution_stats", "true"); + return fetchWithErrorHandling(url.toString()); + }, + }); + + return ( + + {title} + + {isLoading && ( + + Loading runs… + + )} + + {error && ( + + {error.message} + + )} + + {data && ( + + {(data.pipeline_runs ?? []) + .filter((run) => `${run.id}` !== excludeRunId) + .map((run) => { + const runId = `${run.id}`; + const status = getOverallExecutionStatusFromStats( + run.execution_status_stats ?? undefined, + ); + + return ( + + + + ); + })} + + )} + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx new file mode 100644 index 000000000..357175c46 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx @@ -0,0 +1,44 @@ +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { DiffStatus } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { + DIFF_STATUS_CLASSES, + STATUS_ICON, +} from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/upgradePreviewConstants"; + +const DIFF_STATUS_LABELS: Record = { + unchanged: "Unchanged", + lost: "Removed", + new: "Added", + changed: "Changed", +}; + +interface DiffStatusBadgeProps { + status: DiffStatus; + className?: string; +} + +export function DiffStatusBadge({ status, className }: DiffStatusBadgeProps) { + const icon = STATUS_ICON[status]; + + return ( + + {icon && } + + {DIFF_STATUS_LABELS[status]} + + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx b/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx new file mode 100644 index 000000000..abb197c33 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/FieldDiffRow.tsx @@ -0,0 +1,54 @@ +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import type { KeyedDiffEntry } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; + +import { DiffStatusBadge } from "./DiffStatusBadge"; + +function formatValue(value: unknown): string { + if (value === undefined) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +interface ValueLineProps { + label: string; + value: unknown; +} + +function ValueLine({ label, value }: ValueLineProps) { + return ( + + + {label} + + + {formatValue(value)} + + + ); +} + +interface FieldDiffRowProps { + entry: KeyedDiffEntry; + labelA: string; + labelB: string; +} + +export function FieldDiffRow({ entry, labelA, labelB }: FieldDiffRowProps) { + return ( + + + + {entry.key} + + + + {entry.status !== "new" && } + {entry.status !== "lost" && } + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx b/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx new file mode 100644 index 000000000..68747d354 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/GraphDiffPlaceholder.tsx @@ -0,0 +1,16 @@ +import { Icon } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; + +export function GraphDiffPlaceholder() { + return ( + + + Visual graph comparison is coming soon + + A merged graph that overlays both runs and highlights where they differ + will live here. For now, use the Structured and YAML tabs. + + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx b/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx new file mode 100644 index 000000000..32f4ebb9a --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/StructuredDiffView.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Switch } from "@/components/ui/switch"; +import { Text } from "@/components/ui/typography"; +import type { PipelineComparison } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { tracking } from "@/utils/tracking"; + +import { TaskDiffRow } from "./TaskDiffRow"; + +interface SummaryCountProps { + label: string; + value: number; +} + +function SummaryCount({ label, value }: SummaryCountProps) { + return ( + + + {value} + + + {label} + + + ); +} + +interface StructuredDiffViewProps { + comparison: PipelineComparison; + labelA: string; + labelB: string; +} + +export function StructuredDiffView({ + comparison, + labelA, + labelB, +}: StructuredDiffViewProps) { + const [showUnchanged, setShowUnchanged] = useState(false); + + if (!comparison.hasComparableTasks) { + return ( + + Neither run has a graph pipeline, so there are no tasks to align. Use + the YAML tab to compare the raw specifications. + + ); + } + + const { counts } = comparison; + const visibleDiffs = showUnchanged + ? comparison.taskDiffs + : comparison.taskDiffs.filter( + (diff) => diff.status !== "unchanged" || diff.outcomeChanged, + ); + + return ( + + + + + + + + {counts.outcomeChanged > 0 && ( + + + + {counts.outcomeChanged} + + + outcome differs + + + )} + + + + + + + + {visibleDiffs.length === 0 ? ( + + These two runs have identical task configurations. + + ) : ( + + {visibleDiffs.map((diff) => ( + + ))} + + )} + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx b/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx new file mode 100644 index 000000000..4af7741d9 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/TaskDiffRow.tsx @@ -0,0 +1,142 @@ +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { TaskDiff } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { + EXECUTION_STATUS_BG_COLORS, + getExecutionStatusLabel, +} from "@/utils/executionStatus"; + +import { DiffStatusBadge } from "./DiffStatusBadge"; +import { FieldDiffRow } from "./FieldDiffRow"; + +interface ExecutionStatusPillProps { + label: string; + status: string | undefined; +} + +function ExecutionStatusPill({ label, status }: ExecutionStatusPillProps) { + if (!status) return null; + + return ( + + + {label} + + + + {getExecutionStatusLabel(status)} + + + ); +} + +interface TaskDiffRowProps { + diff: TaskDiff; + labelA: string; + labelB: string; +} + +export function TaskDiffRow({ diff, labelA, labelB }: TaskDiffRowProps) { + const changedArguments = diff.argumentDiffs.filter( + (entry) => entry.status !== "unchanged", + ); + const changedAnnotations = diff.annotationDiffs.filter( + (entry) => entry.status !== "unchanged", + ); + + const componentChanged = + diff.status === "changed" && !diff.sameComponentVersion; + const hasFieldChanges = + componentChanged || + changedArguments.length > 0 || + changedAnnotations.length > 0; + + return ( + + + + + {diff.taskId} + + + + + + {diff.outcomeChanged && diff.statusA && diff.statusB && ( + + )} + + + + + {componentChanged && ( + + + Component + + + {diff.digestA?.slice(0, 8) ?? "—"} →{" "} + {diff.digestB?.slice(0, 8) ?? "—"} + + + )} + + {changedArguments.length > 0 && ( + + + Arguments + + {changedArguments.map((entry) => ( + + ))} + + )} + + {changedAnnotations.length > 0 && ( + + + Annotations + + {changedAnnotations.map((entry) => ( + + ))} + + )} + + {!hasFieldChanges && ( + + {diff.outcomeChanged + ? "No configuration changes — the execution outcome differs between runs." + : "No differences in this task."} + + )} + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx b/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx new file mode 100644 index 000000000..a7ffeeb32 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/YamlDiffView.tsx @@ -0,0 +1,36 @@ +import { DiffEditor } from "@monaco-editor/react"; + +import type { ComponentSpec } from "@/utils/componentSpec"; +import { componentSpecToText } from "@/utils/yaml"; + +interface YamlDiffViewProps { + specA: ComponentSpec; + specB: ComponentSpec; +} + +export function YamlDiffView({ specA, specB }: YamlDiffViewProps) { + const yamlA = componentSpecToText(specA); + const yamlB = componentSpecToText(specB); + + return ( + + ); +} diff --git a/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts b/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts new file mode 100644 index 000000000..d5440c3f6 --- /dev/null +++ b/src/routes/v2/pages/CompareView/hooks/useRunComparisonSide.ts @@ -0,0 +1,37 @@ +import { usePipelineRunData } from "@/hooks/usePipelineRunData"; +import type { ComponentSpec } from "@/utils/componentSpec"; +import { buildTaskExecutionStatusMap } from "@/utils/executionStatus"; + +export interface RunComparisonSide { + runId: string; + spec: ComponentSpec | undefined; + taskStatusMap: Map; + isLoading: boolean; + error: Error | null; +} + +/** + * Loads a single run's spec and per-task execution status for the comparison + * view. Safe to call twice in one component (once per side) because + * `usePipelineRunData` scopes all of its queries by id. Pass an empty string + * for an unselected side — the underlying queries stay disabled. + */ +export function useRunComparisonSide(runId: string): RunComparisonSide { + const { executionData, isLoading, error } = usePipelineRunData(runId); + + const details = executionData?.details; + const state = executionData?.state; + + const spec = details?.task_spec.componentRef.spec as + ComponentSpec | undefined; + + const taskStatusMap = buildTaskExecutionStatusMap(details, state); + + return { + runId, + spec, + taskStatusMap, + isLoading, + error: error ?? null, + }; +} diff --git a/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts new file mode 100644 index 000000000..007a85f3e --- /dev/null +++ b/src/routes/v2/pages/CompareView/utils/comparePipelines.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "vitest"; + +import type { ComponentSpec, TaskSpec } from "@/utils/componentSpec"; + +import { buildPipelineComparison } from "./comparePipelines"; + +const task = (digest: string, overrides: Partial = {}): TaskSpec => ({ + componentRef: { name: "comp", digest }, + ...overrides, +}); + +const graphSpec = (tasks: Record): ComponentSpec => ({ + implementation: { graph: { tasks } }, +}); + +const containerSpec = (): ComponentSpec => ({ + implementation: { container: { image: "python:3.11" } }, +}); + +const noStatus = new Map(); + +describe("buildPipelineComparison()", () => { + test("flags added, removed, and unchanged tasks by id", () => { + const specA = graphSpec({ train: task("d1"), evaluate: task("d2") }); + const specB = graphSpec({ train: task("d1"), deploy: task("d3") }); + + const { taskDiffs, counts } = buildPipelineComparison( + specA, + specB, + noStatus, + noStatus, + ); + + const byId = Object.fromEntries(taskDiffs.map((d) => [d.taskId, d.status])); + expect(byId).toEqual({ + train: "unchanged", + evaluate: "lost", + deploy: "new", + }); + expect(counts).toEqual({ + added: 1, + removed: 1, + changed: 0, + unchanged: 1, + outcomeChanged: 0, + }); + }); + + test("marks a task changed when the component digest differs", () => { + const specA = graphSpec({ train: task("d1") }); + const specB = graphSpec({ train: task("d2") }); + + const [diff] = buildPipelineComparison( + specA, + specB, + noStatus, + noStatus, + ).taskDiffs; + + expect(diff.status).toBe("changed"); + expect(diff.sameComponentVersion).toBe(false); + }); + + test("marks a task changed when arguments differ but the component is identical", () => { + const specA = graphSpec({ + train: task("d1", { arguments: { epochs: "10" } }), + }); + const specB = graphSpec({ + train: task("d1", { arguments: { epochs: "20" } }), + }); + + const [diff] = buildPipelineComparison( + specA, + specB, + noStatus, + noStatus, + ).taskDiffs; + + expect(diff.status).toBe("changed"); + expect(diff.sameComponentVersion).toBe(true); + const epochs = diff.argumentDiffs.find((a) => a.key === "epochs"); + expect(epochs?.status).toBe("changed"); + }); + + test("treats structurally equal object arguments as unchanged", () => { + const arg = { taskOutput: { taskId: "prep", outputName: "data" } }; + const specA = graphSpec({ train: task("d1", { arguments: { in: arg } }) }); + const specB = graphSpec({ + train: task("d1", { arguments: { in: { ...arg } } }), + }); + + const [diff] = buildPipelineComparison( + specA, + specB, + noStatus, + noStatus, + ).taskDiffs; + + expect(diff.status).toBe("unchanged"); + }); + + test("carries per-run execution status onto each task diff", () => { + const specA = graphSpec({ train: task("d1") }); + const specB = graphSpec({ train: task("d1") }); + + const [diff] = buildPipelineComparison( + specA, + specB, + new Map([["train", "SUCCEEDED"]]), + new Map([["train", "FAILED"]]), + ).taskDiffs; + + expect(diff.statusA).toBe("SUCCEEDED"); + expect(diff.statusB).toBe("FAILED"); + }); + + test("flags an outcome difference even when the task spec is unchanged", () => { + const specA = graphSpec({ train: task("d1") }); + const specB = graphSpec({ train: task("d1") }); + + const { taskDiffs, counts } = buildPipelineComparison( + specA, + specB, + new Map([["train", "SUCCEEDED"]]), + new Map([["train", "FAILED"]]), + ); + + expect(taskDiffs[0].status).toBe("unchanged"); + expect(taskDiffs[0].outcomeChanged).toBe(true); + expect(counts.outcomeChanged).toBe(1); + }); + + test("does not flag an outcome difference when both runs share a status", () => { + const specA = graphSpec({ train: task("d1") }); + const specB = graphSpec({ train: task("d1") }); + + const { taskDiffs, counts } = buildPipelineComparison( + specA, + specB, + new Map([["train", "SUCCEEDED"]]), + new Map([["train", "SUCCEEDED"]]), + ); + + expect(taskDiffs[0].outcomeChanged).toBe(false); + expect(counts.outcomeChanged).toBe(0); + }); + + test("reports no comparable tasks for container-implementation specs", () => { + const { taskDiffs, hasComparableTasks } = buildPipelineComparison( + containerSpec(), + containerSpec(), + noStatus, + noStatus, + ); + + expect(taskDiffs).toHaveLength(0); + expect(hasComparableTasks).toBe(false); + }); + + test("treats unrelated pipelines as fully added/removed", () => { + const specA = graphSpec({ a1: task("d1"), a2: task("d2") }); + const specB = graphSpec({ b1: task("d3") }); + + const { counts } = buildPipelineComparison( + specA, + specB, + noStatus, + noStatus, + ); + + expect(counts).toEqual({ + added: 1, + removed: 2, + changed: 0, + unchanged: 0, + outcomeChanged: 0, + }); + }); +}); diff --git a/src/routes/v2/pages/CompareView/utils/comparePipelines.ts b/src/routes/v2/pages/CompareView/utils/comparePipelines.ts new file mode 100644 index 000000000..3a5f744a8 --- /dev/null +++ b/src/routes/v2/pages/CompareView/utils/comparePipelines.ts @@ -0,0 +1,192 @@ +import equal from "fast-deep-equal"; + +import type { DiffStatus } from "@/routes/v2/pages/Editor/store/actions/task.utils"; +import type { + ArgumentType, + ComponentSpec, + TaskSpec, +} from "@/utils/componentSpec"; +import { isGraphImplementation } from "@/utils/componentSpec"; + +export type { DiffStatus }; + +export interface KeyedDiffEntry { + key: string; + a?: T; + b?: T; + status: DiffStatus; +} + +export interface TaskDiff { + taskId: string; + status: DiffStatus; + a?: TaskSpec; + b?: TaskSpec; + digestA?: string; + digestB?: string; + sameComponentVersion: boolean; + statusA?: string; + statusB?: string; + outcomeChanged: boolean; + argumentDiffs: KeyedDiffEntry[]; + annotationDiffs: KeyedDiffEntry[]; +} + +interface ComparisonCounts { + added: number; + removed: number; + changed: number; + unchanged: number; + outcomeChanged: number; +} + +export interface PipelineComparison { + taskDiffs: TaskDiff[]; + counts: ComparisonCounts; + hasComparableTasks: boolean; +} + +/** + * Union of keys preserving `a`'s order first, then appending keys present only + * in `b` in `b`'s order. Mirrors the ordering of the editor's diff lists so the + * two features read consistently. + */ +function unionKeysAFirst( + a: Record, + b: Record, +): string[] { + const seen = new Set(); + const keys: string[] = []; + for (const key of Object.keys(a)) { + seen.add(key); + keys.push(key); + } + for (const key of Object.keys(b)) { + if (!seen.has(key)) keys.push(key); + } + return keys; +} + +function diffKeyedRecords( + a: Record | undefined, + b: Record | undefined, +): KeyedDiffEntry[] { + const aRecord = a ?? {}; + const bRecord = b ?? {}; + + return unionKeysAFirst(aRecord, bRecord).map((key) => { + const inA = key in aRecord; + const inB = key in bRecord; + const aValue = inA ? aRecord[key] : undefined; + const bValue = inB ? bRecord[key] : undefined; + + let status: DiffStatus; + if (inA && !inB) status = "lost"; + else if (!inA && inB) status = "new"; + else status = equal(aValue, bValue) ? "unchanged" : "changed"; + + return { key, a: aValue, b: bValue, status }; + }); +} + +function isComponentChanged(a: TaskSpec, b: TaskSpec): boolean { + const digestA = a.componentRef.digest; + const digestB = b.componentRef.digest; + if (digestA && digestB) return digestA !== digestB; + return !equal(a.componentRef, b.componentRef); +} + +function buildTaskDiff( + taskId: string, + a: TaskSpec | undefined, + b: TaskSpec | undefined, + statusA: string | undefined, + statusB: string | undefined, +): TaskDiff { + const argumentDiffs = diffKeyedRecords(a?.arguments, b?.arguments); + const annotationDiffs = diffKeyedRecords(a?.annotations, b?.annotations); + const digestA = a?.componentRef.digest; + const digestB = b?.componentRef.digest; + + let status: DiffStatus; + if (a && !b) status = "lost"; + else if (!a && b) status = "new"; + else if (a && b) { + const hasFieldChanges = [...argumentDiffs, ...annotationDiffs].some( + (entry) => entry.status !== "unchanged", + ); + status = + isComponentChanged(a, b) || hasFieldChanges ? "changed" : "unchanged"; + } else { + status = "unchanged"; + } + + return { + taskId, + status, + a, + b, + digestA, + digestB, + sameComponentVersion: Boolean(digestA && digestB && digestA === digestB), + statusA, + statusB, + outcomeChanged: (statusA ?? "") !== (statusB ?? ""), + argumentDiffs, + annotationDiffs, + }; +} + +function getGraphTasks( + spec: ComponentSpec | undefined, +): Record { + if (!spec || !isGraphImplementation(spec.implementation)) return {}; + return spec.implementation.graph.tasks; +} + +/** + * Aligns two runs' pipeline specs by task id and produces a per-task diff of + * component version, arguments, annotations, and execution status. Task + * ordering follows run A first, with tasks only present in run B appended. + */ +export function buildPipelineComparison( + specA: ComponentSpec | undefined, + specB: ComponentSpec | undefined, + statusMapA: Map, + statusMapB: Map, +): PipelineComparison { + const tasksA = getGraphTasks(specA); + const tasksB = getGraphTasks(specB); + + const taskDiffs = unionKeysAFirst(tasksA, tasksB).map((taskId) => + buildTaskDiff( + taskId, + tasksA[taskId], + tasksB[taskId], + statusMapA.get(taskId), + statusMapB.get(taskId), + ), + ); + + const counts: ComparisonCounts = { + added: 0, + removed: 0, + changed: 0, + unchanged: 0, + outcomeChanged: 0, + }; + for (const diff of taskDiffs) { + if (diff.status === "new") counts.added += 1; + else if (diff.status === "lost") counts.removed += 1; + else if (diff.status === "changed") counts.changed += 1; + else counts.unchanged += 1; + + if (diff.outcomeChanged) counts.outcomeChanged += 1; + } + + return { + taskDiffs, + counts, + hasComparableTasks: taskDiffs.length > 0, + }; +} diff --git a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx index 0ae354421..a18a38a29 100644 --- a/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx +++ b/src/routes/v2/pages/RunView/components/RunViewMenuBar/components/RunMenu.tsx @@ -1,4 +1,7 @@ +import { useNavigate } from "@tanstack/react-router"; + import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog"; +import { useFlagValue } from "@/components/shared/Settings/useFlags"; import TaskImplementation from "@/components/shared/TaskDetails/Implementation"; import { DropdownMenu, @@ -8,6 +11,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icon"; +import { APP_ROUTES } from "@/routes/appRoutes"; import { useCancelPipelineRun } from "@/routes/v2/pages/RunView/hooks/useCancelPipelineRun"; import { useClonePipelineRun } from "@/routes/v2/pages/RunView/hooks/useClonePipelineRun"; import { useExportPipelineYaml } from "@/routes/v2/pages/RunView/hooks/useExportPipelineYaml"; @@ -19,7 +23,9 @@ import { MenuTriggerButton } from "@/routes/v2/shared/components/MenuTriggerButt import { tracking } from "@/utils/tracking"; export function RunMenu() { + const navigate = useNavigate(); const actions = useRunViewActions(); + const compareEnabled = useFlagValue("compare-runs"); const componentSpec = actions.ready ? actions.componentSpec : undefined; const runId = actions.ready ? actions.runId : undefined; const pipelineName = actions.ready ? actions.pipelineName : undefined; @@ -37,6 +43,12 @@ export function RunMenu() { const { inspect } = useInspectPipeline(pipelineName); const { exportYaml } = useExportPipelineYaml(componentSpec, pipelineName); + const handleCompare = () => { + if (runId) { + navigate({ to: APP_ROUTES.COMPARE, search: { a: runId } }); + } + }; + if (!actions.ready) { return ( @@ -84,6 +96,16 @@ export function RunMenu() { View YAML + {compareEnabled && runId && ( + + + Compare with another run… + + )} + sum + (c ?? 0), 0); return total > 0 && countInProgressFromStats(stats) === 0; } + +/** + * Build a map of task id → aggregated execution status by joining a run's + * task→execution id mapping against its per-execution status stats. + */ +export function buildTaskExecutionStatusMap( + details?: GetExecutionInfoResponse, + state?: GetGraphExecutionStateResponse, +): Map { + const taskExecutionStatusMap = new Map(); + + if (!details?.child_task_execution_ids) { + return taskExecutionStatusMap; + } + + for (const [taskId, executionId] of Object.entries( + details.child_task_execution_ids, + )) { + const statusStats = state?.child_execution_status_stats?.[executionId]; + const aggregated = getOverallExecutionStatusFromStats(statusStats); + + if (aggregated) { + taskExecutionStatusMap.set(taskId, aggregated); + } + } + + return taskExecutionStatusMap; +}