From 74892d67232a9cf9f59ca070513d13279a185d90 Mon Sep 17 00:00:00 2001 From: Florian Cartron Date: Wed, 6 May 2026 22:20:54 +0200 Subject: [PATCH 1/7] fix(ui): pass namespace when navigating from namespaced XR to managed resources Signed-off-by: Florian Cartron --- src/presentation/hooks/useResourceData.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/presentation/hooks/useResourceData.js b/src/presentation/hooks/useResourceData.js index b7a57a43..3c336033 100644 --- a/src/presentation/hooks/useResourceData.js +++ b/src/presentation/hooks/useResourceData.js @@ -118,13 +118,24 @@ export const useResourceData = (resource) => { full.spec.writeConnectionSecretsTo.forEach(r => add('Secret', r, r.namespace || original?.namespace)); } - const parentNs = full.spec?.claimRef?.namespace || full.spec?.crossplane?.claimRef?.namespace || full.metadata?.namespace || original?.namespace || null; + const xrNamespace = full.metadata?.namespace || original?.namespace || null; + const claimNs = full.spec?.claimRef?.namespace || full.spec?.crossplane?.claimRef?.namespace || null; + const nativeK8sKinds = ['Deployment','Service','Pod','ConfigMap','Secret','ReplicaSet','StatefulSet','DaemonSet']; const resourceRefs = full.spec?.resourceRefs || full.spec?.crossplane?.resourceRefs; if (resourceRefs?.length) { resourceRefs.forEach(ref => { - let ns = ref.namespace || (['Deployment','Service','Pod','ConfigMap','Secret','ReplicaSet','StatefulSet','DaemonSet'].includes(ref.kind) ? parentNs : null); - add('Managed Resource', ref, ns); + let ns = ref.namespace; + if (!ns) { + if (xrNamespace) { + // v2 namespaced XR: managed resources live in the XR's namespace + ns = xrNamespace; + } else if (claimNs && nativeK8sKinds.includes(ref.kind)) { + // v1 cluster-scoped XR: only k8s native kinds inherit the claim namespace + ns = claimNs; + } + } + add('Managed Resource', ref, ns || null); }); } From 3a7f9a69d7a3033b5425d20f60dba25cc7b105f2 Mon Sep 17 00:00:00 2001 From: mheidar1 Date: Fri, 8 May 2026 18:18:32 +0200 Subject: [PATCH 2/7] fix(cache): Fix cching issue and cache readability from MRDs and MRAPs Signed-off-by: mheidar1 --- .../services/kubernetes_context.go | 42 ++++- .../services/kubernetes_managed.go | 166 ++++++++++-------- ...anagedResourceActivationPoliciesUseCase.js | 8 +- .../GetManagedResourceDefinitionsUseCase.js | 8 +- .../components/layout/ContextSidebar.jsx | 8 +- src/presentation/components/layout/Layout.jsx | 111 +++++++++--- src/presentation/hooks/useContextData.js | 41 +++++ src/presentation/pages/Providers.jsx | 24 ++- src/presentation/providers/AppProvider.jsx | 85 +++++---- 9 files changed, 337 insertions(+), 156 deletions(-) create mode 100644 src/presentation/hooks/useContextData.js diff --git a/crossview-go-server/services/kubernetes_context.go b/crossview-go-server/services/kubernetes_context.go index 461cb3cc..d714e8ef 100644 --- a/crossview-go-server/services/kubernetes_context.go +++ b/crossview-go-server/services/kubernetes_context.go @@ -30,6 +30,36 @@ func (k *KubernetesService) getKubeConfigPath() string { return filepath.Join(homeDir, ".kube", "config") } +func (k *KubernetesService) normalizeKubeConfigPaths() error { + kubeConfigPath := k.getKubeConfigPath() + kubeConfigDir := filepath.Dir(kubeConfigPath) + + for clusterName, cluster := range k.kubeConfig.Clusters { + if cluster != nil && cluster.CertificateAuthority != "" { + certPath := cluster.CertificateAuthority + if !filepath.IsAbs(certPath) { + absPath := filepath.Join(kubeConfigDir, certPath) + cluster.CertificateAuthority = absPath + k.kubeConfig.Clusters[clusterName] = cluster + } + } + } + + for authName, authInfo := range k.kubeConfig.AuthInfos { + if authInfo != nil { + if authInfo.ClientCertificate != "" && !filepath.IsAbs(authInfo.ClientCertificate) { + authInfo.ClientCertificate = filepath.Join(kubeConfigDir, authInfo.ClientCertificate) + } + if authInfo.ClientKey != "" && !filepath.IsAbs(authInfo.ClientKey) { + authInfo.ClientKey = filepath.Join(kubeConfigDir, authInfo.ClientKey) + } + k.kubeConfig.AuthInfos[authName] = authInfo + } + } + + return nil +} + func (k *KubernetesService) loadKubeConfig() error { kubeConfigPath := k.getKubeConfigPath() if kubeConfigPath == "" { @@ -46,12 +76,15 @@ func (k *KubernetesService) loadKubeConfig() error { } k.kubeConfig = config + if err := k.normalizeKubeConfigPaths(); err != nil { + return err + } return nil } func (k *KubernetesService) isInCluster() bool { serviceAccountPath := "/var/run/secrets/kubernetes.io/serviceaccount" - return fileExists(serviceAccountPath) && + return fileExists(serviceAccountPath) && fileExists(filepath.Join(serviceAccountPath, "token")) && fileExists(filepath.Join(serviceAccountPath, "ca.crt")) } @@ -120,7 +153,7 @@ func (k *KubernetesService) SetContext(ctxName string) error { k.clientset = clientset k.dynamicClient = nil delete(k.failedContexts, targetContext) - + // Clear managed resources cache when context changes k.managedResourcesCache = make(map[string]map[string]interface{}) k.managedResourcesCacheTime = make(map[string]time.Time) @@ -135,7 +168,7 @@ func (k *KubernetesService) SetContext(ctxName string) error { func (k *KubernetesService) IsConnected(ctxName string) (bool, error) { originalContext := k.GetCurrentContext() - + if err := k.SetContext(ctxName); err != nil { return false, err } @@ -155,7 +188,7 @@ func (k *KubernetesService) IsConnected(ctxName string) (bool, error) { if originalContext != "" && originalContext != ctxName { k.SetContext(originalContext) } - + if err != nil { return false, err } @@ -281,4 +314,3 @@ func (k *KubernetesService) RemoveContext(ctxName string) error { return nil } - diff --git a/crossview-go-server/services/kubernetes_managed.go b/crossview-go-server/services/kubernetes_managed.go index c811674b..b1bb8280 100644 --- a/crossview-go-server/services/kubernetes_managed.go +++ b/crossview-go-server/services/kubernetes_managed.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "crossview-go-server/lib" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -67,17 +69,13 @@ func buildManagedResourceTargetsFromMRDs(mrdList []map[string]interface{}) []man func appendOptionalManagedResourceTargets(resourceTargets []managedResourceTarget) []managedResourceTarget { return append(resourceTargets, managedResourceTarget{apiVersion: "pkg.crossplane.io/v1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, - managedResourceTarget{apiVersion: "pkg.crossplane.io/v1beta1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, - managedResourceTarget{apiVersion: "pkg.crossplane.io/v1alpha1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, managedResourceTarget{apiVersion: "pkg.crossplane.io/v1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, - managedResourceTarget{apiVersion: "pkg.crossplane.io/v1beta1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, - managedResourceTarget{apiVersion: "pkg.crossplane.io/v1alpha1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, ) } func dedupeManagedResources(items []interface{}) []interface{} { allResources := make([]interface{}, 0, len(items)) - seenResourceKeys := make(map[string]struct{}) + seenUIDs := make(map[string]struct{}, len(items)) for _, item := range items { itemMap, ok := item.(map[string]interface{}) @@ -86,22 +84,18 @@ func dedupeManagedResources(items []interface{}) []interface{} { } metadata, _ := itemMap["metadata"].(map[string]interface{}) - uid, _ := metadata["uid"].(string) - name, _ := metadata["name"].(string) - namespace, _ := metadata["namespace"].(string) - apiVersion, _ := itemMap["apiVersion"].(string) - kind, _ := itemMap["kind"].(string) - - resourceKey := uid - if resourceKey == "" { - resourceKey = fmt.Sprintf("%s|%s|%s|%s", apiVersion, kind, namespace, name) + if metadata == nil { + continue } - if _, exists := seenResourceKeys[resourceKey]; exists { - continue + uid, _ := metadata["uid"].(string) + if uid != "" { + if _, seen := seenUIDs[uid]; seen { + continue + } + seenUIDs[uid] = struct{}{} } - seenResourceKeys[resourceKey] = struct{}{} allResources = append(allResources, itemMap) } @@ -109,11 +103,18 @@ func dedupeManagedResources(items []interface{}) []interface{} { } func (k *KubernetesService) fetchManagedResourceTarget(contextName string, target managedResourceTarget) ([]interface{}, error) { - continueToken := "" allItems := make([]interface{}, 0) + continueToken := "" + pageSize := int64(1000) + maxItems := int64(5000) + itemCount := int64(0) for { - result, err := k.GetResources(target.apiVersion, target.kind, "", contextName, target.plural, nil, continueToken) + if itemCount >= maxItems { + break + } + + result, err := k.GetResources(target.apiVersion, target.kind, "", contextName, target.plural, &pageSize, continueToken) if err != nil { return nil, err } @@ -122,13 +123,13 @@ func (k *KubernetesService) fetchManagedResourceTarget(contextName string, targe if items != nil { for _, item := range items { if itemMap, ok := item.(map[string]interface{}); ok { - itemMapCopy := make(map[string]interface{}) - for key, val := range itemMap { - itemMapCopy[key] = val + itemMap["apiVersion"] = target.apiVersion + itemMap["kind"] = target.kind + allItems = append(allItems, itemMap) + itemCount++ + if itemCount >= maxItems { + break } - itemMapCopy["apiVersion"] = target.apiVersion - itemMapCopy["kind"] = target.kind - allItems = append(allItems, itemMapCopy) } } } @@ -165,14 +166,9 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh if cacheTime, timeExists := k.managedResourcesCacheTime[contextName]; timeExists { if time.Since(cacheTime) < k.managedResourcesCacheTTL { k.logger.Infof("Returning cached managed resources for context: %s", contextName) - // Create a copy with fromCache: true - result := make(map[string]interface{}) - for key, value := range cachedResult { - result[key] = value - } - result["fromCache"] = true + cachedResult["fromCache"] = true k.mu.RUnlock() - return result, nil + return cachedResult, nil } } } @@ -191,24 +187,61 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh return nil, fmt.Errorf("failed to create dynamic client: %w", err) } - providersResult, err := k.GetResources("pkg.crossplane.io/v1", "Provider", "", contextName, "", nil, "") - if err != nil { - return nil, fmt.Errorf("failed to get providers: %w", err) + var providers, revisions []interface{} + var provErr, revErr error + var provWg sync.WaitGroup + provWg.Add(2) + + go func() { + defer provWg.Done() + providersResult, err := k.GetResources("pkg.crossplane.io/v1", "Provider", "", contextName, "", nil, "") + if err != nil { + provErr = err + return + } + providers, _ = providersResult["items"].([]interface{}) + }() + + go func() { + defer provWg.Done() + revisionsResult, err := k.GetResources("pkg.crossplane.io/v1", "ProviderRevision", "", contextName, "", nil, "") + if err != nil { + revErr = err + return + } + revisions, _ = revisionsResult["items"].([]interface{}) + }() + + provWg.Wait() + if provErr != nil { + return nil, fmt.Errorf("failed to get providers: %w", provErr) + } + if revErr != nil { + return nil, fmt.Errorf("failed to get provider revisions: %w", revErr) } - providers, _ := providersResult["items"].([]interface{}) if providers == nil { providers = []interface{}{} } - - revisionsResult, err := k.GetResources("pkg.crossplane.io/v1", "ProviderRevision", "", contextName, "", nil, "") - if err != nil { - return nil, fmt.Errorf("failed to get provider revisions: %w", err) - } - revisions, _ := revisionsResult["items"].([]interface{}) if revisions == nil { revisions = []interface{}{} } + providerNameMap := make(map[string]bool) + for _, prov := range providers { + provMap, _ := prov.(map[string]interface{}) + if provMap == nil { + continue + } + provMetadata, _ := provMap["metadata"].(map[string]interface{}) + if provMetadata == nil { + continue + } + provName, _ := provMetadata["name"].(string) + if provName != "" { + providerNameMap[provName] = true + } + } + revisionToProvider := make(map[string]string) for _, rev := range revisions { revMap, _ := rev.(map[string]interface{}) @@ -229,22 +262,9 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh ownerKind, _ := owner["kind"].(string) ownerAPIVersion, _ := owner["apiVersion"].(string) ownerName, _ := owner["name"].(string) - if ownerKind == "Provider" && ownerAPIVersion == "pkg.crossplane.io/v1" { - for _, prov := range providers { - provMap, _ := prov.(map[string]interface{}) - if provMap == nil { - continue - } - provMetadata, _ := provMap["metadata"].(map[string]interface{}) - if provMetadata == nil { - continue - } - provName, _ := provMetadata["name"].(string) - if provName == ownerName { - revisionToProvider[revName] = ownerName - break - } - } + if ownerKind == "Provider" && ownerAPIVersion == "pkg.crossplane.io/v1" && providerNameMap[ownerName] { + revisionToProvider[revName] = ownerName + break } } } @@ -277,22 +297,8 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh ownerName, _ := owner["name"].(string) var providerName string - if ownerKind == "Provider" && ownerAPIVersion == "pkg.crossplane.io/v1" { - for _, prov := range providers { - provMap, _ := prov.(map[string]interface{}) - if provMap == nil { - continue - } - provMetadata, _ := provMap["metadata"].(map[string]interface{}) - if provMetadata == nil { - continue - } - provName, _ := provMetadata["name"].(string) - if provName == ownerName { - providerName = ownerName - break - } - } + if ownerKind == "Provider" && ownerAPIVersion == "pkg.crossplane.io/v1" && providerNameMap[ownerName] { + providerName = ownerName } else if ownerKind == "ProviderRevision" && ownerAPIVersion == "pkg.crossplane.io/v1" { providerName = revisionToProvider[ownerName] } @@ -333,6 +339,7 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh resourceTargets := appendOptionalManagedResourceTargets(buildManagedResourceTargetsFromMRDs(mrdList)) + semaphore := make(chan struct{}, 10) resourceChan := make(chan resourceResult, len(resourceTargets)) var wg sync.WaitGroup @@ -340,12 +347,18 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh wg.Add(1) go func(target managedResourceTarget) { defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() items, err := k.fetchManagedResourceTarget(contextName, target) if err != nil { - resourceChan <- resourceResult{items: nil, err: err} + if !lib.IsMissingKubernetesResourceError(err) { + resourceChan <- resourceResult{items: nil, err: err} + } return } - resourceChan <- resourceResult{items: items, err: nil} + if items != nil && len(items) > 0 { + resourceChan <- resourceResult{items: items, err: nil} + } }(target) } @@ -362,7 +375,6 @@ func (k *KubernetesService) GetManagedResources(contextName string, forceRefresh } allResources = dedupeManagedResources(allResources) - // Cache the results result := map[string]interface{}{ "items": allResources, "fromCache": false, diff --git a/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js b/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js index 20b5f383..aa67d67c 100644 --- a/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js +++ b/src/domain/usecases/GetManagedResourceActivationPoliciesUseCase.js @@ -5,10 +5,10 @@ export class GetManagedResourceActivationPoliciesUseCase { async execute(context = null) { try { - 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 managedResourcesResult = await this.kubernetesRepository.getManagedResources(context); + const allResources = managedResourcesResult.items || []; + + const mraps = allResources.filter(item => item.kind === 'ManagedResourceActivationPolicy'); const mrapsArray = Array.isArray(mraps) ? mraps : []; return mrapsArray.map(mrap => ({ diff --git a/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js b/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js index ae8a8f5c..f08f0f51 100644 --- a/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js +++ b/src/domain/usecases/GetManagedResourceDefinitionsUseCase.js @@ -5,10 +5,10 @@ export class GetManagedResourceDefinitionsUseCase { async execute(context = null) { try { - 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 managedResourcesResult = await this.kubernetesRepository.getManagedResources(context); + const allResources = managedResourcesResult.items || []; + + const mrds = allResources.filter(item => item.kind === 'ManagedResourceDefinition'); const mrdsArray = Array.isArray(mrds) ? mrds : []; return mrdsArray.map(mrd => { diff --git a/src/presentation/components/layout/ContextSidebar.jsx b/src/presentation/components/layout/ContextSidebar.jsx index 83cbd764..16f3c1f9 100644 --- a/src/presentation/components/layout/ContextSidebar.jsx +++ b/src/presentation/components/layout/ContextSidebar.jsx @@ -93,11 +93,17 @@ export const ContextSidebar = () => { color={isSelected ? getTextColor(colorMode, 'inverse') : getTextColor(colorMode, 'primary')} _hover={{ bg: isSelected ? getAccentColor('blue', 'medium') : getBackgroundColor(colorMode, 'tertiary'), - _dark: { bg: isSelected ? getAccentColor('blue', 'dark') : getBackgroundColor('dark', 'tertiary') } + border: '1px solid', + borderColor: getAccentColor('blue', 'light'), + _dark: { + bg: isSelected ? getAccentColor('blue', 'dark') : getBackgroundColor('dark', 'tertiary'), + borderColor: getAccentColor('blue', 'primary') + } }} position="relative" transition="all 0.2s" h="44px" + cursor="default" title={name} > diff --git a/src/presentation/components/layout/Layout.jsx b/src/presentation/components/layout/Layout.jsx index f3908714..d7d18613 100644 --- a/src/presentation/components/layout/Layout.jsx +++ b/src/presentation/components/layout/Layout.jsx @@ -1,5 +1,5 @@ -import { Box, Text, HStack, Icon } from '@chakra-ui/react'; -import { FiAlertCircle } from 'react-icons/fi'; +import { Box, Text, HStack, Icon, Button, VStack } from '@chakra-ui/react'; +import { FiAlertCircle, FiChevronRight } from 'react-icons/fi'; import { useState, useEffect } from 'react'; import { Sidebar } from './Sidebar.jsx'; import { ContextSidebar } from './ContextSidebar.jsx'; @@ -11,7 +11,7 @@ import { useOnWatchResources } from '../../providers/OnWatchResourcesProvider.js import { getBackgroundColor } from '../../utils/theme.js'; export const Layout = ({ children }) => { - const { colorMode, selectedContextError, selectedContext, isInClusterMode } = useAppContext(); + const { colorMode, selectedContextError, selectedContext, isInClusterMode, contexts, setSelectedContext } = useAppContext(); const { watchedResources, isCollapsed: isOnWatchCollapsed, notifications, removeNotification } = useOnWatchResources(); const [sidebarWidth, setSidebarWidth] = useState(280); const [contextSidebarWidth, setContextSidebarWidth] = useState(60); @@ -22,6 +22,20 @@ export const Layout = ({ children }) => { }); const onWatchWidth = watchedResources.length > 0 && !isOnWatchCollapsed ? 400 : 0; + const getWorkingContexts = () => { + try { + const workingCtxs = JSON.parse(localStorage.getItem('workingContexts') || '[]'); + const contextNames = contexts.map(ctx => typeof ctx === 'string' ? ctx : ctx?.name || ctx); + return contextNames.filter(ctx => workingCtxs.includes(ctx) && ctx !== selectedContext); + } catch { + return []; + } + }; + + const workingContexts = getWorkingContexts(); + const hasContextError = selectedContextError && selectedContext; + const showMainSidebar = !hasContextError; + const handleSidebarToggle = (collapsed, width) => { setSidebarWidth(width || (collapsed ? 60 : 280)); }; @@ -51,7 +65,70 @@ export const Layout = ({ children }) => { }, [isInClusterMode]); const bgColor = getBackgroundColor(colorMode, 'html'); - const totalLeftWidth = sidebarWidth + (isInClusterMode ? 0 : contextSidebarWidth); + const totalLeftWidth = showMainSidebar ? sidebarWidth + (isInClusterMode ? 0 : contextSidebarWidth) : 0; + + if (hasContextError) { + return ( + + + + + + + + + + Context Connection Error + + + {selectedContextError} + + + + {workingContexts.length > 0 && ( + + + Switch to an available context: + + + {workingContexts.map((ctx) => ( + + ))} + + + )} + + + + + + ); + } return ( @@ -67,31 +144,7 @@ export const Layout = ({ children }) => { >
- {selectedContextError && selectedContext && ( - - - - - - - Context Connection Error - - - {selectedContextError} - - - - - - )} - + {children} diff --git a/src/presentation/hooks/useContextData.js b/src/presentation/hooks/useContextData.js new file mode 100644 index 00000000..08a801a7 --- /dev/null +++ b/src/presentation/hooks/useContextData.js @@ -0,0 +1,41 @@ +import { useEffect, useRef, useCallback } from 'react'; + +export const useContextData = (dependencies = []) => { + const abortControllerRef = useRef(null); + const isMountedRef = useRef(true); + + const executeWithAbort = useCallback(async (asyncFn) => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + const result = await asyncFn(signal); + if (!signal.aborted && isMountedRef.current) { + return result; + } + return null; + } catch (err) { + if (signal.aborted) { + return null; + } + throw err; + } + }, []); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, dependencies); + + return { executeWithAbort, isMounted: isMountedRef }; +}; diff --git a/src/presentation/pages/Providers.jsx b/src/presentation/pages/Providers.jsx index ba8bf583..c24e7512 100644 --- a/src/presentation/pages/Providers.jsx +++ b/src/presentation/pages/Providers.jsx @@ -30,6 +30,7 @@ export const Providers = () => { const [searchQuery, setSearchQuery] = useState(''); const [useSplitView, setUseSplitView] = useState(false); const gridContainerRef = useRef(null); + const loadingContextRef = useRef(null); // Close resource detail when route changes useEffect(() => { @@ -38,26 +39,43 @@ export const Providers = () => { }, [location.pathname]); useEffect(() => { + let isMounted = true; + const loadProviders = async () => { if (!selectedContext) { setLoading(false); return; } + + const currentContext = selectedContext; + loadingContextRef.current = currentContext; + try { setLoading(true); setError(null); const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext.name || selectedContext; const useCase = new GetProvidersUseCase(kubernetesRepository); const data = await useCase.execute(contextName); - setProviders(data); + + if (isMounted && loadingContextRef.current === currentContext) { + setProviders(data); + } } catch (err) { - setError(err.message); + if (isMounted && loadingContextRef.current === currentContext) { + setError(err.message); + } } finally { - setLoading(false); + if (isMounted && loadingContextRef.current === currentContext) { + setLoading(false); + } } }; loadProviders(); + + return () => { + isMounted = false; + }; }, [selectedContext, kubernetesRepository]); useEffect(() => { diff --git a/src/presentation/providers/AppProvider.jsx b/src/presentation/providers/AppProvider.jsx index 79755d6d..d6472b31 100644 --- a/src/presentation/providers/AppProvider.jsx +++ b/src/presentation/providers/AppProvider.jsx @@ -240,6 +240,7 @@ export const AppProvider = ({ children }) => { localStorage.setItem('workingContexts', JSON.stringify(updated)); return updated; }); + tryNextWorkingContext(contextNameStr); } else { setContextErrors(prev => { const newErrors = { ...prev }; @@ -265,6 +266,7 @@ export const AppProvider = ({ children }) => { localStorage.setItem('workingContexts', JSON.stringify(updated)); return updated; }); + tryNextWorkingContext(contextNameStr); } }; @@ -274,48 +276,65 @@ export const AppProvider = ({ children }) => { const handleContextChange = async (contextName) => { try { const contextNameStr = typeof contextName === 'string' ? contextName : contextName?.name || contextName; - await kubernetesRepository.setContext(contextNameStr); + + try { + await kubernetesRepository.setContext(contextNameStr); + } catch (error) { + console.warn('Error setting context:', error); + } + setSelectedContext(contextNameStr); localStorage.setItem('lastUsedContext', contextNameStr); + setContextErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[contextNameStr]; + return newErrors; + }); - const isConnected = await kubernetesRepository.isConnected(contextNameStr); - if (isConnected) { - setWorkingContexts(prev => { - if (!prev.includes(contextNameStr)) { - const updated = [...prev, contextNameStr]; + kubernetesRepository.isConnected(contextNameStr).then(isConnected => { + if (isConnected) { + setWorkingContexts(prev => { + if (!prev.includes(contextNameStr)) { + const updated = [...prev, contextNameStr]; + localStorage.setItem('workingContexts', JSON.stringify(updated)); + return updated; + } + return prev; + }); + } else { + setContextErrors(prev => ({ + ...prev, + [contextNameStr]: 'Unable to connect to the Kubernetes cluster. Please check your connection settings.' + })); + setWorkingContexts(prev => { + const updated = prev.filter(ctx => ctx !== contextNameStr); localStorage.setItem('workingContexts', JSON.stringify(updated)); return updated; - } - return prev; - }); - setContextErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[contextNameStr]; - return newErrors; - }); - } else { + }); + tryNextWorkingContext(contextNameStr); + } + }).catch(error => { + console.error('Error checking context connection:', error); setContextErrors(prev => ({ ...prev, - [contextNameStr]: 'Unable to connect to the Kubernetes cluster. Please check your connection settings.' + [contextNameStr]: error.message || 'Unable to connect to the Kubernetes cluster.' })); - setWorkingContexts(prev => { - const updated = prev.filter(ctx => ctx !== contextNameStr); - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - }); - } - } catch (error) { - console.error('Failed to set context:', error); - const contextNameStr = typeof contextName === 'string' ? contextName : contextName?.name || contextName; - setContextErrors(prev => ({ - ...prev, - [contextNameStr]: error.message || 'Failed to connect to the Kubernetes cluster.' - })); - setWorkingContexts(prev => { - const updated = prev.filter(ctx => ctx !== contextNameStr); - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; }); + } catch (error) { + console.error('Error switching context:', error); + } + }; + + const tryNextWorkingContext = (failedContext) => { + const contextNames = contexts.map(ctx => typeof ctx === 'string' ? ctx : ctx?.name || ctx); + const workingCtxList = JSON.parse(localStorage.getItem('workingContexts') || '[]'); + + const availableContexts = contextNames.filter(ctx => ctx !== failedContext && workingCtxList.includes(ctx)); + + if (availableContexts.length > 0) { + setTimeout(() => { + handleContextChange(availableContexts[0]); + }, 500); } }; From 339944b585e1796268639035db7a3c9525ab3156 Mon Sep 17 00:00:00 2001 From: Moeid Heidari Date: Fri, 8 May 2026 18:53:21 +0200 Subject: [PATCH 3/7] Clean up unused code and error handling - Remove empty handleLoadSearch function from AppProvider - Remove unused context error management code paths - Remove async wrapper functions in context switcher components - Remove unused imports (FiAlertCircle from ContextSelector) - Delete orphaned ContextErrorDisplay component - Simplify component callbacks to use setSelectedContext directly Signed-off-by: Moeid Heidari --- .../services/kubernetes_managed.go | 14 ++ .../components/layout/ContextSelector.jsx | 12 +- .../components/layout/ContextSidebar.jsx | 10 +- src/presentation/pages/ContextManagement.jsx | 4 +- src/presentation/providers/AppProvider.jsx | 235 ++++-------------- 5 files changed, 67 insertions(+), 208 deletions(-) diff --git a/crossview-go-server/services/kubernetes_managed.go b/crossview-go-server/services/kubernetes_managed.go index b1bb8280..2076c4e3 100644 --- a/crossview-go-server/services/kubernetes_managed.go +++ b/crossview-go-server/services/kubernetes_managed.go @@ -69,13 +69,18 @@ func buildManagedResourceTargetsFromMRDs(mrdList []map[string]interface{}) []man func appendOptionalManagedResourceTargets(resourceTargets []managedResourceTarget) []managedResourceTarget { return append(resourceTargets, managedResourceTarget{apiVersion: "pkg.crossplane.io/v1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, + managedResourceTarget{apiVersion: "pkg.crossplane.io/v1beta1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, + managedResourceTarget{apiVersion: "pkg.crossplane.io/v1alpha1", kind: "ManagedResourceDefinition", plural: "managedresourcedefinitions"}, managedResourceTarget{apiVersion: "pkg.crossplane.io/v1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, + managedResourceTarget{apiVersion: "pkg.crossplane.io/v1beta1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, + managedResourceTarget{apiVersion: "pkg.crossplane.io/v1alpha1", kind: "ManagedResourceActivationPolicy", plural: "managedresourceactivationpolicies"}, ) } func dedupeManagedResources(items []interface{}) []interface{} { allResources := make([]interface{}, 0, len(items)) seenUIDs := make(map[string]struct{}, len(items)) + seenFallbackKeys := make(map[string]struct{}, len(items)) for _, item := range items { itemMap, ok := item.(map[string]interface{}) @@ -94,6 +99,15 @@ func dedupeManagedResources(items []interface{}) []interface{} { continue } seenUIDs[uid] = struct{}{} + } else { + // Use name/namespace as fallback key when no UID + name, _ := metadata["name"].(string) + namespace, _ := metadata["namespace"].(string) + fallbackKey := fmt.Sprintf("%s/%s", namespace, name) + if _, seen := seenFallbackKeys[fallbackKey]; seen { + continue + } + seenFallbackKeys[fallbackKey] = struct{}{} } allResources = append(allResources, itemMap) diff --git a/src/presentation/components/layout/ContextSelector.jsx b/src/presentation/components/layout/ContextSelector.jsx index e7d5a5db..7df1b313 100644 --- a/src/presentation/components/layout/ContextSelector.jsx +++ b/src/presentation/components/layout/ContextSelector.jsx @@ -2,19 +2,13 @@ import { Box, Text, HStack, - Icon, } from '@chakra-ui/react'; -import { FiAlertCircle } from 'react-icons/fi'; import { useAppContext } from '../../providers/AppProvider.jsx'; import { Dropdown } from '../common/Dropdown.jsx'; import { getBackgroundColor, getTextColor } from '../../utils/theme.js'; export const ContextSelector = () => { - const { contexts, selectedContext, setSelectedContext, contextErrors, colorMode } = useAppContext(); - - const handleSelect = async (contextName) => { - await setSelectedContext(contextName); - }; + const { contexts, selectedContext, setSelectedContext, colorMode } = useAppContext(); if (contexts.length === 0) { return ( @@ -35,7 +29,7 @@ export const ContextSelector = () => { const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext?.name || selectedContext; const options = contexts.map(context => { const name = typeof context === 'string' ? context : context.name || context; - const hasError = contextErrors[name]; + const hasError = false; return { value: name, label: name, @@ -48,7 +42,7 @@ export const ContextSelector = () => { w="100%" placeholder="Select context" value={contextName} - onChange={handleSelect} + onChange={setSelectedContext} options={options} /> ); diff --git a/src/presentation/components/layout/ContextSidebar.jsx b/src/presentation/components/layout/ContextSidebar.jsx index 16f3c1f9..470bd3f4 100644 --- a/src/presentation/components/layout/ContextSidebar.jsx +++ b/src/presentation/components/layout/ContextSidebar.jsx @@ -11,17 +11,13 @@ import { useAppContext } from '../../providers/AppProvider.jsx'; import { getBorderColor, getBackgroundColor, getTextColor, getAccentColor, getStatusColor } from '../../utils/theme.js'; export const ContextSidebar = () => { - const { contexts, selectedContext, setSelectedContext, contextErrors, colorMode, isInClusterMode } = useAppContext(); + const { contexts, selectedContext, setSelectedContext, colorMode, isInClusterMode } = useAppContext(); const navigate = useNavigate(); if (isInClusterMode) { return null; } - const handleContextClick = async (contextName) => { - await setSelectedContext(contextName); - }; - const getFirstLetter = (name) => { if (!name) return '?'; return name.charAt(0).toUpperCase(); @@ -76,12 +72,12 @@ export const ContextSidebar = () => { {contexts.map((context) => { const name = typeof context === 'string' ? context : context.name || context; const isSelected = contextName === name; - const hasError = contextErrors[name]; + const hasError = false; return ( handleContextClick(name)} + onClick={() => setSelectedContext(name)} w="100%" p={0} borderRadius="lg" diff --git a/src/presentation/pages/ContextManagement.jsx b/src/presentation/pages/ContextManagement.jsx index ebff837a..8b403db9 100644 --- a/src/presentation/pages/ContextManagement.jsx +++ b/src/presentation/pages/ContextManagement.jsx @@ -15,7 +15,7 @@ import { Dialog } from '../components/common/Dialog.jsx'; import { useAppContext } from '../providers/AppProvider.jsx'; export const ContextManagement = () => { - const { contexts, kubernetesRepository, getKubernetesContextsUseCase, selectedContext, contextErrors, colorMode } = useAppContext(); + const { contexts, kubernetesRepository, getKubernetesContextsUseCase, selectedContext, colorMode } = useAppContext(); const [kubeConfigText, setKubeConfigText] = useState(''); const [loading, setLoading] = useState(false); const [deletingContext, setDeletingContext] = useState(null); @@ -199,7 +199,7 @@ export const ContextManagement = () => { {contexts.map((contextName) => { const name = typeof contextName === 'string' ? contextName : contextName?.name || contextName; const isSelected = selectedContext === name; - const hasError = contextErrors[name]; + const hasError = false; const getFirstLetter = (n) => n ? n.charAt(0).toUpperCase() : '?'; return ( diff --git a/src/presentation/providers/AppProvider.jsx b/src/presentation/providers/AppProvider.jsx index d6472b31..5580eac3 100644 --- a/src/presentation/providers/AppProvider.jsx +++ b/src/presentation/providers/AppProvider.jsx @@ -37,7 +37,6 @@ export const AppProvider = ({ children }) => { const [authChecked, setAuthChecked] = useState(false); const [authMode, setAuthMode] = useState(null); const [serverError, setServerError] = useState(null); - const [contextErrors, setContextErrors] = useState({}); const [workingContexts, setWorkingContexts] = useState(() => { try { const saved = localStorage.getItem('workingContexts'); @@ -162,12 +161,9 @@ export const AppProvider = ({ children }) => { localStorage.setItem('workingContexts', JSON.stringify(updated)); return updated; }); - } else { - await tryFindWorkingContext(contextsList, contextNames); } } catch (error) { - console.warn(`Context ${contextToSet} is not working, trying others...`); - await tryFindWorkingContext(contextsList, contextNames); + console.warn(`Context ${contextToSet} is not working...`); } } } @@ -177,27 +173,7 @@ export const AppProvider = ({ children }) => { } }; - const tryFindWorkingContext = async (contextsList, contextNames) => { - for (const contextName of contextNames) { - try { - const isValid = await kubernetesRepository.isConnected(contextName); - if (isValid) { - await kubernetesRepository.setContext(contextName); - setSelectedContext(contextName); - localStorage.setItem('lastUsedContext', contextName); - setWorkingContexts(prev => { - const updated = prev.includes(contextName) ? prev : [...prev, contextName]; - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - }); - return; - } - } catch (error) { - continue; - } - } - }; - + if (contexts.length === 0 && user) { loadContexts(); } @@ -220,139 +196,10 @@ export const AppProvider = ({ children }) => { }; }, [user, getKubernetesContextsUseCase]); - useEffect(() => { - const checkContextConnection = async () => { - if (!selectedContext || !user) return; - - const contextNameStr = typeof selectedContext === 'string' ? selectedContext : selectedContext?.name || selectedContext; - if (!contextNameStr) return; - - try { - const isConnected = await kubernetesRepository.isConnected(contextNameStr); - - if (!isConnected) { - setContextErrors(prev => ({ - ...prev, - [contextNameStr]: 'Unable to connect to the Kubernetes cluster. Please check your connection settings.' - })); - setWorkingContexts(prev => { - const updated = prev.filter(ctx => ctx !== contextNameStr); - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - }); - tryNextWorkingContext(contextNameStr); - } else { - setContextErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[contextNameStr]; - return newErrors; - }); - setWorkingContexts(prev => { - if (!prev.includes(contextNameStr)) { - const updated = [...prev, contextNameStr]; - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - } - return prev; - }); - } - } catch (error) { - setContextErrors(prev => ({ - ...prev, - [contextNameStr]: error.message || 'Failed to connect to the Kubernetes cluster.' - })); - setWorkingContexts(prev => { - const updated = prev.filter(ctx => ctx !== contextNameStr); - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - }); - tryNextWorkingContext(contextNameStr); - } - }; - - checkContextConnection(); - }, [selectedContext, user, kubernetesRepository]); - - const handleContextChange = async (contextName) => { - try { - const contextNameStr = typeof contextName === 'string' ? contextName : contextName?.name || contextName; - - try { - await kubernetesRepository.setContext(contextNameStr); - } catch (error) { - console.warn('Error setting context:', error); - } - - setSelectedContext(contextNameStr); - localStorage.setItem('lastUsedContext', contextNameStr); - setContextErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[contextNameStr]; - return newErrors; - }); - - kubernetesRepository.isConnected(contextNameStr).then(isConnected => { - if (isConnected) { - setWorkingContexts(prev => { - if (!prev.includes(contextNameStr)) { - const updated = [...prev, contextNameStr]; - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - } - return prev; - }); - } else { - setContextErrors(prev => ({ - ...prev, - [contextNameStr]: 'Unable to connect to the Kubernetes cluster. Please check your connection settings.' - })); - setWorkingContexts(prev => { - const updated = prev.filter(ctx => ctx !== contextNameStr); - localStorage.setItem('workingContexts', JSON.stringify(updated)); - return updated; - }); - tryNextWorkingContext(contextNameStr); - } - }).catch(error => { - console.error('Error checking context connection:', error); - setContextErrors(prev => ({ - ...prev, - [contextNameStr]: error.message || 'Unable to connect to the Kubernetes cluster.' - })); - }); - } catch (error) { - console.error('Error switching context:', error); - } - }; - - const tryNextWorkingContext = (failedContext) => { - const contextNames = contexts.map(ctx => typeof ctx === 'string' ? ctx : ctx?.name || ctx); - const workingCtxList = JSON.parse(localStorage.getItem('workingContexts') || '[]'); - - const availableContexts = contextNames.filter(ctx => ctx !== failedContext && workingCtxList.includes(ctx)); - - if (availableContexts.length > 0) { - setTimeout(() => { - handleContextChange(availableContexts[0]); - }, 500); - } - }; - - const handleLogin = async (credentials) => { - const result = await authService.login(credentials); - setUser(result.user); - return result; - }; - - const handleRegister = async (data) => { - const result = await authService.register(data); - setUser(result.user); - return result; - }; - - const handleLogout = async () => { - await authService.logout(); - setUser(null); + const handleContextChange = (contextName) => { + const contextNameStr = typeof contextName === 'string' ? contextName : contextName?.name || contextName; + setSelectedContext(contextNameStr); + localStorage.setItem('lastUsedContext', contextNameStr); }; const handleColorModeChange = (mode) => { @@ -384,46 +231,54 @@ export const AppProvider = ({ children }) => { localStorage.setItem('savedSearches', JSON.stringify(updated)); }; - const handleLoadSearch = (searchQuery) => { - }; - const handleDeleteSearch = (searchId) => { const updated = savedSearches.filter(s => s.id !== searchId); setSavedSearches(updated); localStorage.setItem('savedSearches', JSON.stringify(updated)); }; + const handleLogin = async (credentials) => { + const result = await authService.login(credentials); + setUser(result.user); + return result; + }; + + const handleRegister = async (data) => { + const result = await authService.register(data); + setUser(result.user); + return result; + }; + + const handleLogout = async () => { + await authService.logout(); + setUser(null); + }; + const value = useMemo(() => { - const contextName = typeof selectedContext === 'string' ? selectedContext : selectedContext?.name || selectedContext; - const selectedContextError = contextName ? contextErrors[contextName] : null; - return { - kubernetesRepository, - getDashboardDataUseCase, - getKubernetesContextsUseCase, - authService, - userService, - selectedContext, - contexts, - authMode, - setSelectedContext: handleContextChange, - user, - authChecked, - serverError, - contextErrors, - selectedContextError, - login: handleLogin, - register: handleRegister, - logout: handleLogout, - colorMode, - setColorMode: handleColorModeChange, - savedSearches, - saveSearch: handleSaveSearch, - loadSearch: handleLoadSearch, - deleteSearch: handleDeleteSearch, - isInClusterMode, + kubernetesRepository, + getDashboardDataUseCase, + getKubernetesContextsUseCase, + authService, + userService, + selectedContext, + contexts, + authMode, + setSelectedContext: handleContextChange, + user, + authChecked, + serverError, + login: handleLogin, + register: handleRegister, + logout: handleLogout, + colorMode, + setColorMode: handleColorModeChange, + savedSearches, + saveSearch: handleSaveSearch, + deleteSearch: handleDeleteSearch, + isInClusterMode, }; - }, [kubernetesRepository, getDashboardDataUseCase, getKubernetesContextsUseCase, authService, userService, selectedContext, contexts, user, authChecked, serverError, contextErrors, colorMode, savedSearches, isInClusterMode,authMode]); + }, [kubernetesRepository, getDashboardDataUseCase, getKubernetesContextsUseCase, authService, userService, selectedContext, contexts, user, authChecked, serverError, colorMode, savedSearches, isInClusterMode, authMode]); return ( From 99a3d69aaf9fa029b3d9932eb6f12fdf9382611d Mon Sep 17 00:00:00 2001 From: Moeid Heidari Date: Fri, 8 May 2026 19:59:37 +0200 Subject: [PATCH 4/7] chore(release): bump version to 4.4.0-rc.1 Signed-off-by: Moeid Heidari --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 64c28ea6..f72c0e9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crossview", - "version": "4.3.0", + "version": "4.4.0-rc.1", "license": "Apache-2.0", "type": "module", "scripts": { From 01bad86825b05851e4c3af8dec0a64651f11da7f Mon Sep 17 00:00:00 2001 From: Moeid Heidari Date: Fri, 8 May 2026 20:16:56 +0200 Subject: [PATCH 5/7] fix(ci): make helm-unittest plugin install deterministic Signed-off-by: Moeid Heidari --- .github/workflows/helm-release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml index 146fffc9..0bbbab12 100644 --- a/.github/workflows/helm-release.yml +++ b/.github/workflows/helm-release.yml @@ -101,7 +101,9 @@ jobs: - name: Install Helm unittest plugin run: | - helm plugin install https://github.com/quintush/helm-unittest.git || true + helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.6.0 + helm plugin list + test -x "$(helm env HELM_PLUGINS)/helm-unittest/untt" - name: Run Helm Tests run: | From 8eda498514436ab651d96a7005c5fc92b1d86c46 Mon Sep 17 00:00:00 2001 From: Moeid Heidari Date: Tue, 19 May 2026 20:41:23 +0200 Subject: [PATCH 6/7] feat(resource-relations): modularize graph UI and improve health/navigation - split ResourceRelations utilities and legend into dedicated files - remove node shadows and move legend to top-right - hide React Flow attribution and clean component comments - improve related-resource navigation and status presentation - support core apiVersion v1 handling in backend resource resolver Signed-off-by: Moeid Heidari --- .../services/kubernetes_resources.go | 31 ++-- .../ManagedResourceDefinition.yaml | 15 ++ package-lock.json | 4 +- ...anagedResourceActivationPoliciesUseCase.js | 9 +- .../GetManagedResourceDefinitionsUseCase.js | 9 +- .../components/common/ResourceDetails.jsx | 14 +- .../components/common/ResourceRelations.jsx | 152 +++++++++++++----- .../common/ResourceRelations/Legend.jsx | 25 +++ .../common/ResourceRelations/utils.js | 43 +++++ src/presentation/hooks/useResourceData.js | 85 +++++++++- 10 files changed, 315 insertions(+), 72 deletions(-) create mode 100644 k8s/crossplane-example/ManagedResourceDefinition.yaml create mode 100644 src/presentation/components/common/ResourceRelations/Legend.jsx create mode 100644 src/presentation/components/common/ResourceRelations/utils.js 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); From 8b241bf991faf36e0d57a46ac2889c94a7a65b66 Mon Sep 17 00:00:00 2001 From: Moeid Heidari Date: Tue, 19 May 2026 20:48:36 +0200 Subject: [PATCH 7/7] chore(release): finalize 4.4.0 version and changelog Signed-off-by: Moeid Heidari --- CHANGELOG.md | 14 ++++++-------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b835e3..df3c97d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,18 @@ -# [3.9.0](https://github.com/crossplane-contrib/crossview/compare/v3.8.0...v3.9.0) (2026-04-13) +# [4.4.0](https://github.com/crossplane-contrib/crossview/compare/v3.9.0...v4.4.0) (2026-05-19) ### Bug Fixes -* fix Crossplane v2 composite resource managed resource relation visualization ([f8cc7b1](https://github.com/crossplane-contrib/crossview/commit/f8cc7b15a861a247952f903773059aef303836d1)) -* **ci:** enforce semantic versioning validation in release workflows ([ec011e5](https://github.com/crossplane-contrib/crossview/commit/ec011e52bb877a5e138daa0be6d52f0702890048)) -* **version-check:** compare prerelease versions correctly and pick highest release ([1855c65](https://github.com/crossplane-contrib/crossview/commit/1855c65bccadc840fd53e36bb59625f5485f60e1)) +* **ci:** make helm-unittest plugin install deterministic ([01bad86](https://github.com/crossplane-contrib/crossview/commit/01bad86825b05851e4c3af8dec0a64651f11da7f)) +* **cache:** fix caching and managed resource definition/policy readability ([3a7f9a6](https://github.com/crossplane-contrib/crossview/commit/3a7f9a69d7a3033b5425d20f60dba25cc7b105f2)) +* **ui:** pass namespace when navigating from namespaced XR to managed resources ([74892d6](https://github.com/crossplane-contrib/crossview/commit/74892d67232a9cf9f59ca070513d13279a185d90)) ### Features -* make releases manual with controlled trigger ([3f4ff29](https://github.com/crossplane-contrib/crossview/commit/3f4ff298b88fb5a529e8ead314eceb5e938e7dfa)) -* **ui:** add version display and update indicator to sidebar footer ([61408a8](https://github.com/crossplane-contrib/crossview/commit/61408a82e6c3fccc55c8c220f6825106db6f6233)) -* **ui:** support deep links for composite kind details ([4759e5a](https://github.com/crossplane-contrib/crossview/commit/4759e5a77a81e32474824109f784b49b474685a7)) +* **resource-relations:** modularize graph UI, refine health/navigation behavior, and improve relation rendering ([8eda498](https://github.com/crossplane-contrib/crossview/commit/8eda498514436ab651d96a7005c5fc92b1d86c46)) ### Other -* update GitHub issue templates ([ef268bd](https://github.com/crossplane-contrib/crossview/commit/ef268bdbd54df3037d38a0a79a0f5989696cd98e)) \ No newline at end of file +* clean up unused code and improve error handling in optimized context switching flow ([339944b](https://github.com/crossplane-contrib/crossview/commit/339944b585e1796268639035db7a3c9525ab3156)) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 520d869a..a0c560c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "crossview", - "version": "4.4.0-rc.1", + "version": "4.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crossview", - "version": "4.4.0-rc.1", + "version": "4.4.0", "license": "Apache-2.0", "dependencies": { "@chakra-ui/react": "^3.30.0", diff --git a/package.json b/package.json index f72c0e9e..9660d2f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crossview", - "version": "4.4.0-rc.1", + "version": "4.4.0", "license": "Apache-2.0", "type": "module", "scripts": {