diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index d1bbc6e..81aae9e 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -160,6 +160,29 @@ export type WorkflowStandardProvenance = { reset_available: boolean; }; +export type WorkflowStandardDiffItem = { + resource_type: "graph" | "node" | "edge"; + resource_id: string; + state: "unchanged" | "local_only" | "upstream_only" | "same_change" | "conflict"; + recommended_action: "none" | "keep_local" | "adopt_upstream" | "either" | "manual_resolution"; + changed_fields: string[]; + baseline?: Record | null; + local?: Record | null; + latest?: Record | null; +}; + +export type WorkflowStandardDiff = { + override_definition_id: string; + baseline_definition_id: string; + pinned_baseline_revision: number; + local_revision: number; + latest_baseline_revision: number; + counts: Record; + conflict_count: number; + auto_mergeable: boolean; + items: WorkflowStandardDiffItem[]; +}; + export type WorkflowDefinition = { id: string; tenant_id: string | null; @@ -457,6 +480,16 @@ export function resetWorkflowDefinitionToStandard( ); } +export function compareWorkflowDefinitionToStandard( + settings: ApiSettings, + definitionId: string +): Promise { + return apiFetch( + settings, + `/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/standard-diff` + ); +} + export async function listWorkflowRevisions( settings: ApiSettings, definitionId: string diff --git a/webui/src/features/workflow/WorkflowPage.tsx b/webui/src/features/workflow/WorkflowPage.tsx index e53e4bb..8732959 100644 --- a/webui/src/features/workflow/WorkflowPage.tsx +++ b/webui/src/features/workflow/WorkflowPage.tsx @@ -45,11 +45,11 @@ import { import { activateWorkflowDefinition, archiveWorkflowDefinition, + compareWorkflowDefinitionToStandard, compileWorkflowBpmn, createWorkflowDefinition, deleteWorkflowDefinition, deriveWorkflowDefinition, - getWorkflowRevision, listWorkflowDefinitions, listWorkflowNodeTypes, listWorkflowRevisions, @@ -63,7 +63,8 @@ import { type WorkflowDiagnostic, type WorkflowGraphEdge, type WorkflowNodeType, - type WorkflowRevision + type WorkflowRevision, + type WorkflowStandardDiff } from "../../api/workflow"; import WorkflowCanvas, { updateWorkflowGraphNode @@ -151,10 +152,6 @@ export default function WorkflowPage({ () => definitions.find((item) => item.id === draft?.id) ?? null, [definitions, draft?.id] ); - const baselineDefinition = useMemo(() => { - const baselineId = selectedDefinition?.standard?.baseline_definition_id; - return definitions.find((item) => item.id === baselineId) ?? null; - }, [definitions, selectedDefinition]); const dirty = Boolean(draft) && workflowFingerprint(draft) !== workflowFingerprint(savedDraft); const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null; @@ -987,7 +984,6 @@ export default function WorkflowPage({ open={compareOpen} settings={settings} override={selectedDefinition} - baseline={baselineDefinition} onClose={() => setCompareOpen(false)} /> void; }) { - const [pinned, setPinned] = useState(null); - const [latest, setLatest] = useState(null); + const [comparison, setComparison] = useState(null); const [error, setError] = useState(""); - const standard = override?.standard; useEffect(() => { - if (!open || !baseline || !standard) return; + if (!open || !override) return; let cancelled = false; setError(""); - const pinnedRevision = standard.pinned_baseline_revision - ?? standard.active_baseline_revision - ?? 1; - void Promise.all([ - getWorkflowRevision(settings, baseline.id, pinnedRevision), - getWorkflowRevision( - settings, - baseline.id, - standard.latest_baseline_revision - ) - ]).then(([pinnedItem, latestItem]) => { + setComparison(null); + void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => { if (cancelled) return; - setPinned(pinnedItem); - setLatest(latestItem); + setComparison(result); }).catch((loadError) => { if (!cancelled) setError(apiErrorMessage(loadError)); }); return () => { cancelled = true; }; - }, [baseline, open, settings, standard]); + }, [open, override, settings]); return ( ) : null} -
- - - -
+ {!error && !comparison ? : null} + {comparison ? ( +
+
+ + Pinned revision {comparison.pinned_baseline_revision} + + + Local revision {comparison.local_revision} + + + Latest standard {comparison.latest_baseline_revision} + + +
+
+ {comparison.items.map((item) => ( +
+
+ + {item.resource_id} + {item.resource_type} · {item.changed_fields.join(", ")} + + +
+

{standardDiffAction(item.recommended_action)}

+
+ ))} + {!comparison.items.length ? ( +

+ The local override and latest standard contain no semantic changes. +

+ ) : null} +
+
+ ) : null}
); } -function StandardRevisionColumn({ - label, - revision -}: { - label: string; - revision: WorkflowRevision | null; -}) { - return ( -
-
- {label} - - {revision - ? `Revision ${revision.revision} · ${revision.content_hash.slice(0, 12)}` - : "Loading..."} - -
-
{revision ? JSON.stringify(revision.graph, null, 2) : ""}
-
- ); +function standardDiffLabel(state: string): string { + return ({ + local_only: "Local change", + upstream_only: "Standard update", + same_change: "Same change", + conflict: "Conflict", + unchanged: "Unchanged" + } as Record)[state] ?? state; +} + +function standardDiffAction(action: string): string { + return ({ + keep_local: "The local change can be retained automatically.", + adopt_upstream: "The standard update can be adopted automatically.", + either: "Both revisions made the same semantic change.", + manual_resolution: "Local and upstream changes overlap and need a decision.", + none: "No action is required." + } as Record)[action] ?? action; } function WorkflowDefinitionSettingsDialog({ diff --git a/webui/src/styles/workflow.css b/webui/src/styles/workflow.css index dacb806..e15b8a6 100644 --- a/webui/src/styles/workflow.css +++ b/webui/src/styles/workflow.css @@ -19,55 +19,83 @@ } .workflow-standard-comparison { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; + flex-direction: column; height: 100%; min-height: 0; gap: 10px; overflow: hidden; } -.workflow-standard-revision { +.workflow-standard-comparison-summary { display: flex; - min-width: 0; - min-height: 0; - flex-direction: column; + align-items: center; + gap: 18px; + padding: 10px 12px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-soft); - overflow: hidden; } -.workflow-standard-revision header { +.workflow-standard-comparison-summary .status-badge { + margin-left: auto; +} + +.workflow-standard-diff-list { display: grid; - gap: 3px; - padding: 10px; - border-bottom: var(--border-line); -} - -.workflow-standard-revision small { - color: var(--muted); - font-size: 11px; -} - -.workflow-standard-revision pre { min-height: 0; - flex: 1 1 auto; - margin: 0; - padding: 10px; + gap: 6px; overflow: auto; - font-size: 11px; - white-space: pre; +} + +.workflow-standard-diff-item { + padding: 10px 12px; + border: var(--border-line); + border-left: 3px solid var(--success); + border-radius: var(--radius-sm); + background: var(--panel); +} + +.workflow-standard-diff-item.state-conflict { + border-left-color: var(--warning); +} + +.workflow-standard-diff-item.state-upstream_only { + border-left-color: var(--info-text-strong); +} + +.workflow-standard-diff-item header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.workflow-standard-diff-item header > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.workflow-standard-diff-item small, +.workflow-standard-diff-item p, +.workflow-standard-diff-empty { + color: var(--muted); +} + +.workflow-standard-diff-item p { + margin: 6px 0 0; } @media (max-width: 900px) { - .workflow-standard-comparison { - grid-template-columns: 1fr; - overflow: auto; + .workflow-standard-comparison-summary { + align-items: flex-start; + flex-direction: column; + gap: 5px; } - .workflow-standard-revision { - min-height: 260px; + .workflow-standard-comparison-summary .status-badge { + margin-left: 0; } }