Files
windmill/frontend/src/lib/components/FlowGraphDiffViewer.svelte
T
centdix 00949f937b feat(flow): Add graph diff visualizer (#6948)
* graph mode

* show colors

* show module diff viewer button

* better diff logic

* small width merge graph diff

* invert logic

* simplify

* better logic

* put removed modules in initial position

* nit

* nit

* fix conflicting ids

* fix

* add shadowed for after

* better position logic

* fix

* cleaning

* use splitpanes

* add toggle

* fix

* sync move

* icons

* handle zoom

* left header snippet

* cleaning

* cleaning

* remove stats

* big cleaning

* fix

* fix

* fix

* remove not working logic

* invert logic

* nit

* use in deploymentui

* fix typo

* no custom style

* handle nested

* simpler logic

* fix

* fix
2025-11-01 15:17:56 +01:00

289 lines
9.0 KiB
Svelte

<script lang="ts">
import type { FlowModule, FlowValue, OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { buildFlowTimeline, hasInputSchemaChanged } from './flows/flowDiff'
import { dfs } from './flows/dfs'
import DiffDrawer from './DiffDrawer.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
interface Props {
beforeYaml: string
afterYaml: string
}
let { beforeYaml, afterYaml }: Props = $props()
let parseError = $state<string | undefined>(undefined)
let moduleDiffDrawer: DiffDrawer | undefined = $state(undefined)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
let beforeFlow: OpenFlow | undefined = $derived.by(() => {
try {
const parsed = YAML.parse(beforeYaml)
return parsed as OpenFlow
} catch (error) {
parseError = `Error parsing before flow: ${error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'}`
return undefined
}
})
let afterFlow: OpenFlow | undefined = $derived.by(() => {
try {
const parsed = YAML.parse(afterYaml)
return parsed as OpenFlow
} catch (error) {
parseError = `Error parsing after flow: ${error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'}`
return undefined
}
})
// Detect if input schema has changed
let inputSchemaModified = $derived(hasInputSchemaChanged(beforeFlow, afterFlow))
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
// In unified view, mark removed modules as 'removed' to show them in red
let timeline = $derived.by(() => {
if (!beforeFlow || !afterFlow) return undefined
return buildFlowTimeline(beforeFlow.value, afterFlow.value, {
markRemovedAsShadowed: isSideBySide
})
})
// Extract merged flow from timeline
let mergedFlow = $derived(timeline?.mergedFlow)
// Get the unified actions directly from timeline
let unifiedActions = $derived(timeline?.afterActions ?? {})
// Helper to find module by ID in a flow
function getModuleById(flow: FlowValue, moduleId: string): FlowModule | undefined {
const allModules = dfs(flow.modules ?? [], (m) => m)
return (
allModules.find((m) => m?.id === moduleId) ??
(flow.failure_module?.id === moduleId ? flow.failure_module : undefined) ??
(flow.preprocessor_module?.id === moduleId ? flow.preprocessor_module : undefined)
)
}
// Handler for viewport changes - updates shared state for synchronization
function handleViewportChange(viewport: Viewport, isUserInitiated: boolean) {
if (isUserInitiated) {
sharedViewport = viewport
}
}
// Callback to show module diff
function handleShowModuleDiff(moduleId: string) {
if (!beforeFlow || !afterFlow) return
// Handle special case for Input schema diff
if (moduleId === 'Input') {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: 'Flow Input Schema Diff',
original: { schema: beforeFlow.schema ?? {} },
current: { schema: afterFlow.schema ?? {} }
})
return
}
const beforeModule = getModuleById(beforeFlow.value, moduleId)
const afterModule = getModuleById(afterFlow.value, moduleId)
if (beforeModule && afterModule) {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: `Module Diff: ${moduleId}`,
original: beforeModule,
current: afterModule
})
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
<Alert type="error" title="Parse Error">
{parseError}
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
{item}
value="sidebyside"
label="Side by Side"
icon={SquareSplitHorizontal}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={timeline?.beforeActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
{#if mergedFlow}
<FlowGraphV2
bind:this={afterGraph}
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
{/if}
</div>
</div>
</Pane>
</Splitpanes>
{:else}
<!-- Unified view for narrow screens - show merged flow with all diff colors -->
{#if mergedFlow}
<div class="h-full overflow-hidden">
<FlowGraphV2
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
/>
</div>
{/if}
{/if}
</div>
<!-- Nested DiffDrawer for module-level diffs -->
<DiffDrawer bind:this={moduleDiffDrawer} />
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}