diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index d6842cd054..61874e4b1f 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -749,6 +749,7 @@ kubeconfig-id: error: "Couldn't load kubeconfig ID; configuration not changed (Error: ${{error}})" must-be-an-object: Kubeconfig must be a JSON or YAML object. kyma-modules: + associated-resources: Associated Resources unmanaged-modules-info: One of the modules is not managed and may not work properly. We cannot guarantee any service level agreement (SLA) or provide updates and maintenance for the module. unmanaged-modules-save-warning: Before proceeding, be aware that disabling module management may impact the stability and data integrity of your cluster. Once the management is disabled, reverting back may not be possible. Are you sure you want to continue? unmanaged-modules-warning: Disabling this option can lead to potential instability and loss of data within your cluster. Proceed with caution. Once disabled, it may not be possible to revert back. @@ -775,6 +776,7 @@ kyma-modules: no-version: No version available channel-overridden: Overridden managed: Managed + associated-resources-warning: If there are resources left, remove them first to delete the module. beta: Beta beta-alert: "CAUTION: The Service Level Agreements (SLAs) and Support obligations do not apply to Beta modules and functionalities. If Beta modules or functionalities directly or indirectly affect other modules, the Service Level Agreements and Support for these modules are limited to priority levels P3 (Medium) or P4 (Low). Thus, Beta releases are not intended for use in customer production environments." change: Change diff --git a/src/components/KymaModules/KymaModulesList.js b/src/components/KymaModules/KymaModulesList.js index d34d62bb4b..bd9f5ff8a1 100644 --- a/src/components/KymaModules/KymaModulesList.js +++ b/src/components/KymaModules/KymaModulesList.js @@ -9,13 +9,21 @@ import { FlexBox, Text, Badge, + List, + StandardListItem, + MessageStrip, } from '@ui5/webcomponents-react'; import { HintButton } from 'shared/components/DescriptionHint/DescriptionHint'; import { spacing } from '@ui5/webcomponents-react-base'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { GenericList } from 'shared/components/GenericList/GenericList'; -import { useGet, useGetList } from 'shared/hooks/BackendAPI/useGet'; +import { + useGet, + useGetList, + useGetScope, + useSingleGet, +} from 'shared/hooks/BackendAPI/useGet'; import { ExternalLink } from 'shared/components/ExternalLink/ExternalLink'; import { EMPTY_TEXT_PLACEHOLDER } from 'shared/constants'; import KymaModulesCreate from './KymaModulesCreate'; @@ -36,6 +44,7 @@ import { isFormOpenState } from 'state/formOpenAtom'; import { ModuleStatus } from './components/ModuleStatus'; import { cloneDeep } from 'lodash'; import { StatusBadge } from 'shared/components/StatusBadge/StatusBadge'; +import { useNavigate } from 'react-router-dom'; export default function KymaModulesList({ DeleteMessageBox, @@ -62,6 +71,9 @@ export default function KymaModulesList({ const setLayoutColumn = useSetRecoilState(columnLayoutState); const setIsFormOpen = useSetRecoilState(isFormOpenState); const { clusterUrl, namespaceUrl } = useUrl(); + const fetch = useSingleGet(); + const getScope = useGetScope(); + const navigate = useNavigate(); const { data: kymaExt } = useGetList( ext => ext.metadata.labels['app.kubernetes.io/part-of'] === 'Kyma', @@ -378,11 +390,128 @@ export default function KymaModulesList({ ); }; + const getAssociatedResources = () => { + const module = findModule( + selectedModules[chosenModuleIndex]?.name, + selectedModules[chosenModuleIndex]?.channel || + kymaResource?.spec?.channel, + selectedModules[chosenModuleIndex]?.version || + findStatus(selectedModules[chosenModuleIndex]?.name)?.version, + ); + + return module?.spec?.associatedResources || []; + }; + + const getNumberOfResources = async (kind, group, version) => { + const url = + group === 'v1' + ? '/api/v1' + : `/apis/${group}/${version}/${pluralize(kind.toLowerCase())}`; + + try { + const response = await fetch(url); + const json = await response.json(); + return json.items.length; + } catch (e) { + console.warn(e); + return 'Error'; + } + }; + + const handleItemClick = async (kind, group, version) => { + const isNamespaced = await getScope(group, version, kind); + const path = `${pluralize(kind.toLowerCase())}`; + const link = isNamespaced + ? namespaceUrl(path, { namespace: '-all-' }) + : clusterUrl(path); + + navigate(link); + }; + + const fetchResourceCounts = async () => { + const resources = getAssociatedResources(); + const counts = {}; + for (const resource of resources) { + const count = await getNumberOfResources( + resource.kind, + resource.group, + resource.version, + ); + counts[ + `${resource.kind}-${resource.group}-${resource.version}` + ] = count; + } + return counts; + }; + + const [resourceCounts, setResourceCounts] = useState({}); + + useEffect(() => { + const fetchCounts = async () => { + const counts = await fetchResourceCounts(); + setResourceCounts(counts); + }; + + fetchCounts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chosenModuleIndex]); + + const checkIfAssociatedResourceLeft = () => { + const resources = getAssociatedResources(); + for (const resource of resources) { + if ( + resourceCounts[ + `${resource.kind}-${resource.group}-${resource.version}` + ] > 0 + ) { + return true; + } + } + return false; + }; + return ( <> {!detailsOpen && createPortal( 0 && ( + <> + + {t('kyma-modules.associated-resources-warning')} + + + {getAssociatedResources().map(assResource => ( + { + e.preventDefault(); + handleItemClick( + assResource.kind, + assResource.group, + assResource.version, + ); + }} + type="Active" + key={`${assResource.kind}-${assResource.group}-${assResource.version}`} + additionalText={ + resourceCounts[ + `${assResource.kind}-${assResource.group}-${assResource.version}` + ] || t('common.headers.loading') + } + > + {pluralize(assResource?.kind)} + + ))} + + + ) + } resourceTitle={selectedModules[chosenModuleIndex]?.name} deleteFn={() => { selectedModules.splice(chosenModuleIndex, 1); diff --git a/src/shared/hooks/BackendAPI/useGet.js b/src/shared/hooks/BackendAPI/useGet.js index 90f9912910..9badb89c6c 100644 --- a/src/shared/hooks/BackendAPI/useGet.js +++ b/src/shared/hooks/BackendAPI/useGet.js @@ -358,3 +358,15 @@ export const useSingleGet = () => { const fetch = useFetch(); return url => fetch({ relativeUrl: url }); }; + +export const useGetScope = () => { + const fetch = useFetch(); + return async (group, version, kind) => { + const response = await fetch({ + relativeUrl: `/apis/${group}/${version}`, + }); + const openApiSpec = await response.json(); + + return openApiSpec.resources.find(r => r.kind === kind).namespaced; + }; +}; diff --git a/src/shared/hooks/useDeleteResource.js b/src/shared/hooks/useDeleteResource.js index d2e122dfe4..3b0c4deeb9 100644 --- a/src/shared/hooks/useDeleteResource.js +++ b/src/shared/hooks/useDeleteResource.js @@ -173,76 +173,84 @@ export function useDeleteResource({ resourceIsCluster = false, resourceUrl, deleteFn, - }) => ( - performDelete(resource, resourceUrl, deleteFn)} + additionalDeleteInfo, + disableDeleteButton = false, + }) => { + return ( + performDelete(resource, resourceUrl, deleteFn)} + disabled={disableDeleteButton} + > + {t( + resourceIsCluster + ? 'common.buttons.disconnect' + : 'common.buttons.delete', + )} + , + , + ]} + onClose={closeDeleteDialog} + > + - {t( - resourceIsCluster - ? 'common.buttons.disconnect' - : 'common.buttons.delete', + + {t( + resourceIsCluster + ? 'common.delete-dialog.disconnect-message' + : 'common.delete-dialog.delete-message', + { + type: prettifiedResourceName, + name: resourceTitle || resource?.metadata?.name, + }, + )} + + {additionalDeleteInfo && ( + {additionalDeleteInfo} )} - , - , - ]} - onClose={closeDeleteDialog} - > - - - {t( - resourceIsCluster - ? 'common.delete-dialog.disconnect-message' - : 'common.delete-dialog.delete-message', - { - type: prettifiedResourceName, - name: resourceTitle || resource?.metadata?.name, - }, + {!forceConfirmDelete && ( + setDontConfirmDelete(prevState => !prevState)} + text={t('common.delete-dialog.delete-confirm')} + /> )} - - {!forceConfirmDelete && ( - setDontConfirmDelete(prevState => !prevState)} - text={t('common.delete-dialog.delete-confirm')} - /> - )} - {dontConfirmDelete && !forceConfirmDelete && ( - - {t('common.delete-dialog.information')} - - )} - - - ); + {dontConfirmDelete && !forceConfirmDelete && ( + + {t('common.delete-dialog.information')} + + )} + + + ); + }; return [DeleteMessageBox, handleResourceDelete]; }