diff --git a/crossview-go-server/services/kubernetes_resources.go b/crossview-go-server/services/kubernetes_resources.go index 4ee8a0ff..6d0701ab 100644 --- a/crossview-go-server/services/kubernetes_resources.go +++ b/crossview-go-server/services/kubernetes_resources.go @@ -7,6 +7,7 @@ import ( "strings" "crossview-go-server/lib" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -36,19 +37,23 @@ func (k *KubernetesService) GetResources(apiVersion, kind, namespace, contextNam return nil, fmt.Errorf("apiVersion is required") } - apiVersionParts := strings.Split(apiVersion, "/") - if len(apiVersionParts) != 2 { - return nil, fmt.Errorf("invalid apiVersion format: %s, expected group/version", apiVersion) - } - - group := strings.TrimSpace(apiVersionParts[0]) - version := strings.TrimSpace(apiVersionParts[1]) - - if group == "" { - return nil, fmt.Errorf("invalid apiVersion format: %s, group is required", apiVersion) - } - if version == "" { - return nil, fmt.Errorf("invalid apiVersion format: %s, version is required", apiVersion) + var group, version string + if apiVersion == "v1" { + group = "" + version = "v1" + } else { + apiVersionParts := strings.Split(apiVersion, "/") + if len(apiVersionParts) != 2 { + return nil, fmt.Errorf("invalid apiVersion format: %s, expected group/version or v1", apiVersion) + } + group = strings.TrimSpace(apiVersionParts[0]) + version = strings.TrimSpace(apiVersionParts[1]) + if group == "" { + return nil, fmt.Errorf("invalid apiVersion format: %s, group is required", apiVersion) + } + if version == "" { + return nil, fmt.Errorf("invalid apiVersion format: %s, version is required", apiVersion) + } } if plural == "" { diff --git a/k8s/crossplane-example/ManagedResourceDefinition.yaml b/k8s/crossplane-example/ManagedResourceDefinition.yaml new file mode 100644 index 00000000..3681693b --- /dev/null +++ b/k8s/crossplane-example/ManagedResourceDefinition.yaml @@ -0,0 +1,15 @@ +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: ManagedResourceDefinition +metadata: + name: example-mrd +spec: + group: example.crossplane.io + names: + kind: ExampleResource + plural: exampleresources + singular: exampleresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff421fef..520d869a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "crossview", - "version": "4.3.0", + "version": "4.4.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crossview", - "version": "4.3.0", + "version": "4.4.0-rc.1", "license": "Apache-2.0", "dependencies": { "@chakra-ui/react": "^3.30.0", diff --git a/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js b/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js index aa67d67c..87a107d5 100644 --- a/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js +++ b/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js @@ -5,12 +5,11 @@ export class GetManagedResourceActivationPoliciesUseCase { async execute(context = null) { try { - const managedResourcesResult = await this.kubernetesRepository.getManagedResources(context); - const allResources = managedResourcesResult.items || []; - - const mraps = allResources.filter(item => item.kind === 'ManagedResourceActivationPolicy'); + const apiVersion = 'apiextensions.crossplane.io/v1alpha1'; + const kind = 'ManagedResourceActivationPolicy'; + const mrapsResult = await this.kubernetesRepository.getResources(apiVersion, kind, null, context); + const mraps = mrapsResult.items || mrapsResult; // Support both new format and legacy array format const mrapsArray = Array.isArray(mraps) ? mraps : []; - return mrapsArray.map(mrap => ({ name: mrap.metadata?.name || 'unknown', namespace: mrap.metadata?.namespace || null, diff --git a/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js b/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js index f08f0f51..8765ca40 100644 --- a/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js +++ b/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js @@ -5,12 +5,11 @@ export class GetManagedResourceDefinitionsUseCase { async execute(context = null) { try { - const managedResourcesResult = await this.kubernetesRepository.getManagedResources(context); - const allResources = managedResourcesResult.items || []; - - const mrds = allResources.filter(item => item.kind === 'ManagedResourceDefinition'); + const apiVersion = 'apiextensions.crossplane.io/v1alpha1'; + const kind = 'ManagedResourceDefinition'; + const mrdsResult = await this.kubernetesRepository.getResources(apiVersion, kind, null, context); + const mrds = mrdsResult.items || mrdsResult; // Support both new format and legacy array format const mrdsArray = Array.isArray(mrds) ? mrds : []; - return mrdsArray.map(mrd => { const conditions = mrd.status?.conditions || []; const establishedCondition = conditions.find(c => c.type === 'Established'); diff --git a/src/presentation/components/common/ResourceDetails.jsx b/src/presentation/components/common/ResourceDetails.jsx index e73ae176..0bad0fef 100644 --- a/src/presentation/components/common/ResourceDetails.jsx +++ b/src/presentation/components/common/ResourceDetails.jsx @@ -23,14 +23,12 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { const { addResource, removeResource, watchedResources } = useOnWatchResources(); const [activeTab, setActiveTab] = useState('overview'); - // Use the custom hook for resource data loading const { loading, fullResource, relatedResources, events, eventsLoading } = useResourceData(resource); const getResourceKey = (res) => { return `${res.apiVersion || ''}:${res.kind || ''}:${res.metadata?.namespace || ''}:${res.metadata?.name || ''}`; }; - // Check if resource is watched - use both current resource and fullResource const currentResource = fullResource || resource; const resourceKey = currentResource ? getResourceKey(currentResource) : null; const watchedResource = resourceKey ? watchedResources.find(r => { @@ -41,11 +39,9 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { const handleWatchToggle = () => { if (isWatched && watchedResource) { - // Unwatch the resource - use _key if available, otherwise calculate it const keyToRemove = watchedResource._key || getResourceKey(watchedResource); removeResource(keyToRemove); } else { - // Watch the resource const resourceToAdd = fullResource || resource; if (resourceToAdd) { const resourceWithPlural = { @@ -60,14 +56,13 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { if (!resource) return null; const handleRelatedClick = (related) => { - // Ensure namespace is null (not "undefined" string) for cluster-scoped resources const namespace = related.namespace && related.namespace !== 'undefined' && related.namespace !== 'null' ? related.namespace : null; onNavigate({ apiVersion: related.apiVersion, kind: related.kind, name: related.name, namespace: namespace, - plural: related.plural || null, // Include plural if available + plural: related.plural || null, }); }; @@ -157,7 +152,6 @@ export const ResourceDetails = ({ resource, onClose, onNavigate, onBack }) => { ) : ( - {/* Tabs Navigation */} { /> - {/* Tab Content */} {activeTab === 'overview' && ( { {activeTab === 'relations' && ( )} diff --git a/src/presentation/components/common/ResourceRelations.jsx b/src/presentation/components/common/ResourceRelations.jsx index 0984ac3e..59f3a3df 100644 --- a/src/presentation/components/common/ResourceRelations.jsx +++ b/src/presentation/components/common/ResourceRelations.jsx @@ -1,5 +1,7 @@ import { Box, Text } from '@chakra-ui/react'; import { useMemo, useEffect } from 'react'; +import { getResourceHealth, getHealthColor, getNodeBorderColor } from './ResourceRelations/utils.js'; +import { Legend } from './ResourceRelations/Legend.jsx'; import { ReactFlow, Background, @@ -17,10 +19,12 @@ const edgeTypes = { floating: FloatingEdge, }; -export const ResourceRelations = ({ resource, relatedResources, colorMode }) => { +export const ResourceRelations = ({ resource, relatedResources, colorMode, onNavigate }) => { const initialNodes = useMemo(() => { if (!resource) return []; + const mainHealth = getResourceHealth(resource); + const mainNode = { id: 'main-resource', type: 'default', @@ -31,9 +35,11 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => {resource.kind || 'Resource'} + {resource.name} + {resource.namespace && resource.namespace !== 'default' && ( ns: {resource.namespace} @@ -44,20 +50,23 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => }, style: { background: getBackgroundColor(colorMode, 'primary'), - border: `0.5px solid ${getBorderColor(colorMode, 'gray')}`, + border: `2px solid ${getNodeBorderColor(mainHealth, colorMode)}`, borderRadius: '8px', - padding: '0', + padding: 0, minWidth: '150px', - boxShadow: `0 2px 4px ${colors.shadow[colorMode === 'dark' ? 'dark' : 'light']}`, + boxSizing: 'border-box', }, }; const relatedNodes = (relatedResources || []).map((related, index) => { const angle = (index * 2 * Math.PI) / Math.max(relatedResources.length, 1); const radius = Math.max(250, relatedResources.length * 30); + const x = 400 + radius * Math.cos(angle); const y = 300 + radius * Math.sin(angle); + const health = getResourceHealth(related); + return { id: `related-${index}`, type: 'default', @@ -65,9 +74,14 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => data: { label: ( - + {related.type || related.kind} + > {related.name} - {related.namespace && ( + + {related.namespace && related.namespace !== 'default' && ( ns: {related.namespace} @@ -87,11 +102,11 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => }, style: { background: getBackgroundColor(colorMode, 'secondary'), - border: `1px solid ${getBorderColor(colorMode, 'gray')}`, + border: `2px solid ${getNodeBorderColor(health, colorMode)}`, borderRadius: '8px', - padding: '0', + padding: 0, minWidth: '120px', - boxShadow: `0 1px 2px ${colors.shadow[colorMode === 'dark' ? 'dark' : 'light']}`, + boxSizing: 'border-box', }, }; }); @@ -101,6 +116,7 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => const initialEdges = useMemo(() => { if (!resource) return []; + return initialNodes .filter((n) => n.id !== 'main-resource') .map((node, index) => ({ @@ -110,75 +126,90 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => type: 'floating', markerEnd: { type: MarkerType.Arrow }, style: { - stroke: colorMode === 'dark' ? colors.border.dark.gray : colors.border.light.gray, + stroke: + colorMode === 'dark' + ? colors.border.dark.gray + : colors.border.light.gray, }, })); }, [initialNodes, resource, colorMode]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // Reset nodes when initialNodes changes (resource or relatedResources changed) useEffect(() => { setNodes(initialNodes); - // Optional: you could also call onNodesChange([]) first if you want to fully clear - // but usually setNodes() is enough }, [initialNodes, setNodes]); - // Reset edges when initialEdges changes useEffect(() => { setEdges(initialEdges); }, [initialEdges, setEdges]); - // Update styles & labels when colorMode / resource / relatedResources change useEffect(() => { setNodes((nds) => nds.map((node) => { const isMain = node.id === 'main-resource'; if (isMain) { + const health = getResourceHealth(resource); + return { ...node, style: { ...node.style, background: getBackgroundColor(colorMode, 'primary'), - border: `0.5px solid ${getBorderColor(colorMode, 'gray')}`, - boxShadow: `0 2px 4px ${colors.shadow[colorMode === 'dark' ? 'dark' : 'light']}`, + border: `2px solid ${getNodeBorderColor(health, colorMode)}`, }, data: { ...node.data, label: ( - + {resource?.kind || 'Resource'} - + + {resource?.name} - {resource?.namespace && resource.namespace !== 'default' && ( - - ns: {resource.namespace} - - )} + + {resource?.namespace && + resource.namespace !== 'default' && ( + + ns: {resource.namespace} + + )} ), }, }; } - // related node const index = parseInt(node.id.split('-')[1], 10); const related = relatedResources?.[index]; - if (!related) return node; // safety + if (!related) return node; + + const health = getResourceHealth(related); return { ...node, style: { ...node.style, background: getBackgroundColor(colorMode, 'secondary'), - border: `1px solid ${getBorderColor(colorMode, 'gray')}`, - boxShadow: `0 1px 2px ${colors.shadow[colorMode === 'dark' ? 'dark' : 'light']}`, + border: `2px solid ${getNodeBorderColor(health, colorMode)}`, }, data: { ...node.data, @@ -191,6 +222,7 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => > {related.type || related.kind} + > {related.name} - {related.namespace && related.namespace !== 'default' && ( - - ns: {related.namespace} - - )} + + {related.namespace && + related.namespace !== 'default' && ( + + ns: {related.namespace} + + )} ), }, @@ -213,14 +251,48 @@ export const ResourceRelations = ({ resource, relatedResources, colorMode }) => ); }, [colorMode, resource, relatedResources, setNodes]); + const handleNodeClick = (_event, node) => { + if (!onNavigate || node.id === 'main-resource') { + return false; + } + + const index = parseInt(node.id.split('-')[1], 10); + const related = relatedResources?.[index]; + + if (!related?.apiVersion || !related?.kind || !related?.name) { + return false; + } + + const namespace = related.namespace && related.namespace !== 'undefined' && related.namespace !== 'null' + ? related.namespace + : null; + + onNavigate({ + apiVersion: related.apiVersion, + kind: related.kind, + name: related.name, + namespace, + plural: related.plural || null, + }); + return true; + }; + return ( - + {nodes.length > 0 ? ( }} fitView fitViewOptions={{ padding: 0.2 }} + proOptions={{ hideAttribution: true }} > ) : ( - No related resources found + + No related resources found + )} + ); }; \ No newline at end of file diff --git a/src/presentation/components/common/ResourceRelations/Legend.jsx b/src/presentation/components/common/ResourceRelations/Legend.jsx new file mode 100644 index 00000000..5bb4e4ba --- /dev/null +++ b/src/presentation/components/common/ResourceRelations/Legend.jsx @@ -0,0 +1,25 @@ +import { Box, Text } from '@chakra-ui/react'; +import { getBorderColor } from '../../../utils/theme.js'; + +export function Legend({ colorMode }) { + return ( + + + Healthy + + Unhealthy + + Warning/Unknown + + No status + + ); +} diff --git a/src/presentation/components/common/ResourceRelations/utils.js b/src/presentation/components/common/ResourceRelations/utils.js new file mode 100644 index 00000000..8da768e1 --- /dev/null +++ b/src/presentation/components/common/ResourceRelations/utils.js @@ -0,0 +1,43 @@ +import { getBorderColor } from '../../../utils/theme.js'; + +export function getResourceHealth(resource) { + if (!resource) return 'none'; + const conditions = Array.isArray(resource.conditions) + ? resource.conditions + : Array.isArray(resource.status?.conditions) + ? resource.status.conditions + : []; + if (conditions.length === 0) return 'none'; + const normalizedStatuses = conditions + .map((c) => c?.status) + .map((status) => { + if (typeof status === 'string') { + const normalized = status.toLowerCase(); + if (normalized === 'true') return 'true'; + if (normalized === 'false') return 'false'; + } + if (status === true) return 'true'; + if (status === false) return 'false'; + return 'unknown'; + }); + const hasFalse = normalizedStatuses.some((s) => s === 'false'); + const hasUnknown = normalizedStatuses.some((s) => s === 'unknown'); + const allTrue = normalizedStatuses.every((s) => s === 'true'); + if (allTrue) return 'healthy'; + if (hasFalse) return 'unhealthy'; + if (hasUnknown) return 'warning'; + return 'warning'; +} + +export function getHealthColor(health) { + if (health === 'healthy') return '#38A169'; + if (health === 'unhealthy') return '#E53E3E'; + return '#DD6B20'; +} + +export function getNodeBorderColor(health, colorMode) { + if (health === 'none') { + return getBorderColor(colorMode, 'gray'); + } + return getHealthColor(health); +} diff --git a/src/presentation/hooks/useResourceData.js b/src/presentation/hooks/useResourceData.js index 3c336033..301cf6c7 100644 --- a/src/presentation/hooks/useResourceData.js +++ b/src/presentation/hooks/useResourceData.js @@ -38,7 +38,9 @@ export const useResourceData = (resource) => { } const related = extractRelations(full, resource); - setRelatedResources(related); + const resourceIndex = await loadRelatedResourceIndex(related, contextName); + const hydratedRelated = hydrateRelatedResources(related, resourceIndex); + setRelatedResources(hydratedRelated); await loadEvents(full, resource, contextName); } } catch (error) { @@ -79,6 +81,87 @@ export const useResourceData = (resource) => { } }; + const loadRelatedResourceIndex = async (related, contextName) => { + const index = new Map(); + + if (!Array.isArray(related) || related.length === 0) { + return index; + } + + const relationTypes = new Set(); + related.forEach((rel) => { + if (rel?.apiVersion && rel.apiVersion !== 'unknown' && rel?.kind) { + relationTypes.add(`${rel.apiVersion}|${rel.kind}`); + } + }); + + if (relationTypes.size === 0) { + return index; + } + + const jobs = Array.from(relationTypes).map(async (typeKey) => { + const [apiVersion, kind] = typeKey.split('|'); + try { + const result = await kubernetesRepository.getResources(apiVersion, kind, null, contextName, null, null, null); + const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : []; + + items.forEach((item) => { + const itemApiVersion = item?.apiVersion || apiVersion; + const itemKind = item?.kind || kind; + const itemName = item?.metadata?.name || item?.name; + const itemNamespace = item?.metadata?.namespace || item?.namespace || null; + + if (!itemApiVersion || !itemKind || !itemName) return; + + const exactKey = buildResourceKey(itemApiVersion, itemKind, itemName, itemNamespace); + const clusterKey = buildResourceKey(itemApiVersion, itemKind, itemName, null); + const looseKey = `${itemKind}|${itemName}|${itemNamespace || 'cluster'}`; + + index.set(exactKey, item); + if (!index.has(clusterKey)) index.set(clusterKey, item); + if (!index.has(looseKey)) index.set(looseKey, item); + }); + } catch (error) { + console.warn(`[useResourceData] Failed to load related type ${apiVersion}/${kind}:`, error.message); + } + }); + + await Promise.all(jobs); + return index; + }; + + const buildResourceKey = (apiVersion, kind, name, namespace = null) => { + const ns = namespace || null; + return `${apiVersion || 'unknown'}|${kind || 'unknown'}|${name || 'unknown'}|${ns || 'cluster'}`; + }; + + const hydrateRelatedResources = (related, resourceIndex) => { + if (!Array.isArray(related) || related.length === 0 || !(resourceIndex instanceof Map) || resourceIndex.size === 0) { + return related; + } + + return related.map((rel) => { + if (!rel?.apiVersion || rel.apiVersion === 'unknown' || !rel.kind || !rel.name) { + return rel; + } + + const namespacedKey = buildResourceKey(rel.apiVersion, rel.kind, rel.name, rel.namespace || null); + const clusterKey = buildResourceKey(rel.apiVersion, rel.kind, rel.name, null); + const looseKey = `${rel.kind}|${rel.name}|${rel.namespace || 'cluster'}`; + const fullRelated = resourceIndex.get(namespacedKey) || resourceIndex.get(clusterKey) || resourceIndex.get(looseKey); + + if (!fullRelated) { + return rel; + } + + return { + ...rel, + status: fullRelated.status || rel.status, + conditions: fullRelated.status?.conditions || fullRelated.conditions || rel.conditions || [], + }; + }); + }; + const extractRelations = (full, original) => { const related = []; mergeSpec(full, original);