Add workflow standard comparison controls

This commit is contained in:
2026-07-31 19:40:08 +02:00
parent 8fd8753012
commit 9d987c7339
3 changed files with 168 additions and 90 deletions
+33
View File
@@ -160,6 +160,29 @@ export type WorkflowStandardProvenance = {
reset_available: boolean; 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<string, unknown> | null;
local?: Record<string, unknown> | null;
latest?: Record<string, unknown> | 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<string, number>;
conflict_count: number;
auto_mergeable: boolean;
items: WorkflowStandardDiffItem[];
};
export type WorkflowDefinition = { export type WorkflowDefinition = {
id: string; id: string;
tenant_id: string | null; tenant_id: string | null;
@@ -457,6 +480,16 @@ export function resetWorkflowDefinitionToStandard(
); );
} }
export function compareWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowStandardDiff> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/standard-diff`
);
}
export async function listWorkflowRevisions( export async function listWorkflowRevisions(
settings: ApiSettings, settings: ApiSettings,
definitionId: string definitionId: string
+78 -61
View File
@@ -45,11 +45,11 @@ import {
import { import {
activateWorkflowDefinition, activateWorkflowDefinition,
archiveWorkflowDefinition, archiveWorkflowDefinition,
compareWorkflowDefinitionToStandard,
compileWorkflowBpmn, compileWorkflowBpmn,
createWorkflowDefinition, createWorkflowDefinition,
deleteWorkflowDefinition, deleteWorkflowDefinition,
deriveWorkflowDefinition, deriveWorkflowDefinition,
getWorkflowRevision,
listWorkflowDefinitions, listWorkflowDefinitions,
listWorkflowNodeTypes, listWorkflowNodeTypes,
listWorkflowRevisions, listWorkflowRevisions,
@@ -63,7 +63,8 @@ import {
type WorkflowDiagnostic, type WorkflowDiagnostic,
type WorkflowGraphEdge, type WorkflowGraphEdge,
type WorkflowNodeType, type WorkflowNodeType,
type WorkflowRevision type WorkflowRevision,
type WorkflowStandardDiff
} from "../../api/workflow"; } from "../../api/workflow";
import WorkflowCanvas, { import WorkflowCanvas, {
updateWorkflowGraphNode updateWorkflowGraphNode
@@ -151,10 +152,6 @@ export default function WorkflowPage({
() => definitions.find((item) => item.id === draft?.id) ?? null, () => definitions.find((item) => item.id === draft?.id) ?? null,
[definitions, draft?.id] [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) const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft); && workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null; const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
@@ -987,7 +984,6 @@ export default function WorkflowPage({
open={compareOpen} open={compareOpen}
settings={settings} settings={settings}
override={selectedDefinition} override={selectedDefinition}
baseline={baselineDefinition}
onClose={() => setCompareOpen(false)} onClose={() => setCompareOpen(false)}
/> />
<WorkflowDefinitionSettingsDialog <WorkflowDefinitionSettingsDialog
@@ -1041,45 +1037,31 @@ function WorkflowStandardComparisonDialog({
open, open,
settings, settings,
override, override,
baseline,
onClose onClose
}: { }: {
open: boolean; open: boolean;
settings: ApiSettings; settings: ApiSettings;
override: WorkflowDefinition | null; override: WorkflowDefinition | null;
baseline: WorkflowDefinition | null;
onClose: () => void; onClose: () => void;
}) { }) {
const [pinned, setPinned] = useState<WorkflowRevision | null>(null); const [comparison, setComparison] = useState<WorkflowStandardDiff | null>(null);
const [latest, setLatest] = useState<WorkflowRevision | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const standard = override?.standard;
useEffect(() => { useEffect(() => {
if (!open || !baseline || !standard) return; if (!open || !override) return;
let cancelled = false; let cancelled = false;
setError(""); setError("");
const pinnedRevision = standard.pinned_baseline_revision setComparison(null);
?? standard.active_baseline_revision void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => {
?? 1;
void Promise.all([
getWorkflowRevision(settings, baseline.id, pinnedRevision),
getWorkflowRevision(
settings,
baseline.id,
standard.latest_baseline_revision
)
]).then(([pinnedItem, latestItem]) => {
if (cancelled) return; if (cancelled) return;
setPinned(pinnedItem); setComparison(result);
setLatest(latestItem);
}).catch((loadError) => { }).catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError)); if (!cancelled) setError(apiErrorMessage(loadError));
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [baseline, open, settings, standard]); }, [open, override, settings]);
return ( return (
<Dialog <Dialog
@@ -1094,44 +1076,79 @@ function WorkflowStandardComparisonDialog({
{error} {error}
</DismissibleAlert> </DismissibleAlert>
) : null} ) : null}
<div className="workflow-standard-comparison"> {!error && !comparison ? <LoadingFrame label="Comparing workflow revisions..." /> : null}
<StandardRevisionColumn {comparison ? (
label="Pinned standard" <div className="workflow-standard-comparison">
revision={pinned} <div className="workflow-standard-comparison-summary">
/> <span>
<StandardRevisionColumn Pinned revision <strong>{comparison.pinned_baseline_revision}</strong>
label="Local override" </span>
revision={override?.revision ?? null} <span>
/> Local revision <strong>{comparison.local_revision}</strong>
<StandardRevisionColumn </span>
label="Latest standard" <span>
revision={latest} Latest standard <strong>{comparison.latest_baseline_revision}</strong>
/> </span>
</div> <StatusBadge
status={comparison.auto_mergeable ? "ready" : "warning"}
label={comparison.auto_mergeable
? "No semantic conflicts"
: `${comparison.conflict_count} conflicts`}
/>
</div>
<div className="workflow-standard-diff-list">
{comparison.items.map((item) => (
<section
className={`workflow-standard-diff-item state-${item.state}`}
key={`${item.resource_type}:${item.resource_id}`}
>
<header>
<span>
<strong>{item.resource_id}</strong>
<small>{item.resource_type} · {item.changed_fields.join(", ")}</small>
</span>
<StatusBadge
status={item.state === "conflict"
? "warning"
: item.state === "upstream_only"
? "queued"
: "active"}
label={standardDiffLabel(item.state)}
/>
</header>
<p>{standardDiffAction(item.recommended_action)}</p>
</section>
))}
{!comparison.items.length ? (
<p className="workflow-standard-diff-empty">
The local override and latest standard contain no semantic changes.
</p>
) : null}
</div>
</div>
) : null}
</Dialog> </Dialog>
); );
} }
function StandardRevisionColumn({ function standardDiffLabel(state: string): string {
label, return ({
revision local_only: "Local change",
}: { upstream_only: "Standard update",
label: string; same_change: "Same change",
revision: WorkflowRevision | null; conflict: "Conflict",
}) { unchanged: "Unchanged"
return ( } as Record<string, string>)[state] ?? state;
<section className="workflow-standard-revision"> }
<header>
<strong>{label}</strong> function standardDiffAction(action: string): string {
<small> return ({
{revision keep_local: "The local change can be retained automatically.",
? `Revision ${revision.revision} · ${revision.content_hash.slice(0, 12)}` adopt_upstream: "The standard update can be adopted automatically.",
: "Loading..."} either: "Both revisions made the same semantic change.",
</small> manual_resolution: "Local and upstream changes overlap and need a decision.",
</header> none: "No action is required."
<pre>{revision ? JSON.stringify(revision.graph, null, 2) : ""}</pre> } as Record<string, string>)[action] ?? action;
</section>
);
} }
function WorkflowDefinitionSettingsDialog({ function WorkflowDefinitionSettingsDialog({
+57 -29
View File
@@ -19,55 +19,83 @@
} }
.workflow-standard-comparison { .workflow-standard-comparison {
display: grid; display: flex;
grid-template-columns: repeat(3, minmax(0, 1fr)); flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
gap: 10px; gap: 10px;
overflow: hidden; overflow: hidden;
} }
.workflow-standard-revision { .workflow-standard-comparison-summary {
display: flex; display: flex;
min-width: 0; align-items: center;
min-height: 0; gap: 18px;
flex-direction: column; padding: 10px 12px;
border: var(--border-line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--panel-soft); 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; 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; min-height: 0;
flex: 1 1 auto; gap: 6px;
margin: 0;
padding: 10px;
overflow: auto; 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) { @media (max-width: 900px) {
.workflow-standard-comparison { .workflow-standard-comparison-summary {
grid-template-columns: 1fr; align-items: flex-start;
overflow: auto; flex-direction: column;
gap: 5px;
} }
.workflow-standard-revision { .workflow-standard-comparison-summary .status-badge {
min-height: 260px; margin-left: 0;
} }
} }