Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 14 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/presentation/components/common/ResourceDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 || ''}`;
};
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -213,6 +221,10 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => {
colorMode={colorMode}
/>
)}

{activeTab === 'drift' && (
<ResourceDrift fullResource={fullResource} />
)}
</Box>
</Box>
)}
Expand Down
158 changes: 158 additions & 0 deletions src/presentation/components/common/ResourceDrift.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<HStack mb={2} fontSize="xs" color={getTextColor(colorMode, 'secondary')}>
<Text fontFamily="mono" fontWeight="semibold" flex={1} textAlign="center">status.atProvider (current)</Text>
<Text fontFamily="mono" fontWeight="semibold" flex={1} textAlign="center">spec.forProvider (desired)</Text>
</HStack>
<Box
ref={ref}
borderRadius="md"
overflow="hidden"
border="1px solid"
borderColor={getBorderColor(colorMode)}
sx={{
'.cm-mergeView': { width: '100%' },
'.cm-mergeViewEditor': { flex: 1 },
'.cm-deletedChunk': { backgroundColor: 'rgba(248,81,73,0.15)' },
'.cm-deletedChunk .cm-deletedText': { backgroundColor: 'rgba(248,81,73,0.35)' },
'.cm-changedChunk': { backgroundColor: 'rgba(56,139,253,0.12)' },
'.cm-changedText': { backgroundColor: 'rgba(56,139,253,0.35)' },
}}
/>
</Box>
);
}

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 (
<Box p={6} textAlign="center" bg={getBackgroundColor(colorMode, 'secondary')} borderRadius="md" m={4}>
<Text color={getTextColor(colorMode, 'tertiary')} fontSize="sm">
This resource does not have <Text as="span" fontFamily="mono">spec.forProvider</Text> or{' '}
<Text as="span" fontFamily="mono">status.atProvider</Text>. Drift detection is only available for Crossplane managed resources.
</Text>
</Box>
);
}
if (!forProvider) {
return (
<Box p={6} textAlign="center" bg={getBackgroundColor(colorMode, 'secondary')} borderRadius="md" m={4}>
<Text color={getTextColor(colorMode, 'tertiary')} fontSize="sm">
No <Text as="span" fontFamily="mono">spec.forProvider</Text> found.
</Text>
</Box>
);
}
if (!atProvider) {
return (
<Box p={6} textAlign="center" bg={getBackgroundColor(colorMode, 'secondary')} borderRadius="md" m={4}>
<Text color={getTextColor(colorMode, 'tertiary')} fontSize="sm">
No <Text as="span" fontFamily="mono">status.atProvider</Text> yet — resource may still be provisioning.
</Text>
</Box>
);
}

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 (
<Box p={4} flex={1} overflowY="auto">
<Box p={3} mb={4} borderRadius="md" bg={getBackgroundColor(colorMode, 'secondary')} border="1px solid" borderColor={getBorderColor(colorMode)}>
<HStack spacing={2} flexWrap="wrap" gap={2}>
{realDiff > 0 ? <FiAlertCircle size={14} color="#dd6b20" /> : <FiGitMerge size={14} color="#38a169" />}
<Text fontSize="sm" fontWeight="semibold" color={getTextColor(colorMode, 'primary')}>
{realDiff === 0 ? 'In sync — no drift detected' : `${realDiff} field${realDiff !== 1 ? 's' : ''} differ`}
</Text>
{toAdd.length > 0 && <Badge colorScheme="blue" fontSize="2xs">{toAdd.length} to add</Badge>}
{changed.length > 0 && <Badge colorScheme="orange" fontSize="2xs">{changed.length} changed</Badge>}
{providerOnlyCount > 0 && <Badge colorScheme="gray" fontSize="2xs">{providerOnlyCount} provider-managed</Badge>}
</HStack>
</Box>

<SplitDiffView forProvider={forProvider} atProvider={atProvider} colorMode={colorMode} />
</Box>
);
};
24 changes: 22 additions & 2 deletions src/presentation/components/common/ResourceTabs.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,7 +8,8 @@ export const ResourceTabs = ({
resource,
hasStatus,
hasRelations,
isNamespaced
isNamespaced,
hasDrift,
}) => {
return (
<HStack spacing={0} borderBottom="1px solid" borderColor="gray.200" _dark={{ borderColor: 'gray.700' }} mb={4}>
Expand Down Expand Up @@ -100,6 +101,25 @@ export const ResourceTabs = ({
Events
</Button>
)}
{hasDrift && (
<Button
variant="ghost"
size="sm"
borderRadius="none"
borderBottom="2px solid"
borderBottomColor={activeTab === 'drift' ? 'orange.500' : 'transparent'}
color={activeTab === 'drift' ? 'orange.600' : 'gray.600'}
_dark={{
color: activeTab === 'drift' ? 'orange.400' : 'gray.400',
borderBottomColor: activeTab === 'drift' ? 'orange.400' : 'transparent',
}}
onClick={() => setActiveTab('drift')}
_hover={{ bg: 'gray.100', _dark: { bg: 'gray.700' } }}
leftIcon={<FiGitMerge />}
>
Drift
</Button>
)}
</HStack>
);
};
Expand Down
Loading
Loading