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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/components/Compare/ComparePage.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingScreen message="Loading runs for comparison..." />;
}

// Error state if some runs weren't found
const hasNotFoundError = showComparison && notFoundIds.length > 0;

return (
<div className="container mx-auto w-3/4 p-4">
<BlockStack gap="4">
<BlockStack gap="1">
<Text as="h1" size="2xl" weight="bold">
Compare Runs
</Text>
{!showComparison && (
<Text as="p" tone="subdued">
Select a pipeline and compare up to 3 runs to see differences in
arguments and tasks.
</Text>
)}
</BlockStack>

{/* Warning if some runs weren't found */}
{hasNotFoundError && (
<div className="border border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg p-4">
<InlineStack gap="2" blockAlign="center">
<Icon name="TriangleAlert" className="w-5 h-5 text-yellow-600" />
<Text>
Could not find {notFoundIds.length === 1 ? "run" : "runs"}:{" "}
{notFoundIds.join(", ")}
</Text>
</InlineStack>
</div>
)}

{/* Show selector if not enough runs, or comparison if ready */}
{!showComparison || !isReady ? (
<RunSelector
onCompare={handleCompare}
initialPipelineName={search.pipeline}
/>
) : (
<ComparisonView
runs={sortRunsChronologically(fetchedRuns)}
onBack={handleBack}
/>
)}
</BlockStack>
</div>
);
};
74 changes: 74 additions & 0 deletions src/components/Compare/Diff/ArgumentsDiff.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="border border-dashed border-border rounded-md p-4">
<Text tone="subdued">No pipeline arguments to compare.</Text>
</div>
);
}

return (
<BlockStack gap="4">
<InlineStack align="space-between" blockAlign="center">
<Text as="h3" size="lg" weight="semibold">
Pipeline Arguments
</Text>
<Text size="sm" tone="subdued">
{changedArgs.length} changed, {unchangedArgs.length} unchanged
</Text>
</InlineStack>

{/* Changed arguments */}
{changedArgs.length > 0 && (
<ArgumentsTable runLabels={runLabels}>
{changedArgs.map((diff) => (
<ArgumentRow key={diff.key} diff={diff} />
))}
</ArgumentsTable>
)}

{/* Unchanged arguments (collapsible) */}
{unchangedArgs.length > 0 && (
<Accordion type="single" collapsible>
<AccordionItem value="unchanged">
<AccordionTrigger>
<Text size="sm" tone="subdued">
{unchangedArgs.length} unchanged arguments
</Text>
</AccordionTrigger>
<AccordionContent>
<ArgumentsTable runLabels={runLabels} compact>
{unchangedArgs.map((diff) => (
<ArgumentRow key={diff.key} diff={diff} compact />
))}
</ArgumentsTable>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</BlockStack>
);
};
154 changes: 154 additions & 0 deletions src/components/Compare/Diff/ArgumentsTable.tsx
Original file line number Diff line number Diff line change
@@ -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<ChangeType, string> = {
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<ChangeType, string> = {
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 (
<div className="w-full overflow-x-auto">
<div
className="grid gap-2 min-w-fit"
style={{
gridTemplateColumns: `auto 1.5rem repeat(${columnCount}, minmax(150px, 1fr))`,
}}
>
{/* Header row */}
{!compact && (
<>
<div className="py-2 px-1">
<Text size="sm" weight="semibold" tone="subdued">
Argument
</Text>
</div>
<div /> {/* Spacer for change indicator */}
{runLabels.map((label) => (
<div key={label} className="py-2 px-2">
<Text
size="sm"
weight="semibold"
tone="subdued"
className="truncate block"
title={label}
>
{label}
</Text>
</div>
))}
</>
)}

{children}
</div>
</div>
);
};

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 */}
<div
className={cn(
"py-2 px-1 flex items-start",
compact ? "min-h-8" : "min-h-10",
)}
>
<Text
size="sm"
weight="semibold"
className="truncate"
title={key}
>
{key}
</Text>
</div>

{/* Change indicator column */}
<div className="py-2 flex items-start justify-center">
{showChangeIndicator && (
<span
className={cn(
"inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono shrink-0",
changeTypeStyles[changeType],
)}
>
{changeTypeIcons[changeType]}
</span>
)}
</div>

{/* Value columns */}
{values.map((value, index) => {
const isEmpty = value === undefined || value === "";
const isFirst = index === 0;
const showHighlight = changeType !== "unchanged" && !isFirst;

return (
<div
key={index}
className={cn(
"py-2 px-2 rounded border",
compact ? "min-h-8" : "min-h-10",
showHighlight && changeTypeStyles[changeType],
isEmpty && "bg-muted/50",
!showHighlight && !isEmpty && "border-border bg-muted/20",
isEmpty && "border-dashed",
)}
>
<Text
size="sm"
className={cn(
"break-words",
isEmpty && "text-muted-foreground italic",
)}
>
{isEmpty ? "(empty)" : value}
</Text>
</div>
);
})}
</>
);
};

Loading
Loading