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);
+}