diff --git a/packages/api-v4/.changeset/pr-11368-added-1733420616390.md b/packages/api-v4/.changeset/pr-11368-added-1733420616390.md new file mode 100644 index 00000000000..55919e94eeb --- /dev/null +++ b/packages/api-v4/.changeset/pr-11368-added-1733420616390.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Tags to `KubeNodePoolResponse` and `CreateNodePoolData` ([#11368](https://github.com/linode/manager/pull/11368)) diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index 262db13dfc0..6642fcec895 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -23,6 +23,7 @@ export interface KubeNodePoolResponse { count: number; id: number; nodes: PoolNodeResponse[]; + tags: string[]; type: string; autoscaler: AutoscaleSettings; disk_encryption?: EncryptionStatus; // @TODO LDE: remove optionality once LDE is fully rolled out @@ -42,6 +43,7 @@ export interface CreateNodePoolData { export interface UpdateNodePoolData { autoscaler: AutoscaleSettings; count: number; + tags: string[]; } export interface AutoscaleSettings { diff --git a/packages/manager/.changeset/pr-11368-added-1733415278919.md b/packages/manager/.changeset/pr-11368-added-1733415278919.md new file mode 100644 index 00000000000..bafa6dfcf3d --- /dev/null +++ b/packages/manager/.changeset/pr-11368-added-1733415278919.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Node Pool Tags to LKE Cluster details page ([#11368](https://github.com/linode/manager/pull/11368)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 4d4f89c60cb..9557c469128 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -860,6 +860,72 @@ describe('LKE cluster updates', () => { }); }); + it('can add and delete node pool tags', () => { + const mockCluster = kubernetesClusterFactory.build({ + k8s_version: latestKubernetesVersion, + }); + + const mockNodePoolNoTags = nodePoolFactory.build({ + id: 1, + type: 'g6-dedicated-4', + }); + + const mockNodePoolWithTags = { + ...mockNodePoolNoTags, + tags: ['test-tag'], + }; + + mockGetCluster(mockCluster).as('getCluster'); + mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( + 'getNodePoolsNoTags' + ); + mockGetKubernetesVersions().as('getVersions'); + mockUpdateNodePool(mockCluster.id, mockNodePoolWithTags).as('addTag'); + mockGetDashboardUrl(mockCluster.id); + mockGetApiEndpoints(mockCluster.id); + + cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); + cy.wait(['@getCluster', '@getNodePoolsNoTags', '@getVersions']); + + cy.get(`[data-qa-node-pool-id="${mockNodePoolNoTags.id}"]`).within(() => { + ui.button.findByTitle('Add a tag').should('be.visible').click(); + + cy.findByLabelText('Create or Select a Tag') + .should('be.visible') + .type(`${mockNodePoolWithTags.tags[0]}`); + + ui.autocompletePopper + .findByTitle(`Create "${mockNodePoolWithTags.tags[0]}"`) + .scrollIntoView() + .should('be.visible') + .click(); + }); + + mockGetClusterPools(mockCluster.id, [mockNodePoolWithTags]).as( + 'getNodePoolsWithTags' + ); + + cy.wait(['@addTag', '@getNodePoolsWithTags']); + + mockUpdateNodePool(mockCluster.id, mockNodePoolNoTags).as('deleteTag'); + mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( + 'getNodePoolsNoTags' + ); + + // Delete the newly added node pool tag. + cy.get(`[data-qa-tag="${mockNodePoolWithTags.tags[0]}"]`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-delete-tag="true"]').should('be.visible').click(); + }); + + cy.wait(['@deleteTag', '@getNodePoolsNoTags']); + + cy.get(`[data-qa-tag="${mockNodePoolWithTags.tags[0]}"]`).should( + 'not.exist' + ); + }); + describe('LKE cluster updates for DC-specific prices', () => { /* * - Confirms node pool resize UI flow using mocked API responses. diff --git a/packages/manager/src/factories/kubernetesCluster.ts b/packages/manager/src/factories/kubernetesCluster.ts index 9680a530b95..5d5d3dcf3bd 100644 --- a/packages/manager/src/factories/kubernetesCluster.ts +++ b/packages/manager/src/factories/kubernetesCluster.ts @@ -29,6 +29,7 @@ export const nodePoolFactory = Factory.Sync.makeFactory({ disk_encryption: 'enabled', id: Factory.each((id) => id), nodes: kubeLinodeFactory.buildList(3), + tags: [], type: 'g6-standard-1', }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index 46a8512f97f..66f7e915a47 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -21,6 +21,7 @@ import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; interface Props { autoscaler: AutoscaleSettings; + clusterId: number; encryptionStatus: EncryptionStatus | undefined; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; @@ -30,12 +31,14 @@ interface Props { openRecycleAllNodesDialog: (poolId: number) => void; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; poolId: number; + tags: string[]; typeLabel: string; } export const NodePool = (props: Props) => { const { autoscaler, + clusterId, encryptionStatus, handleClickResize, isOnlyNodePool, @@ -45,6 +48,7 @@ export const NodePool = (props: Props) => { openRecycleAllNodesDialog, openRecycleNodeDialog, poolId, + tags, typeLabel, } = props; @@ -134,10 +138,12 @@ export const NodePool = (props: Props) => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx index 89dc3d25b72..7c6bf596049 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx @@ -104,7 +104,7 @@ export const NodePoolsDisplay = (props: Props) => { {poolsError && } {_pools?.map((thisPool) => { - const { disk_encryption, id, nodes } = thisPool; + const { disk_encryption, id, nodes, tags } = thisPool; const thisPoolType = types?.find( (thisType) => thisType.id === thisPool.type @@ -131,12 +131,14 @@ export const NodePoolsDisplay = (props: Props) => { setIsRecycleNodeOpen(true); }} autoscaler={thisPool.autoscaler} + clusterId={clusterID} encryptionStatus={disk_encryption} handleClickResize={handleOpenResizeDrawer} isOnlyNodePool={pools?.length === 1} key={id} nodes={nodes ?? []} poolId={thisPool.id} + tags={tags} typeLabel={typeLabel} /> ); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts index 847e78b6ba2..62a215f48cc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts @@ -1,4 +1,4 @@ -import { Typography } from '@linode/ui'; +import { Box, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import VerticalDivider from 'src/assets/icons/divider-vertical.svg'; @@ -25,5 +25,38 @@ export const StyledVerticalDivider = styled(VerticalDivider, { export const StyledTypography = styled(Typography, { label: 'StyledTypography', })(({ theme }) => ({ - margin: `0 0 0 ${theme.spacing()}`, + margin: `0 ${theme.spacing(2)} 0 ${theme.spacing()}`, + [theme.breakpoints.down('md')]: { + padding: theme.spacing(), + }, +})); + +export const StyledNotEncryptedBox = styled(Box, { + label: 'StyledNotEncryptedBox', +})(({ theme }) => ({ + alignItems: 'center', + display: 'flex', + margin: `0 ${theme.spacing(2)} 0 ${theme.spacing()}`, +})); + +export const StyledPoolInfoBox = styled(Box, { + label: 'StyledPoolInfoBox', +})(() => ({ + display: 'flex', + width: '50%', +})); + +export const StyledTableFooter = styled(Box, { + label: 'StyledTableFooter', +})(({ theme }) => ({ + alignItems: 'center', + background: theme.bg.bgPaper, + display: 'flex', + justifyContent: 'space-between', + padding: `0 ${theme.spacing(2)}`, + [theme.breakpoints.down('md')]: { + display: 'block', + flexDirection: 'column', + paddingBottom: theme.spacing(), + }, })); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index f2a3c863165..33b73495240 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -4,17 +4,21 @@ import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; import { linodeFactory } from 'src/factories/linodes'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NodeTable, Props, encryptionStatusTestId } from './NodeTable'; +import { NodeTable, encryptionStatusTestId } from './NodeTable'; + +import type { Props } from './NodeTable'; const mockLinodes = linodeFactory.buildList(3); const mockKubeNodes = kubeLinodeFactory.buildList(3); const props: Props = { + clusterId: 1, encryptionStatus: 'enabled', nodes: mockKubeNodes, openRecycleNodeDialog: vi.fn(), poolId: 1, + tags: [], typeLabel: 'Linode 2G', }; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index 2bf57d29baf..e19ee92496f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -1,4 +1,5 @@ import { Box, TooltipIcon, Typography } from '@linode/ui'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import Lock from 'src/assets/icons/lock.svg'; @@ -12,14 +13,22 @@ import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; -import { TableFooter } from 'src/components/TableFooter'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TagCell } from 'src/components/TagCell/TagCell'; +import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { NodeRow as _NodeRow } from './NodeRow'; -import { StyledTypography, StyledVerticalDivider } from './NodeTable.styles'; +import { + StyledNotEncryptedBox, + StyledPoolInfoBox, + StyledTableFooter, + StyledTypography, + StyledVerticalDivider, +} from './NodeTable.styles'; import type { NodeRow } from './NodeRow'; import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; @@ -27,10 +36,12 @@ import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; import type { LinodeWithMaintenance } from 'src/utilities/linodes'; export interface Props { + clusterId: number; encryptionStatus: EncryptionStatus | undefined; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; poolId: number; + tags: string[]; typeLabel: string; } @@ -38,10 +49,12 @@ export const encryptionStatusTestId = 'encryption-status-fragment'; export const NodeTable = React.memo((props: Props) => { const { + clusterId, encryptionStatus, nodes, openRecycleNodeDialog, poolId, + tags, typeLabel, } = props; @@ -50,6 +63,25 @@ export const NodeTable = React.memo((props: Props) => { isDiskEncryptionFeatureEnabled, } = useIsDiskEncryptionFeatureEnabled(); + const { mutateAsync: updateNodePool } = useUpdateNodePoolMutation( + clusterId, + poolId + ); + + const updateTags = React.useCallback( + (tags: string[]) => { + return updateNodePool({ tags }).catch((e) => + enqueueSnackbar( + getAPIErrorOrDefault(e, 'Error updating tags')[0].reason, + { + variant: 'error', + } + ) + ); + }, + [updateNodePool] + ); + const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? [])); return ( @@ -131,33 +163,31 @@ export const NodeTable = React.memo((props: Props) => { })} - - - - {isDiskEncryptionFeatureEnabled && - encryptionStatus !== undefined ? ( - - Pool ID {poolId} - - - - ) : ( - Pool ID {poolId} - )} - - - + + + {isDiskEncryptionFeatureEnabled && + encryptionStatus !== undefined ? ( + + + Pool ID {poolId} + + + + + ) : ( + Pool ID {poolId} + )} + + + ) : encryptionStatus === 'disabled' ? ( <> - - - Not Encrypted - - {tooltipText ? : null} + + + Not Encrypted + {tooltipText ? : null} + ) : null; };