diff --git a/public/locales/en.json b/public/locales/en.json index 4d2bf0d3..a1eba822 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -34,7 +34,15 @@ "tableHeaderName": "Name", "tableHeaderCreated": "Created", "tableHeaderSynced": "Synced", - "tableHeaderReady": "Ready" + "tableHeaderReady": "Ready", + "tableHeaderDelete": "Delete", + "deleteAction": "Delete resource", + "deleteDialogTitle": "Delete resource", + "advancedOptions": "Advanced options", + "forceDeletion": "Force deletion", + "forceWarningLine": "Force deletion removes finalizers. Related resources may not be deleted and cleanup may be skipped.", + "deleteStarted": "Deleting {{resourceName}} initialized", + "actionColumnHeader": " " }, "ProvidersConfig": { "tableHeaderProvider": "Provider", diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 1fc31a74..5b91de95 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -8,19 +8,28 @@ import { Toolbar, ToolbarSpacer, } from '@ui5/webcomponents-react'; -import { useApiResource } from '../../lib/api/useApiResource'; +import { useApiResource, useApiResourceMutation } from '../../lib/api/useApiResource'; import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listManagedResources'; import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo'; import IllustratedError from '../Shared/IllustratedError'; -import '@ui5/webcomponents-icons/dist/sys-enter-2'; -import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { resourcesInterval } from '../../lib/shared/constants'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import StatusFilter from '../Shared/StatusFilter/StatusFilter.tsx'; import { ResourceStatusCell } from '../Shared/ResourceStatusCell.tsx'; import { Resource } from '../../utils/removeManagedFieldsAndFilterData.ts'; +import { ManagedResourceItem } from '../../lib/shared/types.ts'; +import { ManagedResourceDeleteDialog } from '../Dialogs/ManagedResourceDeleteDialog.tsx'; +import { RowActionsMenu } from './ManagedResourcesActionMenu.tsx'; +import { useToast } from '../../context/ToastContext.tsx'; +import { + DeleteManagedResourceType, + DeleteMCPManagedResource, + PatchResourceForForceDeletion, + PatchResourceForForceDeletionBody, +} from '../../lib/api/types/crate/deleteResource'; +import { useResourcePluralNames } from '../../hooks/useResourcePluralNames'; interface CellData { cell: { @@ -46,15 +55,32 @@ type ResourceRow = { export function ManagedResources() { const { t } = useTranslation(); + const toast = useToast(); + const [pendingDeleteItem, setPendingDeleteItem] = useState(null); const { data: managedResources, error, isLoading, } = useApiResource(ManagedResourcesRequest, { - refreshInterval: resourcesInterval, // Resources are quite expensive to fetch, so we refresh every 30 seconds + refreshInterval: resourcesInterval, }); + const { getPluralKind, isLoading: isLoadingPluralNames, error: pluralNamesError } = useResourcePluralNames(); + + const resourceName = pendingDeleteItem?.metadata?.name ?? ''; + const apiVersion = pendingDeleteItem?.apiVersion ?? ''; + const pluralKind = pendingDeleteItem ? getPluralKind(pendingDeleteItem.kind) : ''; + const namespace = pendingDeleteItem?.metadata?.namespace ?? ''; + + const { trigger: deleteTrigger } = useApiResourceMutation( + DeleteMCPManagedResource(apiVersion, pluralKind, resourceName, namespace), + ); + + const { trigger: patchTrigger } = useApiResourceMutation( + PatchResourceForForceDeletion(apiVersion, pluralKind, resourceName, namespace), + ); + const columns: AnalyticalTableColumnDefinition[] = useMemo( () => [ { @@ -109,10 +135,24 @@ export function ManagedResources() { width: 75, accessor: 'yaml', disableFilters: true, - Cell: (cellData: CellData) => - cellData.cell.row.original?.item ? ( + Cell: (cellData: CellData) => { + return cellData.cell.row.original?.item ? ( - ) : undefined, + ) : undefined; + }, + }, + { + Header: t('ManagedResources.actionColumnHeader'), + hAlign: 'Center', + width: 60, + disableFilters: true, + Cell: (cellData: CellData) => { + const item = cellData.cell.row.original?.item as ManagedResourceItem; + + return cellData.cell.row.original?.item ? ( + + ) : undefined; + }, }, ], [t], @@ -141,11 +181,34 @@ export function ManagedResources() { }), ) ?? []; + const openDeleteDialog = (item: ManagedResourceItem) => { + setPendingDeleteItem(item); + }; + + const handleDeletionConfirmed = async (item: ManagedResourceItem, force: boolean) => { + toast.show(t('ManagedResources.deleteStarted', { resourceName: item.metadata.name })); + + try { + await deleteTrigger(); + + if (force) { + await patchTrigger(PatchResourceForForceDeletionBody); + } + } catch (_) { + // Ignore errors - will be handled by the mutation hook + } finally { + setPendingDeleteItem(null); + } + }; + + const combinedError = error || pluralNamesError; + const combinedLoading = isLoading || isLoadingPluralNames; + return ( <> - {error && } + {combinedError && } - {!error && ( + {!combinedError && ( } > - + <> + + + setPendingDeleteItem(null)} + onDeletionConfirmed={handleDeletionConfirmed} + /> + )} diff --git a/src/components/ControlPlane/ManagedResourcesActionMenu.tsx b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx new file mode 100644 index 00000000..91d8e7a1 --- /dev/null +++ b/src/components/ControlPlane/ManagedResourcesActionMenu.tsx @@ -0,0 +1,44 @@ +import { FC, useRef, useState } from 'react'; +import { Button, Menu, MenuItem, MenuDomRef } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import { ManagedResourceItem } from '../../lib/shared/types'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import type { Ui5CustomEvent, ButtonDomRef } from '@ui5/webcomponents-react'; + +interface RowActionsMenuProps { + item: ManagedResourceItem; + onOpen: (item: ManagedResourceItem) => void; +} + +export const RowActionsMenu: FC = ({ item, onOpen }) => { + const { t } = useTranslation(); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleOpenerClick = (e: Ui5CustomEvent) => { + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as unknown as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( + <> + + + + + + ); +}; diff --git a/src/components/Helper/getPluralKind.ts b/src/components/Helper/getPluralKind.ts new file mode 100644 index 00000000..35fdc7fb --- /dev/null +++ b/src/components/Helper/getPluralKind.ts @@ -0,0 +1,6 @@ +import { ManagedResourceItem } from '../../lib/shared/types'; + +export const getPluralKind = (item: ManagedResourceItem, kindMapping: Record): string => { + const singularKind = (item?.kind ?? '').toLowerCase(); + return kindMapping[singularKind] ?? ''; +}; diff --git a/src/hooks/useResourcePluralNames.ts b/src/hooks/useResourcePluralNames.ts new file mode 100644 index 00000000..7f798abf --- /dev/null +++ b/src/hooks/useResourcePluralNames.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; +import { useCRDItemsMapping } from '../lib/api/useApiResource'; +import { resourcesInterval } from '../lib/shared/constants'; + +export const useResourcePluralNames = () => { + const { + data: kindMapping, + isLoading, + error, + } = useCRDItemsMapping({ + refreshInterval: resourcesInterval, + }); + + const getPluralKind = useMemo(() => { + return (singularKind: string): string => { + if (!kindMapping) return ''; + return kindMapping[singularKind.toLowerCase()] ?? ''; + }; + }, [kindMapping]); + + return { + getPluralKind, + isLoading, + error, + }; +}; diff --git a/src/lib/api/types/crate/deleteResource.ts b/src/lib/api/types/crate/deleteResource.ts new file mode 100644 index 00000000..178bd85f --- /dev/null +++ b/src/lib/api/types/crate/deleteResource.ts @@ -0,0 +1,53 @@ +import { Resource } from '../resource'; + +export interface DeleteManagedResourceType { + name: string; + namespace: string; +} + +export const PatchResourceForForceDeletionBody = { + metadata: { + finalizers: null, + }, +}; + +export const PatchResourceForForceDeletion = ( + apiVersion: string, + pluralKind: string, + resourceName: string, + namespace?: string, +): Resource => { + // If namespace is provided, use namespaced path; otherwise, use cluster-scoped path + const basePath = `/apis/${apiVersion}`; + + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + + return { + path: path, + method: 'PATCH', + jq: undefined, + body: undefined, + }; +}; + +export const DeleteMCPManagedResource = ( + apiVersion: string, + pluralKind: string, + resourceName: string, + namespace?: string, +): Resource => { + // If namespace is provided, use namespaced path; otherwise, use cluster-scoped path + const basePath = `/apis/${apiVersion}`; + + const path = namespace + ? `${basePath}/namespaces/${namespace}/${pluralKind}/${resourceName}` + : `${basePath}/${pluralKind}/${resourceName}`; + return { + path: path, + method: 'DELETE', + jq: undefined, + body: undefined, + }; +}; diff --git a/src/lib/api/useApiResource.ts b/src/lib/api/useApiResource.ts index 63d846d4..a2997804 100644 --- a/src/lib/api/useApiResource.ts +++ b/src/lib/api/useApiResource.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState, useRef } from 'react'; +import { useContext, useEffect, useState, useRef, useMemo } from 'react'; import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'; import { fetchApiServerJson } from './fetch'; import { ApiConfigContext } from '../../components/Shared/k8s'; @@ -39,6 +39,37 @@ export const useApiResource = ( }; }; +export const useCRDItemsMapping = (config?: SWRConfiguration) => { + const apiConfig = useContext(ApiConfigContext); + const { data, error, isValidating, isLoading } = useSWR( + CRDRequest.path === null ? null : [CRDRequest.path, apiConfig], + ([path, apiConfig]) => + fetchApiServerJson(path, apiConfig, CRDRequest.jq, CRDRequest.method, CRDRequest.body), + config, + ); + + const kindMapping = useMemo(() => { + if (!data?.items) { + return {}; + } + + return data.items.reduce((kinds: Record, item) => { + const { singular, plural } = item.spec.names as { singular?: string; plural?: string; kind: string }; + if (singular && plural) { + kinds[singular] = plural; + } + return kinds; + }, {}); + }, [data]); + + return { + data: kindMapping, + error, + isValidating, + isLoading, + }; +}; + export const useProvidersConfigResource = (config?: SWRConfiguration) => { const apiConfig = useContext(ApiConfigContext); const { data, error, isValidating } = useSWR( diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index 798a30ec..9e853d9f 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -31,6 +31,13 @@ export interface ProviderConfigItem { count: string; users: string; }; + spec: { + names: { + kind: string; + plural: string; + singular: string; + }; + }; apiVersion?: string; } @@ -51,7 +58,9 @@ export type ManagedResourceItem = { metadata: { name: string; creationTimestamp: string; + resourceVersion: string; labels: []; + namespace?: string; }; apiVersion?: string; spec?: {