diff --git a/src/components/Compare/ComparePage.tsx b/src/components/Compare/ComparePage.tsx new file mode 100644 index 000000000..8036ca41f --- /dev/null +++ b/src/components/Compare/ComparePage.tsx @@ -0,0 +1,138 @@ +import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { useRunsById } from "@/hooks/useRunsById"; +import type { CompareSearchParams } from "@/routes/router"; +import type { PipelineRun } from "@/types/pipelineRun"; + +import { ComparisonView } from "./Diff"; +import { RunSelector } from "./RunSelector"; + +/** + * Parse run IDs from URL search param + */ +function parseRunIds(runsParam: string | undefined): string[] { + if (!runsParam) return []; + return runsParam + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0); +} + +/** + * Sort runs chronologically (earliest first). + * This ensures consistent diff semantics: newer runs show additions relative to older runs. + */ +function sortRunsChronologically(runs: PipelineRun[]): PipelineRun[] { + return [...runs].sort((a, b) => { + const dateA = a.created_at ? new Date(a.created_at).getTime() : 0; + const dateB = b.created_at ? new Date(b.created_at).getTime() : 0; + return dateA - dateB; + }); +} + +/** + * Main page for comparing pipeline runs. + * State is persisted in URL for shareability. + * URL format: /compare?runs=123,456,789&pipeline=MyPipeline + */ +export const ComparePage = () => { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const search = useSearch({ strict: false }) as CompareSearchParams; + + // Parse run IDs from URL + const runIdsFromUrl = useMemo(() => parseRunIds(search.runs), [search.runs]); + + // Fetch runs by IDs if present in URL + const { + runs: fetchedRuns, + isLoading, + notFoundIds, + isReady, + } = useRunsById(runIdsFromUrl); + + // Determine view state based on URL + const showComparison = runIdsFromUrl.length >= 2; + + const handleCompare = (runs: PipelineRun[]) => { + // Sort runs chronologically before storing in URL + const sortedRuns = sortRunsChronologically(runs); + const runIds = sortedRuns.map((r) => String(r.id)).join(","); + const pipelineName = sortedRuns[0]?.pipeline_name; + + navigate({ + to: pathname, + search: { + runs: runIds, + pipeline: pipelineName, + } as CompareSearchParams, + }); + }; + + const handleBack = () => { + // Clear runs from URL but keep pipeline for convenience + navigate({ + to: pathname, + search: { + pipeline: search.pipeline, + } as CompareSearchParams, + }); + }; + + // Loading state when fetching runs from URL + if (showComparison && isLoading) { + return ; + } + + // Error state if some runs weren't found + const hasNotFoundError = showComparison && notFoundIds.length > 0; + + return ( +
+ + + + Compare Runs + + {!showComparison && ( + + Select a pipeline and compare up to 3 runs to see differences in + arguments and tasks. + + )} + + + {/* Warning if some runs weren't found */} + {hasNotFoundError && ( +
+ + + + Could not find {notFoundIds.length === 1 ? "run" : "runs"}:{" "} + {notFoundIds.join(", ")} + + +
+ )} + + {/* Show selector if not enough runs, or comparison if ready */} + {!showComparison || !isReady ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/components/Compare/Diff/ArgumentsDiff.tsx b/src/components/Compare/Diff/ArgumentsDiff.tsx new file mode 100644 index 000000000..607762d85 --- /dev/null +++ b/src/components/Compare/Diff/ArgumentsDiff.tsx @@ -0,0 +1,74 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import type { ValueDiff } from "@/utils/diff/types"; + +import { ArgumentRow, ArgumentsTable } from "./ArgumentsTable"; + +interface ArgumentsDiffProps { + arguments: ValueDiff[]; + runLabels: string[]; +} + +export const ArgumentsDiff = ({ + arguments: args, + runLabels, +}: ArgumentsDiffProps) => { + const changedArgs = args.filter((a) => a.changeType !== "unchanged"); + const unchangedArgs = args.filter((a) => a.changeType === "unchanged"); + + if (args.length === 0) { + return ( +
+ No pipeline arguments to compare. +
+ ); + } + + return ( + + + + Pipeline Arguments + + + {changedArgs.length} changed, {unchangedArgs.length} unchanged + + + + {/* Changed arguments */} + {changedArgs.length > 0 && ( + + {changedArgs.map((diff) => ( + + ))} + + )} + + {/* Unchanged arguments (collapsible) */} + {unchangedArgs.length > 0 && ( + + + + + {unchangedArgs.length} unchanged arguments + + + + + {unchangedArgs.map((diff) => ( + + ))} + + + + + )} + + ); +}; diff --git a/src/components/Compare/Diff/ArgumentsTable.tsx b/src/components/Compare/Diff/ArgumentsTable.tsx new file mode 100644 index 000000000..35c1262ba --- /dev/null +++ b/src/components/Compare/Diff/ArgumentsTable.tsx @@ -0,0 +1,154 @@ +import type { PropsWithChildren } from "react"; + +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { ChangeType, ValueDiff } from "@/utils/diff/types"; + +const changeTypeStyles: Record = { + added: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400", + removed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + modified: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + unchanged: "", +}; + +const changeTypeIcons: Record = { + added: "+", + removed: "-", + modified: "~", + unchanged: "", +}; + +interface ArgumentsTableProps extends PropsWithChildren { + runLabels: string[]; + compact?: boolean; +} + +/** + * Table container for arguments comparison with proper column alignment + */ +export const ArgumentsTable = ({ + runLabels, + compact = false, + children, +}: ArgumentsTableProps) => { + const columnCount = runLabels.length; + + return ( +
+
+ {/* Header row */} + {!compact && ( + <> +
+ + Argument + +
+
{/* Spacer for change indicator */} + {runLabels.map((label) => ( +
+ + {label} + +
+ ))} + + )} + + {children} +
+
+ ); +}; + +interface ArgumentRowProps { + diff: ValueDiff; + compact?: boolean; +} + +/** + * Single row in the arguments table + */ +export const ArgumentRow = ({ diff, compact = false }: ArgumentRowProps) => { + const { key, values, changeType } = diff; + const showChangeIndicator = changeType !== "unchanged"; + + return ( + <> + {/* Key column */} +
+ + {key} + +
+ + {/* Change indicator column */} +
+ {showChangeIndicator && ( + + {changeTypeIcons[changeType]} + + )} +
+ + {/* Value columns */} + {values.map((value, index) => { + const isEmpty = value === undefined || value === ""; + const isFirst = index === 0; + const showHighlight = changeType !== "unchanged" && !isFirst; + + return ( +
+ + {isEmpty ? "(empty)" : value} + +
+ ); + })} + + ); +}; + diff --git a/src/components/Compare/Diff/ComparisonView.tsx b/src/components/Compare/Diff/ComparisonView.tsx new file mode 100644 index 000000000..ce1bf575b --- /dev/null +++ b/src/components/Compare/Diff/ComparisonView.tsx @@ -0,0 +1,175 @@ +import { useMemo } from "react"; + +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/typography"; +import { useRunComparisonData } from "@/hooks/useRunComparisonData"; +import { useBackend } from "@/providers/BackendProvider"; +import { APP_ROUTES } from "@/routes/router"; +import type { PipelineRun } from "@/types/pipelineRun"; +import { formatDate } from "@/utils/date"; +import { compareRuns } from "@/utils/diff/compareRuns"; + +import { ArgumentsDiff } from "./ArgumentsDiff"; +import { TasksVisualDiff } from "./TasksVisualDiff"; + +interface ComparisonViewProps { + runs: PipelineRun[]; + onBack: () => void; +} + +/** + * Generate run labels for display (e.g., "Run #123 (Jan 1)") + */ +function generateRunLabels(runs: PipelineRun[]): string[] { + return runs.map((run) => { + const date = run.created_at ? formatDate(run.created_at) : "Unknown date"; + return `Run #${run.id} (${date})`; + }); +} + +export const ComparisonView = ({ runs, onBack }: ComparisonViewProps) => { + const { ready } = useBackend(); + const { comparisonData, isLoading, error, isReady } = + useRunComparisonData(runs); + + const runLabels = useMemo(() => generateRunLabels(runs), [runs]); + + const diffResult = useMemo(() => { + if (!isReady || comparisonData.length < 2) { + return null; + } + try { + return compareRuns(comparisonData); + } catch (e) { + console.error("Failed to compute diff:", e); + return null; + } + }, [comparisonData, isReady]); + + if (!ready) { + return ( + + +
+ + + + Backend is not configured. Full comparison requires a connected + backend to fetch execution details. + + +
+ + Basic metadata comparison is shown below based on locally available + data. + +
+ ); + } + + if (isLoading) { + return ; + } + + if (error) { + return ( + + +
+ + + Failed to load run details: {String(error)} + +
+
+ ); + } + + if (!diffResult) { + return ( + + +
+ Unable to compute comparison. +
+
+ ); + } + + return ( + + {/* Header */} + + + + Comparing {runs.length} runs + + + + {/* Run info cards */} +
+ {runs.map((run, index) => ( +
+ + + {runLabels[index]} + + + {run.pipeline_name} + + + Status: {run.status || "Unknown"} + + +
+ ))} +
+ + + + {/* Arguments Diff */} + + + + + {/* Tasks Visual Diff */} + +
+ ); +}; diff --git a/src/components/Compare/Diff/DiffSummary.tsx b/src/components/Compare/Diff/DiffSummary.tsx new file mode 100644 index 000000000..103ec4ac4 --- /dev/null +++ b/src/components/Compare/Diff/DiffSummary.tsx @@ -0,0 +1,112 @@ +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 { RunDiffResult } from "@/utils/diff/types"; +import { pluralize } from "@/utils/string"; + +interface DiffSummaryProps { + diff: RunDiffResult; +} + +interface SummaryCardProps { + title: string; + count: number; + icon: string; + variant: "default" | "success" | "warning" | "error"; +} + +const SummaryCard = ({ title, count, icon, variant }: SummaryCardProps) => { + const variantStyles = { + default: "border-border bg-card", + success: "border-green-500 bg-green-50 dark:bg-green-900/10", + warning: "border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10", + error: "border-red-500 bg-red-50 dark:bg-red-900/10", + }; + + return ( +
+ + + + + {title} + + + + {count} + + +
+ ); +}; + +export const DiffSummary = ({ diff }: DiffSummaryProps) => { + const { tasks, summary } = diff; + + const taskChanges = tasks.added.length + tasks.removed.length + tasks.modified.length; + + if (!summary.hasChanges) { + return ( +
+ + + No differences found between runs + +
+ ); + } + + return ( + + + Summary + + +
+ 0 ? "warning" : "default"} + /> + + 0 ? "warning" : "default"} + /> + + 0 ? "success" : "default"} + /> + + 0 ? "error" : "default"} + /> +
+ + {summary.hasChanges && ( + + Found {summary.totalArgumentChanges}{" "} + {pluralize(summary.totalArgumentChanges, "argument change")} and{" "} + {taskChanges} {pluralize(taskChanges, "task change")} across{" "} + {diff.runIds.length} runs. + + )} +
+ ); +}; + diff --git a/src/components/Compare/Diff/TasksDiff.tsx b/src/components/Compare/Diff/TasksDiff.tsx new file mode 100644 index 000000000..4f910fe7b --- /dev/null +++ b/src/components/Compare/Diff/TasksDiff.tsx @@ -0,0 +1,188 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +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 { RunDiffResult, TaskDiff as TaskDiffType } from "@/utils/diff/types"; + +import { ValueDiffInline } from "./ValueDiffDisplay"; + +interface TasksDiffProps { + tasks: RunDiffResult["tasks"]; +} + +const TaskBadge = ({ + taskId, + type, +}: { + taskId: string; + type: "added" | "removed" | "modified" | "unchanged"; +}) => { + const styles = { + added: "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700", + removed: "bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-700", + modified: "bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-700", + unchanged: "bg-muted text-muted-foreground border-border", + }; + + const icons = { + added: "Plus", + removed: "Minus", + modified: "Pencil", + unchanged: "Check", + }; + + return ( + + + {taskId} + + ); +}; + +const TaskDetailDiff = ({ + task, +}: { + task: TaskDiffType; +}) => { + const hasDetails = + task.digestDiff || task.componentNameDiff || task.argumentsDiff.length > 0; + + if (!hasDetails) { + return null; + } + + return ( + + {task.componentNameDiff && ( + + )} + {task.digestDiff && } + {task.argumentsDiff.length > 0 && ( + + + Changed Arguments: + + {task.argumentsDiff.map((argDiff) => ( + + ))} + + )} + + ); +}; + +export const TasksDiff = ({ tasks }: TasksDiffProps) => { + const { added, removed, modified, unchanged } = tasks; + const hasChanges = added.length > 0 || removed.length > 0 || modified.length > 0; + + if (!hasChanges && unchanged.length === 0) { + return ( +
+ No tasks to compare. +
+ ); + } + + return ( + + + + Tasks + + + {added.length + removed.length + modified.length} changed,{" "} + {unchanged.length} unchanged + + + + {/* Added tasks */} + {added.length > 0 && ( + + + + + Added Tasks ({added.length}) + + + + {added.map((taskId) => ( + + ))} + + + )} + + {/* Removed tasks */} + {removed.length > 0 && ( + + + + + Removed Tasks ({removed.length}) + + + + {removed.map((taskId) => ( + + ))} + + + )} + + {/* Modified tasks */} + {modified.length > 0 && ( + + + + + Modified Tasks ({modified.length}) + + + + {modified.map((task) => ( + + + + + + + + + ))} + + + )} + + {/* Unchanged tasks (collapsible) */} + {unchanged.length > 0 && ( + + + + + {unchanged.length} unchanged tasks + + + + + {unchanged.map((taskId) => ( + + ))} + + + + + )} + + ); +}; + diff --git a/src/components/Compare/Diff/TasksVisualDiff.tsx b/src/components/Compare/Diff/TasksVisualDiff.tsx new file mode 100644 index 000000000..e284e5882 --- /dev/null +++ b/src/components/Compare/Diff/TasksVisualDiff.tsx @@ -0,0 +1,596 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +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 { ComponentSpec, TaskSpec } from "@/utils/componentSpec"; +import type { + ChangeType, + RunComparisonData, + TaskDiff, +} from "@/utils/diff/types"; + +/** + * Extracted task data for visual display + */ +interface VisualTask { + taskId: string; + /** Full path for nested tasks (e.g., "parent/child/grandchild") */ + fullPath: string; + changeType: ChangeType; + componentName?: string; + componentDigest?: string; + /** Arguments per run: [run1Args, run2Args, ...] */ + argumentsByRun: (Record | undefined)[]; + /** Diff details if modified */ + diff?: TaskDiff; + /** Nested tasks if this task has a graph implementation */ + children: VisualTask[]; + /** Nesting depth for indentation */ + depth: number; + /** Whether this task is a subgraph */ + isSubgraph: boolean; +} + +interface TasksVisualDiffProps { + comparisonData: RunComparisonData[]; + tasksDiff: { + added: string[]; + removed: string[]; + modified: TaskDiff[]; + unchanged: string[]; + }; + runLabels: string[]; +} + +const changeColors: Record = { + added: "border-l-green-500", + removed: "border-l-red-500", + modified: "border-l-yellow-500", + unchanged: "border-l-transparent", +}; + +const changeDotColors: Record = { + added: "bg-green-500", + removed: "bg-red-500", + modified: "bg-yellow-500", + unchanged: "bg-muted-foreground/30", +}; + +/** + * Check if a component spec has a graph implementation + */ +function hasGraphImplementation(spec?: ComponentSpec): boolean { + return !!(spec?.implementation && "graph" in spec.implementation); +} + +/** + * Get nested component spec from a task's componentRef + */ +function getNestedSpec(taskSpec?: TaskSpec): ComponentSpec | undefined { + return taskSpec?.componentRef?.spec as ComponentSpec | undefined; +} + +/** + * Extract tasks from a graph implementation, handling nested subgraphs recursively + */ +function extractTasksFromGraph( + taskSpecs: (TaskSpec | undefined)[], + taskId: string, + parentPath: string, + depth: number, + changeTypeMap: Map, + modifiedMap: Map, +): VisualTask { + const fullPath = parentPath ? `${parentPath}/${taskId}` : taskId; + const changeType = + changeTypeMap.get(fullPath) ?? changeTypeMap.get(taskId) ?? "unchanged"; + const diff = modifiedMap.get(fullPath) ?? modifiedMap.get(taskId); + + // Extract arguments from each run + const argumentsByRun = taskSpecs.map((taskSpec) => { + if (!taskSpec?.arguments) return undefined; + const args: Record = {}; + for (const [key, value] of Object.entries(taskSpec.arguments)) { + args[key] = stringifyArgValue(value); + } + return args; + }); + + // Get component info from first available task spec + const firstTaskSpec = taskSpecs.find((ts) => ts !== undefined); + const componentName = + diff?.componentNameDiff?.values[0] ?? firstTaskSpec?.componentRef?.name; + const componentDigest = + diff?.digestDiff?.values[0] ?? firstTaskSpec?.componentRef?.digest; + + // Check if any run has a nested graph implementation + const nestedSpecs = taskSpecs.map((ts) => getNestedSpec(ts)); + const hasNestedGraph = nestedSpecs.some((spec) => + hasGraphImplementation(spec), + ); + + // Extract children recursively if this is a subgraph + const children: VisualTask[] = []; + if (hasNestedGraph) { + // Collect all nested task IDs across runs + const nestedTaskIds: string[] = []; + const seenNestedIds = new Set(); + + for (const spec of nestedSpecs) { + if (hasGraphImplementation(spec)) { + const nestedTasks = ( + spec!.implementation as { graph: { tasks: Record } } + ).graph.tasks; + for (const nestedTaskId of Object.keys(nestedTasks)) { + if (!seenNestedIds.has(nestedTaskId)) { + seenNestedIds.add(nestedTaskId); + nestedTaskIds.push(nestedTaskId); + } + } + } + } + + // Recursively extract each nested task + for (const nestedTaskId of nestedTaskIds) { + const nestedTaskSpecs = nestedSpecs.map((spec) => { + if (!hasGraphImplementation(spec)) return undefined; + return ( + spec!.implementation as { graph: { tasks: Record } } + ).graph.tasks[nestedTaskId]; + }); + + children.push( + extractTasksFromGraph( + nestedTaskSpecs, + nestedTaskId, + fullPath, + depth + 1, + changeTypeMap, + modifiedMap, + ), + ); + } + } + + return { + taskId, + fullPath, + changeType, + componentName, + componentDigest, + argumentsByRun, + diff, + children, + depth, + isSubgraph: hasNestedGraph, + }; +} + +/** + * Extract all tasks from comparison data for visual display, + * preserving the order as they appear in the YAML (first run's order as base, + * with new tasks from other runs appended at the end). + * Handles nested graph implementations recursively. + */ +function extractVisualTasks( + comparisonData: RunComparisonData[], + tasksDiff: TasksVisualDiffProps["tasksDiff"], +): VisualTask[] { + // Build a map of change types for quick lookup + const changeTypeMap = new Map(); + const modifiedMap = new Map(); + + for (const taskId of tasksDiff.added) { + changeTypeMap.set(taskId, "added"); + } + for (const taskId of tasksDiff.removed) { + changeTypeMap.set(taskId, "removed"); + } + for (const diff of tasksDiff.modified) { + changeTypeMap.set(diff.taskId, "modified"); + modifiedMap.set(diff.taskId, diff); + } + for (const taskId of tasksDiff.unchanged) { + changeTypeMap.set(taskId, "unchanged"); + } + + // Collect task IDs in YAML order from all runs, preserving first occurrence order + const orderedTaskIds: string[] = []; + const seenTaskIds = new Set(); + + for (const run of comparisonData) { + const impl = run.componentSpec?.implementation; + if (impl && "graph" in impl && impl.graph.tasks) { + for (const taskId of Object.keys(impl.graph.tasks)) { + if (!seenTaskIds.has(taskId)) { + seenTaskIds.add(taskId); + orderedTaskIds.push(taskId); + } + } + } + } + + // Build visual tasks in the preserved order with recursive subgraph extraction + const tasks: VisualTask[] = []; + + for (const taskId of orderedTaskIds) { + // Get task specs from all runs + const taskSpecs = comparisonData.map((run) => { + const impl = run.componentSpec?.implementation; + if (impl && "graph" in impl) { + return impl.graph.tasks?.[taskId] as TaskSpec | undefined; + } + return undefined; + }); + + tasks.push( + extractTasksFromGraph( + taskSpecs, + taskId, + "", + 0, + changeTypeMap, + modifiedMap, + ), + ); + } + + return tasks; +} + +/** + * Convert argument value to display string + */ +function stringifyArgValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "object") { + // Handle taskOutput references + if ("taskOutput" in value) { + const ref = value as { + taskOutput: { taskId: string; outputName: string }; + }; + return `{{${ref.taskOutput.taskId}.${ref.taskOutput.outputName}}}`; + } + // Handle graphInput references + if ("graphInput" in value) { + const ref = value as { graphInput: { inputName: string } }; + return `{{inputs.${ref.graphInput.inputName}}}`; + } + return JSON.stringify(value); + } + return String(value); +} + +/** + * Single task node card - compact design with support for nested subgraphs + */ +const TaskNodeCard = ({ + task, + runLabels, + expandedTasks, + onToggle, +}: { + task: VisualTask; + runLabels: string[]; + expandedTasks: Set; + onToggle: (fullPath: string) => void; +}) => { + // Collect all unique argument keys across runs + const allArgKeys = new Set(); + task.argumentsByRun.forEach((args) => { + if (args) Object.keys(args).forEach((k) => allArgKeys.add(k)); + }); + + const hasArgs = allArgKeys.size > 0; + const changedCount = task.diff?.argumentsDiff?.length ?? 0; + const isExpanded = expandedTasks.has(task.fullPath); + const hasChildren = task.children.length > 0; + + return ( +
0 ? `${task.depth * 16}px` : undefined, + }} + > +
+ {/* Header - compact */} + + + {/* Expanded content - arguments */} + {isExpanded && hasArgs && ( +
+ + + + + {runLabels.map((label, i) => ( + + ))} + + + + {Array.from(allArgKeys) + .sort() + .map((argKey) => { + const values = task.argumentsByRun.map( + (args) => args?.[argKey], + ); + const isChanged = + task.diff?.argumentsDiff?.some((d) => d.key === argKey) ?? + false; + + return ( + + + {values.map((val, i) => ( + + ))} + + ); + })} + +
+ arg + + {label.replace(/^Run #/, "#")} +
+ {argKey} + 0 && + val !== values[0] && + "text-yellow-700 dark:text-yellow-400", + )} + title={val || undefined} + > + {val || ( + + )} +
+
+ )} +
+ + {/* Nested children (subgraph tasks) */} + {isExpanded && hasChildren && ( + + {task.children.map((child) => ( + + ))} + + )} +
+ ); +}; + +/** + * Collect all task paths recursively from visual tasks + */ +function collectAllPaths(tasks: VisualTask[]): string[] { + const paths: string[] = []; + for (const task of tasks) { + paths.push(task.fullPath); + if (task.children.length > 0) { + paths.push(...collectAllPaths(task.children)); + } + } + return paths; +} + +/** + * Collect paths of changed tasks (including nested) + */ +function collectChangedPaths(tasks: VisualTask[]): string[] { + const paths: string[] = []; + for (const task of tasks) { + if (task.changeType !== "unchanged") { + paths.push(task.fullPath); + } + if (task.children.length > 0) { + paths.push(...collectChangedPaths(task.children)); + } + } + return paths; +} + +/** + * Count total tasks including nested + */ +function countAllTasks(tasks: VisualTask[]): number { + let count = tasks.length; + for (const task of tasks) { + count += countAllTasks(task.children); + } + return count; +} + +/** + * Visual DAG comparison showing tasks as node cards + */ +export const TasksVisualDiff = ({ + comparisonData, + tasksDiff, + runLabels, +}: TasksVisualDiffProps) => { + const visualTasks = extractVisualTasks(comparisonData, tasksDiff); + + const [expandedTasks, setExpandedTasks] = useState>(() => { + // Auto-expand all changed tasks (including nested) + return new Set(collectChangedPaths(visualTasks)); + }); + + const toggleTask = (fullPath: string) => { + setExpandedTasks((prev) => { + const next = new Set(prev); + if (next.has(fullPath)) { + next.delete(fullPath); + } else { + next.add(fullPath); + } + return next; + }); + }; + + const expandAll = () => { + setExpandedTasks(new Set(collectAllPaths(visualTasks))); + }; + + const collapseAll = () => { + setExpandedTasks(new Set()); + }; + + const totalTasks = countAllTasks(visualTasks); + + if (visualTasks.length === 0) { + return ( +
+ No tasks to compare. +
+ ); + } + + return ( + + {/* Header - compact */} + + + + Tasks ({totalTasks}) + + {/* Inline legend */} + + + + {tasksDiff.added.length} + + + + {tasksDiff.removed.length} + + + + {tasksDiff.modified.length} + + + + {tasksDiff.unchanged.length} + + + + + + + + + + {/* All tasks - tight spacing */} + + {visualTasks.map((task) => ( + + ))} + + + ); +}; diff --git a/src/components/Compare/Diff/ValueDiffDisplay.tsx b/src/components/Compare/Diff/ValueDiffDisplay.tsx new file mode 100644 index 000000000..a941b238e --- /dev/null +++ b/src/components/Compare/Diff/ValueDiffDisplay.tsx @@ -0,0 +1,75 @@ +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { ChangeType, ValueDiff } from "@/utils/diff/types"; + +const changeTypeStyles: Record = { + added: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400", + removed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + modified: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + unchanged: "", +}; + +const changeTypeIcons: Record = { + added: "+", + removed: "-", + modified: "~", + unchanged: "", +}; + +/** + * Compact inline display for value diffs (used in task details) + */ +export const ValueDiffInline = ({ diff }: { diff: ValueDiff }) => { + const { key, values, changeType } = diff; + const showIndicator = changeType !== "unchanged"; + + return ( +
+ {/* Key */} + + {key} + + + {/* Change indicator */} +
+ {showIndicator && ( + + {changeTypeIcons[changeType]} + + )} +
+ + {/* Values */} + + {values.map((value, index) => { + const isEmpty = value === undefined || value === ""; + const isFirst = index === 0; + const showHighlight = changeType !== "unchanged" && !isFirst; + + return ( + + {isEmpty ? "(empty)" : value} + + ); + })} + +
+ ); +}; diff --git a/src/components/Compare/Diff/index.ts b/src/components/Compare/Diff/index.ts new file mode 100644 index 000000000..3f35f39fe --- /dev/null +++ b/src/components/Compare/Diff/index.ts @@ -0,0 +1,2 @@ +export { ComparisonView } from "./ComparisonView"; + diff --git a/src/components/Compare/RunSelector/RunSelectionList.tsx b/src/components/Compare/RunSelector/RunSelectionList.tsx new file mode 100644 index 000000000..b1961ff62 --- /dev/null +++ b/src/components/Compare/RunSelector/RunSelectionList.tsx @@ -0,0 +1,108 @@ +import { usePipelineRuns } from "@/components/shared/PipelineRunDisplay/usePipelineRuns"; +import { Checkbox } from "@/components/ui/checkbox"; +import { InlineStack } from "@/components/ui/layout"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { PipelineRun } from "@/types/pipelineRun"; +import { formatDate } from "@/utils/date"; + +import { PipelineRunStatus } from "../../shared/PipelineRunDisplay/components/PipelineRunStatus"; + +interface RunSelectionListProps { + pipelineName: string; + selectedRuns: PipelineRun[]; + onRunSelect: (run: PipelineRun, selected: boolean) => void; + maxSelections: number; +} + +export const RunSelectionList = ({ + pipelineName, + selectedRuns, + onRunSelect, + maxSelections, +}: RunSelectionListProps) => { + const { data: runs } = usePipelineRuns(pipelineName); + + if (!runs || runs.length === 0) { + return ( + + No runs found for this pipeline. + + ); + } + + const isRunSelected = (run: PipelineRun) => + selectedRuns.some((r) => r.id === run.id); + + const isMaxSelected = selectedRuns.length >= maxSelections; + + return ( + + + + + Run ID + Status + Created At + Created By + + + + {runs.map((run) => { + const selected = isRunSelected(run); + const disabled = !selected && isMaxSelected; + + return ( + !disabled && onRunSelect(run, !selected)} + > + e.stopPropagation()}> + + onRunSelect(run, checked === true) + } + /> + + + #{run.id} + + + + + + + {run.created_at ? formatDate(run.created_at) : "N/A"} + + + + + {run.created_by || "Unknown"} + + + + ); + })} + +
+ ); +}; + diff --git a/src/components/Compare/RunSelector/RunSelector.tsx b/src/components/Compare/RunSelector/RunSelector.tsx new file mode 100644 index 000000000..2ee170bcf --- /dev/null +++ b/src/components/Compare/RunSelector/RunSelector.tsx @@ -0,0 +1,165 @@ +import { Suspense, useEffect, useState } from "react"; + +import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Text } from "@/components/ui/typography"; +import type { PipelineRun } from "@/types/pipelineRun"; +import { + type ComponentFileEntry, + getAllComponentFilesFromList, +} from "@/utils/componentStore"; +import { USER_PIPELINES_LIST_NAME } from "@/utils/constants"; + +import { RunSelectionList } from "./RunSelectionList"; +import { SelectionBar } from "./SelectionBar"; + +const MAX_SELECTIONS = 3; + +interface RunSelectorProps { + onCompare: (runs: PipelineRun[]) => void; + /** Pre-select a pipeline from URL */ + initialPipelineName?: string; +} + +type Pipelines = Map; + +const PipelineSelectorSkeleton = () => ( + + + + +); + +export const RunSelector = withSuspenseWrapper( + ({ onCompare, initialPipelineName }: RunSelectorProps) => { + const [pipelines, setPipelines] = useState(new Map()); + const [selectedPipeline, setSelectedPipeline] = useState( + initialPipelineName ?? "", + ); + const [selectedRuns, setSelectedRuns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchPipelines = async () => { + setIsLoading(true); + try { + const result = await getAllComponentFilesFromList( + USER_PIPELINES_LIST_NAME, + ); + const sortedPipelines = new Map( + [...result.entries()].sort((a, b) => { + return ( + new Date(b[1].modificationTime).getTime() - + new Date(a[1].modificationTime).getTime() + ); + }), + ); + setPipelines(sortedPipelines); + + // If initialPipelineName was provided but not valid, clear selection + if (initialPipelineName && !result.has(initialPipelineName)) { + setSelectedPipeline(""); + } + } catch (error) { + console.error("Failed to load pipelines:", error); + } finally { + setIsLoading(false); + } + }; + + fetchPipelines(); + }, [initialPipelineName]); + + const handlePipelineSelect = (value: string) => { + setSelectedPipeline(value); + setSelectedRuns([]); + }; + + const handleRunSelect = (run: PipelineRun, selected: boolean) => { + if (selected) { + if (selectedRuns.length < MAX_SELECTIONS) { + setSelectedRuns([...selectedRuns, run]); + } + } else { + setSelectedRuns(selectedRuns.filter((r) => r.id !== run.id)); + } + }; + + const handleCompare = () => { + if (selectedRuns.length >= 2) { + onCompare(selectedRuns); + } + }; + + const handleClearSelection = () => { + setSelectedRuns([]); + }; + + if (isLoading) { + return ; + } + + const pipelineNames = Array.from(pipelines.keys()); + + return ( + + + + + + + {selectedPipeline && ( + + + + Select Runs to Compare + + + Select 2-{MAX_SELECTIONS} runs + + + + }> + + + + )} + + {selectedRuns.length > 0 && ( + + )} + + ); + }, + PipelineSelectorSkeleton, +); + diff --git a/src/components/Compare/RunSelector/SelectionBar.tsx b/src/components/Compare/RunSelector/SelectionBar.tsx new file mode 100644 index 000000000..457514da3 --- /dev/null +++ b/src/components/Compare/RunSelector/SelectionBar.tsx @@ -0,0 +1,48 @@ +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 type { PipelineRun } from "@/types/pipelineRun"; +import { pluralize } from "@/utils/string"; + +interface SelectionBarProps { + selectedRuns: PipelineRun[]; + onCompare: () => void; + onClearSelection: () => void; +} + +export const SelectionBar = ({ + selectedRuns, + onCompare, + onClearSelection, +}: SelectionBarProps) => { + const canCompare = selectedRuns.length >= 2; + + return ( +
+ + + {selectedRuns.length} {pluralize(selectedRuns.length, "run")} selected + + + + + + + + +
+ ); +}; + diff --git a/src/components/Compare/RunSelector/index.ts b/src/components/Compare/RunSelector/index.ts new file mode 100644 index 000000000..83acac27a --- /dev/null +++ b/src/components/Compare/RunSelector/index.ts @@ -0,0 +1,2 @@ +export { RunSelector } from "./RunSelector"; + diff --git a/src/components/Home/RunSection/RunSection.tsx b/src/components/Home/RunSection/RunSection.tsx index 2ec03c0ec..c66acd516 100644 --- a/src/components/Home/RunSection/RunSection.tsx +++ b/src/components/Home/RunSection/RunSection.tsx @@ -20,6 +20,7 @@ import { TableRow, } from "@/components/ui/table"; import { useBackend } from "@/providers/BackendProvider"; +import type { HomeSearchParams } from "@/routes/router"; import { getBackendStatusString } from "@/utils/backend"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; @@ -32,13 +33,11 @@ const CREATED_BY_ME_FILTER = "created_by:me"; const INCLUDE_PIPELINE_NAME_QUERY_KEY = "include_pipeline_names"; const INCLUDE_EXECUTION_STATS_QUERY_KEY = "include_execution_stats"; -type RunSectionSearch = { page_token?: string; filter?: string }; - export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => { const { backendUrl, configured, available, ready } = useBackend(); const navigate = useNavigate(); const { pathname } = useLocation(); - const search = useSearch({ strict: false }) as RunSectionSearch; + const search = useSearch({ strict: false }) as HomeSearchParams; const isCreatedByMeDefault = useBetaFlagValue("created-by-me-default"); const dataVersion = useRef(0); @@ -103,7 +102,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => { }, [isCreatedByMeDefault]); const handleFilterChange = (value: boolean) => { - const nextSearch: RunSectionSearch = { ...search }; + const nextSearch: HomeSearchParams = { ...search }; delete nextSearch.page_token; if (value) { @@ -140,7 +139,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => { const handleUserSearch = () => { if (!searchUser.trim()) return; - const nextSearch: RunSectionSearch = { ...search }; + const nextSearch: HomeSearchParams = { ...search }; delete nextSearch.page_token; // Create or update the created_by filter @@ -171,7 +170,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => { const handlePreviousPage = () => { const previousToken = previousPageTokens[previousPageTokens.length - 1]; setPreviousPageTokens(previousPageTokens.slice(0, -1)); - const nextSearch: RunSectionSearch = { ...search }; + const nextSearch: HomeSearchParams = { ...search }; if (previousToken) { nextSearch.page_token = previousToken; } else { @@ -182,7 +181,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => { const handleFirstPage = () => { setPreviousPageTokens([]); - const nextSearch: RunSectionSearch = { ...search }; + const nextSearch: HomeSearchParams = { ...search }; delete nextSearch.page_token; navigate({ to: pathname, search: nextSearch }); }; diff --git a/src/hooks/useRunComparisonData.ts b/src/hooks/useRunComparisonData.ts new file mode 100644 index 000000000..dfdf3f7fd --- /dev/null +++ b/src/hooks/useRunComparisonData.ts @@ -0,0 +1,165 @@ +import { useQueries } from "@tanstack/react-query"; +import yaml from "js-yaml"; + +import type { + GetExecutionInfoResponse, + PipelineRunResponse, +} from "@/api/types.gen"; +import { useBackend } from "@/providers/BackendProvider"; +import { + fetchExecutionDetails, + fetchPipelineRun, +} from "@/services/executionService"; +import type { PipelineRun } from "@/types/pipelineRun"; +import { type ComponentSpec, isValidComponentSpec } from "@/utils/componentSpec"; +import type { RunComparisonData } from "@/utils/diff/types"; +import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; + +/** + * Extract ComponentSpec from execution details + */ +function extractComponentSpec( + details: GetExecutionInfoResponse | undefined, +): ComponentSpec | undefined { + if (!details?.task_spec?.componentRef?.spec) { + return undefined; + } + + const spec = details.task_spec.componentRef.spec; + if (isValidComponentSpec(spec)) { + return spec as ComponentSpec; + } + + // Try parsing from text if spec is stored as string + const text = details.task_spec.componentRef.text; + if (text) { + try { + const parsed = yaml.load(text); + if (isValidComponentSpec(parsed)) { + return parsed as ComponentSpec; + } + } catch { + // Failed to parse + } + } + + return undefined; +} + +/** + * Transform fetched data into RunComparisonData format + */ +function transformToComparisonData( + run: PipelineRun, + metadata: PipelineRunResponse | undefined, + details: GetExecutionInfoResponse | undefined, +): RunComparisonData { + const componentSpec = extractComponentSpec(details); + + // Get status from execution stats + let status: string | undefined; + if (metadata?.execution_status_stats) { + status = getOverallExecutionStatusFromStats(metadata.execution_status_stats); + } else if (details) { + // Try to derive from other data + status = run.status; + } + + return { + runId: String(run.id), + pipelineName: run.pipeline_name, + createdAt: run.created_at, + createdBy: metadata?.created_by ?? run.created_by, + status, + arguments: details?.task_spec?.arguments as + | Record + | undefined, + componentSpec, + }; +} + +/** + * Hook to fetch comparison data for multiple pipeline runs + */ +export function useRunComparisonData(runs: PipelineRun[]) { + const { backendUrl, ready } = useBackend(); + + // Fetch metadata for all runs + const metadataQueries = useQueries({ + queries: runs.map((run) => ({ + queryKey: ["comparison-metadata", run.id], + queryFn: () => fetchPipelineRun(String(run.id), backendUrl), + enabled: ready && runs.length > 0, + staleTime: Infinity, + })), + }); + + // Extract root execution IDs from metadata + const rootExecutionIds = metadataQueries.map((query) => ({ + rootExecutionId: query.data?.root_execution_id, + })); + + // Fetch execution details for all runs + const detailsQueries = useQueries({ + queries: rootExecutionIds.map(({ rootExecutionId }) => ({ + queryKey: ["comparison-details", rootExecutionId], + queryFn: () => + rootExecutionId + ? fetchExecutionDetails(rootExecutionId, backendUrl) + : Promise.resolve(undefined), + enabled: ready && !!rootExecutionId, + staleTime: Infinity, + })), + }); + + // Combine all data + const isLoading = + metadataQueries.some((q) => q.isLoading) || + detailsQueries.some((q) => q.isLoading); + + const error = + metadataQueries.find((q) => q.error)?.error || + detailsQueries.find((q) => q.error)?.error; + + const comparisonData: RunComparisonData[] = runs.map((run, index) => { + const metadata = metadataQueries[index]?.data; + const details = detailsQueries[index]?.data; + return transformToComparisonData(run, metadata, details); + }); + + const isReady = + !isLoading && + !error && + comparisonData.every((data) => data.runId); + + return { + comparisonData, + isLoading, + error, + isReady, + }; +} + +/** + * Simpler hook for when backend is not available - uses only local data + */ +export function useLocalRunComparisonData(runs: PipelineRun[]) { + // For local runs, we only have basic metadata + const comparisonData: RunComparisonData[] = runs.map((run) => ({ + runId: String(run.id), + pipelineName: run.pipeline_name, + createdAt: run.created_at, + createdBy: run.created_by, + status: run.status, + arguments: undefined, + componentSpec: undefined, + })); + + return { + comparisonData, + isLoading: false, + error: null, + isReady: runs.length > 0, + }; +} + diff --git a/src/hooks/useRunsById.ts b/src/hooks/useRunsById.ts new file mode 100644 index 000000000..b2473e434 --- /dev/null +++ b/src/hooks/useRunsById.ts @@ -0,0 +1,90 @@ +import { useQueries } from "@tanstack/react-query"; +import localForage from "localforage"; + +import type { PipelineRunResponse } from "@/api/types.gen"; +import { useBackend } from "@/providers/BackendProvider"; +import { fetchPipelineRun } from "@/services/executionService"; +import type { PipelineRun } from "@/types/pipelineRun"; +import { DB_NAME, PIPELINE_RUNS_STORE_NAME } from "@/utils/constants"; +import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus"; + +/** + * Fetch a run from local storage by ID + */ +async function fetchLocalRun(runId: string): Promise { + const pipelineRunsDb = localForage.createInstance({ + name: DB_NAME, + storeName: PIPELINE_RUNS_STORE_NAME, + }); + + return pipelineRunsDb.getItem(runId); +} + +/** + * Transform backend response to PipelineRun type + */ +function transformBackendResponse(response: PipelineRunResponse): PipelineRun { + // execution_status_stats in PipelineRunResponse is already flat { status: count } + const status = response.execution_status_stats + ? getOverallExecutionStatusFromStats(response.execution_status_stats) + : undefined; + + return { + id: Number(response.id), + root_execution_id: Number(response.root_execution_id), + created_at: response.created_at ?? "", + created_by: response.created_by ?? "", + pipeline_name: response.pipeline_name ?? "Unknown Pipeline", + status, + }; +} + +/** + * Hook to fetch multiple pipeline runs by their IDs. + * First tries to fetch from backend, falls back to local storage. + */ +export function useRunsById(runIds: string[]) { + const { backendUrl, ready } = useBackend(); + + const queries = useQueries({ + queries: runIds.map((runId) => ({ + queryKey: ["run-by-id", runId, backendUrl], + queryFn: async (): Promise => { + // Try backend first + if (ready && backendUrl) { + try { + const response = await fetchPipelineRun(runId, backendUrl); + return transformBackendResponse(response); + } catch { + // Backend failed, try local storage + } + } + + // Fallback to local storage + return fetchLocalRun(runId); + }, + enabled: runIds.length > 0, + staleTime: Infinity, + retry: false, + })), + }); + + const isLoading = queries.some((q) => q.isLoading); + const error = queries.find((q) => q.error)?.error; + + // Filter out null results and maintain order + const runs: PipelineRun[] = queries + .map((q) => q.data) + .filter((run): run is PipelineRun => run !== null && run !== undefined); + + // Track which IDs were not found + const notFoundIds = runIds.filter((_, index) => !queries[index]?.data); + + return { + runs, + isLoading, + error, + notFoundIds, + isReady: !isLoading && runs.length > 0, + }; +} diff --git a/src/routes/Compare/Compare.tsx b/src/routes/Compare/Compare.tsx new file mode 100644 index 000000000..66e43bd4b --- /dev/null +++ b/src/routes/Compare/Compare.tsx @@ -0,0 +1,8 @@ +import { ComparePage } from "@/components/Compare/ComparePage"; + +const Compare = () => { + return ; +}; + +export default Compare; + diff --git a/src/routes/Compare/index.ts b/src/routes/Compare/index.ts new file mode 100644 index 000000000..188e05ed7 --- /dev/null +++ b/src/routes/Compare/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Compare"; + diff --git a/src/routes/Home/Home.tsx b/src/routes/Home/Home.tsx index abcce081f..7025b6206 100644 --- a/src/routes/Home/Home.tsx +++ b/src/routes/Home/Home.tsx @@ -1,7 +1,12 @@ import { useRef, useState } from "react"; import { PipelineSection, RunSection } from "@/components/Home"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { APP_ROUTES } from "@/routes/router"; const Home = () => { const [activeTab, setActiveTab] = useState("runs"); @@ -19,9 +24,15 @@ const Home = () => { return (
-
+

Pipelines

-
+ + + + mainLayout, path: APP_ROUTES.HOME, component: Home, + validateSearch: (search: Record): HomeSearchParams => ({ + page_token: + typeof search.page_token === "string" ? search.page_token : undefined, + filter: typeof search.filter === "string" ? search.filter : undefined, + }), }); const quickStartRoute = createRoute({ @@ -96,12 +114,33 @@ const runDetailWithSubgraphRoute = createRoute({ component: PipelineRun, }); +/** + * Search params for the compare route. + * - runs: Comma-separated list of run IDs to compare + * - pipeline: Optional pipeline name to pre-select in the selector + */ +export type CompareSearchParams = { + runs?: string; + pipeline?: string; +}; + +const compareRoute = createRoute({ + getParentRoute: () => mainLayout, + path: APP_ROUTES.COMPARE, + component: Compare, + validateSearch: (search: Record): CompareSearchParams => ({ + runs: typeof search.runs === "string" ? search.runs : undefined, + pipeline: typeof search.pipeline === "string" ? search.pipeline : undefined, + }), +}); + const appRouteTree = mainLayout.addChildren([ indexRoute, quickStartRoute, editorRoute, runDetailRoute, runDetailWithSubgraphRoute, + compareRoute, ]); const rootRouteTree = rootRoute.addChildren([ diff --git a/src/utils/diff/compareRuns.ts b/src/utils/diff/compareRuns.ts new file mode 100644 index 000000000..5fd32d395 --- /dev/null +++ b/src/utils/diff/compareRuns.ts @@ -0,0 +1,278 @@ +import type { ArgumentType } from "@/utils/componentSpec"; +import { isGraphImplementation } from "@/utils/componentSpec"; + +import type { + ChangeType, + MetadataDiff, + RunComparisonData, + RunDiffResult, + TaskDiff, + TaskForComparison, + ValueDiff, +} from "./types"; + +/** + * Stringify an argument value for display and comparison + */ +export function stringifyArgumentValue(value: ArgumentType | undefined): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +} + +/** + * Determine the change type for a set of values across runs + */ +function getChangeType(values: (string | undefined)[]): ChangeType { + const definedValues = values.filter((v) => v !== undefined); + + if (definedValues.length === 0) { + return "unchanged"; + } + + if (definedValues.length < values.length) { + // Some runs have the value, some don't + if (definedValues.length === values.length - 1) { + // Check if first run doesn't have it (added) or last run doesn't have it (removed) + if (values[0] === undefined) { + return "added"; + } + if (values[values.length - 1] === undefined) { + return "removed"; + } + } + return "modified"; + } + + // All runs have values - check if they're the same + const firstValue = definedValues[0]; + const allSame = definedValues.every((v) => v === firstValue); + + return allSame ? "unchanged" : "modified"; +} + +/** + * Create a ValueDiff from values across runs + */ +function createValueDiff(key: string, values: (string | undefined)[]): ValueDiff { + return { + key, + values, + changeType: getChangeType(values), + }; +} + +/** + * Extract tasks from a ComponentSpec for comparison + */ +function extractTasksForComparison( + run: RunComparisonData, +): Map { + const tasks = new Map(); + + if (!run.componentSpec?.implementation) { + return tasks; + } + + if (!isGraphImplementation(run.componentSpec.implementation)) { + return tasks; + } + + const graphTasks = run.componentSpec.implementation.graph.tasks; + + for (const [taskId, taskSpec] of Object.entries(graphTasks)) { + tasks.set(taskId, { + taskId, + componentName: taskSpec.componentRef.name, + componentDigest: taskSpec.componentRef.digest, + arguments: taskSpec.arguments ?? {}, + }); + } + + return tasks; +} + +/** + * Compare metadata between runs + */ +function compareMetadata(runs: RunComparisonData[]): MetadataDiff { + return { + createdAt: createValueDiff( + "Created At", + runs.map((r) => r.createdAt), + ), + createdBy: createValueDiff( + "Created By", + runs.map((r) => r.createdBy), + ), + status: createValueDiff( + "Status", + runs.map((r) => r.status), + ), + }; +} + +/** + * Compare pipeline arguments between runs + */ +function compareArguments(runs: RunComparisonData[]): ValueDiff[] { + // Collect all unique argument keys across all runs + const allKeys = new Set(); + + for (const run of runs) { + if (run.arguments) { + Object.keys(run.arguments).forEach((key) => allKeys.add(key)); + } + } + + const diffs: ValueDiff[] = []; + + for (const key of allKeys) { + const values = runs.map((run) => + stringifyArgumentValue(run.arguments?.[key]), + ); + + // Only return non-empty or if at least one run has this argument + const hasAnyValue = values.some((v) => v !== ""); + if (hasAnyValue) { + const diff = createValueDiff(key, values.map((v) => v || undefined)); + diffs.push(diff); + } + } + + // Sort by key name + return diffs.sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Compare tasks between runs + */ +function compareTasks(runs: RunComparisonData[]): RunDiffResult["tasks"] { + const taskMaps = runs.map((run) => extractTasksForComparison(run)); + + // Collect all unique task IDs across all runs + const allTaskIds = new Set(); + taskMaps.forEach((map) => map.forEach((_, id) => allTaskIds.add(id))); + + const added: string[] = []; + const removed: string[] = []; + const modified: TaskDiff[] = []; + const unchanged: string[] = []; + + for (const taskId of allTaskIds) { + const tasksInRuns = taskMaps.map((map) => map.get(taskId)); + const presentCount = tasksInRuns.filter(Boolean).length; + + // Determine if task was added/removed + if (presentCount < runs.length) { + // Task doesn't exist in all runs + if (tasksInRuns[0] === undefined && tasksInRuns.some(Boolean)) { + added.push(taskId); + } else if (tasksInRuns[0] !== undefined && tasksInRuns.some((t) => !t)) { + removed.push(taskId); + } + continue; + } + + // Task exists in all runs - compare details + const taskDiff = compareTask(taskId, tasksInRuns as TaskForComparison[]); + + if (taskDiff.changeType === "unchanged") { + unchanged.push(taskId); + } else { + modified.push(taskDiff); + } + } + + return { added, removed, modified, unchanged }; +} + +/** + * Compare a single task across runs + */ +function compareTask( + taskId: string, + tasks: TaskForComparison[], +): TaskDiff { + const digestDiff = createValueDiff( + "Component Digest", + tasks.map((t) => t.componentDigest), + ); + + const componentNameDiff = createValueDiff( + "Component Name", + tasks.map((t) => t.componentName), + ); + + // Compare arguments + const allArgKeys = new Set(); + tasks.forEach((task) => + Object.keys(task.arguments).forEach((key) => allArgKeys.add(key)), + ); + + const argumentsDiff: ValueDiff[] = []; + + for (const key of allArgKeys) { + const values = tasks.map((task) => + stringifyArgumentValue(task.arguments[key]), + ); + const diff = createValueDiff(key, values.map((v) => v || undefined)); + + if (diff.changeType !== "unchanged") { + argumentsDiff.push(diff); + } + } + + // Determine overall change type + const hasDigestChange = digestDiff.changeType !== "unchanged"; + const hasArgumentChanges = argumentsDiff.length > 0; + const changeType: ChangeType = + hasDigestChange || hasArgumentChanges ? "modified" : "unchanged"; + + return { + taskId, + changeType, + digestDiff: hasDigestChange ? digestDiff : undefined, + componentNameDiff: + componentNameDiff.changeType !== "unchanged" + ? componentNameDiff + : undefined, + argumentsDiff, + }; +} + +/** + * Compare multiple pipeline runs and return a structured diff + */ +export function compareRuns(runs: RunComparisonData[]): RunDiffResult { + if (runs.length < 2) { + throw new Error("At least 2 runs are required for comparison"); + } + + const metadata = compareMetadata(runs); + const arguments_ = compareArguments(runs); + const tasks = compareTasks(runs); + + const argumentChanges = arguments_.filter( + (a) => a.changeType !== "unchanged", + ).length; + const taskChanges = + tasks.added.length + tasks.removed.length + tasks.modified.length; + + return { + runIds: runs.map((r) => r.runId), + metadata, + arguments: arguments_, + tasks, + summary: { + totalArgumentChanges: argumentChanges, + totalTaskChanges: taskChanges, + hasChanges: argumentChanges > 0 || taskChanges > 0, + }, + }; +} + diff --git a/src/utils/diff/types.ts b/src/utils/diff/types.ts new file mode 100644 index 000000000..169f5e515 --- /dev/null +++ b/src/utils/diff/types.ts @@ -0,0 +1,79 @@ +import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec"; + +/** + * Represents the data needed to compare runs + */ +export interface RunComparisonData { + runId: string; + pipelineName: string; + createdAt: string; + createdBy?: string; + status?: string; + arguments?: Record; + componentSpec?: ComponentSpec; +} + +/** + * Change type for diff items + */ +export type ChangeType = "added" | "removed" | "modified" | "unchanged"; + +/** + * Represents a single value difference between runs + */ +export interface ValueDiff { + key: string; + values: (string | undefined)[]; + changeType: ChangeType; +} + +/** + * Represents metadata differences between runs + */ +export interface MetadataDiff { + createdAt: ValueDiff; + createdBy: ValueDiff; + status: ValueDiff; +} + +/** + * Represents differences in a task between runs + */ +export interface TaskDiff { + taskId: string; + changeType: ChangeType; + digestDiff?: ValueDiff; + componentNameDiff?: ValueDiff; + argumentsDiff: ValueDiff[]; +} + +/** + * Complete diff result between multiple runs + */ +export interface RunDiffResult { + runIds: string[]; + metadata: MetadataDiff; + arguments: ValueDiff[]; + tasks: { + added: string[]; + removed: string[]; + modified: TaskDiff[]; + unchanged: string[]; + }; + summary: { + totalArgumentChanges: number; + totalTaskChanges: number; + hasChanges: boolean; + }; +} + +/** + * A simplified task representation for comparison + */ +export interface TaskForComparison { + taskId: string; + componentName?: string; + componentDigest?: string; + arguments: Record; +} +