From 2f9503b737eaa7a1526d3917ab187079bf241a04 Mon Sep 17 00:00:00 2001 From: claudiocosta Date: Thu, 7 May 2026 20:19:49 -0300 Subject: [PATCH 1/2] feat(ui): add drift detection for Crossplane managed resources Adds a Drift tab to the resource detail panel for Crossplane managed resources that have both spec.forProvider (desired state) and status.atProvider (observed state). The tab shows a plan-style unified YAML diff: - Red lines: current state in the provider (atProvider) that differs - Blue lines: desired state declared in the spec (forProvider) - Gray lines: fields only present in atProvider (e.g. arn, id, tagsAll) The diff uses LCS-based line comparison with context collapse (2 lines around each change) and supports expand-all and copy-to-clipboard. Provider-only fields are filtered from the diff to avoid false positives. Signed-off-by: claudiocosta --- .../components/common/ResourceDetails.jsx | 12 ++ .../components/common/ResourceDrift.jsx | 125 ++++++++++++++ .../components/common/ResourceTabs.jsx | 24 ++- src/presentation/utils/driftUtils.js | 156 ++++++++++++++++++ 4 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 src/presentation/components/common/ResourceDrift.jsx create mode 100644 src/presentation/utils/driftUtils.js diff --git a/src/presentation/components/common/ResourceDetails.jsx b/src/presentation/components/common/ResourceDetails.jsx index e73ae176..60ef1441 100644 --- a/src/presentation/components/common/ResourceDetails.jsx +++ b/src/presentation/components/common/ResourceDetails.jsx @@ -16,7 +16,9 @@ import { ResourceYAML } from './ResourceYAML.jsx'; import { ResourceStatus } from './ResourceStatus.jsx'; import { ResourceRelations } from './ResourceRelations.jsx'; import { ResourceEvents } from './ResourceEvents.jsx'; +import { ResourceDrift } from './ResourceDrift.jsx'; import { getBorderColor } from '../../utils/theme.js'; +import { getDriftEntries } from '../../utils/driftUtils.js'; // kept for potential future use export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { const { colorMode } = useAppContext(); @@ -26,6 +28,11 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { // Use the custom hook for resource data loading const { loading, fullResource, relatedResources, events, eventsLoading } = useResourceData(resource); + // Drift detection — only for managed resources with forProvider/atProvider + const hasDrift = !loading && !!fullResource && + fullResource.spec?.forProvider !== undefined && + fullResource.status?.atProvider !== undefined; + const getResourceKey = (res) => { return `${res.apiVersion || ''}:${res.kind || ''}:${res.metadata?.namespace || ''}:${res.metadata?.name || ''}`; }; @@ -166,6 +173,7 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { resource={resource} hasStatus={!!fullResource.status} hasRelations={relatedResources.length > 0} + hasDrift={hasDrift} isNamespaced={(() => { const namespace = fullResource.metadata?.namespace || resource.namespace || fullResource.namespace; if (namespace && namespace !== 'undefined' && namespace !== 'null') { @@ -213,6 +221,10 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { colorMode={colorMode} /> )} + + {activeTab === 'drift' && ( + + )} )} diff --git a/src/presentation/components/common/ResourceDrift.jsx b/src/presentation/components/common/ResourceDrift.jsx new file mode 100644 index 00000000..046adad3 --- /dev/null +++ b/src/presentation/components/common/ResourceDrift.jsx @@ -0,0 +1,125 @@ +import { Box, Text, Badge, HStack, Grid } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { FiGitMerge, FiAlertCircle } from 'react-icons/fi'; +import CodeMirror from '@uiw/react-codemirror'; +import { yaml } from '@codemirror/lang-yaml'; +import YAML from 'yaml'; +import { getDriftEntries } from '../../utils/driftUtils.js'; +import { crossviewMirrorTheme } from '../../utils/crossviewMirrorTheme.js'; +import { getBackgroundColor, getBorderColor, getTextColor } from '../../utils/theme.js'; +import { useAppContext } from '../../providers/AppProvider.jsx'; + +function sortKeys(obj) { + if (Array.isArray(obj)) return obj.map(sortKeys); + if (obj && typeof obj === 'object') { + return Object.keys(obj).sort().reduce((acc, k) => { acc[k] = sortKeys(obj[k]); return acc; }, {}); + } + return obj; +} + +function PlanView({ forProvider, atProvider, colorMode }) { + const isDark = colorMode === 'dark'; + + const forYaml = useMemo( + () => YAML.stringify(sortKeys(forProvider || {}), { indent: 2, simpleKeys: true }), + [forProvider] + ); + const atYaml = useMemo( + () => YAML.stringify(sortKeys(atProvider || {}), { indent: 2, simpleKeys: true }), + [atProvider] + ); + + return ( + + + + status.atProvider — current state + + + + + + + + spec.forProvider — desired state + + + + + + + ); +} + +export const ResourceDrift = ({ fullResource }) => { + const { colorMode } = useAppContext(); + + const forProvider = fullResource?.spec?.forProvider; + const atProvider = fullResource?.status?.atProvider; + const entries = useMemo(() => getDriftEntries(fullResource), [fullResource]); + + if (!forProvider && !atProvider) { + return ( + + + This resource does not have spec.forProvider or{' '} + status.atProvider. Drift detection is only available for Crossplane managed resources. + + + ); + } + if (!forProvider) { + return ( + + + No spec.forProvider found. + + + ); + } + if (!atProvider) { + return ( + + + No status.atProvider yet — resource may still be provisioning. + + + ); + } + + const toAdd = entries.filter(e => e.type === 'removed'); + const changed = entries.filter(e => e.type === 'changed'); + const realDiff = toAdd.length + changed.length; + const providerOnlyCount = Object.keys(atProvider).filter(k => !(k in (forProvider || {}))).length; + + return ( + + {/* Summary bar */} + + + {realDiff > 0 ? : } + + {realDiff === 0 ? 'In sync — no drift detected' : `${realDiff} field${realDiff !== 1 ? 's' : ''} differ`} + + {toAdd.length > 0 && {toAdd.length} to add} + {changed.length > 0 && {changed.length} changed} + {providerOnlyCount > 0 && {providerOnlyCount} provider-managed} + + + + + + ); +}; diff --git a/src/presentation/components/common/ResourceTabs.jsx b/src/presentation/components/common/ResourceTabs.jsx index de3e3b34..47c3f0bb 100644 --- a/src/presentation/components/common/ResourceTabs.jsx +++ b/src/presentation/components/common/ResourceTabs.jsx @@ -1,5 +1,5 @@ import { HStack, Button } from '@chakra-ui/react'; -import { FiActivity } from 'react-icons/fi'; +import { FiActivity, FiGitMerge } from 'react-icons/fi'; export const ResourceTabs = ({ activeTab, @@ -8,7 +8,8 @@ export const ResourceTabs = ({ resource, hasStatus, hasRelations, - isNamespaced + isNamespaced, + hasDrift, }) => { return ( @@ -100,6 +101,25 @@ export const ResourceTabs = ({ Events )} + {hasDrift && ( + + )} ); }; diff --git a/src/presentation/utils/driftUtils.js b/src/presentation/utils/driftUtils.js new file mode 100644 index 00000000..314f6bd8 --- /dev/null +++ b/src/presentation/utils/driftUtils.js @@ -0,0 +1,156 @@ +/** + * Utilities for computing drift between spec.forProvider (desired) and status.atProvider (observed) + * on Crossplane managed resources. + */ + +/** + * Line-level LCS diff between two arrays of strings. + * Returns array of { type: 'same'|'removed'|'added', line: string } + * 'removed' = only in A (forProvider/desired) + * 'added' = only in B (atProvider/actual) + */ +export function lcsLineDiff(aLines, bLines) { + const m = aLines.length; + const n = bLines.length; + + // Build LCS DP table + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (aLines[i - 1] === bLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to get diff + const result = []; + let i = m, j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) { + result.unshift({ type: 'same', line: aLines[i - 1] }); + i--; j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.unshift({ type: 'added', line: bLines[j - 1] }); + j--; + } else { + result.unshift({ type: 'removed', line: aLines[i - 1] }); + i--; + } + } + return result; +} + +/** + * Deep-diff two objects, returning an array of change entries. + * Each entry: { path: string, type: 'changed'|'added'|'removed', forValue, atValue } + */ +export function computeDrift(forProvider, atProvider, path = '') { + const results = []; + + if (forProvider === null || forProvider === undefined) return results; + if (atProvider === null || atProvider === undefined) { + // Everything in forProvider is missing from atProvider + collectAll(forProvider, path, 'removed', results); + return results; + } + + const forType = getType(forProvider); + const atType = getType(atProvider); + + if (forType !== atType) { + results.push({ path: path || '(root)', type: 'changed', forValue: forProvider, atValue: atProvider }); + return results; + } + + if (forType === 'object') { + const allKeys = new Set([...Object.keys(forProvider), ...Object.keys(atProvider)]); + for (const key of allKeys) { + const childPath = path ? `${path}.${key}` : key; + const inFor = Object.prototype.hasOwnProperty.call(forProvider, key); + const inAt = Object.prototype.hasOwnProperty.call(atProvider, key); + + if (inFor && !inAt) { + collectAll(forProvider[key], childPath, 'removed', results); + } else if (!inFor && inAt) { + collectAll(atProvider[key], childPath, 'added', results); + } else { + const childResults = computeDrift(forProvider[key], atProvider[key], childPath); + results.push(...childResults); + } + } + return results; + } + + if (forType === 'array') { + // Compare element-by-element up to the longer length + const maxLen = Math.max(forProvider.length, atProvider.length); + for (let i = 0; i < maxLen; i++) { + const childPath = `${path || '(root)'}[${i}]`; + if (i >= forProvider.length) { + collectAll(atProvider[i], childPath, 'added', results); + } else if (i >= atProvider.length) { + collectAll(forProvider[i], childPath, 'removed', results); + } else { + const childResults = computeDrift(forProvider[i], atProvider[i], childPath); + results.push(...childResults); + } + } + return results; + } + + // Primitive comparison + if (forProvider !== atProvider) { + results.push({ path: path || '(root)', type: 'changed', forValue: forProvider, atValue: atProvider }); + } + + return results; +} + +function getType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function collectAll(value, path, type, results) { + if (value === null || value === undefined || typeof value !== 'object') { + results.push({ path, type, forValue: type === 'removed' ? value : undefined, atValue: type === 'added' ? value : undefined }); + return; + } + if (Array.isArray(value)) { + value.forEach((item, i) => collectAll(item, `${path}[${i}]`, type, results)); + return; + } + for (const key of Object.keys(value)) { + collectAll(value[key], path ? `${path}.${key}` : key, type, results); + } +} + +/** + * Returns true if the resource has both spec.forProvider and status.atProvider + * and they differ. + */ +export function hasDrift(fullResource) { + if (!fullResource) return false; + const forProvider = fullResource.spec?.forProvider; + const atProvider = fullResource.status?.atProvider; + if (forProvider === undefined || forProvider === null) return false; + if (atProvider === undefined || atProvider === null) return false; + const diffs = computeDrift(forProvider, atProvider); + return diffs.length > 0; +} + +/** + * Returns the drift entries or [] if either side is missing. + */ +export function getDriftEntries(fullResource) { + if (!fullResource) return []; + const forProvider = fullResource.spec?.forProvider; + const atProvider = fullResource.status?.atProvider; + if (forProvider === undefined || forProvider === null) return []; + if (atProvider === undefined || atProvider === null) return []; + return computeDrift(forProvider, atProvider); +} From 392e0ea41663a61e3c7929352261752d6bce210e Mon Sep 17 00:00:00 2001 From: claudiocosta Date: Mon, 11 May 2026 13:57:44 -0300 Subject: [PATCH 2/2] refactor(ui): replace drift plan view with @codemirror/merge split diff Replace the custom LCS plan view in ResourceDrift with a side-by-side split diff using the @codemirror/merge package, which is part of the official CodeMirror 6 ecosystem already used in ResourceYAML. Changes: - Use MergeView from @codemirror/merge for character-level diff highlight - Apply filterToSchema() to atProvider before diffing to exclude provider-only fields (arn, id, tagsAll) and avoid false positives - Apply sortKeys() to both sides to eliminate key-ordering false positives - Enable EditorView.lineWrapping to handle long lines (>300 chars) - collapseUnchanged to fold identical sections automatically - Centered panel headers for atProvider/forProvider labels - Add @codemirror/merge to package.json dependencies Signed-off-by: claudiocosta --- package-lock.json | 35 +++--- package.json | 1 + .../components/common/ResourceDrift.jsx | 119 +++++++++++------- 3 files changed, 91 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff421fef..f418f173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@chakra-ui/react": "^3.30.0", "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/merge": "^6.12.1", "@codemirror/search": "^6.6.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -492,6 +493,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.1.tgz", + "integrity": "sha512-GA8hBq2T+IFM0sb5fk8CunTrqOulA3zurJmHtzcU15EMnL8aYpVINfJ5bkfd53M4ikwoew4Y1ydtSaAlk6+B1w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/search": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", @@ -1977,18 +1991,6 @@ "@types/unist": "*" } }, - "node_modules/@types/node": { - "version": "20.19.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", - "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -7782,15 +7784,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", diff --git a/package.json b/package.json index 64c28ea6..7482ceae 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@chakra-ui/react": "^3.30.0", "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/merge": "^6.12.1", "@codemirror/search": "^6.6.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/src/presentation/components/common/ResourceDrift.jsx b/src/presentation/components/common/ResourceDrift.jsx index 046adad3..6368830a 100644 --- a/src/presentation/components/common/ResourceDrift.jsx +++ b/src/presentation/components/common/ResourceDrift.jsx @@ -1,8 +1,9 @@ -import { Box, Text, Badge, HStack, Grid } from '@chakra-ui/react'; -import { useMemo } from 'react'; +import { Box, Text, Badge, HStack } from '@chakra-ui/react'; +import { useMemo, useEffect, useRef } from 'react'; import { FiGitMerge, FiAlertCircle } from 'react-icons/fi'; -import CodeMirror from '@uiw/react-codemirror'; +import { EditorView } from '@uiw/react-codemirror'; import { yaml } from '@codemirror/lang-yaml'; +import { MergeView } from '@codemirror/merge'; import YAML from 'yaml'; import { getDriftEntries } from '../../utils/driftUtils.js'; import { crossviewMirrorTheme } from '../../utils/crossviewMirrorTheme.js'; @@ -17,49 +18,82 @@ function sortKeys(obj) { return obj; } -function PlanView({ forProvider, atProvider, colorMode }) { +function filterToSchema(obj, schema) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + const result = {}; + for (const key of Object.keys(schema)) { + if (!(key in obj)) continue; + const sv = schema[key]; + const ov = obj[key]; + if (sv && typeof sv === 'object' && !Array.isArray(sv) && ov && typeof ov === 'object' && !Array.isArray(ov)) { + result[key] = filterToSchema(ov, sv); + } else { + result[key] = ov; + } + } + return result; +} + +function SplitDiffView({ forProvider, atProvider, colorMode }) { + const ref = useRef(null); + const viewRef = useRef(null); const isDark = colorMode === 'dark'; + const atYaml = useMemo( + () => YAML.stringify(sortKeys(filterToSchema(atProvider || {}, forProvider || {})), { indent: 2, simpleKeys: true }), + [atProvider, forProvider] + ); const forYaml = useMemo( () => YAML.stringify(sortKeys(forProvider || {}), { indent: 2, simpleKeys: true }), [forProvider] ); - const atYaml = useMemo( - () => YAML.stringify(sortKeys(atProvider || {}), { indent: 2, simpleKeys: true }), - [atProvider] - ); + + useEffect(() => { + if (!ref.current) return; + if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } + + const extensions = [ + yaml(), + EditorView.lineWrapping, + EditorView.editable.of(false), + ...(isDark ? [crossviewMirrorTheme] : []), + EditorView.theme({ '&': { fontSize: '0.75rem', lineHeight: '1.5' } }), + ]; + + viewRef.current = new MergeView({ + parent: ref.current, + a: { doc: atYaml, extensions }, + b: { doc: forYaml, extensions }, + highlightChanges: true, + gutter: true, + collapseUnchanged: { minSize: 4, margin: 2 }, + }); + + return () => { if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } }; + }, [atYaml, forYaml, isDark]); return ( - - - - status.atProvider — current state - - - - - - - - spec.forProvider — desired state - - - - - - + + + status.atProvider (current) + spec.forProvider (desired) + + + ); } @@ -106,20 +140,19 @@ export const ResourceDrift = ({ fullResource }) => { return ( - {/* Summary bar */} {realDiff > 0 ? : } {realDiff === 0 ? 'In sync — no drift detected' : `${realDiff} field${realDiff !== 1 ? 's' : ''} differ`} - {toAdd.length > 0 && {toAdd.length} to add} - {changed.length > 0 && {changed.length} changed} - {providerOnlyCount > 0 && {providerOnlyCount} provider-managed} + {toAdd.length > 0 && {toAdd.length} to add} + {changed.length > 0 && {changed.length} changed} + {providerOnlyCount > 0 && {providerOnlyCount} provider-managed} - + ); };