From d3b3be94ca9e34fb54ca05a87fe47def5b4c81c9 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:27:11 +0200 Subject: [PATCH 01/54] change: [UIE-8820] - Refactor permission/entities truncation (#12825) * browser consistency small fix * save progress * improve script * save progress * save progress * debouncing and sorting * cleanup and testing * post rebase fix * even moar cleanup * Added changeset: Refactor permission/entities truncation * changeset correctiom * children should be a dep * feedback @mjac0bs * fix annoying test complaint --- .../pr-12825-tech-stories-1757923303722.md | 5 + .../features/IAM/truncated-list.spec.tsx | 135 +++++++++ .../AssignedRolesTable/AssignedRolesTable.tsx | 1 + .../Shared/Permissions/Permissions.style.ts | 33 +-- .../Shared/Permissions/Permissions.test.tsx | 102 +------ .../IAM/Shared/Permissions/Permissions.tsx | 74 ++--- .../IAM/Shared/TruncatedList.styles.ts | 39 +++ .../IAM/Shared/TruncatedList.test.tsx | 55 ++++ .../src/features/IAM/Shared/TruncatedList.tsx | 270 ++++++++++++++++++ .../IAM/Users/UserRoles/AssignedEntities.tsx | 194 +++++-------- .../IAM/hooks/useCalculateHiddenItems.ts | 65 ----- 11 files changed, 603 insertions(+), 370 deletions(-) create mode 100644 packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md create mode 100644 packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx create mode 100644 packages/manager/src/features/IAM/Shared/TruncatedList.styles.ts create mode 100644 packages/manager/src/features/IAM/Shared/TruncatedList.test.tsx create mode 100644 packages/manager/src/features/IAM/Shared/TruncatedList.tsx delete mode 100644 packages/manager/src/features/IAM/hooks/useCalculateHiddenItems.ts diff --git a/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md b/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md new file mode 100644 index 00000000000..280f1c7085d --- /dev/null +++ b/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Refactor IAM permission/entities truncation utilities ([#12825](https://github.com/linode/manager/pull/12825)) diff --git a/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx b/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx new file mode 100644 index 00000000000..db669757700 --- /dev/null +++ b/packages/manager/cypress/component/features/IAM/truncated-list.spec.tsx @@ -0,0 +1,135 @@ +import { TruncatedList } from '@src/features/IAM/Shared/TruncatedList'; +import * as React from 'react'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { componentTests } from 'support/util/components'; + +const mockPermissions = [ + 'list_linode_firewalls', + 'list_firewall_devices', + 'view_linode_disk', + 'list_billing_payments', + 'list_billing_invoices', + 'list_payment_methods', + 'view_billing_invoice', + 'list_invoice_items', + 'view_payment_method', + 'view_billing_payment', +]; + +const permissionList = mockPermissions.map((permission) => ( +
{permission}
+)); + +componentTests('TruncatedList', (mount) => { + describe('wide breakpoint', () => { + beforeEach(() => { + cy.viewport(1600, 600); + }); + + it('renders all list items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + }); + }); + + describe('medium breakpoint', () => { + beforeEach(() => { + cy.viewport(600, 600); + }); + + it('renders a truncated list with the correct number of items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.slice(0, 7).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Expand (+3)').should('be.visible').click(); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Hide').should('be.visible').click(); + mockPermissions.slice(0, 7).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.get('[class*="visible-overflow-button"]').should('not.exist'); + }); + + it('allows rendering a custom overflow button', () => { + const handleClick = cy.spy().as('handleClick'); + + mount( + ( + + )} + dataTestId="permission" + > + {permissionList} + + ); + cy.findByRole('button', { name: 'Custom Overflow Button' }) + .should('be.visible') + .click(); + cy.get('@handleClick').should('have.been.called'); + }); + + it('floats the overflow button to the right with justifyOverflowButtonRight', () => { + mount( + + {permissionList} + + ); + + cy.get('[class*="visible-overflow-button"]') + .should('be.visible') + .should('have.css', 'justify-content', 'end'); + }); + }); + + describe('small breakpoint', () => { + beforeEach(() => { + cy.viewport(400, 600); + }); + + it('renders a truncated list with the correct number of items', () => { + mount( + {permissionList} + ); + cy.findAllByTestId('permission-list-item').should( + 'have.length', + mockPermissions.length + ); + mockPermissions.slice(0, 4).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Expand (+5)').should('be.visible').click(); + mockPermissions.forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + cy.findByText('Hide').should('be.visible').click(); + mockPermissions.slice(0, 4).forEach((permission) => { + cy.findByText(permission).should('be.visible'); + }); + }); + }); + + describe('Accessibility checks', () => { + it('passes aXe accessibility', () => { + mount({permissionList}); + checkComponentA11y(); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index e94b43a3c1a..f4d8e93dff5 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -256,6 +256,7 @@ export const AssignedRolesTable = () => { Description diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts index 31af9d1d319..1958819a40f 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts @@ -1,43 +1,16 @@ -import { Box, Typography } from '@linode/ui'; +import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; export const StyledTitle = styled(Typography, { label: 'StyledTitle' })( ({ theme }) => ({ font: theme.tokens.alias.Typography.Label.Bold.S, - marginBottom: theme.tokens.spacing.S8, + marginBottom: theme.tokens.spacing.S4, }) ); -export const StyledPermissionItem = styled(Typography, { +export const StyledPermissionItem = styled('span', { label: 'StyledPermissionItem', })(({ theme }) => ({ - borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, display: 'inline-block', padding: `0px ${theme.tokens.spacing.S6} ${theme.tokens.spacing.S2}`, })); - -export const StyledContainer = styled('div', { - label: 'StyledContainer', -})(() => ({ - marginLeft: -6, - position: 'relative', -})); - -export const StyledClampedContent = styled('div', { - label: 'StyledClampedContent', -})<{ showAll?: boolean }>(({ showAll }) => ({ - '& p:last-child': { - borderRight: 0, - }, - WebkitBoxOrient: 'vertical', - WebkitLineClamp: showAll ? 'unset' : 2, - display: '-webkit-box', - overflow: 'hidden', -})); - -export const StyledBox = styled(Box, { - label: 'StyledBox', -})(({ theme }) => ({ - font: theme.tokens.alias.Typography.Label.Semibold.Xs, - paddingLeft: theme.tokens.spacing.S6, -})); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx index 93da531ebc0..a98d7d4d9e7 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.test.tsx @@ -1,5 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -20,23 +19,6 @@ const mockPermissionsLong: PermissionType[] = [ 'view_billing_payment', ]; -const mockPermissionsLongExpand: PermissionType[] = [ - 'list_linode_firewalls', - 'list_firewall_devices', - 'view_linode_disk', - 'view_linode_monthly_network_transfer_stats', - 'view_linode_network_transfer', - 'view_linode_stats', - 'view_linode_backup', - 'list_linode_volumes', - 'view_linode', - 'list_linode_nodebalancers', - 'view_linode_monthly_stats', - 'view_linode_config_profile', - 'list_firewall_rules', - 'view_linode_config_profile_interface', -]; - describe('Permissions', () => { it('renders the correct number of permission chips', () => { const { getAllByTestId, getByText } = renderWithTheme( @@ -74,85 +56,3 @@ describe('Permissions', () => { ); }); }); - -describe('useCalculateHiddenItems', () => { - beforeEach(() => { - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - configurable: true, - get() { - if (this.dataset?.testid === 'container') return 40; - return 10; - }, - }); - let permissionIndex = 0; - HTMLElement.prototype.getBoundingClientRect = function () { - const testId = this.dataset?.testid; - if (testId === 'container') { - return { top: 0, bottom: 42, height: 42 } as DOMRect; - } - - if (testId === 'permission') { - const top = Math.floor(permissionIndex / 13) * 20; - permissionIndex++; - return { - top, - bottom: top + 10, - height: 10, - } as DOMRect; - } - return { top: 0, bottom: 10, height: 10 } as DOMRect; - }; - - window.requestAnimationFrame = (cb) => { - cb(Date.now()); - return 0; - }; - window.cancelAnimationFrame = () => {}; - }); - - it('shows correct number of hidden permissions', async () => { - renderWithTheme(); - - const hiddenCount = await screen.findByText('Expand', { exact: false }); - - await waitFor(() => { - expect(hiddenCount).toHaveTextContent('+1'); - }); - }); - - it('shows all permissions when expanded', async () => { - renderWithTheme(); - - const expandButton = await screen.findByText('Expand', { exact: false }); - await userEvent.click(expandButton); - - await waitFor(() => { - const allPermissions = screen.getAllByTestId('permission'); - expect(allPermissions).toHaveLength(mockPermissionsLongExpand.length); - }); - }); - it('toggles between expand and hide', async () => { - renderWithTheme(); - - const expandButton = await screen.findByText('Expand', { exact: false }); - await userEvent.click(expandButton); - - await waitFor(() => { - const hideButton = screen.getByText(/hide/i); - expect(hideButton).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByText(/hide/i)); - - await waitFor(() => { - const expandButton = screen.getByText('Expand', { exact: false }); - expect(expandButton).toBeInTheDocument(); - }); - }); - it('not shows expand button when permissions fit', async () => { - renderWithTheme(); - - const expandButton = screen.queryByText('Expand', { exact: false }); - expect(expandButton).not.toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx index fc7bcea2a89..e83905385be 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx @@ -1,16 +1,10 @@ -import { LinkButton, Typography } from '@linode/ui'; -import { debounce } from '@mui/material'; +import { Typography } from '@linode/ui'; +import { sortByString } from '@linode/utilities'; import { Grid } from '@mui/material'; import * as React from 'react'; -import { useCalculateHiddenItems } from '../../hooks/useCalculateHiddenItems'; -import { - StyledBox, - StyledClampedContent, - StyledContainer, - StyledPermissionItem, - StyledTitle, -} from './Permissions.style'; +import { TruncatedList } from '../TruncatedList'; +import { StyledPermissionItem, StyledTitle } from './Permissions.style'; import type { PermissionType } from '@linode/api-v4/lib/iam/types'; @@ -20,22 +14,9 @@ type Props = { }; export const Permissions = React.memo(({ permissions }: Props) => { - const [showAll, setShowAll] = React.useState(false); - - const { calculateHiddenItems, containerRef, itemRefs, numHiddenItems } = - useCalculateHiddenItems(permissions, showAll); - - const handleResize = React.useMemo( - () => debounce(() => calculateHiddenItems(), 100), - [calculateHiddenItems] - ); - - React.useEffect(() => { - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); + const sortedPermissions = permissions.sort((a, b) => { + return sortByString(a, b, 'asc'); + }); return ( @@ -46,35 +27,18 @@ export const Permissions = React.memo(({ permissions }: Props) => { to understand what access is granted by this role. ) : ( - - - {permissions.map((permission: PermissionType, index: number) => ( - { - itemRefs.current[index] = el; - }} - > - {permission} - - ))} - - - {(numHiddenItems > 0 || showAll) && ( - - { - e.stopPropagation(); - setShowAll(!showAll); - }} - type="button" - > - {showAll ? 'Hide' : `Expand (+${numHiddenItems})`} - - - )} - + ({ + marginLeft: `-${theme.spacingFunction(6)}`, + })} + > + {sortedPermissions.map((permission: PermissionType) => ( + + {permission} + + ))} + )} ); diff --git a/packages/manager/src/features/IAM/Shared/TruncatedList.styles.ts b/packages/manager/src/features/IAM/Shared/TruncatedList.styles.ts new file mode 100644 index 00000000000..ccd4b092572 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/TruncatedList.styles.ts @@ -0,0 +1,39 @@ +import { List } from '@linode/ui'; +import { styled } from '@mui/material/styles'; + +export const StyledTruncatedList = styled(List, { + label: 'StyledTruncatedList', +})(({ theme }) => ({ + margin: 0, + padding: 0, + display: 'flex', + listStyleType: 'none', + flexWrap: 'wrap', + maxHeight: '3.2em', + '&.expanded': { + maxHeight: 'none', + }, + '& .visible-overflow-button': { + flex: 1, + display: 'flex', + justifyContent: 'end', + }, + '& .last-visible-before-overflow': { + position: 'relative', + '&::after': { + content: "'...'", + display: 'inline', + top: 2, + position: 'absolute', + right: -3, + }, + }, + // Show right border on all visible items except the last visible one (@supports is really here to get around our test environment limitations - :has is widely supported) + // - The item does not have the hidden attribute + // - There exists a following visible
  • + '@supports (selector(:has(*)))': { + '& li:not([hidden]):has(~ li:not([hidden])) > span': { + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + }, + }, +})); diff --git a/packages/manager/src/features/IAM/Shared/TruncatedList.test.tsx b/packages/manager/src/features/IAM/Shared/TruncatedList.test.tsx new file mode 100644 index 00000000000..5678cd20162 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/TruncatedList.test.tsx @@ -0,0 +1,55 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TruncatedList } from './TruncatedList'; + +import type { PermissionType } from '@linode/api-v4'; + +const mockPermissions: PermissionType[] = [ + 'list_linode_firewalls', + 'list_firewall_devices', + 'view_linode_disk', +]; + +describe('TruncatedList', () => { + it('renders all items with correct test ids', () => { + const { getAllByTestId } = renderWithTheme( + + {mockPermissions.map((permission) => ( +
    {permission}
    + ))} +
    + ); + + const allListItems = getAllByTestId('permission-list-item'); + expect(allListItems).toHaveLength(mockPermissions.length); + + mockPermissions.forEach((permission, index) => { + expect(allListItems[index]).toHaveTextContent(permission); + }); + }); + + it('uses custom expand and collapse text', async () => { + const { container, getByText } = renderWithTheme( + + {mockPermissions.map((permission) => ( +
    {permission}
    + ))} +
    + ); + + expect(container).toBeInTheDocument(); + const expandText = getByText('Show all permissions (+3)'); + expect(expandText).toBeInTheDocument(); + + await userEvent.click(expandText); + const collapseText = getByText('Hide permissions'); + expect(collapseText).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/TruncatedList.tsx b/packages/manager/src/features/IAM/Shared/TruncatedList.tsx new file mode 100644 index 00000000000..e40c0616003 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/TruncatedList.tsx @@ -0,0 +1,270 @@ +import { Box, LinkButton } from '@linode/ui'; +import React, { useLayoutEffect, useRef } from 'react'; +import { useCallback } from 'react'; +import { debounce } from 'throttle-debounce'; + +import { StyledTruncatedList } from './TruncatedList.styles'; + +import type { SxProps, Theme } from '@mui/material'; + +export interface TruncatedListProps { + addEllipsis?: boolean; + children?: React.ReactNode; + collapseText?: string; + customOverflowButton?: (hiddenItemsCount: number) => React.ReactNode; + dataTestId?: string; + expandText?: string; + justifyOverflowButtonRight?: boolean; + listContainerSx?: SxProps; +} + +type OverflowButtonProps = { + buttonCopy: string; + hiddenItemsCount: number; + onClick: () => void; +}; + +/** + * Checks if a child rectangle is fully contained within a parent rectangle. + */ +const rectFullyContains = (parentRect: DOMRect, childRect: DOMRect) => { + return ( + childRect.top >= parentRect.top && + childRect.bottom <= parentRect.bottom && + childRect.left >= parentRect.left && + childRect.right <= parentRect.right + ); +}; + +export const TruncatedList = (props: TruncatedListProps) => { + const { + addEllipsis = false, + children, + collapseText = 'Hide', + customOverflowButton, + dataTestId, + expandText = 'Expand', + justifyOverflowButtonRight = false, + listContainerSx, + } = props; + const [showAll, setShowAll] = React.useState(false); + + const containerRef = useRef(null); + const expandedRef = useRef(null); + + const OverflowButton = React.memo((props: OverflowButtonProps) => { + const { hiddenItemsCount, onClick, buttonCopy } = props; + + if (customOverflowButton) { + return customOverflowButton(hiddenItemsCount); + } + + return ( + ({ + font: theme.tokens.alias.Typography.Label.Semibold.Xs, + paddingLeft: theme.tokens.spacing.S6, + })} + > + {buttonCopy} {!showAll && `(+${hiddenItemsCount})`} + + ); + }); + + const handleToggle = () => { + setShowAll(!showAll); + }; + + const truncate = useCallback(() => { + if (!containerRef.current) { + return; + } + + const listItems = Array.from( + containerRef.current.children + ) as HTMLElement[]; + + containerRef.current.style.overflow = showAll ? 'visible' : 'hidden'; + + for (let i = 0; i < listItems.length; ++i) { + listItems[i].hidden = i % 2 === 0; + if (i % 2 === 0 && justifyOverflowButtonRight) { + listItems[i].classList.remove('visible-overflow-button'); + } else if (addEllipsis) { + listItems[i].classList.remove('last-visible-before-overflow'); + } + } + + if (listItems.length === 1) { + return; + } + + const itemEl = listItems[listItems.length - 2]; + if ( + rectFullyContains( + containerRef.current.getBoundingClientRect(), + itemEl.getBoundingClientRect() + ) + ) { + return; + } + + const numBreakpoints = Math.floor((listItems.length - 1) / 2); + let left = 0; + let right = numBreakpoints - 1; + let numItemsShowingWithTruncation: null | number = null; + + while (left <= right) { + const middle = Math.floor((left + right) / 2); + + for (let i = 0; i < middle; i += 1) { + listItems[i * 2 + 1].hidden = false; + } + for (let i = middle; i < numBreakpoints; i += 1) { + listItems[i * 2 + 1].hidden = true; + } + + const breakpointEl = listItems[middle * 2]; + breakpointEl.hidden = false; + + if ( + rectFullyContains( + containerRef.current.getBoundingClientRect(), + breakpointEl.getBoundingClientRect() + ) + ) { + numItemsShowingWithTruncation = middle; + left = middle + 1; + } else { + right = middle - 1; + } + + breakpointEl.hidden = true; + } + + if (numItemsShowingWithTruncation === null) { + return; + } + + for (let i = 0; i < numItemsShowingWithTruncation; i += 1) { + listItems[i * 2 + 1].hidden = false; + } + for (let i = numItemsShowingWithTruncation; i < numBreakpoints; i += 1) { + listItems[i * 2 + 1].hidden = true; + } + + const breakpointEl = listItems[numItemsShowingWithTruncation * 2]; + breakpointEl.hidden = false; + + if (justifyOverflowButtonRight) { + breakpointEl.classList.add('visible-overflow-button'); // Add class to the visible one + } + + if (numItemsShowingWithTruncation > 0) { + const lastVisibleContentIndex = + (numItemsShowingWithTruncation - 1) * 2 + 1; + const lastVisibleContentEl = listItems[lastVisibleContentIndex]; + if (lastVisibleContentEl && addEllipsis) { + lastVisibleContentEl.classList.add('last-visible-before-overflow'); + } + } + }, [showAll, justifyOverflowButtonRight, addEllipsis]); + + useLayoutEffect(() => { + const container = showAll ? expandedRef.current : containerRef.current; + if (!container) return; + + let isInitialObservation = true; + + const resizeObserver = new ResizeObserver( + debounce(150, () => { + if (isInitialObservation) { + isInitialObservation = false; + truncate(); + return; + } + + // Only reset to collapsed on actual resize events, not initial observation + if (showAll) { + setShowAll(false); + } + truncate(); + }) + ); + + resizeObserver.observe(container); + truncate(); + + return () => { + resizeObserver.unobserve(container); + }; + }, [truncate, showAll, children]); + + const childArray = React.Children.toArray(children); + + if (showAll) { + return ( + + {childArray.map((item, i) => ( +
  • + {item} +
  • + ))} +
  • + +
  • + + ); + } + + const items = childArray.map((item, i) => ( + + +
  • + {item} +
  • +
    + )); + + return ( + + + {showAll ? null : ( + <> + {items} + + + )} + + + ); +}; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 8559ad920e8..f1c53a8b555 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -1,8 +1,9 @@ import { Box, Button, Chip, CloseIcon, Tooltip } from '@linode/ui'; -import { debounce, useTheme } from '@mui/material'; +import { sortByString } from '@linode/utilities'; +import { useTheme } from '@mui/material'; import * as React from 'react'; -import { useCalculateHiddenItems } from '../../hooks/useCalculateHiddenItems'; +import { TruncatedList } from '../../Shared/TruncatedList'; import type { CombinedEntity, ExtendedRoleView } from '../../Shared/types'; import type { AccountRoleType, EntityRoleType } from '@linode/api-v4'; @@ -20,28 +21,6 @@ export const AssignedEntities = ({ }: Props) => { const theme = useTheme(); - const { calculateHiddenItems, containerRef, itemRefs, numHiddenItems } = - useCalculateHiddenItems(role.entity_names!); - - const handleResize = React.useMemo( - () => debounce(() => calculateHiddenItems(), 250), - [calculateHiddenItems] - ); - - React.useEffect(() => { - // Double RAF for good measure - see https://stackoverflow.com/questions/44145740/how-does-double-requestanimationframe-work - const rafId = requestAnimationFrame(() => { - requestAnimationFrame(() => calculateHiddenItems()); - }); - - window.addEventListener('resize', handleResize); - - return () => { - cancelAnimationFrame(rafId); - window.removeEventListener('resize', handleResize); - }; - }, [calculateHiddenItems, handleResize]); - const combinedEntities: CombinedEntity[] = React.useMemo( () => role.entity_names!.map((name, index) => ({ @@ -51,114 +30,91 @@ export const AssignedEntities = ({ [role.entity_names, role.entity_ids] ); - const isLastVisibleItem = React.useCallback( - (index: number) => { - return combinedEntities.length - numHiddenItems - 1 === index; - }, - [combinedEntities.length, numHiddenItems] - ); + const sortedEntities = combinedEntities?.sort((a, b) => { + return sortByString(a.name, b.name, 'asc'); + }); - const items = combinedEntities?.map( - (entity: CombinedEntity, index: number) => ( - { - itemRefs.current[index] = el; - }} - sx={{ - display: 'inline', - marginRight: - numHiddenItems > 0 && isLastVisibleItem(index) - ? theme.tokens.spacing.S16 - : theme.tokens.spacing.S8, - }} + const items = sortedEntities?.map((entity: CombinedEntity) => ( + + 30 ? entity.name : null} > - 30 ? entity.name : null} - > - } - label={ - entity.name.length > 30 - ? `${entity.name.slice(0, 20)}...` - : entity.name - } - onDelete={() => onRemoveAssignment(entity, role)} + } + label={ + entity.name.length > 30 + ? `${entity.name.slice(0, 20)}...` + : entity.name + } + onDelete={() => onRemoveAssignment(entity, role)} + sx={{ + backgroundColor: + theme.name === 'light' + ? theme.tokens.color.Ultramarine[20] + : theme.tokens.color.Neutrals.Black, + color: theme.tokens.alias.Content.Text.Primary.Default, + '& .MuiChip-deleteIcon': { + color: theme.tokens.alias.Content.Text.Primary.Default, + }, + position: 'relative', + }} + /> + + + )); + + return ( + + ( + 0 && isLastVisibleItem(index) - ? '"..."' - : '""', - position: 'absolute', - top: 0, - right: -16, - width: 14, - }, + top: 2, }} - /> - - - ) - ); - - return ( - - + + + + + )} + justifyOverflowButtonRight + listContainerSx={{ + width: '100%', overflow: 'hidden', - height: 24, + maxHeight: 24, }} > {items} - - {numHiddenItems > 0 && ( - - - - - - )} + ); }; diff --git a/packages/manager/src/features/IAM/hooks/useCalculateHiddenItems.ts b/packages/manager/src/features/IAM/hooks/useCalculateHiddenItems.ts deleted file mode 100644 index b3a13dc051c..00000000000 --- a/packages/manager/src/features/IAM/hooks/useCalculateHiddenItems.ts +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useLayoutEffect, useRef, useState } from 'react'; - -import type { PermissionType } from '@linode/api-v4'; - -/** - * Custom hook to calculate hidden items - */ -export const useCalculateHiddenItems = ( - items: PermissionType[] | string[], - showAll?: boolean -) => { - const [numHiddenItems, setNumHiddenItems] = useState(0); - const containerRef = useRef(null); - const itemRefs = useRef<(HTMLDivElement | HTMLSpanElement)[]>([]); - - const calculateHiddenItems = React.useCallback(() => { - if (showAll) { - setNumHiddenItems(0); - return; - } - - if (!containerRef.current || !itemRefs.current) { - return; - } - - const containerBottom = containerRef.current.getBoundingClientRect().bottom; - - const itemsArray = Array.from(itemRefs.current); - - const firstHiddenIndex = itemsArray.findIndex( - (item: HTMLDivElement | HTMLSpanElement) => { - if (!item) { - return false; - } - const rect = item.getBoundingClientRect(); - return rect.top >= containerBottom; - } - ); - - const numHiddenItems = - firstHiddenIndex !== -1 ? itemsArray.length - firstHiddenIndex : 0; - - setNumHiddenItems(numHiddenItems); - }, [showAll]); - - useLayoutEffect(() => { - let rafId: number; - - const run = () => { - const container = containerRef.current; - if (!container || container.offsetHeight === 0) { - rafId = requestAnimationFrame(run); - return; - } - - calculateHiddenItems(); - }; - - rafId = requestAnimationFrame(run); - - return () => cancelAnimationFrame(rafId); - }, [items, calculateHiddenItems]); - - return { calculateHiddenItems, containerRef, itemRefs, numHiddenItems }; -}; From 95e3860ea6d967a372615539938f484dc39b237f Mon Sep 17 00:00:00 2001 From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:51:40 +0530 Subject: [PATCH 02/54] upcoming: [DI-27338] - Added group by on metrics global and widget filter (#12887) * upcoming: [DI-22916] - Added group by on metrics global filter * upcoming: [DI-22916] - Updated typechecks * upcoming: [DI-26666] - Added dashboardId prop in widget * upcoming: [DI-26666] - Added group by option in cloud pulse widgets * upcoming: [DI-26666] - Updated test cases * upcoming: [DI-26666] - Send groupBy as undefined to metrics api if it is empty * upcoming: [DI-26666] - Updated default value logic * upcoming: [DI-27108] - Set legend row as metric name for empty group by * upcoming: [DI-26666] - Show group by icon for small screen * upcoming: [DI-26666] - Added empty string if metric name is undefined * upcoming: [DI-27108] - Updated legend row label when no group by selected * upcoming: [DI-27108] - Updated types * Added changeset * upcoming: [DI-27338] - Prevented metric definition api to be refetched * Added changeset for queries --- .../pr-12887-changed-1758097710169.md | 5 ++ packages/api-v4/src/cloudpulse/types.ts | 4 +- ...r-12887-upcoming-features-1758097638883.md | 5 ++ .../Dashboard/CloudPulseDashboard.tsx | 9 +++- .../Dashboard/CloudPulseDashboardLanding.tsx | 9 ++++ .../Dashboard/CloudPulseDashboardRenderer.tsx | 4 +- .../CloudPulseDashboardWithFilters.tsx | 29 +++++++++-- .../GroupBy/CloudPulseGroupByDrawer.tsx | 4 +- .../GroupBy/GlobalFilterGroupByRenderer.tsx | 4 +- .../Overview/GlobalFilters.test.tsx | 2 + .../CloudPulse/Overview/GlobalFilters.tsx | 8 +++ .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 51 ++++++++++++++----- .../ReusableDashboardFilterUtils.test.ts | 8 +++ .../Utils/ReusableDashboardFilterUtils.ts | 8 ++- .../Widget/CloudPulseWidget.test.tsx | 1 + .../CloudPulse/Widget/CloudPulseWidget.tsx | 26 ++++++++-- .../Widget/CloudPulseWidgetRenderer.tsx | 10 +++- .../CloudPulse/Widget/components/Zoomer.tsx | 2 + .../src/queries/cloudpulse/services.ts | 3 ++ .../pr-12887-changed-1758177198097.md | 5 ++ 20 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12887-changed-1758097710169.md create mode 100644 packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md create mode 100644 packages/queries/.changeset/pr-12887-changed-1758177198097.md diff --git a/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md b/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md new file mode 100644 index 00000000000..50d87c6549c --- /dev/null +++ b/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +ACLP: update `group_by` property to optional for `Widgets` and `CloudPulseMetricRequest` interface ([#12887](https://github.com/linode/manager/pull/12887)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 8e2974fb5a2..26e09e6743b 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -72,7 +72,7 @@ export interface Widgets { color: string; entity_ids: string[]; filters: Filters[]; - group_by: string[]; + group_by?: string[]; label: string; metric: string; namespace_id: number; @@ -150,7 +150,7 @@ export interface CloudPulseMetricsRequest { associated_entity_region?: string; entity_ids: number[]; filters?: Filters[]; - group_by: string[]; + group_by?: string[]; metrics: Metric[]; relative_time_duration: TimeDuration | undefined; time_granularity: TimeGranularity | undefined; diff --git a/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md b/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md new file mode 100644 index 00000000000..3b25767d831 --- /dev/null +++ b/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP: add `Group By` option on `Global Filters` and `Widget Filters` ([#12887](https://github.com/linode/manager/pull/12887)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 36dcf2c988b..f9b3c3240ad 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -35,6 +35,11 @@ export interface DashboardProperties { */ duration: DateTimeWithPreset; + /** + * list of fields to group the metrics data by + */ + groupBy: string[]; + /** * Selected linode region for the dashboard */ @@ -74,11 +79,11 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { manualRefreshTimeStamp, resources, savePref, + groupBy, linodeRegion, } = props; const { preferences } = useAclpPreference(); - const getJweTokenPayload = (): JWETokenPayLoad => { return { entity_ids: resources?.map((resource) => Number(resource)) ?? [], @@ -152,12 +157,12 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { 'No visualizations are available at this moment. Create Dashboards to list here.' ); } - return ( { label: {}, }); + const [groupBy, setGroupBy] = React.useState([]); + const [timeDuration, setTimeDuration] = React.useState< DateTimeWithPreset | undefined >(); @@ -46,6 +49,10 @@ export const CloudPulseDashboardLanding = () => { setShowAppliedFilters(isVisible); }; + const onGroupByChange = React.useCallback((selectedValues: string[]) => { + setGroupBy(selectedValues); + }, []); + const onFilterChange = React.useCallback( (filterKey: string, filterValue: FilterValueType, labels: string[]) => { setFilterData((prev: FilterData) => { @@ -92,6 +99,7 @@ export const CloudPulseDashboardLanding = () => { @@ -107,6 +115,7 @@ export const CloudPulseDashboardLanding = () => { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx index c1f0d881df9..d0868b7b15e 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx @@ -19,8 +19,7 @@ import type { DashboardProp } from './CloudPulseDashboardLanding'; export const CloudPulseDashboardRenderer = React.memo( (props: DashboardProp) => { - const { dashboard, filterValue, timeDuration } = props; - + const { dashboard, filterValue, timeDuration, groupBy } = props; const selectDashboardAndFilterMessage = 'Select a dashboard and apply filters to visualize metrics.'; @@ -63,6 +62,7 @@ export const CloudPulseDashboardRenderer = React.memo( additionalFilters={getMetricsCall} dashboardId={dashboard.id} duration={timeDuration} + groupBy={groupBy} linodeRegion={ filterValue[LINODE_REGION] && typeof filterValue[LINODE_REGION] === 'string' diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 1cfdc379444..26e39a95ed2 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; +import { GlobalFilterGroupByRenderer } from '../GroupBy/GlobalFilterGroupByRenderer'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; @@ -43,6 +44,8 @@ export const CloudPulseDashboardWithFilters = React.memo( label: {}, }); + const [groupBy, setGroupBy] = React.useState([]); + const [timeDuration, setTimeDuration] = React.useState(); @@ -71,6 +74,10 @@ export const CloudPulseDashboardWithFilters = React.memo( [] ); + const handleGroupByChange = React.useCallback((groupBy: string[]) => { + setGroupBy(groupBy); + }, []); + const handleTimeRangeChange = React.useCallback( (timeDuration: DateTimeWithPreset) => { setTimeDuration({ @@ -116,6 +123,7 @@ export const CloudPulseDashboardWithFilters = React.memo( filterValue: filterData.id, resource, timeDuration, + groupBy, }); return ( @@ -139,11 +147,21 @@ export const CloudPulseDashboardWithFilters = React.memo( defaultValue={dashboardId} isServiceIntegration /> - - + + + + @@ -189,6 +207,7 @@ export const CloudPulseDashboardWithFilters = React.memo( filterValue: filterData.id, resource, timeDuration, + groupBy, })} linodeRegion={ filterData.id[LINODE_REGION] diff --git a/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx index 13a8694df9e..30907f5a7a3 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx +++ b/packages/manager/src/features/CloudPulse/GroupBy/CloudPulseGroupByDrawer.tsx @@ -76,7 +76,7 @@ export const CloudPulseGroupByDrawer = React.memo( const [selectedValue, setSelectedValue] = React.useState( defaultValue.slice( 0, - Math.min(defaultValue.length ?? 0, GROUP_BY_SELECTION_LIMIT) + Math.min(defaultValue.length, GROUP_BY_SELECTION_LIMIT) ) ); const previousValueRef = React.useRef( @@ -94,7 +94,7 @@ export const CloudPulseGroupByDrawer = React.memo( React.useEffect(() => { const value = defaultValue.slice( 0, - Math.min(defaultValue.length ?? 0, GROUP_BY_SELECTION_LIMIT) + Math.min(defaultValue.length, GROUP_BY_SELECTION_LIMIT) ); onApply(value); setSelectedValue(value); diff --git a/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx index 193ee4480f6..836cb2a0dee 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/GroupBy/GlobalFilterGroupByRenderer.tsx @@ -65,7 +65,9 @@ export const GlobalFilterGroupByRenderer = ( sx={(theme) => ({ marginBlockEnd: 'auto', marginTop: { md: theme.spacingFunction(28) }, - color: isSelected ? theme.color.buttonPrimaryHover : 'inherit', + color: isSelected + ? theme.tokens.component.Button.Primary.Hover.Background + : 'inherit', })} > diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index 79a911c5c22..b3539af8a8f 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -10,11 +10,13 @@ const mockHandleAnyFilterChange = vi.fn(); const mockHandleDashboardChange = vi.fn(); const mockHandleTimeDurationChange = vi.fn(); const mockHandleToggleAppliedFilter = vi.fn(); +const mockHandleGroupByChange = vi.fn(); const setup = () => { renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index 69663ae83a4..6d66ec243ec 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import Reload from 'src/assets/icons/refresh.svg'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { GlobalFilterGroupByRenderer } from '../GroupBy/GlobalFilterGroupByRenderer'; import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardFilterBuilder'; import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; @@ -29,6 +30,7 @@ export interface GlobalFilterProperties { labels: string[] ): void; handleDashboardChange(dashboard: Dashboard | undefined): void; + handleGroupByChange: (selectedValues: string[]) => void; handleTimeDurationChange(timeDuration: DateTimeWithPreset): void; handleToggleAppliedFilter(isVisible: boolean): void; } @@ -39,6 +41,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleDashboardChange, handleTimeDurationChange, handleToggleAppliedFilter, + handleGroupByChange, } = props; const { preferences, updateGlobalFilterPreference: updatePreferences } = @@ -129,6 +132,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleStatsChange={handleTimeRangeChange} savePreferences /> + { + diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 125da415326..7ebfc02b5c4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -32,8 +32,7 @@ export interface LabelNameOptionsProps { /** * array of group by fields */ - groupBy: string[]; - + groupBy?: string[]; /** * Boolean to check if metric name should be hidden */ @@ -49,6 +48,11 @@ export interface LabelNameOptionsProps { */ metric: { [label: string]: string }; + /** + * label for the current metric + */ + metricLabel?: string; + /** * list of CloudPulseResources available */ @@ -69,13 +73,17 @@ interface GraphDataOptionsProps { /** * array of group by fields */ - groupBy: string[]; - + groupBy?: string[]; /** * label for the graph title */ label: string; + /** + * label for the current metric + */ + metricLabel?: string; + /** * data that will be displayed on graph */ @@ -113,6 +121,8 @@ interface MetricRequestProps { */ entityIds: string[]; + groupBy?: string[]; + /** * selected linode region for the widget */ @@ -133,16 +143,19 @@ export interface DimensionNameProperties { /** * array of group by fields */ - groupBy: string[]; + groupBy?: string[]; /** * Boolean to check if metric name should be hidden */ hideMetricName?: boolean; - /** * metric key-value to generate dimension name */ metric: { [label: string]: string }; + /** + * label for the current metric + */ + metricLabel?: string; /** * resources list of CloudPulseResources available @@ -182,8 +195,16 @@ interface GraphData { * @returns parameters which will be necessary to populate graph & legends */ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { - const { label, metricsList, resources, serviceType, status, unit, groupBy } = - props; + const { + label, + metricsList, + resources, + serviceType, + status, + unit, + groupBy, + metricLabel, + } = props; const legendRowsData: MetricsDisplayRow[] = []; const dimension: { [timestamp: number]: { [label: string]: number } } = {}; const areas: AreaProps[] = []; @@ -221,6 +242,7 @@ export const generateGraphData = (props: GraphDataOptionsProps): GraphData => { hideMetricName, serviceType, groupBy, + metricLabel, }; const labelName = getLabelName(labelOptions); const data = seriesDataFormatter(transformedData.values, start, end); @@ -304,7 +326,8 @@ export const generateMaxUnit = ( export const getCloudPulseMetricRequest = ( props: MetricRequestProps ): CloudPulseMetricsRequest => { - const { duration, entityIds, resources, widget, linodeRegion } = props; + const { duration, entityIds, resources, widget, groupBy, linodeRegion } = + props; const preset = duration.preset; return { @@ -316,7 +339,7 @@ export const getCloudPulseMetricRequest = ( ? entityIds.map((id) => parseInt(id, 10)) : widget.entity_ids.map((id) => parseInt(id, 10)), filters: undefined, - group_by: widget.group_by, + group_by: !groupBy?.length ? undefined : groupBy, relative_time_duration: getTimeDurationFromPreset(preset), metrics: [ { @@ -348,6 +371,7 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { hideMetricName = false, serviceType, groupBy, + metricLabel, } = props; // aggregated metric, where metric keys will be 0 if (!Object.keys(metric).length) { @@ -361,6 +385,7 @@ export const getLabelName = (props: LabelNameOptionsProps): string => { hideMetricName, serviceType, groupBy, + metricLabel, }); }; @@ -375,7 +400,8 @@ export const getDimensionName = (props: DimensionNameProperties): string => { resources, hideMetricName = false, serviceType, - groupBy, + groupBy = [], + metricLabel = '', } = props; const labels: string[] = new Array(groupBy.length).fill(''); Object.entries(metric).forEach(([key, value]) => { @@ -416,7 +442,8 @@ export const getDimensionName = (props: DimensionNameProperties): string => { labels.push(dimensionValue); } }); - return labels.filter(Boolean).join(' | '); + const label = labels.filter(Boolean).join(' | '); + return label || metricLabel; }; /** diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts index 71a9c75d244..d3e0132dbfe 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -19,6 +19,7 @@ it('test getDashboardProperties method', () => { dashboardObj: mockDashboard, filterValue: { region: 'us-east' }, resource: 1, + groupBy: [], }); expect(result).toBeDefined(); @@ -31,6 +32,7 @@ it('test checkMandatoryFiltersSelected method for time duration and resource', ( dashboardObj: mockDashboard, filterValue: { region: 'us-east' }, resource: 0, + groupBy: [], }); expect(result).toBe(false); result = checkMandatoryFiltersSelected({ @@ -42,6 +44,7 @@ it('test checkMandatoryFiltersSelected method for time duration and resource', ( preset, start: start.toISO(), }, + groupBy: [], }); expect(result).toBe(true); @@ -51,6 +54,7 @@ it('test checkMandatoryFiltersSelected method for time duration and resource', ( filterValue: { region: 'us-east' }, resource: 1, timeDuration: undefined, // here time duration is undefined, so it should return false + groupBy: [], }); expect(result).toBe(false); @@ -60,6 +64,7 @@ it('test checkMandatoryFiltersSelected method for time duration and resource', ( filterValue: { region: 'us-east' }, resource: 0, // here resource is 0, so it should return false timeDuration: { end: end.toISO(), preset, start: start.toISO() }, + groupBy: [], }); expect(result).toBe(false); @@ -72,6 +77,7 @@ it('test checkMandatoryFiltersSelected method for role', () => { filterValue: { region: 'us-east' }, // here role is missing resource: 1, timeDuration: { end: end.toISO(), preset, start: start.toISO() }, + groupBy: [], }); expect(result).toBe(false); @@ -81,6 +87,7 @@ it('test checkMandatoryFiltersSelected method for role', () => { filterValue: { node_type: 'primary', region: 'us-east' }, resource: 1, timeDuration: { end: end.toISO(), preset, start: start.toISO() }, + groupBy: [], }); expect(result).toBe(true); @@ -92,6 +99,7 @@ it('test constructDimensionFilters method', () => { dashboardObj: mockDashboard, filterValue: { node_type: 'primary' }, resource: 1, + groupBy: [], }); expect(result.length).toEqual(1); diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index 8c9d9181d9d..7337e235b4d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -19,6 +19,10 @@ interface ReusableDashboardFilterUtilProps { * The selected filter values */ filterValue: CloudPulseMetricsFilter; + /** + * The selected grouping criteria + */ + groupBy: string[]; /** * The selected resource id */ @@ -36,17 +40,19 @@ interface ReusableDashboardFilterUtilProps { export const getDashboardProperties = ( props: ReusableDashboardFilterUtilProps ): DashboardProperties => { - const { dashboardObj, filterValue, resource, timeDuration } = props; + const { dashboardObj, filterValue, resource, timeDuration, groupBy } = props; return { additionalFilters: constructDimensionFilters({ dashboardObj, filterValue, resource, + groupBy, }), dashboardId: dashboardObj.id, duration: timeDuration ?? defaultTimeDuration(), resources: [String(resource)], savePref: false, + groupBy, }; }; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx index 03f61e4c683..0a0309b07e0 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -27,6 +27,7 @@ const props: CloudPulseWidgetProperties = { scrape_interval: '2m', unit: 'percent', }, + dashboardId: 1, duration: { end: DateTime.now().toISO(), preset: '30minutes', diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 43f00e898e8..8ef969b8e97 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; +import { WidgetFilterGroupByRenderer } from '../GroupBy/WidgetFilterGroupByRenderer'; import { generateGraphData, getCloudPulseMetricRequest, @@ -60,6 +61,11 @@ export interface CloudPulseWidgetProperties { */ availableMetrics: MetricDefinition | undefined; + /** + * ID of the selected dashboard + */ + dashboardId: number; + /** * time duration to fetch the metrics data in this widget */ @@ -114,7 +120,6 @@ export interface CloudPulseWidgetProperties { * color index to be selected from available them if not theme is provided by user */ useColorIndex?: number; - /** * this comes from dashboard, has inbuilt metrics, agg_func,group_by,filters,gridsize etc , also helpful in publishing any changes */ @@ -138,11 +143,13 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const { data: profile } = useProfile(); const [widget, setWidget] = React.useState({ ...props.widget }); + const [groupBy, setGroupBy] = React.useState([]); const theme = useTheme(); const { additionalFilters, + dashboardId, ariaLabel, authToken, availableMetrics, @@ -237,7 +244,9 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, [] ); - + const handleGroupByChange = React.useCallback((selectedGroupBy: string[]) => { + setGroupBy(selectedGroupBy); + }, []); const { data: metricsList, error, @@ -251,6 +260,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { entityIds, resources, widget, + groupBy: [...(widgetProp.group_by ?? []), ...groupBy], linodeRegion, }), filters, // any additional dimension filters will be constructed and passed here @@ -277,7 +287,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { status, unit, serviceType, - groupBy: widgetProp.group_by, + groupBy: [...(widgetProp.group_by ?? []), ...groupBy], + metricLabel: availableMetrics?.label, }); data = generatedData.dimensions; @@ -352,7 +363,14 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { onAggregateFuncChange={handleAggregateFunctionChange} /> )} - + + { onClick={() => handleZoomToggle(false)} sx={{ padding: 0, + visibility: { lg: 'visible', xs: 'hidden' }, }} > @@ -50,6 +51,7 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { onClick={() => handleZoomToggle(true)} sx={{ padding: 0, + visibility: { lg: 'visible', xs: 'hidden' }, }} > diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index b85b82ee659..583a395b317 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -1,3 +1,4 @@ +import { queryPresets } from '@linode/queries'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { queryFactory } from './queries'; @@ -23,6 +24,8 @@ export const useGetCloudPulseMetricDefinitionsByServiceType = ( return useQuery, APIError[]>({ ...queryFactory.metricsDefinitons(serviceType, params, filter), enabled, + ...queryPresets.oneTimeFetch, // It is a configuration and not need to be refetched + retry: 2, }); }; diff --git a/packages/queries/.changeset/pr-12887-changed-1758177198097.md b/packages/queries/.changeset/pr-12887-changed-1758177198097.md new file mode 100644 index 00000000000..c6ea8ebcbd9 --- /dev/null +++ b/packages/queries/.changeset/pr-12887-changed-1758177198097.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Changed +--- + +ACLP: update metric definition queries cache time to inifinity ([#12887](https://github.com/linode/manager/pull/12887)) From 93dd7f08ab70a2cde7edaf95d1889f2f6608e587 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:26:52 -0400 Subject: [PATCH 03/54] deps: [M3-10272] - Update `brace-expansion` to resolve dependabot (#12869) * use resolution * pin `brace-expansion` version to secure versions * update format * Added changeset: Add dependency resolution for `brace-expansion` --- package.json | 4 ++- .../pr-12869-tech-stories-1757684471209.md | 5 ++++ pnpm-lock.yaml | 26 +++---------------- 3 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md diff --git a/package.json b/package.json index 31a477cbe00..ff1cd8feb0b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,9 @@ "resolutions": { "semver": "^7.5.2", "yaml": "^2.3.0", - "form-data": "^4.0.4" + "form-data": "^4.0.4", + "brace-expansion@>=1.0.0 <=1.1.11": ">=1.1.12", + "brace-expansion@>=2.0.0 <=2.0.1": ">=2.0.2" }, "version": "0.0.0", "volta": { diff --git a/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md b/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md new file mode 100644 index 00000000000..eb838a8b1ce --- /dev/null +++ b/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add dependency resolution for `brace-expansion` ([#12869](https://github.com/linode/manager/pull/12869)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0b8a9b0f0..c804b49b6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,8 @@ overrides: semver: ^7.5.2 yaml: ^2.3.0 form-data: ^4.0.4 + brace-expansion@>=1.0.0 <=1.1.11: '>=1.1.12' + brace-expansion@>=2.0.0 <=2.0.1: '>=2.0.2' importers: @@ -3062,12 +3064,6 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -3319,9 +3315,6 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.1.0: resolution: {integrity: sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==} engines: {node: '>=18'} @@ -8700,15 +8693,6 @@ snapshots: bluebird@3.7.2: {} - brace-expansion@1.1.11: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -8970,8 +8954,6 @@ snapshots: common-tags@1.8.2: {} - concat-map@0.0.1: {} - concurrently@9.1.0: dependencies: chalk: 4.1.2 @@ -10756,7 +10738,7 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 2.0.2 minimatch@5.1.6: dependencies: @@ -10764,7 +10746,7 @@ snapshots: minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.2 minimist@1.2.8: {} From 6fc1baaac43d1e714eee28960b338452765caa2b Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 19 Sep 2025 12:52:16 +0200 Subject: [PATCH 04/54] feat: [UIE-9162, UIE-9161] - IAM RBAC: fix tooltips in volumes (#12881) * feat: [UIE-9162, UIE-9161] - IAM RBAC: fix tooltips in volumes * Added changeset: IAM RBAC: fix tooltips in volumes * fix e2e test --- .../pr-12881-fixed-1758025080641.md | 5 ++++ .../restricted-user-details-pages.spec.ts | 30 ++++++++++++------- .../Volumes/Partials/VolumesActionMenu.tsx | 7 +++++ .../src/features/Volumes/VolumeCreate.tsx | 29 +++++------------- 4 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 packages/manager/.changeset/pr-12881-fixed-1758025080641.md diff --git a/packages/manager/.changeset/pr-12881-fixed-1758025080641.md b/packages/manager/.changeset/pr-12881-fixed-1758025080641.md new file mode 100644 index 00000000000..6450a71283c --- /dev/null +++ b/packages/manager/.changeset/pr-12881-fixed-1758025080641.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM RBAC: fix tooltips in volumes ([#12881](https://github.com/linode/manager/pull/12881)) diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index d77503cb1df..7e9c4c9e285 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -301,19 +301,29 @@ describe('restricted user details pages', () => { .should('be.visible') .should('be.enabled') .click(); - ['Edit', 'Manage Tags', 'Resize', 'Clone', 'Attach', 'Delete'].forEach( - (menuItem: string) => { + [ + 'Show Config', + 'Edit', + 'Manage Tags', + 'Resize', + 'Clone', + 'Attach', + 'Delete', + ].forEach((menuItem: string) => { + if (menuItem === 'Show Config') { + ui.actionMenuItem.findByTitle(menuItem).should('not.be.disabled'); + } else { ui.actionMenuItem.findByTitle(menuItem).should('be.disabled'); + // Optionally check tooltip for disabled items - if (menuItem !== 'Manage Tags') { - const tooltipMessage = `You don't have permissions to ${menuItem.toLocaleLowerCase()} this Volume. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; - ui.button - .findByAttribute('aria-label', tooltipMessage) - .trigger('mouseover'); - ui.tooltip.findByText(tooltipMessage); - } + const tooltipMessage = `You don't have permissions to ${menuItem === 'Manage Tags' ? 'edit' : menuItem.toLocaleLowerCase()} this Volume. Please contact your ${ADMINISTRATOR} to request the necessary permissions.`; + ui.button + .findByAttribute('aria-label', tooltipMessage) + .first() + .trigger('mouseover'); + ui.tooltip.findByText(tooltipMessage); } - ); + }); }); databaseConfigurations.forEach( diff --git a/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx index 2452ba4f9b2..5da3152a1ae 100644 --- a/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/Partials/VolumesActionMenu.tsx @@ -72,6 +72,13 @@ export const VolumesActionMenu = (props: Props) => { disabled: !volumePermissions?.update_volume, onClick: handlers.handleManageTags, title: 'Manage Tags', + tooltip: !volumePermissions?.update_volume + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, RESIZE: { disabled: !volumePermissions?.resize_volume, diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 498c5995610..a49d2a1d4e6 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -170,22 +170,6 @@ export const VolumeCreate = () => { ) .map((thisRegion) => thisRegion.id) ?? []; - const renderSelectTooltip = (tooltipText: string) => { - return ( - - ); - }; - const { enqueueSnackbar } = useSnackbar(); const { @@ -426,12 +410,10 @@ export const VolumeCreate = () => { onBlur={handleBlur} onChange={(e, region) => handleRegionChange(region)} regions={regions ?? []} + tooltipText="Volumes must be created in a region. You can choose to create a Volume in a region and attach it later to a Linode in the same region." value={values.region} width={400} /> - {renderSelectTooltip( - 'Volumes must be created in a region. You can choose to create a Volume in a region and attach it later to a Linode in the same region.' - )} { }} value={values.linode_id} /> - {renderSelectTooltip( - 'If you select a Linode, the Volume will be automatically created in that Linode’s region and attached upon creation.' - )} + {shouldDisplayClientLibraryCopy && values.encryption === 'enabled' && ( From 48be4d9174ae385101d999f78a2e7860357df1a5 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Fri, 19 Sep 2025 12:53:38 +0200 Subject: [PATCH 05/54] feat: [UIE-9068] - IAM RBAC: disable field in the drawer (#12892) * feat: [UIE-9068] - IAM RBAC: disable field in the drawer * Added changeset: IAM RBAC: disable fields in the drawer --- .../pr-12892-added-1758187361526.md | 5 +++ .../Devices/AddNodebalancerDrawer.test.tsx | 33 ++++++++++--------- .../Devices/AddNodebalancerDrawer.tsx | 25 +++++++------- .../Devices/FirewallDeviceLanding.tsx | 1 + 4 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 packages/manager/.changeset/pr-12892-added-1758187361526.md diff --git a/packages/manager/.changeset/pr-12892-added-1758187361526.md b/packages/manager/.changeset/pr-12892-added-1758187361526.md new file mode 100644 index 00000000000..6e9001c7e3d --- /dev/null +++ b/packages/manager/.changeset/pr-12892-added-1758187361526.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM RBAC: disable fields in the drawer ([#12892](https://github.com/linode/manager/pull/12892)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx index da04b843c12..bb8f8ab25ea 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -11,19 +12,11 @@ const props = { helperText, onClose, open: true, + disabled: false, }; const queryMocks = vi.hoisted(() => ({ useParams: vi.fn().mockReturnValue({}), - userPermissions: vi.fn(() => ({ - data: { - create_firewall_device: true, - }, - })), -})); - -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - usePermissions: queryMocks.userPermissions, })); vi.mock('@tanstack/react-router', async () => { @@ -60,20 +53,30 @@ describe('AddNodeBalancerDrawer', () => { const { getByText } = renderWithTheme(); expect(getByText('Add')).toBeInTheDocument(); }); + it('should enable select if the user has create_firewall_device permission', async () => { + const { getByRole } = renderWithTheme(); + + const select = getByRole('combobox'); + expect(select).toBeInTheDocument(); - it('should disable "Add" button if the user does not have create_firewall_device permission', async () => { - queryMocks.userPermissions.mockReturnValue({ - data: { - create_firewall_device: false, - }, + await waitFor(() => { + expect(select).toBeEnabled(); }); + }); - const { getByRole } = renderWithTheme(); + it('should disable "Add" button and select if the user does not have create_firewall_device permission', async () => { + const { getByRole } = renderWithTheme( + + ); const addButton = getByRole('button', { name: 'Add', }); expect(addButton).toBeInTheDocument(); expect(addButton).toBeDisabled(); + + const select = getByRole('combobox'); + expect(select).toBeInTheDocument(); + expect(select).toBeDisabled(); }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index f6c00ec48d3..879c1994803 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -14,7 +14,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { SupportLink } from 'src/components/SupportLink'; import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; -import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; @@ -22,13 +22,14 @@ import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; import type { NodeBalancer } from '@linode/api-v4'; interface Props { + disabled?: boolean; helperText: string; onClose: () => void; open: boolean; } export const AddNodebalancerDrawer = (props: Props) => { - const { helperText, onClose, open } = props; + const { helperText, onClose, open, disabled } = props; const { enqueueSnackbar } = useSnackbar(); const { id } = useParams({ strict: false }); const { data: grants } = useGrants(); @@ -39,12 +40,6 @@ export const AddNodebalancerDrawer = (props: Props) => { const firewall = data?.find((firewall) => firewall.id === Number(id)); - const { data: permissions } = usePermissions( - 'firewall', - ['create_firewall_device'], - firewall?.id - ); - const theme = useTheme(); const { isPending: addDeviceIsLoading, mutateAsync: addDevice } = @@ -187,6 +182,14 @@ export const AddNodebalancerDrawer = (props: Props) => { open={open} title={`Add Nodebalancer to Firewall: ${firewall?.label}`} > + {disabled && ( + + )} Only the Firewall's inbound rules apply to NodeBalancers. Any existing outbound rules won't be applied.{' '} @@ -200,7 +203,7 @@ export const AddNodebalancerDrawer = (props: Props) => { > {localError ? errorNotice() : null} @@ -211,9 +214,7 @@ export const AddNodebalancerDrawer = (props: Props) => { /> ) : ( Date: Fri, 19 Sep 2025 09:18:40 -0400 Subject: [PATCH 06/54] tech-story: [M3-10605] - Update Node.js from `20.17` to `22.19` (#12838) * initial update * use built in glob * update the cypress factory dockerfile version * Added changeset: Update Node.js from `20.17` to `22.19` * debug: let cypress handle things * fix: broken command * use node version from `package.json` * fix missing node version * Install latest version of Chrome in Cypress image via Google's apt repo --------- Co-authored-by: Joe D'Amore --- .github/workflows/ci.yml | 36 +-- .github/workflows/coverage_badge.yml | 5 +- .github/workflows/e2e_schedule_and_push.yml | 2 +- .github/workflows/eslint_review.yml | 2 +- .nvmrc | 2 +- docs/GETTING_STARTED.md | 6 +- package.json | 2 +- .../pr-12838-tech-stories-1757350386803.md | 5 + packages/manager/Dockerfile | 20 +- .../support/plugins/node-version-check.ts | 2 +- .../cypress/support/plugins/split-run.ts | 3 +- packages/manager/package.json | 3 +- pnpm-lock.yaml | 267 +++++++++--------- scripts/package.json | 2 +- 14 files changed, 178 insertions(+), 179 deletions(-) create mode 100644 packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab44266804..57a52f14260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter ${{ matrix.package }} lint @@ -49,7 +49,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/validation build @@ -69,7 +69,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@v4 @@ -89,7 +89,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -113,7 +113,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile @@ -154,7 +154,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -177,7 +177,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/search test @@ -192,7 +192,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/ui test @@ -208,7 +208,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -228,7 +228,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -248,7 +248,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -272,7 +272,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run --filter @linode/ui typecheck @@ -288,7 +288,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -308,7 +308,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -328,7 +328,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -352,7 +352,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -381,7 +381,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: @@ -419,7 +419,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index 639f21e3e0d..44d5902256d 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -21,10 +21,9 @@ jobs: run_install: false version: 10 - - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - name: Install Dependencies diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index ba726631bbb..bf66d7cd3eb 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -43,7 +43,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" - run: | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - run: | diff --git a/.github/workflows/eslint_review.yml b/.github/workflows/eslint_review.yml index 44430dfec58..cb1424cd786 100644 --- a/.github/workflows/eslint_review.yml +++ b/.github/workflows/eslint_review.yml @@ -18,7 +18,7 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: "20.17" + node-version-file: "package.json" cache: "pnpm" - run: pnpm install - uses: abailly-akamai/action-eslint@8ad68ba04fa60924ef7607b07deb5989f38f5ed6 # v1.0.2 diff --git a/.nvmrc b/.nvmrc index 65da8ce3917..6e77d0a7496 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.17 +22.19 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 3e36f22e05f..b4b99f6102a 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -7,17 +7,17 @@ 5. After your OAuth App has been created, copy the ID (not the secret). 6. In `packages/manager`, copy the contents of `.env.example` and paste them into a new file called `.env`. 7. In `.env` set `REACT_APP_CLIENT_ID` to the ID from step 5. -8. Install Node.js 20.17 LTS. We recommend using [Volta](https://volta.sh/): +8. Install Node.js 22.19 LTS. We recommend using [Volta](https://volta.sh/): ```bash curl https://get.volta.sh | bash ## Add volta to your .*rc file, or open a new terminal window. - volta install node@20.17 + volta install node@22.19 node --version - ## v20.17.0 + ## v22.19.0 ``` 9. Install pnpm v10 using Volta or view the [pnpm docs](https://pnpm.io/installation) for more installation methods diff --git a/package.json b/package.json index ff1cd8feb0b..c2fa7452a5f 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ }, "version": "0.0.0", "volta": { - "node": "20.17.0" + "node": "22.19.0" }, "workspaces": { "packages": [ diff --git a/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md b/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md new file mode 100644 index 00000000000..5931a02aabe --- /dev/null +++ b/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update Node.js from `20.17` to `22.19` ([#12838](https://github.com/linode/manager/pull/12838)) diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index 8bdc220a59b..7f65e774d78 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -3,7 +3,7 @@ ARG IMAGE_REGISTRY=docker.io # Node.js version to use. -ARG NODE_VERSION=20.17.0 +ARG NODE_VERSION=22.19.0 # Cypress version. ARG CYPRESS_VERSION=14.3.0 @@ -41,21 +41,21 @@ CMD pnpm start:manager:ci # # Uses Cypress factory image. For more information, see: # https://github.com/cypress-io/cypress-docker-images/tree/master/factory#usage -FROM ${IMAGE_REGISTRY}/cypress/factory:5.2.1 AS e2e-build +FROM ${IMAGE_REGISTRY}/cypress/factory:6.0.1 AS e2e-build ARG CYPRESS_VERSION ARG NODE_VERSION # Add Chrome apt repo RUN apt-get update \ - && apt-get install -y wget gnupg2 \ - && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ - && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ - && apt-get update + && apt-get install -y wget gnupg2 \ + && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /usr/share/keyrings/google-chrome-archive-keyring.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/google-chrome-archive-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list \ + && apt-get update RUN apt-get install -y google-chrome-stable \ - && rm -rf /var/cache/apt/* \ - && rm -rf /var/lib/apt/lists* \ - && apt-get clean \ - && npm install -g cypress@${CYPRESS_VERSION} pnpm bun yarn + && rm -rf /var/cache/apt/* \ + && rm -rf /var/lib/apt/lists* \ + && apt-get clean \ + && npm install -g cypress@${CYPRESS_VERSION} pnpm bun yarn USER node WORKDIR /home/node/app diff --git a/packages/manager/cypress/support/plugins/node-version-check.ts b/packages/manager/cypress/support/plugins/node-version-check.ts index bda06bd4cec..f01b43bd0d9 100644 --- a/packages/manager/cypress/support/plugins/node-version-check.ts +++ b/packages/manager/cypress/support/plugins/node-version-check.ts @@ -2,7 +2,7 @@ import type { CypressPlugin } from './plugin'; // Supported major versions of Node.js. // Running Cypress using other versions will cause a warning to be displayed. -const supportedVersions = [18, 20]; +const supportedVersions = [20, 22, 24]; /** * Returns a string describing the version of Node.js that is running the tests. diff --git a/packages/manager/cypress/support/plugins/split-run.ts b/packages/manager/cypress/support/plugins/split-run.ts index 62e647c485a..407f49b3e07 100644 --- a/packages/manager/cypress/support/plugins/split-run.ts +++ b/packages/manager/cypress/support/plugins/split-run.ts @@ -2,8 +2,7 @@ * @file Allows parallelization without Cypress Cloud. */ -import { readFileSync } from 'fs'; -import { globSync } from 'glob'; +import { globSync, readFileSync } from 'fs'; import { resolve } from 'path'; import { specWeightsSchema } from './generate-weights'; diff --git a/packages/manager/package.json b/packages/manager/package.json index a283ebd713b..7c9074e9394 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -145,7 +145,7 @@ "@types/markdown-it": "^14.1.2", "@types/md5": "^2.1.32", "@types/mocha": "^10.0.2", - "@types/node": "^20.17.0", + "@types/node": "^22.13.14", "@types/ramda": "0.25.16", "@types/react": "^19.1.6", "@types/react-csv": "^1.1.3", @@ -171,7 +171,6 @@ "cypress-vite": "^1.6.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", - "glob": "^10.3.1", "globals": "^16.0.0", "history": "4", "jsdom": "^24.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c804b49b6dd..a7b2dbc7fd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ importers: version: 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) vitest: specifier: ^3.1.2 - version: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) packages/api-v4: dependencies: @@ -358,7 +358,7 @@ importers: version: 9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -414,8 +414,8 @@ importers: specifier: ^10.0.2 version: 10.0.9 '@types/node': - specifier: ^20.17.0 - version: 20.17.6 + specifier: ^22.13.14 + version: 22.18.1 '@types/ramda': specifier: 0.25.16 version: 0.25.16 @@ -442,7 +442,7 @@ importers: version: 4.4.5 '@vitejs/plugin-react-swc': specifier: ^3.7.2 - version: 3.7.2(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.1.2 version: 3.1.2(vitest@3.1.2) @@ -484,16 +484,13 @@ importers: version: 1.14.0(cypress@14.3.0) cypress-vite: specifier: ^1.6.0 - version: 1.6.0(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 factory.ts: specifier: ^0.5.1 version: 0.5.2 - glob: - specifier: ^10.3.1 - version: 10.4.5 globals: specifier: ^16.0.0 version: 16.0.0 @@ -508,7 +505,7 @@ importers: version: 2.2.1(mocha@10.8.2) msw: specifier: ^2.2.3 - version: 2.6.5(@types/node@20.17.6)(typescript@5.7.3) + version: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) pdfreader: specifier: ^3.0.7 version: 3.0.7 @@ -520,10 +517,10 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite: specifier: ^6.3.6 - version: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -575,7 +572,7 @@ importers: version: 4.2.0 vite: specifier: '*' - version: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) devDependencies: '@linode/tsconfig': specifier: workspace:* @@ -607,7 +604,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -631,7 +628,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -676,7 +673,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -703,7 +700,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^3.2.0 - version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -773,8 +770,8 @@ importers: specifier: ^14.1.2 version: 14.1.2 '@types/node': - specifier: ^20.17.0 - version: 20.17.6 + specifier: ^22.13.14 + version: 22.18.1 chalk: specifier: ^5.2.0 version: 5.4.1 @@ -783,7 +780,7 @@ importers: version: 6.2.1 inquirer: specifier: ^12.9.4 - version: 12.9.4(@types/node@20.17.6) + version: 12.9.4(@types/node@22.18.1) junit2json: specifier: ^3.1.4 version: 3.1.12 @@ -2596,8 +2593,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.17.6': - resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + '@types/node@22.18.1': + resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} '@types/novnc__novnc@1.5.0': resolution: {integrity: sha512-9DrDJK1hUT6Cbp4t03IsU/DsR6ndnIrDgZVrzITvspldHQ7n81F3wUDfq89zmPM3wg4GErH11IQa0QuTgLMf+w==} @@ -6077,8 +6074,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} @@ -7014,33 +7011,33 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} - '@inquirer/checkbox@4.2.2(@types/node@20.17.6)': + '@inquirer/checkbox@4.2.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/confirm@5.1.0(@types/node@20.17.6)': + '@inquirer/confirm@5.1.0(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.1.1(@types/node@20.17.6) - '@inquirer/type': 3.0.1(@types/node@20.17.6) - '@types/node': 20.17.6 + '@inquirer/core': 10.1.1(@types/node@22.18.1) + '@inquirer/type': 3.0.1(@types/node@22.18.1) + '@types/node': 22.18.1 - '@inquirer/confirm@5.1.16(@types/node@20.17.6)': + '@inquirer/confirm@5.1.16(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/core@10.1.1(@types/node@20.17.6)': + '@inquirer/core@10.1.1(@types/node@22.18.1)': dependencies: '@inquirer/figures': 1.0.8 - '@inquirer/type': 3.0.1(@types/node@20.17.6) + '@inquirer/type': 3.0.1(@types/node@22.18.1) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -7051,10 +7048,10 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@inquirer/core@10.2.0(@types/node@20.17.6)': + '@inquirer/core@10.2.0(@types/node@22.18.1)': dependencies: '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -7062,106 +7059,106 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/editor@4.2.18(@types/node@20.17.6)': + '@inquirer/editor@4.2.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/external-editor': 1.0.1(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/external-editor': 1.0.1(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/expand@4.0.18(@types/node@20.17.6)': + '@inquirer/expand@4.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/external-editor@1.0.1(@types/node@20.17.6)': + '@inquirer/external-editor@1.0.1(@types/node@22.18.1)': dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@inquirer/figures@1.0.13': {} '@inquirer/figures@1.0.8': {} - '@inquirer/input@4.2.2(@types/node@20.17.6)': + '@inquirer/input@4.2.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/number@3.0.18(@types/node@20.17.6)': + '@inquirer/number@3.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/password@4.0.18(@types/node@20.17.6)': + '@inquirer/password@4.0.18(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 optionalDependencies: - '@types/node': 20.17.6 - - '@inquirer/prompts@7.8.4(@types/node@20.17.6)': - dependencies: - '@inquirer/checkbox': 4.2.2(@types/node@20.17.6) - '@inquirer/confirm': 5.1.16(@types/node@20.17.6) - '@inquirer/editor': 4.2.18(@types/node@20.17.6) - '@inquirer/expand': 4.0.18(@types/node@20.17.6) - '@inquirer/input': 4.2.2(@types/node@20.17.6) - '@inquirer/number': 3.0.18(@types/node@20.17.6) - '@inquirer/password': 4.0.18(@types/node@20.17.6) - '@inquirer/rawlist': 4.1.6(@types/node@20.17.6) - '@inquirer/search': 3.1.1(@types/node@20.17.6) - '@inquirer/select': 4.3.2(@types/node@20.17.6) + '@types/node': 22.18.1 + + '@inquirer/prompts@7.8.4(@types/node@22.18.1)': + dependencies: + '@inquirer/checkbox': 4.2.2(@types/node@22.18.1) + '@inquirer/confirm': 5.1.16(@types/node@22.18.1) + '@inquirer/editor': 4.2.18(@types/node@22.18.1) + '@inquirer/expand': 4.0.18(@types/node@22.18.1) + '@inquirer/input': 4.2.2(@types/node@22.18.1) + '@inquirer/number': 3.0.18(@types/node@22.18.1) + '@inquirer/password': 4.0.18(@types/node@22.18.1) + '@inquirer/rawlist': 4.1.6(@types/node@22.18.1) + '@inquirer/search': 3.1.1(@types/node@22.18.1) + '@inquirer/select': 4.3.2(@types/node@22.18.1) optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/rawlist@4.1.6(@types/node@20.17.6)': + '@inquirer/rawlist@4.1.6(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/search@3.1.1(@types/node@20.17.6)': + '@inquirer/search@3.1.1(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/select@4.3.2(@types/node@20.17.6)': + '@inquirer/select@4.3.2(@types/node@22.18.1)': dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/type@3.0.1(@types/node@20.17.6)': + '@inquirer/type@3.0.1(@types/node@22.18.1)': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 - '@inquirer/type@3.0.8(@types/node@20.17.6)': + '@inquirer/type@3.0.8(@types/node@22.18.1)': optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@isaacs/cliui@8.0.2': dependencies: @@ -7174,12 +7171,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.4.5 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.7.3 @@ -7699,12 +7696,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) ts-dedent: 2.2.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/csf-plugin@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': dependencies: @@ -7729,11 +7726,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@rollup/pluginutils': 5.1.3(rollup@4.50.1) - '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3) find-up: 7.0.0 magic-string: 0.30.17 @@ -7743,7 +7740,7 @@ snapshots: resolve: 1.22.8 storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) tsconfig-paths: 4.2.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color @@ -8108,9 +8105,9 @@ snapshots: '@types/ms@2.1.0': optional: true - '@types/node@20.17.6': + '@types/node@22.18.1': dependencies: - undici-types: 6.19.8 + undici-types: 6.21.0 '@types/novnc__novnc@1.5.0': {} @@ -8172,11 +8169,11 @@ snapshots: '@types/xml2js@0.4.14': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 optional: true '@types/zxcvbn@4.4.5': {} @@ -8353,10 +8350,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@3.7.2(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@3.7.2(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@swc/core': 1.10.11 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -8374,7 +8371,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -8392,14 +8389,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.2(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@vitest/spy': 3.1.2 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.6.5(@types/node@20.17.6)(typescript@5.7.3) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.0.9': dependencies: @@ -8437,7 +8434,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.13 tinyrainbow: 2.0.0 - vitest: 3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/utils@3.0.9': dependencies: @@ -9068,11 +9065,11 @@ snapshots: dependencies: cypress: 14.3.0 - cypress-vite@1.6.0(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.6.0(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.0(supports-color@8.1.1) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -10143,17 +10140,17 @@ snapshots: inject-stylesheet@6.0.1: {} - inquirer@12.9.4(@types/node@20.17.6): + inquirer@12.9.4(@types/node@22.18.1): dependencies: - '@inquirer/core': 10.2.0(@types/node@20.17.6) - '@inquirer/prompts': 7.8.4(@types/node@20.17.6) - '@inquirer/type': 3.0.8(@types/node@20.17.6) + '@inquirer/core': 10.2.0(@types/node@22.18.1) + '@inquirer/prompts': 7.8.4(@types/node@22.18.1) + '@inquirer/type': 3.0.8(@types/node@22.18.1) ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 4.0.6 rxjs: 7.8.2 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 internal-slot@1.1.0: dependencies: @@ -10829,12 +10826,12 @@ snapshots: ms@2.1.3: {} - msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3): + msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.0(@types/node@20.17.6) + '@inquirer/confirm': 5.1.0(@types/node@22.18.1) '@mswjs/interceptors': 0.37.1 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 @@ -12100,7 +12097,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.19.8: {} + undici-types@6.21.0: {} unicorn-magic@0.1.0: {} @@ -12203,13 +12200,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.1.2(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite-node@3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1(supports-color@8.1.1) es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@types/node' - jiti @@ -12224,18 +12221,18 @@ snapshots: - tsx - yaml - vite-plugin-svgr@3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@3.3.0(rollup@4.50.1)(typescript@5.7.3)(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.50.1) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -12244,17 +12241,17 @@ snapshots: rollup: 4.50.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 20.17.6 + '@types/node': 22.18.1 fsevents: 2.3.3 jiti: 2.4.2 terser: 5.36.0 tsx: 4.19.3 yaml: 2.6.1 - vitest@3.1.2(@types/debug@4.1.12)(@types/node@20.17.6)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.1.2)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: '@vitest/expect': 3.1.2 - '@vitest/mocker': 3.1.2(msw@2.6.5(@types/node@20.17.6)(typescript@5.7.3))(vite@6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/mocker': 3.1.2(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/pretty-format': 3.1.2 '@vitest/runner': 3.1.2 '@vitest/snapshot': 3.1.2 @@ -12271,12 +12268,12 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.3.6(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - vite-node: 3.1.2(@types/node@20.17.6)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 6.3.6(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite-node: 3.1.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 20.17.6 + '@types/node': 22.18.1 '@vitest/ui': 3.1.2(vitest@3.1.2) jsdom: 24.1.3 transitivePeerDependencies: diff --git a/scripts/package.json b/scripts/package.json index c59dfae4e8c..ccc9b8c3884 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@types/markdown-it": "^14.1.2", "markdown-it": "^14.1.0", - "@types/node": "^20.17.0", + "@types/node": "^22.13.14", "chalk": "^5.2.0", "commander": "^6.2.1", "inquirer": "^12.9.4", From ccca5557f4055235df9b8b511fc3a55aa4c39816 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:31:50 -0400 Subject: [PATCH 07/54] fix: Cypress Github Actions after Node 22 upgrade (#12896) Our Github Actions that run Cypress on develop after merging tech-story: [M3-10605] - Update Node.js from 20.17 to 22.19 #12838 There may be other ways to address this, like bringing our own container image, but for now, the easiest solution is to just revert to using the glob package rather than Node 22's built in glob so that the cypress pipeline can run on older versions of node if needed --- packages/manager/cypress/support/plugins/split-run.ts | 3 ++- packages/manager/package.json | 1 + pnpm-lock.yaml | 11 +++++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/manager/cypress/support/plugins/split-run.ts b/packages/manager/cypress/support/plugins/split-run.ts index 407f49b3e07..62e647c485a 100644 --- a/packages/manager/cypress/support/plugins/split-run.ts +++ b/packages/manager/cypress/support/plugins/split-run.ts @@ -2,7 +2,8 @@ * @file Allows parallelization without Cypress Cloud. */ -import { globSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; +import { globSync } from 'glob'; import { resolve } from 'path'; import { specWeightsSchema } from './generate-weights'; diff --git a/packages/manager/package.json b/packages/manager/package.json index 7c9074e9394..78ebfb29391 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -171,6 +171,7 @@ "cypress-vite": "^1.6.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", + "glob": "^10.3.1", "globals": "^16.0.0", "history": "4", "jsdom": "^24.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7b2dbc7fd9..4537fd1e4cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -491,6 +491,9 @@ importers: factory.ts: specifier: ^0.5.1 version: 0.5.2 + glob: + specifier: ^10.3.1 + version: 10.4.5 globals: specifier: ^16.0.0 version: 16.0.0 @@ -4001,8 +4004,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.0: - resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} forever-agent@0.6.1: @@ -9826,7 +9829,7 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.0: + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 @@ -9956,7 +9959,7 @@ snapshots: glob@10.4.5: dependencies: - foreground-child: 3.3.0 + foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.2 From 2acc6820b5742521765e298c44da130d15fd5949 Mon Sep 17 00:00:00 2001 From: Ankita Date: Fri, 19 Sep 2025 21:06:59 +0530 Subject: [PATCH 08/54] [DI-27060] - Alerts Contextual View - Api flexibility (#12870) * [DI-27060] - Api flexibility initial changes * [DI-27060] - Have everything typed * [DI-27060] - Remove service level invalidation from aclp invalidations * [DI-27060] refactor: improve alerts mutation type system and error handling - Replace TransformedPayload with AlertPayload for clearer type semantics - Add null-safe payload transformation with optional chaining - Remove unused PayloadTransformOverrides interface and imports - Reorganize type definitions for better maintainability - Improve type safety in AlertInformationActionTable component * [DI-27060] - update alerts invalidation logic on mutation * [DI-27060] - Manage service hooks inside a common hook, discard excessive typecasting * [DI-27060] - PR comments * [DI-27060] - PR comments * [DI-27060] - remove cloudpulse api mutation from generic hook as of now * [DI-27060] - change payload to have system_alerts and user_alerts instead of system,user * [DI-27060] - Fix create-alert spec * [DI-27060] - Add changesets * [DI-27060] - Rename 'invalidateAlerts' to invalidateAclpAlerts' * [DI-27060] - update validation schema and unit test * [DI-26070] - Update schema * [DI-27060] - Update schema * [DI-27060] - Pr suggestions - rename, add comments --- .../pr-12870-changed-1757668838404.md | 5 + packages/api-v4/src/cloudpulse/alerts.ts | 4 +- packages/api-v4/src/cloudpulse/types.ts | 4 +- ...r-12870-upcoming-features-1757668940949.md | 5 + .../e2e/core/linodes/alerts-create.spec.ts | 20 +-- .../e2e/core/linodes/alerts-edit.spec.ts | 12 +- .../AlertInformationActionTable.tsx | 49 ++++--- .../CloudPulse/Alerts/Utils/utils.test.ts | 38 +++++- .../features/CloudPulse/Alerts/Utils/utils.ts | 21 +++ .../src/features/CloudPulse/Utils/utils.ts | 14 +- .../LinodeCreate/AdditionalOptions/Alerts.tsx | 2 +- .../Linodes/LinodeCreate/Summary/Summary.tsx | 6 +- .../LinodeAlerts/AlertsPanel.tsx | 4 +- packages/manager/src/mocks/serverHandlers.ts | 19 ++- .../manager/src/queries/cloudpulse/alerts.ts | 41 +----- .../queries/cloudpulse/useAlertsMutation.ts | 122 ++++++++++++++++++ .../hooks/useIsLinodeAclpSubscribed.test.ts | 20 +-- .../src/hooks/useIsLinodeAclpSubscribed.ts | 4 +- packages/validation/src/linodes.schema.ts | 50 +++++-- 19 files changed, 316 insertions(+), 124 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12870-changed-1757668838404.md create mode 100644 packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md create mode 100644 packages/manager/src/queries/cloudpulse/useAlertsMutation.ts diff --git a/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md b/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md new file mode 100644 index 00000000000..6b86ccfa93f --- /dev/null +++ b/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +CloudPulse-Alerts: Update `CloudPulseAlertsPayload` in types.ts ([#12870](https://github.com/linode/manager/pull/12870)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index ea31bdfdff1..4518b92c486 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -132,9 +132,9 @@ export const updateServiceAlerts = ( entityId: string, payload: CloudPulseAlertsPayload, ) => - Request<{}>( + Request( setURL( - `${API_ROOT}/${serviceType}/instances/${encodeURIComponent(entityId)}`, + `${API_ROOT}/monitor/services/${encodeURIComponent(serviceType)}/alert-definitions/${encodeURIComponent(entityId)}`, ), setMethod('PUT'), setData(payload), diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 26e09e6743b..88727de8737 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -389,10 +389,10 @@ export interface CloudPulseAlertsPayload { * Array of enabled system alert IDs in ACLP (Beta) mode. * Only included in Beta mode. */ - system?: number[]; + system_alerts?: number[]; /** * Array of enabled user alert IDs in ACLP (Beta) mode. * Only included in Beta mode. */ - user?: number[]; + user_alerts?: number[]; } diff --git a/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md b/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md new file mode 100644 index 00000000000..00c98269c59 --- /dev/null +++ b/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Alerts: Add `useAlertsMutation.ts`, update `AlertInformationActionTable.tsx` to handle api integration for mutliple services ([#12870](https://github.com/linode/manager/pull/12870)) diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts index 87cbfc09cf0..ab7c8e4646e 100644 --- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts @@ -299,24 +299,24 @@ describe('Create flow when beta alerts enabled by region and feature flag', func cy.get('pre code').should('be.visible'); /** alert in code snippet * "alerts": { - * "system": [ + * "system_alerts": [ * 1, * 2, * ], - * "user": [ + * "user_alerts": [ * 2 * ] * } */ - const strAlertSnippet = `alerts '{"system": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user":[${alertDefinitions[2].id}]}`; + const strAlertSnippet = `alerts '{"system_alerts": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user_alerts":[${alertDefinitions[2].id}]}`; cy.contains(strAlertSnippet).should('be.visible'); // cURL tab ui.tabList.findTabByTitle('cURL').should('be.visible').click(); // hard to consolidate text within multiple spans in
    
             cy.get('pre code').within(() => {
               cy.contains('alerts');
    -          cy.contains('system');
    -          cy.contains('user');
    +          cy.contains('system_alerts');
    +          cy.contains('user_alerts');
             });
             ui.button
               .findByTitle('Close')
    @@ -341,11 +341,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
           .click();
         cy.wait('@createLinode').then((intercept) => {
           const alerts = intercept.request.body['alerts'];
    -      expect(alerts.system.length).to.equal(2);
    -      expect(alerts.system[0]).to.eq(alertDefinitions[0].id);
    -      expect(alerts.system[1]).to.eq(alertDefinitions[1].id);
    -      expect(alerts.user.length).to.equal(1);
    -      expect(alerts.user[0]).to.eq(alertDefinitions[2].id);
    +      expect(alerts.system_alerts.length).to.equal(2);
    +      expect(alerts.system_alerts[0]).to.eq(alertDefinitions[0].id);
    +      expect(alerts.system_alerts[1]).to.eq(alertDefinitions[1].id);
    +      expect(alerts.user_alerts.length).to.equal(1);
    +      expect(alerts.user_alerts[0]).to.eq(alertDefinitions[2].id);
         });
       });
     
    diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
    index d2b7e3267a4..7513f2b8fe2 100644
    --- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
    +++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
    @@ -37,21 +37,21 @@ const mockEnabledLegacyAlerts = {
     };
     
     const mockDisabledBetaAlerts = {
    -  system: [],
    -  user: [],
    +  system_alerts: [],
    +  user_alerts: [],
     };
     
     const mockEnabledBetaAlerts = {
    -  system: [1, 2],
    -  user: [3],
    +  system_alerts: [1, 2],
    +  user_alerts: [3],
     };
     
     /*
      * UI of Linode alerts tab based on beta and legacy alert values in linode.alerts. Dependent on region support for alerts
      * Legacy alerts = 0, Beta alerts = [] (empty arrays or no values at all) => legacy disabled for `beta` stage OR beta disabled for `ga` stage
      * Legacy alerts > 0, Beta alerts = [] (empty arrays or no values at all) => legacy enabled
    - * Legacy alerts = 0, Beta alerts has values (either system, user, or both) => beta enabled
    - * Legacy alerts > 0, Beta alerts has values (either system, user, or both) => beta enabled
    + * Legacy alerts = 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
    + * Legacy alerts > 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
      *
      * Note: Here, "disabled" means that all toggles are in the OFF state, but it's still editable (not read-only)
      */
    diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
    index 6bf2a79e408..a457d7d79ab 100644
    --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
    +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
    @@ -1,6 +1,7 @@
     import { type Alert, type APIError } from '@linode/api-v4';
     import { Box, Button, TooltipIcon } from '@linode/ui';
     import { Grid, TableBody, TableHead } from '@mui/material';
    +import { useQueryClient } from '@tanstack/react-query';
     import { useSnackbar } from 'notistack';
     import React from 'react';
     
    @@ -13,14 +14,18 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon
     import { TableRow } from 'src/components/TableRow';
     import { TableSortCell } from 'src/components/TableSortCell';
     import { ALERTS_BETA_PROMPT } from 'src/features/Linodes/constants';
    -import { useServiceAlertsMutation } from 'src/queries/cloudpulse/alerts';
    +import {
    +  invalidateAclpAlerts,
    +  servicePayloadTransformerMap,
    +  useAlertsMutation,
    +} from 'src/queries/cloudpulse/useAlertsMutation';
     import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
     
    -import { compareArrays } from '../../Utils/FilterBuilder';
     import { useContextualAlertsState } from '../../Utils/utils';
     import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog';
     import { ALERT_SCOPE_TOOLTIP_CONTEXTUAL } from '../constants';
     import { scrollToElement } from '../Utils/AlertResourceUtils';
    +import { arraysEqual } from '../Utils/utils';
     import { AlertInformationActionRow } from './AlertInformationActionRow';
     
     import type {
    @@ -142,6 +147,8 @@ export const AlertInformationActionTable = (
     
       const isEditMode = !!entityId;
       const isCreateMode = !isEditMode;
    +  const payloadAlertType = (alert: Alert) =>
    +    alert.type === 'system' ? 'system_alerts' : 'user_alerts';
     
       const {
         enabledAlerts,
    @@ -151,10 +158,8 @@ export const AlertInformationActionTable = (
         resetToInitialState,
       } = useContextualAlertsState(alerts, entityId);
     
    -  const { mutateAsync: updateAlerts } = useServiceAlertsMutation(
    -    serviceType,
    -    entityId ?? ''
    -  );
    +  // Mutation to update alerts as per service type
    +  const updateAlerts = useAlertsMutation(serviceType, entityId ?? '');
     
       React.useEffect(() => {
         // To send initial state of alerts through toggle handler function (For Create Flow)
    @@ -174,19 +179,26 @@ export const AlertInformationActionTable = (
         setIsDialogOpen(false);
       };
     
    +  const queryClient = useQueryClient();
    +
       const handleConfirm = React.useCallback(
         (alertIds: CloudPulseAlertsPayload) => {
           setIsLoading(true);
    -      updateAlerts({
    -        user: alertIds.user,
    -        system: alertIds.system,
    -      })
    +      const payload: CloudPulseAlertsPayload = {
    +        user_alerts: alertIds.user_alerts,
    +        system_alerts: alertIds.system_alerts,
    +      };
    +      // call updateAlerts mutation with the transformed payload based on the service type
    +      updateAlerts(
    +        servicePayloadTransformerMap[serviceType]?.(payload) ?? payload
    +      )
             .then(() => {
               enqueueSnackbar('Your settings for alerts have been saved.', {
                 variant: 'success',
               });
               // Reset the state to sync with the updated alerts from API
               resetToInitialState();
    +          invalidateAclpAlerts(queryClient, serviceType, entityId, payload);
             })
             .catch(() => {
               enqueueSnackbar('Alerts changes were not saved, please try again.', {
    @@ -205,11 +217,11 @@ export const AlertInformationActionTable = (
         (alert: Alert) => {
           setEnabledAlerts((prev: CloudPulseAlertsPayload) => {
             const newPayload = {
    -          system: [...(prev.system ?? [])],
    -          user: [...(prev.user ?? [])],
    +          system_alerts: [...(prev.system_alerts ?? [])],
    +          user_alerts: [...(prev.user_alerts ?? [])],
             };
     
    -        const alertIds = newPayload[alert.type];
    +        const alertIds = newPayload[payloadAlertType(alert)];
             const isCurrentlyEnabled = alertIds.includes(alert.id);
     
             if (isCurrentlyEnabled) {
    @@ -222,8 +234,8 @@ export const AlertInformationActionTable = (
             }
     
             const hasNewUnsavedChanges =
    -          !compareArrays(newPayload.system ?? [], initialState.system ?? []) ||
    -          !compareArrays(newPayload.user ?? [], initialState.user ?? []);
    +          !arraysEqual(newPayload.system_alerts, initialState.system_alerts) ||
    +          !arraysEqual(newPayload.user_alerts, initialState.user_alerts);
     
             // Call onToggleAlert in both create and edit flow
             if (onToggleAlert) {
    @@ -314,10 +326,9 @@ export const AlertInformationActionTable = (
                               if (!(isEditMode || isCreateMode)) {
                                 return null;
                               }
    -
    -                          const status = enabledAlerts[alert.type]?.includes(
    -                            alert.id
    -                          );
    +                          const status = enabledAlerts[
    +                            payloadAlertType(alert)
    +                          ]?.includes(alert.id);
     
                               return (
                                  {
       it('should return empty initial state when no entityId provided', () => {
         const alerts = alertFactory.buildList(3);
         const { result } = renderHook(() => useContextualAlertsState(alerts));
    -    expect(result.current.initialState).toEqual({ system: [], user: [] });
    +    expect(result.current.initialState).toEqual({
    +      system_alerts: [],
    +      user_alerts: [],
    +    });
       });
     
       it('should include alerts that match entityId or account/region level alerts in initial states', () => {
    @@ -284,9 +288,9 @@ describe('useContextualAlertsState', () => {
           useContextualAlertsState(alerts, entityId)
         );
     
    -    expect(result.current.initialState.system).toContain(1);
    -    expect(result.current.initialState.system).toContain(3);
    -    expect(result.current.initialState.user).toContain(2);
    +    expect(result.current.initialState.system_alerts).toContain(1);
    +    expect(result.current.initialState.system_alerts).toContain(3);
    +    expect(result.current.initialState.user_alerts).toContain(2);
       });
     
       it('should detect unsaved changes when alerts are modified', () => {
    @@ -309,7 +313,7 @@ describe('useContextualAlertsState', () => {
         act(() => {
           result.current.setEnabledAlerts((prev) => ({
             ...prev,
    -        system: [...(prev.system ?? []), 999],
    +        system_alerts: [...(prev.system_alerts ?? []), 999],
           }));
         });
     
    @@ -494,3 +498,27 @@ describe('transformDimensionValue', () => {
         ).toBe('Test_value');
       });
     });
    +
    +describe('arraysEqual', () => {
    +  it('should return true when both arrays are empty', () => {
    +    expect(arraysEqual([], [])).toBe(true);
    +  });
    +  it('should return false when one array is empty and the other is not', () => {
    +    expect(arraysEqual([], [1, 2, 3])).toBe(false);
    +  });
    +  it('should return true when arrays are undefined', () => {
    +    expect(arraysEqual(undefined, undefined)).toBe(true);
    +  });
    +  it('should return false when one of the arrays is undefined', () => {
    +    expect(arraysEqual(undefined, [1, 2, 3])).toBe(false);
    +  });
    +  it('should return true when arrays are equal', () => {
    +    expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true);
    +  });
    +  it('should return false when arrays are not equal', () => {
    +    expect(arraysEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false);
    +  });
    +  it('should return true when arrays have same elements but in different order', () => {
    +    expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true);
    +  });
    +});
    diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
    index f8b9e9b993e..e756d6831e5 100644
    --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
    +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
    @@ -17,6 +17,7 @@ import {
       DIMENSION_TRANSFORM_CONFIG,
       TRANSFORMS,
     } from '../../shared/DimensionTransform';
    +import { compareArrays } from '../../Utils/FilterBuilder';
     import { aggregationTypeMap, metricOperatorTypeMap } from '../constants';
     
     import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect';
    @@ -618,3 +619,23 @@ export const transformDimensionValue = (
         )?.(value) ?? TRANSFORMS.capitalize(value)
       );
     };
    +
    +/**
    + * Checks if two arrays are equal, ignores the order of the elements
    + * @param a The first array
    + * @param b The second array
    + * @returns True if the arrays are equal, false otherwise
    + */
    +export const arraysEqual = (
    +  a: number[] | undefined,
    +  b: number[] | undefined
    +) => {
    +  if (a === undefined && b === undefined) return true;
    +  if (a === undefined || b === undefined) return false;
    +  if (a.length !== b.length) return false;
    +
    +  return compareArrays(
    +    [...a].sort((x, y) => x - y),
    +    [...b].sort((x, y) => x - y)
    +  );
    +};
    diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts
    index 8916d7fedce..7829f8de042 100644
    --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts
    +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts
    @@ -5,6 +5,7 @@ import React from 'react';
     import { convertData } from 'src/features/Longview/shared/formatters';
     import { useFlags } from 'src/hooks/useFlags';
     
    +import { arraysEqual } from '../Alerts/Utils/utils';
     import {
       INTERFACE_ID,
       INTERFACE_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE,
    @@ -19,7 +20,6 @@ import {
       PORTS_LIMIT_ERROR_MESSAGE,
       PORTS_RANGE_ERROR_MESSAGE,
     } from './constants';
    -import { compareArrays } from './FilterBuilder';
     
     import type {
       Alert,
    @@ -93,8 +93,8 @@ export const useContextualAlertsState = (
       const calculateInitialState = React.useCallback(
         (alerts: Alert[], entityId?: string): CloudPulseAlertsPayload => {
           const initialStates: CloudPulseAlertsPayload = {
    -        system: [],
    -        user: [],
    +        system_alerts: [],
    +        user_alerts: [],
           };
     
           alerts.forEach((alert) => {
    @@ -107,7 +107,9 @@ export const useContextualAlertsState = (
               : isAccountOrRegion;
     
             if (shouldInclude) {
    -          initialStates[alert.type]?.push(alert.id);
    +          const payloadAlertType =
    +            alert.type === 'system' ? 'system_alerts' : 'user_alerts';
    +          initialStates[payloadAlertType]?.push(alert.id);
             }
           });
     
    @@ -131,8 +133,8 @@ export const useContextualAlertsState = (
       // Check if the enabled alerts have changed from the initial state
       const hasUnsavedChanges = React.useMemo(() => {
         return (
    -      !compareArrays(enabledAlerts.system ?? [], initialState.system ?? []) ||
    -      !compareArrays(enabledAlerts.user ?? [], initialState.user ?? [])
    +      !arraysEqual(enabledAlerts.system_alerts, initialState.system_alerts) ||
    +      !arraysEqual(enabledAlerts.user_alerts, initialState.user_alerts)
         );
       }, [enabledAlerts, initialState]);
     
    diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
    index 5161ad1b6c4..e2af0ce0371 100644
    --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
    +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
    @@ -27,7 +27,7 @@ export const Alerts = ({
       const { field } = useController({
         control,
         name: 'alerts',
    -    defaultValue: { system: [], user: [] },
    +    defaultValue: { system_alerts: [], user_alerts: [] },
       });
     
       const handleToggleAlert = (updatedAlerts: CloudPulseAlertsPayload) => {
    diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
    index e6659c55867..7cb67eca885 100644
    --- a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
    +++ b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
    @@ -104,11 +104,11 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
         isAlertsBetaMode;
     
       const totalBetaAclpAlertsAssignedCount =
    -    (alerts?.system?.length ?? 0) + (alerts?.user?.length ?? 0);
    +    (alerts?.system_alerts?.length ?? 0) + (alerts?.user_alerts?.length ?? 0);
     
       const betaAclpAlertsAssignedList = [
    -    ...(alerts?.system ?? []),
    -    ...(alerts?.user ?? []),
    +    ...(alerts?.system_alerts ?? []),
    +    ...(alerts?.user_alerts ?? []),
       ].join(', ');
     
       const betaAclpAlertsAssignedDetails =
    diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
    index 271b9df67a9..45473a8904b 100644
    --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
    +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
    @@ -5,7 +5,7 @@ import {
     } from '@linode/queries';
     import { useIsLinodeAclpSubscribed } from '@linode/shared';
     import { ActionsPanel, Divider, Notice, Paper, Typography } from '@linode/ui';
    -import { alertsSchema } from '@linode/validation';
    +import { UpdateLinodeAlertsSchema } from '@linode/validation';
     import { styled } from '@mui/material/styles';
     import { useFormik } from 'formik';
     import { useSnackbar } from 'notistack';
    @@ -81,7 +81,7 @@ export const AlertsPanel = (props: Props) => {
         enableReinitialize: true,
         initialValues,
         validateOnChange: true,
    -    validationSchema: alertsSchema,
    +    validationSchema: UpdateLinodeAlertsSchema,
         async onSubmit({ cpu, io, network_in, network_out, transfer_quota }) {
           await updateLinode({
             alerts: {
    diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
    index 35dab25ceb8..0f3a5be3bd8 100644
    --- a/packages/manager/src/mocks/serverHandlers.ts
    +++ b/packages/manager/src/mocks/serverHandlers.ts
    @@ -1008,8 +1008,8 @@ export const handlers = [
               label: 'aclp-supported-region-linode-1',
               region: 'us-iad',
               alerts: {
    -            user: [21, 22, 23, 24, 25],
    -            system: [19, 20],
    +            user_alerts: [21, 22, 23, 24, 25],
    +            system_alerts: [19, 20],
                 cpu: 0,
                 io: 0,
                 network_in: 0,
    @@ -1024,8 +1024,8 @@ export const handlers = [
               label: 'aclp-supported-region-linode-2',
               region: 'us-east',
               alerts: {
    -            user: [],
    -            system: [],
    +            user_alerts: [],
    +            system_alerts: [],
                 cpu: 10,
                 io: 10000,
                 network_in: 0,
    @@ -1043,8 +1043,8 @@ export const handlers = [
               label: 'aclp-supported-region-linode-3',
               region: 'us-iad',
               alerts: {
    -            user: [],
    -            system: [],
    +            user_alerts: [],
    +            system_alerts: [],
                 cpu: 0,
                 io: 0,
                 network_in: 0,
    @@ -2865,6 +2865,13 @@ export const handlers = [
                 scope: 'region',
                 regions: ['us-east'],
               }),
    +          ...alertFactory.buildList(6, {
    +            service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode',
    +            type: 'user',
    +            scope: 'entity',
    +            regions: ['us-east'],
    +            entity_ids: ['5', '6'],
    +          }),
             ],
           });
         }
    diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts
    index 8b78ef91fa8..c0ea1b86b13 100644
    --- a/packages/manager/src/queries/cloudpulse/alerts.ts
    +++ b/packages/manager/src/queries/cloudpulse/alerts.ts
    @@ -15,6 +15,7 @@ import {
     } from '@tanstack/react-query';
     
     import { queryFactory } from './queries';
    +import { invalidateAclpAlerts } from './useAlertsMutation';
     
     import type {
       Alert,
    @@ -248,48 +249,12 @@ export const useServiceAlertsMutation = (
       entityId: string
     ) => {
       const queryClient = useQueryClient();
    -  return useMutation<{}, APIError[], CloudPulseAlertsPayload>({
    +  return useMutation({
         mutationFn: (payload: CloudPulseAlertsPayload) => {
           return updateServiceAlerts(serviceType, entityId, payload);
         },
         onSuccess(_, payload) {
    -      const allAlerts = queryClient.getQueryData(
    -        queryFactory.alerts._ctx.all().queryKey
    -      );
    -
    -      // Get alerts previously enabled for this entity
    -      const oldEnabledAlertIds =
    -        allAlerts
    -          ?.filter((alert) => alert.entity_ids.includes(entityId))
    -          .map((alert) => alert.id) || [];
    -
    -      // Combine enabled user and system alert IDs from payload
    -      const newEnabledAlertIds = [
    -        ...(payload.user ?? []),
    -        ...(payload.system ?? []),
    -      ];
    -
    -      // Get unique list of all enabled alert IDs for cache invalidation
    -      const alertIdsToInvalidate = Array.from(
    -        new Set([...oldEnabledAlertIds, ...newEnabledAlertIds])
    -      );
    -
    -      queryClient.invalidateQueries({
    -        queryKey: queryFactory.resources(serviceType).queryKey,
    -      });
    -
    -      queryClient.invalidateQueries({
    -        queryKey: queryFactory.alerts._ctx.all().queryKey,
    -      });
    -
    -      alertIdsToInvalidate.forEach((alertId) => {
    -        queryClient.invalidateQueries({
    -          queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId(
    -            serviceType,
    -            String(alertId)
    -          ).queryKey,
    -        });
    -      });
    +      invalidateAclpAlerts(queryClient, serviceType, entityId, payload);
         },
       });
     };
    diff --git a/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts
    new file mode 100644
    index 00000000000..49ff9f3a751
    --- /dev/null
    +++ b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts
    @@ -0,0 +1,122 @@
    +import {
    +  type CloudPulseAlertsPayload,
    +  type CloudPulseServiceType,
    +  type DeepPartial,
    +  type Linode,
    +} from '@linode/api-v4';
    +import { useLinodeUpdateMutation } from '@linode/queries';
    +
    +import { queryFactory } from './queries';
    +
    +import type { Alert, LinodeAlerts } from '@linode/api-v4/lib/cloudpulse';
    +import type { QueryClient } from '@linode/queries';
    +
    +/**
    + * The alert type overrides for a given service type.
    + * It contains the payload transformer function type and the response type.
    + * This is used for types only, not to be used anywhere else.
    + */
    +interface AlertTypeOverrides {
    +  linode: (basePayload: LinodeAlerts) => DeepPartial;
    +  // Future overrides go here (e.g. dbaas, ...)
    +}
    +
    +/**
    + * The type of the payload transformer function for a given service type.
    + */
    +type AlertPayloadTransformerFn =
    +  T extends keyof AlertTypeOverrides
    +    ? AlertTypeOverrides[T]
    +    : (basePayload: CloudPulseAlertsPayload) => CloudPulseAlertsPayload;
    +
    +/**
    + * Type of the service payload transformer map
    + */
    +export type ServicePayloadTransformerMap = Partial<{
    +  [K in CloudPulseServiceType]: AlertPayloadTransformerFn;
    +}>;
    +
    +/**
    + * Service payload transformer map
    + */
    +export const servicePayloadTransformerMap: ServicePayloadTransformerMap = {
    +  linode: (basePayload: LinodeAlerts) => ({ alerts: basePayload }),
    +  // Future transformers go here (e.g. dbaas, ...)
    +};
    +
    +/**
    + *
    + * @param serviceType service type
    + * @param entityId entity id
    + * @returns alerts mutation
    + */
    +export const useAlertsMutation = (
    +  serviceType: CloudPulseServiceType,
    +  entityId: string
    +) => {
    +  // linode api alerts mutation
    +  const { mutateAsync: updateLinode } = useLinodeUpdateMutation(
    +    Number(entityId)
    +  );
    +
    +  switch (serviceType) {
    +    case 'linode':
    +      return updateLinode;
    +    default:
    +      return (_payload: CloudPulseAlertsPayload) =>
    +        Promise.reject(new Error('Error encountered'));
    +  }
    +};
    +
    +/**
    + * Invalidates the alerts cache
    + * @param qc The query client
    + * @param serviceType The service type
    + * @param entityId The entity id
    + * @param payload The payload
    + */
    +export const invalidateAclpAlerts = (
    +  queryClient: QueryClient,
    +  serviceType: string,
    +  entityId: string | undefined,
    +  payload: CloudPulseAlertsPayload
    +) => {
    +  if (!entityId) return;
    +
    +  const allAlerts = queryClient.getQueryData(
    +    queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey
    +  );
    +
    +  // Get alerts previously enabled for this entity
    +  const oldEnabledAlertIds =
    +    allAlerts
    +      ?.filter((alert) => alert.entity_ids.includes(entityId))
    +      .map((alert) => alert.id) || [];
    +
    +  // Combine enabled user and system alert IDs from payload
    +  const newEnabledAlertIds = [
    +    ...(payload.user_alerts ?? []),
    +    ...(payload.system_alerts ?? []),
    +  ];
    +
    +  // Get unique list of all enabled alert IDs for cache invalidation
    +  const alertIdsToInvalidate = [...oldEnabledAlertIds, ...newEnabledAlertIds];
    +
    +  queryClient.invalidateQueries({
    +    queryKey: queryFactory.alerts._ctx.all().queryKey,
    +  });
    +
    +  queryClient.invalidateQueries({
    +    queryKey:
    +      queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey,
    +  });
    +
    +  alertIdsToInvalidate.forEach((alertId) => {
    +    queryClient.invalidateQueries({
    +      queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId(
    +        serviceType,
    +        String(alertId)
    +      ).queryKey,
    +    });
    +  });
    +};
    diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
    index 1c9aac39bb5..acb95442383 100644
    --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
    +++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
    @@ -47,8 +47,8 @@ describe('useIsLinodeAclpSubscribed', () => {
               network_in: 0,
               network_out: 0,
               transfer_quota: 0,
    -          system: [],
    -          user: [],
    +          system_alerts: [],
    +          user_alerts: [],
             },
           },
         });
    @@ -67,8 +67,8 @@ describe('useIsLinodeAclpSubscribed', () => {
               network_in: 0,
               network_out: 0,
               transfer_quota: 0,
    -          system: [],
    -          user: [],
    +          system_alerts: [],
    +          user_alerts: [],
             },
           },
         });
    @@ -87,8 +87,8 @@ describe('useIsLinodeAclpSubscribed', () => {
               network_in: 0,
               network_out: 0,
               transfer_quota: 0,
    -          system: [],
    -          user: [],
    +          system_alerts: [],
    +          user_alerts: [],
             },
           },
         });
    @@ -107,8 +107,8 @@ describe('useIsLinodeAclpSubscribed', () => {
               network_in: 0,
               network_out: 0,
               transfer_quota: 0,
    -          system: [100],
    -          user: [],
    +          system_alerts: [100],
    +          user_alerts: [],
             },
           },
         });
    @@ -127,8 +127,8 @@ describe('useIsLinodeAclpSubscribed', () => {
               network_in: 0,
               network_out: 0,
               transfer_quota: 0,
    -          system: [100],
    -          user: [200],
    +          system_alerts: [100],
    +          user_alerts: [200],
             },
           },
         });
    diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
    index df71aa5b692..a2667c199e2 100644
    --- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
    +++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
    @@ -39,8 +39,8 @@ export const useIsLinodeAclpSubscribed = (
         (linode.alerts.transfer_quota ?? 0) > 0;
     
       const hasAclpAlerts =
    -    (linode.alerts.system?.length ?? 0) > 0 ||
    -    (linode.alerts.user?.length ?? 0) > 0;
    +    (linode.alerts.system_alerts?.length ?? 0) > 0 ||
    +    (linode.alerts.user_alerts?.length ?? 0) > 0;
     
       // Always subscribed if ACLP alerts exist. For GA stage, default to subscribed if no alerts exist.
       return (
    diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts
    index 3550da808b5..bb30e34fc89 100644
    --- a/packages/validation/src/linodes.schema.ts
    +++ b/packages/validation/src/linodes.schema.ts
    @@ -363,15 +363,41 @@ const DiskEncryptionSchema = string()
       .oneOf(['enabled', 'disabled'])
       .notRequired();
     
    -export const alertsSchema = object({
    -  cpu: number()
    -    .required('CPU Usage is required.')
    +/**
    + * A number field schema with conditional validation for legacy alert fields.
    + * @param label - The label used in the required error message.
    + * @returns A number schema with conditional validation.
    + */
    +const legacyAlertsFieldSchema = (
    +  label:
    +    | 'CPU Usage'
    +    | 'Disk I/O Rate'
    +    | 'Incoming Traffic'
    +    | 'Outbound Traffic'
    +    | 'Transfer Quota',
    +) =>
    +  // If system_alerts and user_alerts are undefined, then it is legacy alerts context.
    +  // If it is legacy alerts context, then the field is required.
    +  number().when(['system_alerts', 'user_alerts'], {
    +    is: (systemAlerts?: number[], userAlerts?: number[]) => {
    +      return systemAlerts === undefined && userAlerts === undefined;
    +    },
    +    then: (schema) => schema.required(`${label} is required.`),
    +    otherwise: (schema) => schema.notRequired(),
    +  });
    +
    +export const UpdateLinodeAlertsSchema = object({
    +  // Legacy numeric-threshold alerts. All fields are required to update legacy alerts, but not for ACLP alerts.
    +  cpu: legacyAlertsFieldSchema('CPU Usage')
         .min(0, 'Must be between 0 and 4800')
         .max(4800, 'Must be between 0 and 4800'),
    -  network_in: number().required('Incoming Traffic is required.'),
    -  network_out: number().required('Outbound Traffic is required.'),
    -  transfer_quota: number().required('Transfer Quota is required.'),
    -  io: number().required('Disk I/O Rate is required.'),
    +  network_in: legacyAlertsFieldSchema('Incoming Traffic'),
    +  network_out: legacyAlertsFieldSchema('Outbound Traffic'),
    +  transfer_quota: legacyAlertsFieldSchema('Transfer Quota'),
    +  io: legacyAlertsFieldSchema('Disk I/O Rate'),
    +  // ACLP alerts. All fields are required to update ACLP alerts, but not for legacy alerts.
    +  system_alerts: array().of(number().defined()).notRequired(),
    +  user_alerts: array().of(number().defined()).notRequired(),
     });
     
     const schedule = object({
    @@ -420,7 +446,7 @@ export const UpdateLinodeSchema = object({
         .max(64, LINODE_LABEL_CHAR_REQUIREMENT),
       tags: array().of(string()).notRequired(),
       watchdog_enabled: boolean().notRequired(),
    -  alerts: alertsSchema.notRequired().default(undefined),
    +  alerts: UpdateLinodeAlertsSchema.notRequired().default(undefined),
       backups,
     });
     
    @@ -656,9 +682,9 @@ const CreateVlanInterfaceSchema = object({
       ipam_address: string().nullable(),
     });
     
    -const AclpAlertsPayloadSchema = object({
    -  system: array().of(number().defined()).required(),
    -  user: array().of(number().defined()).required(),
    +const CreateLinodeAclpAlertsSchema = object({
    +  system_alerts: array().of(number().defined()).required(),
    +  user_alerts: array().of(number().defined()).required(),
     });
     
     export const CreateVPCInterfaceSchema = object({
    @@ -833,5 +859,5 @@ export const CreateLinodeSchema = object({
         .oneOf(['linode/migrate', 'linode/power_off_on', undefined])
         .notRequired()
         .nullable(),
    -  alerts: AclpAlertsPayloadSchema.notRequired().default(undefined),
    +  alerts: CreateLinodeAclpAlertsSchema.notRequired().default(undefined),
     });
    
    From 41f5418fb87af866431b1cfe64a86cb83bc8c313 Mon Sep 17 00:00:00 2001
    From: Dmytro Chyrva 
    Date: Fri, 19 Sep 2025 18:58:28 +0200
    Subject: [PATCH 09/54] fix: [STORIF-101] - Volume deletion from the Volume
     Details page (#12894)
    
    ---
     .../.changeset/pr-12894-fixed-1758298095602.md       |  5 +++++
     .../features/Volumes/Dialogs/DeleteVolumeDialog.tsx  | 11 +++++++++--
     .../features/Volumes/VolumeDetails/VolumeDetails.tsx | 12 +++++++++++-
     .../features/Volumes/VolumeDrawers/VolumeDrawers.tsx |  7 ++++++-
     .../manager/src/features/Volumes/VolumesLanding.tsx  |  5 ++++-
     5 files changed, 35 insertions(+), 5 deletions(-)
     create mode 100644 packages/manager/.changeset/pr-12894-fixed-1758298095602.md
    
    diff --git a/packages/manager/.changeset/pr-12894-fixed-1758298095602.md b/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
    new file mode 100644
    index 00000000000..b41af7cdf2c
    --- /dev/null
    +++ b/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
    @@ -0,0 +1,5 @@
    +---
    +"@linode/manager": Fixed
    +---
    +
    +Navigation after successful volume deletion ([#12894](https://github.com/linode/manager/pull/12894))
    diff --git a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
    index 13c4a5b65e4..938da3ddabf 100644
    --- a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
    +++ b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
    @@ -9,13 +9,15 @@ import type { APIError, Volume } from '@linode/api-v4';
     interface Props {
       isFetching?: boolean;
       onClose: () => void;
    +  onDeleteSuccess?: () => void;
       open: boolean;
       volume: undefined | Volume;
       volumeError?: APIError[] | null;
     }
     
     export const DeleteVolumeDialog = (props: Props) => {
    -  const { isFetching, onClose, open, volume, volumeError } = props;
    +  const { isFetching, onClose, onDeleteSuccess, open, volume, volumeError } =
    +    props;
     
       const {
         error,
    @@ -27,7 +29,12 @@ export const DeleteVolumeDialog = (props: Props) => {
     
       const onDelete = () => {
         deleteVolume({ id: volume?.id ?? -1 }).then(() => {
    -      onClose();
    +      if (onDeleteSuccess) {
    +        onDeleteSuccess();
    +      } else {
    +        onClose();
    +      }
    +
           checkForNewEvents();
         });
       };
    diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
    index d29983324a9..9a500492981 100644
    --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
    +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
    @@ -36,6 +36,13 @@ export const VolumeDetails = () => {
         return ;
       }
     
    +  const navigateToVolumes = () => {
    +    navigate({
    +      search: (prev) => prev,
    +      to: '/volumes',
    +    });
    +  };
    +
       const navigateToVolumeSummary = () => {
         navigate({
           search: (prev) => prev,
    @@ -62,7 +69,10 @@ export const VolumeDetails = () => {
             
           
     
    -      
    +      
         
       );
     };
    diff --git a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
    index 409617819e7..6745c377f16 100644
    --- a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
    +++ b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
    @@ -14,9 +14,13 @@ import { VolumeDetailsDrawer } from './VolumeDetailsDrawer';
     
     interface Props {
       onCloseHandler: () => void;
    +  onDeleteSuccessHandler: () => void;
     }
     
    -export const VolumeDrawers = ({ onCloseHandler }: Props) => {
    +export const VolumeDrawers = ({
    +  onCloseHandler,
    +  onDeleteSuccessHandler,
    +}: Props) => {
       const params = useParams({ strict: false });
     
       const {
    @@ -85,6 +89,7 @@ export const VolumeDrawers = ({ onCloseHandler }: Props) => {
            {
             pageSize={pagination.pageSize}
           />
     
    -      
    +      
         
       );
     };
    
    From 9af32a7fcc3a1ab7fb4801ea667483b184552789 Mon Sep 17 00:00:00 2001
    From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
    Date: Fri, 19 Sep 2025 14:15:52 -0400
    Subject: [PATCH 10/54] test: [M3-10365] - LKE-E "postLa" feature flag smoke
     tests (#12886)
    
    * Add LKE-E "Post-LA" feature flag smoke tests for LKE create page
    
    * Add LKE-E "Post-LA" feature flag smoke tests for LKE details page
    
    * Organize and consolidate mock setup
    
    * Added changeset: Add LKE-E Post-LA feature flag smoke tests
    ---
     .../pr-12886-tests-1758051113240.md           |   5 +
     .../core/cloudpulse/create-user-alert.spec.ts |   2 +-
     .../core/cloudpulse/edit-system-alert.spec.ts |   2 +-
     .../cloudpulse/timerange-verification.spec.ts |   2 -
     .../kubernetes/smoke-lke-enterprise.spec.ts   | 980 ++++++++++++------
     .../cypress/support/intercepts/cloudpulse.ts  |   2 +-
     .../SelectFirewallPanel.tsx                   |   5 +-
     .../DimensionFilterValue/utils.test.ts        |   8 +-
     8 files changed, 657 insertions(+), 349 deletions(-)
     create mode 100644 packages/manager/.changeset/pr-12886-tests-1758051113240.md
    
    diff --git a/packages/manager/.changeset/pr-12886-tests-1758051113240.md b/packages/manager/.changeset/pr-12886-tests-1758051113240.md
    new file mode 100644
    index 00000000000..c6a4d334d42
    --- /dev/null
    +++ b/packages/manager/.changeset/pr-12886-tests-1758051113240.md
    @@ -0,0 +1,5 @@
    +---
    +"@linode/manager": Tests
    +---
    +
    +Add LKE-E Post-LA feature flag smoke tests ([#12886](https://github.com/linode/manager/pull/12886))
    diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
    index 69a5c06847d..e2b882a866b 100644
    --- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
    +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
    @@ -499,4 +499,4 @@ describe('Create Firewall Alert Successfully', () => {
           });
         });
       });
    -});
    \ No newline at end of file
    +});
    diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
    index 315ce8a3135..b7b8f7a7c7b 100644
    --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
    +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
    @@ -267,4 +267,4 @@ describe('Integration Tests for Edit Alert', () => {
           ui.toast.assertMessage('Alert entities successfully updated.');
         });
       });
    -});
    \ No newline at end of file
    +});
    diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
    index a24540c3bb7..d94b7e32ff4 100644
    --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
    +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
    @@ -279,7 +279,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
           cy.get(`[aria-label="${startHour} hours"]`).click();
         });
     
    -
         cy.findByLabelText('Select minutes')
           .as('selectMinutes')
           .scrollIntoView({ duration: 500, easing: 'linear' });
    @@ -288,7 +287,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
           cy.get(`[aria-label="${startMinute} minutes"]`).click();
         });
     
    -
         cy.findByLabelText('Select meridiem')
           .as('startMeridiemSelect')
           .scrollIntoView();
    diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
    index 86f121919f5..1a9f1ec1076 100644
    --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
    +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
    @@ -1,10 +1,9 @@
     /**
      * Tests basic functionality for LKE-E feature-flagged work.
    - * TODO: M3-10365 - Add `postLa` smoke tests to this file.
      * TODO: M3-8838 - Delete this spec file once LKE-E is released to GA.
      */
     
    -import { regionFactory } from '@linode/utilities';
    +import { linodeTypeFactory, regionFactory } from '@linode/utilities';
     import {
       accountFactory,
       kubernetesClusterFactory,
    @@ -15,23 +14,32 @@ import {
     import {
       latestEnterpriseTierKubernetesVersion,
       minimumNodeNotice,
    +  mockTieredEnterpriseVersions,
    +  mockTieredStandardVersions,
     } from 'support/constants/lke';
     import { mockGetAccount } from 'support/intercepts/account';
     import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags';
    +import {
    +  mockGetLinodeType,
    +  mockGetLinodeTypes,
    +} from 'support/intercepts/linodes';
     import {
       mockCreateCluster,
       mockGetCluster,
       mockGetClusterPools,
    +  mockGetClusters,
       mockGetTieredKubernetesVersions,
     } from 'support/intercepts/lke';
    -import { mockGetClusters } from 'support/intercepts/lke';
     import {} from 'support/intercepts/profile';
     import { mockGetRegions } from 'support/intercepts/regions';
     import { mockGetVPC } from 'support/intercepts/vpc';
     import { ui } from 'support/ui';
    +import { lkeClusterCreatePage } from 'support/ui/pages';
     import { addNodes } from 'support/util/lke';
     import { randomLabel } from 'support/util/random';
     
    +import { extendType } from 'src/utilities/extendType';
    +
     const mockCluster = kubernetesClusterFactory.build({
       id: 1,
       vpc_id: 123,
    @@ -39,6 +47,12 @@ const mockCluster = kubernetesClusterFactory.build({
       tier: 'enterprise',
     });
     
    +const mockPlan = extendType(
    +  linodeTypeFactory.build({
    +    class: 'dedicated',
    +  })
    +);
    +
     const mockVPC = vpcFactory.build({
       id: 123,
       label: 'lke-e-vpc',
    @@ -48,18 +62,10 @@ const mockVPC = vpcFactory.build({
     const mockNodePools = [nodePoolFactory.build()];
     
     // Mock a valid region for LKE-E to avoid test flake.
    -const mockRegions = [
    -  regionFactory.build({
    -    capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'],
    -    id: 'us-iad',
    -    label: 'Washington, DC',
    -  }),
    -];
    +const mockRegion = regionFactory.build({
    +  capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'],
    +});
     
    -/**
    - * - Confirms VPC and IP Stack selections are shown with the respective `phase2Mtc` feature flags enabled.
    - * - Confirms VPC and IP Stack selections are not shown in create flow with their respective `phase2Mtc` feature flags disabled.
    - */
     describe('LKE-E Cluster Create', () => {
       beforeEach(() => {
         mockGetAccount(
    @@ -71,290 +77,446 @@ describe('LKE-E Cluster Create', () => {
             ],
           })
         ).as('getAccount');
    -  });
    -
    -  it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        postLa: false,
    -        phase2Mtc: { byoVPC: true, dualStack: false },
    -      },
    -    }).as('getFeatureFlags');
    -
    +    mockGetRegions([mockRegion]);
    +    mockGetLinodeTypes([mockPlan]);
    +    mockGetLinodeType(mockPlan);
    +    mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions);
    +    mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions);
         mockCreateCluster(mockCluster).as('createCluster');
    -    mockGetTieredKubernetesVersions('enterprise', [
    -      latestEnterpriseTierKubernetesVersion,
    -    ]).as('getTieredKubernetesVersions');
    -    mockGetRegions(mockRegions);
    -
    -    cy.visitWithLogin('/kubernetes/create');
    -    cy.findByText('Add Node Pools').should('be.visible');
    -
    -    cy.findByLabelText('Cluster Label').click();
    -    cy.focused().type(mockCluster.label);
    -
    -    cy.findByText('LKE Enterprise').click();
    -
    -    ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
    -    ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
    -
    -    cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    -    cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    -      .should('be.visible')
    -      .click();
    +  });
     
    -    // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF.
    -    cy.findByText('IP Stack').should('not.exist');
    -    cy.findByText('IPv4', { exact: true }).should('not.exist');
    -    cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
    +  /*
    +   * Smoke tests to confirm the state of the LKE Create page when the LKE-E
    +   * Post-LA feature flag is enabled and disabled.
    +   *
    +   * The Post-LA feature flag introduces the "Configure Node Pool" button and
    +   * flow when choosing node pools during the create flow. When disabled, it's
    +   * expected that users can add node pools from directly within the plan table.
    +   * When the flag is enabled, users instead select the plan they want and
    +   * configure the pool from within a new drawer. Additional configuration options
    +   * are available for LKE-E clusters as well.
    +   */
    +  describe('Post-LA feature flag', () => {
    +    /*
    +     * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is enabled.
    +     * - Confirms that node pools are configured via new drawer.
    +     */
    +    it('Simple Page Check - Post LA Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: true,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +        },
    +      });
     
    -    // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON.
    -    cy.findByText('Automatically generate a VPC for this cluster').should(
    -      'be.visible'
    -    );
    -    cy.findByText('Use an existing VPC').should('be.visible');
    +      cy.visitWithLogin('/kubernetes/create');
     
    -    cy.findByText('Shared CPU').should('be.visible').click();
    -    addNodes('Linode 2 GB');
    +      lkeClusterCreatePage.setLabel(randomLabel());
    +      lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
    +      lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
    +      lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel);
     
    -    // Bypass ACL validation
    -    cy.get('input[name="acl-acknowledgement"]').check();
    +      // Confirm that the "Configure Node Pool" drawer appears.
    +      lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => {
    +        ui.button
    +          .findByTitle('Add Pool')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
    +      });
     
    -    // Confirm change is reflected in checkout bar.
    -    cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    -      cy.findByText('Linode 2 GB Plan').should('be.visible');
    -      cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
    +      // Confirm that "Edit Configuration" button is shown for each node pool
    +      // in the order summary section.
    +      lkeClusterCreatePage.withinOrderSummary(() => {
    +        cy.contains(mockPlan.formattedLabel)
    +          .closest('[data-testid="node-pool-summary"]')
    +          .within(() => {
    +            cy.findByText('Edit Configuration').should('be.visible');
    +          });
    +      });
    +    });
     
    -      cy.get('[data-qa-notice="true"]').within(() => {
    -        cy.findByText(minimumNodeNotice).should('be.visible');
    +    /*
    +     * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is disabled.
    +     * - Confirms that node pools are added directly via the plan table.
    +     */
    +    it('Simple Page Check - Post LA Flag OFF', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: false,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +        },
           });
     
    -      ui.button
    -        .findByTitle('Create Cluster')
    -        .should('be.visible')
    -        .should('be.enabled')
    -        .click();
    +      cy.visitWithLogin('/kubernetes/create');
    +
    +      lkeClusterCreatePage.setLabel(randomLabel());
    +      lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
    +      lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
    +
    +      // Add a node pool with a custom number of nodes, confirm that
    +      // it gets added to the summary as expected.
    +      lkeClusterCreatePage.addNodePool(mockPlan.formattedLabel, 5);
    +
    +      lkeClusterCreatePage.withinOrderSummary(() => {
    +        cy.contains(mockPlan.formattedLabel)
    +          .closest('[data-testid="node-pool-summary"]')
    +          .within(() => {
    +            // Confirm that fields to edit the node pool size are present and enabled.
    +            cy.findByLabelText('Subtract 1')
    +              .should('be.visible')
    +              .should('be.enabled');
    +            cy.findByLabelText('Add 1')
    +              .should('be.visible')
    +              .should('be.enabled');
    +            cy.findByLabelText('Edit Quantity').should('have.value', '5');
    +          });
    +      });
         });
    -
    -    cy.wait('@createCluster');
    -    cy.url().should(
    -      'endWith',
    -      `/kubernetes/clusters/${mockCluster.id}/summary`
    -    );
       });
     
    -  it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        postLa: false,
    -        phase2Mtc: { byoVPC: false, dualStack: true },
    -      },
    -    }).as('getFeatureFlags');
    -
    -    mockCreateCluster(mockCluster).as('createCluster');
    -    mockGetTieredKubernetesVersions('enterprise', [
    -      latestEnterpriseTierKubernetesVersion,
    -    ]).as('getTieredKubernetesVersions');
    -    mockGetRegions(mockRegions);
    +  /**
    +   * - Confirms that VPC options are shown when the `phase2Mtc.byoVPC` feature is enabled.
    +   * - Confirms that IP stack selections are shown when the `phase2Mtc.dualStack` feature is enabled.
    +   * - Confirms that VPC options and IP stack selections are absent when respective `phase2Mtc` options are disabled.
    +   */
    +  describe('Phase 2 MTC feature flag', () => {
    +    it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: false,
    +          phase2Mtc: { byoVPC: true, dualStack: false },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin('/kubernetes/create');
    +      cy.findByText('Add Node Pools').should('be.visible');
    +
    +      cy.findByLabelText('Cluster Label').click();
    +      cy.focused().type(mockCluster.label);
    +
    +      cy.findByText('LKE Enterprise').click();
    +
    +      ui.regionSelect.find().click().type(`${mockRegion.label}`);
    +      ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
    +
    +      cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    +      cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    +        .should('be.visible')
    +        .click();
     
    -    cy.visitWithLogin('/kubernetes/create');
    -    cy.findByText('Add Node Pools').should('be.visible');
    +      // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF.
    +      cy.findByText('IP Stack').should('not.exist');
    +      cy.findByText('IPv4', { exact: true }).should('not.exist');
    +      cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
     
    -    cy.findByLabelText('Cluster Label').click();
    -    cy.focused().type(mockCluster.label);
    +      // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON.
    +      cy.findByText('Automatically generate a VPC for this cluster').should(
    +        'be.visible'
    +      );
    +      cy.findByText('Use an existing VPC').should('be.visible');
    +
    +      cy.findByText('Dedicated CPU').should('be.visible').click();
    +      addNodes(mockPlan.formattedLabel);
    +
    +      // Bypass ACL validation
    +      cy.get('input[name="acl-acknowledgement"]').check();
    +
    +      // Confirm change is reflected in checkout bar.
    +      cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    +        cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
    +        cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
    +          'be.visible'
    +        );
    +
    +        cy.get('[data-qa-notice="true"]').within(() => {
    +          cy.findByText(minimumNodeNotice).should('be.visible');
    +        });
    +
    +        ui.button
    +          .findByTitle('Create Cluster')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
    +      });
     
    -    cy.findByText('LKE Enterprise').click();
    +      cy.wait('@createCluster');
    +      cy.url().should(
    +        'endWith',
    +        `/kubernetes/clusters/${mockCluster.id}/summary`
    +      );
    +    });
     
    -    ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
    -    ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
    +    it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: false,
    +          phase2Mtc: { byoVPC: false, dualStack: true },
    +        },
    +      }).as('getFeatureFlags');
     
    -    cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    -    cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    -      .should('be.visible')
    -      .click();
    +      cy.visitWithLogin('/kubernetes/create');
    +      cy.findByText('Add Node Pools').should('be.visible');
     
    -    // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON.
    -    cy.findByText('IP Stack').should('be.visible');
    -    cy.findByText('IPv4', { exact: true }).should('be.visible');
    -    cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
    +      cy.findByLabelText('Cluster Label').click();
    +      cy.focused().type(mockCluster.label);
     
    -    // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF.
    -    cy.findByText('Automatically generate a VPC for this cluster').should(
    -      'not.exist'
    -    );
    -    cy.findByText('Use an existing VPC').should('not.exist');
    +      cy.findByText('LKE Enterprise').click();
     
    -    cy.findByText('Shared CPU').should('be.visible').click();
    -    addNodes('Linode 2 GB');
    +      ui.regionSelect.find().click().type(`${mockRegion.label}`);
    +      ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
     
    -    // Bypass ACL validation
    -    cy.get('input[name="acl-acknowledgement"]').check();
    +      cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    +      cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    +        .should('be.visible')
    +        .click();
     
    -    // Confirm change is reflected in checkout bar.
    -    cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    -      cy.findByText('Linode 2 GB Plan').should('be.visible');
    -      cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
    +      // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON.
    +      cy.findByText('IP Stack').should('be.visible');
    +      cy.findByText('IPv4', { exact: true }).should('be.visible');
    +      cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
     
    -      cy.get('[data-qa-notice="true"]').within(() => {
    -        cy.findByText(minimumNodeNotice).should('be.visible');
    +      // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF.
    +      cy.findByText('Automatically generate a VPC for this cluster').should(
    +        'not.exist'
    +      );
    +      cy.findByText('Use an existing VPC').should('not.exist');
    +
    +      cy.findByText('Dedicated CPU').should('be.visible').click();
    +      addNodes(mockPlan.formattedLabel);
    +
    +      // Bypass ACL validation
    +      cy.get('input[name="acl-acknowledgement"]').check();
    +
    +      // Confirm change is reflected in checkout bar.
    +      cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    +        cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
    +        cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
    +          'be.visible'
    +        );
    +
    +        cy.get('[data-qa-notice="true"]').within(() => {
    +          cy.findByText(minimumNodeNotice).should('be.visible');
    +        });
    +
    +        ui.button
    +          .findByTitle('Create Cluster')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
           });
     
    -      ui.button
    -        .findByTitle('Create Cluster')
    -        .should('be.visible')
    -        .should('be.enabled')
    -        .click();
    +      cy.wait('@createCluster');
    +      cy.url().should(
    +        'endWith',
    +        `/kubernetes/clusters/${mockCluster.id}/summary`
    +      );
         });
     
    -    cy.wait('@createCluster');
    -    cy.url().should(
    -      'endWith',
    -      `/kubernetes/clusters/${mockCluster.id}/summary`
    -    );
    -  });
    +    it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: false,
    +          phase2Mtc: { byoVPC: true, dualStack: true },
    +        },
    +      }).as('getFeatureFlags');
     
    -  it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        postLa: false,
    -        phase2Mtc: { byoVPC: true, dualStack: true },
    -      },
    -    }).as('getFeatureFlags');
    +      cy.visitWithLogin('/kubernetes/create');
    +      cy.findByText('Add Node Pools').should('be.visible');
     
    -    mockCreateCluster(mockCluster).as('createCluster');
    -    mockGetTieredKubernetesVersions('enterprise', [
    -      latestEnterpriseTierKubernetesVersion,
    -    ]).as('getTieredKubernetesVersions');
    -    mockGetRegions(mockRegions);
    +      cy.findByLabelText('Cluster Label').click();
    +      cy.focused().type(mockCluster.label);
     
    -    cy.visitWithLogin('/kubernetes/create');
    -    cy.findByText('Add Node Pools').should('be.visible');
    +      cy.findByText('LKE Enterprise').click();
     
    -    cy.findByLabelText('Cluster Label').click();
    -    cy.focused().type(mockCluster.label);
    +      ui.regionSelect.find().click().type(`${mockRegion.label}`);
    +      ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
     
    -    cy.findByText('LKE Enterprise').click();
    +      cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    +      cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    +        .should('be.visible')
    +        .click();
     
    -    ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
    -    ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
    +      // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON.
    +      cy.findByText('IP Stack').should('be.visible');
    +      cy.findByText('IPv4', { exact: true }).should('be.visible');
    +      cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
    +      cy.findByText('Automatically generate a VPC for this cluster').should(
    +        'be.visible'
    +      );
    +      cy.findByText('Use an existing VPC').should('be.visible');
    +
    +      cy.findByText('Dedicated CPU').should('be.visible').click();
    +      addNodes(mockPlan.formattedLabel);
    +
    +      // Bypass ACL validation
    +      cy.get('input[name="acl-acknowledgement"]').check();
    +
    +      // Confirm change is reflected in checkout bar.
    +      cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    +        cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
    +        cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
    +          'be.visible'
    +        );
    +
    +        cy.get('[data-qa-notice="true"]').within(() => {
    +          cy.findByText(minimumNodeNotice).should('be.visible');
    +        });
    +
    +        ui.button
    +          .findByTitle('Create Cluster')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
    +      });
     
    -    cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    -    cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    -      .should('be.visible')
    -      .click();
    +      cy.wait('@createCluster');
    +      cy.url().should(
    +        'endWith',
    +        `/kubernetes/clusters/${mockCluster.id}/summary`
    +      );
    +    });
     
    -    // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON.
    -    cy.findByText('IP Stack').should('be.visible');
    -    cy.findByText('IPv4', { exact: true }).should('be.visible');
    -    cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
    -    cy.findByText('Automatically generate a VPC for this cluster').should(
    -      'be.visible'
    -    );
    -    cy.findByText('Use an existing VPC').should('be.visible');
    +    it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: false,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +        },
    +      }).as('getFeatureFlags');
     
    -    cy.findByText('Shared CPU').should('be.visible').click();
    -    addNodes('Linode 2 GB');
    +      cy.visitWithLogin('/kubernetes/create');
    +      cy.findByText('Add Node Pools').should('be.visible');
     
    -    // Bypass ACL validation
    -    cy.get('input[name="acl-acknowledgement"]').check();
    +      cy.findByLabelText('Cluster Label').click();
    +      cy.focused().type(mockCluster.label);
     
    -    // Confirm change is reflected in checkout bar.
    -    cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    -      cy.findByText('Linode 2 GB Plan').should('be.visible');
    -      cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
    +      cy.findByText('LKE Enterprise').click();
     
    -      cy.get('[data-qa-notice="true"]').within(() => {
    -        cy.findByText(minimumNodeNotice).should('be.visible');
    -      });
    +      ui.regionSelect.find().click().type(`${mockRegion.label}`);
    +      ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
     
    -      ui.button
    -        .findByTitle('Create Cluster')
    +      cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    +      cy.findByText(latestEnterpriseTierKubernetesVersion.id)
             .should('be.visible')
    -        .should('be.enabled')
             .click();
    -    });
    -
    -    cy.wait('@createCluster');
    -    cy.url().should(
    -      'endWith',
    -      `/kubernetes/clusters/${mockCluster.id}/summary`
    -    );
    -  });
    -
    -  it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        postLa: false,
    -        phase2Mtc: { byoVPC: false, dualStack: false },
    -      },
    -    }).as('getFeatureFlags');
     
    -    mockCreateCluster(mockCluster).as('createCluster');
    -    mockGetTieredKubernetesVersions('enterprise', [
    -      latestEnterpriseTierKubernetesVersion,
    -    ]).as('getTieredKubernetesVersions');
    -    mockGetRegions(mockRegions);
    -
    -    cy.visitWithLogin('/kubernetes/create');
    -    cy.findByText('Add Node Pools').should('be.visible');
    -
    -    cy.findByLabelText('Cluster Label').click();
    -    cy.focused().type(mockCluster.label);
    +      // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF.
    +      cy.findByText('IP Stack').should('not.exist');
    +      cy.findByText('IPv4', { exact: true }).should('not.exist');
    +      cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
    +      cy.findByText('Automatically generate a VPC for this cluster').should(
    +        'not.exist'
    +      );
    +      cy.findByText('Use an existing VPC').should('not.exist');
    +
    +      cy.findByText('Dedicated CPU').should('be.visible').click();
    +      addNodes(mockPlan.formattedLabel);
    +
    +      // Bypass ACL validation
    +      cy.get('input[name="acl-acknowledgement"]').check();
    +
    +      // Confirm change is reflected in checkout bar.
    +      cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    +        cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
    +        cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
    +          'be.visible'
    +        );
    +
    +        cy.get('[data-qa-notice="true"]').within(() => {
    +          cy.findByText(minimumNodeNotice).should('be.visible');
    +        });
    +
    +        ui.button
    +          .findByTitle('Create Cluster')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
    +      });
     
    -    cy.findByText('LKE Enterprise').click();
    +      cy.wait('@createCluster');
    +      cy.url().should(
    +        'endWith',
    +        `/kubernetes/clusters/${mockCluster.id}/summary`
    +      );
    +    });
    +  });
     
    -    ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
    -    ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
    +  describe('Phase 2 MTC & Post-LA feature flags', () => {
    +    it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: true,
    +          phase2Mtc: { byoVPC: true, dualStack: true },
    +        },
    +      });
     
    -    cy.findByLabelText('Kubernetes Version').should('be.visible').click();
    -    cy.findByText(latestEnterpriseTierKubernetesVersion.id)
    -      .should('be.visible')
    -      .click();
    +      cy.visitWithLogin('/kubernetes/create');
     
    -    // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF.
    -    cy.findByText('IP Stack').should('not.exist');
    -    cy.findByText('IPv4', { exact: true }).should('not.exist');
    -    cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
    -    cy.findByText('Automatically generate a VPC for this cluster').should(
    -      'not.exist'
    -    );
    -    cy.findByText('Use an existing VPC').should('not.exist');
    +      lkeClusterCreatePage.setLabel(randomLabel());
    +      lkeClusterCreatePage.selectClusterTier('enterprise');
    +      lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
    +      lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
     
    -    cy.findByText('Shared CPU').should('be.visible').click();
    -    addNodes('Linode 2 GB');
    +      // Confirm that IP stack selection and VPC options are present.
    +      cy.findByText('IPv4')
    +        .should('be.visible')
    +        .closest('input')
    +        .should('be.enabled');
     
    -    // Bypass ACL validation
    -    cy.get('input[name="acl-acknowledgement"]').check();
    +      cy.findByText('IPv4 + IPv6 (dual-stack)')
    +        .should('be.visible')
    +        .closest('input')
    +        .should('be.enabled');
     
    -    // Confirm change is reflected in checkout bar.
    -    cy.get('[data-testid="kube-checkout-bar"]').within(() => {
    -      cy.findByText('Linode 2 GB Plan').should('be.visible');
    -      cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
    +      cy.findByText('Automatically generate a VPC for this cluster')
    +        .should('be.visible')
    +        .closest('input')
    +        .should('be.enabled');
     
    -      cy.get('[data-qa-notice="true"]').within(() => {
    -        cy.findByText(minimumNodeNotice).should('be.visible');
    +      cy.findByText('Use an existing VPC')
    +        .should('be.visible')
    +        .closest('input')
    +        .should('be.enabled');
    +
    +      // Confirm that node pools are configured via new drawer rather than directly within table.
    +      lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel);
    +      lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => {
    +        // Confirm that Enterprise-tier specific options are present.
    +        cy.findByText('Update Strategy').should('be.visible');
    +        cy.findByText('Use default firewall').should('be.visible');
    +        cy.findByText('Select existing firewall').should('be.visible');
    +
    +        ui.button
    +          .findByTitle('Add Pool')
    +          .should('be.visible')
    +          .should('be.enabled')
    +          .click();
           });
     
    -      ui.button
    -        .findByTitle('Create Cluster')
    -        .should('be.visible')
    -        .should('be.enabled')
    -        .click();
    +      lkeClusterCreatePage.withinOrderSummary(() => {
    +        cy.contains(mockPlan.formattedLabel)
    +          .closest('[data-testid="node-pool-summary"]')
    +          .within(() => {
    +            cy.findByText('3 Nodes').should('be.visible');
    +            cy.findByText('Edit Configuration').should('be.visible');
    +          });
    +      });
         });
    -
    -    cy.wait('@createCluster');
    -    cy.url().should(
    -      'endWith',
    -      `/kubernetes/clusters/${mockCluster.id}/summary`
    -    );
       });
     });
     
    @@ -373,129 +535,265 @@ describe('LKE-E Cluster Read', () => {
             ],
           })
         ).as('getAccount');
    -  });
    -
    -  it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        phase2Mtc: { byoVPC: true, dualStack: false },
    -      },
    -    }).as('getFeatureFlags');
    -
         mockGetClusters([mockCluster]).as('getClusters');
         mockGetCluster(mockCluster).as('getCluster');
         mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
         mockGetVPC(mockVPC).as('getVPC');
    +  });
     
    -    cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    -    cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
    -
    -    // Confirm linked VPC is present
    -    cy.get('[data-qa-kube-entity-footer]').within(() => {
    -      cy.contains('VPC:').should('exist');
    -      cy.findByTestId('assigned-lke-cluster-label').should(
    -        'contain.text',
    -        mockVPC.label
    -      );
    -    });
    +  /*
    +   * Smoke tests to confirm the state of the LKE cluster details page when the
    +   * LKE-E "phase2Mtc" feature flag is enabled.
    +   */
    +  describe('Phase 2 MTC feature flag', () => {
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the Phase 2 BYO VPC feature is enabled.
    +     * - Confirms that attached VPC label is displayed in the cluster summary.
    +     * - Confirms that VPC IP columns are not present when Phase 2 dual stack flag is disabled.
    +     */
    +    it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: true, dualStack: false },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
    +
    +      // Confirm linked VPC is present
    +      cy.get('[data-qa-kube-entity-footer]').within(() => {
    +        cy.contains('VPC:').should('exist');
    +        cy.findByTestId('assigned-lke-cluster-label').should(
    +          'contain.text',
    +          mockVPC.label
    +        );
    +      });
     
    -    // Confirm VPC IP columns are not present in the node table header
    -    cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    -      cy.contains('th', 'VPC IPv4').should('not.exist');
    -      cy.contains('th', 'VPC IPv6').should('not.exist');
    +      // Confirm VPC IP columns are not present in the node table header
    +      cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    +        cy.contains('th', 'VPC IPv4').should('not.exist');
    +        cy.contains('th', 'VPC IPv6').should('not.exist');
    +      });
         });
    -  });
     
    -  it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        phase2Mtc: { byoVPC: false, dualStack: true },
    -      },
    -    }).as('getFeatureFlags');
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack feature is enabled.
    +     * - Confirms that VPC node pool table IP columns are present.
    +     * - Confirms that attached VPC label is absent in the cluster summary when the BYO VPC feature is disabled.
    +     */
    +    it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: false, dualStack: true },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      cy.wait(['@getCluster', '@getNodePools']);
    +
    +      // Confirm linked VPC is not present
    +      cy.get('[data-qa-kube-entity-footer]').within(() => {
    +        cy.contains('VPC:').should('not.exist');
    +        cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
    +      });
     
    -    mockGetClusters([mockCluster]).as('getClusters');
    -    mockGetCluster(mockCluster).as('getCluster');
    -    mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
    +      // Confirm VPC IP columns are present in the node table header
    +      cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    +        cy.contains('th', 'VPC IPv4').should('be.visible');
    +        cy.contains('th', 'VPC IPv6').should('be.visible');
    +      });
    +    });
     
    -    cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    -    cy.wait(['@getCluster', '@getNodePools']);
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack and BYO VPC features are enabled.
    +     * - Confirms that VPC node pool table IP columns are present.
    +     * - Confirms that attached VPC label is displayed in the cluster summary.
    +     */
    +    it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: true, dualStack: true },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
    +
    +      // Confirm linked VPC is present
    +      cy.get('[data-qa-kube-entity-footer]').within(() => {
    +        cy.contains('VPC:').should('exist');
    +        cy.findByTestId('assigned-lke-cluster-label').should(
    +          'contain.text',
    +          mockVPC.label
    +        );
    +      });
     
    -    // Confirm linked VPC is not present
    -    cy.get('[data-qa-kube-entity-footer]').within(() => {
    -      cy.contains('VPC:').should('not.exist');
    -      cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
    +      // Confirm VPC IP columns are present in the node table header
    +      cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    +        cy.contains('th', 'VPC IPv4').should('be.visible');
    +        cy.contains('th', 'VPC IPv6').should('be.visible');
    +      });
         });
     
    -    // Confirm VPC IP columns are present in the node table header
    -    cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    -      cy.contains('th', 'VPC IPv4').should('be.visible');
    -      cy.contains('th', 'VPC IPv6').should('be.visible');
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the "phase2Mtc" feature is disabled.
    +     * - Confirms that no VPC label is shown in the cluster summary.
    +     * - Confirms that IPv4 and IPv6 node pool table columns are absent.
    +     */
    +    it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      cy.wait(['@getCluster', '@getNodePools']);
    +
    +      // Confirm linked VPC is not present
    +      cy.get('[data-qa-kube-entity-footer]').within(() => {
    +        cy.contains('VPC:').should('not.exist');
    +        cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
    +      });
    +
    +      // Confirm VPC IP columns are not present in the node table header
    +      cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    +        cy.contains('th', 'VPC IPv4').should('not.exist');
    +        cy.contains('th', 'VPC IPv6').should('not.exist');
    +      });
         });
       });
     
    -  it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        phase2Mtc: { byoVPC: true, dualStack: true },
    -      },
    -    }).as('getFeatureFlags');
    -
    -    mockGetClusters([mockCluster]).as('getClusters');
    -    mockGetCluster(mockCluster).as('getCluster');
    -    mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
    -    mockGetVPC(mockVPC).as('getVPC');
    -
    -    cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    -    cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
    +  /*
    +   * Smoke tests to confirm the state of the LKE cluster details page when the
    +   * LKE-E "postLa" feature flag is enabled and disabled.
    +   */
    +  describe('Post-LA feature flags', () => {
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is enabled.
    +     * - Confirms that update strategy and firewall options are present in the Add Node Pool drawer.
    +     */
    +    it('Simple Page Check - Post-LA Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          postLa: true,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      ui.button
    +        .findByTitle('Add a Node Pool')
    +        .should('be.visible')
    +        .should('be.enabled')
    +        .click();
     
    -    // Confirm linked VPC is present
    -    cy.get('[data-qa-kube-entity-footer]').within(() => {
    -      cy.contains('VPC:').should('exist');
    -      cy.findByTestId('assigned-lke-cluster-label').should(
    -        'contain.text',
    -        mockVPC.label
    -      );
    +      ui.drawer
    +        .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
    +        .should('be.visible')
    +        .within(() => {
    +          cy.findByText('Update Strategy').scrollIntoView();
    +          cy.findByText('Update Strategy').should('be.visible');
    +          cy.findByText('Use default firewall').should('be.visible');
    +          cy.findByText('Select existing firewall').should('be.visible');
    +        });
         });
     
    -    // Confirm VPC IP columns are present in the node table header
    -    cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    -      cy.contains('th', 'VPC IPv4').should('be.visible');
    -      cy.contains('th', 'VPC IPv6').should('be.visible');
    +    /*
    +     * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is disabled.
    +     * - Confirms that update strategy and firewall options are absent in the Add Node Pool drawer.
    +     */
    +    it('Simple Page Check - Post-LA Flag OFF', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: false, dualStack: false },
    +          postLa: false,
    +        },
    +      }).as('getFeatureFlags');
    +
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    +      ui.button
    +        .findByTitle('Add a Node Pool')
    +        .should('be.visible')
    +        .should('be.enabled')
    +        .click();
    +
    +      ui.drawer
    +        .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
    +        .should('be.visible')
    +        .within(() => {
    +          cy.findByText('Update Strategy').should('not.exist');
    +          cy.findByText('Use default firewall').should('not.exist');
    +          cy.findByText('Select existing firewall').should('not.exist');
    +        });
         });
       });
     
    -  it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
    -    mockAppendFeatureFlags({
    -      lkeEnterprise2: {
    -        enabled: true,
    -        la: true,
    -        phase2Mtc: { byoVPC: false, dualStack: false },
    -      },
    -    }).as('getFeatureFlags');
    +  /*
    +   * Smoke tests to confirm the state of the LKE cluster details page when the
    +   * 'phase2Mtc' and 'postLa' LKE-E feature flags are both enabled.
    +   */
    +  describe('Phase 2 MTC & Post-LA feature flags', () => {
    +    /*
    +     * - Confirms the state of LKE details page when "phase2Mtc" and "postLa" are both enabled.
    +     * - Confirms that update strategy and Firewall options are present in Add Node Pool drawer.
    +     * - Confirms that attached VPC is shown in the summary, and IPv4 and IPv6 node pool table columns are present.
    +     */
    +    it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => {
    +      mockAppendFeatureFlags({
    +        lkeEnterprise2: {
    +          enabled: true,
    +          la: true,
    +          phase2Mtc: { byoVPC: true, dualStack: true },
    +          postLa: true,
    +        },
    +      });
     
    -    mockGetClusters([mockCluster]).as('getClusters');
    -    mockGetCluster(mockCluster).as('getCluster');
    -    mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
    +      cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
     
    -    cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
    -    cy.wait(['@getCluster', '@getNodePools']);
    +      // Confirm that VPC label is shown in summary, and that IPv4 and IPv6
    +      // node pool table columns are present.
    +      cy.get('[data-qa-kube-entity-footer]').within(() => {
    +        cy.contains('VPC:').should('exist');
    +        cy.findByTestId('assigned-lke-cluster-label').should(
    +          'contain.text',
    +          mockVPC.label
    +        );
    +      });
     
    -    // Confirm linked VPC is not present
    -    cy.get('[data-qa-kube-entity-footer]').within(() => {
    -      cy.contains('VPC:').should('not.exist');
    -      cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
    -    });
    +      cy.findByLabelText('List of Your Cluster Nodes').within(() => {
    +        cy.contains('th', 'VPC IPv4').should('be.visible');
    +        cy.contains('th', 'VPC IPv6').should('be.visible');
    +      });
     
    -    // Confirm VPC IP columns are not present in the node table header
    -    cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
    -      cy.contains('th', 'VPC IPv4').should('not.exist');
    -      cy.contains('th', 'VPC IPv6').should('not.exist');
    +      ui.button
    +        .findByTitle('Add a Node Pool')
    +        .should('be.visible')
    +        .should('be.enabled')
    +        .click();
    +
    +      ui.drawer
    +        .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
    +        .should('be.visible')
    +        .within(() => {
    +          cy.findByText('Update Strategy').scrollIntoView();
    +          cy.findByText('Update Strategy').should('be.visible');
    +          cy.findByText('Use default firewall').should('be.visible');
    +          cy.findByText('Select existing firewall').should('be.visible');
    +        });
         });
       });
     });
    diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts
    index 27a8236571f..da8a091b869 100644
    --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts
    +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts
    @@ -595,4 +595,4 @@ export const mockGetCloudPulseServiceByType = (
         apiMatcher(`monitor/services/${serviceType}`),
         makeResponse(service)
       );
    -};
    \ No newline at end of file
    +};
    diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
    index 16c242cf062..c533c062fa5 100644
    --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
    +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
    @@ -114,7 +114,10 @@ export const SelectFirewallPanel = (props: Props) => {
               value={selectedFirewall}
             />
             
    -          
    +          
                 Create Firewall
               
             
    diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
    index b9979009e1a..aa83c7492de 100644
    --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
    +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
    @@ -122,7 +122,9 @@ describe('Utils', () => {
         ];
     
         it('should return matched resources by entity IDs', () => {
    -      expect(getFilteredFirewallParentEntities(resources, ['1'])).toEqual(['a']);
    +      expect(getFilteredFirewallParentEntities(resources, ['1'])).toEqual([
    +        'a',
    +      ]);
         });
     
         it('should return empty array if no match', () => {
    @@ -131,7 +133,9 @@ describe('Utils', () => {
     
         it('should handle undefined inputs', () => {
           expect(getFilteredFirewallParentEntities(undefined, ['1'])).toEqual([]);
    -      expect(getFilteredFirewallParentEntities(resources, undefined)).toEqual([]);
    +      expect(getFilteredFirewallParentEntities(resources, undefined)).toEqual(
    +        []
    +      );
         });
       });
     
    
    From d3a83c85365f2c79e0722a7e4c1bdea6f09b1295 Mon Sep 17 00:00:00 2001
    From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
    Date: Mon, 22 Sep 2025 14:38:01 +0200
    Subject: [PATCH 11/54] fix: [UIE-9134] - IAM RBAC: fetch all entities
     client-side to avoid missing items (#12888)
    
    * fetch all entities
    
    * changeset
    
    * add CRUD MSW support for entities
    
    ---------
    
    Co-authored-by: Alban Bailly 
    ---
     packages/manager/src/dev-tools/load.ts        |  1 +
     .../AssignedPermissionsPanel.test.tsx         | 13 +++--
     .../AssignedRolesTable.test.tsx               | 21 ++++----
     .../AssignedRolesTable/AssignedRolesTable.tsx | 10 ++--
     .../UpdateEntitiesDrawer.test.tsx             |  9 ++--
     .../IAM/Shared/Entities/Entities.test.tsx     | 21 ++++----
     .../features/IAM/Shared/Entities/Entities.tsx |  7 +--
     .../AssignedEntitiesTable.test.tsx            | 21 ++++----
     .../UserEntities/AssignedEntitiesTable.tsx    |  6 +--
     .../Users/UserEntities/UserEntities.test.tsx  | 10 ++--
     .../IAM/Users/UserRoles/UserRoles.test.tsx    | 21 ++++----
     packages/manager/src/mocks/mockState.ts       |  1 +
     .../src/mocks/presets/baseline/crud.ts        |  2 +
     .../src/mocks/presets/crud/entities.ts        | 10 ++++
     .../mocks/presets/crud/handlers/entities.ts   | 50 +++++++++++++++++++
     .../src/mocks/presets/crud/seeds/entities.ts  | 28 +++++++++++
     .../src/mocks/presets/crud/seeds/index.ts     |  2 +
     packages/manager/src/mocks/types.ts           |  4 ++
     .../manager/src/queries/entities/entities.ts  | 25 ++++++++--
     .../manager/src/queries/entities/queries.ts   | 26 ++++++++--
     .../pr-12888-added-1758193857654.md           |  5 ++
     21 files changed, 211 insertions(+), 82 deletions(-)
     create mode 100644 packages/manager/src/mocks/presets/crud/entities.ts
     create mode 100644 packages/manager/src/mocks/presets/crud/handlers/entities.ts
     create mode 100644 packages/manager/src/mocks/presets/crud/seeds/entities.ts
     create mode 100644 packages/queries/.changeset/pr-12888-added-1758193857654.md
    
    diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts
    index 489e4989c09..c810a6bcc5f 100644
    --- a/packages/manager/src/dev-tools/load.ts
    +++ b/packages/manager/src/dev-tools/load.ts
    @@ -92,6 +92,7 @@ export async function loadDevTools() {
             ...initialContext.firewalls,
             ...(seedContext?.firewalls || []),
           ],
    +      entities: [...initialContext.entities, ...(seedContext?.entities || [])],
           kubernetesClusters: [
             ...initialContext.kubernetesClusters,
             ...(seedContext?.kubernetesClusters || []),
    diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
    index beecaadb47e..3f5a7e79ef8 100644
    --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
    +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
    @@ -2,7 +2,6 @@ import { fireEvent, screen } from '@testing-library/react';
     import React from 'react';
     
     import { accountEntityFactory } from 'src/factories/accountEntities';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import { AssignedPermissionsPanel } from './AssignedPermissionsPanel';
    @@ -10,14 +9,14 @@ import { AssignedPermissionsPanel } from './AssignedPermissionsPanel';
     import type { ExtendedRole } from '../utilities';
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
     }));
     
     vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -84,8 +83,8 @@ describe('AssignedPermissionsPanel', () => {
       });
     
       it('renders with the correct context when the access is an entity', () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
         renderWithTheme(
           
    @@ -107,8 +106,8 @@ describe('AssignedPermissionsPanel', () => {
       });
     
       it('renders the Autocomplete when the access is an entity', () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
         renderWithTheme(
           
    diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
    index 4bcdc69e072..ecd6448b229 100644
    --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
    +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
    @@ -5,13 +5,12 @@ import React from 'react';
     import { accountEntityFactory } from 'src/factories/accountEntities';
     import { accountRolesFactory } from 'src/factories/accountRoles';
     import { userRolesFactory } from 'src/factories/userRoles';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import { AssignedRolesTable } from './AssignedRolesTable';
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
       useParams: vi.fn().mockReturnValue({}),
       useAccountRoles: vi.fn().mockReturnValue({}),
       useUserRoles: vi.fn().mockReturnValue({}),
    @@ -30,7 +29,7 @@ vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -80,8 +79,8 @@ describe('AssignedRolesTable', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -108,8 +107,8 @@ describe('AssignedRolesTable', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -131,8 +130,8 @@ describe('AssignedRolesTable', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -154,8 +153,8 @@ describe('AssignedRolesTable', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
    index f4d8e93dff5..cb20cbaac04 100644
    --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
    +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
    @@ -15,7 +15,7 @@ import { TableRow } from 'src/components/TableRow';
     import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
     import { TableSortCell } from 'src/components/TableSortCell/TableSortCell';
     import { usePaginationV2 } from 'src/hooks/usePaginationV2';
    -import { useAccountEntities } from 'src/queries/entities/entities';
    +import { useAllAccountEntities } from 'src/queries/entities/entities';
     
     import { usePermissions } from '../../hooks/usePermissions';
     import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities';
    @@ -135,11 +135,13 @@ export const AssignedRolesTable = () => {
     
       const { data: accountRoles, isLoading: accountPermissionsLoading } =
         useAccountRoles();
    -  const { data: entities, isLoading: entitiesLoading } = useAccountEntities();
    +  const { data: entities, isLoading: entitiesLoading } = useAllAccountEntities(
    +    {}
    +  );
    +
       const { data: assignedRoles, isLoading: assignedRolesLoading } = useUserRoles(
         username ?? ''
       );
    -
       const { filterableOptions, roles } = React.useMemo(() => {
         if (!assignedRoles || !accountRoles) {
           return { filterableOptions: [], roles: [] };
    @@ -154,7 +156,7 @@ export const AssignedRolesTable = () => {
         ];
     
         if (entities) {
    -      const transformedEntities = groupAccountEntitiesByType(entities.data);
    +      const transformedEntities = groupAccountEntitiesByType(entities);
     
           roles = addEntitiesNamesToRoles(roles, transformedEntities);
         }
    diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
    index 43657751ec8..ecbcf169880 100644
    --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
    +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
    @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
     import React from 'react';
     
     import { accountEntityFactory } from 'src/factories/accountEntities';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import { UpdateEntitiesDrawer } from './UpdateEntitiesDrawer';
    @@ -11,7 +10,7 @@ import { UpdateEntitiesDrawer } from './UpdateEntitiesDrawer';
     import type { ExtendedRoleView } from '../types';
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
       useParams: vi.fn().mockReturnValue({}),
       useAccountRoles: vi.fn().mockReturnValue({}),
       useUserRoles: vi.fn().mockReturnValue({}),
    @@ -51,7 +50,7 @@ vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -113,8 +112,8 @@ describe('UpdateEntitiesDrawer', () => {
       });
     
       it('should allow updating entities', async () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
         queryMocks.useUserRoles.mockReturnValue({
           data: {
    diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
    index c27a4f682cd..abb88d4ea26 100644
    --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
    +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
    @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
     import React from 'react';
     
     import { accountEntityFactory } from 'src/factories/accountEntities';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import { Entities } from './Entities';
    @@ -11,14 +10,14 @@ import { Entities } from './Entities';
     import type { EntitiesOption } from '../types';
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
     }));
     
     vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -82,8 +81,8 @@ describe('Entities', () => {
       });
     
       it('renders correct data when it is an entity access', () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme(
    @@ -108,8 +107,8 @@ describe('Entities', () => {
       });
     
       it('renders correct data when it is an entity access', () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme(
    @@ -134,8 +133,8 @@ describe('Entities', () => {
       });
     
       it('renders correct options in Autocomplete dropdown when it is an entity access', async () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme(
    @@ -156,8 +155,8 @@ describe('Entities', () => {
       });
     
       it('updates selected options when Autocomplete value changes when it is an entity access', async () => {
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme(
    diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
    index 50db9a551e5..bea517a7611 100644
    --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
    +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
    @@ -4,7 +4,7 @@ import React from 'react';
     
     import { FormLabel } from 'src/components/FormLabel';
     import { Link } from 'src/components/Link';
    -import { useAccountEntities } from 'src/queries/entities/entities';
    +import { useAllAccountEntities } from 'src/queries/entities/entities';
     
     import { getFormattedEntityType } from '../utilities';
     import {
    @@ -34,14 +34,15 @@ export const Entities = ({
       type,
       value,
     }: Props) => {
    -  const { data: entities } = useAccountEntities();
    +  const { data: entities } = useAllAccountEntities({});
       const theme = useTheme();
     
       const memoizedEntities = React.useMemo(() => {
         if (access !== 'entity_access' || !entities) {
           return [];
         }
    -    const typeEntities = getEntitiesByType(type, entities.data);
    +    const typeEntities = getEntitiesByType(type, entities);
    +
         return typeEntities ? mapEntitiesToOptions(typeEntities) : [];
       }, [entities, access, type]);
     
    diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
    index fe06dd6582f..d0c06967d7d 100644
    --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
    +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
    @@ -4,13 +4,12 @@ import React from 'react';
     
     import { accountEntityFactory } from 'src/factories/accountEntities';
     import { userRolesFactory } from 'src/factories/userRoles';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable';
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
       useParams: vi.fn().mockReturnValue({}),
       useSearch: vi.fn().mockReturnValue({}),
       useUserRoles: vi.fn().mockReturnValue({}),
    @@ -28,7 +27,7 @@ vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -74,8 +73,8 @@ describe('AssignedEntitiesTable', () => {
           data: userRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -99,8 +98,8 @@ describe('AssignedEntitiesTable', () => {
           data: userRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -118,8 +117,8 @@ describe('AssignedEntitiesTable', () => {
           data: userRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -137,8 +136,8 @@ describe('AssignedEntitiesTable', () => {
           data: userRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
    index a1fd2a6ee39..fc922a54522 100644
    --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
    +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
    @@ -18,7 +18,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError';
     import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
     import { TableSortCell } from 'src/components/TableSortCell';
     import { usePaginationV2 } from 'src/hooks/usePaginationV2';
    -import { useAccountEntities } from 'src/queries/entities/entities';
    +import { useAllAccountEntities } from 'src/queries/entities/entities';
     
     import { usePermissions } from '../../hooks/usePermissions';
     import { ENTITIES_TABLE_PREFERENCE_KEY } from '../../Shared/constants';
    @@ -90,7 +90,7 @@ export const AssignedEntitiesTable = () => {
         data: entities,
         error: entitiesError,
         isLoading: entitiesLoading,
    -  } = useAccountEntities();
    +  } = useAllAccountEntities({});
     
       const {
         data: assignedRoles,
    @@ -102,7 +102,7 @@ export const AssignedEntitiesTable = () => {
         if (!assignedRoles || !entities) {
           return { filterableOptions: [], roles: [] };
         }
    -    const transformedEntities = groupAccountEntitiesByType(entities.data);
    +    const transformedEntities = groupAccountEntitiesByType(entities);
     
         const roles = addEntityNamesToRoles(assignedRoles, transformedEntities);
     
    diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
    index 22034c79276..1858996f4ea 100644
    --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
    +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
    @@ -5,7 +5,6 @@ import React from 'react';
     import { accountEntityFactory } from 'src/factories/accountEntities';
     import { accountRolesFactory } from 'src/factories/accountRoles';
     import { userRolesFactory } from 'src/factories/userRoles';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import {
    @@ -23,7 +22,7 @@ const mockEntities = [
     ];
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
       useParams: vi.fn().mockReturnValue({}),
       useSearch: vi.fn().mockReturnValue({}),
       useAccountRoles: vi.fn().mockReturnValue({}),
    @@ -44,7 +43,7 @@ vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -121,14 +120,13 @@ describe('UserEntities', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
     
         expect(screen.queryByText('Assign New Roles')).toBeNull();
    -
         expect(screen.getByText('firewall_admin')).toBeVisible();
         expect(screen.getByText('firewall-1')).toBeVisible();
     
    diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
    index a14d7e6cf9d..3565e7d17c2 100644
    --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
    +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
    @@ -5,7 +5,6 @@ import React from 'react';
     import { accountEntityFactory } from 'src/factories/accountEntities';
     import { accountRolesFactory } from 'src/factories/accountRoles';
     import { userRolesFactory } from 'src/factories/userRoles';
    -import { makeResourcePage } from 'src/mocks/serverHandlers';
     import { renderWithTheme } from 'src/utilities/testHelpers';
     
     import {
    @@ -22,7 +21,7 @@ const mockEntities = [
     ];
     
     const queryMocks = vi.hoisted(() => ({
    -  useAccountEntities: vi.fn().mockReturnValue({}),
    +  useAllAccountEntities: vi.fn().mockReturnValue({}),
       useParams: vi.fn().mockReturnValue({}),
       useSearch: vi.fn().mockReturnValue({}),
       useAccountRoles: vi.fn().mockReturnValue({}),
    @@ -43,7 +42,7 @@ vi.mock('src/queries/entities/entities', async () => {
       const actual = await vi.importActual('src/queries/entities/entities');
       return {
         ...actual,
    -    useAccountEntities: queryMocks.useAccountEntities,
    +    useAllAccountEntities: queryMocks.useAllAccountEntities,
       };
     });
     
    @@ -108,8 +107,8 @@ describe('UserRoles', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -140,8 +139,8 @@ describe('UserRoles', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -167,8 +166,8 @@ describe('UserRoles', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    @@ -185,8 +184,8 @@ describe('UserRoles', () => {
           data: accountRolesFactory.build(),
         });
     
    -    queryMocks.useAccountEntities.mockReturnValue({
    -      data: makeResourcePage(mockEntities),
    +    queryMocks.useAllAccountEntities.mockReturnValue({
    +      data: mockEntities,
         });
     
         renderWithTheme();
    diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts
    index 2a6d78f4d1e..56f9b46ede9 100644
    --- a/packages/manager/src/mocks/mockState.ts
    +++ b/packages/manager/src/mocks/mockState.ts
    @@ -27,6 +27,7 @@ export const emptyStore: MockState = {
       destinations: [],
       domainRecords: [],
       domains: [],
    +  entities: [],
       eventQueue: [],
       firewallDevices: [],
       firewalls: [],
    diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts
    index 51b4519e118..37df11b0d68 100644
    --- a/packages/manager/src/mocks/presets/baseline/crud.ts
    +++ b/packages/manager/src/mocks/presets/baseline/crud.ts
    @@ -7,6 +7,7 @@ import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes';
     
     import { cloudNATCrudPreset } from '../crud/cloudnats';
     import { domainCrudPreset } from '../crud/domains';
    +import { entityCrudPreset } from '../crud/entities';
     import { firewallCrudPreset } from '../crud/firewalls';
     import { kubernetesCrudPreset } from '../crud/kubernetes';
     import { nodeBalancerCrudPreset } from '../crud/nodebalancers';
    @@ -24,6 +25,7 @@ export const baselineCrudPreset: MockPresetBaseline = {
         ...cloudNATCrudPreset.handlers,
         ...domainCrudPreset.handlers,
         ...deliveryCrudPreset.handlers,
    +    ...entityCrudPreset.handlers,
         ...firewallCrudPreset.handlers,
         ...kubernetesCrudPreset.handlers,
         ...linodeCrudPreset.handlers,
    diff --git a/packages/manager/src/mocks/presets/crud/entities.ts b/packages/manager/src/mocks/presets/crud/entities.ts
    new file mode 100644
    index 00000000000..fa735b49440
    --- /dev/null
    +++ b/packages/manager/src/mocks/presets/crud/entities.ts
    @@ -0,0 +1,10 @@
    +import { getEntities } from 'src/mocks/presets/crud/handlers/entities';
    +
    +import type { MockPresetCrud } from 'src/mocks/types';
    +
    +export const entityCrudPreset: MockPresetCrud = {
    +  group: { id: 'Entities' },
    +  handlers: [getEntities],
    +  id: 'entities:crud',
    +  label: 'Entities CRUD',
    +};
    diff --git a/packages/manager/src/mocks/presets/crud/handlers/entities.ts b/packages/manager/src/mocks/presets/crud/handlers/entities.ts
    new file mode 100644
    index 00000000000..f39ae9e6b81
    --- /dev/null
    +++ b/packages/manager/src/mocks/presets/crud/handlers/entities.ts
    @@ -0,0 +1,50 @@
    +import { http } from 'msw';
    +
    +import { mswDB } from 'src/mocks/indexedDB';
    +import {
    +  makeNotFoundResponse,
    +  makePaginatedResponse,
    +  makeResponse,
    +} from 'src/mocks/utilities/response';
    +
    +import type { Entity } from '@linode/api-v4';
    +import type { StrictResponse } from 'msw';
    +import type {
    +  APIErrorResponse,
    +  APIPaginatedResponse,
    +} from 'src/mocks/utilities/response';
    +
    +export const getEntities = () => [
    +  http.get(
    +    '*/v4*/entities',
    +    async ({
    +      request,
    +    }): Promise<
    +      StrictResponse>
    +    > => {
    +      const entities = await mswDB.getAll('entities');
    +
    +      if (!entities) {
    +        return makeNotFoundResponse();
    +      }
    +      return makePaginatedResponse({
    +        data: entities,
    +        request,
    +      });
    +    }
    +  ),
    +
    +  http.get(
    +    '*/v4*/entities/:id',
    +    async ({ params }): Promise> => {
    +      const id = Number(params.id);
    +      const entity = await mswDB.get('entities', id);
    +
    +      if (!entity) {
    +        return makeNotFoundResponse();
    +      }
    +
    +      return makeResponse(entity);
    +    }
    +  ),
    +];
    diff --git a/packages/manager/src/mocks/presets/crud/seeds/entities.ts b/packages/manager/src/mocks/presets/crud/seeds/entities.ts
    new file mode 100644
    index 00000000000..7bbfd8c9cbc
    --- /dev/null
    +++ b/packages/manager/src/mocks/presets/crud/seeds/entities.ts
    @@ -0,0 +1,28 @@
    +import { getSeedsCountMap } from 'src/dev-tools/utils';
    +import { entityFactory } from 'src/factories';
    +import { mswDB } from 'src/mocks/indexedDB';
    +
    +import type { MockSeeder, MockState } from 'src/mocks/types';
    +
    +export const entitiesSeeder: MockSeeder = {
    +  canUpdateCount: true,
    +  desc: 'Entities Seeds',
    +  group: { id: 'Entities' },
    +  id: 'entities:crud',
    +  label: 'Entities',
    +
    +  seeder: async (mockState: MockState) => {
    +    const seedsCountMap = getSeedsCountMap();
    +    const count = seedsCountMap[entitiesSeeder.id] ?? 0;
    +    const entities = entityFactory.buildList(count);
    +
    +    const updatedMockState = {
    +      ...mockState,
    +      entities: mockState.entities.concat(entities),
    +    };
    +
    +    await mswDB.saveStore(updatedMockState, 'seedState');
    +
    +    return updatedMockState;
    +  },
    +};
    diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts
    index c46a700914a..7a21e2e67f7 100644
    --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts
    +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts
    @@ -1,5 +1,6 @@
     import { cloudNATSeeder } from './cloudnats';
     import { domainSeeder } from './domains';
    +import { entitiesSeeder } from './entities';
     import { firewallSeeder } from './firewalls';
     import { kubernetesSeeder } from './kubernetes';
     import { linodesSeeder } from './linodes';
    @@ -13,6 +14,7 @@ import { vpcSeeder } from './vpcs';
     export const dbSeeders = [
       cloudNATSeeder,
       domainSeeder,
    +  entitiesSeeder,
       firewallSeeder,
       ipAddressSeeder,
       kubernetesSeeder,
    diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts
    index 5b13155d66c..8a1dc9e45f7 100644
    --- a/packages/manager/src/mocks/types.ts
    +++ b/packages/manager/src/mocks/types.ts
    @@ -4,6 +4,7 @@ import type {
       Destination,
       Domain,
       DomainRecord,
    +  Entity,
       Event,
       Firewall,
       FirewallDevice,
    @@ -123,6 +124,7 @@ export type MockPresetCrudGroup = {
         | 'CloudNATs'
         | 'Delivery'
         | 'Domains'
    +    | 'Entities'
         | 'Firewalls'
         | 'IP Addresses'
         | 'Kubernetes'
    @@ -138,6 +140,7 @@ export type MockPresetCrudId =
       | 'cloudnats:crud'
       | 'delivery:crud'
       | 'domains:crud'
    +  | 'entities:crud'
       | 'firewalls:crud'
       | 'ip-addresses:crud'
       | 'kubernetes:crud'
    @@ -165,6 +168,7 @@ export interface MockState {
       destinations: Destination[];
       domainRecords: DomainRecord[];
       domains: Domain[];
    +  entities: Entity[];
       eventQueue: Event[];
       firewallDevices: [number, FirewallDevice][]; // number is Firewall ID
       firewalls: Firewall[];
    diff --git a/packages/manager/src/queries/entities/entities.ts b/packages/manager/src/queries/entities/entities.ts
    index 0f3dc308d1d..50e6fe5525a 100644
    --- a/packages/manager/src/queries/entities/entities.ts
    +++ b/packages/manager/src/queries/entities/entities.ts
    @@ -3,11 +3,26 @@ import { useQuery } from '@tanstack/react-query';
     
     import { entitiesQueries } from './queries';
     
    -import type { AccountEntity, APIError, ResourcePage } from '@linode/api-v4';
    +import type {
    +  AccountEntity,
    +  APIError,
    +  Filter,
    +  Params,
    +  ResourcePage,
    +} from '@linode/api-v4';
     
    -export const useAccountEntities = () => {
    -  return useQuery, APIError[]>({
    -    ...entitiesQueries.entities,
    +export const useAllAccountEntities = ({
    +  enabled = true,
    +  filter = {},
    +  params = {},
    +}) =>
    +  useQuery({
    +    enabled,
    +    ...entitiesQueries.all(params, filter),
    +  });
    +
    +export const useAccountEntities = (params: Params, filter: Filter) =>
    +  useQuery, APIError[]>({
    +    ...entitiesQueries.paginated(params, filter),
         ...queryPresets.shortLived,
       });
    -};
    diff --git a/packages/manager/src/queries/entities/queries.ts b/packages/manager/src/queries/entities/queries.ts
    index e5d237dbe58..00e76fcff89 100644
    --- a/packages/manager/src/queries/entities/queries.ts
    +++ b/packages/manager/src/queries/entities/queries.ts
    @@ -1,10 +1,26 @@
     import { getAccountEntities } from '@linode/api-v4';
    +import { getAll } from '@linode/utilities';
     import { createQueryKeys } from '@lukemorales/query-key-factory';
     
    +import type { AccountEntity, Filter, Params } from '@linode/api-v4';
    +
    +// TODO: Temporary—use getAll since API can’t filter yet.
    +// Switch to paginated + API filtering (X-Filter) when supported.
    +const getAllAccountEntitiesRequest = (
    +  _params: Params = {},
    +  _filter: Filter = {}
    +) =>
    +  getAll((params) =>
    +    getAccountEntities({ ...params, ..._params })
    +  )().then((data) => data.data);
    +
     export const entitiesQueries = createQueryKeys('entities', {
    -  entities: {
    -    queryFn: ({ pageParam }) =>
    -      getAccountEntities({ page: pageParam as number, page_size: 500 }),
    -    queryKey: null,
    -  },
    +  all: (params: Params = {}, filter: Filter = {}) => ({
    +    queryFn: () => getAllAccountEntitiesRequest(params, filter),
    +    queryKey: [params, filter],
    +  }),
    +  paginated: (params: Params, filter: Filter) => ({
    +    queryFn: () => getAccountEntities(params),
    +    queryKey: [params, filter],
    +  }),
     });
    diff --git a/packages/queries/.changeset/pr-12888-added-1758193857654.md b/packages/queries/.changeset/pr-12888-added-1758193857654.md
    new file mode 100644
    index 00000000000..e7013aec397
    --- /dev/null
    +++ b/packages/queries/.changeset/pr-12888-added-1758193857654.md
    @@ -0,0 +1,5 @@
    +---
    +"@linode/queries": Added
    +---
    +
    +IAM RBAC: useAllAccountEntities to fetch all pages client-side via getAll, preventing missing items on large accounts ([#12888](https://github.com/linode/manager/pull/12888))
    
    From f9ecfd312006a03781ff7bfaa34eb2c7fc33abf4 Mon Sep 17 00:00:00 2001
    From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com>
    Date: Mon, 22 Sep 2025 16:51:24 -0400
    Subject: [PATCH 12/54] test: [M3-10608] - Add tests for Linode Interfaces
     table in Linode Networking tab (part 1) (#12842)
    
    * WIP Linode network tests
    
    * organize stuff
    
    * save
    
    * add test
    
    * add tests
    
    * add details tests
    
    * update test
    
    * changeset + other test
    
    * remove aria label feedback
    
    ---------
    
    Co-authored-by: Joe D'Amore 
    ---
     .../pr-12842-tests-1757434078486.md           |   5 +
     .../e2e/core/linodes/linode-network.spec.ts   | 748 +++++++++++++++---
     .../cypress/support/intercepts/linodes.ts     |  21 +
     .../LinodeCreate/Networking/Firewall.tsx      |   2 +-
     .../Networking/InterfaceFirewall.tsx          |   2 +-
     .../LinodeFirewalls/LinodeFirewalls.tsx       |   2 +-
     .../VlanInterfaceDetailsContent.tsx           |   6 +-
     .../LinodeInterfacesTable.tsx                 |   2 +-
     8 files changed, 663 insertions(+), 125 deletions(-)
     create mode 100644 packages/manager/.changeset/pr-12842-tests-1757434078486.md
    
    diff --git a/packages/manager/.changeset/pr-12842-tests-1757434078486.md b/packages/manager/.changeset/pr-12842-tests-1757434078486.md
    new file mode 100644
    index 00000000000..9d0d850a651
    --- /dev/null
    +++ b/packages/manager/.changeset/pr-12842-tests-1757434078486.md
    @@ -0,0 +1,5 @@
    +---
    +"@linode/manager": Tests
    +---
    +
    +Add tests for Linode Interface Networking table - details drawer and adding a VLAN interface ([#12842](https://github.com/linode/manager/pull/12842))
    diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
    index 1a47787c0cd..87840e9ae1a 100644
    --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
    +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
    @@ -1,5 +1,6 @@
     import {
       linodeInterfaceFactoryPublic,
    +  linodeInterfaceFactoryVlan,
       linodeInterfaceFactoryVPC,
     } from '@linode/utilities';
     import { linodeFactory } from '@linode/utilities';
    @@ -22,16 +23,18 @@ import {
       mockCreateLinodeInterface,
       mockGetLinodeDetails,
       mockGetLinodeFirewalls,
    +  mockGetLinodeInterface,
       mockGetLinodeInterfaces,
       mockGetLinodeIPAddresses,
     } from 'support/intercepts/linodes';
     import { mockUpdateIPAddress } from 'support/intercepts/networking';
    -import { mockGetVPCs } from 'support/intercepts/vpc';
    +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc';
     import { ui } from 'support/ui';
     
    -import type { IPRange } from '@linode/api-v4';
    +import type { IPRange, LinodeIPsResponse } from '@linode/api-v4';
     
     describe('IP Addresses', () => {
    +  // TODO M3-9775: Set mock linode interface type to legacy once Linode Interfaces is GA.
       const mockLinode = linodeFactory.build();
       const linodeIPv4 = mockLinode.ipv4[0];
       const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`;
    @@ -253,11 +256,11 @@ describe('Firewalls', () => {
       });
     });
     
    -describe('Linode Interfaces', () => {
    +describe('Linode Interfaces enabled', () => {
       beforeEach(() => {
         mockGetAccount(
           accountFactory.build({
    -        capabilities: ['Linode Interfaces'],
    +        capabilities: ['Linodes', 'Linode Interfaces'],
           })
         );
         mockAppendFeatureFlags({
    @@ -265,162 +268,673 @@ describe('Linode Interfaces', () => {
         });
       });
     
    -  it('allows the user to add a public network interface with a firewall', () => {
    -    const linode = linodeFactory.build({ interface_generation: 'linode' });
    -    const firewalls = firewallFactory.buildList(3);
    -    const linodeInterface = linodeInterfaceFactoryPublic.build();
    -
    -    const selectedFirewall = firewalls[1];
    +  describe('Linode with legacy config-based interfaces', () => {
    +    const mockLinode = linodeFactory.build({
    +      interface_generation: 'legacy_config',
    +    });
     
    -    mockGetLinodeDetails(linode.id, linode).as('getLinode');
    -    mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
    -    mockGetFirewalls(firewalls).as('getFirewalls');
    -    mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
    -    mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
    -      selectedFirewall,
    -    ]).as('getInterfaceFirewalls');
    +    const mockLinodeIPv4 = ipAddressFactory.build({
    +      linode_id: mockLinode.id,
    +      public: true,
    +      type: 'ipv4',
    +      region: mockLinode.region,
    +      interface_id: null,
    +    });
     
    -    cy.visitWithLogin(`/linodes/${linode.id}/networking`);
    +    const mockLinodeIPs: LinodeIPsResponse = {
    +      ipv4: {
    +        public: [mockLinodeIPv4],
    +        private: [],
    +        reserved: [],
    +        shared: [],
    +        vpc: [],
    +      },
    +    };
     
    -    cy.wait(['@getLinode', '@getInterfaces']);
    +    beforeEach(() => {
    +      mockGetLinodeDetails(mockLinode.id, mockLinode);
    +      mockGetLinodeFirewalls(mockLinode.id, []);
    +      mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs);
    +    });
     
    -    ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
    +    /*
    +     * - Confirms network tab Firewall table is present for Linodes with config-based interfaces.
    +     * - Confirms that "Add Firewall" button is present and enabled for Linodes with config-based interfaces.
    +     */
    +    it('shows the Firewall table for Linodes with config-based interfaces', () => {
    +      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +      ui.button
    +        .findByTitle('Add Firewall')
    +        .should('be.visible')
    +        .should('be.enabled');
    +
    +      cy.get('[data-qa-linode-firewalls-table]')
    +        .should('be.visible')
    +        .within(() => {
    +          cy.findByText('No Firewalls are assigned.').should('be.visible');
    +        });
    +    });
     
    -    ui.drawer.findByTitle('Add Network Interface').within(() => {
    -      // Verify firewalls fetch
    -      cy.wait('@getFirewalls');
    +    /*
    +     * - Confirms that network tab IP Addresses table is present for Linodes with config-based interfaces.
    +     * - Confirms that IP address add and delete buttons are present for Linodes with config-based interfaces.
    +     */
    +    it('shows the IP address add and remove buttons for Linodes with config-based interfaces', () => {
    +      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +      ui.button
    +        .findByTitle('Add an IP Address')
    +        .should('be.visible')
    +        .should('be.enabled');
    +
    +      cy.findByLabelText('Linode IP Addresses').should('be.visible');
    +      cy.findByText(mockLinodeIPv4.address)
    +        .should('be.visible')
    +        .closest('tr')
    +        .within(() => {
    +          cy.findByText('Public – IPv4').should('be.visible');
    +          ui.button.findByTitle('Delete').should('be.visible');
    +        });
    +    });
     
    -      // Try submitting the form
    -      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
    +    /**
    +     * - Confirms the Networking Interface table doesn't exist for config-based interfaces
    +     */
    +    it('does not show the Linode Interface networking table', () => {
    +      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
     
    -      // Verify a validation error shows
    -      cy.findByText('You must selected an Interface type.').should(
    -        'be.visible'
    -      );
    +      cy.get('[data-qa-linode-interfaces-table]').should('not.exist');
    +      cy.findByText('Add Network Interface').should('not.exist');
    +      cy.findByText('Interface Settings').should('not.exist');
    +    });
    +  });
     
    -      // Select the public interface type
    -      cy.findByLabelText('Public').click();
    +  describe('Linode with Linode-based interfaces', () => {
    +    const mockLinode = linodeFactory.build({
    +      interface_generation: 'linode',
    +    });
     
    -      // Verify a validation error goes away
    -      cy.findByText('You must selected an Interface type.').should('not.exist');
    +    const mockLinodeIPv4 = ipAddressFactory.build({
    +      linode_id: mockLinode.id,
    +      public: true,
    +      type: 'ipv4',
    +      region: mockLinode.region,
    +      interface_id: null,
    +    });
     
    -      // Select a Firewall
    -      ui.autocomplete.findByLabel('Firewall').click();
    -      ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
    +    const mockLinodeIPs: LinodeIPsResponse = {
    +      ipv4: {
    +        public: [mockLinodeIPv4],
    +        private: [],
    +        reserved: [],
    +        shared: [],
    +        vpc: [],
    +      },
    +    };
     
    -      mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
    +    const mockFirewalls = firewallFactory.buildList(3);
     
    -      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
    +    beforeEach(() => {
    +      mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode');
    +      mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs).as('getLinodeIPs');
    +      mockGetLinodeInterfaces(mockLinode.id, { interfaces: [] }).as(
    +        'getInterfaces'
    +      );
         });
     
    -    cy.wait('@createInterface').then((xhr) => {
    -      const requestPayload = xhr.request.body;
    +    /*
    +     * - Confirms that network tab Firewall table is absent for Linodes using new Linode-based interfaces.
    +     * - Confirms that IP address add and delete buttons are absent for Linodes using new Linode-based interfaces.
    +     */
    +    it('hides Firewall table and IP address buttons', () => {
    +      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +      // Confirm Firewalls section is absent
    +      cy.get('[data-qa-linode-firewalls-table]').should('not.exist');
    +      cy.findByText('Add Firewall').should('not.exist');
    +
    +      // Confirm add IP and delete IP buttons are missing from IP address section
    +      cy.findByLabelText('Linode IP Addresses').should('be.visible');
    +      cy.findByText('Add an IP Address').should('not.exist');
    +      cy.findByText(mockLinodeIPv4.address)
    +        .should('be.visible')
    +        .closest('tr')
    +        .within(() => {
    +          cy.findByText('Public – IPv4').should('be.visible');
    +          cy.findByText('Delete').should('not.exist');
    +        });
    +    });
     
    -      // Confirm that request payload includes a Public interface only
    -      expect(requestPayload['public']).to.be.an('object');
    -      expect(requestPayload['vpc']).to.equal(null);
    -      expect(requestPayload['vlan']).to.equal(null);
    +    it('confirms the Network Interfaces table functions as expected', () => {
    +      const publicInterface = linodeInterfaceFactoryPublic.build();
    +      mockGetLinodeInterfaces(mockLinode.id, {
    +        interfaces: [publicInterface],
    +      }).as('getInterfaces');
    +
    +      cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +      ui.button
    +        .findByTitle('Add Network Interface')
    +        .should('be.visible')
    +        .should('be.enabled');
    +      ui.button
    +        .findByTitle('Interface Settings')
    +        .should('be.visible')
    +        .should('be.enabled');
    +
    +      // Confirm table heading row
    +      cy.get('[data-qa-linode-interfaces-table]')
    +        .should('be.visible')
    +        .within(() => {
    +          cy.findByText('Type').should('be.visible');
    +          cy.findByText('ID').should('be.visible');
    +          cy.findByText('MAC Address').should('be.visible');
    +          cy.findByText('IP Addresses').should('be.visible');
    +          cy.findByText('Version').should('be.visible');
    +          cy.findByText('Firewall').should('be.visible');
    +          cy.findByText('Updated').should('be.visible');
    +          cy.findByText('Created').should('be.visible');
    +        });
    +
    +      // Confirm interface row's action menu
    +      cy.findByText(publicInterface.mac_address)
    +        .should('be.visible')
    +        .closest('tr')
    +        .within(() => {
    +          ui.actionMenu
    +            .findByTitle(
    +              `Action menu for Public Interface (${publicInterface.id})`
    +            )
    +            .should('be.visible')
    +            .should('be.enabled')
    +            .click();
    +
    +          ui.actionMenuItem
    +            .findByTitle('Details')
    +            .should('be.visible')
    +            .should('be.enabled');
    +          ui.actionMenuItem
    +            .findByTitle('Edit')
    +            .should('be.visible')
    +            .should('be.enabled');
    +          ui.actionMenuItem
    +            .findByTitle('Delete')
    +            .should('be.visible')
    +            .should('be.enabled');
    +        });
         });
     
    -    ui.toast.assertMessage('Successfully added network interface.');
    +    describe('Adding a Linode Interface', () => {
    +      it('allows the user to add a VLAN interface', () => {
    +        const mockLinodeInterface = linodeInterfaceFactoryVlan.build();
     
    -    // Verify the interface row shows upon creation
    -    cy.findByText(linodeInterface.mac_address)
    -      .closest('tr')
    -      .within(() => {
    -        // Verify we fetch the interfaces firewalls and the label shows
    -        cy.wait('@getInterfaceFirewalls');
    -        cy.findByText(selectedFirewall.label).should('be.visible');
    +        mockGetFirewalls(mockFirewalls).as('getFirewalls');
    +        mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
    +          'createInterface'
    +        );
    +        mockGetLinodeInterfaceFirewalls(
    +          mockLinode.id,
    +          mockLinodeInterface.id,
    +          []
    +        ).as('getInterfaceFirewalls');
    +
    +        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +        cy.wait(['@getLinode', '@getInterfaces']);
    +
    +        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
    +
    +        ui.drawer.findByTitle('Add Network Interface').within(() => {
    +          // Verify firewalls fetch
    +          cy.wait('@getFirewalls');
    +
    +          // Try submitting the form
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +
    +          // Verify a validation error shows
    +          cy.findByText('You must selected an Interface type.').should(
    +            'be.visible'
    +          );
    +
    +          // Select the public interface type
    +          cy.findByLabelText('VLAN').click();
    +
    +          // Verify a validation error goes away
    +          cy.findByText('You must selected an Interface type.').should(
    +            'not.exist'
    +          );
    +
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +
    +          // Verify an error shows because a VLAN was not selected nor created
    +          cy.findByText('VLAN label is required.').should('be.visible');
    +
    +          // Verify VLAN label and IPAM selects
    +          // Type label for VLAN
    +          ui.autocomplete
    +            .findByLabel('VLAN')
    +            .should('be.visible')
    +            .click()
    +            .type('testVLAN');
    +
    +          cy.findByText('IPAM Address').should('be.visible').click();
    +          cy.findByText(
    +            'IPAM address must use IP/netmask format, e.g. 192.0.2.0/24.'
    +          ).should('be.visible');
    +
    +          // Verify VLAN error disappears
    +          cy.findByText('VLAN label is required.').should('not.exist');
    +
    +          // Verify firewall select doees not appear
    +          cy.findByText('Firewall').should('not.exist');
    +
    +          mockGetLinodeInterfaces(mockLinode.id, {
    +            interfaces: [mockLinodeInterface],
    +          });
    +
    +          mockGetLinodeInterfaces(mockLinode.id, {
    +            interfaces: [mockLinodeInterface],
    +          });
    +
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +        });
    +
    +        cy.wait('@createInterface').then((xhr) => {
    +          const requestPayload = xhr.request.body;
    +
    +          // Confirm that request payload includes a VLAN interface only
    +          expect(requestPayload['public']).to.equal(null);
    +          expect(requestPayload['vpc']).to.equal(null);
    +          expect(requestPayload['vlan']).to.be.an('object');
    +        });
    +
    +        ui.toast.assertMessage('Successfully added network interface.');
    +
    +        // Verify the interface row shows upon creation
    +        cy.findByText(mockLinodeInterface.mac_address)
    +          .closest('tr')
    +          .within(() => {
    +            // Verify we fetch the interfaces firewalls and the label shows
    +            cy.wait('@getInterfaceFirewalls');
    +            cy.findByText('None').should('be.visible');
    +
    +            // Verify the interface type shows
    +            cy.findByText('VLAN').should('be.visible');
    +          });
    +      });
    +
    +      it('allows the user to add a public network interface with a firewall', () => {
    +        const mockLinodeInterface = linodeInterfaceFactoryPublic.build();
    +        const selectedMockFirewall = mockFirewalls[1];
     
    -        // Verify the interface type shows
    -        cy.findByText('Public').should('be.visible');
    +        mockGetFirewalls(mockFirewalls).as('getFirewalls');
    +        mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
    +          'createInterface'
    +        );
    +        mockGetLinodeInterfaceFirewalls(mockLinode.id, mockLinodeInterface.id, [
    +          selectedMockFirewall,
    +        ]).as('getInterfaceFirewalls');
    +
    +        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +        cy.wait(['@getLinode', '@getInterfaces']);
    +
    +        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
    +
    +        ui.drawer.findByTitle('Add Network Interface').within(() => {
    +          // Verify firewalls fetch
    +          cy.wait('@getFirewalls');
    +
    +          // Try submitting the form
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +
    +          // Verify a validation error shows
    +          cy.findByText('You must selected an Interface type.').should(
    +            'be.visible'
    +          );
    +
    +          // Select the public interface type
    +          cy.findByLabelText('Public').click();
    +
    +          // Verify a validation error goes away
    +          cy.findByText('You must selected an Interface type.').should(
    +            'not.exist'
    +          );
    +
    +          // Select a Firewall
    +          ui.autocomplete.findByLabel('Firewall').click();
    +          ui.autocompletePopper.findByTitle(selectedMockFirewall.label).click();
    +
    +          mockGetLinodeInterfaces(mockLinode.id, {
    +            interfaces: [mockLinodeInterface],
    +          });
    +
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +        });
    +
    +        cy.wait('@createInterface').then((xhr) => {
    +          const requestPayload = xhr.request.body;
    +
    +          // Confirm that request payload includes a Public interface only
    +          expect(requestPayload['public']).to.be.an('object');
    +          expect(requestPayload['vpc']).to.equal(null);
    +          expect(requestPayload['vlan']).to.equal(null);
    +        });
    +
    +        ui.toast.assertMessage('Successfully added network interface.');
    +
    +        // Verify the interface row shows upon creation
    +        cy.findByText(mockLinodeInterface.mac_address)
    +          .closest('tr')
    +          .within(() => {
    +            // Verify we fetch the interfaces firewalls and the label shows
    +            cy.wait('@getInterfaceFirewalls');
    +            cy.findByText(selectedMockFirewall.label).should('be.visible');
    +
    +            // Verify the interface type shows
    +            cy.findByText('Public').should('be.visible');
    +          });
           });
    -  });
     
    -  it('allows the user to add a VPC network interface with a firewall', () => {
    -    const linode = linodeFactory.build({ interface_generation: 'linode' });
    -    const firewalls = firewallFactory.buildList(3);
    -    const subnets = subnetFactory.buildList(3);
    -    const vpcs = vpcFactory.buildList(3, { subnets });
    -    const linodeInterface = linodeInterfaceFactoryVPC.build();
    +      it('allows the user to add a VPC network interface with a firewall', () => {
    +        const linode = linodeFactory.build({ interface_generation: 'linode' });
    +        const firewalls = firewallFactory.buildList(3);
    +        const subnets = subnetFactory.buildList(3);
    +        const vpcs = vpcFactory.buildList(3, { subnets });
    +        const linodeInterface = linodeInterfaceFactoryVPC.build();
     
    -    const selectedFirewall = firewalls[1];
    -    const selectedVPC = vpcs[1];
    -    const selectedSubnet = selectedVPC.subnets[0];
    +        const selectedFirewall = firewalls[1];
    +        const selectedVPC = vpcs[1];
    +        const selectedSubnet = selectedVPC.subnets[0];
     
    -    mockGetLinodeDetails(linode.id, linode).as('getLinode');
    -    mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
    -    mockGetFirewalls(firewalls).as('getFirewalls');
    -    mockGetVPCs(vpcs).as('getVPCs');
    -    mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
    -    mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
    -      selectedFirewall,
    -    ]).as('getInterfaceFirewalls');
    +        mockGetLinodeDetails(linode.id, linode).as('getLinode');
    +        mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as(
    +          'getInterfaces'
    +        );
    +        mockGetFirewalls(firewalls).as('getFirewalls');
    +        mockGetVPCs(vpcs).as('getVPCs');
    +        mockCreateLinodeInterface(linode.id, linodeInterface).as(
    +          'createInterface'
    +        );
    +        mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
    +          selectedFirewall,
    +        ]).as('getInterfaceFirewalls');
     
    -    cy.visitWithLogin(`/linodes/${linode.id}/networking`);
    +        cy.visitWithLogin(`/linodes/${linode.id}/networking`);
     
    -    cy.wait(['@getLinode', '@getInterfaces']);
    +        cy.wait(['@getLinode', '@getInterfaces']);
     
    -    ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
    +        ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
     
    -    ui.drawer.findByTitle('Add Network Interface').within(() => {
    -      // Verify firewalls fetch
    -      cy.wait('@getFirewalls');
    +        ui.drawer.findByTitle('Add Network Interface').within(() => {
    +          // Verify firewalls fetch
    +          cy.wait('@getFirewalls');
     
    -      cy.findByLabelText('VPC').click();
    +          cy.findByLabelText('VPC').click();
     
    -      // Verify VPCs fetch
    -      cy.wait('@getVPCs');
    +          // Verify VPCs fetch
    +          cy.wait('@getVPCs');
     
    -      // Select a VPC
    -      ui.autocomplete.findByLabel('VPC').click();
    -      ui.autocompletePopper.findByTitle(selectedVPC.label).click();
    +          // Select a VPC
    +          ui.autocomplete.findByLabel('VPC').click();
    +          ui.autocompletePopper.findByTitle(selectedVPC.label).click();
     
    -      // Select a Firewall
    -      ui.autocomplete.findByLabel('Firewall').click();
    -      ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
    +          // Select a Firewall
    +          ui.autocomplete.findByLabel('Firewall').click();
    +          ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
     
    -      // Submit the form
    -      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
    +          // Submit the form
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
     
    -      // Verify an error shows because a subnet is not selected
    -      cy.findByText('Subnet is required.').should('be.visible');
    +          // Verify an error shows because a subnet is not selected
    +          cy.findByText('Subnet is required.').should('be.visible');
     
    -      // Select a Subnet
    -      ui.autocomplete.findByLabel('Subnet').click();
    -      ui.autocompletePopper
    -        .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
    -        .click();
    +          // Select a Subnet
    +          ui.autocomplete.findByLabel('Subnet').click();
    +          ui.autocompletePopper
    +            .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
    +            .click();
     
    -      // Verify the error goes away
    -      cy.findByText('Subnet is required.').should('not.exist');
    +          // Verify the error goes away
    +          cy.findByText('Subnet is required.').should('not.exist');
     
    -      mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
    +          mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
     
    -      ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
    -    });
    +          ui.button
    +            .findByAttribute('type', 'submit')
    +            .should('be.enabled')
    +            .click();
    +        });
     
    -    cy.wait('@createInterface').then((xhr) => {
    -      const requestPayload = xhr.request.body;
    +        cy.wait('@createInterface').then((xhr) => {
    +          const requestPayload = xhr.request.body;
    +
    +          // Confirm that request payload includes VPC interface only
    +          expect(requestPayload['public']).to.equal(null);
    +          expect(requestPayload['vpc']['subnet_id']).to.equal(
    +            selectedSubnet.id
    +          );
    +          expect(requestPayload['vlan']).to.equal(null);
    +        });
     
    -      // Confirm that request payload includes VPC interface only
    -      expect(requestPayload['public']).to.equal(null);
    -      expect(requestPayload['vpc']['subnet_id']).to.equal(selectedSubnet.id);
    -      expect(requestPayload['vlan']).to.equal(null);
    +        ui.toast.assertMessage('Successfully added network interface.');
    +
    +        // Verify the interface row shows upon creation
    +        cy.findByText(linodeInterface.mac_address)
    +          .closest('tr')
    +          .within(() => {
    +            // Verify we fetch the interfaces firewalls and the label shows
    +            cy.wait('@getInterfaceFirewalls');
    +            cy.findByText(selectedFirewall.label).should('be.visible');
    +
    +            // Verify the interface type shows
    +            cy.findByText('VPC').should('be.visible');
    +          });
    +      });
         });
     
    -    ui.toast.assertMessage('Successfully added network interface.');
    +    describe('Interface Details drawer', () => {
    +      it('confirms the details drawer for a public interface', () => {
    +        const linodeInterface = linodeInterfaceFactoryPublic.build({
    +          public: {
    +            ipv6: {
    +              ranges: [
    +                {
    +                  range: '2600:3c06:e001:149::/64',
    +                  route_target: null,
    +                },
    +                {
    +                  range: '2600:3c06:e001:149::/56',
    +                  route_target: null,
    +                },
    +              ],
    +              shared: [],
    +              slaac: [
    +                { address: '2600:3c06::2000:13ff:fe6b:31b0', prefix: '64' },
    +              ],
    +            },
    +          },
    +        });
    +        mockGetLinodeInterfaces(mockLinode.id, {
    +          interfaces: [linodeInterface],
    +        }).as('getInterfaces');
    +        mockGetLinodeInterface(
    +          mockLinode.id,
    +          linodeInterface.id,
    +          linodeInterface
    +        );
     
    -    // Verify the interface row shows upon creation
    -    cy.findByText(linodeInterface.mac_address)
    -      .closest('tr')
    -      .within(() => {
    -        // Verify we fetch the interfaces firewalls and the label shows
    -        cy.wait('@getInterfaceFirewalls');
    -        cy.findByText(selectedFirewall.label).should('be.visible');
    +        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +        // Open up the detail drawer
    +        cy.findByText(linodeInterface.mac_address)
    +          .should('be.visible')
    +          .closest('tr')
    +          .within(() => {
    +            ui.actionMenu
    +              .findByTitle(
    +                `Action menu for Public Interface (${linodeInterface.id})`
    +              )
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +
    +            ui.actionMenuItem
    +              .findByTitle('Details')
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +          });
    +
    +        // Confirm drawer content
    +        ui.drawer
    +          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
    +          .within(() => {
    +            cy.findByText('IPv4 Default Route').should('be.visible');
    +            cy.findByText('IPv6 Default Route').should('be.visible');
    +            cy.findByText('Type').should('be.visible');
    +            cy.findByText('Public').should('be.visible');
    +            cy.findByText('IPv4 Addresses').should('be.visible');
    +            cy.findByText(
    +              `${linodeInterface.public?.ipv4.addresses[0].address} (Primary)`
    +            ).should('be.visible');
    +            cy.findByText('2600:3c06::2000:13ff:fe6b:31b0 (SLAAC)').should(
    +              'be.visible'
    +            );
    +            cy.findByText('2600:3c06:e001:149::/64 (Range)').should(
    +              'be.visible'
    +            );
    +            cy.findByText('2600:3c06:e001:149::/56 (Range)').should(
    +              'be.visible'
    +            );
    +          });
    +      });
    +
    +      it('confirms the details drawer for a VLAN interface', () => {
    +        const linodeInterface = linodeInterfaceFactoryVlan.build();
    +        mockGetLinodeInterfaces(mockLinode.id, {
    +          interfaces: [linodeInterface],
    +        }).as('getInterfaces');
    +        mockGetLinodeInterface(
    +          mockLinode.id,
    +          linodeInterface.id,
    +          linodeInterface
    +        );
    +
    +        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
     
    -        // Verify the interface type shows
    -        cy.findByText('VPC').should('be.visible');
    +        // Open up the detail drawer
    +        cy.findByText(linodeInterface.mac_address)
    +          .should('be.visible')
    +          .closest('tr')
    +          .within(() => {
    +            ui.actionMenu
    +              .findByTitle(
    +                `Action menu for VLAN Interface (${linodeInterface.id})`
    +              )
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +
    +            ui.actionMenuItem
    +              .findByTitle('Details')
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +          });
    +
    +        // Confirm drawer content
    +        ui.drawer
    +          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
    +          .within(() => {
    +            cy.findByText('Type').should('be.visible');
    +            cy.findByText('VLAN').should('be.visible');
    +            cy.findByText('VLAN Label').should('be.visible');
    +            cy.findByText(`${linodeInterface.vlan?.vlan_label}`).should(
    +              'be.visible'
    +            );
    +            cy.findByText('IPAM Address').should('be.visible');
    +            cy.findByText(`${linodeInterface.vlan?.ipam_address}`).should(
    +              'be.visible'
    +            );
    +          });
    +      });
    +
    +      it('confirms the details drawer for a VPC interface', () => {
    +        const linodeInterface = linodeInterfaceFactoryVPC.build();
    +        const mockSubnet = subnetFactory.build({
    +          id: linodeInterface.vpc?.subnet_id,
    +        });
    +        const mockVPC = vpcFactory.build({
    +          id: linodeInterface.vpc?.vpc_id,
    +          subnets: [mockSubnet],
    +        });
    +
    +        mockGetVPC(mockVPC);
    +        mockGetLinodeInterfaces(mockLinode.id, {
    +          interfaces: [linodeInterface],
    +        }).as('getInterfaces');
    +        mockGetLinodeInterface(
    +          mockLinode.id,
    +          linodeInterface.id,
    +          linodeInterface
    +        );
    +
    +        cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
    +
    +        // Open up the details drawer
    +        cy.findByText(linodeInterface.mac_address)
    +          .should('be.visible')
    +          .closest('tr')
    +          .within(() => {
    +            ui.actionMenu
    +              .findByTitle(
    +                `Action menu for VPC Interface (${linodeInterface.id})`
    +              )
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +
    +            ui.actionMenuItem
    +              .findByTitle('Details')
    +              .should('be.visible')
    +              .should('be.enabled')
    +              .click();
    +          });
    +
    +        // Confirm drawer content
    +        ui.drawer
    +          .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
    +          .within(() => {
    +            cy.findByText('IPv4 Default Route').should('be.visible');
    +            cy.findByText('Type').should('be.visible');
    +            cy.findByText('VPC').should('be.visible');
    +            cy.findByText('VPC Label').should('be.visible');
    +            cy.findByText(`${mockVPC.label}`).should('be.visible');
    +            cy.findByText('Subnet Label').should('be.visible');
    +            cy.findByText(`${mockSubnet.label}`).should('be.visible');
    +            cy.findByText('IPv4 Addresses').should('be.visible');
    +          });
           });
    +    });
       });
     });
    diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts
    index 28516154615..26c50864836 100644
    --- a/packages/manager/cypress/support/intercepts/linodes.ts
    +++ b/packages/manager/cypress/support/intercepts/linodes.ts
    @@ -684,6 +684,27 @@ export const mockGetLinodeInterfaces = (
       );
     };
     
    +/**
    + * Mocks GET request to get a single Linode Interface.
    + *
    + * @param linodeId - ID of Linode to get interface associated with it
    + * @param interfaceId - ID of interface to get
    + * @param interfaces - the mocked Linode interface
    + *
    + * @returns Cypress Chainable.
    + */
    +export const mockGetLinodeInterface = (
    +  linodeId: number,
    +  interfaceId: number,
    +  linodeInterface: LinodeInterface
    +): Cypress.Chainable => {
    +  return cy.intercept(
    +    'GET',
    +    apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`),
    +    linodeInterface
    +  );
    +};
    +
     /**
      * Intercepts POST request to create a Linode Interface.
      *
    diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
    index f537896fd81..2787eea9874 100644
    --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
    +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
    @@ -52,7 +52,7 @@ export const Firewall = () => {
                 }
                 text={
                   flags.secureVmCopy?.linodeCreate?.text ??
    -              'All accounts must apply an compliant firewall to all their Linodes.'
    +              'All accounts must apply a compliant firewall to all their Linodes.'
                 }
               />
             )}
    diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
    index cab7c07820d..34d99be08f3 100644
    --- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
    +++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
    @@ -62,7 +62,7 @@ export const InterfaceFirewall = ({ index }: Props) => {
                 }
                 text={
                   flags.secureVmCopy?.linodeCreate?.text ??
    -              'All accounts must apply an compliant firewall to all their Linodes.'
    +              'All accounts must apply a compliant firewall to all their Linodes.'
                 }
               />
             )}
    diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
    index 6aed322306f..03e67423d15 100644
    --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
    +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
    @@ -110,7 +110,7 @@ export const LinodeFirewalls = (props: LinodeFirewallsProps) => {
               Add Firewall
             
           
    -      
    +      
    Firewall diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx index e7561e27138..35087465117 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx @@ -5,7 +5,7 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText'; export const VlanInterfaceDetailsContent = (props: { ipam_address: string; - vlan_label: string; + vlan_label: null | string; }) => { const { ipam_address, vlan_label } = props; return ( @@ -20,9 +20,7 @@ export const VlanInterfaceDetailsContent = (props: { IPAM Address - - - + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx index 5530bfb6172..80013557fa2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx @@ -18,7 +18,7 @@ interface Props { export const LinodeInterfacesTable = ({ handlers, linodeId }: Props) => { return ( -
    +
    Type From 906b50fe44459d48f226cc24e4589f782fa0d97e Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:10:45 +0200 Subject: [PATCH 13/54] changed: [UIE-9191] -UIE/RBAC LA gating for useQueryWithPermissions (#12880) * add LA gating to useQueryWithPermissions * small cleanup * coverage * Added changeset: UIE/RBAC LA gating for useQueryWithPermissions --- .../pr-12880-changed-1758009806570.md | 5 + .../src/features/IAM/hooks/usePermissions.ts | 32 ++- .../IAM/hooks/useQueryWithPermissions.test.ts | 267 ++++++++++++++++++ 3 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-12880-changed-1758009806570.md create mode 100644 packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts diff --git a/packages/manager/.changeset/pr-12880-changed-1758009806570.md b/packages/manager/.changeset/pr-12880-changed-1758009806570.md new file mode 100644 index 00000000000..095e49a3929 --- /dev/null +++ b/packages/manager/.changeset/pr-12880-changed-1758009806570.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +UIE/RBAC LA gating for useQueryWithPermissions ([#12880](https://github.com/linode/manager/pull/12880)) diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index d5eba95fcba..117bbf322e9 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -169,17 +169,41 @@ export const useQueryWithPermissions = ( ...restQueryResult } = useQueryResult; const { data: profile } = useProfile(); - const { isIAMEnabled } = useIsIAMEnabled(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + + const accessType = entityType; + + /** + * Apply the same Beta/LA permission logic as usePermissions. + * - Use Beta Permissions if: + * - The feature is beta + * - The access type is in the BETA_ACCESS_TYPE_SCOPE + * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE + * - Use LA Permissions if: + * - The feature is not beta + */ + const useBetaPermissions = + isIAMEnabled && + isIAMBeta && + BETA_ACCESS_TYPE_SCOPE.includes(accessType) && + LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some((blacklistedPermission) => + permissionsToCheck.includes(blacklistedPermission as AccountAdmin) + ) === false; + const useLAPermissions = isIAMEnabled && !isIAMBeta; + const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; + const { data: entityPermissions, isLoading: areEntityPermissionsLoading } = useEntitiesPermissions( allEntities, entityType, profile, - isIAMEnabled && enabled + shouldUsePermissionMap && enabled ); - const { data: grants } = useGrants(!isIAMEnabled); + const { data: grants } = useGrants( + (!isIAMEnabled || !shouldUsePermissionMap) && enabled + ); - const entityPermissionsMap = isIAMEnabled + const entityPermissionsMap = shouldUsePermissionMap ? toEntityPermissionMap( allEntities, entityPermissions, diff --git a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts new file mode 100644 index 00000000000..1ecd51aab28 --- /dev/null +++ b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts @@ -0,0 +1,267 @@ +import { renderHook } from '@testing-library/react'; + +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useQueryWithPermissions } from './usePermissions'; + +import type { EntityBase } from './usePermissions'; +import type { APIError, PermissionType } from '@linode/api-v4'; +import type { UseQueryResult } from '@tanstack/react-query'; + +type Entity = { id: number; label: string }; + +const queryMocks = vi.hoisted(() => { + let entitiesPermsLoading = false; + + return { + useIsIAMEnabled: vi + .fn() + .mockReturnValue({ isIAMEnabled: true, isIAMBeta: true }), + useGrants: vi.fn().mockReturnValue({ data: null }), + useProfile: vi + .fn() + .mockReturnValue({ data: { username: 'user-1', restricted: true } }), + useQueries: Object.assign( + vi.fn().mockImplementation(({ queries }) => + (queries || []).map(() => ({ + data: null, + error: null, + isError: false, + isLoading: entitiesPermsLoading, + })) + ), + { + setEntitiesPermsLoading: (b: boolean) => { + entitiesPermsLoading = b; + }, + } + ), + }; +}); + +vi.mock(import('@linode/queries'), async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useGrants: queryMocks.useGrants, + useProfile: queryMocks.useProfile, + useQueries: queryMocks.useQueries, + }; +}); + +vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/useIsIAMEnabled' + ); + + return { + ...actual, + useIsIAMEnabled: queryMocks.useIsIAMEnabled, + }; +}); + +vi.mock('./adapters/permissionAdapters', () => ({ + toEntityPermissionMap: vi.fn( + (entities: EntityBase[] = [], entitiesPermissions: PermissionType[]) => { + const map: Record> = {}; + entities.forEach((e) => { + map[e.id] = entitiesPermissions?.reduce>( + (acc, p) => { + acc[p] = e.id === 1; + return acc; + }, + {} + ); + }); + + return map; + } + ), + entityPermissionMapFrom: vi.fn(() => { + return { + 1: { update_linode: true, resize_volume: true, create_volume: true }, + 2: { update_linode: true, resize_volume: true, create_volume: true }, + }; + }), +})); + +describe('useQueryWithPermissions', () => { + const entities: Entity[] = [ + { id: 1, label: 'one' }, + { id: 2, label: 'two' }, + ]; + + const baseQueryResult = { + data: entities, + error: null, + isError: false, + isLoading: false, + } as UseQueryResult; + + beforeEach(() => { + vi.clearAllMocks(); + queryMocks.useQueries.setEntitiesPermsLoading(false); + }); + + it('uses Beta permissions when IAM enabled + beta true + in scope; filters restricted entities', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + queryMocks.useProfile.mockReturnValue({ + data: { username: 'user-1', restricted: true }, + }); + + const { result } = renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'linode', + ['update_linode'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + + const calls = queryMocks.useQueries.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const queryArgs = calls[0][0]; + expect( + queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true) + ).toBe(true); + + expect(result.current.data.map((e) => e.id)).toEqual([]); + expect(result.current.hasFiltered).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('falls back to grants when IAM disabled', () => { + const flags = { iam: { beta: false, enabled: false } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: false, + isIAMBeta: false, + }); + + const { result } = renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'linode', + ['update_linode'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + + const queryArgs = queryMocks.useQueries.mock.calls[0][0]; + expect( + queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) + ).toBe(true); + + expect(result.current.data.map((e) => e.id)).toEqual([1, 2]); + expect(result.current.hasFiltered).toBe(false); + }); + + it('falls back to grants when Beta true but entityType not in scope', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'volume', + ['resize_volume'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + const qArg = queryMocks.useQueries.mock.calls[0][0]; + expect( + qArg.queries.every((q: { enabled?: boolean }) => q.enabled === false) + ).toBe(true); + }); + + it('falls back to grants when Beta true but permission is in the LA exclusion list', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + + renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'volume', + ['create_volume'], // blacklisted + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(true); + const queryArgs = queryMocks.useQueries.mock.calls[0][0]; + expect( + queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false) + ).toBe(true); + }); + + it('uses LA permissions when IAM enabled + beta false', () => { + const flags = { iam: { beta: false, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: false, + }); + + renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'linode', + ['update_linode'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(queryMocks.useGrants).toHaveBeenCalledWith(false); + const queryArgs = queryMocks.useQueries.mock.calls[0][0]; + expect( + queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true) + ).toBe(true); + }); + + it('marks loading when entity permissions queries are loading', () => { + const flags = { iam: { beta: true, enabled: true } }; + queryMocks.useIsIAMEnabled.mockReturnValue({ + isIAMEnabled: true, + isIAMBeta: true, + }); + queryMocks.useQueries.setEntitiesPermsLoading(true); + + const { result } = renderHook( + () => + useQueryWithPermissions( + baseQueryResult, + 'linode', + ['update_linode'], + true + ), + { wrapper: (ui) => wrapWithTheme(ui, { flags }) } + ); + + expect(result.current.isLoading).toBe(true); + }); +}); From 6f7b56e969f6a51274f54ed25ced0e0303d94375 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Tue, 23 Sep 2025 13:45:23 +0200 Subject: [PATCH 14/54] upcoming: [DPS-34666] - Log path sample component (#12851) * upcoming: [DPS-34666] - Log path sample component --- ...r-12851-upcoming-features-1757490281299.md | 5 + packages/api-v4/src/delivery/types.ts | 11 ++- ...r-12851-upcoming-features-1757490359796.md | 5 + .../DestinationCreate.test.tsx | 54 +++++++++- .../DestinationForm/DestinationCreate.tsx | 13 ++- .../DestinationForm/DestinationEdit.tsx | 16 ++- ...tinationLinodeObjectStorageDetailsForm.tsx | 20 ++-- .../features/Delivery/Shared/LabelValue.tsx | 10 +- .../features/Delivery/Shared/PathSample.tsx | 98 ++++++++++++++++++- .../src/features/Delivery/Shared/types.ts | 11 ++- .../Clusters/StreamFormClusters.tsx | 6 +- .../Clusters/StreamFormClustersTable.tsx | 6 +- ...LinodeObjectStorageDetailsSummary.test.tsx | 28 ++++++ ...ationLinodeObjectStorageDetailsSummary.tsx | 24 ++++- .../Streams/StreamForm/StreamCreate.test.tsx | 1 + .../Streams/StreamForm/StreamCreate.tsx | 1 + .../Streams/StreamForm/StreamEdit.tsx | 21 +++- .../Streams/StreamForm/StreamForm.tsx | 20 +++- .../features/Delivery/deliveryUtils.test.ts | 38 ++++++- .../src/features/Delivery/deliveryUtils.ts | 17 +++- .../mocks/presets/crud/handlers/delivery.ts | 22 ++++- ...r-12851-upcoming-features-1757490426873.md | 5 + packages/validation/src/delivery.schema.ts | 33 +++++-- 23 files changed, 416 insertions(+), 49 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md create mode 100644 packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md create mode 100644 packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md diff --git a/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md b/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md new file mode 100644 index 00000000000..ec645e1fbc0 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Update Destination's details interface ([#12851](https://github.com/linode/manager/pull/12851)) diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts index 15b3129fdd3..565686bb810 100644 --- a/packages/api-v4/src/delivery/types.ts +++ b/packages/api-v4/src/delivery/types.ts @@ -121,8 +121,17 @@ export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { id: number; } +export interface LinodeObjectStorageDetailsPayload + extends Omit { + path?: string; +} + +export type DestinationDetailsPayload = + | CustomHTTPsDetails + | LinodeObjectStorageDetailsPayload; + export interface CreateDestinationPayload { - details: CustomHTTPsDetails | LinodeObjectStorageDetails; + details: DestinationDetailsPayload; label: string; type: DestinationType; } diff --git a/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md b/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md new file mode 100644 index 00000000000..83714b72577 --- /dev/null +++ b/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Generate Destination's sample Path based on Stream Type or custom value ([#12851](https://github.com/linode/manager/pull/12851)) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx index 38ee59db256..65371c3eccf 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -1,5 +1,10 @@ import { destinationType } from '@linode/api-v4'; -import { screen, waitFor } from '@testing-library/react'; +import { profileFactory } from '@linode/utilities'; +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; @@ -70,6 +75,47 @@ describe('DestinationCreate', () => { } ); + it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { + const profileUid = 123; + const [month, day, year] = new Date().toLocaleDateString().split('/'); + server.use( + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build({ uid: profileUid })); + }) + ); + + renderDestinationCreate(); + + const loadingElement = screen.queryByTestId('circle-progress'); + await waitForElementToBeRemoved(loadingElement); + + const samplePath = screen.getByText( + `/audit_logs/com.akamai.audit.login/${profileUid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597.gz` + ); + expect(samplePath).toBeInTheDocument(); + + // Type the test value inside the input + const logPathPrefixInput = screen.getByLabelText('Log Path Prefix'); + + await userEvent.type(logPathPrefixInput, 'test'); + // sample path should be created based on *log path* value + expect(samplePath.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597.gz' + ); + + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/test'); + expect(samplePath.textContent).toEqual( + '/test/akamai_log-000166-1756015362-319597.gz' + ); + + await userEvent.clear(logPathPrefixInput); + await userEvent.type(logPathPrefixInput, '/'); + expect(samplePath.textContent).toEqual( + '/akamai_log-000166-1756015362-319597.gz' + ); + }); + describe('given Test Connection and Create Destination buttons', () => { const testConnectionButtonText = 'Test Connection'; const createDestinationButtonText = 'Create Destination'; @@ -107,6 +153,9 @@ describe('DestinationCreate', () => { http.post('*/monitor/streams/destinations', () => { createDestinationSpy(); return HttpResponse.json({}); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); }) ); @@ -141,6 +190,9 @@ describe('DestinationCreate', () => { http.post('*/monitor/streams/destinations/verify', () => { verifyDestinationSpy(); return HttpResponse.error(); + }), + http.get('*/profile', () => { + return HttpResponse.json(profileFactory.build()); }) ); diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx index 15b997bb0c1..dd0eb514589 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx @@ -1,7 +1,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { destinationType } from '@linode/api-v4'; import { useCreateDestinationMutation } from '@linode/queries'; -import { destinationSchema } from '@linode/validation'; +import { destinationFormSchema } from '@linode/validation'; import { useNavigate } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; @@ -9,8 +9,10 @@ import { FormProvider, useForm } from 'react-hook-form'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; +import { getDestinationPayloadDetails } from 'src/features/Delivery/deliveryUtils'; import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; +import type { CreateDestinationPayload } from '@linode/api-v4'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; @@ -39,14 +41,19 @@ export const DestinationCreate = () => { type: destinationType.LinodeObjectStorage, details: { region: '', + path: '', }, }, mode: 'onBlur', - resolver: yupResolver(destinationSchema), + resolver: yupResolver(destinationFormSchema), }); const onSubmit = () => { - const destination = form.getValues(); + const formValues = form.getValues(); + const destination: CreateDestinationPayload = { + ...formValues, + details: getDestinationPayloadDetails(formValues.details), + }; createDestination(destination) .then(() => { diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx index 17c777e4455..b040d6aebbf 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx @@ -5,7 +5,7 @@ import { useUpdateDestinationMutation, } from '@linode/queries'; import { Box, CircleProgress, ErrorState } from '@linode/ui'; -import { destinationSchema } from '@linode/validation'; +import { destinationFormSchema } from '@linode/validation'; import { useNavigate, useParams } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; @@ -17,6 +17,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import type { UpdateDestinationPayloadWithId } from '@linode/api-v4'; import type { LandingHeaderProps } from 'src/components/LandingHeader'; import type { DestinationFormType } from 'src/features/Delivery/Shared/types'; @@ -53,22 +54,31 @@ export const DestinationEdit = () => { type: destinationType.LinodeObjectStorage, details: { region: '', + path: '', }, }, mode: 'onBlur', - resolver: yupResolver(destinationSchema), + resolver: yupResolver(destinationFormSchema), }); useEffect(() => { if (destination) { form.reset({ ...destination, + ...('path' in destination.details + ? { + details: { + ...destination.details, + path: destination.details.path || '', + }, + } + : {}), }); } }, [destination, form]); const onSubmit = () => { - const destination = { + const destination: UpdateDestinationPayloadWithId = { id: destinationId, ...form.getValues(), }; diff --git a/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx index cf599ef9374..9f99b4f030f 100644 --- a/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx +++ b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx @@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; import { Box, Divider, TextField, Typography } from '@linode/ui'; import React from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { HideShowText } from 'src/components/PasswordInput/HideShowText'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -36,6 +36,10 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la); const { data: regions } = useRegionsQuery(); const { control } = useFormContext(); + const path = useWatch({ + control, + name: controlPaths?.path, + }); return ( <> @@ -126,7 +130,13 @@ export const DestinationLinodeObjectStorageDetailsForm = ({ /> Path - + *': { width: '100%' } }} + > field.onChange(value)} placeholder="Log Path Prefix" - sx={{ width: 416 }} + sx={{ maxWidth: 416 }} value={field.value} /> )} /> - + ); diff --git a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx index 0104400dcf0..da41143ffc3 100644 --- a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx +++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx @@ -3,6 +3,7 @@ import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; interface LabelValueProps { + children?: React.ReactNode; compact?: boolean; 'data-testid'?: string; label: string; @@ -10,7 +11,13 @@ interface LabelValueProps { } export const LabelValue = (props: LabelValueProps) => { - const { compact = false, label, value, 'data-testid': dataTestId } = props; + const { + compact = false, + label, + value, + 'data-testid': dataTestId, + children, + } = props; const theme = useTheme(); return ( @@ -29,6 +36,7 @@ export const LabelValue = (props: LabelValueProps) => { {label}: {value} + {children} ); }; diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx index 15920c4a658..77ae2f2cac1 100644 --- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -1,6 +1,23 @@ -import { Box, InputLabel } from '@linode/ui'; +import { streamType, type StreamType } from '@linode/api-v4'; +import { useProfile } from '@linode/queries'; +import { Box, InputLabel, Stack, TooltipIcon, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; + +const sxTooltipIcon = { + marginLeft: '4px', + padding: '0px', + marginTop: '-2px', +}; + +const logType = { + [streamType.LKEAuditLogs]: 'com.akamai.audit.k8s', + [streamType.AuditLogs]: 'com.akamai.audit.login', +}; interface PathSampleProps { value: string; @@ -8,18 +25,89 @@ interface PathSampleProps { export const PathSample = (props: PathSampleProps) => { const { value } = props; + const fileName = 'akamai_log-000166-1756015362-319597.gz'; + const sampleClusterId = useMemo( + // eslint-disable-next-line sonarjs/pseudo-random + () => Math.floor(Math.random() * 90000) + 10000, + [] + ); + + const { control } = useFormContext(); + const streamTypeFormValue = useWatch({ + control, + name: 'stream.type', + }); + + const clusterId = useWatch({ + control, + name: 'stream.details.cluster_ids[0]', + }); + + const { data: profile } = useProfile(); + const [month, day, year] = new Date().toLocaleDateString().split('/'); + + const setStreamType = (): StreamType => { + return streamTypeFormValue ?? streamType.AuditLogs; + }; + + const streamTypeValue = useMemo(setStreamType, [streamTypeFormValue]); + + const createSamplePath = (): string => { + let partition = ''; + + if (streamTypeValue === streamType.LKEAuditLogs) { + partition = `${clusterId ?? sampleClusterId}/`; + } + + return `/${streamTypeValue}/${logType[streamTypeValue]}/${profile?.uid}/${partition}${year}/${month}/${day}`; + }; + + const defaultPath = useMemo(createSamplePath, [ + profile, + streamTypeValue, + clusterId, + ]); + + const getPath = () => { + if (value === '/') { + return `/${fileName}`; + } + + const path = `${value || defaultPath}/${fileName}`; + + if (!path.startsWith('/')) { + return `/${path}`; + } + + return path; + }; return ( - Destination object name sample - {value} + + Sample Destination Object Name + + Default paths: + {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`} + {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`} + + } + /> + + {getPath()} ); }; const StyledValue = styled('span', { label: 'StyledValue' })(({ theme }) => ({ backgroundColor: theme.tokens.alias.Interaction.Background.Disabled, - height: 34, - width: theme.inputMaxWidth, + width: '100%', + maxWidth: 'max-content', + minHeight: 34, padding: theme.spacingFunction(8), + overflowWrap: 'anywhere', })); diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 0730b676705..ceac897e5ce 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -2,7 +2,7 @@ import { destinationType, streamStatus, streamType } from '@linode/api-v4'; import type { CreateDestinationPayload, - UpdateDestinationPayload, + DestinationDetails, } from '@linode/api-v4'; export type FormMode = 'create' | 'edit'; @@ -46,6 +46,9 @@ export const streamStatusOptions: LabelValueOption[] = [ }, ]; -export type DestinationFormType = - | CreateDestinationPayload - | UpdateDestinationPayload; +export interface DestinationForm + extends Omit { + details: DestinationDetails; +} + +export type DestinationFormType = DestinationForm; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index 745346f8a88..405d37b32e0 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -29,7 +29,7 @@ const controlPaths = { } as const; export const StreamFormClusters = () => { - const { control, setValue, formState } = + const { control, setValue, formState, trigger } = useFormContext(); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); @@ -109,14 +109,14 @@ export const StreamFormClusters = () => { render={({ field }) => ( { + onChange={async (_, checked) => { field.onChange(checked); if (checked) { setValue(controlPaths.clusterIds, idsWithLogsEnabled); } else { setValue(controlPaths.clusterIds, []); } + await trigger('stream.details'); }} sxFormLabel={{ ml: -1 }} text="Automatically include all existing and recently configured clusters." diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx index 4c4b5495448..8b3fa16c815 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx @@ -43,8 +43,10 @@ export const StreamFormClusterTableContent = ({ selectedIds.length === (idsWithLogsEnabled?.length ?? 0); const isIndeterminate = selectedIds.length > 0 && !isAllSelected; - const toggleAllClusters = () => + const toggleAllClusters = () => { field.onChange(isAllSelected ? [] : idsWithLogsEnabled); + field.onBlur(); + }; const toggleCluster = (toggledId: number) => { const updatedClusterIds = selectedIds.includes(toggledId) @@ -52,6 +54,7 @@ export const StreamFormClusterTableContent = ({ : [...selectedIds, toggledId]; field.onChange(updatedClusterIds); + field.onBlur(); }; return ( @@ -114,7 +117,6 @@ export const StreamFormClusterTableContent = ({ aria-label={`Toggle ${label} cluster`} checked={selectedIds.includes(id)} disabled={isAutoAddAllClustersEnabled || !logsEnabled} - onBlur={field.onBlur} onChange={() => toggleCluster(id)} /> diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx index b66fe12a369..13a7e819123 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx @@ -1,6 +1,7 @@ import { regionFactory } from '@linode/utilities'; import { screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { expect } from 'vitest'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; @@ -39,6 +40,7 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { expect(screen.getByText('test bucket')).toBeVisible(); // Log Path: expect(screen.getByText('test/path')).toBeVisible(); + expect(screen.queryByTestId('tooltip-info-icon')).not.toBeInTheDocument(); // Region: await waitFor(() => { expect(screen.getByText('US, Chicago, IL')).toBeVisible(); @@ -52,4 +54,30 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { '*****************' ); }); + + it('renders info icon next to path when it is empty', async () => { + server.use( + http.get('*/regions', () => { + const regions = regionFactory.buildList(1, { + id: 'us-ord', + label: 'Chicago, IL', + }); + return HttpResponse.json(makeResourcePage(regions)); + }) + ); + + const details = { + bucket_name: 'test bucket', + host: 'test host', + path: '', + region: 'us-ord', + } as LinodeObjectStorageDetails; + + renderWithTheme( + + ); + + // Log Path info icon: + expect(screen.getByTestId('tooltip-info-icon')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx index fa578cc4da3..5da8052d576 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -1,10 +1,18 @@ +import { streamType } from '@linode/api-v4'; import { useRegionsQuery } from '@linode/queries'; +import { Stack, TooltipIcon, Typography } from '@linode/ui'; import React from 'react'; +import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { LinodeObjectStorageDetails } from '@linode/api-v4'; +const sxTooltipIcon = { + marginLeft: '4px', + padding: '0px', +}; + export const DestinationLinodeObjectStorageDetailsSummary = ( props: LinodeObjectStorageDetails ) => { @@ -28,7 +36,21 @@ export const DestinationLinodeObjectStorageDetailsSummary = ( label="Secret Access Key" value="*****************" /> - + + {!path && ( + + Default paths: + {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`} + {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`} + + } + /> + )} + ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx index 5f9739fdb38..183e1ffb692 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -28,6 +28,7 @@ describe('StreamCreate', () => { type: destinationType.LinodeObjectStorage, details: { region: '', + path: '', }, }, }, diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx index f7728d87a35..688227d3a02 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx @@ -39,6 +39,7 @@ export const StreamCreate = () => { type: destinationType.LinodeObjectStorage, details: { region: '', + path: '', }, }, }, diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 6ede8ecf012..7e84853915e 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -57,6 +57,7 @@ export const StreamEdit = () => { type: destinationType.LinodeObjectStorage, details: { region: '', + path: '', }, }, }, @@ -76,15 +77,29 @@ export const StreamEdit = () => { : {}; const streamsDestinationIds = stream.destinations.map(({ id }) => id); + const destination = destinations?.data?.find( + ({ id }) => id === streamsDestinationIds[0] + ); + form.reset({ stream: { ...stream, details, destinations: streamsDestinationIds, }, - destination: destinations?.data?.find( - ({ id }) => id === streamsDestinationIds[0] - ), + destination: destination + ? { + ...destination, + ...('path' in destination.details + ? { + details: { + ...destination.details, + path: destination.details.path || '', + }, + } + : {}), + } + : undefined, }); } }, [stream, destinations, form]); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 00ae4fbe5ab..5e65f6341ff 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -1,4 +1,8 @@ -import { type StreamStatus, streamType } from '@linode/api-v4'; +import { + type CreateDestinationPayload, + type StreamStatus, + streamType, +} from '@linode/api-v4'; import { useCreateDestinationMutation, useCreateStreamMutation, @@ -12,7 +16,10 @@ import * as React from 'react'; import { useEffect } from 'react'; import { type SubmitHandler, useFormContext, useWatch } from 'react-hook-form'; -import { getStreamPayloadDetails } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationPayloadDetails, + getStreamPayloadDetails, +} from 'src/features/Delivery/deliveryUtils'; import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar'; import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination'; import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery'; @@ -20,6 +27,7 @@ import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Del import { StreamFormClusters } from './Clusters/StreamFormClusters'; import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; +import type { UpdateDestinationPayload } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; @@ -79,7 +87,13 @@ export const StreamForm = (props: StreamFormProps) => { let destinationId = destinations?.[0]; if (!destinationId) { try { - const { id } = await createDestination(destination); + const destinationPayload: + | CreateDestinationPayload + | UpdateDestinationPayload = { + ...destination, + details: getDestinationPayloadDetails(destination.details), + }; + const { id } = await createDestination(destinationPayload); destinationId = id; enqueueSnackbar( `Destination ${destination.label} created successfully`, diff --git a/packages/manager/src/features/Delivery/deliveryUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts index aa255f8ecc2..3b2df03578a 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.test.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts @@ -1,9 +1,17 @@ import { destinationType } from '@linode/api-v4'; import { expect } from 'vitest'; -import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils'; +import { + getDestinationPayloadDetails, + getDestinationTypeOption, +} from 'src/features/Delivery/deliveryUtils'; import { destinationTypeOptions } from 'src/features/Delivery/Shared/types'; +import type { + LinodeObjectStorageDetails, + LinodeObjectStorageDetailsPayload, +} from '@linode/api-v4'; + describe('delivery utils functions', () => { describe('getDestinationTypeOption ', () => { it('should return option object matching provided value', () => { @@ -18,4 +26,32 @@ describe('delivery utils functions', () => { expect(result).toEqual(undefined); }); }); + + describe('getDestinationPayloadDetails ', () => { + const testDetails: LinodeObjectStorageDetails = { + path: 'testpath', + access_key_id: 'keyId', + access_key_secret: 'secret', + bucket_name: 'name', + host: 'host', + region: 'us-ord', + }; + + it('should return payload details with path', () => { + const result = getDestinationPayloadDetails( + testDetails + ) as LinodeObjectStorageDetailsPayload; + + expect(result.path).toEqual(testDetails.path); + }); + + it('should return details without path property', () => { + const result = getDestinationPayloadDetails({ + ...testDetails, + path: '', + }) as LinodeObjectStorageDetailsPayload; + + expect(result.path).toEqual(undefined); + }); + }); }); diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts index 918cb0a2c96..7f7e3385cec 100644 --- a/packages/manager/src/features/Delivery/deliveryUtils.ts +++ b/packages/manager/src/features/Delivery/deliveryUtils.ts @@ -11,7 +11,12 @@ import { streamTypeOptions, } from 'src/features/Delivery/Shared/types'; -import type { StreamDetails, StreamType } from '@linode/api-v4'; +import type { + DestinationDetails, + DestinationDetailsPayload, + StreamDetails, + StreamType, +} from '@linode/api-v4'; import type { FormMode, LabelValueOption, @@ -46,6 +51,16 @@ export const getStreamPayloadDetails = ( return payloadDetails; }; +export const getDestinationPayloadDetails = ( + details: DestinationDetails +): DestinationDetailsPayload => { + if ('path' in details && details.path === '') { + return omitProps(details, ['path']); + } + + return details; +}; + export const getStreamDescription = (stream: Stream) => { return `${getStreamTypeOption(stream.type)?.label}`; }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts index c7ae5c8ad5b..2d111289e32 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts @@ -1,3 +1,4 @@ +import { destinationType } from '@linode/api-v4'; import { DateTime } from 'luxon'; import { http } from 'msw'; @@ -11,7 +12,12 @@ import { makeResponse, } from 'src/mocks/utilities/response'; -import type { Destination, Stream } from '@linode/api-v4'; +import type { + CreateDestinationPayload, + Destination, + LinodeObjectStorageDetails, + Stream, +} from '@linode/api-v4'; import type { StrictResponse } from 'msw'; import type { MockState } from 'src/mocks/types'; import type { @@ -214,11 +220,17 @@ export const createDestinations = (mockState: MockState) => [ async ({ request, }): Promise> => { - const payload = await request.clone().json(); + const payload: CreateDestinationPayload = await request.clone().json(); + const details = payload.details; const destination = destinationFactory.build({ - label: payload['label'], - type: payload['type'], - details: payload['details'], + label: payload.label, + type: payload.type, + details: { + ...details, + ...(payload.type === destinationType.LinodeObjectStorage + ? { path: (details as LinodeObjectStorageDetails).path ?? null } + : {}), + }, created: DateTime.now().toISO(), updated: DateTime.now().toISO(), }); diff --git a/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md b/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md new file mode 100644 index 00000000000..bb3e3056264 --- /dev/null +++ b/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Update validation schema for Destination - Details - Path ([#12851](https://github.com/linode/manager/pull/12851)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index 05dc69d2cd6..f027add7128 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -57,7 +57,7 @@ const customHTTPsDetailsSchema = object({ endpoint_url: string().max(maxLength, maxLengthMessage).required(), }); -const linodeObjectStorageDetailsSchema = object({ +const linodeObjectStorageDetailsBaseSchema = object({ host: string().max(maxLength, maxLengthMessage).required('Host is required.'), bucket_name: string() .max(maxLength, maxLengthMessage) @@ -65,7 +65,7 @@ const linodeObjectStorageDetailsSchema = object({ region: string() .max(maxLength, maxLengthMessage) .required('Region is required.'), - path: string().max(maxLength, maxLengthMessage).required('Path is required.'), + path: string().max(maxLength, maxLengthMessage).defined(), access_key_id: string() .max(maxLength, maxLengthMessage) .required('Access Key ID is required.'), @@ -74,20 +74,41 @@ const linodeObjectStorageDetailsSchema = object({ .required('Access Key Secret is required.'), }); -export const destinationSchema = object().shape({ +const linodeObjectStorageDetailsPayloadSchema = + linodeObjectStorageDetailsBaseSchema.shape({ + path: string().max(maxLength, maxLengthMessage).optional(), + }); + +const destinationSchemaBase = object().shape({ label: string() .max(maxLength, maxLengthMessage) .required('Destination name is required.'), type: string().oneOf(['linode_object_storage', 'custom_https']).required(), details: mixed< | InferType - | InferType + | InferType + >() + .defined() + .required() + .when('type', { + is: 'linode_object_storage', + then: () => linodeObjectStorageDetailsBaseSchema, + otherwise: () => customHTTPsDetailsSchema, + }), +}); + +export const destinationFormSchema = destinationSchemaBase; + +export const destinationSchema = destinationSchemaBase.shape({ + details: mixed< + | InferType + | InferType >() .defined() .required() .when('type', { is: 'linode_object_storage', - then: () => linodeObjectStorageDetailsSchema, + then: () => linodeObjectStorageDetailsPayloadSchema, otherwise: () => customHTTPsDetailsSchema, }), }); @@ -158,7 +179,7 @@ export const streamAndDestinationFormSchema = object({ }) .required(), }), - destination: destinationSchema.defined().when('stream.destinations', { + destination: destinationFormSchema.defined().when('stream.destinations', { is: (value: never[]) => !value?.length, then: (schema) => schema, otherwise: (schema) => From c3488114fdef9cf83c384b9f8e6b6f0b3f4ef165 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:14:57 +0200 Subject: [PATCH 15/54] tech-story: [UIE-9205] IAM - Improve type safety in `usePermissions` (#12893) * strenghten type narrowing in usePermissions * changeset and cleanup --- .../pr-12893-tech-stories-1758193861545.md | 5 + .../src/features/IAM/hooks/usePermissions.ts | 124 +++++++++++++++++- .../AdditionalOptions/MaintenancePolicy.tsx | 5 +- 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md diff --git a/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md new file mode 100644 index 00000000000..cfdfe96c005 --- /dev/null +++ b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893)) diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts index 117bbf322e9..486263c3adc 100644 --- a/packages/manager/src/features/IAM/hooks/usePermissions.ts +++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts @@ -21,9 +21,27 @@ import type { AccountEntity, APIError, EntityType, + FirewallAdmin, + FirewallContributor, + FirewallViewer, GrantType, + ImageAdmin, + ImageContributor, + ImageViewer, + LinodeAdmin, + LinodeContributor, + LinodeViewer, + NodeBalancerAdmin, + NodeBalancerContributor, + NodeBalancerViewer, PermissionType, Profile, + VolumeAdmin, + VolumeContributor, + VolumeViewer, + VPCAdmin, + VPCContributor, + VPCViewer, } from '@linode/api-v4'; import type { UseQueryResult } from '@linode/queries'; @@ -36,16 +54,110 @@ const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [ 'create_nodebalancer', ]; +type EntityPermission = + | FirewallAdmin + | FirewallContributor + | FirewallViewer + | ImageAdmin + | ImageContributor + | ImageViewer + | LinodeAdmin + | LinodeContributor + | LinodeViewer + | NodeBalancerAdmin + | NodeBalancerContributor + | NodeBalancerViewer + | VolumeAdmin + | VolumeContributor + | VolumeViewer + | VPCAdmin + | VPCContributor + | VPCViewer; + +declare const PermissionByAccessKnown: { + account: Exclude; + database: never; // TODO: add database permissions + domain: never; // TODO: add domain permissions + firewall: FirewallAdmin | FirewallContributor | FirewallViewer; + image: ImageAdmin | ImageContributor | ImageViewer; + linode: LinodeAdmin | LinodeContributor | LinodeViewer; + lkecluster: never; // TODO: add lkecluster permissions + longview: never; // TODO: add longview permissions + nodebalancer: + | NodeBalancerAdmin + | NodeBalancerContributor + | NodeBalancerViewer; + placement_group: never; // TODO: add placement_group permissions + stackscript: never; // TODO: add stackscript permissions + volume: VolumeAdmin | VolumeContributor | VolumeViewer; + vpc: VPCAdmin | VPCContributor | VPCViewer; +}; + +type AssertNever = T; + +/** + * Compile‑time assertions only. + * + * Ensure: + * - PermissionByAccessKnown has only allowed AccessTypes. + * - All AccessTypes are handled by PermissionByAccessKnown. + */ +export type NoExtraKeys = AssertNever< + Exclude +>; +export type AllHandled = AssertNever< + Exclude +>; + +type KnownAccessKeys = keyof typeof PermissionByAccessKnown & AccessType; + +type AllowedPermissionsFor = A extends KnownAccessKeys + ? (typeof PermissionByAccessKnown)[A] + : // exhaustiveness check, no fallback + never; + export type PermissionsResult = { data: Record; } & Omit, 'data'>; -export const usePermissions = ( - accessType: AccessType, +/** + * Overload 1: account-level + */ +export function usePermissions< + A extends 'account', + T extends readonly AllowedPermissionsFor[], +>( + accessType: A, + permissionsToCheck: T, + entityId?: undefined, + enabled?: boolean +): PermissionsResult; + +/** + * Overload 2: entity-level + */ +export function usePermissions< + A extends Exclude, + T extends readonly AllowedPermissionsFor[], +>( + accessType: A, + permissionsToCheck: T, + entityId: number | string | undefined, + enabled?: boolean +): PermissionsResult; + +/** + * Implementation + */ +export function usePermissions< + A extends AccessType, + T extends readonly PermissionType[], +>( + accessType: A, permissionsToCheck: T, entityId?: number | string, enabled: boolean = true -): PermissionsResult => { +): PermissionsResult { const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); const { data: profile } = useProfile(); @@ -70,7 +182,9 @@ export const usePermissions = ( BETA_ACCESS_TYPE_SCOPE.includes(accessType) && LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some( (blacklistedPermission) => - permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet + permissionsToCheck.includes( + blacklistedPermission as AllowedPermissionsFor + ) // some of the account admin in the blacklist have not been added yet ) === false; const useLAPermissions = isIAMEnabled && !isIAMBeta; const shouldUsePermissionMap = useBetaPermissions || useLAPermissions; @@ -113,7 +227,7 @@ export const usePermissions = ( ...restAccountPermissions, ...restEntityPermissions, } as const; -}; +} export type EntityBase = Pick; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx index 4bdd304b90d..61357db3320 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx @@ -31,8 +31,7 @@ export const MaintenancePolicy = () => { const { data: region } = useRegionQuery(selectedRegion); const { data: type } = useTypeQuery(selectedType, Boolean(selectedType)); - // Check if user has permission to update linodes (needed for maintenance policy) - const { data: permissions } = usePermissions('linode', ['update_linode']); + const { data: permissions } = usePermissions('account', ['create_linode']); const isGPUPlan = type && type.class === 'gpu'; @@ -42,7 +41,7 @@ export const MaintenancePolicy = () => { // Determine if disabled due to missing prerequisites vs permission issues const isDisabledDueToPrerequisites = !selectedRegion || !regionSupportsMaintenancePolicy; - const isDisabledDueToPermissions = !permissions?.update_linode; + const isDisabledDueToPermissions = !permissions?.create_linode; const isDisabled = isDisabledDueToPrerequisites || isDisabledDueToPermissions; return ( From 77d3b024519652dc7fb1f49ef10aada791751224 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:54:47 +0200 Subject: [PATCH 16/54] feat: [UIE-9126] - IAM RBAC: VPC Details permissions check (#12810) * IAM RBAC: VPC permission check for the Details page * unit tests * update sub-entity permissions * add update_vpc check to the linode action menu * Added changeset: IAM RBAC: Implements IAM RBAC permissions for VPC Details page * fix unit tests * add a delay to the action menu for VPC Landing page * add enabled option to the useQueryWithPermissions hook, move permissions to the drawer * add useArrayWithPermissions hook, fix e2e tests, review fix * unit test fix * update_vpc for unassign linode drawer, refactor * remove useArrayWithPermissions, refactoring * tooltipText fix * add TODO * review fix --- ...r-12810-upcoming-features-1757334461818.md | 5 ++ .../e2e/core/vpc/vpc-landing-page.spec.ts | 78 +++++++++++------ .../VPCs/VPCDetail/SubnetActionMenu.test.tsx | 85 +++++++++++++++++++ .../VPCs/VPCDetail/SubnetActionMenu.tsx | 25 +++++- .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 45 +++++----- .../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 25 +++--- .../VPCs/VPCDetail/SubnetDeleteDialog.tsx | 15 +++- .../VPCs/VPCDetail/SubnetEditDrawer.test.tsx | 45 ++++++++++ .../VPCs/VPCDetail/SubnetEditDrawer.tsx | 27 ++---- .../VPCDetail/SubnetLinodeActionMenu.test.tsx | 71 +++++++++++++++- .../VPCs/VPCDetail/SubnetLinodeActionMenu.tsx | 27 ++++++ .../VPCs/VPCDetail/SubnetLinodeRow.test.tsx | 8 ++ .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 3 + .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 36 ++++---- .../VPCs/VPCDetail/VPCDetail.styles.ts | 2 +- .../VPCs/VPCDetail/VPCDetail.test.tsx | 42 +++++++++ .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 27 +++++- .../VPCs/VPCDetail/VPCSubnetsTable.test.tsx | 54 +++++++++++- .../VPCs/VPCDetail/VPCSubnetsTable.tsx | 14 +++ .../features/VPCs/VPCLanding/VPCRow.test.tsx | 30 +++++-- .../src/features/VPCs/VPCLanding/VPCRow.tsx | 26 +++--- 21 files changed, 565 insertions(+), 125 deletions(-) create mode 100644 packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md diff --git a/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md b/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md new file mode 100644 index 00000000000..d3d8e218f4c --- /dev/null +++ b/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +IAM RBAC: Implements IAM RBAC permissions for VPC Details page ([#12810](https://github.com/linode/manager/pull/12810)) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 50ad31d690a..62f9e387b66 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -28,25 +28,28 @@ describe('VPC landing page', () => { cy.wait('@getVPCs'); // Confirm each VPC is listed with expected data. - mockVPCs.forEach((mockVPC) => { - const regionLabel = getRegionById(mockVPC.region).label; - cy.findByText(mockVPC.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText(regionLabel).should('be.visible'); - - ui.button - .findByTitle('Edit') - .should('be.visible') - .should('be.enabled'); - - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled'); - }); - }); + const regionLabel = getRegionById(mockVPCs[0].region).label; + cy.findByText(mockVPCs[0].label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(regionLabel).should('be.visible'); + + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled'); + + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled'); + }); }); /* @@ -112,7 +115,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button.findByTitle('Edit').should('be.visible').click(); + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); }); // Confirm correct information is shown and update label and description. @@ -149,7 +156,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button.findByTitle('Edit').should('be.visible').click(); + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockUpdatedVPC.label}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); }); ui.drawer @@ -179,7 +190,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`) + .should('be.visible') + .click(); + ui.actionMenuItem .findByTitle('Delete') .should('be.visible') .should('be.enabled') @@ -192,7 +207,6 @@ describe('VPC landing page', () => { .within(() => { cy.findByLabelText('VPC Label').should('be.visible').click(); cy.focused().type(mockVPCs[0].label); - ui.button .findByTitle('Delete') .should('be.visible') @@ -211,7 +225,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`) + .should('be.visible') + .click(); + ui.actionMenuItem .findByTitle('Delete') .should('be.visible') .should('be.enabled') @@ -269,7 +287,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`) + .should('be.visible') + .click(); + ui.actionMenuItem .findByTitle('Delete') .should('be.visible') .should('be.enabled') @@ -312,7 +334,11 @@ describe('VPC landing page', () => { .should('be.visible') .closest('tr') .within(() => { - ui.button + ui.actionMenu + .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`) + .should('be.visible') + .click(); + ui.actionMenuItem .findByTitle('Delete') .should('be.visible') .should('be.enabled') diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx index 06ec7e74c87..945889997cf 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx @@ -7,6 +7,27 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { SubnetActionMenu } from './SubnetActionMenu'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + update_linode: true, + delete_linode: true, + update_vpc: true, + }, + })), + useQueryWithPermissions: vi.fn().mockReturnValue({ + data: [ + { id: 1, label: 'linode-1' }, + { id: 2, label: 'linode-2' }, + ], + isLoading: false, + isError: false, + }), +})); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, + useQueryWithPermissions: queryMocks.useQueryWithPermissions, +})); afterEach(() => { vi.clearAllMocks(); }); @@ -105,4 +126,68 @@ describe('SubnetActionMenu', () => { await userEvent.click(assignButton); expect(props.handleAssignLinodes).toHaveBeenCalled(); }); + + it('should disable the Assign Linodes button if user does not have update_linode permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: false, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' }); + expect(assignButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable the Assign Linodes button if user has update_linode and update_vpc permissions', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: true, + delete_linode: false, + update_vpc: true, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' }); + expect(assignButton).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('should disable the Edit button if user does not have update_vpc permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: false, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const editButton = view.getByRole('menuitem', { name: 'Edit' }); + expect(editButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should enable the Edit button if user has update_vpc permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + update_linode: false, + delete_linode: false, + update_vpc: true, + }, + }); + const view = renderWithTheme(); + const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`); + await userEvent.click(actionMenu); + + const editButton = view.getByRole('menuitem', { name: 'Edit' }); + expect(editButton).not.toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx index 0fad2132dc0..347a70b52d2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import type { Subnet } from '@linode/api-v4'; @@ -29,31 +30,49 @@ export const SubnetActionMenu = (props: Props) => { numLinodes, numNodebalancers, subnet, + vpcId, } = props; const flags = useIsNodebalancerVPCEnabled(); + const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); + const canUpdateVPC = permissions?.update_vpc; + const actions: Action[] = [ { onClick: () => { handleAssignLinodes(subnet); }, title: 'Assign Linodes', + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to assign Linode to this subnet.' + : undefined, }, { onClick: () => { handleUnassignLinodes(subnet); }, title: 'Unassign Linodes', + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to unassign Linode from this subnet.' + : undefined, }, { onClick: () => { handleEdit(subnet); }, title: 'Edit', + // TODO: change to 'update_vpc_subnet' once it's available + disabled: !canUpdateVPC, + tooltip: !canUpdateVPC + ? 'You do not have permission to edit this subnet.' + : undefined, }, { - disabled: numLinodes !== 0 || numNodebalancers !== 0, + // TODO: change to 'delete_vpc_subnet' once it's available + disabled: numLinodes !== 0 || numNodebalancers !== 0 || !canUpdateVPC, onClick: () => { handleDelete(subnet); }, @@ -61,7 +80,9 @@ export const SubnetActionMenu = (props: Props) => { tooltip: numLinodes > 0 || numNodebalancers > 0 ? `${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'} assigned to a subnet must be unassigned before the subnet can be deleted.` - : '', + : !canUpdateVPC + ? 'You do not have permission to delete this subnet.' + : undefined, }, ]; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 563a51cccc7..b28deda1f32 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -4,8 +4,6 @@ import { getAllLinodeConfigs, useAllLinodesQuery, useFirewallSettingsQuery, - useGrants, - useProfile, } from '@linode/queries'; import { LinodeSelect } from '@linode/shared'; import { @@ -31,6 +29,10 @@ import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Link } from 'src/components/Link'; import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable'; import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect'; +import { + usePermissions, + useQueryWithPermissions, +} from 'src/features/IAM/hooks/usePermissions'; import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/LinodeCreate/Networking/utilities'; import { REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS, @@ -146,16 +148,19 @@ export const SubnetAssignLinodesDrawer = ( const [allowPublicIPv6Access, setAllowPublicIPv6Access] = React.useState(false); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId); + const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId); + // TODO: change update_linode to create_linode_config_profile_interface once it's available + // TODO: change delete_linode to delete_linode_config_profile_interface once it's available + // TODO: refactor useQueryWithPermissions once API filter is available + const { data: filteredLinodes } = useQueryWithPermissions( + useAllLinodesQuery(), + 'linode', + ['update_linode', 'delete_linode'], + open + ); - // @TODO VPC: this logic for vpc grants/perms appears a lot - commenting a todo here in case we want to move this logic to a parent component - // there isn't a 'view VPC/Subnet' grant that does anything, so all VPCs get returned even for restricted users - // with permissions set to 'None'. Therefore, we're treating those as read_only as well - const userCannotAssignLinodes = - Boolean(profile?.restricted) && - (vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0); + const userCanAssignLinodes = + permissions?.update_vpc && filteredLinodes?.length > 0; const downloadCSV = async () => { await getCSVData(); @@ -586,7 +591,7 @@ export const SubnetAssignLinodesDrawer = ( subnet?.ipv4 ?? subnet?.ipv6 ?? 'Unknown' })`} > - {userCannotAssignLinodes && ( + {!userCanAssignLinodes && ( {REGIONAL_LINODE_MESSAGE} { setFieldValue('selectedLinode', selected); @@ -633,7 +638,7 @@ export const SubnetAssignLinodesDrawer = ( /> } data-testid="vpc-ipv4-checkbox" - disabled={userCannotAssignLinodes} + disabled={!userCanAssignLinodes} label={Auto-assign VPC IPv4 address} sx={{ marginRight: 0 }} /> @@ -654,7 +659,7 @@ export const SubnetAssignLinodesDrawer = ( {!autoAssignVPCIPv4Address && ( } data-testid="vpc-ipv6-checkbox" - disabled={userCannotAssignLinodes} + disabled={!userCanAssignLinodes} label={ Auto-assign VPC IPv6 address } @@ -711,7 +716,7 @@ export const SubnetAssignLinodesDrawer = ( {!autoAssignVPCIPv6Address && ( { setFieldValue('selectedConfig', value); @@ -760,7 +765,7 @@ export const SubnetAssignLinodesDrawer = ( } showIPv6Content={showIPv6Content} sx={{ margin: `${theme.spacingFunction(16)} 0` }} - userCannotAssignLinodes={userCannotAssignLinodes} + userCannotAssignLinodes={!userCanAssignLinodes} /> {/* Display the 'Assign additional [IPv4] ranges' section if the Configuration Profile section has been populated, or @@ -801,7 +806,7 @@ export const SubnetAssignLinodesDrawer = ( diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx index 3da8b5343c7..32f2f31e0cd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx @@ -25,11 +25,11 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ })); describe('VPC Table Row', () => { - it('should render a VPC row', () => { + it('should render a VPC row', async () => { const vpc = vpcFactory.build({ id: 24, subnets: [subnetFactory.build()] }); resizeScreenSize(1600); - const { getByText } = renderWithTheme( + const { getByText, getByLabelText } = renderWithTheme( wrapWithTableBody( { ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); // Check to see if the row rendered some data expect(getByText(vpc.label)).toBeVisible(); expect(getByText(vpc.id)).toBeVisible(); @@ -52,7 +54,7 @@ describe('VPC Table Row', () => { it('should have a delete button that calls the provided callback when clicked', async () => { const vpc = vpcFactory.build(); const handleDelete = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const deleteBtn = getByTestId('Delete'); await userEvent.click(deleteBtn); expect(handleDelete).toHaveBeenCalled(); @@ -70,7 +75,7 @@ describe('VPC Table Row', () => { it('should have an edit button that calls the provided callback when clicked', async () => { const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); await userEvent.click(editButton); expect(handleEdit).toHaveBeenCalled(); @@ -94,7 +102,7 @@ describe('VPC Table Row', () => { }); const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); - expect(editButton).toBeDisabled(); + expect(editButton).toHaveAttribute('aria-disabled', 'true'); const deleteButton = getByTestId('Delete'); - expect(deleteButton).toBeDisabled(); + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); }); it('should enable "Edit" and "Delete" button if user has "update_vpc" and "delete_vpc" permissions', async () => { queryMocks.userPermissions.mockReturnValue({ @@ -118,7 +129,7 @@ describe('VPC Table Row', () => { }); const vpc = vpcFactory.build(); const handleEdit = vi.fn(); - const { getByTestId } = renderWithTheme( + const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { /> ) ); + const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`); + await userEvent.click(actionMenu); + const editButton = getByTestId('Edit'); expect(editButton).toBeEnabled(); const deleteButton = getByTestId('Delete'); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index ad7ac6e70ca..eae57e2347a 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries'; import { Hidden } from '@linode/ui'; import * as React from 'react'; -import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { type Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -15,7 +15,6 @@ import { } from '../utils'; import type { VPC } from '@linode/api-v4/lib/vpcs/types'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { handleDeleteVPC: () => void; @@ -33,15 +32,18 @@ export const VPCRow = ({ const { id, label, subnets } = vpc; const { data: regions } = useRegionsQuery(); + const [isOpen, setIsOpen] = React.useState(false); + const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; const numResources = isNodebalancerVPCEnabled ? getUniqueResourcesFromSubnets(vpc.subnets) : getUniqueLinodesFromSubnets(vpc.subnets); - const { data: permissions } = usePermissions( + const { data: permissions, isLoading } = usePermissions( 'vpc', ['update_vpc', 'delete_vpc'], - vpc.id + vpc.id, + isOpen ); const actions: Action[] = [ @@ -87,16 +89,12 @@ export const VPCRow = ({ {numResources} - {actions.map((action) => ( - - ))} + setIsOpen(true)} + /> ); From 755ebeb03fc2812f451010b8ede66cadf758a48d Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:55:10 +0200 Subject: [PATCH 17/54] change: [UIE-9202] - IAM RBAC: Improve Change Role Autocomplete UX in Change Role Drawer (#12901) * change: [UIE-9202] - IAM RBAC: filter for Change Role Drawer * Added changeset: Improve role selection UX in change role drawer --- .../pr-12901-changed-1758617282890.md | 5 ++++ .../ChangeRoleDrawer.test.tsx | 28 ++++++++++++++++++ .../AssignedRolesTable/ChangeRoleDrawer.tsx | 29 +++++++++++++++---- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-12901-changed-1758617282890.md diff --git a/packages/manager/.changeset/pr-12901-changed-1758617282890.md b/packages/manager/.changeset/pr-12901-changed-1758617282890.md new file mode 100644 index 00000000000..793be65208e --- /dev/null +++ b/packages/manager/.changeset/pr-12901-changed-1758617282890.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve role selection UX in change role drawer ([#12901](https://github.com/linode/manager/pull/12901)) diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx index f78689acdd7..548c4a0d63a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx @@ -196,4 +196,32 @@ describe('ChangeRoleDrawer', () => { mockAccountAccessRole.name ); }); + + it('should not list roles that the user already has', async () => { + queryMocks.useUserRoles.mockReturnValue({ + data: { + account_access: ['account_linode_admin', 'account_viewer'], + entity_access: [], + }, + }); + + queryMocks.useAccountRoles.mockReturnValue({ + data: accountRolesFactory.build(), + }); + + renderWithTheme(); + + const autocomplete = screen.getByRole('combobox'); + + await userEvent.click(autocomplete); + + // expect select not to have the current role as one of the options + const options = screen.getAllByRole('option'); + expect(options.map((option) => option.textContent)).not.toContain( + 'account_linode_admin' + ); + expect(options.map((option) => option.textContent)).not.toContain( + 'account_viewer' + ); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx index acf271a1fb0..41bd9b69874 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx @@ -24,6 +24,8 @@ import { getAllRoles, getErrorMessage, getRoleByName, + isAccountRole, + isEntityRole, } from '../utilities'; import type { DrawerModes, EntitiesOption, ExtendedRoleView } from '../types'; @@ -63,14 +65,29 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => { if (!accountRoles) { return []; } - - return getAllRoles(accountRoles).filter( - (el) => + return getAllRoles(accountRoles).filter((el) => { + const matchesRoleContext = el.entity_type === role?.entity_type && el.access === role?.access && - el.value !== role?.name - ); - }, [accountRoles, role]); + el.value !== role?.name; + // Exclude account roles already assigned to the user + if (isAccountRole(el)) { + return ( + !assignedRoles?.account_access.includes(el.value) && + matchesRoleContext + ); + } + // Exclude entity roles already assigned to the user + if (isEntityRole(el)) { + return ( + !assignedRoles?.entity_access.some((entity) => + entity.roles.includes(el.value) + ) && matchesRoleContext + ); + } + return true; + }); + }, [accountRoles, assignedRoles, role]); const { control, From 23a8dc09da7a66a470612cf9619c33775712ba70 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:12:56 +0200 Subject: [PATCH 18/54] feat: [UIE-9242] - Add IAM delegation (parent/child) feature flag (#12906) * addd feature flag and dev tool support * Added changeset: IAM delegation feature flag --- packages/manager/.changeset/pr-12906-added-1758634850982.md | 5 +++++ packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + 3 files changed, 7 insertions(+) create mode 100644 packages/manager/.changeset/pr-12906-added-1758634850982.md diff --git a/packages/manager/.changeset/pr-12906-added-1758634850982.md b/packages/manager/.changeset/pr-12906-added-1758634850982.md new file mode 100644 index 00000000000..27db11bdf09 --- /dev/null +++ b/packages/manager/.changeset/pr-12906-added-1758634850982.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM delegation feature flag ([#12906](https://github.com/linode/manager/pull/12906)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 2dea41f2d3c..f6ad923d33e 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -53,6 +53,7 @@ const options: { flag: keyof Flags; label: string }[] = [ }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, { flag: 'iam', label: 'Identity and Access Beta' }, + { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' }, { flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' }, { flag: 'linodeCloneFirewall', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index bb9f6c93cb5..ecbabf65481 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -174,6 +174,7 @@ export interface Flags { gecko2: GeckoFeatureFlag; gpuv2: GpuV2; iam: BetaFeatureFlag; + iamDelegation: BaseFeatureFlag; iamRbacPrimaryNavChanges: boolean; ipv6Sharing: boolean; kubernetesBlackwellPlans: boolean; From 675174304f13ee9094b34c2227bee100690b485d Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Wed, 24 Sep 2025 10:38:19 +0200 Subject: [PATCH 19/54] change: [STORIF-84] Updated "Getting Started" link on the Volume Details page. (#12904) * change: [STORIF-84] Updated "Getting Started" link on the Volume Details page. * Added changeset: Getting started link on the volume details page --- .../manager/.changeset/pr-12904-changed-1758623601366.md | 5 +++++ .../features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12904-changed-1758623601366.md diff --git a/packages/manager/.changeset/pr-12904-changed-1758623601366.md b/packages/manager/.changeset/pr-12904-changed-1758623601366.md new file mode 100644 index 00000000000..69f7ebf8a8a --- /dev/null +++ b/packages/manager/.changeset/pr-12904-changed-1758623601366.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Getting started link on the volume details page ([#12904](https://github.com/linode/manager/pull/12904)) diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx index a717462fd9f..8420243d2cb 100644 --- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx +++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx @@ -28,7 +28,7 @@ export const VolumeDetailsHeader = ({ volume }: Props) => { pathname: `/volumes/${volume.label}`, }} docsLabel="Getting Started" - docsLink="https://techdocs.akamai.com/cloud-computing/docs/faqs-for-compute-instances" + docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage" entity="Volume" spacingBottom={16} /> From d95ad8a7df9f735c4a58cb4ef3b9212589f372ca Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:06:43 +0200 Subject: [PATCH 20/54] feat: [UIE-9203] - IAM Parent/Child - Implement new delegation types, endpoints & hooks (#12895) * types and queries * hooks + cleanup * moar cleanup * changesets * feedback @bnussman-akamai * cleanup * moar cleanup * feedback @aaleksee-akamai * cleanup from feedback --- .../pr-12895-added-1758540546949.md | 5 + packages/api-v4/src/iam/delegation.ts | 107 ++++++++++ packages/api-v4/src/iam/delegation.types.ts | 34 +++ packages/api-v4/src/iam/index.ts | 4 +- .../pr-12895-added-1758540593030.md | 5 + packages/queries/src/iam/delegation.ts | 200 ++++++++++++++++++ 6 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 packages/api-v4/.changeset/pr-12895-added-1758540546949.md create mode 100644 packages/api-v4/src/iam/delegation.ts create mode 100644 packages/api-v4/src/iam/delegation.types.ts create mode 100644 packages/queries/.changeset/pr-12895-added-1758540593030.md create mode 100644 packages/queries/src/iam/delegation.ts diff --git a/packages/api-v4/.changeset/pr-12895-added-1758540546949.md b/packages/api-v4/.changeset/pr-12895-added-1758540546949.md new file mode 100644 index 00000000000..6da438424cc --- /dev/null +++ b/packages/api-v4/.changeset/pr-12895-added-1758540546949.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +IAM Parent/Child - Implement new delegation types and endpoints definitions ([#12895](https://github.com/linode/manager/pull/12895)) diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts new file mode 100644 index 00000000000..2632fe85f19 --- /dev/null +++ b/packages/api-v4/src/iam/delegation.ts @@ -0,0 +1,107 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setData, setMethod, setParams, setURL } from '../request'; + +import type { Account } from '../account'; +import type { Token } from '../profile'; +import type { ResourcePage as Page } from '../types'; +import type { + ChildAccount, + ChildAccountWithDelegates, + GetChildAccountDelegatesParams, + GetChildAccountsIamParams, + GetDelegatedChildAccountsForUserParams, + GetMyDelegatedChildAccountsParams, + UpdateChildAccountDelegatesParams, +} from './delegation.types'; +import type { IamUserRoles } from './types'; + +export const getChildAccountsIam = ({ + params, + users, +}: GetChildAccountsIamParams) => + users + ? Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts?users=true`), + setMethod('GET'), + setParams({ ...params }), + ) + : Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts`), + setMethod('GET'), + setParams({ ...params }), + ); + +export const getDelegatedChildAccountsForUser = ({ + username, + params, +}: GetDelegatedChildAccountsForUserParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/users/${encodeURIComponent(username)}/child-accounts`, + ), + setMethod('GET'), + setParams(params), + ); + +export const getChildAccountDelegates = ({ + euuid, + params, +}: GetChildAccountDelegatesParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, + ), + setMethod('GET'), + setParams(params), + ); + +export const updateChildAccountDelegates = ({ + euuid, + data, +}: UpdateChildAccountDelegatesParams) => + Request>( + setURL( + `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, + ), + setMethod('PUT'), + setData(data), + ); + +export const getMyDelegatedChildAccounts = ({ + params, +}: GetMyDelegatedChildAccountsParams) => + Request>( + setURL(`${BETA_API_ROOT}/iam/delegation/profile/child-accounts`), + setMethod('GET'), + setParams(params), + ); + +export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) => + Request( + setURL( + `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}`, + ), + setMethod('GET'), + ); + +export const generateChildAccountToken = ({ euuid }: { euuid: string }) => + Request( + setURL( + `${BETA_API_ROOT}/iam/delegation/child-accounts/child-accounts/${encodeURIComponent(euuid)}/token`, + ), + setMethod('POST'), + setData(euuid), + ); + +export const getDefaultDelegationAccess = () => + Request( + setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`), + setMethod('GET'), + ); + +export const updateDefaultDelegationAccess = (data: IamUserRoles) => + Request( + setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`), + setMethod('PUT'), + setData(data), + ); diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts new file mode 100644 index 00000000000..2eafc480b7a --- /dev/null +++ b/packages/api-v4/src/iam/delegation.types.ts @@ -0,0 +1,34 @@ +import type { Params } from 'src/types'; + +export interface ChildAccount { + company: string; + euuid: string; +} + +export interface GetChildAccountsIamParams { + params?: Params; + users?: boolean; +} + +export interface ChildAccountWithDelegates extends ChildAccount { + users: string[]; +} + +export interface GetMyDelegatedChildAccountsParams { + params?: Params; +} + +export interface GetDelegatedChildAccountsForUserParams { + params?: Params; + username: string; +} + +export interface GetChildAccountDelegatesParams { + euuid: string; + params?: Params; +} + +export interface UpdateChildAccountDelegatesParams { + data: string[]; + euuid: string; +} diff --git a/packages/api-v4/src/iam/index.ts b/packages/api-v4/src/iam/index.ts index 8442040a86a..9bc43a8eb93 100644 --- a/packages/api-v4/src/iam/index.ts +++ b/packages/api-v4/src/iam/index.ts @@ -1,3 +1,5 @@ -export * from './iam'; +export * from './delegation'; +export * from './delegation.types'; +export * from './iam'; export * from './types'; diff --git a/packages/queries/.changeset/pr-12895-added-1758540593030.md b/packages/queries/.changeset/pr-12895-added-1758540593030.md new file mode 100644 index 00000000000..a3664d63328 --- /dev/null +++ b/packages/queries/.changeset/pr-12895-added-1758540593030.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Added +--- + +IAM Parent/Child - Implement new delegation query hooks ([#12895](https://github.com/linode/manager/pull/12895)) diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts new file mode 100644 index 00000000000..58400a5f810 --- /dev/null +++ b/packages/queries/src/iam/delegation.ts @@ -0,0 +1,200 @@ +import { + generateChildAccountToken, + getChildAccountDelegates, + getChildAccountsIam, + getDefaultDelegationAccess, + getDelegatedChildAccount, + getDelegatedChildAccountsForUser, + getMyDelegatedChildAccounts, + updateChildAccountDelegates, + updateDefaultDelegationAccess, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { + APIError, + GetChildAccountDelegatesParams, + GetChildAccountsIamParams, + GetDelegatedChildAccountsForUserParams, + IamUserRoles, + Params, + ResourcePage, + Token, +} from '@linode/api-v4'; + +export const delegationQueries = createQueryKeys('delegation', { + childAccounts: ({ params, users }) => ({ + queryFn: () => getChildAccountsIam({ params, users }), + queryKey: [params], + }), + delegatedChildAccountsForUser: ({ + username, + params, + }: GetDelegatedChildAccountsForUserParams) => ({ + queryFn: getDelegatedChildAccountsForUser, + queryKey: [username, params], + }), + childAccountDelegates: ({ + euuid, + params, + }: GetChildAccountDelegatesParams) => ({ + queryFn: getChildAccountDelegates, + queryKey: [euuid, params], + }), + myDelegatedChildAccounts: (params: Params) => ({ + queryFn: getMyDelegatedChildAccounts, + queryKey: [params], + }), + delegatedChildAccount: (euuid: string) => ({ + queryFn: getDelegatedChildAccount, + queryKey: [euuid], + }), + defaultAccess: { + queryFn: getDefaultDelegationAccess, + queryKey: null, + }, +}); + +/** + * List all child accounts (gets all child accounts from customerParentChild table for the parent account) + * - Purpose: Inventory child accounts under the caller’s parent account. + * - Scope: All child accounts for the parent; not filtered by any user’s delegation. + * - Audience: Parent account administrators managing delegation. + * - Data: Page; optionally Page when `users=true` (use `params.includeDelegates` to set). + */ +export const useListChildAccountsQuery = ( + params: GetChildAccountsIamParams, +) => { + return useQuery({ + ...delegationQueries.childAccounts(params), + }); +}; + +/** + * List delegated child accounts for a user + * - Purpose: Which child accounts the specified parent user is delegated to manage. + * - Scope: Subset filtered by `username`; only where that user has an active delegate and required view permission. + * - Audience: Parent account administrators auditing a user’s delegated access. + * - Data: Page for `GET /iam/delegation/users/:username/child-accounts`. + */ +export const useListDelegatedChildAccountsForUserQuery = ({ + username, + params, +}: GetDelegatedChildAccountsForUserParams) => { + return useQuery({ + ...delegationQueries.delegatedChildAccountsForUser({ username, params }), + }); +}; + +/** + * List delegates for a child account + * - Purpose: Which parent users are currently delegated to manage this child account. + * - Scope: Delegates tied to `euuid`; only active delegate users and active parent user records included. + * - Audience: Parent account administrators managing delegates for a specific child account. + * - Data: Page (usernames) for `GET /iam/delegation/child-accounts/:euuid/users`. + */ +export const useListChildAccountDelegatesQuery = ({ + euuid, + params, +}: GetChildAccountDelegatesParams) => { + return useQuery({ + ...delegationQueries.childAccountDelegates({ + euuid, + params, + }), + }); +}; + +/** + * Update delegates for a child account + * - Purpose: Replace the full set of parent users delegated to a child account. + * - Scope: Requires parent-account context, valid parent→child relationship, and authorization; payload must be non-empty. + * - Audience: Parent account administrators assigning/removing delegates for a child account. + * - Data: Request usernames (**full replacement**); Response Page of resulting delegate usernames for `PUT /.../:euuid/users`. + */ +export const useUpdateChildAccountDelegatesQuery = () => { + const queryClient = useQueryClient(); + return useMutation< + ResourcePage, + APIError[], + { data: string[]; euuid: string } + >({ + mutationFn: updateChildAccountDelegates, + onSuccess(_data, { euuid }) { + // Invalidate all child account delegates + queryClient.invalidateQueries({ + queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey, + }); + }, + }); +}; + +/** + * List my delegated child accounts (gets child accounts where user has view_child_account permission) + * - Purpose: Which child accounts the current caller can manage via delegation. + * - Scope: Only child accounts where the caller has an active delegate and required view permission. + * - Audience: Needing to return accounts the caller can actually access + * - Data: Page (limited profile fields) for `GET /iam/delegation/profile/child-accounts`. + */ +export const useListMyDelegatedChildAccountsQuery = (params: Params) => { + return useQuery({ + ...delegationQueries.myDelegatedChildAccounts(params), + }); +}; + +/** + * Get child account + * - Purpose: Retrieve profile information for a specific child account by EUUID. + * - Scope: Single child account identified by `euuid`; subject to required grants. + * - Audience: Callers needing basic child account info in the delegation context. + * - Data: Account (limited account fields) for `GET /iam/delegation/profile/child-accounts/:euuid`. + */ +export const useGetChildAccountQuery = (euuid: string) => { + return useQuery({ + ...delegationQueries.delegatedChildAccount(euuid), + }); +}; + +/** + * Create child account token + * - Purpose: Create a short‑lived bearer token to act on a child account as a proxy/delegate. + * - Scope: For a parent user delegated on the target child account identified by `euuid`. + * - Audience: Clients that need temporary auth to perform actions in the child account. + * - Data: Token for `POST /iam/delegation/child-accounts/:euuid/token`. + */ +export const useGenerateChildAccountTokenQuery = () => { + return useMutation({ + mutationFn: generateChildAccountToken, + }); +}; + +/** + * Get default delegation access + * - Purpose: View the default access (roles/permissions) applied to new delegates on this child account. + * - Scope: Child-account context; restricted to authorized, non-delegate callers. + * - Audience: Child account administrators reviewing default delegate access. + * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`. + */ +export const useGetDefaultDelegationAccessQuery = () => { + return useQuery({ + ...delegationQueries.defaultAccess, + }); +}; + +/** + * Update default delegation access + * - Purpose: Update the default access (roles/permissions) applied to new delegates on this child account. + * - Scope: Child-account context; restricted to authorized, non-delegate callers; validates entity IDs. + * - Audience: Child account administrators configuring default delegate access. + * - Data: Request/Response IamUserRoles for `PUT /iam/delegation/default-role-permissions`. + */ +export const useUpdateDefaultDelegationAccessQuery = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateDefaultDelegationAccess, + onSuccess(data) { + queryClient.setQueryData(delegationQueries.defaultAccess.queryKey, data); + }, + }); +}; From b87b7393fd94a85978f11118f1a071a602e460a8 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 24 Sep 2025 22:51:27 +0530 Subject: [PATCH 21/54] [DI-26882] - Add new component for endpoints filter in Object Storage (#12905) * [DI-26882] - Add new component - cloudpulseendpointsselect * [DI-26882] - Add tests for endpoints props, update comments * [DI-26882] - Remove prop not in use * [DI-26882] - Simplify props, add new func * [DI-26882] - Update util * [DI-26882] - Fix failing tests * [DI-26882] - Use resources query and update new component * [DI-26882] - Update service type in types.ts * [DI-26882] - Add changesets --------- Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com> --- .../pr-12905-added-1758635135139.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 4 +- ...r-12905-upcoming-features-1758635267264.md | 5 + .../CloudPulse/Utils/FilterBuilder.test.ts | 34 ++- .../CloudPulse/Utils/FilterBuilder.ts | 24 ++ .../shared/CloudPulseEndpointsSelect.test.tsx | 228 ++++++++++++++++ .../shared/CloudPulseEndpointsSelect.tsx | 249 ++++++++++++++++++ .../shared/CloudPulseResourcesSelect.tsx | 1 + packages/manager/src/mocks/serverHandlers.ts | 1 + 9 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12905-added-1758635135139.md create mode 100644 packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx diff --git a/packages/api-v4/.changeset/pr-12905-added-1758635135139.md b/packages/api-v4/.changeset/pr-12905-added-1758635135139.md new file mode 100644 index 00000000000..e96d51ceb49 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12905-added-1758635135139.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +CloudPulse-Metrics: Update `CloudPulseServiceType` and constant `capabilityServiceTypeMapping` at `types.ts` ([#12905](https://github.com/linode/manager/pull/12905)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 88727de8737..494e4e2ee21 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -7,7 +7,8 @@ export type CloudPulseServiceType = | 'dbaas' | 'firewall' | 'linode' - | 'nodebalancer'; + | 'nodebalancer' + | 'objectstorage'; export type AlertClass = 'dedicated' | 'shared'; export type DimensionFilterOperatorType = @@ -375,6 +376,7 @@ export const capabilityServiceTypeMapping: Record< dbaas: 'Managed Databases', nodebalancer: 'NodeBalancers', firewall: 'Cloud Firewall', + objectstorage: 'Object Storage', }; /** diff --git a/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md b/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md new file mode 100644 index 00000000000..9fc7c721a9a --- /dev/null +++ b/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Add new component at `CloudPulseEndpointsSelect.tsx` ([#12905](https://github.com/linode/manager/pull/12905)) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 217a73b1222..3d9b0bf6633 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,12 +1,17 @@ import { databaseQueries } from '@linode/queries'; import { DateTime } from 'luxon'; -import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; +import { + dashboardFactory, + databaseInstanceFactory, + objectStorageEndpointsFactory, +} from 'src/factories'; import { RESOURCE_ID, RESOURCES } from './constants'; import { deepEqual, filterBasedOnConfig, + filterEndpointsUsingRegion, filterUsingDependentFilters, getFilters, getTextFilterProperties, @@ -25,6 +30,7 @@ import { import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { CloudPulseServiceTypeFilters } from './models'; @@ -556,6 +562,32 @@ describe('filterUsingDependentFilters', () => { }); }); +describe('filterEndpointsUsingRegion', () => { + const mockData: CloudPulseEndpoints[] = [ + { + ...objectStorageEndpointsFactory.build({ region: 'us-east' }), + label: 'us-east-1.linodeobjects.com', + }, + { + ...objectStorageEndpointsFactory.build({ region: 'us-west' }), + label: 'us-west-1.linodeobjects.com', + }, + ]; + it('should return data as is if data is undefined', () => { + expect( + filterEndpointsUsingRegion(undefined, { region: 'us-east' }) + ).toEqual(undefined); + }); + it('should return undefined if region filter is undefined', () => { + expect(filterEndpointsUsingRegion(mockData, undefined)).toEqual(undefined); + }); + it('should return endpoints based on region if region filter is provided', () => { + expect(filterEndpointsUsingRegion(mockData, { region: 'us-east' })).toEqual( + [mockData[0]] + ); + }); +}); + describe('filterBasedOnConfig', () => { const config: CloudPulseServiceTypeFilters = { configuration: { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 0998166afc6..34b07701b8b 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -14,6 +14,7 @@ import type { FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; +import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; import type { @@ -672,4 +673,27 @@ export const filterUsingDependentFilters = ( } }); }); +} + +/** + * @param data The endpoints for which the filter needs to be applied + * @param regionFilter The selected region filter that will be used to filter the endpoints + * @returns The filtered endpoints + */ +export const filterEndpointsUsingRegion = ( + data?: CloudPulseEndpoints[], + regionFilter?: CloudPulseMetricsFilter +): CloudPulseEndpoints[] | undefined => { + if (!data) { + return data; + } + + const regionFromFilter = regionFilter?.region; + + // If no region filter is provided, return undefined as region is mandatory filter + if (!regionFromFilter) { + return undefined; + } + + return data.filter(({ region }) => region === regionFromFilter); }; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx new file mode 100644 index 00000000000..e38acc948bd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx @@ -0,0 +1,228 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { objectStorageBucketFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; + +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockEndpointHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; + +const mockBuckets: CloudPulseResources[] = [ + { + id: 'obj-bucket-1.us-east-1.linodeobjects.com', + label: 'obj-bucket-1.us-east-1.linodeobjects.com', + region: 'us-east', + endpoint: 'us-east-1.linodeobjects.com', + }, + { + id: 'obj-bucket-2.us-east-2.linodeobjects.com', + label: 'obj-bucket-2.us-east-2.linodeobjects.com', + region: 'us-east', + endpoint: 'us-east-2.linodeobjects.com', + }, + { + id: 'obj-bucket-1.br-gru-1.linodeobjects.com', + label: 'obj-bucket-1.br-gru-1.linodeobjects.com', + region: 'us-east', + endpoint: 'br-gru-1.linodeobjects.com', + }, +]; + +describe('CloudPulseEndpointsSelect component tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + objectStorageBucketFactory.resetSequenceNumber(); + }); + + it('renders with the correct label and placeholder', () => { + renderWithTheme( + + ); + + expect(screen.getByLabelText('Endpoints')).toBeVisible(); + expect(screen.getByPlaceholderText('Select Endpoints')).toBeVisible(); + }); + + it('should render disabled component if the props are undefined', () => { + renderWithTheme( + + ); + + expect(screen.getByTestId('textfield-input')).toBeDisabled(); + }); + + it('should render endpoints', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockBuckets, + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + expect( + await screen.findByRole('option', { + name: mockBuckets[0].endpoint, + }) + ).toBeVisible(); + + expect( + await screen.findByRole('option', { + name: mockBuckets[1].endpoint, + }) + ).toBeVisible(); + }); + + it('should be able to deselect the selected endpoints', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockBuckets, + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: SELECT_ALL }) + ); + await userEvent.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + + // Check that both endpoints are deselected + expect( + await screen.findByRole('option', { + name: mockBuckets[0].endpoint, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + await screen.findByRole('option', { + name: mockBuckets[1].endpoint, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should select multiple endpoints', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockBuckets, + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { + name: mockBuckets[0].endpoint, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: mockBuckets[1].endpoint, + }) + ); + + // Check that the correct endpoints are selected/not selected + expect( + await screen.findByRole('option', { + name: mockBuckets[0].endpoint, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: mockBuckets[1].endpoint, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: mockBuckets[2].endpoint, + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + await screen.findByRole('option', { name: SELECT_ALL }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should show appropriate error message on endpoints call failure', async () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: undefined, + isError: true, + isLoading: false, + status: 'error', + }); + + renderWithTheme( + + ); + expect( + await waitFor(() => { + return screen.findByText('Failed to fetch Endpoints.'); + }) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx new file mode 100644 index 00000000000..42cf343176e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -0,0 +1,249 @@ +import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; +import { Box } from '@mui/material'; +import React, { useMemo } from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; + +import type { + CloudPulseMetricsFilter, + FilterValueType, +} from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; + +export interface CloudPulseEndpoints { + /** + * The label of the endpoint which is 's3_endpoint' in the response from the API + */ + label: string; + /** + * The region of the endpoint + */ + region: string; +} + +export interface CloudPulseEndpointsSelectProps { + /** + * The default value of the endpoints filter + */ + defaultValue?: Partial; + /** + * Whether the endpoints filter is disabled + */ + disabled?: boolean; + /** + * The function to handle the endpoints selection + */ + handleEndpointsSelection: (endpoints: string[], savePref?: boolean) => void; + /** + * The label of the endpoints filter + */ + label: string; + /** + * The placeholder of the endpoints filter + */ + placeholder?: string; + /** + * The region of the endpoints + */ + region?: FilterValueType; + /** + * Whether to save the preferences + */ + savePreferences?: boolean; + /** + * The service type + */ + serviceType: CloudPulseServiceType | undefined; + /** + * The dependent filters of the endpoints + */ + xFilter?: CloudPulseMetricsFilter; +} + +export const CloudPulseEndpointsSelect = React.memo( + (props: CloudPulseEndpointsSelectProps) => { + const { + defaultValue, + disabled, + handleEndpointsSelection, + label, + placeholder, + region, + serviceType, + savePreferences, + xFilter, + } = props; + + const { + data: buckets, + isError, + isLoading, + } = useResourcesQuery( + disabled !== undefined ? !disabled : Boolean(region && serviceType), + serviceType, + {}, + + RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {} + ); + + const validSortedEndpoints = useMemo(() => { + if (!buckets) return []; + + const visitedEndpoints = new Set(); + const uniqueEndpoints: CloudPulseEndpoints[] = []; + + buckets.forEach(({ endpoint, region }) => { + if (endpoint && region && !visitedEndpoints.has(endpoint)) { + visitedEndpoints.add(endpoint); + uniqueEndpoints.push({ label: endpoint, region }); + } + }); + + uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); + return uniqueEndpoints; + }, [buckets]); + + const [selectedEndpoints, setSelectedEndpoints] = + React.useState(); + + /** + * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below + * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time + * When the autocomplete is open, we should publish any resources on clear action until the autocomplete is close + */ + const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete + + const getEndpointsList = React.useMemo(() => { + return filterEndpointsUsingRegion(validSortedEndpoints, xFilter) ?? []; + }, [validSortedEndpoints, xFilter]); + + // Once the data is loaded, set the state variable with value stored in preferences + React.useEffect(() => { + if (disabled && !selectedEndpoints) { + return; + } + // To save default values, go through side effects if disabled is false + if (!buckets || !savePreferences || selectedEndpoints) { + if (selectedEndpoints) { + setSelectedEndpoints([]); + handleEndpointsSelection([]); + } + } else { + const defaultEndpoints = + defaultValue && Array.isArray(defaultValue) + ? defaultValue.map((endpoint) => String(endpoint)) + : []; + const endpoints = getEndpointsList.filter((endpointObj) => + defaultEndpoints.includes(endpointObj.label) + ); + + handleEndpointsSelection(endpoints.map((endpoint) => endpoint.label)); + setSelectedEndpoints(endpoints); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [buckets, region, xFilter, serviceType]); + + return ( + option.label === value.label} + label={label || 'Endpoints'} + limitTags={1} + loading={isLoading} + multiple + noMarginTop + onChange={(_e, endpointSelections) => { + setSelectedEndpoints(endpointSelections); + + if (!isAutocompleteOpen.current) { + handleEndpointsSelection( + endpointSelections.map((endpoint) => endpoint.label), + savePreferences + ); + } + }} + onClose={() => { + isAutocompleteOpen.current = false; + handleEndpointsSelection( + selectedEndpoints?.map((endpoint) => endpoint.label) ?? [], + savePreferences + ); + }} + onOpen={() => { + isAutocompleteOpen.current = true; + }} + options={getEndpointsList} + placeholder={ + selectedEndpoints?.length ? '' : placeholder || 'Select Endpoints' + } + renderOption={(props, option) => { + const { key, ...rest } = props; + const isEndpointSelected = selectedEndpoints?.some( + (item) => item.label === option.label + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} + textFieldProps={{ + InputProps: { + sx: { + '::-webkit-scrollbar': { + display: 'none', + }, + maxHeight: '55px', + msOverflowStyle: 'none', + overflow: 'auto', + scrollbarWidth: 'none', + }, + }, + }} + value={selectedEndpoints ?? []} + /> + ); + }, + compareProps +); + +function compareProps( + prevProps: CloudPulseEndpointsSelectProps, + nextProps: CloudPulseEndpointsSelectProps +): boolean { + // these properties can be extended going forward + const keysToCompare: (keyof CloudPulseEndpointsSelectProps)[] = [ + 'region', + 'serviceType', + ]; + + for (const key of keysToCompare) { + if (prevProps[key] !== nextProps[key]) { + return false; + } + } + if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) { + return false; + } + + // Ignore function props in comparison + return true; +} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index c95f20d2236..cbf097c063b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -13,6 +13,7 @@ import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; export interface CloudPulseResources { clusterSize?: number; + endpoint?: string; engineType?: string; entities?: Record; id: string; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 0f3a5be3bd8..b051a64238c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3060,6 +3060,7 @@ export const handlers = [ dbaas: 'Databases', nodebalancer: 'NodeBalancers', firewall: 'Firewalls', + objectstorage: 'Object Storage', }; const response = serviceTypesFactory.build({ service_type: `${serviceType}`, From 92a19efbd5bae8b6eca7cb52884368d71082dd1d Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Thu, 25 Sep 2025 10:31:13 +0530 Subject: [PATCH 22/54] fix: [DI-27257] - Disable metric and dimension filter on no serviceType selected, Also disable resources hook on no supported regions in Create Alerts flow (#12891) * DI-27257: Fix for bugs, disable metric and dimension filter button if service type is not selected and disable useResources query in alerts section if no supported regions * DI-27257: Add changeset --- .../pr-12891-fixed-1758180544654.md | 5 +++++ .../core/cloudpulse/edit-system-alert.spec.ts | 2 ++ .../AlertsResources/AlertsResources.tsx | 20 +++++++++---------- .../CreateAlertDefinition.test.tsx | 6 +----- .../CreateAlert/Criteria/DimensionFilter.tsx | 6 +++++- .../CreateAlert/Criteria/MetricCriteria.tsx | 4 +++- .../src/queries/cloudpulse/resources.ts | 3 +++ 7 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 packages/manager/.changeset/pr-12891-fixed-1758180544654.md diff --git a/packages/manager/.changeset/pr-12891-fixed-1758180544654.md b/packages/manager/.changeset/pr-12891-fixed-1758180544654.md new file mode 100644 index 00000000000..7e257eb3deb --- /dev/null +++ b/packages/manager/.changeset/pr-12891-fixed-1758180544654.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disable `Add Metric and Add Dimension Filter` without serviceType; skip `useResources` if no supported regions in CloudPulse Alerting ([#12891](https://github.com/linode/manager/pull/12891)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index b7b8f7a7c7b..335b2ae55eb 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -42,11 +42,13 @@ const regions = [ capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', + monitors: { alerts: ['Managed Databases'] }, }), regionFactory.build({ capabilities: ['Managed Databases'], id: 'us-east', label: 'Newark', + monitors: { alerts: ['Managed Databases'] }, }), ]; const databases: Database[] = databaseFactory diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 5490bef4be5..5de8c1725f8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -131,17 +131,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const supportedRegionIds = getSupportedRegionIds(regions, serviceType); const xFilterToBeApplied: Filter | undefined = React.useMemo(() => { - if (serviceType === 'firewall') { + if (serviceType === 'firewall' || !supportedRegionIds?.length) { return undefined; } - const regionFilter: Filter = supportedRegionIds - ? { - '+or': supportedRegionIds.map((regionId) => ({ - region: regionId, - })), - } - : {}; + const regionFilter: Filter = { + '+or': supportedRegionIds?.map((regionId) => ({ + region: regionId, + })), + }; // if service type is other than dbaas, return only region filter if (serviceType !== 'dbaas') { @@ -153,7 +151,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { // If alertType is not 'system' or alertClass is not defined, return only platform filter if (alertType !== 'system' || !alertClass) { - return platformFilter; + return { ...platformFilter, '+and': [regionFilter] }; } // Dynamically exclude 'dedicated' if alertClass is 'shared' @@ -182,7 +180,9 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { isError: isResourcesError, isLoading: isResourcesLoading, } = useResourcesQuery( - Boolean(serviceType), + Boolean( + serviceType && (serviceType === 'firewall' || supportedRegionIds?.length) + ), // Enable query only if serviceType and supportedRegionIds are available, in case of firewall only serviceType is needed serviceType, {}, xFilterToBeApplied diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index da1b0d8249b..71dc62de48f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -184,12 +184,8 @@ describe('AlertDefinition Create', () => { const submitButton = container.getByText('Submit'); - await user.click( - container.getByRole('button', { name: 'Add dimension filter' }) - ); - await user.click(submitButton!); - expect(container.getAllByText(errorMessage).length).toBe(12); + expect(container.getAllByText(errorMessage).length).toBe(9); container.getAllByText(errorMessage).forEach((element) => { expect(element).toBeVisible(); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx index 469de3cea93..64668cc2900 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -32,6 +32,7 @@ export const DimensionFilters = (props: DimensionFilterProps) => { }); const dimensionFilterWatcher = useWatch({ control, name }); + const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); return ( @@ -54,7 +55,10 @@ export const DimensionFilters = (props: DimensionFilterProps) => {
    - - - - Name - - - Type - - - ID - - - - Creation Time - - - - Last Modified - - - - - - {destinations?.data.map((destination) => ( - - ))} - -
    - + {isLoading ? ( + + ) : ( + <> + + + + + Name + + + Type + + + ID + + + + Creation Time + + + + Last Modified + + + + + + {destinations?.data.map((destination) => ( + + ))} + {destinations?.results === 0 && } + +
    + + + )} ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 15518bd479a..d880b6220a7 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -49,13 +49,13 @@ export const StreamFormDelivery = () => { const [creatingNewDestination, setCreatingNewDestination] = useState(false); - const destinationNameOptions: DestinationName[] = ( - destinations?.data || [] - ).map(({ id, label, type }) => ({ - id, - label, - type, - })); + const destinationNameOptions: DestinationName[] = (destinations || []).map( + ({ id, label, type }) => ({ + id, + label, + type, + }) + ); const selectedDestinationType = useWatch({ control, @@ -70,7 +70,7 @@ export const StreamFormDelivery = () => { const destinationNameFilterOptions = createFilterOptions(); const findDestination = (id: number) => - destinations?.data?.find((destination) => destination.id === id); + destinations?.find((destination) => destination.id === id); const getDestinationForm = () => ( <> diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 7e84853915e..f9bb8b845f6 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -77,7 +77,7 @@ export const StreamEdit = () => { : {}; const streamsDestinationIds = stream.destinations.map(({ id }) => id); - const destination = destinations?.data?.find( + const destination = destinations?.find( ({ id }) => id === streamsDestinationIds[0] ); diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx index b21e33cf5dc..90fd9d2fbb0 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx @@ -1,22 +1,17 @@ -import { - screen, - waitForElementToBeRemoved, - within, -} from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { beforeEach, describe, expect } from 'vitest'; import { streamFactory } from 'src/factories/delivery'; import { StreamsLanding } from 'src/features/Delivery/Streams/StreamsLanding'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; - const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn(() => vi.fn()), + useSearch: vi.fn(), + useStreamsQuery: vi.fn().mockReturnValue({}), useUpdateStreamMutation: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), }), @@ -30,6 +25,7 @@ vi.mock('@tanstack/react-router', async () => { return { ...actual, useNavigate: queryMocks.useNavigate, + useSearch: queryMocks.useSearch, }; }); @@ -37,6 +33,7 @@ vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); return { ...actual, + useStreamsQuery: queryMocks.useStreamsQuery, useUpdateStreamMutation: queryMocks.useUpdateStreamMutation, useDeleteStreamMutation: queryMocks.useDeleteStreamMutation, }; @@ -46,28 +43,27 @@ const stream = streamFactory.build({ id: 1 }); const streams = [stream, ...streamFactory.buildList(30)]; describe('Streams Landing Table', () => { - const renderComponentAndWaitForLoadingComplete = async () => { + const renderComponent = () => { renderWithTheme(, { initialRoute: '/logs/delivery/streams', }); - - const loadingElement = screen.queryByTestId(loadingTestId); - expect(loadingElement).toBeInTheDocument(); - await waitForElementToBeRemoved(loadingElement); }; beforeEach(() => { mockMatchMedia(); + queryMocks.useSearch.mockReturnValue({}); }); - it('should render streams landing tab header and table with items PaginationFooter', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streams)); - }) - ); + it('should render streams landing tab header and table with items PaginationFooter', () => { + queryMocks.useStreamsQuery.mockReturnValue({ + data: { + data: streams, + results: 31, + }, + isLoading: false, + }); - await renderComponentAndWaitForLoadingComplete(); + renderComponent(); // search text input screen.getByPlaceholderText('Search for a Stream'); @@ -93,20 +89,50 @@ describe('Streams Landing Table', () => { expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); }); - it('should render streams landing empty state', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage([])); - }) - ); + it('should render streams landing table with empty row when there are no search results', () => { + queryMocks.useStreamsQuery.mockReturnValue({ + data: { + data: [], + results: 0, + }, + }); + + queryMocks.useSearch.mockReturnValue({ + label: 'Same unknown label', + }); + + renderComponent(); - await renderComponentAndWaitForLoadingComplete(); + const emptyRow = screen.getByText('No items to display.'); + expect(emptyRow).toBeInTheDocument(); + }); + + it('should render streams landing empty state', () => { + queryMocks.useStreamsQuery.mockReturnValue({ + data: { + data: [], + results: 0, + }, + }); + + renderComponent(); screen.getByText((text) => text.includes('Create a stream and configure delivery of cloud logs') ); }); + it('should render loading state when fetching streams', () => { + queryMocks.useStreamsQuery.mockReturnValue({ + isLoading: true, + }); + + renderComponent(); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + }); + const clickOnActionMenu = async () => { const actionMenu = screen.getByLabelText( `Action menu for Stream ${stream.label}` @@ -120,11 +146,12 @@ describe('Streams Landing Table', () => { describe('given action menu', () => { beforeEach(() => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streams)); - }) - ); + queryMocks.useStreamsQuery.mockReturnValue({ + data: { + data: streams, + results: 31, + }, + }); }); describe('when Edit clicked', () => { @@ -132,7 +159,7 @@ describe('Streams Landing Table', () => { const mockNavigate = vi.fn(); queryMocks.useNavigate.mockReturnValue(mockNavigate); - await renderComponentAndWaitForLoadingComplete(); + renderComponent(); await clickOnActionMenu(); await clickOnActionMenuItem('Edit'); @@ -150,7 +177,7 @@ describe('Streams Landing Table', () => { mutateAsync: mockUpdateStreamMutation, }); - await renderComponentAndWaitForLoadingComplete(); + renderComponent(); await clickOnActionMenu(); await clickOnActionMenuItem('Disable'); @@ -173,7 +200,7 @@ describe('Streams Landing Table', () => { }); stream.status = 'inactive'; - await renderComponentAndWaitForLoadingComplete(); + renderComponent(); await clickOnActionMenu(); await clickOnActionMenuItem('Enable'); @@ -195,7 +222,7 @@ describe('Streams Landing Table', () => { mutateAsync: mockDeleteStreamMutation, }); - await renderComponentAndWaitForLoadingComplete(); + renderComponent(); await clickOnActionMenu(); await clickOnActionMenuItem('Delete'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index de441286baf..4a6968edb04 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -12,6 +12,7 @@ import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { DeliveryTabHeader } from 'src/features/Delivery/Shared/DeliveryTabHeader/DeliveryTabHeader'; import { streamStatusOptions } from 'src/features/Delivery/Shared/types'; @@ -31,7 +32,6 @@ import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); - const streamsUrl = '/logs/delivery/streams'; const search = useSearch({ from: '/logs/delivery/streams', @@ -106,17 +106,13 @@ export const StreamsLanding = () => { navigate({ to: '/logs/delivery/streams/create' }); }; - if (isLoading) { - return ; - } - if (error) { return ( ); } - if (!streams?.data.length) { + if (streams?.results === 0 && !search?.status && !search?.label) { return ; } @@ -205,65 +201,73 @@ export const StreamsLanding = () => { selectList={streamStatusOptions} selectValue={search?.status} /> - - - - - Name - - Stream Type - - Status - - - ID - - - Destination Type - - - - Creation Time - - - - - - - {streams?.data.map((stream) => ( - - ))} - -
    - + + {isLoading ? ( + + ) : ( + <> + + + + + Name + + Stream Type + + Status + + + ID + + + Destination Type + + + + Creation Time + + + + + + + {streams?.data.map((stream) => ( + + ))} + {streams?.results === 0 && } + +
    + + + )} ); }; diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index 7c5cf8e0706..df897bb8d70 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -115,9 +115,9 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { objectStorageBuckets?.buckets.map(bucketToSearchableItem) ?? []; const searchableClusters = clusters?.map(kubernetesClusterToSearchableItem) ?? []; - const searchableStreams = streams?.data?.map(streamToSearchableItem) ?? []; + const searchableStreams = streams?.map(streamToSearchableItem) ?? []; const searchableDestinations = - destinations?.data?.map(destinationToSearchableItem) ?? []; + destinations?.map(destinationToSearchableItem) ?? []; const searchableItems = [ ...searchableLinodes, diff --git a/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md b/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md new file mode 100644 index 00000000000..a921d84ae12 --- /dev/null +++ b/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Logs Delivery Streams/Destinations update useAll queries ([#12802](https://github.com/linode/manager/pull/12802)) diff --git a/packages/queries/src/delivery/delivery.ts b/packages/queries/src/delivery/delivery.ts index 314656eed11..503947d666b 100644 --- a/packages/queries/src/delivery/delivery.ts +++ b/packages/queries/src/delivery/delivery.ts @@ -33,7 +33,6 @@ import type { UpdateDestinationPayloadWithId, UpdateStreamPayloadWithId, } from '@linode/api-v4'; -import type { GetAllData } from '@linode/utilities'; export const getAllStreams = ( passedParams: Params = {}, @@ -41,7 +40,7 @@ export const getAllStreams = ( ) => getAll((params, filter) => getStreams({ ...params, ...passedParams }, { ...filter, ...passedFilter }), - )(); + )().then((data) => data.data); export const getAllDestinations = ( passedParams: Params = {}, @@ -52,7 +51,7 @@ export const getAllDestinations = ( { ...params, ...passedParams }, { ...filter, ...passedFilter }, ), - )(); + )().then((data) => data.data); export const deliveryQueries = createQueryKeys('delivery', { stream: (id: number) => ({ @@ -111,7 +110,7 @@ export const useAllStreamsQuery = ( filter: Filter = {}, enabled = true, ) => - useQuery, APIError[]>({ + useQuery({ ...deliveryQueries.streams._ctx.all(params, filter), enabled, }); @@ -209,7 +208,7 @@ export const useAllDestinationsQuery = ( filter: Filter = {}, enabled = true, ) => - useQuery, APIError[]>({ + useQuery({ ...deliveryQueries.destinations._ctx.all(params, filter), enabled, }); From b668d533995d61ac4b3f1536c8d7720b8d9f8133 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 25 Sep 2025 07:25:04 -0700 Subject: [PATCH 24/54] change: Remove deprecated flag from Flags interface (#12911) * Remove deprecated lkeEnterprise flag from Flags interface * Add changeset --- .../.changeset/pr-12911-tech-stories-1758736596975.md | 5 +++++ packages/manager/src/featureFlags.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md diff --git a/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md b/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md new file mode 100644 index 00000000000..df6b48b9518 --- /dev/null +++ b/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove deprecated `lkeEnterprise` flag from Flags interface ([#12911](https://github.com/linode/manager/pull/12911)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ecbabf65481..45e984cffe4 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -182,7 +182,6 @@ export interface Flags { linodeCloneFirewall: boolean; linodeDiskEncryption: boolean; linodeInterfaces: LinodeInterfacesFlag; - lkeEnterprise: LkeEnterpriseFlag; lkeEnterprise2: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; From c686aa833d1ddad880c92ed3c2b38bffc499f6ff Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:23:44 +0200 Subject: [PATCH 25/54] feat: [UIE-9244] - Iam Delegation mock data (#12914) * factories * seeds and handlers * wrap up seeds and handlers * better handling before seeding * load seeding * fixes and cleanup * Added changeset: IAM delegation mock data --- packages/api-v4/src/iam/delegation.ts | 6 +- .../pr-12914-added-1758802782862.md | 5 + packages/manager/src/dev-tools/load.ts | 8 + packages/manager/src/mocks/mockState.ts | 2 + .../src/mocks/presets/baseline/crud.ts | 2 + .../src/mocks/presets/crud/delegation.ts | 24 ++ .../mocks/presets/crud/handlers/delegation.ts | 318 ++++++++++++++++++ .../mocks/presets/crud/seeds/delegation.ts | 57 ++++ .../src/mocks/presets/crud/seeds/index.ts | 2 + packages/manager/src/mocks/types.ts | 12 + packages/queries/src/iam/delegation.ts | 113 ++++--- packages/queries/src/iam/index.ts | 1 + .../utilities/src/factories/delegation.ts | 89 +++++ packages/utilities/src/factories/index.ts | 1 + packages/utilities/src/helpers/random.ts | 11 + 15 files changed, 608 insertions(+), 43 deletions(-) create mode 100644 packages/manager/.changeset/pr-12914-added-1758802782862.md create mode 100644 packages/manager/src/mocks/presets/crud/delegation.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/delegation.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/delegation.ts create mode 100644 packages/utilities/src/factories/delegation.ts diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts index 2632fe85f19..dba6dcc0016 100644 --- a/packages/api-v4/src/iam/delegation.ts +++ b/packages/api-v4/src/iam/delegation.ts @@ -47,7 +47,7 @@ export const getChildAccountDelegates = ({ euuid, params, }: GetChildAccountDelegatesParams) => - Request>( + Request>( setURL( `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, ), @@ -59,7 +59,7 @@ export const updateChildAccountDelegates = ({ euuid, data, }: UpdateChildAccountDelegatesParams) => - Request>( + Request>( setURL( `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`, ), @@ -87,7 +87,7 @@ export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) => export const generateChildAccountToken = ({ euuid }: { euuid: string }) => Request( setURL( - `${BETA_API_ROOT}/iam/delegation/child-accounts/child-accounts/${encodeURIComponent(euuid)}/token`, + `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}/token`, ), setMethod('POST'), setData(euuid), diff --git a/packages/manager/.changeset/pr-12914-added-1758802782862.md b/packages/manager/.changeset/pr-12914-added-1758802782862.md new file mode 100644 index 00000000000..f2942070d74 --- /dev/null +++ b/packages/manager/.changeset/pr-12914-added-1758802782862.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM delegation mock data ([#12914](https://github.com/linode/manager/pull/12914)) diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index c810a6bcc5f..eb6079b3e23 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -79,6 +79,14 @@ export async function loadDevTools() { // Merge the contexts const mergedContext: MockState = { ...initialContext, + childAccounts: [ + ...initialContext.childAccounts, + ...(seedContext?.childAccounts || []), + ], + delegations: [ + ...initialContext.delegations, + ...(seedContext?.delegations || []), + ], domains: [...initialContext.domains, ...(seedContext?.domains || [])], eventQueue: [ ...initialContext.eventQueue, diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts index 56f9b46ede9..c6b05eacaee 100644 --- a/packages/manager/src/mocks/mockState.ts +++ b/packages/manager/src/mocks/mockState.ts @@ -22,8 +22,10 @@ export const getStateSeederGroups = ( }; export const emptyStore: MockState = { + childAccounts: [], cloudnats: [], configInterfaces: [], + delegations: [], destinations: [], domainRecords: [], domains: [], diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 37df11b0d68..c6140d30212 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -6,6 +6,7 @@ import { import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; import { cloudNATCrudPreset } from '../crud/cloudnats'; +import { childAccountsCrudPreset } from '../crud/delegation'; import { domainCrudPreset } from '../crud/domains'; import { entityCrudPreset } from '../crud/entities'; import { firewallCrudPreset } from '../crud/firewalls'; @@ -22,6 +23,7 @@ import type { MockPresetBaseline } from 'src/mocks/types'; export const baselineCrudPreset: MockPresetBaseline = { group: { id: 'General' }, handlers: [ + ...childAccountsCrudPreset.handlers, ...cloudNATCrudPreset.handlers, ...domainCrudPreset.handlers, ...deliveryCrudPreset.handlers, diff --git a/packages/manager/src/mocks/presets/crud/delegation.ts b/packages/manager/src/mocks/presets/crud/delegation.ts new file mode 100644 index 00000000000..fc3a6ad52b6 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/delegation.ts @@ -0,0 +1,24 @@ +import { + childAccountDelegates, + defaultDelegationAccess, + delegatedChildAccounts, + generateChildAccountToken, + getChildAccounts, + getDelegatedChildAccountsForUser, +} from 'src/mocks/presets/crud/handlers/delegation'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const childAccountsCrudPreset: MockPresetCrud = { + group: { id: 'Child Accounts' }, + handlers: [ + getChildAccounts, + getDelegatedChildAccountsForUser, + childAccountDelegates, + delegatedChildAccounts, + generateChildAccountToken, + defaultDelegationAccess, + ], + id: 'child-accounts:crud', + label: 'Child Accounts CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts new file mode 100644 index 00000000000..35901534041 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -0,0 +1,318 @@ +import { http } from 'msw'; + +import { accountFactory } from 'src/factories/account'; +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { + Account, + ChildAccount, + IamUserRoles, + Token, +} from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getChildAccounts = () => [ + http.get( + '*/v4*/iam/delegation/child-accounts*', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const childAccounts = await mswDB.getAll('childAccounts'); + const delegations = await mswDB.getAll('delegations'); + const withUsers = request.url.includes('users=true'); + + if (!childAccounts || !delegations) { + return makeNotFoundResponse(); + } + + return makePaginatedResponse({ + data: childAccounts.map((account) => ({ + ...account, + users: withUsers + ? delegations + .filter((d) => d.childAccountEuuid === account.euuid) + .map((d) => d.username) + : undefined, + })), + request, + }); + } + ), + + http.get( + '*/v4*/iam/delegation/child-accounts/:id', + async ({ + params, + }): Promise> => { + const id = Number(params.id); + const entity = await mswDB.get('childAccounts', id); + + if (!entity) { + return makeNotFoundResponse(); + } + + return makeResponse(entity); + } + ), +]; + +export const getDelegatedChildAccountsForUser = () => [ + http.get( + '*/v4*/iam/delegation/users/:username/child-accounts', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const username = params.username; + const delegations = await mswDB.getAll('delegations'); + const childAccounts = await mswDB.getAll('childAccounts'); + + if (!childAccounts || !delegations) { + return makeNotFoundResponse(); + } + + const userDelegations = delegations.filter( + (d) => d.username === username + ); + + return makePaginatedResponse({ + data: childAccounts.filter((account) => + userDelegations.some((d) => d.childAccountEuuid === account.euuid) + ), + request, + }); + } + ), +]; + +export const childAccountDelegates = (mockState: MockState) => [ + http.get( + '*/v4*/iam/delegation/child-accounts/:euuid/users', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const euuid = params.euuid as string; + const delegations = await mswDB.getAll('delegations'); + + if (!delegations) { + return makeNotFoundResponse(); + } + + // Get all usernames delegated to this specific child account + const delegateUsernames = delegations + .filter((d) => d.childAccountEuuid === euuid) + .map((d) => d.username); + + return makePaginatedResponse({ + data: delegateUsernames, + request, + }); + } + ), + + http.put( + '*/v4*/iam/delegation/child-accounts/:euuid/users', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const euuid = params.euuid as string; + const requestData = (await request.json()) as { users: string[] }; + const newUsernames = requestData?.users || []; + + // Get current delegations + const allDelegations = await mswDB.getAll('delegations'); + if (!allDelegations) { + return makeNotFoundResponse(); + } + + // Find and delete delegations for this child account + const delegationsToDelete = allDelegations.filter( + (d) => d.childAccountEuuid === euuid + ); + + for (const delegation of delegationsToDelete) { + await mswDB.delete('delegations', delegation.id, mockState); + } + + // Add new delegations + for (const username of newUsernames) { + await mswDB.add( + 'delegations', + { + childAccountEuuid: euuid, + username, + id: Math.floor(Math.random() * 1000000), + }, + mockState + ); + } + + return makePaginatedResponse({ + data: newUsernames, + request, + }); + } + ), +]; + +export const delegatedChildAccounts = () => [ + http.get( + '*/v4*/iam/delegation/profile/child-accounts', + async ({ + request, + }): Promise< + StrictResponse> + > => { + // For mocking purposes, we'll simulate getting the current user's delegated accounts + // In real implementation, this would use authentication context + const delegations = await mswDB.getAll('delegations'); + const childAccounts = await mswDB.getAll('childAccounts'); + + if (!childAccounts) { + return makeNotFoundResponse(); + } + + const allDelegations = await mswDB.getAll('delegations'); + const mockCurrentUser = allDelegations?.[0]?.username || 'mockuser'; + const userDelegations = delegations?.filter( + (d) => d.username === mockCurrentUser + ); + + const delegatedAccounts = childAccounts + .filter((account) => + userDelegations?.some((d) => d.childAccountEuuid === account.euuid) + ) + .map((childAccount) => ({ + ...accountFactory.build({ + company: childAccount.company, + euuid: childAccount.euuid, + }), + ...childAccount, + })); + + return makePaginatedResponse({ + data: delegatedAccounts, + request, + }); + } + ), + + http.get( + '*/v4*/iam/delegation/profile/child-accounts/:euuid', + async ({ params }): Promise> => { + const euuid = params.euuid as string; + const childAccount = await mswDB.getAll('childAccounts'); + + if (!childAccount) { + return makeNotFoundResponse(); + } + + const account = childAccount.find((acc) => acc.euuid === euuid); + + if (!account) { + return makeNotFoundResponse(); + } + + // Convert ChildAccount to full Account + const fullAccount = { + ...accountFactory.build({ + company: account.company, + euuid: account.euuid, + }), + ...account, + }; + + return makeResponse(fullAccount); + } + ), +]; + +export const generateChildAccountToken = () => [ + http.post( + '*/v4*/iam/delegation/profile/child-accounts/:euuid/token', + async ({ params }): Promise> => { + const euuid = params.euuid as string; + + // Verify the child account exists + const childAccounts = await mswDB.getAll('childAccounts'); + if (!childAccounts?.some((acc) => acc.euuid === euuid)) { + return makeNotFoundResponse(); + } + + // Generate mock token + const mockToken: Token = { + id: Math.floor(Math.random() * 10000), + created: new Date().toISOString(), + expiry: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours + label: `Child Account Token - ${euuid}`, + scopes: '*', + token: `mock_token_${euuid}_${Date.now()}`, + }; + + return makeResponse(mockToken); + } + ), +]; + +export const defaultDelegationAccess = () => [ + http.get( + '*/v4*/iam/delegation/default-role-permissions', + async (): Promise> => { + // Mock default delegation access + const mockDefaultAccess: IamUserRoles = { + account_access: [ + 'account_linode_admin', + 'account_linode_creator', + 'account_firewall_creator', + ], + entity_access: [ + { + id: 12345678, + type: 'linode' as const, + roles: ['linode_contributor'], + }, + { + id: 45678901, + type: 'firewall' as const, + roles: ['firewall_admin'], + }, + ], + }; + + return makeResponse(mockDefaultAccess); + } + ), + + http.put( + '*/v4*/iam/delegation/default-role-permissions', + async ({ + request, + }): Promise> => { + const requestData = (await request.json()) as IamUserRoles; + + // In a real implementation, you'd validate and store this + // For mocking, just return what was sent + return makeResponse(requestData); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/seeds/delegation.ts b/packages/manager/src/mocks/presets/crud/seeds/delegation.ts new file mode 100644 index 00000000000..b35a6d8a2f5 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/delegation.ts @@ -0,0 +1,57 @@ +import { + childAccountFactory, + mockDelegateUsersList, + pickRandomMultiple, +} from '@linode/utilities'; + +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const delegationSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Child Accounts and Delegations Seeds', + group: { id: 'Child Accounts' }, + id: 'child-accounts:crud', + label: 'Child Accounts & Delegations', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[delegationSeeder.id] ?? 3; // Default to 3 child accounts + + // 1. Seed Child Accounts (basic account info only) + const childAccounts = childAccountFactory.buildList(count); + + // 2. Seed Delegations (many-to-many relationships) + const delegations = []; + let delegationId = 1; + + for (const childAccount of childAccounts) { + // Randomly assign 1-3 users to each child account + const numDelegates = Math.floor(Math.random() * 3) + 1; + const selectedUsers = pickRandomMultiple( + mockDelegateUsersList, + numDelegates + ); + + for (const username of selectedUsers) { + delegations.push({ + id: delegationId++, + childAccountEuuid: childAccount.euuid, + username, + }); + } + } + + const updatedMockState = { + ...mockState, + childAccounts: mockState.childAccounts.concat(childAccounts), + delegations: mockState.delegations.concat(delegations), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts index 7a21e2e67f7..6d82f0d18f1 100644 --- a/packages/manager/src/mocks/presets/crud/seeds/index.ts +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -1,4 +1,5 @@ import { cloudNATSeeder } from './cloudnats'; +import { delegationSeeder } from './delegation'; import { domainSeeder } from './domains'; import { entitiesSeeder } from './entities'; import { firewallSeeder } from './firewalls'; @@ -13,6 +14,7 @@ import { vpcSeeder } from './vpcs'; export const dbSeeders = [ cloudNATSeeder, + delegationSeeder, domainSeeder, entitiesSeeder, firewallSeeder, diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts index 8a1dc9e45f7..36b6d539767 100644 --- a/packages/manager/src/mocks/types.ts +++ b/packages/manager/src/mocks/types.ts @@ -1,4 +1,5 @@ import type { + ChildAccount, CloudNAT, Config, Destination, @@ -121,6 +122,7 @@ export interface MockPresetExtra extends MockPresetBase { */ export type MockPresetCrudGroup = { id: + | 'Child Accounts' | 'CloudNATs' | 'Delivery' | 'Domains' @@ -137,6 +139,8 @@ export type MockPresetCrudGroup = { | 'VPCs'; }; export type MockPresetCrudId = + | 'child-accounts-for-user:crud' + | 'child-accounts:crud' | 'cloudnats:crud' | 'delivery:crud' | 'domains:crud' @@ -159,12 +163,20 @@ export interface MockPresetCrud extends MockPresetBase { export type MockHandler = (mockState: MockState) => HttpHandler[]; +interface Delegation { + childAccountEuuid: string; + id: number; + username: string; +} + /** * Stateful data shared among mocks. */ export interface MockState { + childAccounts: ChildAccount[]; cloudnats: CloudNAT[]; configInterfaces: [number, Interface][]; // number is Config ID + delegations: Delegation[]; destinations: Destination[]; domainRecords: DomainRecord[]; domains: Domain[]; diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index 58400a5f810..f342c4fc452 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -13,7 +13,10 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { + Account, APIError, + ChildAccount, + ChildAccountWithDelegates, GetChildAccountDelegatesParams, GetChildAccountsIamParams, GetDelegatedChildAccountsForUserParams, @@ -22,9 +25,10 @@ import type { ResourcePage, Token, } from '@linode/api-v4'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; export const delegationQueries = createQueryKeys('delegation', { - childAccounts: ({ params, users }) => ({ + childAccounts: ({ params, users }: GetChildAccountsIamParams) => ({ queryFn: () => getChildAccountsIam({ params, users }), queryKey: [params], }), @@ -32,22 +36,22 @@ export const delegationQueries = createQueryKeys('delegation', { username, params, }: GetDelegatedChildAccountsForUserParams) => ({ - queryFn: getDelegatedChildAccountsForUser, + queryFn: () => getDelegatedChildAccountsForUser({ username, params }), queryKey: [username, params], }), childAccountDelegates: ({ euuid, params, }: GetChildAccountDelegatesParams) => ({ - queryFn: getChildAccountDelegates, + queryFn: () => getChildAccountDelegates({ euuid, params }), queryKey: [euuid, params], }), myDelegatedChildAccounts: (params: Params) => ({ - queryFn: getMyDelegatedChildAccounts, + queryFn: () => getMyDelegatedChildAccounts({ params }), queryKey: [params], }), delegatedChildAccount: (euuid: string) => ({ - queryFn: getDelegatedChildAccount, + queryFn: () => getDelegatedChildAccount({ euuid }), queryKey: [euuid], }), defaultAccess: { @@ -58,30 +62,37 @@ export const delegationQueries = createQueryKeys('delegation', { /** * List all child accounts (gets all child accounts from customerParentChild table for the parent account) - * - Purpose: Inventory child accounts under the caller’s parent account. - * - Scope: All child accounts for the parent; not filtered by any user’s delegation. + * - Purpose: Get ALL child accounts under a parent account, optionally with their delegate users + * - Scope: All child accounts for the parent (inventory view) * - Audience: Parent account administrators managing delegation. - * - Data: Page; optionally Page when `users=true` (use `params.includeDelegates` to set). + * - CRUD: GET /iam/delegation/child-accounts?users=true (optional) */ -export const useListChildAccountsQuery = ( - params: GetChildAccountsIamParams, -) => { +export const useGetChildAccountsQuery = ({ + params, + users, +}: GetChildAccountsIamParams): UseQueryResult< + ResourcePage, + APIError[] +> => { return useQuery({ - ...delegationQueries.childAccounts(params), + ...delegationQueries.childAccounts({ params, users }), }); }; /** * List delegated child accounts for a user - * - Purpose: Which child accounts the specified parent user is delegated to manage. - * - Scope: Subset filtered by `username`; only where that user has an active delegate and required view permission. + * - Purpose: Get child accounts that a SPECIFIC user is delegated to manage (which child accounts a specific user can access) + * - Scope: Filtered by username - only child accounts where that user has active delegation * - Audience: Parent account administrators auditing a user’s delegated access. - * - Data: Page for `GET /iam/delegation/users/:username/child-accounts`. + * - CRUD: GET /iam/delegation/users/:username/child-accounts */ -export const useListDelegatedChildAccountsForUserQuery = ({ +export const useGetDelegatedChildAccountsForUserQuery = ({ username, params, -}: GetDelegatedChildAccountsForUserParams) => { +}: GetDelegatedChildAccountsForUserParams): UseQueryResult< + ResourcePage, + APIError[] +> => { return useQuery({ ...delegationQueries.delegatedChildAccountsForUser({ username, params }), }); @@ -89,15 +100,18 @@ export const useListDelegatedChildAccountsForUserQuery = ({ /** * List delegates for a child account - * - Purpose: Which parent users are currently delegated to manage this child account. - * - Scope: Delegates tied to `euuid`; only active delegate users and active parent user records included. - * - Audience: Parent account administrators managing delegates for a specific child account. - * - Data: Page (usernames) for `GET /iam/delegation/child-accounts/:euuid/users`. + * - Purpose: Get all delegate users for a SPECIFIC child account + * - Scope: Filtered by child account euuid - only users delegated to that account + * - Audience: Parent account administrators managing delegates for a SPECIFIC child account. + * - CRUD: GET /iam/delegation/child-accounts/:euuid/users */ -export const useListChildAccountDelegatesQuery = ({ +export const useGetChildAccountDelegatesQuery = ({ euuid, params, -}: GetChildAccountDelegatesParams) => { +}: GetChildAccountDelegatesParams): UseQueryResult< + ResourcePage, + APIError[] +> => { return useQuery({ ...delegationQueries.childAccountDelegates({ euuid, @@ -110,13 +124,17 @@ export const useListChildAccountDelegatesQuery = ({ * Update delegates for a child account * - Purpose: Replace the full set of parent users delegated to a child account. * - Scope: Requires parent-account context, valid parent→child relationship, and authorization; payload must be non-empty. - * - Audience: Parent account administrators assigning/removing delegates for a child account. - * - Data: Request usernames (**full replacement**); Response Page of resulting delegate usernames for `PUT /.../:euuid/users`. + * - Audience: Parent account administrators assigning/removing delegates for a SPECIFIC child account. + * - CRUD: PUT /iam/delegation/child-accounts/:euuid/users */ -export const useUpdateChildAccountDelegatesQuery = () => { +export const useUpdateChildAccountDelegatesQuery = (): UseMutationResult< + ResourcePage, + APIError[], + { data: string[]; euuid: string } +> => { const queryClient = useQueryClient(); return useMutation< - ResourcePage, + ResourcePage, APIError[], { data: string[]; euuid: string } >({ @@ -131,13 +149,15 @@ export const useUpdateChildAccountDelegatesQuery = () => { }; /** - * List my delegated child accounts (gets child accounts where user has view_child_account permission) - * - Purpose: Which child accounts the current caller can manage via delegation. + * List my delegated child accounts (gets child accounts where user has view_child_account permission). + * - Purpose: Get child accounts that the current authenticated user can manage via delegation. * - Scope: Only child accounts where the caller has an active delegate and required view permission. - * - Audience: Needing to return accounts the caller can actually access - * - Data: Page (limited profile fields) for `GET /iam/delegation/profile/child-accounts`. + * - Audience: Needing to return accounts the caller can actually access. + * - CRUD: GET /iam/delegation/profile/child-accounts */ -export const useListMyDelegatedChildAccountsQuery = (params: Params) => { +export const useGetMyDelegatedChildAccountsQuery = ( + params: Params, +): UseQueryResult, APIError[]> => { return useQuery({ ...delegationQueries.myDelegatedChildAccounts(params), }); @@ -145,12 +165,14 @@ export const useListMyDelegatedChildAccountsQuery = (params: Params) => { /** * Get child account - * - Purpose: Retrieve profile information for a specific child account by EUUID. - * - Scope: Single child account identified by `euuid`; subject to required grants. - * - Audience: Callers needing basic child account info in the delegation context. - * - Data: Account (limited account fields) for `GET /iam/delegation/profile/child-accounts/:euuid`. + * - Purpose: Get SPECIFIC child account that the current authenticated user can manage via delegation. + * - Scope: Only child accounts where the caller has active delegation and required view permission. + * - Audience: The current user needing to see which accounts they can actually access. + * - CRUD: GET /iam/delegation/profile/child-accounts/:euuid */ -export const useGetChildAccountQuery = (euuid: string) => { +export const useGetChildAccountQuery = ( + euuid: string, +): UseQueryResult => { return useQuery({ ...delegationQueries.delegatedChildAccount(euuid), }); @@ -163,7 +185,11 @@ export const useGetChildAccountQuery = (euuid: string) => { * - Audience: Clients that need temporary auth to perform actions in the child account. * - Data: Token for `POST /iam/delegation/child-accounts/:euuid/token`. */ -export const useGenerateChildAccountTokenQuery = () => { +export const useGenerateChildAccountTokenQuery = (): UseMutationResult< + Token, + APIError[], + { euuid: string } +> => { return useMutation({ mutationFn: generateChildAccountToken, }); @@ -176,7 +202,10 @@ export const useGenerateChildAccountTokenQuery = () => { * - Audience: Child account administrators reviewing default delegate access. * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`. */ -export const useGetDefaultDelegationAccessQuery = () => { +export const useGetDefaultDelegationAccessQuery = (): UseQueryResult< + IamUserRoles, + APIError[] +> => { return useQuery({ ...delegationQueries.defaultAccess, }); @@ -189,7 +218,11 @@ export const useGetDefaultDelegationAccessQuery = () => { * - Audience: Child account administrators configuring default delegate access. * - Data: Request/Response IamUserRoles for `PUT /iam/delegation/default-role-permissions`. */ -export const useUpdateDefaultDelegationAccessQuery = () => { +export const useUpdateDefaultDelegationAccessQuery = (): UseMutationResult< + IamUserRoles, + APIError[], + IamUserRoles +> => { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateDefaultDelegationAccess, diff --git a/packages/queries/src/iam/index.ts b/packages/queries/src/iam/index.ts index cb812bcecee..8af2cd08614 100644 --- a/packages/queries/src/iam/index.ts +++ b/packages/queries/src/iam/index.ts @@ -1,2 +1,3 @@ +export * from './delegation'; export * from './iam'; export * from './keys'; diff --git a/packages/utilities/src/factories/delegation.ts b/packages/utilities/src/factories/delegation.ts new file mode 100644 index 00000000000..d57349eac7e --- /dev/null +++ b/packages/utilities/src/factories/delegation.ts @@ -0,0 +1,89 @@ +import { Factory } from './factoryProxy'; + +import type { + Account, + ChildAccount, + ChildAccountWithDelegates, + IamUserRoles, +} from '@linode/api-v4'; + +export const mockDelegateUsersList = [ + 'John Doe', + 'Jill Smith', + 'Jack Black', + 'Barbara White', + 'Tom Brown', + 'Sam Davis', + 'Alice Wilson', + 'Bob Taylor', + 'Charlie Moore', + 'Diana Harris', + 'Ethan Clark', + 'Fiona Scott', + 'George Green', + 'Hannah Brown', + 'Isaac Lee', + 'Julia Davis', + 'Kevin Wilson', + 'Linda Moore', + 'Michael Harris', + 'Nancy Taylor', + 'Oliver Clark', + 'Patricia Scott', + 'Quincy Green', +]; + +export const childAccountFactory = Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), +}); + +export const childAccountWithDelegatesFactory = + Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), + users: [], + }); + +export const delegatedChildAccountsForUserFactory = + Factory.Sync.makeFactory({ + company: Factory.each((i) => `child-account-${i}`), + euuid: Factory.each(() => window.crypto.randomUUID()), + }); + +export const childAccountDelegatesFactory = Factory.Sync.makeFactory( + [], +); + +export const myDelegatedChildAccountsFactory = + Factory.Sync.makeFactory({ + euuid: Factory.each(() => window.crypto.randomUUID()), + active_promotions: [], + active_since: '', + address_1: '', + address_2: '', + balance: 0, + balance_uninvoiced: 0, + billing_source: 'linode', + capabilities: [], + city: '', + company: 'Parent Account Company', + country: '', + credit_card: { + expiry: '', + last_four: '', + }, + email: 'parent@acme.com', + first_name: 'Parent', + last_name: 'Account', + phone: '', + state: '', + tax_id: '', + zip: '', + }); + +export const delegateDefaultAccessFactory = + Factory.Sync.makeFactory({ + account_access: [], + entity_access: [], + }); diff --git a/packages/utilities/src/factories/index.ts b/packages/utilities/src/factories/index.ts index c4fb376173f..7faf7f76996 100644 --- a/packages/utilities/src/factories/index.ts +++ b/packages/utilities/src/factories/index.ts @@ -1,6 +1,7 @@ export * from './accountAvailability'; export * from './betas'; export * from './config'; +export * from './delegation'; export * from './factoryProxy'; export * from './grants'; export * from './linodeConfigInterface'; diff --git a/packages/utilities/src/helpers/random.ts b/packages/utilities/src/helpers/random.ts index c780a7d4bca..b263bdcf813 100644 --- a/packages/utilities/src/helpers/random.ts +++ b/packages/utilities/src/helpers/random.ts @@ -10,6 +10,17 @@ export const pickRandom = (items: T[]): T => { return items[Math.floor(Math.random() * items.length)]; }; +/** + * Similar to pickRandom, but picks multiple items from an array + * @param items { T[] } an array of any kind + * @param count { number } the number of items to pick + * @returns {T[]} an array of the given type + */ +export const pickRandomMultiple = (items: T[], count: number): T[] => { + // eslint-disable-next-line sonarjs/pseudo-random + return items.sort(() => Math.random() - 0.5).slice(0, count); +}; + /** * Generates a random date between two dates * @param start {Date} the start date From f3ba05161482358a95ebefa8b00bd69b52954c56 Mon Sep 17 00:00:00 2001 From: Joseph Cardillo Date: Fri, 26 Sep 2025 10:42:32 -0400 Subject: [PATCH 26/54] feat: [OCA-1580] - Adds September 2025 Marketplace apps (#12907) * add jaeger, cribl, and wireguard (client & server), to oneClickApps.ts * update isNew for new apps * Added changeset: Split WireGuard into separate server and client apps; add Jaeger and Cribl Marketplace apps --- .../pr-12907-added-1758807632176.md | 5 ++ packages/manager/public/assets/cribl.svg | 1 + packages/manager/public/assets/jaeger.svg | 90 +++++++++++++++++++ .../manager/public/assets/white/cribl.svg | 1 + .../manager/public/assets/white/jaeger.svg | 79 ++++++++++++++++ .../src/features/OneClickApps/oneClickApps.ts | 78 +++++++++++++--- 6 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-12907-added-1758807632176.md create mode 100644 packages/manager/public/assets/cribl.svg create mode 100644 packages/manager/public/assets/jaeger.svg create mode 100644 packages/manager/public/assets/white/cribl.svg create mode 100644 packages/manager/public/assets/white/jaeger.svg diff --git a/packages/manager/.changeset/pr-12907-added-1758807632176.md b/packages/manager/.changeset/pr-12907-added-1758807632176.md new file mode 100644 index 00000000000..0133f0b9131 --- /dev/null +++ b/packages/manager/.changeset/pr-12907-added-1758807632176.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Split WireGuard into separate server and client apps; add Jaeger and Cribl Marketplace apps ([#12907](https://github.com/linode/manager/pull/12907)) diff --git a/packages/manager/public/assets/cribl.svg b/packages/manager/public/assets/cribl.svg new file mode 100644 index 00000000000..7f1999c6f4f --- /dev/null +++ b/packages/manager/public/assets/cribl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/jaeger.svg b/packages/manager/public/assets/jaeger.svg new file mode 100644 index 00000000000..5afce684c30 --- /dev/null +++ b/packages/manager/public/assets/jaeger.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/cribl.svg b/packages/manager/public/assets/white/cribl.svg new file mode 100644 index 00000000000..1024e1ae3ec --- /dev/null +++ b/packages/manager/public/assets/white/cribl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/jaeger.svg b/packages/manager/public/assets/white/jaeger.svg new file mode 100644 index 00000000000..aa15ba8a690 --- /dev/null +++ b/packages/manager/public/assets/white/jaeger.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 085e1b05143..604338a6e3b 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -86,23 +86,22 @@ export const oneClickApps: Record = { summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, }, 401706: { - alt_description: 'Virtual private network.', - alt_name: 'Free VPN', + alt_description: 'Virtual private network server.', + alt_name: 'Free VPN Server', categories: ['Security'], colors: { end: '51171a', start: '88171a', }, - description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up - standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + description: `Deploy a WireGuard® server to create a central VPN hub for secure network connections. This server automatically configures WireGuard with sensible defaults, sets up NAT for full-tunnel capability, and implements security best practices. The server acts as a central point where multiple WireGuard clients can connect by adding their public keys to the server's configuration. WireGuard uses state-of-the-art cryptography and is designed to be faster and more secure than traditional VPN protocols. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, logo_url: 'wireguard.svg', related_guides: [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', - title: 'Deploy WireGuard through the Linode Marketplace', + title: 'Deploy WireGuard Server through the Linode Marketplace', }, ], - summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, + summary: `Modern VPN server which acts as a central hub for secure client connections using state-of-the-art cryptography.`, website: 'https://www.wireguard.com/', }, 401707: { @@ -1832,7 +1831,7 @@ export const oneClickApps: Record = { start: '85A355', }, description: `Distributed, masterless, replicating NoSQL database cluster.`, - isNew: true, + isNew: false, logo_url: 'apachecassandra.svg', related_guides: [ { @@ -1892,7 +1891,7 @@ export const oneClickApps: Record = { start: 'AAAAAA', }, description: `High performance, BSD license key/value database.`, - isNew: true, + isNew: false, logo_url: 'valkey.svg', related_guides: [ { @@ -1912,7 +1911,7 @@ export const oneClickApps: Record = { start: 'FFBA01', }, description: `OSI approved open source secrets platform.`, - isNew: true, + isNew: false, logo_url: 'openbao.svg', related_guides: [ { @@ -1932,7 +1931,7 @@ export const oneClickApps: Record = { start: '9D29FB', }, description: `Time series database supporting native query and visualization.`, - isNew: true, + isNew: false, logo_url: 'influxdb.svg', related_guides: [ { @@ -2094,4 +2093,63 @@ export const oneClickApps: Record = { summary: 'Leading graph database for connected data applications.', website: 'https://neo4j.com/', }, + 1914317: { + alt_description: 'Virtual private network client.', + alt_name: 'Free VPN Client', + categories: ['Security'], + colors: { + end: '51171a', + start: '88171a', + }, + description: `Deploy a WireGuard® client to securely connect your Linode to a remote WireGuard server for private networking, tunneling, or secure remote access. This client automatically configures the WireGuard connection using the server's public key and endpoint information you provide. The client is ideal for creating secure point-to-point connections, accessing private networks through a VPN tunnel, or routing traffic through a central WireGuard server. WireGuard uses state-of-the-art cryptography and is designed to be faster and more secure than traditional VPN protocols. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + logo_url: 'wireguard.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', + title: 'Deploy WireGuard Client through the Linode Marketplace', + }, + ], + summary: `Modern VPN client that connects to a remote WireGuard server for secure network access using state-of-the-art cryptography.`, + website: 'https://www.wireguard.com/', + }, + 1902903: { + alt_description: 'Observability pipeline for data management.', + alt_name: 'Telemetry data routing and optimization', + categories: ['Development'], + colors: { + end: '04cccc', + start: 'ffffff', + }, + description: `Cribl Stream is an observability pipeline that helps organizations collect, reduce, enrich, and route telemetry data in real-time. It connects with 80+ sources and destinations, enabling you to handle data from any source to any analytics tool. Cribl Stream helps reduce data volume and optimize log processing to cut costs, enhance data security with encryption and access controls, and transform data using AI-powered tools. The platform scales from small to enterprise-level deployments and acts as a universal data management layer, giving organizations more control and efficiency in handling their telemetry data across various systems.`, + isNew: true, + logo_url: 'cribl.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/cribl/', + title: 'Deploy Cribl through the Linode Marketplace', + }, + ], + summary: `Observability pipeline for collecting, reducing, enriching, and routing telemetry data in real-time across 80+ sources and destinations.`, + website: 'https://cribl.io/products/stream/', + }, + 1902904: { + alt_description: 'All-in-one distributed tracing platform.', + alt_name: 'Microservices tracing and observability', + categories: ['Development'], + colors: { + end: '68cfe3', + start: '648c19', + }, + description: `Jaeger all-in-one is a complete distributed tracing solution deployed as a single Docker container that includes the Jaeger UI, Collector, Query service, Agent, and in-memory storage. This integrated setup is designed for development, testing, and quick deployment scenarios where you need full tracing capabilities without complex distributed architecture. Jaeger helps developers track request flows across microservices, identify performance bottlenecks, analyze service dependencies, and troubleshoot errors in distributed applications. The all-in-one image supports various tracing protocols including Zipkin and Jaeger's own formats, making it ideal for getting started with distributed tracing.`, + isNew: true, + logo_url: 'jaeger.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/jaeger/', + title: 'Deploy Jaeger through the Linode Marketplace', + }, + ], + summary: `All-in-one distributed tracing platform with integrated UI, collector, and storage for monitoring microservices.`, + website: 'https://www.jaegertracing.io/', + }, }; From 416b5b40df7da47d7fa0735373db775c8ccb93b8 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:40:17 -0700 Subject: [PATCH 27/54] change: [M3-10242] - Move LKE endpoints from v4 to v4beta endpoints to allow restricted billing user access (#12867) * Remove duplicate endpoints and update all to BETA_API_ROOT * Update kubernetes queries and references * Fix my queryFns * Add changesets * Fix more test failures * Remove useKubernetesBetaEndpoint hook now that it's obsolete * Fix incomplete query key reference in hook to fix tests * Improve changesets * Remove feature flag check from enterprise chip, rely on tier * Update postLAFeatureEnabled check to account for restricted billing * Remove LA feature check and rely on tier for versions list * Update comment and expected result for post-LA enablement * Fix test failing due to use of old versions endpoint * More fixes for tests accessing old versions endpoint * Remove duplicated mock function call * More clean up of the old versions query and my past mess * Make tiny fix for accidentally removed mock request; this passes locally --- .../pr-12867-changed-1758062421827.md | 5 + packages/api-v4/src/kubernetes/kubernetes.ts | 98 +++---------- .../pr-12867-tech-stories-1758062471882.md | 5 + .../restricted-user-details-pages.spec.ts | 9 +- .../e2e/core/kubernetes/lke-create.spec.ts | 43 +++--- .../kubernetes/lke-enterprise-create.spec.ts | 16 +-- .../kubernetes/lke-enterprise-read.spec.ts | 24 +++- .../core/kubernetes/lke-landing-page.spec.ts | 54 ++++--- .../core/kubernetes/lke-standard-read.spec.ts | 20 ++- .../core/kubernetes/lke-summary-page.spec.ts | 18 +-- .../e2e/core/kubernetes/lke-update.spec.ts | 106 +++++++++----- .../manager/cypress/support/intercepts/lke.ts | 24 ---- .../Clusters/StreamFormClusters.tsx | 1 - .../ClusterList/ClusterChips.test.tsx | 26 ---- .../Kubernetes/ClusterList/ClusterChips.tsx | 5 +- .../ClusterList/KubernetesClusterRow.tsx | 10 +- .../CreateCluster/CreateCluster.tsx | 24 +--- .../KubeClusterSpecs.tsx | 2 +- .../KubernetesClusterDetail.tsx | 7 +- .../UpgradeKubernetesVersionBanner.tsx | 8 +- .../KubernetesLanding/KubernetesLanding.tsx | 3 - .../Kubernetes/UpgradeVersionModal.tsx | 14 +- .../src/features/Kubernetes/kubeUtils.test.ts | 87 +----------- .../src/features/Kubernetes/kubeUtils.ts | 87 ++---------- .../Linodes/LinodeEntityDetailBody.tsx | 8 +- .../NodeBalancerSummary/SummaryPanel.tsx | 4 - .../features/Search/useClientSideSearch.ts | 4 +- .../SupportTicketProductSelectionFields.tsx | 3 - packages/manager/src/queries/kubernetes.ts | 133 ++++-------------- .../pr-12867-removed-1758062601387.md | 6 + 30 files changed, 286 insertions(+), 568 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12867-changed-1758062421827.md create mode 100644 packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md create mode 100644 packages/queries/.changeset/pr-12867-removed-1758062601387.md diff --git a/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md b/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md new file mode 100644 index 00000000000..2a5694e7fde --- /dev/null +++ b/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +All kubernetes endpoints from `/v4` to `/v4beta`; clean up duplicate endpoints ([#12867](https://github.com/linode/manager/pull/12867)) diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 8b61b41caa3..18b49bd7fbc 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -1,6 +1,6 @@ import { createKubeClusterSchema } from '@linode/validation/lib/kubernetes.schema'; -import { API_ROOT, BETA_API_ROOT } from '../constants'; +import { BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -24,22 +24,9 @@ import type { /** * getKubernetesClusters * - * Gets a list of a user's Kubernetes clusters - */ -export const getKubernetesClusters = (params?: Params, filters?: Filter) => - Request>( - setMethod('GET'), - setParams(params), - setXFilter(filters), - setURL(`${API_ROOT}/lke/clusters`), - ); - -/** - * getKubernetesClustersBeta - * * Gets a list of a user's Kubernetes clusters from beta API */ -export const getKubernetesClustersBeta = (params?: Params, filters?: Filter) => +export const getKubernetesClusters = (params?: Params, filters?: Filter) => Request>( setMethod('GET'), setParams(params), @@ -50,49 +37,23 @@ export const getKubernetesClustersBeta = (params?: Params, filters?: Filter) => /** * getKubernetesCluster * - * Return details about a single Kubernetes cluster - */ -export const getKubernetesCluster = (clusterID: number) => - Request( - setMethod('GET'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), - ); - -/** - * getKubernetesClusterBeta - * * Return details about a single Kubernetes cluster from beta API */ -export const getKubernetesClusterBeta = (clusterID: number) => +export const getKubernetesCluster = (clusterID: number) => Request( setMethod('GET'), setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), ); /** - * createKubernetesClusters - * - * Create a new cluster. - */ -export const createKubernetesCluster = (data: CreateKubeClusterPayload) => { - return Request( - setMethod('POST'), - setURL(`${API_ROOT}/lke/clusters`), - setData(data, createKubeClusterSchema), - ); -}; - -/** - * createKubernetesClustersBeta + * createKubernetesCluster * * Create a new cluster with the beta API: * 1. When the feature flag for APL is enabled and APL is set to enabled in the UI * 2. When the LKE-E feature is enabled * - * duplicated function of createKubernetesCluster - * necessary to call BETA_API_ROOT in a separate function based on feature flag */ -export const createKubernetesClusterBeta = (data: CreateKubeClusterPayload) => { +export const createKubernetesCluster = (data: CreateKubeClusterPayload) => { return Request( setMethod('POST'), setURL(`${BETA_API_ROOT}/lke/clusters`), @@ -111,7 +72,7 @@ export const updateKubernetesCluster = ( ) => Request( setMethod('PUT'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), + setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), setData(data), ); @@ -123,7 +84,7 @@ export const updateKubernetesCluster = ( export const deleteKubernetesCluster = (clusterID: number) => Request<{}>( setMethod('DELETE'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), + setURL(`${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}`), ); /** @@ -138,7 +99,7 @@ export const getKubeConfig = (clusterId: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, ), ); @@ -153,7 +114,7 @@ export const resetKubeConfig = (clusterId: number) => Request<{}>( setMethod('DELETE'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterId)}/kubeconfig`, ), ); @@ -168,16 +129,16 @@ export const getKubernetesVersions = (params?: Params, filters?: Filter) => setMethod('GET'), setXFilter(filters), setParams(params), - setURL(`${API_ROOT}/lke/versions`), + setURL(`${BETA_API_ROOT}/lke/versions`), ); -/** getKubernetesTieredVersionsBeta +/** getKubernetesTieredVersions * * Returns a paginated list of available Kubernetes tiered versions from the beta API. * */ -export const getKubernetesTieredVersionsBeta = ( +export const getKubernetesTieredVersions = ( tier: string, params?: Params, filters?: Filter, @@ -198,19 +159,16 @@ export const getKubernetesTieredVersionsBeta = ( export const getKubernetesVersion = (versionID: string) => Request( setMethod('GET'), - setURL(`${API_ROOT}/lke/versions/${encodeURIComponent(versionID)}`), + setURL(`${BETA_API_ROOT}/lke/versions/${encodeURIComponent(versionID)}`), ); -/** getKubernetesTieredVersionBeta +/** getKubernetesTieredVersion * * Returns a single tiered Kubernetes version by ID from the beta API. * */ -export const getKubernetesTieredVersionBeta = ( - tier: string, - versionID: string, -) => +export const getKubernetesTieredVersion = (tier: string, versionID: string) => Request( setMethod('GET'), setURL( @@ -236,7 +194,7 @@ export const getKubernetesClusterEndpoints = ( setXFilter(filters), setParams(params), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/api-endpoints`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/api-endpoints`, ), ); @@ -249,7 +207,7 @@ export const getKubernetesClusterDashboard = (clusterID: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/dashboard`, + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/dashboard`, ), ); @@ -262,19 +220,9 @@ export const getKubernetesClusterDashboard = (clusterID: number) => export const recycleClusterNodes = (clusterID: number) => Request<{}>( setMethod('POST'), - setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`), - ); - -/** - * getKubernetesTypes - * - * Returns a paginated list of available Kubernetes types; used for dynamic pricing. - */ -export const getKubernetesTypes = (params?: Params) => - Request>( - setURL(`${API_ROOT}/lke/types`), - setMethod('GET'), - setParams(params), + setURL( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`, + ), ); /** @@ -282,7 +230,7 @@ export const getKubernetesTypes = (params?: Params) => * * Returns a paginated list of available Kubernetes types from beta API; used for dynamic pricing. */ -export const getKubernetesTypesBeta = (params?: Params) => +export const getKubernetesTypes = (params?: Params) => Request>( setURL(`${BETA_API_ROOT}/lke/types`), setMethod('GET'), @@ -298,7 +246,7 @@ export const getKubernetesClusterControlPlaneACL = (clusterId: number) => Request( setMethod('GET'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent( clusterId, )}/control_plane_acl`, ), @@ -316,7 +264,7 @@ export const updateKubernetesClusterControlPlaneACL = ( Request( setMethod('PUT'), setURL( - `${API_ROOT}/lke/clusters/${encodeURIComponent( + `${BETA_API_ROOT}/lke/clusters/${encodeURIComponent( clusterID, )}/control_plane_acl`, ), diff --git a/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md b/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md new file mode 100644 index 00000000000..70e2547f0d8 --- /dev/null +++ b/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up logic for toggling between kubernetes `/v4` and `/v4beta` endpoints ([#12867](https://github.com/linode/manager/pull/12867)) diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index 7e9c4c9e285..e3340d0b903 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -8,6 +8,7 @@ import { databaseConfigurations, mockDatabaseNodeTypes, } from 'support/constants/databases'; +import { mockTieredStandardVersions } from 'support/constants/lke'; import { mockGetUser } from 'support/intercepts/account'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { @@ -30,7 +31,7 @@ import { mockGetCluster, mockGetClusterPools, mockGetDashboardUrl, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockUpdateCluster, } from 'support/intercepts/lke'; @@ -419,8 +420,8 @@ describe('restricted user details pages', () => { it.skip("should disable action elements and buttons in the 'Kubernetes' details page", () => { // TODO: M3-9585 Not working for kubernets. Skip this test for now. - const oldVersion = '1.25'; - const newVersion = '1.26'; + const oldVersion = mockTieredStandardVersions[0].id; + const newVersion = mockTieredStandardVersions[1].id; const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -443,7 +444,7 @@ describe('restricted user details pages', () => { const mockNodePools = nodePoolFactory.buildList(2); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index b81ff900f00..d50b25e5d07 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -21,12 +21,10 @@ import { dedicatedNodeCount, dedicatedType, latestEnterpriseTierKubernetesVersion, - latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, mockTieredEnterpriseVersions, mockTieredStandardVersions, - mockTieredVersions, nanodeNodeCount, nanodeType, } from 'support/constants/lke'; @@ -43,7 +41,6 @@ import { mockGetClusters, mockGetControlPlaneACL, mockGetDashboardUrl, - mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; @@ -153,7 +150,7 @@ describe('LKE Cluster Creation', () => { exclude: ['au-mel', 'eu-west', 'id-cgk', 'br-gru'], }); const clusterLabel = randomLabel(); - const clusterVersion = '1.31'; + const clusterVersion = mockTieredStandardVersions[0].id; const mockedLKECluster = kubernetesClusterFactory.build({ label: clusterLabel, region: clusterRegion.id, @@ -184,7 +181,9 @@ describe('LKE Cluster Creation', () => { mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEClusterPrices).as('getLKEClusterTypes'); mockGetClusters([mockedLKECluster]).as('getClusters'); - mockGetKubernetesVersions([clusterVersion]).as('getKubernetesVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions).as( + 'getKubernetesVersions' + ); cy.visitWithLogin('/kubernetes/clusters'); @@ -662,7 +661,10 @@ describe('LKE Cluster Creation with ACL', () => { describe('with LKE IPACL account capability', () => { beforeEach(() => { - mockGetKubernetesVersions([clusterVersion]).as('getLKEVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getLKEVersions'); mockGetRegions([mockRegion]).as('getRegions'); mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); mockGetRegionAvailability(mockRegion.id, []).as('getRegionAvailability'); @@ -935,10 +937,12 @@ describe('LKE Cluster Creation with ACL', () => { ).as('getAccount'); mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); + ]).as('getTieredEnterpriseVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getTieredStandardVersions'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( 'getLKEEnterpriseClusterTypes' @@ -969,7 +973,7 @@ describe('LKE Cluster Creation with ACL', () => { .click(); cy.url().should('endWith', '/kubernetes/create'); - cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); + cy.wait(['@getTieredStandardVersions', '@getTieredEnterpriseVersions']); // Select enterprise tier. cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) @@ -1094,7 +1098,6 @@ describe('LKE Cluster Creation with ACL', () => { '@getCluster', '@getClusterPools', '@createCluster', - '@getLKEEnterpriseClusterTypes', '@getLinodeTypes', '@getApiEndpoints', '@getControlPlaneACL', @@ -1321,10 +1324,12 @@ describe('LKE Cluster Creation with LKE-E', () => { ).as('getAccount'); mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' - ); + ]).as('getEnterpriseTieredVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getStandardTieredVersions'); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( 'getLKEEnterpriseClusterTypes' @@ -1361,8 +1366,8 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.url().should('endWith', '/kubernetes/create'); cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', + '@getEnterpriseTieredVersions', + '@getStandardTieredVersions', '@getLinodeTypes', ]); @@ -1535,7 +1540,6 @@ describe('LKE Cluster Creation with LKE-E', () => { '@getCluster', '@getClusterPools', '@createCluster', - '@getLKEEnterpriseClusterTypes', '@getApiEndpoints', '@getControlPlaneACL', ]); @@ -1633,7 +1637,6 @@ describe('LKE cluster creation with LKE-E Post-LA', () => { capabilities: ['Kubernetes Enterprise'], }) ); - mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index f4a4737a056..32d93f7b6e7 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -6,12 +6,10 @@ import { linodeTypeFactory, regionFactory } from '@linode/utilities'; import { clusterPlans, latestEnterpriseTierKubernetesVersion, - latestKubernetesVersion, mockedLKEClusterTypes, mockedLKEEnterprisePrices, mockTieredEnterpriseVersions, mockTieredStandardVersions, - mockTieredVersions, } from 'support/constants/lke'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; @@ -21,7 +19,6 @@ import { mockCreateCluster, mockCreateClusterError, mockGetCluster, - mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; @@ -106,9 +103,9 @@ describe('LKE Cluster Creation with LKE-E', () => { mockGetTieredKubernetesVersions('enterprise', [ latestEnterpriseTierKubernetesVersion, - ]).as('getTieredKubernetesVersions'); - mockGetKubernetesVersions([latestKubernetesVersion]).as( - 'getKubernetesVersions' + ]).as('getEnterpriseTieredVersions'); + mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions).as( + 'getStandardTieredVersions' ); mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); @@ -127,11 +124,7 @@ describe('LKE Cluster Creation with LKE-E', () => { ui.button.findByTitle('Create Cluster').click(); cy.url().should('endWith', '/kubernetes/create'); - cy.wait([ - '@getKubernetesVersions', - '@getTieredKubernetesVersions', - '@getLinodeTypes', - ]); + cy.wait(['@getLinodeTypes']); }); describe('LKE-E Phase 2 Networking Configurations', () => { @@ -499,7 +492,6 @@ describe('LKE Enterprise cluster creation with LKE-E Post-LA', () => { capabilities: ['Kubernetes Enterprise'], }) ); - mockGetKubernetesVersions(mockTieredVersions.map((version) => version.id)); mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions); mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts index a331de55b59..9607636fb5a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-read.spec.ts @@ -16,7 +16,7 @@ import { import { mockGetCluster, mockGetClusterPools, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetVPC } from 'support/intercepts/vpc'; @@ -138,7 +138,7 @@ describe('LKE-E Cluster Summary - VPC Section', () => { */ it('shows linked VPC in summary for cluster with a VPC', () => { mockGetCluster(mockClusterWithVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithVPC.id, []).as('getNodePools'); mockGetVPC(mockVPC).as('getVPC'); mockGetProfile(mockProfile).as('getProfile'); @@ -147,7 +147,7 @@ describe('LKE-E Cluster Summary - VPC Section', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getVPC', '@getProfile', ]); @@ -174,14 +174,19 @@ describe('LKE-E Cluster Summary - VPC Section', () => { */ it('does not show linked VPC in summary when cluster does not specify a VPC', () => { mockGetCluster(mockClusterWithoutVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithoutVPC.id, []).as('getNodePools'); mockGetProfile(mockProfile).as('getProfile'); cy.visitWithLogin( `/kubernetes/clusters/${mockClusterWithoutVPC.id}/summary` ); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm that no VPC label or link is shown in the summary section cy.get('[data-qa-kube-entity-footer]').within(() => { @@ -218,7 +223,7 @@ describe('LKE-E Node Pools', () => { ).as('getAccount'); mockGetCluster(mockClusterWithVPC).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetClusterPools(mockClusterWithVPC.id, mockNodePools).as( 'getNodePools' ); @@ -229,7 +234,12 @@ describe('LKE-E Node Pools', () => { ); cy.visitWithLogin(`/kubernetes/clusters/${mockClusterWithVPC.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm VPC IP columns are present in the table header cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index 97b14b8b6f5..f4ec7b7c5cb 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -6,7 +6,6 @@ import { mockGetClusterPools, mockGetClusters, mockGetKubeconfig, - mockGetKubernetesVersions, mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockUpdateCluster, @@ -19,6 +18,8 @@ import { getRegionById } from 'support/util/regions'; import { accountFactory, kubernetesClusterFactory, + kubernetesEnterpriseTierVersionFactory, + kubernetesStandardTierVersionFactory, nodePoolFactory, } from 'src/factories'; @@ -187,19 +188,22 @@ describe('LKE landing page', () => { }); it('does not show an Upgrade chip when there is no new kubernetes standard version', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockStandardTierVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const newVersion = mockStandardTierVersions[1].id; const cluster = kubernetesClusterFactory.build({ k8s_version: newVersion, }); mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockStandardTierVersions).as( + 'getTieredVersions' + ); cy.visitWithLogin(`/kubernetes/clusters`); - cy.wait(['@getClusters', '@getVersions']); + cy.wait(['@getClusters', '@getTieredVersions']); cy.findByText(newVersion).should('be.visible'); @@ -207,8 +211,9 @@ describe('LKE landing page', () => { }); it('does not show an Upgrade chip when there is no new kubernetes enterprise version', () => { - const oldVersion = '1.31.1+lke1'; - const newVersion = '1.32.1+lke2'; + const mockEnterpriseTierVersions = + kubernetesEnterpriseTierVersionFactory.buildList(2); + const newVersion = mockEnterpriseTierVersions[1].id; mockGetAccount( accountFactory.build({ @@ -227,10 +232,10 @@ describe('LKE landing page', () => { }); mockGetClusters([cluster]).as('getClusters'); - mockGetTieredKubernetesVersions('enterprise', [ - { id: newVersion, tier: 'enterprise' }, - { id: oldVersion, tier: 'enterprise' }, - ]).as('getTieredVersions'); + mockGetTieredKubernetesVersions( + 'enterprise', + mockEnterpriseTierVersions + ).as('getTieredVersions'); cy.visitWithLogin(`/kubernetes/clusters`); @@ -242,8 +247,10 @@ describe('LKE landing page', () => { }); it('can upgrade the standard kubernetes version from the landing page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockStandardTierVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const oldVersion = mockStandardTierVersions[0].id; + const newVersion = mockStandardTierVersions[1].id; const cluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -254,13 +261,15 @@ describe('LKE landing page', () => { mockGetCluster(cluster).as('getCluster'); mockGetClusters([cluster]).as('getClusters'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions('standard', mockStandardTierVersions).as( + 'getTieredVersions' + ); mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); cy.visitWithLogin(`/kubernetes/clusters`); - cy.wait(['@getClusters', '@getVersions']); + cy.wait(['@getClusters', '@getTieredVersions']); cy.findByText(oldVersion).should('be.visible'); @@ -300,8 +309,11 @@ describe('LKE landing page', () => { }); it('can upgrade the enterprise kubernetes version from the landing page', () => { - const oldVersion = '1.31.1+lke1'; - const newVersion = '1.32.1+lke2'; + const mockEnterpriseTierVersions = + kubernetesEnterpriseTierVersionFactory.buildList(2); + const oldVersion = mockEnterpriseTierVersions[0].id; + + const newVersion = mockEnterpriseTierVersions[1].id; mockGetAccount( accountFactory.build({ @@ -323,10 +335,10 @@ describe('LKE landing page', () => { mockGetCluster(cluster).as('getCluster'); mockGetClusters([cluster]).as('getClusters'); - mockGetTieredKubernetesVersions('enterprise', [ - { id: newVersion, tier: 'enterprise' }, - { id: oldVersion, tier: 'enterprise' }, - ]).as('getTieredVersions'); + mockGetTieredKubernetesVersions( + 'enterprise', + mockEnterpriseTierVersions + ).as('getTieredVersions'); mockUpdateCluster(cluster.id, updatedCluster).as('updateCluster'); mockRecycleAllNodes(cluster.id).as('recycleAllNodes'); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts index 0791247057c..af91ff19658 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-standard-read.spec.ts @@ -9,7 +9,7 @@ import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetCluster, mockGetClusterPools, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, } from 'support/intercepts/lke'; import { mockGetProfile } from 'support/intercepts/profile'; @@ -65,12 +65,17 @@ describe('LKE Cluster Summary', () => { ).as('getAccount'); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, []).as('getNodePools'); mockGetProfile(mockProfile).as('getProfile'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm that no VPC label or link is shown in the summary section cy.get('[data-qa-kube-entity-footer]').within(() => { @@ -104,12 +109,17 @@ describe('LKE Node Pools', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetProfile(profileFactory.build()).as('getProfile'); mockGetLinodes(mockLinodes).as('getLinodes'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions', '@getProfile']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getTieredVersions', + '@getProfile', + ]); // Confirm VPC IP columns are not present in the node table cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => { diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts index a840a3f6272..f57449815fb 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts @@ -7,7 +7,7 @@ import { mockGetControlPlaneACL, mockGetDashboardUrl, mockGetKubeconfig, - mockGetKubernetesVersions, + mockGetTieredKubernetesVersions, mockUpdateCluster, } from 'support/intercepts/lke'; import { ui } from 'support/ui'; @@ -99,7 +99,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -110,7 +110,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -162,7 +162,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -173,7 +173,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -232,7 +232,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -243,7 +243,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); @@ -287,7 +287,7 @@ describe('LKE summary page', () => { }; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockGetApiEndpoints(mockCluster.id).as('getApiEndpoints'); mockGetDashboardUrl(mockCluster.id).as('getDashboardUrl'); @@ -298,7 +298,7 @@ describe('LKE summary page', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getApiEndpoints', '@getDashboardUrl', ]); 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 3720db12603..4e6223739e5 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -23,7 +23,6 @@ import { mockGetControlPlaneACL, mockGetControlPlaneACLError, mockGetDashboardUrl, - mockGetKubernetesVersions, mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockRecycleNode, @@ -48,6 +47,7 @@ import { kubernetesClusterFactory, kubernetesControlPlaneACLFactory, kubernetesControlPlaneACLOptionsFactory, + kubernetesStandardTierVersionFactory, nodePoolFactory, } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; @@ -97,13 +97,13 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockUpdateCluster(mockCluster.id, mockClusterWithHA).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Initiate high availability upgrade and agree to changes. ui.button @@ -145,8 +145,10 @@ describe('LKE cluster updates', () => { * - Confirms that Kubernetes upgrade prompt is hidden when up-to-date. */ it('can upgrade standard kubernetes version from the details page', () => { - const oldVersion = '1.25'; - const newVersion = '1.26'; + const mockTieredStandardVersions = + kubernetesStandardTierVersionFactory.buildList(2); + const oldVersion = mockTieredStandardVersions[0].id; + const newVersion = mockTieredStandardVersions[1].id; const mockCluster = kubernetesClusterFactory.build({ k8s_version: oldVersion, @@ -158,7 +160,7 @@ describe('LKE cluster updates', () => { k8s_version: newVersion, }; - const upgradePrompt = 'A new version of Kubernetes is available (1.26).'; + const upgradePrompt = `A new version of Kubernetes is available (${newVersion}).`; const upgradeNotes = [ 'This upgrades the control plane on your cluster', @@ -169,14 +171,17 @@ describe('LKE cluster updates', () => { ]; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions([newVersion, oldVersion]).as('getVersions'); + mockGetTieredKubernetesVersions( + 'standard', + mockTieredStandardVersions + ).as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockClusterUpdated).as('updateCluster'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Confirm that upgrade prompt is shown. cy.findByText(upgradePrompt).should('be.visible'); @@ -387,12 +392,17 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); mockGetLinodes([mockLinode]).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getTieredVersions', + ]); // Recycle individual node. ui.button @@ -525,7 +535,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); mockGetAccount( @@ -539,7 +549,12 @@ describe('LKE cluster updates', () => { }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getAccount', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Click "Autoscale Pool", enable autoscaling, and set min and max values. mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( @@ -677,7 +692,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('enterprise').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); mockGetAccount( @@ -691,7 +706,12 @@ describe('LKE cluster updates', () => { }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getAccount', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getAccount', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Click "Autoscale Pool", enable autoscaling, and set min and max values. mockUpdateNodePool(mockCluster.id, mockNodePoolAutoscale).as( @@ -801,12 +821,17 @@ describe('LKE cluster updates', () => { 'getNodePools' ); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getLinodes', '@getVersions']); + cy.wait([ + '@getCluster', + '@getNodePools', + '@getLinodes', + '@getTieredVersions', + ]); // Confirm that nodes are listed with correct details. mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => { @@ -945,7 +970,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockResetKubeconfig(mockCluster.id).as('resetKubeconfig'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -957,7 +982,7 @@ describe('LKE cluster updates', () => { ]; cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // Click "Reset" button, proceed through confirmation dialog. cy.findByText('Reset').should('be.visible').click({ force: true }); @@ -1088,7 +1113,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockDeleteNodePool(mockCluster.id, mockNewNodePool.id).as( 'deleteNodePool' @@ -1097,7 +1122,12 @@ describe('LKE cluster updates', () => { mockGetApiEndpoints(mockCluster.id); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); - cy.wait(['@getRegions', '@getCluster', '@getNodePools', '@getVersions']); + cy.wait([ + '@getRegions', + '@getCluster', + '@getNodePools', + '@getTieredVersions', + ]); // Assert that initial node pool is shown on the page. cy.findByText('Dedicated 8 GB', { selector: 'h3' }).should('be.visible'); @@ -1210,12 +1240,12 @@ describe('LKE cluster updates', () => { }); mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateCluster(mockCluster.id, mockNewCluster).as('updateCluster'); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // LKE clusters can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); @@ -1246,14 +1276,14 @@ describe('LKE cluster updates', () => { const mockErrorMessage = 'API request fails'; mockGetCluster(mockCluster).as('getCluster'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools'); mockUpdateClusterError(mockCluster.id, mockErrorMessage).as( 'updateClusterError' ); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`); - cy.wait(['@getCluster', '@getNodePools', '@getVersions']); + cy.wait(['@getCluster', '@getNodePools', '@getTieredVersions']); // LKE cluster can be renamed by clicking on the cluster's name in the breadcrumbs towards the top of the page. cy.get('[data-testid="editable-text"] > [data-testid="button"]').click(); @@ -1308,7 +1338,7 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolNoTags]).as( 'getNodePoolsNoTags' ); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( 'getControlPlaneAcl' ); @@ -1320,7 +1350,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePoolsNoTags', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1425,7 +1455,7 @@ describe('LKE cluster updates', () => { mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as( 'getNodePools' ); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetControlPlaneACL(mockCluster.id, { acl: { enabled: false } }).as( 'getControlPlaneAcl' ); @@ -1446,7 +1476,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1589,7 +1619,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -1771,7 +1801,7 @@ describe('LKE cluster updates', () => { cy.wait([ '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getType', '@getControlPlaneAcl', ]); @@ -2238,7 +2268,7 @@ describe('LKE cluster updates', () => { ); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -2248,7 +2278,7 @@ describe('LKE cluster updates', () => { '@getCluster', '@getNodePools', '@getLinodes', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2378,7 +2408,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); @@ -2390,7 +2420,7 @@ describe('LKE cluster updates', () => { '@getRegions', '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2506,7 +2536,7 @@ describe('LKE cluster updates', () => { ); mockGetLinodes(mockLinodes).as('getLinodes'); mockGetLinodeType(mockPlanType).as('getLinodeType'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockGetDashboardUrl(mockCluster.id); mockGetApiEndpoints(mockCluster.id); @@ -2516,7 +2546,7 @@ describe('LKE cluster updates', () => { '@getCluster', '@getNodePools', '@getLinodes', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); @@ -2637,7 +2667,7 @@ describe('LKE cluster updates', () => { mockGetCluster(mockCluster).as('getCluster'); mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools'); - mockGetKubernetesVersions().as('getVersions'); + mockGetTieredKubernetesVersions('standard').as('getTieredVersions'); mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool'); mockGetLinodeType(mockPlanType).as('getLinodeType'); mockGetLinodeTypes(dcPricingMockLinodeTypes); @@ -2649,7 +2679,7 @@ describe('LKE cluster updates', () => { '@getRegions', '@getCluster', '@getNodePools', - '@getVersions', + '@getTieredVersions', '@getLinodeType', ]); diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 3890968e67d..4c2b6935e9a 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -7,7 +7,6 @@ import { kubernetesDashboardUrlFactory, } from '@src/factories'; import { - kubernetesVersions, latestEnterpriseTierKubernetesVersion, latestStandardTierKubernetesVersion, } from 'support/constants/lke'; @@ -24,32 +23,9 @@ import type { KubernetesControlPlaneACLPayload, KubernetesTier, KubernetesTieredVersion, - KubernetesVersion, PriceType, } from '@linode/api-v4'; -// TODO M3-10442: Examine `mockGetKubernetesVersions` and consider modifying/adding alternative util that mocks response containing tiered version objects. -/** - * Intercepts GET request to retrieve Kubernetes versions and mocks response. - * - * @param versions - Optional array of strings containing mocked versions. - * - * @returns Cypress chainable. - */ -export const mockGetKubernetesVersions = (versions?: string[] | undefined) => { - const versionObjects = (versions ? versions : kubernetesVersions).map( - (kubernetesVersionString: string): KubernetesVersion => { - return { id: kubernetesVersionString }; - } - ); - - return cy.intercept( - 'GET', - apiMatcher('lke/versions*'), - paginateResponse(versionObjects) - ); -}; - /** * Intercepts GET request to retrieve tiered Kubernetes versions and mocks response. * diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index 405d37b32e0..bb4ac9a2d11 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -55,7 +55,6 @@ export const StreamFormClusters = () => { error, } = useKubernetesClustersQuery({ filter, - isUsingBetaEndpoint: true, params: { page, page_size: pageSize, diff --git a/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.test.tsx b/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.test.tsx index bbfb210e005..5df2c8d38c0 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.test.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.test.tsx @@ -73,32 +73,6 @@ describe('Kubernetes cluster action menu', () => { expect(getByText('ENTERPRISE')).toBeVisible(); }); - it('does not render an enterprise chip for an enterprise cluster if the feature is disabled', async () => { - queryMocks.useAccount.mockReturnValue({ - data: { - capabilities: ['Kubernetes Enterprise'], - }, - }); - - const { getByText, queryByText } = renderWithTheme( - , - { - flags: { - lkeEnterprise2: { - enabled: false, - ga: false, - la: true, - phase2Mtc: { byoVPC: false, dualStack: false }, - postLa: false, - }, - }, - } - ); - - expect(getByText('HA', { exact: false })).toBeVisible(); - expect(queryByText('ENTERPRISE')).toBe(null); - }); - it('does not render an enterprise chip for a standard cluster', async () => { queryMocks.useAccount.mockReturnValue({ data: { diff --git a/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.tsx b/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.tsx index 1db03d6f60d..4db9b53b148 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/ClusterChips.tsx @@ -2,8 +2,6 @@ import { Chip, Stack } from '@linode/ui'; import { useLocation } from '@tanstack/react-router'; import React from 'react'; -import { useIsLkeEnterpriseEnabled } from '../kubeUtils'; - import type { KubernetesCluster } from '@linode/api-v4'; import type { SxProps, Theme } from '@mui/material'; @@ -14,13 +12,12 @@ interface Props { export const ClusterChips = (props: Props) => { const { cluster, sx } = props; - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); const location = useLocation({ select: (location) => location.pathname }); return ( - {isLkeEnterpriseLAFeatureEnabled && cluster?.tier === 'enterprise' && ( + {cluster?.tier === 'enterprise' && ( { const region = regions?.find((r) => r.id === cluster.region); - const { versions } = useLkeStandardOrEnterpriseVersions( - cluster.tier ?? 'standard' // TODO LKE: remove fallback once LKE-E is in GA and tier is required + const { data: versions } = useKubernetesTieredVersionsQuery( + cluster?.tier ?? 'standard' ); const isLKEClusterReadOnly = useIsResourceRestricted({ diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index c4d29857c01..f48942e037b 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -38,14 +38,12 @@ import { getLatestVersion, useAPLAvailability, useIsLkeEnterpriseEnabled, - useKubernetesBetaEndpoint, - useLkeStandardOrEnterpriseVersions, } from 'src/features/Kubernetes/kubeUtils'; import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { - useCreateKubernetesClusterBetaMutation, useCreateKubernetesClusterMutation, + useKubernetesTieredVersionsQuery, useKubernetesTypesQuery, } from 'src/queries/kubernetes'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; @@ -126,7 +124,6 @@ export const CreateCluster = () => { const { data, error: regionsError } = useRegionsQuery(); const regionsData = data ?? []; const { showAPL } = useAPLAvailability(); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const [ipV4Addr, setIPv4Addr] = React.useState([ stringToExtendedIP(''), ]); @@ -174,7 +171,7 @@ export const CreateCluster = () => { data: kubernetesHighAvailabilityTypesData, isError: isErrorKubernetesTypes, isLoading: isLoadingKubernetesTypes, - } = useKubernetesTypesQuery(selectedTier === 'enterprise'); + } = useKubernetesTypesQuery(); // LKE-E does not support APL at this time. const isAPLSupported = showAPL && selectedTier === 'standard'; @@ -240,14 +237,11 @@ export const CreateCluster = () => { const { mutateAsync: createKubernetesCluster } = useCreateKubernetesClusterMutation(); - const { mutateAsync: createKubernetesClusterBeta } = - useCreateKubernetesClusterBetaMutation(); - const { - isLoadingVersions, - versions: versionData, - versionsError, - } = useLkeStandardOrEnterpriseVersions(selectedTier); + data: versionData, + isLoading: isLoadingVersions, + error: versionsError, + } = useKubernetesTieredVersionsQuery(selectedTier); const versions = (versionData ?? []).map((thisVersion) => ({ label: thisVersion.id, @@ -349,10 +343,6 @@ export const CreateCluster = () => { }; } - const createClusterFn = isUsingBetaEndpoint - ? createKubernetesClusterBeta - : createKubernetesCluster; - // TODO: Improve error handling in M3-10429, at which point we shouldn't need this. if ( (isLkeEnterprisePostLAFeatureEnabled || @@ -384,7 +374,7 @@ export const CreateCluster = () => { } } - createClusterFn(payload) + createKubernetesCluster(payload) .then((cluster) => { navigate({ to: '/kubernetes/clusters/$clusterId/summary', diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index a16934dc5fb..e6aa6472671 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -67,7 +67,7 @@ export const KubeClusterSpecs = React.memo((props: Props) => { data: kubernetesHighAvailabilityTypesData, isError: isErrorKubernetesTypes, isLoading: isLoadingKubernetesTypes, - } = useKubernetesTypesQuery(cluster.tier === 'enterprise'); + } = useKubernetesTypesQuery(); const matchesColGapBreakpointDown = useMediaQuery( theme.breakpoints.down(theme.breakpoints.values.lg) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 286b79dfb09..43f5ed395bc 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -6,10 +6,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { - useAPLAvailability, - useKubernetesBetaEndpoint, -} from 'src/features/Kubernetes/kubeUtils'; +import { useAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { getKubeHighAvailability } from 'src/features/Kubernetes/kubeUtils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { @@ -31,7 +28,6 @@ export const KubernetesClusterDetail = () => { }); const location = useLocation(); const { showAPL } = useAPLAvailability(); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: cluster, @@ -39,7 +35,6 @@ export const KubernetesClusterDetail = () => { isLoading, } = useKubernetesClusterQuery({ id, - isUsingBetaEndpoint, }); const { mutateAsync: updateKubernetesCluster } = diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeKubernetesVersionBanner.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeKubernetesVersionBanner.tsx index dc244366905..6318e4e088d 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeKubernetesVersionBanner.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeKubernetesVersionBanner.tsx @@ -2,11 +2,9 @@ import { Button, Typography } from '@linode/ui'; import * as React from 'react'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { useKubernetesTieredVersionsQuery } from 'src/queries/kubernetes'; -import { - getNextVersion, - useLkeStandardOrEnterpriseVersions, -} from '../kubeUtils'; +import { getNextVersion } from '../kubeUtils'; import UpgradeVersionModal from '../UpgradeVersionModal'; import type { KubernetesTier } from '@linode/api-v4'; @@ -20,7 +18,7 @@ interface Props { export const UpgradeKubernetesVersionBanner = (props: Props) => { const { clusterID, clusterTier, currentVersion } = props; - const { versions } = useLkeStandardOrEnterpriseVersions(clusterTier); + const { data: versions } = useKubernetesTieredVersionsQuery(clusterTier); const nextVersion = getNextVersion(currentVersion, versions ?? []); const [dialogOpen, setDialogOpen] = React.useState(false); diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 3f54f63deb5..7480e68cc4e 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -28,7 +28,6 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { KubernetesClusterRow } from '../ClusterList/KubernetesClusterRow'; import { DeleteKubernetesClusterDialog } from '../KubernetesClusterDetail/DeleteKubernetesClusterDialog'; -import { useKubernetesBetaEndpoint } from '../kubeUtils'; import UpgradeVersionModal from '../UpgradeVersionModal'; import { KubernetesEmptyState } from './KubernetesLandingEmptyState'; @@ -95,10 +94,8 @@ export const KubernetesLanding = () => { globalGrantType: 'add_lkes', }); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data, error, isLoading } = useKubernetesClustersQuery({ filter, - isUsingBetaEndpoint, params: { page: pagination.page, page_size: pagination.pageSize, diff --git a/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx b/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx index 83a602b02bd..35e1b7d70ef 100644 --- a/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx +++ b/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx @@ -5,14 +5,11 @@ import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Link } from 'src/components/Link'; -import { - getNextVersion, - useKubernetesBetaEndpoint, - useLkeStandardOrEnterpriseVersions, -} from 'src/features/Kubernetes/kubeUtils'; +import { getNextVersion } from 'src/features/Kubernetes/kubeUtils'; import { useKubernetesClusterMutation, useKubernetesClusterQuery, + useKubernetesTieredVersionsQuery, } from 'src/queries/kubernetes'; import { LocalStorageWarningNotice } from './KubernetesClusterDetail/LocalStorageWarningNotice'; @@ -52,19 +49,16 @@ export const UpgradeDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); - const { data: cluster } = useKubernetesClusterQuery({ id: clusterID, - isUsingBetaEndpoint, }); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation(clusterID); - const { versions } = useLkeStandardOrEnterpriseVersions( + const { data: versions } = useKubernetesTieredVersionsQuery( cluster?.tier ?? 'standard' - ); // TODO LKE: remove fallback once LKE-E is in GA and tier is required + ); const nextVersion = getNextVersion( cluster?.k8s_version ?? '', diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 5c63de8b7ab..ae1512add65 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -1,12 +1,7 @@ import { accountBetaFactory, linodeTypeFactory } from '@linode/utilities'; import { renderHook } from '@testing-library/react'; -import { - kubeLinodeFactory, - kubernetesEnterpriseTierVersionFactory, - kubernetesVersionFactory, - nodePoolFactory, -} from 'src/factories'; +import { kubeLinodeFactory, nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; import { @@ -16,7 +11,6 @@ import { getTotalClusterMemoryCPUAndStorage, useAPLAvailability, useIsLkeEnterpriseEnabled, - useLkeStandardOrEnterpriseVersions, } from './kubeUtils'; import type { @@ -24,16 +18,12 @@ import type { KubernetesVersion, } from '@linode/api-v4'; -const mockKubernetesVersions = kubernetesVersionFactory.buildList(1); -const mockKubernetesEnterpriseVersions = - kubernetesEnterpriseTierVersionFactory.buildList(1); - const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), + useGrants: vi.fn().mockReturnValue({}), useAccountBetaQuery: vi.fn().mockReturnValue({}), useFlags: vi.fn().mockReturnValue({}), useKubernetesTieredVersionsQuery: vi.fn().mockReturnValue({}), - useKubernetesVersionQuery: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', () => { @@ -41,6 +31,7 @@ vi.mock('@linode/queries', () => { return { ...actual, useAccount: queryMocks.useAccount, + useGrants: queryMocks.useGrants, useAccountBetaQuery: queryMocks.useAccountBetaQuery, }; }); @@ -59,7 +50,6 @@ vi.mock('src/queries/kubernetes', () => { ...actual, useKubernetesTieredVersionsQuery: queryMocks.useKubernetesTieredVersionsQuery, - useKubernetesVersionQuery: queryMocks.useKubernetesVersionQuery, }; }); @@ -356,7 +346,7 @@ describe('helper functions', () => { describe('hooks', () => { describe('useIsLkeEnterpriseEnabled', () => { - it('returns false for feature enablement if the account does not have the capability', () => { + it('returns false for feature enablement (except post-LA) if the account does not have the capability', () => { queryMocks.useAccount.mockReturnValue({ data: { capabilities: [], @@ -380,7 +370,7 @@ describe('hooks', () => { isLkeEnterpriseLAFlagEnabled: true, isLkeEnterprisePhase2BYOVPCFeatureEnabled: false, isLkeEnterprisePhase2DualStackFeatureEnabled: false, - isLkeEnterprisePostLAFeatureEnabled: false, + isLkeEnterprisePostLAFeatureEnabled: true, // This is okay, because the *LA* feature is gated by the account capability. }); }); @@ -504,71 +494,4 @@ describe('hooks', () => { }); }); }); - - describe('useLkeStandardOrEnterpriseVersions', () => { - beforeAll(() => { - queryMocks.useAccount.mockReturnValue({ - data: { - capabilities: ['Kubernetes Enterprise'], - }, - }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - }, - }); - queryMocks.useKubernetesTieredVersionsQuery.mockReturnValue({ - data: mockKubernetesEnterpriseVersions, - error: null, - isFetching: false, - }); - queryMocks.useKubernetesVersionQuery.mockReturnValue({ - data: mockKubernetesVersions, - error: null, - isLoading: false, - }); - }); - - it('returns enterprise versions for enterprise clusters when the LKE-E feature is enabled', () => { - const { result } = renderHook(() => - useLkeStandardOrEnterpriseVersions('enterprise') - ); - - expect(result.current.versions).toEqual(mockKubernetesEnterpriseVersions); - expect(result.current.isLoadingVersions).toBe(false); - expect(result.current.versionsError).toBe(null); - }); - - it('returns standard versions for standard clusters when the LKE-E feature is enabled', () => { - const { result } = renderHook(() => - useLkeStandardOrEnterpriseVersions('standard') - ); - - expect(result.current.versions).toEqual(mockKubernetesVersions); - expect(result.current.isLoadingVersions).toBe(false); - expect(result.current.versionsError).toBe(null); - }); - - it('returns standard versions when the LKE-E feature is disabled', () => { - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: false, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - }, - }); - - const { result } = renderHook(() => - useLkeStandardOrEnterpriseVersions('standard') - ); - - expect(result.current.versions).toEqual(mockKubernetesVersions); - expect(result.current.isLoadingVersions).toBe(false); - expect(result.current.versionsError).toBe(null); - }); - }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 991104fc614..eb5570f3927 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,17 +1,12 @@ -import { useAccount, useAccountBetaQuery } from '@linode/queries'; +import { useAccount, useAccountBetaQuery, useGrants } from '@linode/queries'; import { getBetaStatus, isFeatureEnabledV2 } from '@linode/utilities'; import { useFlags } from 'src/hooks/useFlags'; -import { - useKubernetesTieredVersionsQuery, - useKubernetesVersionQuery, -} from 'src/queries/kubernetes'; import type { Account } from '@linode/api-v4/lib/account'; import type { KubeNodePoolResponse, KubernetesCluster, - KubernetesTier, KubernetesTieredVersion, KubernetesVersion, } from '@linode/api-v4/lib/kubernetes'; @@ -266,6 +261,11 @@ export const getLatestVersion = ( export const useIsLkeEnterpriseEnabled = () => { const flags = useFlags(); const { data: account } = useAccount(); + const { data: grants } = useGrants(); + + const hasAccountEndpointAccess = + grants?.global.account_access === 'read_only' || + grants?.global.account_access === 'read_write'; const isLkeEnterpriseLAFlagEnabled = Boolean( flags?.lkeEnterprise2?.enabled && flags.lkeEnterprise2.la @@ -299,11 +299,15 @@ export const useIsLkeEnterpriseEnabled = () => { account?.capabilities ?? [] ); // For feature-flagged update strategy and firewall work - const isLkeEnterprisePostLAFeatureEnabled = isFeatureEnabledV2( - 'Kubernetes Enterprise', - isLkeEnterprisePostLAFlagEnabled, - account?.capabilities ?? [] - ); + // For users with restricted billing/account access, skip the inaccessible capability and just check the feature flag. + // This is okay, because the LA feature is gated by the account capability. + const isLkeEnterprisePostLAFeatureEnabled = hasAccountEndpointAccess + ? isFeatureEnabledV2( + 'Kubernetes Enterprise', + isLkeEnterprisePostLAFlagEnabled, + account?.capabilities ?? [] + ) + : isLkeEnterprisePostLAFlagEnabled; const isLkeEnterpriseGAFeatureEnabled = isFeatureEnabledV2( 'Kubernetes Enterprise', isLkeEnterpriseGAFlagEnabled, @@ -320,64 +324,3 @@ export const useIsLkeEnterpriseEnabled = () => { isLkeEnterprisePostLAFeatureEnabled, }; }; - -/** - * @todo Remove this hook and just use `useKubernetesTieredVersionsQuery` directly once we're in GA - * since we'll always have a cluster tier. - * - * A hook to return the correct list of versions depending on the LKE cluster tier. - * @param clusterTier Whether the cluster is standard or enterprise - * @returns The list of either standard or enterprise k8 versions and query loading or error state - */ -export const useLkeStandardOrEnterpriseVersions = ( - clusterTier: KubernetesTier -) => { - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - - /** - * If LKE-E is enabled, use the data from the new /versions/ endpoint for enterprise tiers. - * If LKE-E is disabled, use the data from the existing /versions endpoint and disable the tiered query. - */ - const { - data: enterpriseTierVersions, - error: enterpriseTierVersionsError, - isFetching: enterpriseTierVersionsIsLoading, - } = useKubernetesTieredVersionsQuery( - 'enterprise', - isLkeEnterpriseLAFeatureEnabled - ); - - const { - data: _versions, - error: versionsError, - isLoading: versionsIsLoading, - } = useKubernetesVersionQuery(); - - return { - isLoadingVersions: enterpriseTierVersionsIsLoading || versionsIsLoading, - versions: - isLkeEnterpriseLAFeatureEnabled && clusterTier === 'enterprise' - ? enterpriseTierVersions - : _versions, - versionsError: enterpriseTierVersionsError || versionsError, - }; -}; - -export const useKubernetesBetaEndpoint = () => { - const { - isLoading: isAPLAvailabilityLoading, - showAPL, - isAPLGeneralAvailability, - } = useAPLAvailability(); - const { isLkeEnterpriseLAFeatureEnabled } = useIsLkeEnterpriseEnabled(); - // Use beta endpoint if either: - // 1. LKE Enterprise is enabled - // 2. APL is supported but not in GA - const isUsingBetaEndpoint = - (showAPL && !isAPLGeneralAvailability) || isLkeEnterpriseLAFeatureEnabled; - - return { - isAPLAvailabilityLoading, - isUsingBetaEndpoint, - }; -}; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx index fff726768ad..60bf5453a2c 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/Encryption/utils'; import { Link } from 'src/components/Link'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; +import { useAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { AccessTable } from 'src/features/Linodes/AccessTable'; import { ipTableId } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/utils'; import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; @@ -138,13 +138,11 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { const secondAddress = ipv6 ? ipv6 : ipv4.length > 1 ? ipv4[1] : null; const matchesLgUp = useMediaQuery(theme.breakpoints.up('lg')); - const { isAPLAvailabilityLoading, isUsingBetaEndpoint } = - useKubernetesBetaEndpoint(); + const { isLoading } = useAPLAvailability(); const { data: cluster } = useKubernetesClusterQuery({ - enabled: Boolean(linodeLkeClusterId) && !isAPLAvailabilityLoading, + enabled: Boolean(linodeLkeClusterId) && !isLoading, id: linodeLkeClusterId ?? -1, - isUsingBetaEndpoint, }); return ( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx index e2003220d02..6b2f59db1b6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.tsx @@ -16,15 +16,12 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { TagCell } from 'src/components/TagCell/TagCell'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { useKubernetesClusterQuery } from 'src/queries/kubernetes'; import { useIsNodebalancerVPCEnabled } from '../../utils'; export const SummaryPanel = () => { - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); - const { id } = useParams({ from: '/nodebalancers/$id/summary', }); @@ -77,7 +74,6 @@ export const SummaryPanel = () => { const { status: clusterStatus } = useKubernetesClusterQuery({ enabled: Boolean(nodebalancer?.lke_cluster), id: nodebalancer?.lke_cluster?.id ?? -1, - isUsingBetaEndpoint, options: { refetchOnMount: false, refetchOnReconnect: false, diff --git a/packages/manager/src/features/Search/useClientSideSearch.ts b/packages/manager/src/features/Search/useClientSideSearch.ts index df897bb8d70..fa885192829 100644 --- a/packages/manager/src/features/Search/useClientSideSearch.ts +++ b/packages/manager/src/features/Search/useClientSideSearch.ts @@ -11,7 +11,6 @@ import { useAllVolumesQuery, } from '@linode/queries'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { @@ -48,12 +47,11 @@ export const useClientSideSearch = ({ enabled, query }: Props) => { error: domainsError, isLoading: domainsLoading, } = useAllDomainsQuery(enabled); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: lkeClustersError, isLoading: lkeClustersLoading, - } = useAllKubernetesClustersQuery({ enabled, isUsingBetaEndpoint }); + } = useAllKubernetesClustersQuery({ enabled }); const { data: volumes, error: volumesError, diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 511e4d6b154..685c38f237c 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -11,7 +11,6 @@ import { Autocomplete, FormHelperText, TextField } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { useKubernetesBetaEndpoint } from 'src/features/Kubernetes/kubeUtils'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; @@ -71,14 +70,12 @@ export const SupportTicketProductSelectionFields = (props: Props) => { isLoading: nodebalancersLoading, } = useAllNodeBalancersQuery(entityType === 'nodebalancer_id'); - const { isUsingBetaEndpoint } = useKubernetesBetaEndpoint(); const { data: clusters, error: clustersError, isLoading: clustersLoading, } = useAllKubernetesClustersQuery({ enabled: entityType === 'lkecluster_id', - isUsingBetaEndpoint, }); const { diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index ecd0c84e735..5cbdbfa53fd 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -1,21 +1,16 @@ import { createKubernetesCluster, - createKubernetesClusterBeta, createNodePool, deleteKubernetesCluster, deleteNodePool, getKubeConfig, getKubernetesCluster, - getKubernetesClusterBeta, getKubernetesClusterControlPlaneACL, getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, - getKubernetesClustersBeta, - getKubernetesTieredVersionsBeta, + getKubernetesTieredVersions, getKubernetesTypes, - getKubernetesTypesBeta, - getKubernetesVersions, getNodePool, getNodePools, recycleAllNodes, @@ -46,7 +41,6 @@ import type { KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesTieredVersion, - KubernetesVersion, UpdateNodePoolData, } from '@linode/api-v4'; import type { @@ -64,12 +58,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryFn: () => getKubernetesClusterControlPlaneACL(id), queryKey: [id], }, - cluster: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: isUsingBetaEndpoint - ? () => getKubernetesClusterBeta(id) - : () => getKubernetesCluster(id), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), + cluster: { + queryFn: () => getKubernetesCluster(id), + queryKey: null, + }, dashboard: { queryFn: () => getKubernetesClusterDashboard(id), queryKey: null, @@ -146,44 +138,28 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }), lists: { contextQueries: { - all: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: () => - isUsingBetaEndpoint - ? getAllKubernetesClustersBeta() - : getAllKubernetesClusters(), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), + all: { + queryFn: () => getAllKubernetesClusters(), + queryKey: null, + }, infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam as number }, filter), queryKey: [filter], }), - paginated: ( - params: Params, - filter: Filter, - isUsingBetaEndpoint: boolean = false - ) => ({ - queryFn: () => - isUsingBetaEndpoint - ? getKubernetesClustersBeta(params, filter) - : getKubernetesClusters(params, filter), - queryKey: [params, filter, isUsingBetaEndpoint ? 'v4beta' : 'v4'], + paginated: (params: Params, filter: Filter) => ({ + queryFn: () => getKubernetesClusters(params, filter), + queryKey: [params, filter], }), }, queryKey: null, }, tieredVersions: (tier: string) => ({ - queryFn: () => getAllKubernetesTieredVersionsBeta(tier), + queryFn: () => getAllKubernetesTieredVersions(tier), queryKey: [tier], }), - types: (isUsingBetaEndpoint: boolean = false) => ({ - queryFn: isUsingBetaEndpoint - ? getAllKubernetesTypesBeta - : () => getAllKubernetesTypes(), - queryKey: [isUsingBetaEndpoint ? 'v4beta' : 'v4'], - }), - versions: { - queryFn: () => getAllKubernetesVersions(), + types: { + queryFn: () => getAllKubernetesTypes(), queryKey: null, }, }); @@ -191,11 +167,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { export const useKubernetesClusterQuery = ({ enabled = true, id = -1, - isUsingBetaEndpoint = false, options = {}, }) => { return useQuery({ - ...kubernetesQueries.cluster(id)._ctx.cluster(isUsingBetaEndpoint), + ...kubernetesQueries.cluster(id), enabled, ...options, }); @@ -222,7 +197,6 @@ export const useKubernetesClustersInfiniteQuery = ( interface KubernetesClustersQueryOptions { enabled?: boolean; filter: Filter; - isUsingBetaEndpoint: boolean; params: Params; } @@ -230,14 +204,9 @@ export const useKubernetesClustersQuery = ({ enabled = true, filter, params, - isUsingBetaEndpoint = false, }: KubernetesClustersQueryOptions) => { return useQuery, APIError[]>({ - ...kubernetesQueries.lists._ctx.paginated( - params, - filter, - isUsingBetaEndpoint - ), + ...kubernetesQueries.lists._ctx.paginated(params, filter), enabled, placeholderData: keepPreviousData, }); @@ -255,14 +224,9 @@ export const useKubernetesClusterMutation = (id: number) => { queryClient.invalidateQueries({ queryKey: kubernetesQueries.cluster(id)._ctx.acl.queryKey, }); - // queryClient.setQueryData( - // kubernetesQueries.cluster(id).queryKey, - // data - // ); - // Temporary cache update logic for APL - queryClient.setQueriesData( - { queryKey: kubernetesQueries.cluster(id)._ctx.cluster._def }, - (oldData) => ({ ...oldData, ...data }) + queryClient.setQueryData( + kubernetesQueries.cluster(id).queryKey, + data ); }, } @@ -342,27 +306,6 @@ export const useCreateKubernetesClusterMutation = () => { }); }; -/** - * duplicated function of useCreateKubernetesClusterMutation - * necessary to call BETA_API_ROOT in a separate function based on feature flag - */ - -export const useCreateKubernetesClusterBetaMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createKubernetesClusterBeta, - onSuccess() { - queryClient.invalidateQueries({ - queryKey: kubernetesQueries.lists.queryKey, - }); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries({ - queryKey: profileQueries.grants.queryKey, - }); - }, - }); -}; - export const useCreateNodePoolMutation = (clusterId: number) => { const queryClient = useQueryClient(); return useMutation({ @@ -468,12 +411,6 @@ export const useKubernetesDashboardQuery = ( }); }; -export const useKubernetesVersionQuery = () => - useQuery({ - ...kubernetesQueries.versions, - ...queryPresets.oneTimeFetch, - }); - export const useKubernetesTieredVersionsQuery = ( tier: string, enabled = true @@ -489,12 +426,9 @@ export const useKubernetesTieredVersionsQuery = ( * Avoiding fetching all Kubernetes Clusters if possible. * Before you use this, consider implementing infinite scroll instead. */ -export const useAllKubernetesClustersQuery = ({ - enabled = false, - isUsingBetaEndpoint = false, -}) => { +export const useAllKubernetesClustersQuery = ({ enabled = false }) => { return useQuery({ - ...kubernetesQueries.lists._ctx.all(isUsingBetaEndpoint), + ...kubernetesQueries.lists._ctx.all, enabled, }); }; @@ -537,19 +471,9 @@ const getAllKubernetesClusters = () => getKubernetesClusters(params, filters) )().then((data) => data.data); -const getAllKubernetesClustersBeta = () => - getAll((params, filters) => - getKubernetesClustersBeta(params, filters) - )().then((data) => data.data); - -const getAllKubernetesVersions = () => - getAll((params, filters) => - getKubernetesVersions(params, filters) - )().then((data) => data.data); - -const getAllKubernetesTieredVersionsBeta = (tier: string) => +const getAllKubernetesTieredVersions = (tier: string) => getAll((params, filters) => - getKubernetesTieredVersionsBeta(tier, params, filters) + getKubernetesTieredVersions(tier, params, filters) )().then((data) => data.data); const getAllAPIEndpointsForCluster = (clusterId: number) => @@ -562,13 +486,8 @@ const getAllKubernetesTypes = () => (results) => results.data ); -const getAllKubernetesTypesBeta = () => - getAll((params) => getKubernetesTypesBeta(params))().then( - (results) => results.data - ); - -export const useKubernetesTypesQuery = (isUsingBetaEndpoint?: boolean) => +export const useKubernetesTypesQuery = () => useQuery({ ...queryPresets.oneTimeFetch, - ...kubernetesQueries.types(isUsingBetaEndpoint), + ...kubernetesQueries.types, }); diff --git a/packages/queries/.changeset/pr-12867-removed-1758062601387.md b/packages/queries/.changeset/pr-12867-removed-1758062601387.md new file mode 100644 index 00000000000..3ced1ee669a --- /dev/null +++ b/packages/queries/.changeset/pr-12867-removed-1758062601387.md @@ -0,0 +1,6 @@ +--- +"@linode/queries": Removed +--- + +`isUsingBetaEndpoint` logic for kubernetes queries since all kubernetes endpoints +now use /v4beta ([#12867](https://github.com/linode/manager/pull/12867)) From 0b3c9577a8b39b2f63522e6e9156fc2c27d2e3b5 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:49:30 -0700 Subject: [PATCH 28/54] fix: [M3-10590] - Clear LKE-E specific configuration data from LKE standard request payload (#12916) * Reset LKE-E pool configurations incompatible with LKE standard pools * Uncomment test for full coverage * Added changeset: LKE create request for standard cluster can contain LKE-E-specific payload data --- .../manager/.changeset/pr-12916-fixed-1758811311336.md | 5 +++++ .../manager/cypress/e2e/core/kubernetes/lke-create.spec.ts | 6 ++---- .../features/Kubernetes/CreateCluster/CreateCluster.tsx | 7 ++++++- 3 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-12916-fixed-1758811311336.md diff --git a/packages/manager/.changeset/pr-12916-fixed-1758811311336.md b/packages/manager/.changeset/pr-12916-fixed-1758811311336.md new file mode 100644 index 00000000000..e3d892c2882 --- /dev/null +++ b/packages/manager/.changeset/pr-12916-fixed-1758811311336.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +LKE create request for standard cluster can contain LKE-E-specific payload data ([#12916](https://github.com/linode/manager/pull/12916)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index d50b25e5d07..d6665a7ff20 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -1842,10 +1842,8 @@ describe('LKE cluster creation with LKE-E Post-LA', () => { expect(nodePools[0]).to.be.an('object'); expect(nodePools[0]['type']).to.equal(mockPlan.id); expect(nodePools[0]['count']).to.equal(3); - - // TODO M3-10590 - Uncomment and adjust according to chosen resolution. - // expect(nodePools[0]['update_strategy']).to.be.undefined; - // expect(nodePools[0]['firewall_id']).to.be.undefined; + expect(nodePools[0]['update_strategy']).to.be.undefined; + expect(nodePools[0]['firewall_id']).to.be.undefined; }); cy.url().should('endWith', `kubernetes/clusters/${mockCluster.id}/summary`); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index f48942e037b..38d53a50da7 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -197,7 +197,10 @@ export const CreateCluster = () => { setErrors(undefined); } - // If a user adds > 100 nodes in the LKE-E flow but then switches to LKE, set the max node count to 100 for correct price display + // If a user configures node pools in the LKE-E flow, but then switches to LKE, reset configurations that are incompatible with LKE-E: + // - If a user added > 100 nodes, set the max node count to 100 for correct price display. + // - Clear the firewall selection. + // - Clear the update strategy selection. if (isLkeEnterpriseLAFeatureEnabled) { nodePools.forEach((nodePool, idx) => update(idx, { @@ -208,6 +211,8 @@ export const CreateCluster = () => { ? MAX_NODES_PER_POOL_ENTERPRISE_TIER : MAX_NODES_PER_POOL_STANDARD_TIER ), + firewall_id: undefined, + update_strategy: undefined, }) ); } From de93dad39eb2b2685ae9291f99afb5e92b906187 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:56:11 -0700 Subject: [PATCH 29/54] fix: Update LKE-E cluster Upgrade Version modal copy to mention update strategy (#12922) * Update copy to take update strategy into account * Add changeset * Update copy in test * Shorten title * Improve changeset * Address UX and Tech Writing final, final feedback * Update failing tests with new modal title copy --- .../manager/.changeset/pr-12922-fixed-1758907285299.md | 5 +++++ .../core/account/restricted-user-details-pages.spec.ts | 4 +--- .../e2e/core/kubernetes/lke-landing-page.spec.ts | 8 ++------ .../cypress/e2e/core/kubernetes/lke-update.spec.ts | 10 +++------- .../src/features/Kubernetes/UpgradeVersionModal.tsx | 7 +++---- 5 files changed, 14 insertions(+), 20 deletions(-) create mode 100644 packages/manager/.changeset/pr-12922-fixed-1758907285299.md diff --git a/packages/manager/.changeset/pr-12922-fixed-1758907285299.md b/packages/manager/.changeset/pr-12922-fixed-1758907285299.md new file mode 100644 index 00000000000..7f8b9a61ee7 --- /dev/null +++ b/packages/manager/.changeset/pr-12922-fixed-1758907285299.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Inaccurate Upgrade Version modal copy for LKE-E clusters and overly verbose modal title ([#12922](https://github.com/linode/manager/pull/12922)) diff --git a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts index e3340d0b903..61c93e1f830 100644 --- a/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts +++ b/packages/manager/cypress/e2e/core/account/restricted-user-details-pages.spec.ts @@ -462,9 +462,7 @@ describe('restricted user details pages', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index f4ec7b7c5cb..e27d00a5da1 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -278,9 +278,7 @@ describe('LKE landing page', () => { cy.wait(['@getCluster']); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${cluster.label} to ${newVersion}`) .should('be.visible'); mockGetClusters([updatedCluster]).as('getClusters'); @@ -353,9 +351,7 @@ describe('LKE landing page', () => { cy.wait(['@getCluster']); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${cluster.label} to ${newVersion}`) .should('be.visible'); mockGetClusters([updatedCluster]).as('getClusters'); 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 4e6223739e5..001dae86248 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -192,9 +192,7 @@ describe('LKE cluster updates', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { @@ -298,7 +296,7 @@ describe('LKE cluster updates', () => { const upgradeNotes = [ 'This upgrades the control plane on your cluster', - 'Worker nodes within each node pool can then be upgraded separately.', + 'Existing worker nodes are updated automatically or manually, depending on the update strategy defined for each node pool.', // Confirm that the old version and new version are both shown. oldVersion, newVersion, @@ -332,9 +330,7 @@ describe('LKE cluster updates', () => { .click(); ui.dialog - .findByTitle( - `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` - ) + .findByTitle(`Upgrade Cluster ${mockCluster.label} to ${newVersion}`) .should('be.visible') .within(() => { upgradeNotes.forEach((note: string) => { diff --git a/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx b/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx index 35e1b7d70ef..116e0fdf721 100644 --- a/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx +++ b/packages/manager/src/features/Kubernetes/UpgradeVersionModal.tsx @@ -35,7 +35,8 @@ const getWorkerNodeCopy = (clusterTier: KubernetesTier = 'standard') => { ) : ( - . Worker nodes within each node pool can then be upgraded separately.{' '} + . Existing worker nodes are updated automatically or manually, depending + on the update strategy defined for each node pool.{' '} Learn more @@ -125,9 +126,7 @@ export const UpgradeDialog = (props: Props) => { const dialogTitle = shouldShowRecycleNodesStep ? 'Upgrade complete' - : `Upgrade Kubernetes version ${ - nextVersion ? `to ${nextVersion}` : '' - } on ${cluster?.label}?`; + : `Upgrade Cluster ${cluster?.label} to ${nextVersion}`; const actions = ( Date: Mon, 29 Sep 2025 12:29:05 +0200 Subject: [PATCH 30/54] feat: [UIE-9283] - IAM Parent/Child: remove proxy table (#12921) * feat: [UIE-9283] - IAM Parent/Child: remove proxy table * changeset --- .../pr-12921-changed-1758898196076.md | 5 ++ .../IAM/Users/UsersTable/ProxyUserTable.tsx | 68 ------------------- .../features/IAM/Users/UsersTable/Users.tsx | 62 +++++------------ .../UsersLandingProxyTableHead.test.tsx | 52 -------------- .../UsersTable/UsersLandingProxyTableHead.tsx | 45 ------------ 5 files changed, 23 insertions(+), 209 deletions(-) create mode 100644 packages/manager/.changeset/pr-12921-changed-1758898196076.md delete mode 100644 packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx delete mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.test.tsx delete mode 100644 packages/manager/src/features/IAM/Users/UsersTable/UsersLandingProxyTableHead.tsx diff --git a/packages/manager/.changeset/pr-12921-changed-1758898196076.md b/packages/manager/.changeset/pr-12921-changed-1758898196076.md new file mode 100644 index 00000000000..1de573b95b4 --- /dev/null +++ b/packages/manager/.changeset/pr-12921-changed-1758898196076.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM Delegation: remove ProxyUserTable ([#12921](https://github.com/linode/manager/pull/12921)) diff --git a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx deleted file mode 100644 index 6895a04a313..00000000000 --- a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useAccountUsers } from '@linode/queries'; -import { Typography } from '@linode/ui'; -import React from 'react'; - -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { PARENT_USER } from 'src/features/Account/constants'; - -import { UsersLandingProxyTableHead } from './UsersLandingProxyTableHead'; -import { UsersLandingTableBody } from './UsersLandingTableBody'; - -import type { Order } from './UsersLandingTableHead'; - -interface Props { - canListUsers: boolean | undefined; - handleDelete: (username: string) => void; - isProxyUser: boolean; - order: Order; -} - -export const ProxyUserTable = ({ - handleDelete, - isProxyUser, - canListUsers, - order, -}: Props) => { - const { - data: proxyUser, - error: proxyUserError, - isLoading: isLoadingProxyUser, - } = useAccountUsers({ - enabled: isProxyUser && canListUsers, - filters: { user_type: 'proxy' }, - }); - - const proxyNumCols = 3; - - return ( - <> - ({ - marginBottom: theme.spacing(2), - marginTop: theme.spacing(3), - textTransform: 'capitalize', - [theme.breakpoints.down('md')]: { - marginLeft: theme.spacing(1), - }, - })} - variant="h3" - > - {PARENT_USER} Settings - - - - - - - -
    - - ); -}; diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index a32f4e194d8..81de8087324 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -1,6 +1,6 @@ -import { useAccountUsers, useProfile } from '@linode/queries'; +import { useAccountUsers } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; -import { Button, Paper, Stack, Typography } from '@linode/ui'; +import { Button, Paper, Stack } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { useNavigate, useSearch } from '@tanstack/react-router'; @@ -16,7 +16,6 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { usePermissions } from '../../hooks/usePermissions'; import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; import { CreateUserDrawer } from './CreateUserDrawer'; -import { ProxyUserTable } from './ProxyUserTable'; import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; @@ -31,7 +30,6 @@ export const UsersLanding = () => { React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); - const { data: profile } = useProfile(); const theme = useTheme(); const { data: permissions } = usePermissions('account', ['create_user']); const pagination = usePaginationV2({ @@ -50,9 +48,6 @@ export const UsersLanding = () => { preferenceKey: 'iam-account-users-order', }); - const isProxyUser = - profile?.user_type === 'child' || profile?.user_type === 'proxy'; - const queryParams = new URLSearchParams(location.search); const { error: searchError, filter } = getAPIFilterFromQuery(query, { @@ -108,14 +103,6 @@ export const UsersLanding = () => { return ( - {isProxyUser && ( - - )} ({ marginTop: theme.tokens.spacing.S16 })}> { marginBottom={2} spacing={2} > - {isProxyUser ? ( - ({ - [theme.breakpoints.down('md')]: { - marginLeft: theme.tokens.spacing.S8, - }, - })} - variant="h3" - > - User Settings - - ) : ( - - )} +