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/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..6368830a --- /dev/null +++ b/src/presentation/components/common/ResourceDrift.jsx @@ -0,0 +1,158 @@ +import { Box, Text, Badge, HStack } from '@chakra-ui/react'; +import { useMemo, useEffect, useRef } from 'react'; +import { FiGitMerge, FiAlertCircle } from 'react-icons/fi'; +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'; +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 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] + ); + + 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) + spec.forProvider (desired) + + + + ); +} + +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 ( + + + + {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); +}