From a9cd890d833c4a50cd533e297aef86a99eab323a Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:12:26 -0500 Subject: [PATCH 01/60] test: Temporarily skip DBaaS update tests (#13185) * Skip DBaaS update tests * Added changeset: Temporarily disable DBaaS update tests --- packages/manager/.changeset/pr-13185-tests-1765297901858.md | 5 +++++ .../cypress/e2e/core/databases/update-database.spec.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13185-tests-1765297901858.md diff --git a/packages/manager/.changeset/pr-13185-tests-1765297901858.md b/packages/manager/.changeset/pr-13185-tests-1765297901858.md new file mode 100644 index 00000000000..e39d56f661f --- /dev/null +++ b/packages/manager/.changeset/pr-13185-tests-1765297901858.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Temporarily disable DBaaS update tests ([#13185](https://github.com/linode/manager/pull/13185)) diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index f82641e1065..2f4ae4a8b9d 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -342,7 +342,8 @@ const validateActionItems = (state: string, label: string) => { cy.get('body').click(0, 0); }; -describe('Update database clusters', () => { +// eslint-disable-next-line sonarjs/no-skipped-tests +describe.skip('Update database clusters', () => { beforeEach(() => { mockAppendFeatureFlags({ databaseVpc: true, From ce761d26bccbd5da754166ef26f0d93ec75e7ee3 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Wed, 10 Dec 2025 16:01:56 +0100 Subject: [PATCH 02/60] feat: [UIE-9785] - typo in NodeBalancer Settings tooltip (#13186) * feat: [UIE-9785] - typo in NodeBalancer Settings tooltip * Added changeset: Typo in NodeBalancer Settings tooltip --- packages/manager/.changeset/pr-13186-fixed-1765370117878.md | 5 +++++ .../NodeBalancerDetail/NodeBalancerFirewalls.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13186-fixed-1765370117878.md diff --git a/packages/manager/.changeset/pr-13186-fixed-1765370117878.md b/packages/manager/.changeset/pr-13186-fixed-1765370117878.md new file mode 100644 index 00000000000..eecd7b7f70b --- /dev/null +++ b/packages/manager/.changeset/pr-13186-fixed-1765370117878.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Typo in NodeBalancer Settings tooltip ([#13186](https://github.com/linode/manager/pull/13186)) diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx index 5866fe61d69..7d995435fcc 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx @@ -119,7 +119,7 @@ export const NodeBalancerFirewalls = (props: Props) => { to: '/nodebalancers/$id/settings/add-firewall', }) } - tooltipText="NodeBalanacers can only have one Firewall assigned." + tooltipText="NodeBalancers can only have one Firewall assigned." > Add Firewall From ef127cb62bb353beda6ff6669e5700576296a2e4 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 11 Dec 2025 16:50:47 +0530 Subject: [PATCH 03/60] upcoming: [UIE-9793] - UI/UX enhancements and fixes for Rule Sets & Prefix Lists (part-2) (#13188) * Fix Rule Set Id copy from Rules table row * Change `RuleSets` to `Rule Sets` text * Update Ruleset ID to Rule Set ID for edge case * Exclude Marked for deletion rulesets from the dropdown * Update default state of newly choosen PL or Special PL that supports both Ipv4 & IPv6 * Added changeset: UI/UX enhancements and fixes for Rule Sets & Prefix Lists (part-2) * Fix styling for long PL/RS labels in PL/RS Drawer * Add flex wrap * Minor styling bug fix * Copy Icon display style update * Clean up: avoid repetitive styles * Add a comment for clarity * Add unit tests for Rule Sets filter logic --- ...r-13188-upcoming-features-1765378307536.md | 5 ++ .../Rules/FirewallPrefixListDrawer.tsx | 9 +-- .../Rules/FirewallRuleDrawer.test.tsx | 2 +- .../Rules/FirewallRuleSetDetailsView.tsx | 7 +- .../Rules/FirewallRuleSetForm.test.tsx | 64 +++++++++++++++++++ .../Rules/FirewallRuleSetForm.tsx | 56 ++++++++++++---- .../Rules/FirewallRuleTable.tsx | 5 +- .../Rules/MultiplePrefixListSelect.test.tsx | 29 ++++++++- .../Rules/MultiplePrefixListSelect.tsx | 7 +- .../FirewallDetail/Rules/shared.styles.ts | 13 ++-- 10 files changed, 159 insertions(+), 38 deletions(-) create mode 100644 packages/manager/.changeset/pr-13188-upcoming-features-1765378307536.md create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx diff --git a/packages/manager/.changeset/pr-13188-upcoming-features-1765378307536.md b/packages/manager/.changeset/pr-13188-upcoming-features-1765378307536.md new file mode 100644 index 00000000000..3bc3818f289 --- /dev/null +++ b/packages/manager/.changeset/pr-13188-upcoming-features-1765378307536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +UI/UX enhancements and fixes for Rule Sets & Prefix Lists (part-2) ([#13188](https://github.com/linode/manager/pull/13188)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx index b846482f8b2..c9ceb325f12 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -204,7 +204,7 @@ export const FirewallPrefixListDrawer = React.memo( column?: boolean; copy?: boolean; label: string; - value: React.ReactNode | string; + value: React.ReactNode; }[]; return ( @@ -227,13 +227,10 @@ export const FirewallPrefixListDrawer = React.memo( <> {fields.map((item, idx) => ( {item.label && ( {item.label}: diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx index 49b8b354781..dff04e38cf8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -143,7 +143,7 @@ describe('AddRuleSetDrawer', () => { // Description expect( getByText( - 'RuleSets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.' + 'Rule Sets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.' ) ).toBeVisible(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 5378e7a1386..fed47d634ea 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -107,13 +107,10 @@ export const FirewallRuleSetDetailsView = ( }, ].map((item, idx) => ( {item.label && ( {item.label}: diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx new file mode 100644 index 00000000000..54155903ab1 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.test.tsx @@ -0,0 +1,64 @@ +import { firewallRuleSetFactory } from 'src/factories'; + +import { filterRuleSets } from './FirewallRuleSetForm'; + +import type { FirewallRuleSet } from '@linode/api-v4'; + +describe('filterRuleSets', () => { + const mockRuleSets: FirewallRuleSet[] = [ + firewallRuleSetFactory.build({ + id: 1, + type: 'inbound', + label: 'Inbound RS', + deleted: null, + }), + firewallRuleSetFactory.build({ + id: 2, + type: 'outbound', + label: 'Outbound RS', + deleted: null, + }), + firewallRuleSetFactory.build({ + id: 3, + type: 'inbound', + label: 'Deleted RS', + deleted: '2025-11-18T18:51:11', // Marked for Deletion Rule Set + }), + firewallRuleSetFactory.build({ + id: 4, + type: 'inbound', + label: 'Already Used RS', + deleted: null, + }), + ]; + + it('returns only ruleSets of the correct type', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [], + }); + + expect(result.map((r) => r.id)).toEqual([1, 4]); + }); + + it('excludes ruleSets already referenced by the firewall', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [{ ruleset: 4 }], + }); + + expect(result.map((r) => r.id)).toEqual([1]); // excludes id=4 + }); + + it('excludes ruleSets marked for deletion', () => { + const result = filterRuleSets({ + ruleSets: mockRuleSets, + category: 'inbound', + inboundAndOutboundRules: [], + }); + + expect(result.some((r) => r.id === 3)).toBe(false); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1fec51c09c6..9faeec08867 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -22,6 +22,39 @@ import { import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; +import type { Category } from './shared'; +import type { FirewallRuleSet, FirewallRuleType } from '@linode/api-v4'; + +interface FilterRuleSetsArgs { + category: Category; + inboundAndOutboundRules: FirewallRuleType[]; + ruleSets: FirewallRuleSet[]; +} + +/** + * Display only those Rule Sets that: + * - are applicable to the given category + * - are not already referenced by the firewall + * - are not marked for deletion + */ +export const filterRuleSets = ({ + ruleSets, + category, + inboundAndOutboundRules, +}: FilterRuleSetsArgs) => { + return ruleSets.filter((ruleSet) => { + // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field + const isCorrectType = ruleSet.type === category; + + const isNotAlreadyReferenced = !inboundAndOutboundRules.some( + (rule) => rule.ruleset === ruleSet.id + ); + + const isNotMarkedForDeletion = ruleSet.deleted === null; + + return isCorrectType && isNotAlreadyReferenced && isNotMarkedForDeletion; + }); +}; export const FirewallRuleSetForm = React.memo( (props: FirewallRuleSetFormProps) => { @@ -58,19 +91,14 @@ export const FirewallRuleSetForm = React.memo( // Build dropdown options const ruleSetDropdownOptions = React.useMemo( () => - ruleSets - // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field - .filter( - (ruleSet) => - ruleSet.type === category && - !inboundAndOutboundRules.some( - (rule) => rule.ruleset === ruleSet.id - ) - ) // Display only rule sets applicable to the given category and filter out rule sets already referenced by the FW - .map((ruleSet) => ({ - label: ruleSet.label, - value: ruleSet.id, - })), + filterRuleSets({ + ruleSets, + category, + inboundAndOutboundRules, + }).map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), [ruleSets] ); @@ -83,7 +111,7 @@ export const FirewallRuleSetForm = React.memo( ({ marginTop: theme.spacingFunction(16) })} > - RuleSets are reusable collections of Cloud Firewall rules that use + Rule Sets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference. diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 69a4a59f3e6..2b628249009 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -397,8 +397,6 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { return ; } - const ruleSetCopyableId = `${rulesetDetails ? 'ID:' : 'Ruleset ID:'} ${ruleset}`; - return ( { )} - + {rulesetDetails ? 'ID: ' : 'Rule Set ID: '} + diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx index 91c29ef660d..9022d372ecb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.test.tsx @@ -54,6 +54,7 @@ describe('MultiplePrefixListSelect', () => { }, // supported (supports only ipv4) { name: 'pl:system:supports-both', ipv4: [], ipv6: [] }, // supported (supports both) { name: 'pl:system:not-supported', ipv4: null, ipv6: null }, // unsupported + { name: 'pl::vpcs:' }, // Special Prefix List (Doesn't have IPv4 & IPv6) ]; queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ @@ -177,7 +178,7 @@ describe('MultiplePrefixListSelect', () => { getByDisplayValue('pl:system:supports-only-ipv4'); }); - it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { + it('defaults to both IPv4 and IPv6 selected when choosing a PL that supports both', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { findByText, getByRole } = renderWithTheme( @@ -196,7 +197,31 @@ describe('MultiplePrefixListSelect', () => { { address: 'pl::supports-both', inIPv4Rule: true, - inIPv6Rule: false, + inIPv6Rule: true, + }, + ]); + }); + + it('defaults to both IPv4 and IPv6 selected when choosing a Special PL', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the Special PL name to filter the dropdown + await userEvent.type(input, 'pl::vpcs:'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::vpcs:'); + await userEvent.click(option); + + expect(baseProps.onChange).toHaveBeenCalledWith([ + { + address: 'pl::vpcs:', + inIPv4Rule: true, + inIPv6Rule: true, }, ]); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx index 52609f4d052..096cbf88b33 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx @@ -84,17 +84,20 @@ const getDefaultPLReferenceState = ( ): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { if (support === null) { // Special Prefix List case - return { inIPv4Rule: true, inIPv6Rule: false }; + return { inIPv4Rule: true, inIPv6Rule: true }; } const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; + // Supports both IPv4 & IPv6 if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) - return { inIPv4Rule: true, inIPv6Rule: false }; + return { inIPv4Rule: true, inIPv6Rule: true }; + // Supports only IPv4 if (!isPLIPv4Unsupported && isPLIPv6Unsupported) return { inIPv4Rule: true, inIPv6Rule: false }; + // Supports only Ipv6 if (isPLIPv4Unsupported && !isPLIPv6Unsupported) return { inIPv4Rule: false, inIPv6Rule: true }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts index 391bf1ace37..e3304b4f7a8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -12,16 +12,20 @@ import type { FirewallPolicyType } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface StyledListItemProps { + fieldsMode?: boolean; paddingMultiplier?: number; // optional, default 1 } export const StyledListItem = styled(Typography, { label: 'StyledTypography', - shouldForwardProp: omittedProps(['paddingMultiplier']), -})(({ theme, paddingMultiplier = 1 }) => ({ - alignItems: 'center', + shouldForwardProp: omittedProps(['fieldsMode', 'paddingMultiplier']), +})(({ theme, fieldsMode, paddingMultiplier = 1 }) => ({ + alignItems: fieldsMode ? 'flex-start' : 'center', display: 'flex', padding: `${theme.spacingFunction(4 * paddingMultiplier)} 0`, + ...(fieldsMode && { + flexWrap: 'wrap', // Longer labels start on the next line + }), })); export const StyledLabel = styled(Box, { @@ -71,8 +75,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ width: '1em', }, color: theme.palette.primary.main, - display: 'inline-block', + display: 'flex', position: 'relative', - marginTop: theme.spacingFunction(4), }, })); From 39c8d72713f237b2c857ec98ae5aba2e1abb8925 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Thu, 11 Dec 2025 17:25:47 +0530 Subject: [PATCH 04/60] change: [UIE-9701] - Add Back Navigation functionality in Drawer & Integrate the change with PrefixList Drawer (#13151) * upcoming: [UIE-9698] - Add Beta Chip for Drawer * Add Storybook * PR feedback * PR feedback @pmakode-akamai * upcoming: [UIE-9701] - Add Back Navigation Icon to Drawer title * Added changeset: Add Back Navigation functionality in Drawer * pr feedback * Fix: test failure and back icon while `isFetching` is true * Add test for back icon in PrefixList Drawer * Added changeset: Add back navigation functionality to Drawer and integrate it with PrefixList Drawer * Remove changed changeset * Update comment for clarity --------- Co-authored-by: pmakode-akamai --- ...r-13151-upcoming-features-1765447199412.md | 5 ++ .../Rules/FirewallPrefixListDrawer.test.tsx | 23 +++++-- .../Rules/FirewallPrefixListDrawer.tsx | 3 + .../src/components/Drawer/Drawer.stories.tsx | 64 +++++++++++++++++++ .../ui/src/components/Drawer/Drawer.test.tsx | 9 +++ packages/ui/src/components/Drawer/Drawer.tsx | 28 +++++++- 6 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-13151-upcoming-features-1765447199412.md diff --git a/packages/manager/.changeset/pr-13151-upcoming-features-1765447199412.md b/packages/manager/.changeset/pr-13151-upcoming-features-1765447199412.md new file mode 100644 index 00000000000..63c68beaa1d --- /dev/null +++ b/packages/manager/.changeset/pr-13151-upcoming-features-1765447199412.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add back navigation functionality to Drawer and integrate it with PrefixList Drawer ([#13151](https://github.com/linode/manager/pull/13151)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx index 0bd18ab2a35..ce6bd39325e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx @@ -47,35 +47,40 @@ const computeExpectedElements = ( let title = 'Prefix List details'; let button = 'Close'; let label = 'Name:'; + let hasBackNavigation = false; if (context?.type === 'ruleset' && context.modeViewedFrom === 'create') { title = `Add an ${capitalize(category)} Rule or Rule Set`; button = `Back to ${capitalize(category)} Rule Set`; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'rule' && context.modeViewedFrom === 'create') { title = `Add an ${capitalize(category)} Rule or Rule Set`; button = `Back to ${capitalize(category)} Rule`; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'ruleset' && context.modeViewedFrom === 'view') { title = `${capitalize(category)} Rule Set details`; button = 'Back to the Rule Set'; label = 'Prefix List Name:'; + hasBackNavigation = true; } if (context?.type === 'rule' && context.modeViewedFrom === 'edit') { title = 'Edit Rule'; button = 'Back to Rule'; label = 'Prefix List Name:'; + hasBackNavigation = true; } // Default values when there is no specific drawer context // (e.g., type === 'rule' and modeViewedFrom === undefined, // meaning the drawer is opened directly from the Firewall Table row) - return { title, button, label }; + return { title, button, label, hasBackNavigation }; }; describe('FirewallPrefixListDrawer', () => { @@ -158,7 +163,7 @@ describe('FirewallPrefixListDrawer', () => { mockData, ]); - const { getByText, getByRole } = renderWithTheme( + const { getByText, getByRole, queryByLabelText } = renderWithTheme( { ); // Compute expectations - const { title, button, label } = computeExpectedElements( - category, - context - ); + const { title, button, label, hasBackNavigation } = + computeExpectedElements(category, context); + + // Back Navigation (Expected only for second-level drawers) + const backIconButton = queryByLabelText('back navigation'); + if (hasBackNavigation) { + expect(backIconButton).toBeVisible(); + } else { + expect(backIconButton).not.toBeInTheDocument(); + } // Title expect(getByText(title)).toBeVisible(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx index c9ceb325f12..1c058bb6db6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -210,6 +210,9 @@ export const FirewallPrefixListDrawer = React.memo( return ( onClose({ closeAll: false }) : undefined + } isFetching={isFetching} onClose={() => onClose({ closeAll: true })} open={isOpen} diff --git a/packages/ui/src/components/Drawer/Drawer.stories.tsx b/packages/ui/src/components/Drawer/Drawer.stories.tsx index 94e0b9646c8..fcb4c74960a 100644 --- a/packages/ui/src/components/Drawer/Drawer.stories.tsx +++ b/packages/ui/src/components/Drawer/Drawer.stories.tsx @@ -183,6 +183,70 @@ export const WithError: Story = { }, }; +export const WithBackNavigation: Story = { + args: { + isFetching: false, + open: false, + title: 'My Drawer', + }, + render: (args) => { + const DrawerExampleWrapper = () => { + const [open, setOpen] = React.useState(args.open); + + return ( + <> + + {}} + onClose={() => setOpen(true)} + open={open} + > + + I smirked at their Kale chips banh-mi fingerstache brunch in + Williamsburg. + + + Meanwhile in my closet-style flat in Red-Hook, my pour-over coffee + glitched on my vinyl record player while I styled the bottom left + corner of my beard. Those artisan tacos I ordered were infused + with turmeric and locally sourced honey, a true farm-to-table + vibe. Pabst Blue Ribbon in hand, I sat on my reclaimed wood bench + next to the macramé plant holder. + + + Narwhal selfies dominated my Instagram feed, hashtagged with "slow + living" and "normcore aesthetics". My kombucha brewing kit arrived + just in time for me to ferment my own chai-infused blend. As I + adjusted my vintage round glasses, a tiny house documentary + started playing softly in the background. The retro typewriter + clacked as I typed out my minimalist poetry on sustainably sourced + paper. The sun glowed through the window, shining light on the + delightful cracks of my Apple watch. + + It was Saturday. + setOpen(false), + }} + /> + + + ); + }; + + return DrawerExampleWrapper(); + }, +}; + export const WithTitleSuffix: Story = { args: { isFetching: false, diff --git a/packages/ui/src/components/Drawer/Drawer.test.tsx b/packages/ui/src/components/Drawer/Drawer.test.tsx index c5abbcf7033..fd234768467 100644 --- a/packages/ui/src/components/Drawer/Drawer.test.tsx +++ b/packages/ui/src/components/Drawer/Drawer.test.tsx @@ -83,4 +83,13 @@ describe('Drawer', () => { expect(getByText('beta')).toBeVisible(); }); + + it('should render a Dailog with back button if handleBackNavigation is provided', () => { + const { getByLabelText } = renderWithTheme( + {}} open={true} />, + ); + const iconButton = getByLabelText('back navigation'); + + expect(iconButton).toBeVisible(); + }); }); diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index eb9f423a13b..c1486bf620f 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -4,6 +4,7 @@ import Grid from '@mui/material/Grid'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import ChevronLeftIcon from '../../assets/icons/chevron-left.svg'; import { getErrorText } from '../../utilities/error'; import { convertForAria } from '../../utilities/stringUtils'; import { Box } from '../Box'; @@ -23,6 +24,11 @@ export interface DrawerProps extends _DrawerProps { * It prevents the drawer from showing broken content. */ error?: APIError[] | null | string; + /** + * An optional prop that handles back navigation for second-level drawers. + * It can act as a visual indicator, similar to a back button or `onClose` handler. + */ + handleBackNavigation?: () => void; /** * Whether the drawer is fetching the entity's data. * @@ -62,12 +68,13 @@ export const Drawer = React.forwardRef( const { children, error, - titleSuffix, + handleBackNavigation, isFetching, onClose, open, sx, title, + titleSuffix, wide, ...rest } = props; @@ -159,6 +166,25 @@ export const Drawer = React.forwardRef( data-testid="drawer-title-container" display="flex" > + {handleBackNavigation && ( + ({ + color: theme.palette.text.primary, + padding: 0, + marginRight: theme.spacingFunction(8), + '& svg': { + width: 24, + height: 24, + }, + })} + > + + + )} Date: Thu, 11 Dec 2025 19:29:21 +0530 Subject: [PATCH 05/60] upcoming: [DI-28500] - Enable the Alerts Notification Channel Tab (#13150) * upcoming: [DI-28500] - Introduce Alert's Notification Channel Management tab * upcoming: [DI-28500] - filter and render enabled Tabs only * add changeset * test[DI-28587]: Add permission tests for Notification Channel Management * test[DI-28587]: Add permission tests for Notification Channel Management --------- Co-authored-by: agorthi-akamai --- ...r-13150-upcoming-features-1764648735682.md | 5 + ...ification-channel-permission-tests.spec.ts | 110 ++++++++++++++++++ .../NotificationChannelListing.tsx | 6 + ...rtsNotificationChannelsListingLazyRoute.ts | 8 ++ .../routes/alerts/CloudPulseAlertsRoute.tsx | 21 +++- packages/manager/src/routes/alerts/index.ts | 10 ++ 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-13150-upcoming-features-1764648735682.md create mode 100644 packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts diff --git a/packages/manager/.changeset/pr-13150-upcoming-features-1764648735682.md b/packages/manager/.changeset/pr-13150-upcoming-features-1764648735682.md new file mode 100644 index 00000000000..ce6340d5884 --- /dev/null +++ b/packages/manager/.changeset/pr-13150-upcoming-features-1764648735682.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Introduce and conditionally render Notification Channels tab under ACLP-Alerting ([#13150](https://github.com/linode/manager/pull/13150)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts new file mode 100644 index 00000000000..766296ca33c --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts @@ -0,0 +1,110 @@ +/** + * @file Integration Tests for CloudPulse Alerting — Notification Channel Listing Page + * + * Covers three access-control behaviors: + * 1. Access is allowed when `notificationChannels` is true. + * 2. Navigation/tab visibility is blocked when `notificationChannels` is false. + * 3. Direct URL access is blocked when `notificationChannels` is false. + * 4. All access is blocked when CloudPulse (`aclp`) is disabled. + */ + +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { accountFactory } from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; + +describe('Notification Channel Listing Page — Access Control', () => { + beforeEach(() => { + mockGetAccount(accountFactory.build()); + }); + + it('allows access when notificationChannels is enabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: true, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/linodes'); + + ui.nav.findItemByTitle('Alerts').should('be.visible').click(); + ui.tabList + .findTabByTitle('Notification Channels') + .should('be.visible') + .click(); + + cy.url().should('endWith', 'alerts/notification-channels'); + }); + + it('hides the Notification Channels tab when notificationChannels is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: false, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/linodes'); + + ui.nav.findItemByTitle('Alerts').should('be.visible').click(); + + // Tab should not render at all + ui.tabList.findTabByTitle('Notification Channels').should('not.exist'); + }); + + it('blocks all access when CloudPulse is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: false }, // CloudPulse OFF + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: true, + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/alerts/notification-channels'); + + // Application should return fallback + cy.findByText('Not Found').should('be.visible'); + }); + + it('blocks direct URL access to /alerts/notification-channels when notificationChannels is disabled', () => { + const flags: Partial = { + aclp: { beta: true, enabled: true }, + + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + recentActivity: false, + notificationChannels: false, // feature OFF → user should not enter page + }, + }; + + mockAppendFeatureFlags(flags); + cy.visitWithLogin('/alerts/notification-channels'); + // Tab must not exist + ui.tabList.findTabByTitle('Notification Channels').should('not.exist'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx new file mode 100644 index 00000000000..55559285b9f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx @@ -0,0 +1,6 @@ +import { Paper } from '@linode/ui'; +import React from 'react'; + +export const NotificationChannelListing = () => { + return Notification Channel Page; // Temporary placeholder +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts new file mode 100644 index 00000000000..73dd163145b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute.ts @@ -0,0 +1,8 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { NotificationChannelListing } from './NotificationChannelListing'; + +export const cloudPulseAlertsNotificationChannelsListingLazyRoute = + createLazyRoute('/alerts/notification-channels')({ + component: NotificationChannelListing, + }); diff --git a/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx b/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx index 0e521c5dc49..039a952a560 100644 --- a/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx +++ b/packages/manager/src/routes/alerts/CloudPulseAlertsRoute.tsx @@ -23,8 +23,15 @@ export const CloudPulseAlertsRoute = () => { title: 'Definitions', disabled: !flags.aclpAlerting?.alertDefinitions, }, + { + to: '/alerts/notification-channels', + title: 'Notification Channels', + disabled: !flags.aclpAlerting?.notificationChannels, + }, ]); + const visibleTabs = tabs.filter((tab) => !tab.disabled); + if (!isACLPEnabled) { return ; } @@ -39,14 +46,16 @@ export const CloudPulseAlertsRoute = () => { spacingBottom={4} /> - + }> - - - - - + {visibleTabs.map((_, index) => ( + + + + + + ))} diff --git a/packages/manager/src/routes/alerts/index.ts b/packages/manager/src/routes/alerts/index.ts index 827da3af08e..f91c83d50c2 100644 --- a/packages/manager/src/routes/alerts/index.ts +++ b/packages/manager/src/routes/alerts/index.ts @@ -68,6 +68,15 @@ const cloudPulseAlertsDefinitionsCatchAllRoute = createRoute({ }, }); +const cloudPulseNotificationChannelsRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/cloudPulseAlertsNotificationChannelsListingLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelsListingLazyRoute) +); + export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsIndexRoute, cloudPulseAlertsDefinitionsRoute.addChildren([ @@ -76,4 +85,5 @@ export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsDefinitionsEditRoute, ]), cloudPulseAlertsDefinitionsCatchAllRoute, + cloudPulseNotificationChannelsRoute, ]); From 91913c9d2cf233448df0cf60c9c87fe8ce03e8f2 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 12 Dec 2025 09:17:40 +0100 Subject: [PATCH 06/60] test: [UIE-9804] - Fix e2e tests impacted by Generational Plans release (#13192) * fix what i can fix * Added changeset: Fix e2e tests impacted by Generational Plans release * one more --- .../pr-13192-tests-1765463156655.md | 5 ++ .../e2e/core/kubernetes/lke-create.spec.ts | 13 ++-- .../linodes/create-linode-blackwell.spec.ts | 10 +-- .../e2e/core/linodes/create-linode.spec.ts | 74 +------------------ .../e2e/core/linodes/plan-selection.spec.ts | 32 ++------ 5 files changed, 27 insertions(+), 107 deletions(-) create mode 100644 packages/manager/.changeset/pr-13192-tests-1765463156655.md diff --git a/packages/manager/.changeset/pr-13192-tests-1765463156655.md b/packages/manager/.changeset/pr-13192-tests-1765463156655.md new file mode 100644 index 00000000000..d699487ea2f --- /dev/null +++ b/packages/manager/.changeset/pr-13192-tests-1765463156655.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix e2e tests impacted by Generational Plans release ([#13192](https://github.com/linode/manager/pull/13192)) 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 3c07b7aaffb..5f2b2ef1b3d 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -446,10 +446,13 @@ describe('LKE Cluster Creation with APL enabled', () => { cy.wait('@getRegionAvailability'); - cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); - cy.findByTestId('newFeatureChip') - .should('be.visible') - .should('have.text', 'new'); + cy.findByTestId('application-platform-form').within(() => { + cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('newFeatureChip') + .should('be.visible') + .should('have.text', 'new'); + }); + cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); cy.findByTestId('ha-radio-button-yes').should('be.disabled'); cy.get( @@ -1926,7 +1929,7 @@ describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => ui.tabList.findTabByTitle('GPU').should('be.visible').click(); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { cy.get('tbody tr') .should('have.length', 4) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts index ef433c36440..93e7ef5fc69 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-blackwell.spec.ts @@ -73,11 +73,8 @@ describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { cy.get('[data-qa-error="true"]').should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX PRO 6000 Blackwell Server Edition').should( - 'be.visible' - ); cy.get('tbody tr') .should('have.length', 4) .each((_, index) => { @@ -107,11 +104,8 @@ describe('smoketest for Nvidia blackwell GPUs in linodes/create page', () => { }); cy.findByRole('table', { - name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX PRO 6000 Blackwell Server Edition').should( - 'be.visible' - ); cy.get('tbody tr') .should('have.length', 4) .each((_, index) => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index aa88a244587..838243e286c 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -30,7 +30,6 @@ import { linodeCreatePage } from 'support/ui/pages'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { skip } from 'support/util/skip'; import { accountFactory, accountUserFactory } from 'src/factories'; @@ -156,81 +155,16 @@ describe('Create Linode', () => { }); /* - * - Confirms Premium Plan Linode can be created end-to-end. - * - Confirms creation flow, that Linode boots, and that UI reflects status. + * - Confirms Premium Plan Tab is disabled in Linodes Create */ - it(`creates a Premium CPU Linode`, () => { - cy.tag('env:premiumPlans'); - - // TODO Allow `chooseRegion` to be configured not to throw. - const linodeRegion = (() => { - try { - return chooseRegion({ - capabilities: ['Linodes', 'Premium Plans', 'Vlans'], - }); - } catch { - skip(); - } - return; - })()!; - - const linodeLabel = randomLabel(); - const planId = 'g7-premium-2'; - const planLabel = 'Premium 4 GB'; - const planType = 'Premium CPU'; - + it(`should feature a disabled Premium Tab in Linodes Create`, () => { interceptGetProfile().as('getProfile'); interceptCreateLinode().as('createLinode'); cy.visitWithLogin('/linodes/create'); - // Set Linode label, OS, plan type, password, etc. - linodeCreatePage.setLabel(linodeLabel); - linodeCreatePage.selectImage('Debian 12'); - linodeCreatePage.selectRegionById(linodeRegion.id); - linodeCreatePage.selectPlan(planType, planLabel); - linodeCreatePage.setRootPassword(randomString(32)); - - // Confirm information in summary is shown as expected. - cy.get('[data-qa-linode-create-summary]').scrollIntoView(); - cy.get('[data-qa-linode-create-summary]').within(() => { - cy.findByText('Debian 12').should('be.visible'); - cy.findByText(linodeRegion.label).should('be.visible'); - cy.findByText(planLabel).should('be.visible'); - }); - - // Create Linode and confirm it's provisioned as expected. - ui.button - .findByTitle('Create Linode') + cy.findByRole('tab', { name: 'Premium CPU' }) .should('be.visible') - .should('be.enabled') - .click(); - - cy.wait('@createLinode').then((xhr) => { - const requestPayload = xhr.request.body; - const responsePayload = xhr.response?.body; - - // Confirm that API request and response contain expected data - expect(requestPayload['label']).to.equal(linodeLabel); - expect(requestPayload['region']).to.equal(linodeRegion.id); - expect(requestPayload['type']).to.equal(planId); - - expect(responsePayload['label']).to.equal(linodeLabel); - expect(responsePayload['region']).to.equal(linodeRegion.id); - expect(responsePayload['type']).to.equal(planId); - - // Confirm that Cloud redirects to details page - cy.url().should('endWith', `/linodes/${responsePayload['id']}`); - }); - - cy.wait('@getProfile').then((xhr) => { - username = xhr.response?.body.username; - }); - - // Confirm toast notification should appear on Linode create. - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( - 'be.visible' - ); + .should('be.disabled'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index b6c680627cc..bd92c821f9c 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -390,7 +390,7 @@ describe('displays specific linode plans for GPU', () => { }).as('getFeatureFlags'); }); - it('Should render divided tables when GPU divider enabled', () => { + it('Should render GPU plans in Linodes Create', () => { cy.visitWithLogin('/linodes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); @@ -406,19 +406,11 @@ describe('displays specific linode plans for GPU', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX 4000 Ada Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); - cy.get('[id="gpu-2"]').should('be.disabled'); - }); - - cy.findByRole('table', { - name: 'List of NVIDIA Quadro RTX 6000 Plans', - }).within(() => { - cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[id="gpu-1"]').should('be.disabled'); + cy.get('[id="gpu-2"]').should('be.disabled'); }); }); }); @@ -439,7 +431,7 @@ describe('displays specific kubernetes plans for GPU', () => { }).as('getFeatureFlags'); }); - it('Should render divided tables when GPU divider enabled', () => { + it('Should render GPU plans in Kubernetes Create', () => { cy.visitWithLogin('/kubernetes/create'); cy.wait(['@getRegions', '@getLinodeTypes', '@getFeatureFlags']); ui.regionSelect.find().click(); @@ -455,23 +447,15 @@ describe('displays specific kubernetes plans for GPU', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { - name: 'List of NVIDIA RTX 4000 Ada Plans', + name: 'List of Linode Plans', }).within(() => { - cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); + cy.get('[data-qa-plan-row="gpu-1"]').should('have.attr', 'disabled'); cy.get('[data-qa-plan-row="gpu-2 Ada"]').should( 'have.attr', 'disabled' ); }); - - cy.findByRole('table', { - name: 'List of NVIDIA Quadro RTX 6000 Plans', - }).within(() => { - cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); - cy.findAllByRole('row').should('have.length', 2); - cy.get('[data-qa-plan-row="gpu-1"]').should('have.attr', 'disabled'); - }); }); }); }); From b49985e9541e32b21c0d61c30993963c32084bab Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 12 Dec 2025 09:17:58 +0100 Subject: [PATCH 07/60] fix: [UIE-9799] - Ensure browser history integrity when redirecting in IAM (#13190) * ensure browser history integrity when redirecting in IAM * Added changeset: Ensure browser history integrity when redirecting in IAM --- .../pr-13190-fixed-1765447529132.md | 5 +++++ .../manager/src/features/IAM/IAMLanding.tsx | 2 +- packages/manager/src/routes/IAM/index.ts | 19 +++++++++++++++---- 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-13190-fixed-1765447529132.md diff --git a/packages/manager/.changeset/pr-13190-fixed-1765447529132.md b/packages/manager/.changeset/pr-13190-fixed-1765447529132.md new file mode 100644 index 00000000000..3820a29c855 --- /dev/null +++ b/packages/manager/.changeset/pr-13190-fixed-1765447529132.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Ensure browser history integrity when redirecting in IAM ([#13190](https://github.com/linode/manager/pull/13190)) diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 588ef58e7f9..06ab02a1018 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -53,7 +53,7 @@ export const IdentityAccessLanding = React.memo(() => { }; if (location.pathname === '/iam') { - navigate({ to: '/iam/users' }); + navigate({ to: '/iam/users', replace: true }); } return ( diff --git a/packages/manager/src/routes/IAM/index.ts b/packages/manager/src/routes/IAM/index.ts index 98360cd87eb..8f5cf25a315 100644 --- a/packages/manager/src/routes/IAM/index.ts +++ b/packages/manager/src/routes/IAM/index.ts @@ -30,7 +30,7 @@ const iamCatchAllRoute = createRoute({ getParentRoute: () => iamRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/users' }); + throw redirect({ to: '/iam/users', replace: true }); }, }); @@ -56,7 +56,7 @@ const iamUsersCatchAllRoute = createRoute({ getParentRoute: () => iamUsersRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/users' }); + throw redirect({ to: '/iam/users', replace: true }); }, }); @@ -73,6 +73,7 @@ const iamRolesRoute = createRoute({ if (!isIAMEnabled) { throw redirect({ to: '/account/users', + replace: true, }); } }, @@ -98,6 +99,7 @@ const iamDefaultsTabsRoute = createRoute({ if (userType !== 'child' || !isDelegationEnabled) { throw redirect({ to: '/iam/roles', + replace: true, }); } }, @@ -129,7 +131,7 @@ const iamRolesCatchAllRoute = createRoute({ getParentRoute: () => iamRolesRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/roles' }); + throw redirect({ to: '/iam/roles', replace: true }); }, }); @@ -144,6 +146,7 @@ const iamDelegationsRoute = createRoute({ if (!isDelegationEnabled || isChildAccount) { throw redirect({ to: '/iam/users', + replace: true, }); } }, @@ -157,7 +160,7 @@ const iamDelegationsCatchAllRoute = createRoute({ getParentRoute: () => iamDelegationsRoute, path: '/$invalidPath', beforeLoad: () => { - throw redirect({ to: '/iam/delegations' }); + throw redirect({ to: '/iam/delegations', replace: true }); }, }); @@ -238,6 +241,7 @@ const iamUserNameIndexRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username: params.username }, + replace: true, }); }, }).lazy(() => @@ -260,6 +264,7 @@ const iamUserNameDetailsRoute = createRoute({ throw redirect({ to: '/account/users/$username/profile', params: { username }, + replace: true, }); } }, @@ -309,6 +314,7 @@ const iamUserNameEntitiesRoute = createRoute({ throw redirect({ to: '/account/users/$username', params: { username }, + replace: true, }); } }, @@ -331,6 +337,7 @@ const iamUserNameDelegationsRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username }, + replace: true, }); } }, @@ -349,6 +356,7 @@ const iamUserNameCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username', params: { username: params.username }, + replace: true, }); } }, @@ -361,6 +369,7 @@ const iamUserNameDetailsCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/details', params: { username: params.username }, + replace: true, }); }, }); @@ -372,6 +381,7 @@ const iamUserNameRolesCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/roles', params: { username: params.username }, + replace: true, }); }, }); @@ -383,6 +393,7 @@ const iamUserNameEntitiesCatchAllRoute = createRoute({ throw redirect({ to: '/iam/users/$username/entities', params: { username: params.username }, + replace: true, }); }, }); From aab5b227822a3b6b9650659f8e60c650969e85c6 Mon Sep 17 00:00:00 2001 From: n0vabyte <94801247+n0vabyte@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:26:16 -0500 Subject: [PATCH 08/60] feat: [OCA-1601] - Adds Elastic Stack and Weaviate Marketplace Apps (#13149) * feat: add elastic stack and weaviate apps to Marketplace * Added changeset: Add Elastic Stack to Marketplace Apps * update changeset * Update packages/manager/.changeset/pr-13149-added-1765469597776.md Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../pr-13149-added-1765469597776.md | 5 ++ .../manager/public/assets/elasticstack.svg | 1 + packages/manager/public/assets/weaviate.svg | 88 +++++++++++++++++++ .../public/assets/white/elasticstack.svg | 1 + .../manager/public/assets/white/weaviate.svg | 20 +++++ .../src/features/OneClickApps/oneClickApps.ts | 46 ++++++++++ 6 files changed, 161 insertions(+) create mode 100644 packages/manager/.changeset/pr-13149-added-1765469597776.md create mode 100644 packages/manager/public/assets/elasticstack.svg create mode 100644 packages/manager/public/assets/weaviate.svg create mode 100644 packages/manager/public/assets/white/elasticstack.svg create mode 100644 packages/manager/public/assets/white/weaviate.svg diff --git a/packages/manager/.changeset/pr-13149-added-1765469597776.md b/packages/manager/.changeset/pr-13149-added-1765469597776.md new file mode 100644 index 00000000000..91cfff95a50 --- /dev/null +++ b/packages/manager/.changeset/pr-13149-added-1765469597776.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Elastic Stack and Weaviate to Marketplace Apps ([#13149](https://github.com/linode/manager/pull/13149)) diff --git a/packages/manager/public/assets/elasticstack.svg b/packages/manager/public/assets/elasticstack.svg new file mode 100644 index 00000000000..935abb4ec00 --- /dev/null +++ b/packages/manager/public/assets/elasticstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/weaviate.svg b/packages/manager/public/assets/weaviate.svg new file mode 100644 index 00000000000..10477e24d80 --- /dev/null +++ b/packages/manager/public/assets/weaviate.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/public/assets/white/elasticstack.svg b/packages/manager/public/assets/white/elasticstack.svg new file mode 100644 index 00000000000..82b44e0d3e2 --- /dev/null +++ b/packages/manager/public/assets/white/elasticstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/public/assets/white/weaviate.svg b/packages/manager/public/assets/white/weaviate.svg new file mode 100644 index 00000000000..16b5f807bb0 --- /dev/null +++ b/packages/manager/public/assets/white/weaviate.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ 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 604338a6e3b..ebc7372582b 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -2152,4 +2152,50 @@ export const oneClickApps: Record = { summary: `All-in-one distributed tracing platform with integrated UI, collector, and storage for monitoring microservices.`, website: 'https://www.jaegertracing.io/', }, + 1966222: { + alt_description: + 'Unified observability platform that brings together search, data processing, and visualization through Elasticsearch, Logstash, and Kibana.', + alt_name: 'Log aggregation platform.', + categories: ['Monitoring', 'Security'], + colors: { + end: '0077cc', + start: 'f04e98', + }, + description: + 'The Elastic Stack (known as ELK) is well-suited for log aggregation, application monitoring, infrastructure observability, and security analytics. Its open architecture and extensive ecosystem make it adaptable to a wide range of use cases including distributed system debugging, SIEM workflows, API performance monitoring, and centralized logging across cloud and hybrid environments.', + isNew: true, + logo_url: 'elasticstack.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/elastic-stack/', + title: 'Deploy An Elastic Stack through the Linode Marketplace', + }, + ], + summary: + 'Log aggregation platform that brings together search, data processing, and visualization.', + website: 'https://www.elastic.co/', + }, + 1966231: { + alt_description: + 'Weaviate is an open-source vector database designed to store and index both data objects and their vector embeddings.', + alt_name: 'Open-source vector database.', + categories: ['Databases'], + colors: { + end: 'c4d132', + start: '53b83d', + }, + description: + 'Weaviate is an open-source AI-native vector database designed for building advanced AI applications. It stores and indexes both data objects and their vector embeddings, enabling semantic search, hybrid search, and Retrieval Augmented Generation (RAG) workflows. This deployment includes GPU acceleration for transformer models and comes pre-configured with the sentence-transformers model for high-performance semantic search capabilities.', + isNew: true, + logo_url: 'weaviate.svg', + related_guides: [ + { + href: 'https://www.linode.com/docs/products/tools/marketplace/guides/weaviate/', + title: 'Deploy Weaviate through the Linode Marketplace', + }, + ], + summary: + 'AI-native vector database designed for building advanced AI applications.', + website: 'https://docs.weaviate.io/weaviate', + }, }; From 32e9d7154521e13aee47a75f01f0196e834ff952 Mon Sep 17 00:00:00 2001 From: Ankita Date: Mon, 15 Dec 2025 10:26:50 +0530 Subject: [PATCH 09/60] fix: [DI-28760] - Add type safety for dependent filter ref's usage (#13196) * upcoming: [DI-28760] - Reset dependent filter ref on dashboard switch and add type safety for ref's usage * upcoming: [DI-28760] - Remove side effects * upcoming: [DI-28760] - Add changeset --- .../manager/.changeset/pr-13196-fixed-1765520959220.md | 5 +++++ .../shared/CloudPulseDashboardFilterBuilder.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13196-fixed-1765520959220.md diff --git a/packages/manager/.changeset/pr-13196-fixed-1765520959220.md b/packages/manager/.changeset/pr-13196-fixed-1765520959220.md new file mode 100644 index 00000000000..632e6154b6b --- /dev/null +++ b/packages/manager/.changeset/pr-13196-fixed-1765520959220.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` to add type-check for usage of dependent filters ref ([#13196](https://github.com/linode/manager/pull/13196)) diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 502d51dbfa4..f8c7ec342ae 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -368,9 +368,11 @@ export const CloudPulseDashboardFilterBuilder = React.memo( preferences, resource_ids: resource_ids?.length ? resource_ids - : ( - dependentFilterReference.current[RESOURCE_ID] as string[] - )?.map((id: string) => Number(id)), + : Array.isArray(dependentFilterReference.current[RESOURCE_ID]) + ? dependentFilterReference.current[RESOURCE_ID].map( + Number + ).filter((id) => !Number.isNaN(id)) + : [], shouldDisable: isError || isLoading, }, handleNodeTypeChange From 9263baf25692c2706352871a42d7e73ce5ba3882 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Mon, 15 Dec 2025 09:28:51 +0100 Subject: [PATCH 10/60] feat: [UIE-9794] - IAM: Enable account_viewer to access users table (#13189) * feat: [UIE-9794] - IAM: Enable account_viewer to access users table * changesets * fix a tooltip --- .../.changeset/pr-13189-changed-1765378735594.md | 5 +++++ .../src/features/IAM/Users/UsersTable/UserRow.tsx | 3 ++- .../src/features/IAM/Users/UsersTable/Users.tsx | 4 ++-- .../IAM/Users/UsersTable/UsersActionMenu.test.tsx | 2 ++ .../IAM/Users/UsersTable/UsersActionMenu.tsx | 15 ++++++++------- .../.changeset/pr-13189-changed-1765378759554.md | 5 +++++ packages/queries/src/account/users.ts | 4 +--- 7 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 packages/manager/.changeset/pr-13189-changed-1765378735594.md create mode 100644 packages/queries/.changeset/pr-13189-changed-1765378759554.md diff --git a/packages/manager/.changeset/pr-13189-changed-1765378735594.md b/packages/manager/.changeset/pr-13189-changed-1765378735594.md new file mode 100644 index 00000000000..96f7c6afe87 --- /dev/null +++ b/packages/manager/.changeset/pr-13189-changed-1765378735594.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM: Enable account_viewer to access users table ([#13189](https://github.com/linode/manager/pull/13189)) diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx index a32f449185a..099ed62a8fe 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -30,10 +30,11 @@ export const UserRow = ({ onDelete, user }: Props) => { const { data: permissions } = usePermissions('account', [ 'delete_user', 'is_account_admin', + 'view_account', ]); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const canViewUser = permissions.is_account_admin; + const canViewUser = permissions.view_account; // Determine if the current user is a child account with isIAMDelegationEnabled enabled // If so, we need to show the 'User type' column in the table diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index 301fa35d79d..b4d92e77e73 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -199,8 +199,8 @@ export const UsersLanding = () => { disabled={!canCreateUser} onClick={() => setIsCreateDrawerOpen(true)} tooltipText={ - canCreateUser - ? 'You cannot create other users as a restricted user.' + !canCreateUser + ? 'You do not have permission to create other users.' : undefined } > diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx index 3e91d57baae..18c0681f754 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx @@ -44,6 +44,7 @@ describe('UsersActionMenu', () => { permissions={{ is_account_admin: true, delete_user: true, + view_account: true, }} username="test_user" /> @@ -99,6 +100,7 @@ describe('UsersActionMenu', () => { permissions={{ is_account_admin: true, delete_user: true, + view_account: true, }} username="current_user" /> diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index fcf5920de61..53b5907ca99 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -10,7 +10,7 @@ import type { PickPermissions, UserType } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; type UserActionMenuPermissions = PickPermissions< - 'delete_user' | 'is_account_admin' + 'delete_user' | 'is_account_admin' | 'view_account' >; interface Props { @@ -29,6 +29,7 @@ export const UsersActionMenu = (props: Props) => { useDelegationRole(); const isAccountAdmin = permissions.is_account_admin; + const isAccountViewer = permissions.view_account; const canDeleteUser = isAccountAdmin || permissions.delete_user; const isDelegateUser = userType === 'delegate'; @@ -46,8 +47,8 @@ export const UsersActionMenu = (props: Props) => { }); }, hidden: shouldHideForChildDelegate, - disabled: !isAccountAdmin, - tooltip: !isAccountAdmin + disabled: !isAccountViewer, + tooltip: !isAccountViewer ? 'You do not have permission to view user details.' : undefined, title: 'View User Details', @@ -59,8 +60,8 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !isAccountAdmin, - tooltip: !isAccountAdmin + disabled: !isAccountViewer, + tooltip: !isAccountViewer ? 'You do not have permission to view assigned roles.' : undefined, title: 'View Assigned Roles', @@ -72,8 +73,8 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !isAccountAdmin, - tooltip: !isAccountAdmin + disabled: !isAccountViewer, + tooltip: !isAccountViewer ? 'You do not have permission to view entity access.' : undefined, title: 'View Entity Access', diff --git a/packages/queries/.changeset/pr-13189-changed-1765378759554.md b/packages/queries/.changeset/pr-13189-changed-1765378759554.md new file mode 100644 index 00000000000..e1d8687cb84 --- /dev/null +++ b/packages/queries/.changeset/pr-13189-changed-1765378759554.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Changed +--- + +IAM: Enable account_viewer to access users table ([#13189](https://github.com/linode/manager/pull/13189)) diff --git a/packages/queries/src/account/users.ts b/packages/queries/src/account/users.ts index f7aed11e148..0167fbe15bf 100644 --- a/packages/queries/src/account/users.ts +++ b/packages/queries/src/account/users.ts @@ -29,11 +29,9 @@ export const useAccountUsers = ({ filters?: Filter; params?: Params; }) => { - const { data: profile } = useProfile(); - return useQuery, APIError[]>({ ...accountQueries.users._ctx.paginated(params, filters), - enabled: enabled && !profile?.restricted, + enabled, placeholderData: keepPreviousData, }); }; From 870b3cb7d9e0ab3fd15d7ba8748fff6eb8e0a547 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:46:21 +0100 Subject: [PATCH 11/60] remove all instances of `update_user` (#13198) --- .../Users/UserDetails/UserEmailPanel.test.tsx | 2 +- .../IAM/Users/UserDetails/UserProfile.tsx | 16 ++++------------ .../IAM/Users/UserDetails/UsernamePanel.test.tsx | 14 +++++++------- .../DisplaySettings/UsernameForm.test.tsx | 10 +++++----- .../Profile/DisplaySettings/UsernameForm.tsx | 4 ++-- 5 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index f7ee71ade27..aa9377a1490 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -108,7 +108,7 @@ describe('UserEmailPanel', () => { expect(errorText).toBeInTheDocument(); }); - it('disables the save button when the user does not have update_user permission', async () => { + it('disables the save button when the user does not have is_account_admin permission', async () => { const user = accountUserFactory.build({ email: 'my-linode-email', }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx index f7359289a03..9f0fe3392a5 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx @@ -19,11 +19,7 @@ import { UsernamePanel } from './UsernamePanel'; export const UserProfile = () => { const { username } = useParams({ from: '/iam/users/$username' }); - const { data: permissions } = usePermissions('account', [ - 'is_account_admin', - 'update_user', - 'delete_user', - ]); + const { data: permissions } = usePermissions('account', ['is_account_admin']); const isAccountAdmin = permissions?.is_account_admin; @@ -34,10 +30,6 @@ export const UserProfile = () => { } = useAccountUser(username ?? '', isAccountAdmin); const { data: assignedRoles } = useUserRoles(username ?? '', isAccountAdmin); - // Only admin users get update_user and delete_user permissions, but doing a bit of defensive programming here to be safe. - const canUpdateUser = isAccountAdmin || permissions?.update_user; - const canDeleteUser = isAccountAdmin || permissions?.delete_user; - if (isLoading) { return ; } @@ -66,9 +58,9 @@ export const UserProfile = () => { sx={(theme) => ({ marginTop: theme.tokens.spacing.S16 })} > - - - + + + ); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx index eea6ec395b3..1ec994012d6 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UsernamePanel.test.tsx @@ -9,7 +9,7 @@ import { UsernamePanel } from './UsernamePanel'; const queryMocks = vi.hoisted(() => ({ userPermissions: vi.fn(() => ({ data: { - update_user: false, + is_account_admin: false, }, })), })); @@ -31,7 +31,7 @@ describe('UsernamePanel', () => { expect(usernameTextField).toHaveDisplayValue(user.username); }); - it('disables the input if the user doesn not have update_user permission', async () => { + it('disables the input if the user doesn not have is_account_admin permission', async () => { const user = accountUserFactory.build(); const { getByLabelText } = renderWithTheme( @@ -50,7 +50,7 @@ describe('UsernamePanel', () => { it("does not allow the user to update a proxy user's username", async () => { queryMocks.userPermissions.mockReturnValue({ data: { - update_user: true, + is_account_admin: true, }, }); @@ -76,14 +76,14 @@ describe('UsernamePanel', () => { expect(getByText('Save').closest('button')).toBeDisabled(); }); - it('enables the save button when the user makes a change to the username and has update_user permission', async () => { + it('enables the save button when the user makes a change to the username and has is_account_admin permission', async () => { const user = accountUserFactory.build({ username: 'my-linode-username', }); queryMocks.userPermissions.mockReturnValue({ data: { - update_user: true, + is_account_admin: true, }, }); @@ -102,14 +102,14 @@ describe('UsernamePanel', () => { expect(saveButton).toBeEnabled(); }); - it('disables the save button when the user does not have update_user permission', async () => { + it('disables the save button when the user does not have is_account_admin permission', async () => { const user = accountUserFactory.build({ username: 'my-linode-username', }); queryMocks.userPermissions.mockReturnValue({ data: { - update_user: false, + is_account_admin: false, }, }); diff --git a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.test.tsx index 8c3e0d1fe90..4b9fa26bb54 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.test.tsx @@ -11,7 +11,7 @@ import { UsernameForm } from './UsernameForm'; const queryMocks = vi.hoisted(() => ({ userPermissions: vi.fn(() => ({ data: { - update_user: false, + is_account_admin: false, }, })), })); @@ -38,7 +38,7 @@ describe('UsernameForm', () => { await findByDisplayValue(profile.username); }); - it('disables the input if the user doesn not have update_user permission', async () => { + it('disables the input if the user doesn not have is_account_admin permission', async () => { const { getByLabelText } = renderWithTheme(); expect(getByLabelText('Username')).toBeDisabled(); @@ -53,7 +53,7 @@ describe('UsernameForm', () => { it('disables the input if the user is a proxy user', async () => { queryMocks.userPermissions.mockReturnValue({ data: { - update_user: true, + is_account_admin: true, }, }); @@ -73,14 +73,14 @@ describe('UsernameForm', () => { expect(getByLabelText('This field can’t be modified.')).toBeVisible(); }); - it('enables the save button when the user makes a change to the username and has update_user permission', async () => { + it('enables the save button when the user makes a change to the username and has is_account_admin permission', async () => { const profile = profileFactory.build({ username: 'my-linode-username' }); server.use(http.get('*/v4/profile', () => HttpResponse.json(profile))); queryMocks.userPermissions.mockReturnValue({ data: { - update_user: true, + is_account_admin: true, }, }); diff --git a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx index 0ca2f5a06ba..3231dcdedd9 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/UsernameForm.tsx @@ -24,7 +24,7 @@ export const UsernameForm = () => { const values = { username: profile?.username ?? '' }; - const { data: permissions } = usePermissions('account', ['update_user']); + const { data: permissions } = usePermissions('account', ['is_account_admin']); const { control, @@ -37,7 +37,7 @@ export const UsernameForm = () => { values, }); - const tooltipForDisabledUsernameField = !permissions.update_user + const tooltipForDisabledUsernameField = !permissions.is_account_admin ? 'Restricted users cannot update their username. Please contact an account administrator.' : profile?.user_type === 'proxy' ? RESTRICTED_FIELD_TOOLTIP From bb76191fa19c948cd24d17002369be324f801be3 Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Mon, 15 Dec 2025 10:03:56 +0100 Subject: [PATCH 12/60] change: [DPS-35856] Logs: limit access to "lke_audit_logs" type based on capability (#13171) --- .../pr-13171-added-1764924231615.md | 5 + packages/api-v4/src/account/types.ts | 1 + .../pr-13171-changed-1764924158682.md | 5 + .../e2e/core/delivery/create-stream.spec.ts | 13 ++- .../StreamForm/StreamFormGeneralInfo.test.tsx | 97 +++++++++++++++---- .../StreamForm/StreamFormGeneralInfo.tsx | 16 ++- 6 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13171-added-1764924231615.md create mode 100644 packages/manager/.changeset/pr-13171-changed-1764924158682.md diff --git a/packages/api-v4/.changeset/pr-13171-added-1764924231615.md b/packages/api-v4/.changeset/pr-13171-added-1764924231615.md new file mode 100644 index 00000000000..df3606a8bbc --- /dev/null +++ b/packages/api-v4/.changeset/pr-13171-added-1764924231615.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +`Akamai Cloud Pulse Logs LKE-E Audit ` to the `AccountCapability` type ([#13171](https://github.com/linode/manager/pull/13171)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 2f88bc7b053..9c952bd2dd7 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -63,6 +63,7 @@ export const accountCapabilities = [ 'Akamai Cloud Load Balancer', 'Akamai Cloud Pulse', 'Akamai Cloud Pulse Logs', + 'Akamai Cloud Pulse Logs LKE-E Audit', 'Block Storage', 'Block Storage Encryption', 'Cloud Firewall', diff --git a/packages/manager/.changeset/pr-13171-changed-1764924158682.md b/packages/manager/.changeset/pr-13171-changed-1764924158682.md new file mode 100644 index 00000000000..78d7cdecfdc --- /dev/null +++ b/packages/manager/.changeset/pr-13171-changed-1764924158682.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Logs: in Stream Form limit access to "lke_audit_logs" type based on Akamai Cloud Pulse Logs LKE-E Audit capability ([#13171](https://github.com/linode/manager/pull/13171)) diff --git a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts index c5327ed7854..2bb0b56b2d4 100644 --- a/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts +++ b/packages/manager/cypress/e2e/core/delivery/create-stream.spec.ts @@ -1,5 +1,6 @@ import { streamType } from '@linode/api-v4'; import { mockDestination } from 'support/constants/delivery'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateDestination, mockCreateStream, @@ -12,9 +13,11 @@ import { ui } from 'support/ui'; import { logsStreamForm } from 'support/ui/pages/logs-stream-form'; import { randomLabel } from 'support/util/random'; -import { kubernetesClusterFactory } from 'src/factories'; +import { accountFactory, kubernetesClusterFactory } from 'src/factories'; describe('Create Stream', () => { + const account = accountFactory.build(); + beforeEach(() => { mockAppendFeatureFlags({ aclpLogs: { @@ -23,6 +26,14 @@ describe('Create Stream', () => { bypassAccountCapabilities: true, }, }); + + mockGetAccount({ + ...account, + capabilities: [ + ...account.capabilities, + 'Akamai Cloud Pulse Logs LKE-E Audit', + ], + }); }); describe('given Audit Logs Stream Type', () => { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx index 4ca55775ecf..6b28b56f2e9 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -4,10 +4,23 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; +import { accountFactory } from 'src/factories'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; +const queryMocks = vi.hoisted(() => ({ + useAccount: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccount: queryMocks.useAccount, + }; +}); + describe('StreamFormGeneralInfo', () => { describe('when in create mode', () => { it('should render Name input and allow to type text', async () => { @@ -24,35 +37,77 @@ describe('StreamFormGeneralInfo', () => { }); }); - it('should render Stream type input and allow to select different options', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, + describe('when user has Akamai Cloud Pulse Logs LKE-E Audit capability', () => { + it('should render Stream type input and allow to select different options', async () => { + const account = accountFactory.build({ + capabilities: ['Akamai Cloud Pulse Logs LKE-E Audit'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, }, }, - }, - }); + }); - const streamTypesAutocomplete = screen.getByRole('combobox'); + const streamTypesAutocomplete = screen.getByRole('combobox'); - expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - - // Open the dropdown - await userEvent.click(streamTypesAutocomplete); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - // Select the "Kubernetes API Audit Logs" option - const kubernetesApiAuditLogs = await screen.findByText( - 'Kubernetes API Audit Logs' - ); - await userEvent.click(kubernetesApiAuditLogs); + // Open the dropdown + await userEvent.click(streamTypesAutocomplete); - await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue( + // Select the "Kubernetes API Audit Logs" option + const kubernetesApiAuditLogs = await screen.findByText( 'Kubernetes API Audit Logs' ); + await userEvent.click(kubernetesApiAuditLogs); + + await waitFor(() => { + expect(streamTypesAutocomplete).toHaveValue( + 'Kubernetes API Audit Logs' + ); + }); + }); + }); + + describe('when user does not have Akamai Cloud Pulse Logs LKE-E Audit capability', () => { + it('should render disabled Stream type input with Audit Logs selected', async () => { + const account = accountFactory.build({ + capabilities: [], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toBeDisabled(); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index bf2def9dc75..42244a7e24c 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -1,4 +1,5 @@ import { streamType } from '@linode/api-v4'; +import { useAccount } from '@linode/queries'; import { Autocomplete, Box, @@ -35,6 +36,10 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const theme = useTheme(); const { control, setValue } = useFormContext(); + const { data: account } = useAccount(); + const isLkeEAuditLogsTypeSelectionDisabled = !account?.capabilities?.includes( + 'Akamai Cloud Pulse Logs LKE-E Audit' + ); const capitalizedMode = capitalize(mode); const description = { @@ -47,8 +52,13 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, lke_audit_logs: `Logs Delivery Streams ${capitalizedMode}-Kubernetes Audit Logs`, }; + + const filteredStreamTypeOptions = isLkeEAuditLogsTypeSelectionDisabled + ? streamTypeOptions.filter(({ value }) => value !== streamType.LKEAuditLogs) + : streamTypeOptions; + const streamTypeOptionsWithPendo: AutocompleteOption[] = - streamTypeOptions.map((option) => ({ + filteredStreamTypeOptions.map((option) => ({ ...option, pendoId: pendoIds[option.value as StreamType], })); @@ -98,7 +108,9 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { render={({ field, fieldState }) => ( Date: Mon, 15 Dec 2025 16:41:41 +0530 Subject: [PATCH 13/60] fix: [UIE-9523] - Show edit RDNS button for VPC NAT IPv4 address row in linode network tab (#13170) --- .../pr-13170-fixed-1764918493011.md | 5 ++++ .../LinodeIPAddressRow.test.tsx | 24 ++++++++++++++++++- .../LinodeNetworkingActionMenu.tsx | 4 +++- .../LinodesDetail/LinodeNetworking/utils.ts | 20 ++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13170-fixed-1764918493011.md diff --git a/packages/manager/.changeset/pr-13170-fixed-1764918493011.md b/packages/manager/.changeset/pr-13170-fixed-1764918493011.md new file mode 100644 index 00000000000..b5b3ab97f9f --- /dev/null +++ b/packages/manager/.changeset/pr-13170-fixed-1764918493011.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Show edit RDNS button for VPC NAT IPv4 address row in linode network tab ([#13170](https://github.com/linode/manager/pull/13170)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx index a168eeb9ea9..3bebd768d80 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.test.tsx @@ -16,7 +16,9 @@ const ipDisplay = ipResponseToDisplayRows({ ipResponse: ips, isLinodeInterface: false, })[0]; -const ipDisplayVPC = createVPCIPv4Display([vpcIPv4Factory.build()])[0]; +const [ipDisplayVPC, ipDisplayVPCNAT] = createVPCIPv4Display([ + vpcIPv4Factory.build(), +]); const handlers: IPAddressRowHandlers = { handleOpenEditRDNS: vi.fn(), @@ -72,6 +74,26 @@ describe('LinodeIPAddressRow', () => { expect(queryByText('Edit RDNS')).not.toBeInTheDocument(); }); + it('should render a VPC NAT IPv4 Address row', () => { + const { getAllByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + getAllByText(ipDisplayVPCNAT.address); + getAllByText(ipDisplayVPCNAT.type); + // Check if actions were rendered + getAllByText('Edit RDNS'); + }); + it('should disable the row if disabled is true and display a tooltip', async () => { const { findByRole, getByTestId } = renderWithTheme( wrapWithTableBody( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index 92f15b735ef..317c280e9c8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -49,7 +49,9 @@ export const LinodeNetworkingActionMenu = (props: Props) => { 'Reserved IPv4 (private)', 'Reserved IPv4 (public)', 'VPC – IPv4', - 'VPC NAT – IPv4', + 'VPC – IPv6', + 'VPC – Range – IPv4', + 'VPC – Range – IPv6', ].includes(ipType); const deletableIPTypes = ['Private – IPv4', 'Public – IPv4', 'Range – IPv6']; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/utils.ts index abd2d749849..b91994d4b2d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/utils.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/utils.ts @@ -138,6 +138,25 @@ export const ipToDisplay = (ip: IPAddress, key: IPKey): IPDisplay => { }; }; +const ipAddressForVPC = ( + ip: VPCIP, + ipAddress: string, + ipType: string +): IPAddress => { + return { + address: ipAddress, + gateway: ip.gateway, + interface_id: ip.interface_id, + linode_id: ip.linode_id!, + prefix: ip.prefix!, + public: false, + rdns: null, + region: ip.region, + subnet_mask: ip.subnet_mask, + type: ipType, + }; +}; + export const createVPCIPv4Display = (ips: VPCIP[]): IPDisplay[] => { const emptyProps = { gateway: '', @@ -163,6 +182,7 @@ export const createVPCIPv4Display = (ips: VPCIP[]): IPDisplay[] => { } if (ip.nat_1_1) { vpcIPDisplay.push({ + _ip: ipAddressForVPC(ip, ip.nat_1_1, 'VPC NAT – IPv4'), address: ip.nat_1_1, type: 'VPC NAT – IPv4', ...emptyProps, From 7515c1c709b94928cb730a4db2b9f7a36534a404 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:50:56 +0100 Subject: [PATCH 14/60] fix: [UIE-9795], [UIE-9796], [UIE-9797] - Enable account_viewer to access IAM User Details, User Roles and Entities (#13194) * fix: [UIE-9795], [UIE-9796] - Enable account_viewer to access IAM User Details and User Roles * Added changeset: IAM: Enable account_viewer to access IAM User Details and User Roles * disable chip * fix: [UIE-9797] - Enable account_viewer to access IAM User Entites, chip fix * changeset update --- .../.changeset/pr-13194-fixed-1765470340634.md | 5 +++++ .../AssignedRolesTable/AssignedRolesTable.tsx | 1 + .../IAM/Users/UserDetails/UserProfile.tsx | 17 +++++++++++++---- .../Users/UserEntities/UserEntities.test.tsx | 1 + .../IAM/Users/UserEntities/UserEntities.tsx | 14 ++++++++++---- .../IAM/Users/UserRoles/AssignedEntities.tsx | 10 ++++++++-- .../features/IAM/Users/UserRoles/UserRoles.tsx | 14 ++++++++++---- 7 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 packages/manager/.changeset/pr-13194-fixed-1765470340634.md diff --git a/packages/manager/.changeset/pr-13194-fixed-1765470340634.md b/packages/manager/.changeset/pr-13194-fixed-1765470340634.md new file mode 100644 index 00000000000..7341dd6b228 --- /dev/null +++ b/packages/manager/.changeset/pr-13194-fixed-1765470340634.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: Enable account_viewer to access IAM User Details, User Roles and User Entities ([#13194](https://github.com/linode/manager/pull/13194)) diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index 82da1535714..9c75b01fa72 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -262,6 +262,7 @@ export const AssignedRolesTable = () => { ) : ( { const { username } = useParams({ from: '/iam/users/$username' }); - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions } = usePermissions('account', [ + 'is_account_admin', + 'view_account', + ]); const isAccountAdmin = permissions?.is_account_admin; @@ -27,14 +30,20 @@ export const UserProfile = () => { data: user, error, isLoading, - } = useAccountUser(username ?? '', isAccountAdmin); - const { data: assignedRoles } = useUserRoles(username ?? '', isAccountAdmin); + } = useAccountUser( + username ?? '', + isAccountAdmin || permissions?.view_account + ); + const { data: assignedRoles } = useUserRoles( + username ?? '', + isAccountAdmin || permissions?.view_account + ); if (isLoading) { return ; } - if (!isAccountAdmin) { + if (!(isAccountAdmin || permissions?.view_account)) { return ( You do not have permission to view this user's details. 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 1858996f4ea..d608931073c 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx @@ -156,6 +156,7 @@ describe('UserEntities', () => { queryMocks.usePermissions.mockReturnValue({ data: { is_account_admin: false, + view_account: false, }, }); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index 0b469b44a89..a0c3bb39fe9 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -23,16 +23,22 @@ import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const UserEntities = () => { const theme = useTheme(); const { username } = useParams({ from: '/iam/users/$username' }); - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions } = usePermissions('account', [ + 'is_account_admin', + 'view_account', + ]); const { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles(username ?? '', permissions?.is_account_admin); + } = useUserRoles( + username ?? '', + permissions?.is_account_admin || permissions?.view_account + ); const { error } = useAccountUser( username ?? '', - permissions?.is_account_admin + permissions?.is_account_admin || permissions?.view_account ); const hasAssignedRoles = assignedRoles @@ -43,7 +49,7 @@ export const UserEntities = () => { return ; } - if (!permissions?.is_account_admin) { + if (!(permissions?.is_account_admin || permissions?.view_account)) { return ( You do not have permission to view this user's entities. diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index ee40b3929c0..375c5fe2808 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -9,6 +9,7 @@ import type { CombinedEntity, ExtendedRoleView } from '../../Shared/types'; import type { AccountRoleType, EntityRoleType } from '@linode/api-v4'; interface Props { + disabled?: boolean; onButtonClick: (roleName: AccountRoleType | EntityRoleType) => void; onRemoveAssignment: (entity: CombinedEntity, role: ExtendedRoleView) => void; role: ExtendedRoleView; @@ -18,6 +19,7 @@ export const AssignedEntities = ({ onButtonClick, onRemoveAssignment, role, + disabled, }: Props) => { const theme = useTheme(); @@ -54,13 +56,17 @@ export const AssignedEntities = ({ > } + deleteIcon={ + disabled ? undefined : + } label={ entity.name.length > 30 ? `${entity.name.slice(0, 20)}...` : entity.name } - onDelete={() => onRemoveAssignment(entity, role)} + onDelete={ + disabled ? undefined : () => onRemoveAssignment(entity, role) + } sx={{ backgroundColor: theme.name === 'light' diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index e742ab474a5..f2dc0dd8759 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -22,18 +22,24 @@ import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const UserRoles = () => { const { username } = useParams({ from: '/iam/users/$username' }); - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions } = usePermissions('account', [ + 'is_account_admin', + 'view_account', + ]); const theme = useTheme(); const { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles(username ?? '', permissions?.is_account_admin); + } = useUserRoles( + username ?? '', + permissions?.is_account_admin || permissions?.view_account + ); const { error } = useAccountUser( username ?? '', - permissions?.is_account_admin + permissions?.is_account_admin || permissions?.view_account ); const hasAssignedRoles = assignedRoles @@ -45,7 +51,7 @@ export const UserRoles = () => { return ; } - if (!permissions?.is_account_admin) { + if (!(permissions?.is_account_admin || permissions?.view_account)) { return ( You do not have permission to view this user's roles. From 9533b08a00e3942f742fff593a2751c0109fed13 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:25:52 +0100 Subject: [PATCH 15/60] fix: [UIE-9840] - IAM: Remove Role filter (already assigned roles) in `ChangeRoleForEntityDrawer` (#13201) * don't filter out existing roles in ChangeRoleForEntityDrawer * Added changeset: IAM: Remove Role filter (already assigned roles) in ChangeRoleForEntityDrawer --- .../.changeset/pr-13201-fixed-1765798247833.md | 5 +++++ .../ChangeRoleForEntityDrawer.tsx | 16 ++++------------ 2 files changed, 9 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-13201-fixed-1765798247833.md diff --git a/packages/manager/.changeset/pr-13201-fixed-1765798247833.md b/packages/manager/.changeset/pr-13201-fixed-1765798247833.md new file mode 100644 index 00000000000..091d5aadf49 --- /dev/null +++ b/packages/manager/.changeset/pr-13201-fixed-1765798247833.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: Remove Role filter (already assigned roles) in ChangeRoleForEntityDrawer ([#13201](https://github.com/linode/manager/pull/13201)) diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx index 9d76decc962..3d643b19297 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/ChangeRoleForEntityDrawer.tsx @@ -90,24 +90,16 @@ export const ChangeRoleForEntityDrawer = ({ el.access === role?.access && el.value !== role?.role_name; - // Exclude account roles already assigned to the user if (isAccountRole(el)) { - return ( - !assignedRoles?.account_access.includes(el.value) && - matchesRoleContext - ); + return 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 matchesRoleContext; } return true; }); - }, [accountRoles, role, assignedRoles]); + }, [accountRoles, role]); const { control, From b2ffffcbf1336b4d13aff92a1c88afed716c72cc Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Mon, 15 Dec 2025 14:39:53 +0100 Subject: [PATCH 16/60] new: STORIF-181 - Bucket metrics page created. (#13161) * ref: STORIF-181 - BucketDetails folder cleanup. * new: STORIF-181 - Bucket metrics page created. --- .../object-storage-errors.spec.ts | 17 ++++ .../object-storage.smoke.spec.ts | 9 +++ .../bucket-details-multicluster.spec.ts | 64 --------------- .../{ => AccessTab}/AccessSelect.data.tsx | 0 .../{ => AccessTab}/AccessSelect.test.tsx | 0 .../{ => AccessTab}/AccessSelect.tsx | 2 +- .../{ => AccessTab}/BucketAccess.tsx | 0 .../BucketDetail/BucketProperties.styles.ts | 30 ------- .../BucketDetail/BucketProperties.tsx | 49 ----------- .../{ => CertificatesTab}/BucketSSL.test.tsx | 0 .../{ => CertificatesTab}/BucketSSL.tsx | 0 .../BucketDetail/MetricsTab/MetricsTab.tsx | 18 +++++ .../BucketBreadcrumb.styles.ts | 0 .../{ => ObjectsTab}/BucketBreadcrumb.tsx | 2 +- .../{ => ObjectsTab}/BucketDetail.styles.ts | 0 .../{ => ObjectsTab}/BucketDetail.tsx | 6 +- .../{ => ObjectsTab}/CreateFolderDrawer.tsx | 0 .../{ => ObjectsTab}/FolderActionMenu.tsx | 0 .../{ => ObjectsTab}/FolderTableRow.test.tsx | 5 +- .../{ => ObjectsTab}/FolderTableRow.tsx | 0 .../ObjectActionMenu.test.tsx | 0 .../{ => ObjectsTab}/ObjectActionMenu.tsx | 0 .../ObjectDetailsDrawer.test.tsx | 0 .../{ => ObjectsTab}/ObjectDetailsDrawer.tsx | 2 +- .../{ => ObjectsTab}/ObjectTableContent.tsx | 2 +- .../{ => ObjectsTab}/ObjectTableRow.tsx | 0 .../ObjectStorage/BucketDetail/index.tsx | 81 +++++++++++++++---- .../BucketLanding/BucketDetailsDrawer.tsx | 2 +- .../manager/src/routes/objectStorage/index.ts | 10 +++ 29 files changed, 129 insertions(+), 170 deletions(-) delete mode 100644 packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => AccessTab}/AccessSelect.data.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => AccessTab}/AccessSelect.test.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => AccessTab}/AccessSelect.tsx (99%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => AccessTab}/BucketAccess.tsx (100%) delete mode 100644 packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts delete mode 100644 packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => CertificatesTab}/BucketSSL.test.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => CertificatesTab}/BucketSSL.tsx (100%) create mode 100644 packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/BucketBreadcrumb.styles.ts (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/BucketBreadcrumb.tsx (98%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/BucketDetail.styles.ts (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/BucketDetail.tsx (98%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/CreateFolderDrawer.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/FolderActionMenu.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/FolderTableRow.test.tsx (85%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/FolderTableRow.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectActionMenu.test.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectActionMenu.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectDetailsDrawer.test.tsx (100%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectDetailsDrawer.tsx (98%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectTableContent.tsx (99%) rename packages/manager/src/features/ObjectStorage/BucketDetail/{ => ObjectsTab}/ObjectTableRow.tsx (100%) diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts index c0089040708..fb2d73e4b69 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage-errors.spec.ts @@ -5,11 +5,14 @@ import 'cypress-file-upload'; import { mockGetBucketObjects, + mockGetBuckets, mockUploadBucketObject, } from 'support/intercepts/object-storage'; import { makeError } from 'support/util/errors'; import { randomItem, randomLabel, randomString } from 'support/util/random'; +import { objectStorageBucketFactory } from 'src/factories'; + describe('object storage failure paths', () => { /* * - Tests error UI when an object upload fails. @@ -19,6 +22,12 @@ describe('object storage failure paths', () => { it('shows error upon object upload failure', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); const bucketFile = randomItem([ 'object-storage-files/1.txt', 'object-storage-files/2.jpg', @@ -29,6 +38,7 @@ describe('object storage failure paths', () => { const bucketFilename = bucketFile.split('/')[1]; // Mock empty object list and failed object-url upload request. + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); mockUploadBucketObject( bucketLabel, @@ -73,7 +83,14 @@ describe('object storage failure paths', () => { it('shows error upon object list retrieval failure', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects( bucketLabel, bucketCluster, diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 99b2bee66dc..9d36a170faf 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -88,6 +88,13 @@ describe('object storage smoke tests', () => { it('can upload, view, and delete bucket objects - smoke', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, + objects: 0, + }); + const bucketContents = [ 'object-storage-files/1.txt', 'object-storage-files/2.jpg', @@ -95,11 +102,13 @@ describe('object storage smoke tests', () => { 'object-storage-files/4.zip', ]; + mockGetBuckets([bucketMock]).as('getBuckets'); mockGetBucketObjects(bucketLabel, bucketCluster, []).as('getBucketObjects'); cy.visitWithLogin( `/object-storage/buckets/${bucketCluster}/${bucketLabel}` ); + cy.wait('@getBuckets'); cy.wait('@getBucketObjects'); cy.log('Upload bucket objects'); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts deleted file mode 100644 index 137f3458ead..00000000000 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { regionFactory } from '@linode/utilities'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - mockGetBucket, - mockGetBucketObjects, - mockGetBuckets, -} from 'support/intercepts/object-storage'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { ui } from 'support/ui'; -import { randomLabel } from 'support/util/random'; - -import { accountFactory, objectStorageBucketFactory } from 'src/factories'; - -describe('Object Storage Multicluster Bucket Details Tabs', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: false }, - }).as('getFeatureFlags'); - mockGetAccount( - accountFactory.build({ - capabilities: ['Object Storage', 'Object Storage Access Key Regions'], - }) - ).as('getAccount'); - }); - - const mockRegion = regionFactory.build({ - capabilities: ['Object Storage'], - }); - - const mockBucket = objectStorageBucketFactory.build({ - label: randomLabel(), - region: mockRegion.id, - }); - - describe('Properties tab without required capabilities', () => { - /* - * - Confirms that Gen 2-specific "Properties" tab is absent when OBJ Multicluster is enabled. - */ - it(`confirms the Properties tab does not exist for users without 'Object Storage Endpoint Types' capability`, () => { - const { label } = mockBucket; - - mockGetBucket(label, mockRegion.id); - mockGetBuckets([mockBucket]); - mockGetBucketObjects(label, mockRegion.id, []); - mockGetRegions([mockRegion]); - - cy.visitWithLogin( - `/object-storage/buckets/${mockRegion.id}/${label}/properties` - ); - - cy.wait(['@getFeatureFlags', '@getAccount']); - - // Confirm that expected tabs are visible. - ui.tabList.findTabByTitle('Objects').should('be.visible'); - ui.tabList.findTabByTitle('Access').should('be.visible'); - ui.tabList.findTabByTitle('SSL/TLS').should('be.visible'); - - // Confirm that "Properties" tab is absent. - cy.findByText('Properties').should('not.exist'); - }); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.data.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.data.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.data.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.data.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx similarity index 99% rename from packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx index 043356c899e..596ea4c01cc 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/AccessSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/AccessSelect.tsx @@ -20,7 +20,7 @@ import { } from 'src/queries/object-storage/queries'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { bucketACLOptions, objectACLOptions } from '../utilities'; +import { bucketACLOptions, objectACLOptions } from '../../utilities'; import { copy } from './AccessSelect.data'; import type { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/BucketAccess.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketAccess.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/AccessTab/BucketAccess.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts deleted file mode 100644 index 3938a805792..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ActionsPanel, Paper, Typography } from '@linode/ui'; -import { styled } from '@mui/material/styles'; - -export const StyledText = styled(Typography, { - label: 'StyledText', -})(({ theme }) => ({ - lineHeight: 0.5, - paddingLeft: 8, - [theme.breakpoints.down('lg')]: { - marginLeft: 8, - }, - [theme.breakpoints.down('sm')]: { - lineHeight: 1, - }, -})); - -export const StyledRootContainer = styled(Paper, { - label: 'StyledRootContainer', -})(({ theme }) => ({ - marginTop: 25, - padding: theme.spacing(3), -})); - -export const StyledActionsPanel = styled(ActionsPanel, { - label: 'StyledActionsPanel', -})(() => ({ - display: 'flex', - justifyContent: 'right', - padding: 0, -})); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx deleted file mode 100644 index 36b8ccaa0a3..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useSearch } from '@tanstack/react-router'; -import * as React from 'react'; - -import { BucketRateLimitTable } from '../BucketLanding/BucketRateLimitTable'; -import { BucketBreadcrumb } from './BucketBreadcrumb'; -import { - StyledActionsPanel, - StyledRootContainer, - StyledText, -} from './BucketProperties.styles'; - -import type { ObjectStorageBucket } from '@linode/api-v4'; - -interface Props { - bucket: ObjectStorageBucket; -} - -export const BucketProperties = React.memo((props: Props) => { - const { bucket } = props; - const { endpoint_type, hostname, label } = bucket; - const { prefix = '' } = useSearch({ - from: '/object-storage/buckets/$clusterId/$bucketName', - }); - - return ( - <> - - {hostname} - - - - {/* TODO: OBJGen2 - This will be handled once we receive API for bucket rates */} - - - - ); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/CertificatesTab/BucketSSL.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx new file mode 100644 index 00000000000..312c33d8ab8 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/MetricsTab/MetricsTab.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { CloudPulseDashboardWithFilters } from 'src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters'; + +interface Props { + bucketName: string; + clusterId: string; +} + +export const MetricsTab = ({ bucketName, clusterId }: Props) => { + return ( + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx index 9828f3474ef..7c2ad2ceed3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketBreadcrumb.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketBreadcrumb.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; -import { prefixArrayToString } from '../utilities'; +import { prefixArrayToString } from '../../utilities'; import { StyledCopyTooltip, StyledLink, diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx index fbb79c18d48..f843b4caa61 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/BucketDetail.tsx @@ -30,14 +30,14 @@ import { import { fetchBucketAndUpdateCache } from 'src/queries/object-storage/utilities'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { QuotasInfoNotice } from '../QuotasInfoNotice'; -import { deleteObject as _deleteObject } from '../requests'; +import { QuotasInfoNotice } from '../../QuotasInfoNotice'; +import { deleteObject as _deleteObject } from '../../requests'; import { displayName, generateObjectUrl, isEmptyObjectForFolder, tableUpdateAction, -} from '../utilities'; +} from '../../utilities'; import { BucketBreadcrumb } from './BucketBreadcrumb'; import { StyledCreateFolderButton, diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/CreateFolderDrawer.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/CreateFolderDrawer.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderActionMenu.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderActionMenu.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx similarity index 85% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx index ede3b0762af..d1433c90aa4 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { FolderTableRow } from 'src/features/ObjectStorage/BucketDetail/FolderTableRow'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; -import type { FolderTableRowProps } from 'src/features/ObjectStorage/BucketDetail/FolderTableRow'; +import { FolderTableRow } from './FolderTableRow'; + +import type { FolderTableRowProps } from './FolderTableRow'; vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/FolderTableRow.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectActionMenu.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx similarity index 98% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx index 33853d171d0..88d566f93ac 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectDetailsDrawer.tsx @@ -10,7 +10,7 @@ import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/ import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; -import { AccessSelect } from './AccessSelect'; +import { AccessSelect } from '../AccessTab/AccessSelect'; export interface ObjectDetailsDrawerProps { bucketName: string; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx similarity index 99% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx index 618ffdf4562..d780fb63d34 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableContent.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableContent.tsx @@ -6,7 +6,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; -import { displayName, isEmptyObjectForFolder, isFolder } from '../utilities'; +import { displayName, isEmptyObjectForFolder, isFolder } from '../../utilities'; import { FolderTableRow } from './FolderTableRow'; import { ObjectTableRow } from './ObjectTableRow'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableRow.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx rename to packages/manager/src/features/ObjectStorage/BucketDetail/ObjectsTab/ObjectTableRow.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx index 7d9c427e6da..4c6775c1685 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/index.tsx @@ -1,3 +1,4 @@ +import { BetaChip, CircleProgress, ErrorState } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -9,30 +10,50 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useIsObjectStorageGen2Enabled } from 'src/features/ObjectStorage/hooks/useIsObjectStorageGen2Enabled'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; -import { BucketAccess } from './BucketAccess'; - const ObjectList = React.lazy(() => - import('./BucketDetail').then((module) => ({ default: module.BucketDetail })) + import('./ObjectsTab/BucketDetail').then((module) => ({ + default: module.BucketDetail, + })) +); + +const BucketAccess = React.lazy(() => + import('./AccessTab/BucketAccess').then((module) => ({ + default: module.BucketAccess, + })) ); + const BucketSSL = React.lazy(() => - import('./BucketSSL').then((module) => ({ + import('./CertificatesTab/BucketSSL').then((module) => ({ default: module.BucketSSL, })) ); +const BucketMetrics = React.lazy(() => + import('./MetricsTab/MetricsTab').then((module) => ({ + default: module.MetricsTab, + })) +); + +const BUCKET_DETAILS_URL = '/object-storage/buckets/$clusterId/$bucketName'; + export const BucketDetailLanding = React.memo(() => { const { bucketName, clusterId } = useParams({ - from: '/object-storage/buckets/$clusterId/$bucketName', + from: BUCKET_DETAILS_URL, }); + const { aclpServices } = useFlags(); const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); - const { data: bucketsData } = useObjectStorageBuckets( - isObjectStorageGen2Enabled - ); + const { + data: bucketsData, + isLoading, + error, + isPending, + } = useObjectStorageBuckets(isObjectStorageGen2Enabled); const bucket = bucketsData?.buckets.find(({ label }) => label === bucketName); @@ -40,23 +61,39 @@ export const BucketDetailLanding = React.memo(() => { const isGen2Endpoint = endpoint_type === 'E2' || endpoint_type === 'E3'; - const { handleTabChange, tabIndex, tabs } = useTabs([ + const { handleTabChange, tabIndex, tabs, getTabIndex } = useTabs([ { title: 'Objects', - to: `/object-storage/buckets/$clusterId/$bucketName/objects`, + to: `${BUCKET_DETAILS_URL}/objects`, }, { title: 'Access', - to: `/object-storage/buckets/$clusterId/$bucketName/access`, + to: `${BUCKET_DETAILS_URL}/access`, }, - { - hide: !bucketsData || isGen2Endpoint, title: 'SSL/TLS', - to: `/object-storage/buckets/$clusterId/$bucketName/ssl`, + to: `${BUCKET_DETAILS_URL}/ssl`, + hide: isGen2Endpoint, + }, + { + title: 'Metrics', + to: `${BUCKET_DETAILS_URL}/metrics`, + hide: !aclpServices?.objectstorage?.metrics?.enabled, + chip: aclpServices?.objectstorage?.metrics?.beta ? : null, }, ]); + if (isPending || isLoading) { + return ; + } + + if (!bucket || error) { + return ; + } + + const sslTabIndex = getTabIndex(`${BUCKET_DETAILS_URL}/ssl`); + const metricsTabIndex = getTabIndex(`${BUCKET_DETAILS_URL}/metrics`); + return ( <> @@ -85,6 +122,7 @@ export const BucketDetailLanding = React.memo(() => { + { endpointType={endpoint_type} /> - - - + + {!!sslTabIndex && ( + + + + )} + + {!!metricsTabIndex && ( + + + + )} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 95caecac563..6ffcf11205f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -10,7 +10,7 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; -import { AccessSelect } from '../BucketDetail/AccessSelect'; +import { AccessSelect } from '../BucketDetail/AccessTab/AccessSelect'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index d73e6d8d513..ad2d05b180c 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -107,6 +107,15 @@ const objectStorageBucketSSLRoute = createRoute({ ).then((m) => m.bucketDetailLandingLazyRoute) ); +const objectStorageBucketMetricsRoute = createRoute({ + getParentRoute: () => objectStorageBucketDetailRoute, + path: 'metrics', +}).lazy(() => + import( + 'src/features/ObjectStorage/BucketDetail/bucketDetailLandingLazyRoute' + ).then((m) => m.bucketDetailLandingLazyRoute) +); + export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageIndexRoute.addChildren([ objectStorageSummaryLandingRoute, @@ -119,5 +128,6 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageBucketDetailObjectsRoute, objectStorageBucketDetailAccessRoute, objectStorageBucketSSLRoute, + objectStorageBucketMetricsRoute, ]), ]); From 738f57da3c04397463c06228320986fe0515c74f Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:04:30 +0530 Subject: [PATCH 17/60] upcoming: [DI-28502] - Alerts Notification Channels Listing (#13193) * upcoming: [DI-28502] - Alerts Notification Channels Listing * add changeset * add api-v4 changeset --- .../pr-13193-changed-1765542569473.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 4 +- ...r-13193-upcoming-features-1765540702676.md | 5 + .../src/factories/cloudpulse/channels.ts | 4 +- .../NotificationChannelListTable.test.tsx | 161 +++++++++++++++ .../NotificationChannelListTable.tsx | 194 ++++++++++++++++++ .../NotificationChannelListing.test.tsx | 128 ++++++++++++ .../NotificationChannelListing.tsx | 57 ++++- .../NotificationChannelTableRow.test.tsx | 146 +++++++++++++ .../NotificationChannelTableRow.tsx | 45 ++++ .../NotificationsChannelsListing/constants.ts | 36 ++++ .../features/CloudPulse/Alerts/constants.ts | 7 + packages/manager/src/mocks/serverHandlers.ts | 12 +- 13 files changed, 796 insertions(+), 8 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13193-changed-1765542569473.md create mode 100644 packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts diff --git a/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md b/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md new file mode 100644 index 00000000000..575de3bd42d --- /dev/null +++ b/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Renamed updated_at, created_at to updated,created in NotificationChannelBase interface ([#13193](https://github.com/linode/manager/pull/13193)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 07dca57b539..12b8066f928 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -286,13 +286,13 @@ interface NotificationChannelAlerts { interface NotificationChannelBase { alerts: NotificationChannelAlerts[]; channel_type: ChannelType; - created_at: string; + created: string; created_by: string; id: number; label: string; status: NotificationStatus; type: AlertNotificationType; - updated_at: string; + updated: string; updated_by: string; } diff --git a/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md b/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md new file mode 100644 index 00000000000..403ed960a55 --- /dev/null +++ b/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Introduce Listing for ACLP-Alerting Notification channels with ordering, pagination ([#13193](https://github.com/linode/manager/pull/13193)) diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index 9c96cd36e57..e36c53c1884 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -20,12 +20,12 @@ export const notificationChannelFactory = subject: 'Sample Alert', }, }, - created_at: new Date().toISOString(), + created: new Date().toISOString(), created_by: 'user1', id: Factory.each((i) => i), label: Factory.each((id) => `Channel-${id}`), status: 'Enabled', type: 'custom', - updated_at: new Date().toISOString(), + updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx new file mode 100644 index 00000000000..57be8d91987 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx @@ -0,0 +1,161 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationChannelListTable } from './NotificationChannelListTable'; + +const mockScrollToElement = vi.fn(); + +const ALERT_TYPE = 'alerts-definitions'; + +describe('NotificationChannelListTable', () => { + it('should render the notification channel table headers', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('Alerts')).toBeVisible(); + expect(screen.getByText('Channel Type')).toBeVisible(); + expect(screen.getByText('Created By')).toBeVisible(); + expect(screen.getByText('Last Modified')).toBeVisible(); + expect(screen.getByText('Last Modified By')).toBeVisible(); + }); + + it('should render the error message when error is provided', () => { + renderWithTheme( + + ); + + expect( + screen.getByText('Error in fetching the notification channels') + ).toBeVisible(); + }); + + it('should render notification channel rows', () => { + const updated = new Date().toISOString(); + const channel = notificationChannelFactory.build({ + channel_type: 'email', + created_by: 'user1', + label: 'Test Channel', + updated_by: 'user2', + updated, + }); + + renderWithTheme( + + ); + + expect(screen.getByText('Test Channel')).toBeVisible(); + expect(screen.getByText('Email')).toBeVisible(); + expect(screen.getByText('user1')).toBeVisible(); + expect(screen.getByText('user2')).toBeVisible(); + expect( + screen.getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); + + it('should render the loading state', () => { + renderWithTheme( + + ); + + screen.getByTestId('table-row-loading'); + }); + + it('should render tooltip for Alerts column', async () => { + renderWithTheme( + + ); + + const tooltipIcon = screen.getByTestId('tooltip-info-icon'); + await userEvent.hover(tooltipIcon); + + await waitFor(() => { + expect( + screen.getByText( + 'The number of alert definitions associated with the notification channel.' + ) + ).toBeVisible(); + }); + }); + + it('should render multiple notification channels', () => { + const channels = notificationChannelFactory.buildList(5); + + renderWithTheme( + + ); + + channels.forEach((channel) => { + expect(screen.getByText(channel.label)).toBeVisible(); + }); + }); + + it('should display correct alerts count', () => { + const channel = notificationChannelFactory.build({ + alerts: [ + { id: 1, label: 'Alert 1', type: ALERT_TYPE, url: 'url1' }, + { id: 2, label: 'Alert 2', type: ALERT_TYPE, url: 'url2' }, + { id: 3, label: 'Alert 3', type: ALERT_TYPE, url: 'url3' }, + ], + }); + + renderWithTheme( + + ); + + expect(screen.getByText('3')).toBeVisible(); + }); + + it('should render pagination footer', () => { + const channels = notificationChannelFactory.buildList(30); + + renderWithTheme( + + ); + + screen.getByRole('button', { name: /next/i }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx new file mode 100644 index 00000000000..2566bda7132 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -0,0 +1,194 @@ +import { TooltipIcon } from '@linode/ui'; +import { GridLegacy, TableBody, TableHead } from '@mui/material'; +import React from 'react'; + +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableCell } from 'src/components/TableCell'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { + ChannelAlertsTooltipText, + ChannelListingTableLabelMap, +} from './constants'; +import { NotificationChannelTableRow } from './NotificationChannelTableRow'; + +import type { APIError, NotificationChannel } from '@linode/api-v4'; +import type { Order } from '@linode/utilities'; + +export interface NotificationChannelListTableProps { + /** + * The error returned from the API call to fetch notification channels + */ + error?: APIError[]; + /** + * Indicates if the data is loading + */ + isLoading: boolean; + /** + * The list of notification channels to display in the table + */ + notificationChannels: NotificationChannel[]; + /** + * Function to scroll to a specific element on the page + * @returns void + */ + scrollToElement: () => void; +} + +export const NotificationChannelListTable = React.memo( + (props: NotificationChannelListTableProps) => { + const { error, isLoading, notificationChannels, scrollToElement } = props; + + const _error = error + ? getAPIErrorOrDefault( + error, + 'Error in fetching the notification channels.' + ) + : undefined; + + const handleScrollAndPageChange = ( + page: number, + handlePageChange: (p: number) => void + ) => { + handlePageChange(page); + requestAnimationFrame(() => { + scrollToElement(); + }); + }; + + const handleScrollAndPageSizeChange = ( + pageSize: number, + handlePageChange: (p: number) => void, + handlePageSizeChange: (p: number) => void + ) => { + handlePageSizeChange(pageSize); + handlePageChange(1); + requestAnimationFrame(() => { + scrollToElement(); + }); + }; + + const handleSortClick = ( + orderBy: string, + handleOrderChange: (orderBy: string, order?: Order) => void, + handlePageChange: (page: number) => void, + order?: Order + ) => { + if (order) { + handleOrderChange(orderBy, order); + handlePageChange(1); + } + }; + + const { order, orderBy, handleOrderChange, sortedData } = useOrderV2({ + data: notificationChannels, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'label', + }, + from: '/alerts/notification-channels', + }, + preferenceKey: 'alerts-notification-channels', + }); + + return ( + + {({ + count, + data: paginatedAndOrderedNotificationChannels, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => { + const handleTableSort = (orderBy: string, order?: Order) => + handleSortClick( + orderBy, + handleOrderChange, + handlePageChange, + order + ); + + return ( + <> + + + + + {ChannelListingTableLabelMap.map((value) => ( + + {value.colName} + {value.colName === 'Alerts' && ( + + )} + + ))} + + + + + + + + {paginatedAndOrderedNotificationChannels.map( + (channel: NotificationChannel) => ( + + ) + )} + +
+
+ + handleScrollAndPageChange(page, handlePageChange) + } + handleSizeChange={(pageSize) => { + handleScrollAndPageSizeChange( + pageSize, + handlePageChange, + handlePageSizeChange + ); + }} + page={page} + pageSize={pageSize} + sx={{ border: 0 }} + /> + + ); + }} +
+ ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx new file mode 100644 index 00000000000..0e8d18f048c --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx @@ -0,0 +1,128 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationChannelListing } from './NotificationChannelListing'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertNotificationChannelsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, + }; +}); + +const mockNotificationChannels = notificationChannelFactory.buildList(3); + +describe('NotificationChannelListing', () => { + beforeEach(() => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: mockNotificationChannels, + error: null, + isLoading: false, + }); + }); + + it('should render the notification channel listing with search field', () => { + renderWithTheme(); + + expect( + screen.getByPlaceholderText('Search for Notification Channels') + ).toBeVisible(); + }); + + it('should render the notification channels table', () => { + renderWithTheme(); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('Alerts')).toBeVisible(); + expect(screen.getByText('Channel Type')).toBeVisible(); + expect(screen.getByText('Created By')).toBeVisible(); + expect(screen.getByText('Last Modified')).toBeVisible(); + expect(screen.getByText('Last Modified By')).toBeVisible(); + }); + + it('should render notification channel rows', () => { + renderWithTheme(); + + mockNotificationChannels.forEach((channel) => { + expect(screen.getByText(channel.label)).toBeVisible(); + }); + }); + + it('should filter notification channels based on search text', async () => { + const channels = [ + notificationChannelFactory.build({ label: 'Email Channel' }), + notificationChannelFactory.build({ label: 'Slack Channel' }), + notificationChannelFactory.build({ label: 'PagerDuty Channel' }), + ]; + + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: channels, + error: null, + isLoading: false, + }); + + renderWithTheme(); + + const searchField = screen.getByPlaceholderText( + 'Search for Notification Channels' + ); + + await userEvent.type(searchField, 'Email'); + + // Wait for debounce + await vi.waitFor(() => { + expect(screen.getByText('Email Channel')).toBeVisible(); + expect(screen.queryByText('Slack Channel')).not.toBeInTheDocument(); + expect(screen.queryByText('PagerDuty Channel')).not.toBeInTheDocument(); + }); + }); + + it('should show loading state', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: null, + error: null, + isLoading: true, + }); + + renderWithTheme(); + + screen.getByTestId('table-row-loading'); + }); + + it('should show error state', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: null, + error: [{ reason: 'Error in fetching the notification channels' }], + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.getByText('Error in fetching the notification channels') + ).toBeVisible(); + }); + + it('should render empty table when no notification channels exist', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: [], + error: null, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('No data to display.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx index 55559285b9f..4423cadcde4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx @@ -1,6 +1,59 @@ -import { Paper } from '@linode/ui'; +import { Box, Stack } from '@linode/ui'; import React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; + +import { scrollToElement } from '../../Utils/AlertResourceUtils'; +import { NotificationChannelListTable } from './NotificationChannelListTable'; + export const NotificationChannelListing = () => { - return Notification Channel Page; // Temporary placeholder + const { + data: notificationChannels, + error, + isLoading, + } = useAllAlertNotificationChannelsQuery(); + + const [searchText, setSearchText] = React.useState(''); + + const topRef = React.useRef(null); + + const getNotificationChannelsList = React.useMemo(() => { + if (!notificationChannels) { + return []; + } + if (searchText) { + return notificationChannels.filter(({ label }) => + label.toLowerCase().includes(searchText.toLowerCase()) + ); + } + return notificationChannels; + }, [notificationChannels, searchText]); + + return ( + + + + + scrollToElement(topRef.current)} + /> + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx new file mode 100644 index 00000000000..dfce582e1cd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -0,0 +1,146 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { NotificationChannelTableRow } from './NotificationChannelTableRow'; + +describe('NotificationChannelTableRow', () => { + it('should render a notification channel row with all fields', () => { + const updated = new Date().toISOString(); + const channel = notificationChannelFactory.build({ + alerts: [ + { id: 1, label: 'Alert 1', type: 'alerts-definitions', url: 'url1' }, + { id: 2, label: 'Alert 2', type: 'alerts-definitions', url: 'url2' }, + ], + channel_type: 'email', + created_by: 'user1', + label: 'Test Channel', + updated_by: 'user2', + updated, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Test Channel')).toBeVisible(); + expect(screen.getByText('2')).toBeVisible(); // alerts count + expect(screen.getByText('Email')).toBeVisible(); + expect(screen.getByText('user1')).toBeVisible(); + expect(screen.getByText('user2')).toBeVisible(); + expect( + screen.getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); + + it('should render channel type as Email for email type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'email', + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Email')).toBeVisible(); + }); + + it('should render channel type as Slack for slack type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'slack', + content: { + slack: { + message: 'message', + slack_channel: 'channel', + slack_webhook_url: 'url', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Slack')).toBeVisible(); + }); + + it('should render channel type as PagerDuty for pagerduty type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'pagerduty', + content: { + pagerduty: { + attributes: [], + description: 'desc', + service_api_key: 'key', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('PagerDuty')).toBeVisible(); + }); + + it('should render channel type as Webhook for webhook type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'webhook', + content: { + webhook: { + http_headers: [], + webhook_url: 'url', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Webhook')).toBeVisible(); + }); + + it('should render zero alerts count when no alerts are associated', () => { + const channel = notificationChannelFactory.build({ + alerts: [], + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('0')).toBeVisible(); + }); + + it('should render row with correct data-qa attribute', () => { + const channel = notificationChannelFactory.build({ id: 123 }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText(channel.label)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx new file mode 100644 index 00000000000..086e58d0f7a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -0,0 +1,45 @@ +import { useProfile } from '@linode/queries'; +import React from 'react'; + +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { formatDate } from 'src/utilities/formatDate'; + +import { channelTypeMap } from '../../constants'; + +import type { NotificationChannel } from '@linode/api-v4'; + +interface NotificationChannelTableRowProps { + /** + * The notification channel details used by the component to fill the row details + */ + notificationChannel: NotificationChannel; +} + +export const NotificationChannelTableRow = ( + props: NotificationChannelTableRowProps +) => { + const { notificationChannel } = props; + const { data: profile } = useProfile(); + const { id, label, channel_type, created_by, updated, updated_by, alerts } = + notificationChannel; + return ( + + {label} + {alerts.length} + {channelTypeMap[channel_type]} + {created_by} + + {formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: profile?.timezone, + })} + + {updated_by} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts new file mode 100644 index 00000000000..070d4165f23 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts @@ -0,0 +1,36 @@ +import type { NotificationChannel } from '@linode/api-v4'; + +type ChannelListingTableLabel = { + colName: string; + label: keyof NotificationChannel; +}; + +export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ + { + colName: 'Channel Name', + label: 'label', + }, + { + colName: 'Alerts', + label: 'alerts', + }, + { + colName: 'Channel Type', + label: 'channel_type', + }, + { + colName: 'Created By', + label: 'created_by', + }, + { + colName: 'Last Modified', + label: 'updated', + }, + { + colName: 'Last Modified By', + label: 'updated_by', + }, +]; + +export const ChannelAlertsTooltipText = + 'The number of alert definitions associated with the notification channel.'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 51becf87d3c..95670a13860 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -146,6 +146,13 @@ export const channelTypeOptions: Item[] = [ }, ]; +export const channelTypeMap: Record = { + email: 'Email', + pagerduty: 'PagerDuty', + slack: 'Slack', + webhook: 'Webhook', +}; + export const metricOperatorTypeMap: Record = { eq: '=', gt: '>', diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 53b7dd66f8d..705f935cd84 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3563,9 +3563,17 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/monitor/alert-channels', () => { - return HttpResponse.json( - makeResourcePage(notificationChannelFactory.buildList(7)) + const notificationChannels = notificationChannelFactory.buildList(3); + notificationChannels.push( + notificationChannelFactory.build({ + label: 'Email test channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user3', + created_by: 'admin', + }) ); + notificationChannels.push(...notificationChannelFactory.buildList(75)); + return HttpResponse.json(makeResourcePage(notificationChannels)); }), http.get('*/monitor/services', () => { const response: ServiceTypesList = { From 823b8287ac4f37d1f43211b36471bd4c96c874a8 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:02:11 +0100 Subject: [PATCH 18/60] feat: [UIE-9685] - Proactive IAM e2e gating (#13120) * prepare e2e for IAM * Added changeset: Proactive IAM e2e gating * revert volume drawer delete * more fixes * Fix failing rebuild tests by ensuring expected content is on-screen --------- Co-authored-by: Joe D'Amore --- .../pr-13120-tests-1763655928886.md | 5 +++++ .../core/account/account-cancellation.spec.ts | 16 ++++++++++++++ .../account/account-linode-managed.spec.ts | 9 ++++++++ .../account/account-login-history.spec.ts | 9 ++++++++ .../e2e/core/account/display-settings.spec.ts | 9 ++++++++ .../e2e/core/account/user-permissions.spec.ts | 3 +++ .../e2e/core/account/user-profile.spec.ts | 7 +++++++ .../core/account/users-landing-page.spec.ts | 3 +++ .../billing/restricted-user-billing.spec.ts | 3 +++ .../billing/smoke-billing-activity.spec.ts | 3 +++ .../e2e/core/linodes/create-linode.spec.ts | 1 + .../e2e/core/linodes/rebuild-linode.spec.ts | 21 ++++++++++++++++++- .../smoke-linode-landing-table.spec.ts | 3 +++ .../vm-host-maintenance-linode.spec.ts | 3 +++ .../enable-object-storage.spec.ts | 8 +++++++ .../e2e/core/volumes/delete-volume.spec.ts | 1 - 16 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13120-tests-1763655928886.md diff --git a/packages/manager/.changeset/pr-13120-tests-1763655928886.md b/packages/manager/.changeset/pr-13120-tests-1763655928886.md new file mode 100644 index 00000000000..bba53ded1b2 --- /dev/null +++ b/packages/manager/.changeset/pr-13120-tests-1763655928886.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Proactive IAM e2e gating ([#13120](https://github.com/linode/manager/pull/13120)) diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 8db26e068d3..d68aba62661 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -13,6 +13,7 @@ import { mockCancelAccountError, mockGetAccount, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockWebpageUrl } from 'support/intercepts/general'; import { mockGetProfile, @@ -35,6 +36,14 @@ import { import type { CancelAccount } from '@linode/api-v4'; describe('Account cancellation', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can cancel their account from the Account Settings page. * - Confirms that user is warned that account cancellation is destructive. @@ -227,6 +236,13 @@ describe('Account cancellation', () => { }); describe('Parent/Child account cancellation', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); /* * - Confirms that a child user cannot close the account. */ diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts index c9466939e48..7edf0fc8d84 100644 --- a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -20,6 +20,7 @@ import { mockEnableLinodeManagedError, mockGetAccount, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile, @@ -33,6 +34,14 @@ import { accountFactory } from 'src/factories/account'; import type { Linode } from '@linode/api-v4'; describe('Account Linode Managed', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can add linode managed from the Account Settings page. * - Confirms that user is told about the Managed price. diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index c0fed1236d1..7db08d47829 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -8,6 +8,7 @@ import { loginHelperText, } from 'support/constants/account'; import { mockGetAccountLogins } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, @@ -18,6 +19,14 @@ import { PARENT_USER } from 'src/features/Account/constants'; import { formatDate } from 'src/utilities/formatDate'; describe('Account login history', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that a user can navigate to and view the login history page. * - Confirms that login table displays the expected column headers. diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 3ed8e8724d1..3d95d600dc6 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,6 +1,7 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; import { getProfile } from 'support/api/account'; import { mockUpdateUsername } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptGetProfile, mockGetProfileGrants, @@ -50,6 +51,14 @@ const verifyUsernameAndEmail = ( }; describe('Display Settings', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Validates username update flow via the profile display page using mocked data. */ diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 5d66a00e002..8efd2e884d0 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -173,6 +173,9 @@ describe('User permission management', () => { // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts index b288cdd14ea..1beba9a9374 100644 --- a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts @@ -5,6 +5,7 @@ import { mockGetUsers, mockUpdateUsername, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockUpdateProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; @@ -23,6 +24,12 @@ describe('User Profile', () => { const newUsername = randomString(12); const newEmail = `${newUsername}@example.com`; + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + getProfile().then((profile) => { const activeUsername = profile.body.username; const activeEmail = profile.body.email; diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index f3badb4c852..b343ac638d5 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -84,6 +84,9 @@ describe('Users landing page', () => { // TODO M3-10003 - Remove mock once `limitsEvolution` feature flag is removed. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 61f2c081cc2..febde9e4661 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -227,6 +227,9 @@ describe('restricted user billing flows', () => { // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); }); diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 9a9f8946d11..d130776eb56 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -120,6 +120,9 @@ describe('Billing Activity Feed', () => { mockAppendFeatureFlags({ // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); }); /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 838243e286c..ef7213c5fae 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -44,6 +44,7 @@ describe('Create Linode', () => { beforeEach(() => { mockAppendFeatureFlags({ linodeInterfaces: { enabled: false }, + iam: { enabled: false }, }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index dbab679f216..86072c549f6 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -106,6 +106,7 @@ const assertPasswordComplexity = ( desiredPassword: string, passwordStrength: 'Fair' | 'Good' | 'Weak' ) => { + cy.findByLabelText('Root Password').scrollIntoView(); cy.findByLabelText('Root Password').should('be.visible').clear(); cy.focused().type(desiredPassword); @@ -165,6 +166,14 @@ describe('rebuild linode', () => { let almaLinuxImageLabel: string = 'AlmaLinux'; const rootPassword = randomString(16); + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + before(() => { cleanUp(['lke-clusters', 'linodes', 'stackscripts', 'images']); @@ -220,6 +229,7 @@ describe('rebuild linode', () => { .click(); // Type to confirm. + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').type(linode.label); // Verify the password complexity functionality. @@ -296,6 +306,8 @@ describe('rebuild linode', () => { .should('be.visible') .click(); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') .should('be.visible') .type(linode.label); @@ -372,6 +384,8 @@ describe('rebuild linode', () => { .should('be.visible') .click(); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') .should('be.visible') .type(linode.label); @@ -415,6 +429,7 @@ describe('rebuild linode', () => { assertPasswordComplexity(rootPassword, 'Good'); + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').should('be.visible').click(); cy.focused().type(mockLinode.label); @@ -475,6 +490,7 @@ describe('rebuild linode', () => { ).should('be.checked'); // Type to confirm + cy.findByLabelText('Linode Label').scrollIntoView(); cy.findByLabelText('Linode Label').should('be.visible').click(); cy.focused().type(linode.label); @@ -543,7 +559,10 @@ describe('rebuild linode', () => { .click(); // Type to confirm. - cy.findByLabelText('Linode Label').type(linode.label); + cy.findByLabelText('Linode Label').scrollIntoView(); + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); assertPasswordComplexity(rootPassword, 'Good'); submitRebuildWithRetry(); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index d02b019ee9a..3cacea66f7f 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -72,6 +72,9 @@ describe('linode landing checks', () => { // TODO M3-10491 - Remove `iamRbacPrimaryNavChanges` feature flag mock once flag is deleted. mockAppendFeatureFlags({ iamRbacPrimaryNavChanges: true, + iam: { + enabled: false, + }, }); const mockAccountSettings = accountSettingsFactory.build({ managed: false, diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts index 6c6cab74d15..0f0108239e9 100644 --- a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -61,6 +61,9 @@ describe('Host & VM maintenance notification banner', () => { vmHostMaintenance: { enabled: true, }, + iam: { + enabled: false, + }, }).as('getFeatureFlags'); mockGetLinodes(mockLinodes).as('getLinodes'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 02e3d30623b..708b19561b8 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -51,6 +51,14 @@ const objNotes = { }; describe('Object Storage enrollment', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + iam: { + enabled: false, + }, + }); + }); + /* * - Confirms that Object Storage can be enabled using mock API data. * - Confirms that pricing information link is present in enrollment dialog. diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 11792d1ee35..d7fca561e0e 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -106,7 +106,6 @@ describe('volume delete flow', () => { // Confirm that volume is deleted. cy.wait('@deleteVolume').its('response.statusCode').should('eq', 200); cy.findByText(volume.label).should('not.exist'); - ui.toast.assertMessage(`Volume ${volume.label} has been deleted.`); } ); }); From 3e1af1345a7f6ddee99783e884cae1d60933974e Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Wed, 17 Dec 2025 08:24:34 +0100 Subject: [PATCH 19/60] change: [DPS-35843] - Create destination, validate if host and bucket name match (#13176) --- .../pr-13176-tests-1765218851864.md | 5 +++ .../cypress/support/constants/delivery.ts | 4 +-- packages/manager/src/factories/delivery.ts | 2 +- .../DestinationForm/DestinationEdit.test.tsx | 2 +- .../Streams/StreamForm/StreamEdit.test.tsx | 2 +- .../pr-13176-changed-1765202473278.md | 5 +++ packages/validation/src/delivery.schema.ts | 34 +++++++++++++++++-- 7 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-13176-tests-1765218851864.md create mode 100644 packages/validation/.changeset/pr-13176-changed-1765202473278.md diff --git a/packages/manager/.changeset/pr-13176-tests-1765218851864.md b/packages/manager/.changeset/pr-13176-tests-1765218851864.md new file mode 100644 index 00000000000..7b6b7d1335f --- /dev/null +++ b/packages/manager/.changeset/pr-13176-tests-1765218851864.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock Destination data update values ([#13176](https://github.com/linode/manager/pull/13176)) diff --git a/packages/manager/cypress/support/constants/delivery.ts b/packages/manager/cypress/support/constants/delivery.ts index a45d0f343eb..290974a6a2f 100644 --- a/packages/manager/cypress/support/constants/delivery.ts +++ b/packages/manager/cypress/support/constants/delivery.ts @@ -14,8 +14,8 @@ export const mockDestinationPayload: CreateDestinationPayload = { label: randomLabel(), type: destinationType.AkamaiObjectStorage, details: { - host: randomString(), - bucket_name: randomLabel(), + host: 'test-bucket-name.host.com', + bucket_name: 'test-bucket-name', access_key_id: randomString(), access_key_secret: randomString(), path: '/', diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts index 94c422631cd..c3c19a3f472 100644 --- a/packages/manager/src/factories/delivery.ts +++ b/packages/manager/src/factories/delivery.ts @@ -7,7 +7,7 @@ export const destinationFactory = Factory.Sync.makeFactory({ details: { access_key_id: 'Access Id', bucket_name: 'destinations-bucket-name', - host: '3000', + host: 'destinations-bucket-name.host.com', path: 'file', }, id: Factory.each((id) => id), diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx index 04e577c91f9..0d6cf2b387b 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.test.tsx @@ -51,7 +51,7 @@ describe('DestinationEdit', () => { await waitFor(() => { assertInputHasValue('Destination Name', 'Destination 123'); }); - assertInputHasValue('Host', '3000'); + assertInputHasValue('Host', 'destinations-bucket-name.host.com'); assertInputHasValue('Bucket', 'destinations-bucket-name'); assertInputHasValue('Access Key ID', 'Access Id'); assertInputHasValue('Secret Access Key', ''); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index 0b924f69c19..2d1eaacd74b 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -63,7 +63,7 @@ describe('StreamEdit', () => { assertInputHasValue('Destination Name', 'Destination 1'); // Host: - expect(screen.getByText('3000')).toBeVisible(); + expect(screen.getByText('destinations-bucket-name.host.com')).toBeVisible(); // Bucket: expect(screen.getByText('destinations-bucket-name')).toBeVisible(); // Access Key ID: diff --git a/packages/validation/.changeset/pr-13176-changed-1765202473278.md b/packages/validation/.changeset/pr-13176-changed-1765202473278.md new file mode 100644 index 00000000000..b23b7ff90c0 --- /dev/null +++ b/packages/validation/.changeset/pr-13176-changed-1765202473278.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Logs Destination Form - add matching host and bucket name validation ([#13176](https://github.com/linode/manager/pull/13176)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index b0a2fa52127..28a183b16f5 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -57,8 +57,26 @@ const customHTTPsDetailsSchema = object({ endpoint_url: string().max(maxLength, maxLengthMessage).required(), }); +const hostRgx = + // eslint-disable-next-line sonarjs/slow-regex + /(?[a-z0-9-.]+)\.(?:s3(?:-accesspoint)?\.[a-z0-9-]+\.amazonaws\.com|(?!devcloud\.)[a-z0-9-]+\.(?:devcloud\.)?linodeobjects\.com)/; + const akamaiObjectStorageDetailsBaseSchema = object({ - host: string().max(maxLength, maxLengthMessage).required('Host is required.'), + host: string() + .max(maxLength, maxLengthMessage) + .required('Host is required.') + .test( + 'host-must-match-with-bucket-name-if-provided', + 'Bucket name provided as a part of the host must be the same as the bucket.', + (value, ctx) => { + if (ctx.parent.bucket_name) { + const groups = hostRgx.exec(value)?.groups; + return groups ? groups.bucket === ctx.parent.bucket_name : true; + } + + return true; + }, + ), bucket_name: string() .required('Bucket name is required.') .min(3, 'Bucket name must be between 3 and 63 characters.') @@ -71,7 +89,19 @@ const akamaiObjectStorageDetailsBaseSchema = object({ /^(?!.*[.-]{2})[a-z0-9.-]+$/, 'Bucket name must contain only lowercase letters, numbers, periods (.), and hyphens (-). Adjacent periods and hyphens are not allowed.', ) - .max(63, 'Bucket name must be between 3 and 63 characters.'), + .max(63, 'Bucket name must be between 3 and 63 characters.') + .test( + 'bucket-name-same-in-host-if-provided', + 'Bucket must match the bucket name used in the host prefix.', + (value, ctx) => { + if (ctx.parent.host) { + const groups = hostRgx.exec(ctx.parent.host)?.groups; + return groups ? groups.bucket === value : true; + } + + return true; + }, + ), path: string().max(maxLength, maxLengthMessage).defined(), access_key_id: string() .max(maxLength, maxLengthMessage) From f8584b37b1e592e30e5ca64a799cad15f5cf70cb Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Wed, 17 Dec 2025 17:49:20 +0530 Subject: [PATCH 20/60] =?UTF-8?q?upcoming:=20[UIE-9780]=20-=20Add=20new=20?= =?UTF-8?q?API=20endpoints,=20types=20and=20queries=20for=20R=E2=80=A6=20(?= =?UTF-8?q?#13187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * upcoming: [UIE-9780] - Add new API endpoints, types and queries for Resource Locking feature(RESPROT2) * Added changeset: Add new API endpoints and types for Resource Locking feature(RESPROT2) * Added changeset: Add new API queries for CRUD of locks for Resource Locking feature(RESPROT2) * Address review comments. --- ...r-13187-upcoming-features-1765970845449.md | 5 + packages/api-v4/src/index.ts | 2 + packages/api-v4/src/locks/index.ts | 3 + packages/api-v4/src/locks/locks.ts | 63 ++++++++++ packages/api-v4/src/locks/types.ts | 42 +++++++ ...r-13187-upcoming-features-1765970953049.md | 5 + packages/queries/src/index.ts | 1 + packages/queries/src/locks/index.ts | 1 + packages/queries/src/locks/locks.ts | 109 ++++++++++++++++++ 9 files changed, 231 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13187-upcoming-features-1765970845449.md create mode 100644 packages/api-v4/src/locks/index.ts create mode 100644 packages/api-v4/src/locks/locks.ts create mode 100644 packages/api-v4/src/locks/types.ts create mode 100644 packages/queries/.changeset/pr-13187-upcoming-features-1765970953049.md create mode 100644 packages/queries/src/locks/index.ts create mode 100644 packages/queries/src/locks/locks.ts diff --git a/packages/api-v4/.changeset/pr-13187-upcoming-features-1765970845449.md b/packages/api-v4/.changeset/pr-13187-upcoming-features-1765970845449.md new file mode 100644 index 00000000000..51954aa2c35 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13187-upcoming-features-1765970845449.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add new API endpoints and types for Resource Locking feature(RESPROT2) ([#13187](https://github.com/linode/manager/pull/13187)) diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 3b90f57ec71..5581d74b015 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -24,6 +24,8 @@ export * from './kubernetes'; export * from './linodes'; +export * from './locks'; + export * from './longview'; export * from './managed'; diff --git a/packages/api-v4/src/locks/index.ts b/packages/api-v4/src/locks/index.ts new file mode 100644 index 00000000000..0783eccc39f --- /dev/null +++ b/packages/api-v4/src/locks/index.ts @@ -0,0 +1,3 @@ +export * from './locks'; + +export * from './types'; diff --git a/packages/api-v4/src/locks/locks.ts b/packages/api-v4/src/locks/locks.ts new file mode 100644 index 00000000000..bcb9fb65102 --- /dev/null +++ b/packages/api-v4/src/locks/locks.ts @@ -0,0 +1,63 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setData, setMethod, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { CreateLockPayload, ResourceLock } from './types'; + +/** + * getLocks + * + * Returns a paginated list of resource locks on your Account. + * + * @param params { Params } Pagination parameters + * @param filters { Filter } X-Filter for API + */ +export const getLocks = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/locks`), + setMethod('GET'), + setXFilter(filters), + ); + +/** + * getLock + * + * Returns information about a single resource lock. + * + * @param lockId { number } The ID of the lock to retrieve. + */ +export const getLock = (lockId: number) => + Request( + setURL(`${BETA_API_ROOT}/locks/${encodeURIComponent(lockId)}`), + setMethod('GET'), + ); + +/** + * createLock + * + * Creates a new resource lock to prevent accidental deletion or modification. + * + * @param payload { CreateLockPayload } The lock creation payload + * @param payload.entity_type { string } The type of entity to lock (e.g., 'linode') + * @param payload.entity_id { number | string } The ID of the entity to lock + * @param payload.lock_type { string } The type of lock ('cannot_delete', 'cannot_delete_with_subresources') + */ +export const createLock = (payload: CreateLockPayload) => + Request( + setURL(`${BETA_API_ROOT}/locks`), + setData(payload), + setMethod('POST'), + ); + +/** + * deleteLock + * + * Deletes a resource lock, allowing the resource to be deleted or modified. + * + * @param lockId { number } The ID of the lock to delete. + */ +export const deleteLock = (lockId: number) => + Request<{}>( + setURL(`${BETA_API_ROOT}/locks/${encodeURIComponent(lockId)}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/locks/types.ts b/packages/api-v4/src/locks/types.ts new file mode 100644 index 00000000000..5528af7b249 --- /dev/null +++ b/packages/api-v4/src/locks/types.ts @@ -0,0 +1,42 @@ +import type { EntityType } from '../entities'; + +/** + * Types of locks that can be applied to a resource + */ +export type LockType = 'cannot_delete' | 'cannot_delete_with_subresources'; + +/** + * Entity information attached to a lock + */ +export interface LockEntity { + id: number | string; + label?: string; + type: EntityType; + url?: string; +} + +/** + * Request payload for creating a lock + * POST /v4beta/locks + */ +export interface CreateLockPayload { + /** Required: ID of the entity being locked */ + entity_id: number | string; + /** Required: Type of the entity being locked */ + entity_type: EntityType; + /** Required: Type of lock to apply */ + lock_type: LockType; +} + +/** + * Resource Lock object returned from API + * Response from POST /v4beta/locks + */ +export interface ResourceLock { + /** Information about the locked entity */ + entity: LockEntity; + /** Unique identifier for the lock */ + id: number; + /** Type of lock applied */ + lock_type: LockType; +} diff --git a/packages/queries/.changeset/pr-13187-upcoming-features-1765970953049.md b/packages/queries/.changeset/pr-13187-upcoming-features-1765970953049.md new file mode 100644 index 00000000000..a46bf1bff9c --- /dev/null +++ b/packages/queries/.changeset/pr-13187-upcoming-features-1765970953049.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add new API queries for CRUD of locks for Resource Locking feature(RESPROT2) ([#13187](https://github.com/linode/manager/pull/13187)) diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index 6c61dab7f07..041af378f50 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -10,6 +10,7 @@ export * from './firewalls'; export * from './iam'; export * from './images'; export * from './linodes'; +export * from './locks'; export * from './netloadbalancers'; export * from './networking'; export * from './networktransfer'; diff --git a/packages/queries/src/locks/index.ts b/packages/queries/src/locks/index.ts new file mode 100644 index 00000000000..65e637faeed --- /dev/null +++ b/packages/queries/src/locks/index.ts @@ -0,0 +1 @@ +export * from './locks'; diff --git a/packages/queries/src/locks/locks.ts b/packages/queries/src/locks/locks.ts new file mode 100644 index 00000000000..4994d47c40a --- /dev/null +++ b/packages/queries/src/locks/locks.ts @@ -0,0 +1,109 @@ +import { + createLock, + deleteLock, + getLock, + getLocks, +} from '@linode/api-v4/lib/locks'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { APIError, Filter, Params, ResourcePage } from '@linode/api-v4'; +import type { + CreateLockPayload, + ResourceLock, +} from '@linode/api-v4/lib/locks/types'; + +export const lockQueries = createQueryKeys('locks', { + lock: (id: number) => ({ + queryFn: () => getLock(id), + queryKey: [id], + }), + locks: { + contextQueries: { + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLocks(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); + +/** + * useLocksQuery + * + * Returns a paginated list of resource locks + * + * @example + * const { data, isLoading } = useLocksQuery(); + */ +export const useLocksQuery = (params: Params = {}, filter: Filter = {}) => { + return useQuery, APIError[]>({ + ...lockQueries.locks._ctx.paginated(params, filter), + }); +}; + +/** + * useLockQuery + * + * Returns a single resource lock by ID + * + * @example + * const { data: lock } = useLockQuery(123); + */ +export const useLockQuery = (id: number, enabled: boolean = true) => { + return useQuery({ + ...lockQueries.lock(id), + enabled, + }); +}; + +/** + * + * Creates a new resource lock + * POST /v4beta/locks + * + * @example + * const { mutate: createLock } = useCreateLockMutation(); + * createLock({ + * entity_type: 'linode', + * entity_id: 12345, + * lock_type: 'cannot_delete', + * }); + */ +export const useCreateLockMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload) => createLock(payload), + onSuccess: () => { + // Invalidate all lock queries + queryClient.invalidateQueries({ + queryKey: lockQueries.locks.queryKey, + }); + }, + }); +}; + +/** + * + * Deletes a resource lock + * DELETE /v4beta/locks/{lock_id} + * + * @example + * const { mutate: deleteLock } = useDeleteLockMutation(); + * deleteLock(123); + */ +export const useDeleteLockMutation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[], number>({ + mutationFn: (lockId) => deleteLock(lockId), + onSuccess: () => { + // Invalidate all lock queries + queryClient.invalidateQueries({ + queryKey: lockQueries.locks.queryKey, + }); + }, + }); +}; From f399c857788add60692f3c83d0c80f9744cd76b9 Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Wed, 17 Dec 2025 16:25:01 +0100 Subject: [PATCH 21/60] feat: [UIE-9798] - IAM: Enable account_viewer to access roles table (#13200) * feat: [UIE-9798] - IAM: Enable account_viewer to access roles table * update akamai-cds-react-component lib * Added changeset: IAM: allow users with account_viewer role to see the roles table * add a check for admin * update cds-library * update lib --- .../pr-13200-changed-1765812688683.md | 5 ++++ packages/manager/package.json | 2 +- .../src/features/IAM/Roles/Roles.test.tsx | 4 +++ .../manager/src/features/IAM/Roles/Roles.tsx | 6 ++--- .../IAM/Roles/RolesTable/RolesTable.tsx | 9 ++++--- pnpm-lock.yaml | 26 +++++++++---------- 6 files changed, 31 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-13200-changed-1765812688683.md diff --git a/packages/manager/.changeset/pr-13200-changed-1765812688683.md b/packages/manager/.changeset/pr-13200-changed-1765812688683.md new file mode 100644 index 00000000000..432895fc640 --- /dev/null +++ b/packages/manager/.changeset/pr-13200-changed-1765812688683.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +IAM: allow users with account_viewer role to see the roles table ([#13200](https://github.com/linode/manager/pull/13200)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 57a7a6f5844..8fa31de5526 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -45,7 +45,7 @@ "@tanstack/react-query-devtools": "5.51.24", "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", - "akamai-cds-react-components": "0.0.1-alpha.18", + "akamai-cds-react-components": "0.0.1-alpha.19", "algoliasearch": "^4.14.3", "axios": "~1.12.0", "braintree-web": "^3.92.2", diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx index 99c0e2f21fb..680fb22cd6b 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx @@ -59,6 +59,7 @@ describe('RolesLanding', () => { const mockPermissions = accountRolesFactory.build(); queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); @@ -75,6 +76,7 @@ describe('RolesLanding', () => { it('should show an error message if user does not have permissions', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: false, is_account_admin: false, }, }); @@ -88,6 +90,7 @@ describe('RolesLanding', () => { it('should not show the default roles panel for non-child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); @@ -106,6 +109,7 @@ describe('RolesLanding', () => { it('should show the default roles panel for child accounts', () => { queryMocks.usePermissions.mockReturnValue({ data: { + view_account: true, is_account_admin: true, }, }); diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 787b68ed03f..21a1cabbdc5 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -13,10 +13,10 @@ import { DefaultRolesPanel } from './Defaults/DefaultRolesPanel'; export const RolesLanding = () => { const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( 'account', - ['is_account_admin'] + ['view_account', 'is_account_admin'] ); const { data: accountRoles, isLoading } = useAccountRoles( - permissions?.is_account_admin + permissions?.view_account ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isChildAccount, isProfileLoading } = useDelegationRole(); @@ -33,7 +33,7 @@ export const RolesLanding = () => { return ; } - if (!permissions?.is_account_admin) { + if (!(permissions?.view_account || permissions?.is_account_admin)) { return ( You do not have permission to view roles. ); diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index b924f7ed18b..3b331b7a77d 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -224,10 +224,10 @@ export const RolesTable = ({ roles = [] }: Props) => { onClick={() => handleAssignSelectedRoles()} sx={{ height: 34 }} tooltipText={ - selectedRows.length === 0 - ? 'You must select some roles to assign them.' - : !isAccountAdmin - ? 'You do not have permission to assign roles.' + !isAccountAdmin + ? 'You do not have permission to assign roles.' + : selectedRows.length === 0 + ? 'You must select some roles to assign them.' : undefined } > @@ -305,6 +305,7 @@ export const RolesTable = ({ roles = [] }: Props) => { selected={selectedRows.includes(roleRow)} > Date: Wed, 17 Dec 2025 10:45:53 -0500 Subject: [PATCH 22/60] test: [DBAAS1-1386] - Fix cypress test failing for cds select component (#13199) --- .../e2e/core/databases/update-database.spec.ts | 13 ++++++++++--- .../manager/cypress/support/ui/autocomplete.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 2f4ae4a8b9d..0e274db8967 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -187,8 +187,15 @@ const modifyMaintenanceWindow = (label: string, windowValue: string) => { .should('be.visible') .should('be.disabled'); - ui.autocomplete.findByLabel(label).should('be.visible').type(windowValue); - cy.contains(windowValue).should('be.visible').click(); + // Open the dropdown via shadow DOM + ui.cdsAutoComplete.findByLabel(label, 'input[role="combobox"]').click(); + + // Select the value from the dropdown + ui.cdsAutoComplete + .findByLabel(label, '[role="listbox"]') + .contains(windowValue) + .click(); + ui.cdsButton.findButtonByTitle('Save Changes').then((btn) => { btn[0].click(); // Native DOM click }); @@ -343,7 +350,7 @@ const validateActionItems = (state: string, label: string) => { }; // eslint-disable-next-line sonarjs/no-skipped-tests -describe.skip('Update database clusters', () => { +describe('Update database clusters', () => { beforeEach(() => { mockAppendFeatureFlags({ databaseVpc: true, diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts index 0b84262dd13..7313754d48c 100644 --- a/packages/manager/cypress/support/ui/autocomplete.ts +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -43,6 +43,24 @@ export const autocomplete = { }, }; +export const cdsAutoComplete = { + /** + * Finds a cds select component within shadow DOM by its title and returns the Cypress chainable. + * + * @param cdsSelectLabel - Title of cds button to find + * @param role - Role of the element to find within the shadow DOM (e.g., 'combobox', 'listbox') + * + * @returns Cypress chainable. + */ + findByLabel: (label: string, role: string): Cypress.Chainable => { + return cy + .get(`[data-qa-autocomplete="${label}"] cds-select`) + .shadow() + .find(`${role}`) + .should('be.visible'); + }, +}; + /** * Autocomplete Popper UI element. * From 807c7c1848be09c988fabde1a635da9b2ea7fe29 Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Thu, 18 Dec 2025 13:18:26 +0530 Subject: [PATCH 23/60] test[DI-28502]:- Notification channel Management - Listing Page (#13204) * test[DI-28502]:- Notification channel Management - Listing Page * test[DI-28502]:- Notification channel Management - Listing Page * test[DI-28502]:- Remove hardcoded values from notification channel sorting * test[DI-28502]:- Remove hardcoded values from notification channel sorting * test[DI-28502]:- Remove hardcoded values from notification channel sorting --- .../pr-13204-tests-1765854340931.md | 5 + .../alert-notification-channel-list.spec.ts | 360 ++++++++++++++++++ ...ification-channel-permission-tests.spec.ts | 3 +- .../manager/src/factories/featureFlags.ts | 2 +- .../NotificationChannelListTable.tsx | 2 + 5 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13204-tests-1765854340931.md create mode 100644 packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts diff --git a/packages/manager/.changeset/pr-13204-tests-1765854340931.md b/packages/manager/.changeset/pr-13204-tests-1765854340931.md new file mode 100644 index 00000000000..163fa3b8e54 --- /dev/null +++ b/packages/manager/.changeset/pr-13204-tests-1765854340931.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add coverage for the CloudPulse alerts notification channels listing ([#13204](https://github.com/linode/manager/pull/13204)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts new file mode 100644 index 00000000000..4abe866d6e4 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -0,0 +1,360 @@ +/** + * @file Integration Tests for CloudPulse Alerting — Notification Channel Listing Page + */ +import { profileFactory } from '@linode/utilities'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetAlertChannels } from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + +import { + accountFactory, + flagsFactory, + notificationChannelFactory, +} from 'src/factories'; +import { + ChannelAlertsTooltipText, + ChannelListingTableLabelMap, +} from 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { NotificationChannel } from '@linode/api-v4'; + +const sortOrderMap = { + ascending: 'asc', + descending: 'desc', +}; + +const LabelLookup = Object.fromEntries( + ChannelListingTableLabelMap.map((item) => [item.colName, item.label]) +); +type SortOrder = 'ascending' | 'descending'; + +interface VerifyChannelSortingParams { + columnLabel: string; + expected: number[]; + sortOrder: SortOrder; +} + +const notificationChannels = notificationChannelFactory + .buildList(26) + .map((ch, i) => { + const isEmail = i % 2 === 0; + const alerts = Array.from({ length: isEmail ? 5 : 3 }).map((_, idx) => ({ + id: idx + 1, + label: `Alert-${idx + 1}`, + type: 'alerts-definitions', + url: 'Sample', + })); + + if (isEmail) { + return { + ...ch, + id: i + 1, + label: `Channel-${i + 1}`, + type: 'custom', + created_by: 'user', + updated_by: 'user', + channel_type: 'email', + updated: new Date(2024, 0, i + 1).toISOString(), + alerts, + content: { + email: { + email_addresses: [`test-${i + 1}@example.com`], + subject: 'Test Subject', + message: 'Test message', + }, + }, + } as NotificationChannel; + } else { + return { + ...ch, + id: i + 1, + label: `Channel-${i + 1}`, + type: 'default', + created_by: 'system', + updated_by: 'system', + channel_type: 'webhook', + updated: new Date(2024, 0, i + 1).toISOString(), + alerts, + content: { + webhook: { + webhook_url: `https://example.com/webhook/${i + 1}`, + http_headers: [ + { + header_key: 'Authorization', + header_value: 'Bearer secret-token', + }, + ], + }, + }, + } as NotificationChannel; + } + }); + +const isEmailContent = ( + content: NotificationChannel['content'] +): content is { + email: { + email_addresses: string[]; + message: string; + subject: string; + }; +} => 'email' in content; +const mockProfile = profileFactory.build({ + timezone: 'gmt', +}); + +/** + * Verifies sorting of a column in the alerts table. + * + * @param params - Configuration object for sorting verification. + * @param params.columnLabel - The label of the column to sort. + * @param params.sortOrder - Expected sorting order (ascending | descending). + * @param params.expected - Expected row order after sorting. + */ +const VerifyChannelSortingParams = ( + columnLabel: string, + sortOrder: 'ascending' | 'descending', + expected: number[] +) => { + cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true }); + + cy.get(`[data-qa-header="${columnLabel}"]`) + .invoke('attr', 'aria-sort') + .then((current) => { + if (current !== sortOrder) { + cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true }); + } + }); + + cy.get(`[data-qa-header="${columnLabel}"]`).should( + 'have.attr', + 'aria-sort', + sortOrder + ); + + cy.get('[data-qa="notification-channels-table"] tbody:last-of-type tr').then( + ($rows) => { + const actualOrder = $rows + .toArray() + .map((row) => + Number(row.getAttribute('data-qa-notification-channel-cell')) + ); + expect(actualOrder).to.eqls(expected); + } + ); + + const order = sortOrderMap[sortOrder]; + const orderBy = LabelLookup[columnLabel]; + + cy.url().should( + 'endWith', + `/alerts/notification-channels?order=${order}&orderBy=${orderBy}` + ); +}; + +describe('Notification Channel Listing Page', () => { + /** + * Validates the listing page for CloudPulse notification channels. + * Confirms channel data rendering, search behavior, and table sorting + * across all columns using a controlled 26-item mock dataset. + */ + beforeEach(() => { + mockAppendFeatureFlags(flagsFactory.build()); + mockGetProfile(mockProfile); + mockGetAccount(accountFactory.build()); + mockGetAlertChannels(notificationChannels).as( + 'getAlertNotificationChannels' + ); + + cy.visitWithLogin('/alerts/notification-channels'); + + ui.pagination.findPageSizeSelect().click(); + + cy.get('[data-qa-pagination-page-size-option="100"]') + .should('exist') + .click(); + + ui.tooltip.findByText(ChannelAlertsTooltipText).should('be.visible'); + + cy.wait('@getAlertNotificationChannels').then(({ response }) => { + const body = response?.body; + const data = body?.data; + + const channels = data as NotificationChannel[]; + + expect(body?.results).to.eq(notificationChannels.length); + + channels.forEach((item, index) => { + const expected = notificationChannels[index]; + + // Basic fields + expect(item.id).to.eq(expected.id); + expect(item.label).to.eq(expected.label); + expect(item.type).to.eq(expected.type); + expect(item.status).to.eq(expected.status); + expect(item.channel_type).to.eq(expected.channel_type); + + // Creator/updater fields + expect(item.created_by).to.eq(expected.created_by); + expect(item.updated_by).to.eq(expected.updated_by); + + // Email content (safe narrow) + if (isEmailContent(item.content) && isEmailContent(expected.content)) { + expect(item.content.email.email_addresses).to.deep.eq( + expected.content.email.email_addresses + ); + expect(item.content.email.subject).to.eq( + expected.content.email.subject + ); + expect(item.content.email.message).to.eq( + expected.content.email.message + ); + } + + // Alerts list + expect(item.alerts.length).to.eq(expected.alerts.length); + + item.alerts.forEach((alert, aIndex) => { + const expAlert = expected.alerts[aIndex]; + + expect(alert.id).to.eq(expAlert.id); + expect(alert.label).to.eq(expAlert.label); + expect(alert.type).to.eq(expAlert.type); + expect(alert.url).to.eq(expAlert.url); + }); + }); + }); + }); + + it('searches and validates notification channel details', () => { + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('[data-qa="notification-channels-table"]') + .find('tbody') + .last() + .within(() => { + cy.get('tr').should('have.length', 26); + }); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type('Channel-9'); + cy.get('[data-qa="notification-channels-table"]') + .find('tbody') + .last() + .within(() => { + cy.get('tr').should('have.length', 1); + + cy.get('tr').each(($row) => { + const expected = notificationChannels[8]; + + cy.wrap($row).within(() => { + cy.findByText(expected.label).should('be.visible'); + cy.findByText(String(expected.alerts.length)).should('be.visible'); + cy.findByText('Email').should('be.visible'); + cy.get('td').eq(3).should('have.text', expected.created_by); + cy.findByText( + formatDate(expected.updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: 'GMT', + }) + ).should('be.visible'); + cy.get('td').eq(5).should('have.text', expected.updated_by); + }); + }); + }); + }); + + it('sorting and validates notification channel details', () => { + const sortColumns = [ + { + column: 'Channel Name', + ascending: [...notificationChannels] + .sort((a, b) => a.label.localeCompare(b.label)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.label.localeCompare(a.label)) + .map((ch) => ch.id), + }, + { + column: 'Alerts', + ascending: [...notificationChannels] + .sort((a, b) => a.alerts.length - b.alerts.length) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.alerts.length - a.alerts.length) + .map((ch) => ch.id), + }, + + { + column: 'Channel Type', + ascending: [...notificationChannels] + .sort((a, b) => a.channel_type.localeCompare(b.channel_type)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.channel_type.localeCompare(a.channel_type)) + .map((ch) => ch.id), + }, + + { + column: 'Created By', + ascending: [...notificationChannels] + .sort((a, b) => a.created_by.localeCompare(b.created_by)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.created_by.localeCompare(a.created_by)) + .map((ch) => ch.id), + }, + { + column: 'Last Modified', + ascending: [...notificationChannels] + .sort((a, b) => a.updated.localeCompare(b.updated)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.updated.localeCompare(a.updated)) + .map((ch) => ch.id), + }, + { + column: 'Last Modified By', + ascending: [...notificationChannels] + .sort((a, b) => a.updated_by.localeCompare(b.updated_by)) + .map((ch) => ch.id), + + descending: [...notificationChannels] + .sort((a, b) => b.updated_by.localeCompare(a.updated_by)) + .map((ch) => ch.id), + }, + ]; + + cy.get('[data-qa="notification-channels-table"] thead th').as('headers'); + + cy.get('@headers').then(($headers) => { + const actual = Array.from($headers) + .map((th) => th.textContent?.trim()) + .filter(Boolean); + + expect(actual).to.deep.equal([ + 'Channel Name', + 'Alerts', + 'Channel Type', + 'Created By', + 'Last Modified', + 'Last Modified By', + ]); + }); + + sortColumns.forEach(({ column, ascending, descending }) => { + VerifyChannelSortingParams(column, 'ascending', ascending); + VerifyChannelSortingParams(column, 'descending', descending); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts index 766296ca33c..ad08eabc340 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerting-notification-channel-permission-tests.spec.ts @@ -1,7 +1,7 @@ /** * @file Integration Tests for CloudPulse Alerting — Notification Channel Listing Page * - * Covers three access-control behaviors: + * Covers four access-control behaviors: * 1. Access is allowed when `notificationChannels` is true. * 2. Navigation/tab visibility is blocked when `notificationChannels` is false. * 3. Direct URL access is blocked when `notificationChannels` is false. @@ -91,7 +91,6 @@ describe('Notification Channel Listing Page — Access Control', () => { it('blocks direct URL access to /alerts/notification-channels when notificationChannels is disabled', () => { const flags: Partial = { aclp: { beta: true, enabled: true }, - aclpAlerting: { accountAlertLimit: 10, accountMetricLimit: 10, diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index d3a66e9c6ff..999c799fdd7 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -24,7 +24,7 @@ export const flagsFactory = Factory.Sync.makeFactory>({ alertDefinitions: true, beta: true, recentActivity: false, - notificationChannels: false, + notificationChannels: true, editDisabledStatuses: [ 'in progress', 'failed', diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx index 2566bda7132..ff06ae9eb77 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -129,6 +129,8 @@ export const NotificationChannelListTable = React.memo( {ChannelListingTableLabelMap.map((value) => ( Date: Thu, 18 Dec 2025 11:19:21 +0100 Subject: [PATCH 24/60] feat: [UIE-9852] - IAM: disable input based on permission (#13206) * feat: [UIE-9852] - IAM: disable input based on permission * Added changeset: IAM: a permission check to the users table input based on view_account permission --- packages/manager/.changeset/pr-13206-added-1765891159103.md | 5 +++++ .../manager/src/features/IAM/Users/UsersTable/Users.tsx | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13206-added-1765891159103.md diff --git a/packages/manager/.changeset/pr-13206-added-1765891159103.md b/packages/manager/.changeset/pr-13206-added-1765891159103.md new file mode 100644 index 00000000000..2c5627d4775 --- /dev/null +++ b/packages/manager/.changeset/pr-13206-added-1765891159103.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM: a permission check to the users table input based on view_account permission ([#13206](https://github.com/linode/manager/pull/13206)) diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index b4d92e77e73..8946ff22953 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -41,7 +41,10 @@ export const UsersLanding = () => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); const theme = useTheme(); - const { data: permissions } = usePermissions('account', ['create_user']); + const { data: permissions } = usePermissions('account', [ + 'create_user', + 'view_account', + ]); const pagination = usePaginationV2({ currentRoute: '/iam/users', initialPage: 1, @@ -163,6 +166,7 @@ export const UsersLanding = () => { }, }} debounceTime={250} + disabled={!permissions?.view_account} errorText={searchError?.message} hideLabel isSearching={isFetching} From c05fffb956d5e6649bbcdc0e67ade3bd8778d286 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:57:12 +0530 Subject: [PATCH 25/60] refactor: [UIE-9856] - Clean up hardcoded entity name in firewall add event (#13211) --- .../.changeset/pr-13211-tech-stories-1765974066275.md | 5 +++++ packages/manager/src/features/Events/factories/firewall.tsx | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13211-tech-stories-1765974066275.md diff --git a/packages/manager/.changeset/pr-13211-tech-stories-1765974066275.md b/packages/manager/.changeset/pr-13211-tech-stories-1765974066275.md new file mode 100644 index 00000000000..49123e5231f --- /dev/null +++ b/packages/manager/.changeset/pr-13211-tech-stories-1765974066275.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Code clean up for firewall add event factory ([#13211](https://github.com/linode/manager/pull/13211)) diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index 14ec53f3264..c29700b102f 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -69,10 +69,8 @@ export const firewall: PartialEventMap<'firewall'> = { firewall_device_add: { notification: (e) => { if (e.secondary_entity?.type) { - // TODO - Linode Interfaces [M3-10447] - clean this up when API ticket [VPC-3359] is completed const secondaryEntityName = - formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType] ?? - 'Linode Interface'; + formattedTypes[e.secondary_entity.type as FirewallDeviceEntityType]; return ( <> {secondaryEntityName} {' '} From ceee98618268a5b87b6b66ea94eca790b49fb9c6 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:54:29 +0530 Subject: [PATCH 26/60] upcoming: [DI-28662] - Notification Channel Listing Action Items (#13203) * upcoming: [DI-28662] - Notification Channel Listing Action Items * upcoming: [DI-28662] - Minor linting, code comment changes, updated UT * handle fallback, fix type value and factory * update to new type value * add changeset * use params prop for better type-safety * fix type-check failure in recently merged files --- .../pr-13203-changed-1765855832956.md | 5 +++ packages/api-v4/src/cloudpulse/types.ts | 2 +- ...r-13203-upcoming-features-1765855699865.md | 5 +++ .../alert-notification-channel-list.spec.ts | 4 +- .../core/cloudpulse/create-user-alert.spec.ts | 2 +- .../core/cloudpulse/edit-user-alert.spec.ts | 2 +- .../src/factories/cloudpulse/channels.ts | 2 +- .../NotificationChannelDetail.tsx | 11 +++++ ...ertsNotificationChannelsDetailLazyRoute.ts | 8 ++++ .../NotificationChannelActionMenu.tsx | 43 +++++++++++++++++++ .../NotificationChannelListTable.tsx | 11 +++++ .../NotificationChannelListing.test.tsx | 15 +++++++ .../NotificationChannelTableRow.test.tsx | 38 +++++++++++++--- .../NotificationChannelTableRow.tsx | 42 +++++++++++++++--- .../NotificationChannels/Utils/utils.ts | 23 ++++++++++ packages/manager/src/routes/alerts/index.ts | 13 +++++- 16 files changed, 207 insertions(+), 19 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13203-changed-1765855832956.md create mode 100644 packages/manager/.changeset/pr-13203-upcoming-features-1765855699865.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts diff --git a/packages/api-v4/.changeset/pr-13203-changed-1765855832956.md b/packages/api-v4/.changeset/pr-13203-changed-1765855832956.md new file mode 100644 index 00000000000..b230bf39e33 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13203-changed-1765855832956.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +AlertNotificationType from `custom | default` to `user | system` ([#13203](https://github.com/linode/manager/pull/13203)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 12b8066f928..15fed6ab371 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -41,7 +41,7 @@ export type MetricUnitType = | 'second'; export type NotificationStatus = 'Disabled' | 'Enabled'; export type ChannelType = 'email' | 'pagerduty' | 'slack' | 'webhook'; -export type AlertNotificationType = 'custom' | 'default'; +export type AlertNotificationType = 'system' | 'user'; type AlertNotificationEmail = 'email'; type AlertNotificationSlack = 'slack'; type AlertNotificationPagerDuty = 'pagerduty'; diff --git a/packages/manager/.changeset/pr-13203-upcoming-features-1765855699865.md b/packages/manager/.changeset/pr-13203-upcoming-features-1765855699865.md new file mode 100644 index 00000000000..f10783a9efa --- /dev/null +++ b/packages/manager/.changeset/pr-13203-upcoming-features-1765855699865.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Enable Action Item for ACLP-Alerting Notification Channel Listing ([#13203](https://github.com/linode/manager/pull/13203)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index 4abe866d6e4..01c9b8bfad5 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -53,7 +53,7 @@ const notificationChannels = notificationChannelFactory ...ch, id: i + 1, label: `Channel-${i + 1}`, - type: 'custom', + type: 'user', created_by: 'user', updated_by: 'user', channel_type: 'email', @@ -72,7 +72,7 @@ const notificationChannels = notificationChannelFactory ...ch, id: i + 1, label: `Channel-${i + 1}`, - type: 'default', + type: 'system', created_by: 'system', updated_by: 'system', channel_type: 'webhook', 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 e2b882a866b..07d00cbae43 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 @@ -81,7 +81,7 @@ const notificationChannels = notificationChannelFactory.build({ channel_type: 'email', id: 1, label: 'channel-1', - type: 'custom', + type: 'user', }); const customAlertDefinition = alertDefinitionFactory.build({ diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts index e933804e6c6..95e99e6f132 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -118,7 +118,7 @@ const notificationChannels = notificationChannelFactory.build({ channel_type: 'email', id: 1, label: 'Channel-1', - type: 'custom', + type: 'user', }); const mockProfile = profileFactory.build({ timezone: 'gmt', diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index e36c53c1884..42285f5d831 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -25,7 +25,7 @@ export const notificationChannelFactory = id: Factory.each((i) => i), label: Factory.each((id) => `Channel-${id}`), status: 'Enabled', - type: 'custom', + type: 'user', updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx new file mode 100644 index 00000000000..a7b8cecb8f8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/NotificationChannelDetail.tsx @@ -0,0 +1,11 @@ +import { Paper } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import React from 'react'; + +export const NotificationChannelDetail = () => { + const { channelId } = useParams({ + from: '/alerts/notification-channels/detail/$channelId', + }); + // Placeholder content for Notification Channel Detail + return Notification Channel Details - id: {channelId}; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute.ts new file mode 100644 index 00000000000..3675db53970 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute.ts @@ -0,0 +1,8 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { NotificationChannelDetail } from './NotificationChannelDetail'; + +export const cloudPulseAlertsNotificationChannelDetailLazyRoute = + createLazyRoute('/alerts/notification-channels/detail/$channelId')({ + component: NotificationChannelDetail, + }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx new file mode 100644 index 00000000000..8935de1616a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import { getNotificationChannelActionsList } from '../Utils/utils'; + +import type { AlertNotificationType } from '@linode/api-v4'; + +export interface NotificationChannelActionHandlers { + /** + * Callback for show details action + */ + handleDetails: () => void; +} + +export interface NotificationChannelActionMenuProps { + /** + * The label of the Notification Channel + */ + channelLabel: string; + /** + * Handlers for actions like delete, show details, etc., + */ + handlers: NotificationChannelActionHandlers; + /** + * Type of the notification channel + */ + notificationType: AlertNotificationType; +} +export const NotificationChannelActionMenu = ( + props: NotificationChannelActionMenuProps +) => { + const { channelLabel, handlers, notificationType } = props; + + return ( + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx index ff06ae9eb77..caebac62069 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -1,5 +1,6 @@ import { TooltipIcon } from '@linode/ui'; import { GridLegacy, TableBody, TableHead } from '@mui/material'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; import Paginate from 'src/components/Paginate'; @@ -44,7 +45,14 @@ export interface NotificationChannelListTableProps { export const NotificationChannelListTable = React.memo( (props: NotificationChannelListTableProps) => { const { error, isLoading, notificationChannels, scrollToElement } = props; + const navigate = useNavigate(); + const handleDetails = ({ id }: NotificationChannel) => { + navigate({ + to: '/alerts/notification-channels/detail/$channelId', + params: { channelId: String(id) }, + }); + }; const _error = error ? getAPIErrorOrDefault( error, @@ -162,6 +170,9 @@ export const NotificationChannelListTable = React.memo( {paginatedAndOrderedNotificationChannels.map( (channel: NotificationChannel) => ( handleDetails(channel), + }} key={channel.id} notificationChannel={channel} /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx index 0e8d18f048c..c9c6fee5e04 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx @@ -125,4 +125,19 @@ describe('NotificationChannelListing', () => { expect(screen.getByText('Channel Name')).toBeVisible(); expect(screen.getByText('No data to display.')).toBeVisible(); }); + + it('should render the link in the channel name', () => { + renderWithTheme(); + + mockNotificationChannels.forEach((channel) => { + const link = screen.getByRole('link', { + name: channel.label, + }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute( + 'href', + `/alerts/notification-channels/detail/${channel.id}` + ); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx index dfce582e1cd..655049953d8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -8,6 +8,9 @@ import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { NotificationChannelTableRow } from './NotificationChannelTableRow'; describe('NotificationChannelTableRow', () => { + const mockHandleDetails = vi.fn(); + const handlers = { handleDetails: mockHandleDetails }; + it('should render a notification channel row with all fields', () => { const updated = new Date().toISOString(); const channel = notificationChannelFactory.build({ @@ -24,7 +27,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -49,7 +55,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -70,7 +79,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -91,7 +103,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -111,7 +126,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -125,7 +143,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); @@ -137,7 +158,10 @@ describe('NotificationChannelTableRow', () => { renderWithTheme( wrapWithTableBody( - + ) ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx index 086e58d0f7a..6a2eae0d288 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -1,15 +1,22 @@ import { useProfile } from '@linode/queries'; import React from 'react'; +import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { formatDate } from 'src/utilities/formatDate'; import { channelTypeMap } from '../../constants'; +import { NotificationChannelActionMenu } from './NotificationChannelActionMenu'; +import type { NotificationChannelActionHandlers } from './NotificationChannelActionMenu'; import type { NotificationChannel } from '@linode/api-v4'; interface NotificationChannelTableRowProps { + /** + * The callback handlers for clicking an action menu item + */ + handlers: NotificationChannelActionHandlers; /** * The notification channel details used by the component to fill the row details */ @@ -19,16 +26,32 @@ interface NotificationChannelTableRowProps { export const NotificationChannelTableRow = ( props: NotificationChannelTableRowProps ) => { - const { notificationChannel } = props; + const { handlers, notificationChannel } = props; const { data: profile } = useProfile(); - const { id, label, channel_type, created_by, updated, updated_by, alerts } = - notificationChannel; + const { + id, + label, + channel_type, + created_by, + updated, + updated_by, + alerts, + type, + } = notificationChannel; return ( - {label} + + + {label} + + {alerts.length} {channelTypeMap[channel_type]} {created_by} @@ -39,7 +62,16 @@ export const NotificationChannelTableRow = ( })} {updated_by} - + + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts new file mode 100644 index 00000000000..dc563f68137 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts @@ -0,0 +1,23 @@ +import type { NotificationChannelActionHandlers } from '../NotificationsChannelsListing/NotificationChannelActionMenu'; +import type { AlertNotificationType } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +export const getNotificationChannelActionsList = ({ + handleDetails, +}: NotificationChannelActionHandlers): Record< + AlertNotificationType, + Action[] +> => ({ + system: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + ], + user: [ + { + onClick: handleDetails, + title: 'Show Details', + }, + ], +}); diff --git a/packages/manager/src/routes/alerts/index.ts b/packages/manager/src/routes/alerts/index.ts index f91c83d50c2..2edcd922c60 100644 --- a/packages/manager/src/routes/alerts/index.ts +++ b/packages/manager/src/routes/alerts/index.ts @@ -77,6 +77,15 @@ const cloudPulseNotificationChannelsRoute = createRoute({ ).then((m) => m.cloudPulseAlertsNotificationChannelsListingLazyRoute) ); +const cloudPulseNotificationChannelDetailRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/detail/$channelId', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationChannelDetail/cloudPulseAlertsNotificationChannelsDetailLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelDetailLazyRoute) +); + export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsIndexRoute, cloudPulseAlertsDefinitionsRoute.addChildren([ @@ -85,5 +94,7 @@ export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsDefinitionsEditRoute, ]), cloudPulseAlertsDefinitionsCatchAllRoute, - cloudPulseNotificationChannelsRoute, + cloudPulseNotificationChannelsRoute.addChildren([ + cloudPulseNotificationChannelDetailRoute, + ]), ]); From a4b4e69b11faeb4a4f7b3b36f2ee6ad7e0338fa2 Mon Sep 17 00:00:00 2001 From: smans-akamai Date: Thu, 18 Dec 2025 10:42:11 -0500 Subject: [PATCH 27/60] upcoming: [UIE-9378] - DBaaS - Display connection pool section and table in Networking tab (#13195) * upcoming: [UIE-9378] - DBaaS - Display connection pool section and table in Networking tab * Adding changesets * Applying feedback part 1: Updating to exported StyledActionMenuWrapper and applying paginator min, results and page sizes * Feedback part 2: Applying feedback on Stack usage in makeSettingsItemStyles * Removing refretchInterval --- .../pr-13195-changed-1765486916993.md | 5 + packages/api-v4/src/databases/databases.ts | 6 +- .../pr-13195-changed-1765486842490.md | 5 + ...r-13195-upcoming-features-1765486813689.md | 5 + .../DatabaseConnectionPools.test.tsx | 111 ++++++++++ .../DatabaseConnectionPools.tsx | 208 ++++++++++++++++++ .../DatabaseManageNetworking.tsx | 48 +--- .../DatabaseNetworking/DatabaseNetworking.tsx | 8 + .../Databases/DatabaseLanding/DatabaseRow.tsx | 26 +-- .../src/features/Databases/shared.styles.ts | 50 +++++ packages/manager/src/mocks/serverHandlers.ts | 6 + packages/queries/src/databases/databases.ts | 10 +- packages/queries/src/databases/keys.ts | 4 + 13 files changed, 425 insertions(+), 67 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13195-changed-1765486916993.md create mode 100644 packages/manager/.changeset/pr-13195-changed-1765486842490.md create mode 100644 packages/manager/.changeset/pr-13195-upcoming-features-1765486813689.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx create mode 100644 packages/manager/src/features/Databases/shared.styles.ts diff --git a/packages/api-v4/.changeset/pr-13195-changed-1765486916993.md b/packages/api-v4/.changeset/pr-13195-changed-1765486916993.md new file mode 100644 index 00000000000..d2efb254cd2 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13195-changed-1765486916993.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Updated getDatabaseConnectionPools signature to accept params for pagination ([#13195](https://github.com/linode/manager/pull/13195)) diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index e23f1ac3536..7a9fdbd23ec 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -371,12 +371,16 @@ export const getDatabaseEngineConfig = (engine: Engine) => /** * Get a paginated list of connection pools for a database */ -export const getDatabaseConnectionPools = (databaseID: number) => +export const getDatabaseConnectionPools = ( + databaseID: number, + params?: Params, +) => Request>( setURL( `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, ), setMethod('GET'), + setParams(params), ); /** diff --git a/packages/manager/.changeset/pr-13195-changed-1765486842490.md b/packages/manager/.changeset/pr-13195-changed-1765486842490.md new file mode 100644 index 00000000000..fd49cef5b8d --- /dev/null +++ b/packages/manager/.changeset/pr-13195-changed-1765486842490.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +DBaaS table action menu wrapper and settings item styles are shared and connection pool queries updated for pagination ([#13195](https://github.com/linode/manager/pull/13195)) diff --git a/packages/manager/.changeset/pr-13195-upcoming-features-1765486813689.md b/packages/manager/.changeset/pr-13195-upcoming-features-1765486813689.md new file mode 100644 index 00000000000..87de5643574 --- /dev/null +++ b/packages/manager/.changeset/pr-13195-upcoming-features-1765486813689.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS PgBouncer Connection Pools section to be displayed in Networking tab for PostgreSQL database clusters ([#13195](https://github.com/linode/manager/pull/13195)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx new file mode 100644 index 00000000000..04202fbf78e --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx @@ -0,0 +1,111 @@ +import { screen } from '@testing-library/react'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { + databaseConnectionPoolFactory, + databaseFactory, +} from 'src/factories/databases'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseConnectionPools } from './DatabaseConnectionPools'; + +const mockDatabase = databaseFactory.build({ + platform: 'rdbms-default', + private_network: null, + engine: 'postgresql', + id: 1, +}); + +const mockConnectionPool = databaseConnectionPoolFactory.build({ + database: 'defaultdb', + label: 'pool-1', + mode: 'transaction', + size: 10, + username: null, +}); + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useDatabaseConnectionPoolsQuery: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabaseConnectionPoolsQuery: queryMocks.useDatabaseConnectionPoolsQuery, + }; +}); + +describe('DatabaseManageNetworkingDrawer Component', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render PgBouncer Connection Pools field', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + renderWithTheme(); + + const heading = screen.getByRole('heading'); + expect(heading.textContent).toBe('Manage PgBouncer Connection Pools'); + const addPoolBtnLabel = screen.getByText('Add Pool'); + expect(addPoolBtnLabel).toBeInTheDocument(); + }); + + it('should render loading state', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: true, + }); + const loadingTestId = 'circle-progress'; + renderWithTheme(); + + const loadingCircle = screen.getByTestId(loadingTestId); + expect(loadingCircle).toBeInTheDocument(); + }); + + it('should render table with connection pool data', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + + renderWithTheme(); + + const connectionPoolLabel = screen.getByText(mockConnectionPool.label); + expect(connectionPoolLabel).toBeInTheDocument(); + }); + + it('should render table empty state when no data is provided', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + }); + + renderWithTheme(); + + const emptyStateText = screen.getByText( + "You don't have any connection pools added." + ); + expect(emptyStateText).toBeInTheDocument(); + }); + + it('should render error state state when backend responds with error', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + error: new Error('Failed to fetch VPC'), + }); + + renderWithTheme(); + const errorStateText = screen.getByText( + 'There was a problem retrieving your connection pools. Refresh the page or try again later.' + ); + expect(errorStateText).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx new file mode 100644 index 00000000000..f905b2639c2 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -0,0 +1,208 @@ +import { useDatabaseConnectionPoolsQuery } from '@linode/queries'; +import { + Button, + CircleProgress, + ErrorState, + Hidden, + Stack, + Typography, +} from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; +import React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { + MIN_PAGE_SIZE, + PAGE_SIZES, +} from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { + makeSettingsItemStyles, + StyledActionMenuWrapper, +} from '../../shared.styles'; + +import type { Database } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface Props { + database: Database; + disabled?: boolean; +} + +export const DatabaseConnectionPools = ({ database }: Props) => { + const { classes } = makeSettingsItemStyles(); + const theme = useTheme(); + const poolLabelCellStyles = { + flex: '.5 1 20.5%', + }; + + const pagination = usePaginationV2({ + currentRoute: '/databases/$engine/$databaseId/networking', + initialPage: 1, + preferenceKey: `database-connection-pools-pagination`, + }); + + const { + data: connectionPools, + error: connectionPoolsError, + isLoading: connectionPoolsLoading, + } = useDatabaseConnectionPoolsQuery(database.id, true, { + page: pagination.page, + page_size: pagination.pageSize, + }); + + const connectionPoolActions: Action[] = [ + { + onClick: () => null, + title: 'Edit', // TODO: UIE-9395 Implement edit functionality + }, + { + onClick: () => null, // TODO: UIE-9430 Implement delete functionality + title: 'Delete', + }, + ]; + + if (connectionPoolsLoading) { + return ; + } + + if (connectionPoolsError) { + return ( + + ); + } + + return ( + <> +
+ + + Manage PgBouncer Connection Pools + + + Manage PgBouncer connection pools to minimize the use of your server + resources. + + + +
+
+ + + + + Pool Label + + + Pool Mode + + + Pool Size + + + Username + + + + + + {connectionPools?.data.length === 0 ? ( + + + You don't have any connection pools added. + + + ) : ( + connectionPools?.data.map((pool) => ( + + + {pool.label} + + + + {`${pool.mode.charAt(0).toUpperCase()}${pool.mode.slice(1)}`} + + + + {pool.size} + + + + {pool.username === null + ? 'Reuse inbound user' + : pool.username} + + + + + + + )) + )} + +
+
+ {(connectionPools?.results || 0) > MIN_PAGE_SIZE && ( + ) => + pagination.handlePageChange(Number(e.detail)) + } + onPageSizeChange={( + e: CustomEvent<{ page: number; pageSize: number }> + ) => pagination.handlePageSizeChange(Number(e.detail.pageSize))} + page={pagination.page} + pageSize={pagination.pageSize} + pageSizes={PAGE_SIZES} + style={{ + borderLeft: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderRight: `1px solid ${theme.tokens.alias.Border.Normal}`, + borderTop: 0, + marginTop: '0', + }} + /> + )} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx index 3e35fa76bbb..a441e72e98c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx @@ -4,15 +4,16 @@ import { Button, CircleProgress, ErrorState, + Stack, Typography, } from '@linode/ui'; import React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../../constants'; +import { makeSettingsItemStyles } from '../../shared.styles'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; import { StyledGridContainer } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; @@ -20,7 +21,6 @@ import DatabaseManageNetworkingDrawer from './DatabaseManageNetworkingDrawer'; import { DatabaseNetworkingUnassignVPCDialog } from './DatabaseNetworkingUnassignVPCDialog'; import type { Database } from '@linode/api-v4'; -import type { Theme } from '@mui/material'; interface Props { database: Database; @@ -28,42 +28,8 @@ interface Props { } export const DatabaseManageNetworking = ({ database }: Props) => { - const useStyles = makeStyles()((theme: Theme) => ({ - manageNetworkingBtn: { - minWidth: 225, - [theme.breakpoints.down('md')]: { - alignSelf: 'flex-start', - marginTop: '1rem', - marginBottom: '1rem', - }, - }, - sectionText: { - marginBottom: '1rem', - marginRight: 0, - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '65%', - }, - sectionTitle: { - marginBottom: '0.25rem', - display: 'flex', - }, - sectionTitleAndText: { - width: '100%', - }, - topSection: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - [theme.breakpoints.down('md')]: { - flexDirection: 'column', - }, - }, - })); - const flags = useFlags(); - const { classes } = useStyles(); + const { classes } = makeSettingsItemStyles(); const [isManageNetworkingDrawerOpen, setIsManageNetworkingDrawerOpen] = React.useState(false); const [isUnassignVPCDialogOpen, setIsUnassignVPCDialogOpen] = @@ -111,8 +77,8 @@ export const DatabaseManageNetworking = ({ database }: Props) => { return ( <>
-
-
+ +
Manage Networking {flags.databaseVpcBeta && }
@@ -128,10 +94,10 @@ export const DatabaseManageNetworking = ({ database }: Props) => { availability. Avoid writing data to the database while a change is in progress. -
+
+ {connectionPools && connectionPools.data.length > 0 && ( + + )}
{ + const flags = useFlags(); const navigate = useNavigate(); const { database, disabled, engine, isVPCEnabled } = useDatabaseDetailContext(); - const flags = useFlags(); - const accessControlCopy = ( {ACCESS_CONTROLS_IN_SETTINGS_TEXT} ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx new file mode 100644 index 00000000000..2b65b71c560 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -0,0 +1,94 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { databaseFactory } from 'src/factories/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ServiceURI } from './ServiceURI'; + +const mockDatabase = databaseFactory.build({ + connection_pool_port: 100, + engine: 'postgresql', + id: 1, + platform: 'rdbms-default', + private_network: null, +}); + +const mockCredentials = { + password: 'password123', + username: 'lnroot', +}; + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useDatabaseCredentialsQuery: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, + }; +}); + +describe('ServiceURI', () => { + it('should render the service URI component and copy icon', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + const { container } = renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + ); + + // eslint-disable-next-line testing-library/no-container + const copyButton = container.querySelector('[data-qa-copy-btn]'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should reveal password after clicking reveal button', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + refetch: vi.fn(), + }); + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + await userEvent.click(revealPasswordBtn); + + const serviceURIText = screen.getByTestId('service-uri').textContent; + expect(revealPasswordBtn).not.toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + ); + }); + + it('should render error retry button if the credentials call fails', () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + error: new Error('Failed to fetch credentials'), + }); + + renderWithTheme(); + + const errorRetryBtn = screen.getByRole('button', { + name: '{error. click to retry}', + }); + expect(errorRetryBtn).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx new file mode 100644 index 00000000000..adcc31ed8cc --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx @@ -0,0 +1,152 @@ +import { useDatabaseCredentialsQuery } from '@linode/queries'; +import { Button } from '@linode/ui'; +import { Grid, styled } from '@mui/material'; +import copy from 'copy-to-clipboard'; +import { enqueueSnackbar } from 'notistack'; +import React, { useState } from 'react'; + +import { Code } from 'src/components/Code/Code'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; + +import type { Database } from '@linode/api-v4'; + +interface ServiceURIProps { + database: Database; +} + +export const ServiceURI = (props: ServiceURIProps) => { + const { database } = props; + + const [hidePassword, setHidePassword] = useState(true); + const [isCopying, setIsCopying] = useState(false); + + const { + data: credentials, + error: credentialsError, + isLoading: credentialsLoading, + isFetching: credentialsFetching, + refetch: getDatabaseCredentials, + } = useDatabaseCredentialsQuery(database.engine, database.id, !hidePassword); + + const handleCopy = async () => { + if (!credentials) { + try { + setIsCopying(true); + const { data } = await getDatabaseCredentials(); + if (data) { + // copy with username/password data + copy( + `postgres://${data?.username}:${data?.password}@${database.hosts?.primary}?sslmode=require` + ); + } else { + enqueueSnackbar( + 'There was an error retrieving cluster credentials. Please try again.', + { variant: 'error' } + ); + } + setIsCopying(false); + } catch { + setIsCopying(false); + enqueueSnackbar( + 'There was an error retrieving cluster credentials. Please try again.', + { variant: 'error' } + ); + } + } + }; + + const serviceURI = `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}?sslmode=require`; + + // hide loading state if the user clicks on the copy icon + const showBtnLoading = + !isCopying && (credentialsLoading || credentialsFetching); + + return ( + + + Service URI + + + + postgres:// + {credentialsError ? ( + + ) : hidePassword || (!credentialsError && !credentials) ? ( + + ) : ( + `${credentials?.username}:${credentials?.password}` + )} + @{database.hosts?.primary}: + {'{connection pool port}'}/ + {'{connection pool label}'}?sslmode=require + + {isCopying ? ( + + ) : ( + + + + )} + + + ); +}; + +export const StyledCode = styled(Code, { + label: 'StyledCode', +})(() => ({ + margin: 0, +})); + +export const StyledCopyTooltip = styled(CopyTooltip, { + label: 'StyledCopyTooltip', +})(({ theme }) => ({ + alignSelf: 'center', + '& svg': { + height: theme.spacingFunction(16), + width: theme.spacingFunction(16), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'flex', + margin: `0 ${theme.spacingFunction(4)}`, +})); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4fdf8ec3d23..90421bd0b4f 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -211,6 +211,11 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } + + if (db.engine === 'postgresql') { + db.connection_pool_port = 100; + } + const database = databaseFactory.build(db); if (database.platform !== 'rdbms-default') { From 8c8d9164711c243bfe87e59f02720baf81ea69c7 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 24 Dec 2025 11:30:23 +0530 Subject: [PATCH 35/60] upcoming: [DI-28503] - Add filter components for notification channel create flow (#13217) * upcoming: [DI-28503] - Add components for notification channel create flow * upcoming: [DI-28503] - Add changeset * upcoming: [DI-28503] - PR comment * upcoming: [DI-28503] - Prefer complete user obj in recipient options, address comments --- ...r-13217-upcoming-features-1766329412234.md | 5 + packages/manager/src/featureFlags.ts | 1 + .../NotificationChannelTypeSelect.test.tsx | 97 ++++++ .../NotificationChannelTypeSelect.tsx | 54 +++ .../NotificationRecipients.test.tsx | 324 ++++++++++++++++++ .../CreateChannel/NotificationRecipients.tsx | 181 ++++++++++ 6 files changed, 662 insertions(+) create mode 100644 packages/manager/.changeset/pr-13217-upcoming-features-1766329412234.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.tsx diff --git a/packages/manager/.changeset/pr-13217-upcoming-features-1766329412234.md b/packages/manager/.changeset/pr-13217-upcoming-features-1766329412234.md new file mode 100644 index 00000000000..c62e68b6faa --- /dev/null +++ b/packages/manager/.changeset/pr-13217-upcoming-features-1766329412234.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Alerts: Add components for create notification channel flow ([#13217](https://github.com/linode/manager/pull/13217)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 355c00b044a..dc70ad3bbfa 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -156,6 +156,7 @@ interface AclpAlerting { alertDefinitions: boolean; beta: boolean; editDisabledStatuses?: AlertStatusType[]; + maxEmailChannelRecipients?: number; notificationChannels: boolean; recentActivity: boolean; systemChannelSupportedServices?: CloudPulseServiceType[]; // linode, dbaas, etc. diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.test.tsx new file mode 100644 index 00000000000..b06c6584e0b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.test.tsx @@ -0,0 +1,97 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { channelTypeOptions } from '../../constants'; +import { NotificationChannelTypeSelect } from './NotificationChannelTypeSelect'; + +import type { NotificationChannelTypeSelectProps } from './NotificationChannelTypeSelect'; + +const mockHandleChannelTypeChange = vi.fn(); +const mockOnBlur = vi.fn(); + +const props: NotificationChannelTypeSelectProps = { + handleChannelTypeChange: mockHandleChannelTypeChange, + onBlur: mockOnBlur, + options: channelTypeOptions, + value: null, +}; + +describe('NotificationChannelTypeSelect component tests', () => { + it('should render the Autocomplete component', () => { + renderWithTheme(); + + expect(screen.getByTestId('channel-type-select')).toBeVisible(); + expect(screen.getByText('Type')).toBeVisible(); + expect(screen.getByPlaceholderText('Select a Channel Type')).toBeVisible(); + }); + + it('should render channel type options when opened and able to select an option', async () => { + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(await screen.findByRole('option', { name: 'Email' })).toBeVisible(); + + // select the email option + await userEvent.click(await screen.findByRole('option', { name: 'Email' })); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Email'); + + // verify handleChannelTypeChange is called with the correct value + expect(mockHandleChannelTypeChange).toHaveBeenCalledWith('email'); + }); + + it('should be able to clear the selected channel type', async () => { + renderWithTheme(); + + // Verify initial value is set + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Email'); + + // Click the clear button + const clearButton = screen.getByLabelText('Clear'); + await userEvent.click(clearButton); + + // verify handleChannelTypeChange is called with null + expect(screen.getByRole('combobox')).toHaveAttribute('value', ''); + expect(mockHandleChannelTypeChange).toHaveBeenCalledWith(null); + }); + + it('should display error message when error prop is provided', () => { + const errorMessage = 'This field is required'; + + renderWithTheme( + + ); + + expect(screen.getByText(errorMessage)).toBeVisible(); + }); + + it('should call onBlur when the field loses focus', async () => { + renderWithTheme(); + + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); + await userEvent.tab(); + + expect(mockOnBlur).toHaveBeenCalled(); + }); + + it('should handle empty options array', async () => { + renderWithTheme(); + + expect(screen.getByTestId('channel-type-select')).toBeVisible(); + expect(screen.getByPlaceholderText('Select a Channel Type')).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByText('You have no options to choose from') + ).toBeVisible(); + }); + + it('should render with null value', async () => { + renderWithTheme(); + + expect(screen.getByRole('combobox')).toHaveAttribute('value', ''); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx new file mode 100644 index 00000000000..52de2604463 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx @@ -0,0 +1,54 @@ +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import type { Item } from '../../constants'; +import type { ChannelType } from '@linode/api-v4'; + +export interface NotificationChannelTypeSelectProps { + /** + * Error text to display when the field has a validation error + */ + error?: string; + /** + * Function to handle the change of the channel type + */ + handleChannelTypeChange: (value: ChannelType | null) => void; + /** + * Function to handle the blur event + */ + onBlur?: () => void; + /** + * Options for the channel type select + */ + options: Item[]; + /** + * Value of the channel type in the form + */ + value: ChannelType | null; +} + +export const NotificationChannelTypeSelect = React.memo( + (props: NotificationChannelTypeSelectProps) => { + const { error, handleChannelTypeChange, value, options, onBlur } = props; + + return ( + { + if (selected) { + handleChannelTypeChange(selected.value); + } + if (reason === 'clear') { + handleChannelTypeChange(null); + } + }} + options={options} + placeholder="Select a Channel Type" + value={options.find((option) => option.value === value) ?? null} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.test.tsx new file mode 100644 index 00000000000..3cc5d01944b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.test.tsx @@ -0,0 +1,324 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { accountUserFactory } from 'src/factories/accountUsers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationRecipients } from './NotificationRecipients'; + +import type { NotificationRecipientsProps } from './NotificationRecipients'; + +const queryMocks = vi.hoisted(() => ({ + useAccountUsersInfiniteQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUsersInfiniteQuery: queryMocks.useAccountUsersInfiniteQuery, + }; +}); + +const mockOnChange = vi.fn(); +const mockOnBlur = vi.fn(); + +const props: NotificationRecipientsProps = { + onChange: mockOnChange, + onBlur: mockOnBlur, + value: [], +}; + +const SELECT_ALL = 'Select All'; +const DESELECT_ALL = 'Deselect All'; +const ARIA_SELECTED = 'aria-selected'; + +describe('NotificationRecipients component tests', () => { + it('should render the component with empty state', () => { + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: [] }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByTestId('recipients-select')).toBeVisible(); + expect(screen.getByPlaceholderText('Select recipients')).toBeVisible(); + expect(screen.getByText('Select up to 10 Recipients')).toBeVisible(); + }); + + it('should render loading state', () => { + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: null, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: true, + }); + + renderWithTheme(); + + expect(screen.getByTestId('recipients-select')).toBeVisible(); + }); + + it('should be able to select all recipients', async () => { + const mockUsers = accountUserFactory.buildList(2); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: SELECT_ALL }) + ); + + // Verify onChange was called with all usernames + expect(mockOnChange).toHaveBeenCalledWith( + mockUsers.map((user) => user.username) + ); + }); + + it('should be able to deselect all selected recipients', async () => { + const mockUsers = accountUserFactory.buildList(2); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + // Render with pre-selected users + const selectedUsernames = mockUsers.map((user) => user.username); + renderWithTheme( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + // Verify users are initially selected + expect( + await screen.findByRole('option', { + name: mockUsers[0].username, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: mockUsers[1].username, + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + + // Click Deselect All + await userEvent.click( + await screen.findByRole('option', { name: DESELECT_ALL }) + ); + + // Verify onChange was called with empty array + expect(mockOnChange).toHaveBeenCalledWith([]); + }); + + it('should disable Select All when search input is not empty', async () => { + const mockUsers = accountUserFactory.buildList(3); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(); + + const input = screen.getByPlaceholderText('Select recipients'); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.type(input, 'test'); + + await waitFor(() => { + const selectAllOption = screen.queryByRole('option', { + name: SELECT_ALL, + }); + expect(selectAllOption).not.toBeInTheDocument(); + }); + }); + + it('should disable Select All when recipients exceed max limit', async () => { + const mockUsers = accountUserFactory.buildList(15); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(, { + flags: { + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: false, + maxEmailChannelRecipients: 10, + notificationChannels: true, + recentActivity: false, + }, + }, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + await waitFor(() => { + const selectAllOption = screen.queryByRole('option', { + name: SELECT_ALL, + }); + expect(selectAllOption).not.toBeInTheDocument(); + }); + }); + + it('should disable unselected options when max selections reached', async () => { + const mockUsers = accountUserFactory.buildList(12); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + // Render with 5 users already selected (max limit) + const selectedUsernames = mockUsers + .slice(0, 5) + .map((user) => user.username); + renderWithTheme( + , + { + flags: { + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: false, + maxEmailChannelRecipients: 5, + notificationChannels: true, + recentActivity: false, + }, + }, + } + ); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + // Check that unselected options are disabled + const unselectedOption = await screen.findByRole('option', { + name: mockUsers[5].username, + }); + expect(unselectedOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should fetch next page on scroll to bottom', async () => { + const mockUsers = accountUserFactory.buildList(10); + const fetchNextPage = vi.fn(); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + hasNextPage: true, + isLoading: false, + isFetching: false, + fetchNextPage, + }); + + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + const listbox = screen.getByRole('listbox'); + + // Simulate scroll to bottom + Object.defineProperty(listbox, 'scrollHeight', { value: 1000 }); + Object.defineProperty(listbox, 'clientHeight', { value: 100 }); + Object.defineProperty(listbox, 'scrollTop', { value: 900 }); + + listbox.dispatchEvent(new Event('scroll', { bubbles: true })); + + await waitFor(() => { + expect(fetchNextPage).toHaveBeenCalled(); + }); + }); + + it('should not fetch next page when not at bottom', async () => { + const mockUsers = accountUserFactory.buildList(10); + const fetchNextPage = vi.fn(); + + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: mockUsers }] }, + hasNextPage: true, + isLoading: false, + isFetching: false, + fetchNextPage, + }); + + renderWithTheme(); + + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + + const listbox = screen.getByRole('listbox'); + + // Simulate scroll but not to bottom + Object.defineProperty(listbox, 'scrollHeight', { value: 1000 }); + Object.defineProperty(listbox, 'clientHeight', { value: 100 }); + Object.defineProperty(listbox, 'scrollTop', { value: 400 }); + + listbox.dispatchEvent(new Event('scroll', { bubbles: true })); + + await waitFor(() => { + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + }); + + it('should use default max recipients limit when flag is not set', () => { + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: [] }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText('Select up to 10 Recipients')).toBeVisible(); + }); + + it('should call onBlur when the field loses focus', async () => { + queryMocks.useAccountUsersInfiniteQuery.mockReturnValue({ + data: { pages: [{ data: [] }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + }); + + renderWithTheme(); + + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); + await userEvent.tab(); + + expect(mockOnBlur).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.tsx new file mode 100644 index 00000000000..6e35922bac7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationRecipients.tsx @@ -0,0 +1,181 @@ +import { useAccountUsersInfiniteQuery } from '@linode/queries'; +import { Autocomplete, Box, SelectedIcon, StyledListItem } from '@linode/ui'; +import { useDebouncedValue } from '@linode/utilities'; +import React, { useState } from 'react'; + +import { useFlags } from 'src/hooks/useFlags'; + +export interface NotificationRecipientsProps { + /** + * Error text to display when the field has a validation error + */ + error?: string; + /** + * Function to handle the blur event + */ + onBlur?: () => void; + /** + * Function to handle the change of recipients + */ + onChange: (value: string[]) => void; + /** + * Selected recipients (array of usernames) + */ + value: string[]; +} + +export const NotificationRecipients = React.memo( + (props: NotificationRecipientsProps) => { + const { error, onBlur, onChange, value } = props; + + const [usernameInput, setUsernameInput] = useState(''); + const debouncedUsernameInput = useDebouncedValue(usernameInput); + + const flags = useFlags(); + + // Filter the users by the debounced username input + const userSearchFilter = debouncedUsernameInput + ? { + ['+or']: [{ username: { ['+contains']: debouncedUsernameInput } }], + } + : undefined; + + const { + data: accountUsers, + fetchNextPage, + hasNextPage, + isFetching: isFetchingAccountUsers, + isLoading: isLoadingAccountUsers, + } = useAccountUsersInfiniteQuery({ + ...userSearchFilter, + '+order': 'asc', + '+order_by': 'username', + }); + + const options = React.useMemo(() => { + const users = accountUsers?.pages.flatMap((page) => page.data); + return ( + users?.map((user) => ({ + ...user, + label: user.username, + })) || [] + ); + }, [accountUsers]); + + const selectedOptions = React.useMemo(() => { + if (!value || !Array.isArray(value)) { + return []; + } + return value + .map((val) => { + return options.find((opt) => opt.label === val); + }) + .filter((opt) => opt !== undefined); + }, [options, value]); + + // Handle the scroll event to load more users when the user scrolls to the bottom of the list + const handleScroll = (event: React.SyntheticEvent) => { + const listboxNode = event.currentTarget; + const isAtBottom = + Math.abs( + listboxNode.scrollHeight - + listboxNode.clientHeight - + listboxNode.scrollTop + ) < 1; + + if (isAtBottom && hasNextPage) { + fetchNextPage(); + } + }; + + // Maximum recipients selection limit is fetched from launchdarkly + const maxRecipientsSelectionLimit = + flags.aclpAlerting?.maxEmailChannelRecipients || 10; + + // Check if total number of options and selected options are greater than the limit, if yes then disable the Select All option + const recipientsLimitReached = options.length > maxRecipientsSelectionLimit; + + const maxSelectionsReached = + selectedOptions.length >= maxRecipientsSelectionLimit; + + return ( + option.label} + helperText={ + !error ? `Select up to ${maxRecipientsSelectionLimit} Recipients` : '' + } + inputValue={usernameInput} + isOptionEqualToValue={(option, value) => option.label === value.label} + label="Recipients" + limitTags={1} + loading={isLoadingAccountUsers || isFetchingAccountUsers} + multiple + onBlur={onBlur} + onChange={(_, selected, reason) => { + if (reason === 'clear') { + onChange([]); + return; + } + + onChange(selected.map((item) => item.label)); + setUsernameInput(''); + }} + onInputChange={(_, value, reason) => { + // Only update for actual typing; ignore MUI reset calls + if (reason === 'input') { + setUsernameInput(value); + } + }} + options={options} + placeholder="Select recipients" + renderOption={(props, option) => { + // After selecting resources up to the max resource selection limit, rest of the unselected options will be disabled if there are any + const { key, ...rest } = props; + const isRecipientSelected = selectedOptions?.some( + (item) => item.label === option.label + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const isMaxSelectionsReached = + maxSelectionsReached && + !isRecipientSelected && + !isSelectAllORDeslectAllOption; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} + slotProps={{ + listbox: { + onScroll: handleScroll, + }, + popper: { + placement: 'bottom', + }, + }} + value={selectedOptions} + /> + ); + } +); From 63863f139ad95fd9cbb65a4c187821cd04758c22 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 24 Dec 2025 15:49:31 +0530 Subject: [PATCH 36/60] Added: [DI-28776] - Use customized humanize method for cloudpulse metrics (#13224) * Added: [DI-28776] - Use customized humanize method for cloudpulse metrics * Added: [DI-28776] - Use customized humanize method for cloudpulse metrics * Added: [DI-28776] - Update import * Added: [DI-28776] - Changeset --- .../pr-13224-added-1766499338175.md | 5 ++++ .../Utils/CloudPulseWidgetUtils.test.ts | 2 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 3 +-- .../src/features/CloudPulse/Utils/utils.ts | 25 ++++++++++++++++++- .../Widget/components/CloudPulseLineGraph.tsx | 7 ++++-- 5 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-13224-added-1766499338175.md diff --git a/packages/manager/.changeset/pr-13224-added-1766499338175.md b/packages/manager/.changeset/pr-13224-added-1766499338175.md new file mode 100644 index 00000000000..5014be7c2f5 --- /dev/null +++ b/packages/manager/.changeset/pr-13224-added-1766499338175.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Add customized humanize method for `cloudpulse metric` graphs ([#13224](https://github.com/linode/manager/pull/13224)) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index 0e6b0529cab..a51232bf0b7 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -1,6 +1,5 @@ import { formatPercentage } from '@linode/utilities'; -import * as utilities from 'src/components/AreaChart/utils'; import { widgetFactory } from 'src/factories'; import { @@ -12,6 +11,7 @@ import { getTimeDurationFromPreset, mapResourceIdToName, } from './CloudPulseWidgetUtils'; +import * as utilities from './utils'; import type { DimensionNameProperties, diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index b677e781ca2..75bea2c322c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -2,8 +2,6 @@ import { Alias } from '@linode/design-language-system'; import { DateTimeRangePicker } from '@linode/ui'; import { getMetrics } from '@linode/utilities'; -import { humanizeLargeData } from 'src/components/AreaChart/utils'; - import { DIMENSION_TRANSFORM_CONFIG } from '../shared/DimensionTransform'; import { convertValueToUnit, @@ -13,6 +11,7 @@ import { } from './unitConversion'; import { convertTimeDurationToStartAndEndTimeRange, + humanizeLargeData, seriesDataFormatter, } from './utils'; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index b6173898d61..b18b3a8bf0e 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -1,5 +1,5 @@ import { useAccount, useRegionsQuery } from '@linode/queries'; -import { isFeatureEnabledV2 } from '@linode/utilities'; +import { isFeatureEnabledV2, roundTo } from '@linode/utilities'; import React from 'react'; import { convertData } from 'src/features/Longview/shared/formatters'; @@ -676,3 +676,26 @@ export const arraysEqual = ( [...b].sort((x, y) => x - y) ); }; + +/** + * @param value The numeric value to humanize + * @returns The humanized string representation of the value + */ +export const humanizeLargeData = (value: number) => { + if (value >= 1000000000000) { + return +(value / 1000000000000).toFixed(1) + 'T'; + } + if (value >= 1000000000) { + return +(value / 1000000000).toFixed(1) + 'B'; + } + if (value >= 1000000) { + return +(value / 1000000).toFixed(1) + 'M'; + } + if (value >= 100000) { + return +(value / 1000).toFixed(0) + 'K'; + } + if (value >= 1000) { + return +(value / 1000).toFixed(1) + 'K'; + } + return `${roundTo(value, 1)}`; +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 8fe14dbdfa9..4fe33e746e9 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -4,9 +4,10 @@ import { Box, useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { humanizeLargeData } from 'src/components/AreaChart/utils'; import { useFlags } from 'src/hooks/useFlags'; +import { humanizeLargeData } from '../../Utils/utils'; + import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; export interface CloudPulseLineGraph extends AreaChartProps { @@ -69,7 +70,9 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { } yAxisProps={ isHumanizableUnit - ? undefined + ? { + tickFormat: (value: number) => `${humanizeLargeData(value)}`, + } : { tickFormat: (value: number) => `${roundTo(value, 3)}`, } From 0ba819d45094a28e4d5933a672705486555ff925 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 24 Dec 2025 19:40:11 +0530 Subject: [PATCH 37/60] upcoming: [DI-28682] - UX enhancements of CloudPulseDateTimeRangePicker in metrics (#13216) * upcoming: [DI-28682] - UX enhancements of Time Range Picker in CloudPulse metrics * upcoming: [DI-28682] - Use small screens * upcoming: [DI-28682] - Allow backdrop click * upcoming: [DI-28682] - Remove and use existing timezone * Added changeset: UX enhancements of `CloudPulseDateTimeRangePicker` and `DateTimeRangePicker` components in cloudpulse metrics * upcoming: [DI-28682] - Background style update * Integrate user profile for timezone handling * upcoming: [DI-28682] - Contextual view changes * upcoming: [DI-28682] - Fix for dependency * Added: [DI-28776] - Fix one eslint issue --- ...r-13216-upcoming-features-1766163912058.md | 5 + .../dbaas-widgets-verification.spec.ts | 9 +- .../linode-widget-verification.spec.ts | 9 +- .../nodebalancer-widget-verification.spec.ts | 9 +- .../cloudpulse/timerange-verification.spec.ts | 242 ++++++++++++++---- .../Dashboard/CloudPulseDashboardLanding.tsx | 27 +- .../CloudPulseDashboardWithFilters.test.tsx | 25 +- .../CloudPulseDashboardWithFilters.tsx | 18 +- .../Overview/GlobalFilters.test.tsx | 2 +- .../shared/CloudPulseDateTimeRangePicker.tsx | 112 +++++--- .../DatabaseMonitor/DatabaseMonitor.test.tsx | 4 +- .../DatePicker/DateRangePicker/Presets.tsx | 1 + .../DateTimeRangePicker.tsx | 23 +- 13 files changed, 359 insertions(+), 127 deletions(-) create mode 100644 packages/manager/.changeset/pr-13216-upcoming-features-1766163912058.md diff --git a/packages/manager/.changeset/pr-13216-upcoming-features-1766163912058.md b/packages/manager/.changeset/pr-13216-upcoming-features-1766163912058.md new file mode 100644 index 00000000000..2c206aa6713 --- /dev/null +++ b/packages/manager/.changeset/pr-13216-upcoming-features-1766163912058.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +UX enhancements of `CloudPulseDateTimeRangePicker` and `DateTimeRangePicker` components in cloudpulse metrics ([#13216](https://github.com/linode/manager/pull/13216)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 4f3d43a6bb1..c5bb1da2c8b 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -290,12 +290,11 @@ describe('Integration Tests for DBaaS Dashboard ', () => { .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); // Click the "Apply" button to confirm the end date and time cy.get('[data-qa-buttons="apply"]') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index e0b04829dfa..450fdda6a91 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -179,12 +179,11 @@ describe('Integration Tests for Linode Dashboard ', () => { .should('be.visible') .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); // Click the "Apply" button to confirm the end date and time cy.get('[data-qa-buttons="apply"]') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts index dd26d1d73e5..6a515339b71 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/nodebalancer-widget-verification.spec.ts @@ -182,12 +182,11 @@ describe('Integration Tests for Nodebalancer Dashboard ', () => { .click(); // Select a time duration from the autocomplete input. - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); - cy.get('@startDateInput').click(); - - cy.get('[data-qa-preset="Last day"]').click(); + // select a different preset but cancel + ui.button.findByTitle('Last day').click(); cy.get('[data-qa-buttons="apply"]') .should('be.visible') 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 3ce18db0626..71705981c27 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable cypress/no-unnecessary-waiting */ + /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ @@ -94,17 +95,22 @@ const databaseMock: Database = databaseFactory.build({ region: mockRegion.id, type: engine, }); +// Profile timezone is set to 'UTC' const mockProfile = profileFactory.build({ - timezone: 'GMT', + timezone: 'UTC', }); /** - * Generates a date in UTC based on a specified number of hours and minutes offset. The function also provides individual date components such as day, hour, + * Generates a date in Indian Standard Time (IST) based on a specified number of days offset, + * hour, and minute. The function also provides individual date components such as day, hour, * minute, month, and AM/PM. + * + * @param {number} daysOffset - The number of days to adjust from the current date. Positive + * values give a future date, negative values give a past date. * @param {number} hour - The hour to set for the resulting date (0-23). * @param {number} [minute=0] - The minute to set for the resulting date (0-59). Defaults to 0. * * @returns {Object} - Returns an object containing: - * - `actualDate`: The formatted date and time in UTC (YYYY-MM-DD HH:mm). + * - `actualDate`: The formatted date and time in GMT (YYYY-MM-DD HH:mm). * - `day`: The day of the month as a number. * - `hour`: The hour in the 24-hour format as a number. * - `minute`: The minute of the hour as a number. @@ -121,12 +127,18 @@ const getDateRangeInGMT = ( : now.set({ hour, minute }).setZone('GMT'); const actualDate = targetDate.setZone('GMT').toFormat('yyyy-LL-dd HH:mm'); + const previousMonthDate = targetDate.minus({ months: 1 }); + return { actualDate, day: targetDate.day, hour: targetDate.hour, minute: targetDate.minute, - month: targetDate.month, + month: targetDate.toFormat('LLLL'), + year: targetDate.year, + daysInMonth: targetDate.daysInMonth, + previousMonth: previousMonthDate.toFormat('LLLL'), + previousYear: previousMonthDate.year, }; }; @@ -226,9 +238,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura '@fetchPreferences', '@fetchDatabases', ]); - - // Scroll to the top of the page to ensure consistent test behavior - cy.scrollTo('top'); }); it('should implement and validate custom date/time picker for a specific date and time range', () => { @@ -238,6 +247,11 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura day: startDay, hour: startHour, minute: startMinute, + month: startMonth, + year: startYear, + previousMonth, + previousYear, + daysInMonth, } = getDateRangeInGMT(12, 15, true); const { @@ -249,43 +263,52 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.wait(1000); // --- Select start date --- - // Updated selector for MUI x-date-pickers v8 - click on the wrapper div - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); + cy.get('[role="dialog"]').within(() => { cy.findAllByText(startDay).first().click(); cy.findAllByText(endDay).first().click(); }); - // Updated selector for MUI x-date-pickers v8 time picker button - cy.get('button[aria-label*="time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .first() .should('be.visible', { timeout: 10000 }) // waits up to 10 seconds .as('timePickerButton'); - cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.get('@timePickerButton', { timeout: 15000 }) - .wait(300) // ⛔ doesn't work like this! (cy.wait isn't chainable on element) - .click(); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. + + cy.get(`[aria-label="${startHour} hours"]`).click(); + cy.wait(1000); - cy.findByLabelText('Select hours') - .as('selectHours') - .scrollIntoView({ easing: 'linear' }); + ui.button + .findByAttribute('aria-label^', 'Choose time') + .first() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); - cy.get('@selectHours').within(() => { - cy.get(`[aria-label="${startHour} hours"]`).click(); - }); + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); - cy.findByLabelText('Select minutes') - .as('selectMinutes') - .scrollIntoView({ duration: 500, easing: 'linear' }); + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); - cy.get('@selectMinutes').within(() => { - cy.get(`[aria-label="${startMinute} minutes"]`).click(); - }); + cy.get(`[aria-label="${startMinute} minutes"]`).click(); + + ui.button + .findByAttribute('aria-label^', 'Choose time') + .first() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); + + cy.get('@timePickerButton').scrollIntoView({ easing: 'linear' }); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); cy.findByLabelText('Select meridiem') .as('startMeridiemSelect') @@ -293,8 +316,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('@startMeridiemSelect').find('[aria-label="PM"]').click(); // --- Select end time --- - // Updated selector for MUI x-date-pickers v8 time picker button - cy.get('button[aria-label*="time"]') + ui.button + .findByAttribute('aria-label^', 'Choose time') .last() .should('be.visible', { timeout: 10000 }) .as('timePickerButton'); @@ -306,13 +329,23 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura duration: 500, easing: 'linear', }); - cy.get('@selectHours').within(() => { - cy.get(`[aria-label="${endHour} hours"]`).click(); - }); + cy.get(`[aria-label="${endHour} hours"]`).click(); - cy.get('@selectMinutes').within(() => { - cy.get(`[aria-label="${endMinute} minutes"]`).click(); - }); + cy.get('[aria-label^="Choose time"]') + .last() + .should('be.visible') + .as('timePickerButton'); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); + + cy.get(`[aria-label="${endMinute} minutes"]`).click(); + + cy.get('[aria-label^="Choose time"]') + .last() + .should('be.visible', { timeout: 10000 }) + .as('timePickerButton'); + + cy.get('@timePickerButton', { timeout: 15000 }).wait(300).click(); cy.findByLabelText('Select meridiem') .as('endMeridiemSelect') @@ -320,11 +353,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura cy.get('@endMeridiemSelect').find('[aria-label="PM"]').click(); // --- Set timezone --- - cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').click(); - cy.get('@timezoneInput').clear(); - cy.get('@timezoneInput') - .should('not.be.disabled') - .type('(GMT +0:00) Greenwich Mean Time{enter}'); + cy.findByPlaceholderText('Choose a Timezone').as('timezoneInput').clear(); + cy.get('@timezoneInput').type('(GMT +0:00) Greenwich Mean Time{enter}'); // --- Apply date/time range --- cy.get('[data-qa-buttons="apply"]') @@ -333,7 +363,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .click(); // --- Re-validate after apply --- - cy.get('[aria-labelledby="start-date"]').should( 'have.value', `${startActualDate} PM` @@ -343,6 +372,8 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura `${endActualDate} PM` ); + ui.button.findByTitle('Cancel').and('be.enabled').click(); + // --- Select Node Type --- ui.autocomplete.findByLabel('Node Type').type('Primary{enter}'); @@ -354,17 +385,12 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura const { request: { body }, } = xhr as Interception; - expect(formatToUtcDateTime(body.absolute_time_duration.start)).to.equal( convertToGmt(startActualDate) ); expect(formatToUtcDateTime(body.absolute_time_duration.end)).to.equal( convertToGmt(endActualDate) ); - - // Keep a minimal structural assertion so the request shape is still validated - expect(body).to.have.nested.property('absolute_time_duration.start'); - expect(body).to.have.nested.property('absolute_time_duration.end'); }); // --- Test Time Range Presets --- @@ -372,14 +398,70 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura 'getPresets' ); + // Open the date range picker to apply the "Last 30 Days" preset + + cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="Last 30 days"]').click(); + ui.button.findByTitle('Last 30 days').should('be.visible').click(); + + cy.get('[data-qa-preset="Last 30 days"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + + cy.contains(`${previousMonth} ${previousYear}`) + .closest('div') + .next() + .find('[aria-selected="true"]') + .then(($els) => { + const selectedDays = Array.from($els).map((el) => + Number(el.textContent?.trim()) + ); + + expect(daysInMonth, 'daysInMonth should be defined').to.be.a('number'); + + const totalDays = daysInMonth as number; + const expectedCount = totalDays - endDay; + + expect( + selectedDays.length, + 'number of selected days from the previous month for the last-30-days range' + ).to.eq(expectedCount); + expect( + totalDays - selectedDays.length, + 'start day of Last 30 days' + ).to.eq(endDay); + }); + + cy.contains(`${startMonth} ${startYear}`) + .closest('div') + .next() + .find('[aria-selected="true"]') + .then(($els) => { + const selectedDays = Array.from($els).map((el) => + Number(el.textContent?.trim()) + ); + + expect( + selectedDays.length, + 'number of selected days in the current month for the last-30-days range' + ).to.eq(endDay); + expect(Math.max(...selectedDays), 'end day of Last 30 days').to.eq( + endDay + ); + }); cy.get('[data-qa-buttons="apply"]') .should('be.visible') - .and('be.enabled') + .should('be.enabled') .click(); + ui.button + .findByTitle('Last 30 days') + .should('be.visible') + .should('be.enabled'); + cy.get('@getPresets.all') .should('have.length', 4) .each((xhr: unknown) => { @@ -399,9 +481,19 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura timeRanges.forEach((range) => { it(`Select and validate the functionality of the "${range.label}" preset from the "Time Range" dropdown`, () => { - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get(`[data-qa-preset="${range.label}"]`).click(); + + ui.button.findByTitle(range.label).click(); + + cy.get(`[data-qa-preset="${range.label}"]`).should( + 'have.attr', + 'aria-selected', + 'true' + ); + cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -432,9 +524,17 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura it('Select the "Last Month" preset from the "Time Range" dropdown and verify its functionality.', () => { const { end, start } = getLastMonthRange(); - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="Last month"]').click(); + + ui.button.findByTitle('Last month').click(); + + cy.get('[data-qa-preset="Last month"]') + .should('exist') + .and('have.attr', 'aria-selected', 'true'); + cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -460,9 +560,16 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura it('Select the "This Month" preset from the "Time Range" dropdown and verify its functionality.', () => { const { end, start } = getThisMonthRange(); - cy.get('[aria-labelledby="start-date"]').parent().as('startDateInput'); + ui.button.findByTitle('Last hour').as('startDateInput'); + cy.get('@startDateInput').scrollIntoView(); + cy.get('@startDateInput').click(); - cy.get('[data-qa-preset="This month"]').click(); + + ui.button.findByTitle('This month').click(); + + cy.get('[data-qa-preset="This month"]') + .should('exist') + .and('have.attr', 'aria-selected', 'true'); cy.get('[data-qa-buttons="apply"]') .should('be.visible') .should('be.enabled') @@ -488,4 +595,31 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura ).to.equal(formatDate(end, { format: 'yyyy-MM-dd hh:mm' })); }); }); + + it('should not change the selected preset when a new preset selection is cancelled', () => { + // open the time range picker + ui.button.findByTitle('Last hour').as('timeRangeTrigger'); + cy.get('@timeRangeTrigger').click(); + + // verify initial preset + cy.get('[data-qa-preset="Last hour"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + + // select a different preset but cancel + ui.button.findByTitle('Last month').click(); + ui.button.findByTitle('Cancel').should('be.visible').click(); + + // reopen picker + cy.get('@timeRangeTrigger').click(); + + // original preset should remain selected + cy.get('[data-qa-preset="Last hour"]').should( + 'have.attr', + 'aria-selected', + 'true' + ); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 62f8769bdba..0079167f476 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,5 +1,7 @@ +import { useProfile } from '@linode/queries'; import { Box, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; +import { DateTime } from 'luxon'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -8,6 +10,7 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { GlobalFilters } from '../Overview/GlobalFilters'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; +import { defaultTimeDuration } from '../Utils/CloudPulseDateTimePickerUtils'; import { CloudPulseDashboardRenderer } from './CloudPulseDashboardRenderer'; import type { Dashboard, DateTimeWithPreset } from '@linode/api-v4'; @@ -29,6 +32,7 @@ export interface DashboardProp { } export const CloudPulseDashboardLanding = () => { + const { data: profile } = useProfile(); const [filterData, setFilterData] = React.useState({ id: {}, label: {}, @@ -45,6 +49,11 @@ export const CloudPulseDashboardLanding = () => { const [showAppliedFilters, setShowAppliedFilters] = React.useState(false); + const timezone = + profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName); + const toggleAppliedFilter = (isVisible: boolean) => { setShowAppliedFilters(isVisible); }; @@ -71,13 +80,17 @@ export const CloudPulseDashboardLanding = () => { [] ); - const onDashboardChange = React.useCallback((dashboardObj: Dashboard) => { - setDashboard(dashboardObj); - setFilterData({ - id: {}, - label: {}, - }); // clear the filter values on dashboard change - }, []); + const onDashboardChange = React.useCallback( + (dashboardObj: Dashboard) => { + setDashboard(dashboardObj); + setFilterData({ + id: {}, + label: {}, + }); // clear the filter values on dashboard change + setTimeDuration(defaultTimeDuration(timezone)); // clear time duration on dashboard change + }, + [timezone] + ); const onTimeDurationChange = React.useCallback( (timeDurationObj: DateTimeWithPreset) => { setTimeDuration(timeDurationObj); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx index 238f49432d9..5009ecfbf02 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.test.tsx @@ -39,6 +39,7 @@ vi.mock('../GroupBy/utils', async () => { }; }); const mockDashboard = dashboardFactory.build(); +const PRESET_BUTTON_ID = 'preset-button'; describe('CloudPulseDashboardWithFilters component tests', () => { it('renders a CloudPulseDashboardWithFilters component with error placeholder', () => { @@ -90,9 +91,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { const groupByIcon = screen.getByTestId('group-by'); expect(groupByIcon).toBeEnabled(); - const startDate = screen.getByText('Start Date'); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); const nodeTypeSelect = screen.getByTestId('node-type-select'); - expect(startDate).toBeInTheDocument(); + expect(presetButton).toBeInTheDocument(); expect(nodeTypeSelect).toBeInTheDocument(); }); @@ -141,9 +142,9 @@ describe('CloudPulseDashboardWithFilters component tests', () => { renderWithTheme( ); - const startDate = screen.getByText('Start Date'); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); const portsSelect = screen.getByPlaceholderText('e.g., 80,443,3000'); - expect(startDate).toBeInTheDocument(); + expect(presetButton).toBeInTheDocument(); expect(portsSelect).toBeInTheDocument(); }); @@ -159,8 +160,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { renderWithTheme( ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); expect(screen.getByPlaceholderText('Select a Linode Region')).toBeVisible(); expect(screen.getByPlaceholderText('Select Interface Types')).toBeVisible(); expect(screen.getByPlaceholderText('e.g., 1234,5678')).toBeVisible(); @@ -181,8 +182,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { /> ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); }); it('renders a CloudPulseDashboardWithFilters component with mandatory filter error for objectstorage if region is not provided', () => { @@ -212,8 +213,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); }); it('renders a CloudPulseDashboardWithFilters component successfully for firewall nodebalancer', async () => { @@ -240,8 +241,8 @@ describe('CloudPulseDashboardWithFilters component tests', () => { ); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId(PRESET_BUTTON_ID); + expect(presetButton).toBeInTheDocument(); await userEvent.click(screen.getByPlaceholderText('Select a Dashboard')); await userEvent.click(screen.getByText('nodebalancer_firewall_dashbaord')); expect( diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 8d139630e7a..088f5fbfcc7 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,5 +1,7 @@ +import { useProfile } from '@linode/queries'; import { Box, CircleProgress, Divider, ErrorState, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; +import { DateTime } from 'luxon'; import React from 'react'; import { @@ -13,7 +15,10 @@ import { CloudPulseDashboardFilterBuilder } from '../shared/CloudPulseDashboardF import { CloudPulseDashboardSelect } from '../shared/CloudPulseDashboardSelect'; import { CloudPulseDateTimeRangePicker } from '../shared/CloudPulseDateTimeRangePicker'; import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; -import { convertToGmt } from '../Utils/CloudPulseDateTimePickerUtils'; +import { + convertToGmt, + defaultTimeDuration, +} from '../Utils/CloudPulseDateTimePickerUtils'; import { PARENT_ENTITY_REGION } from '../Utils/constants'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { @@ -62,6 +67,8 @@ export const CloudPulseDashboardWithFilters = React.memo( serviceType ? [serviceType] : [] ); + const { data: profile } = useProfile(); + const [filterData, setFilterData] = React.useState({ id: {}, label: {}, @@ -86,6 +93,11 @@ export const CloudPulseDashboardWithFilters = React.memo( const [showAppliedFilters, setShowAppliedFilters] = React.useState(false); + const timezone = + profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName); + const toggleAppliedFilter = (isVisible: boolean) => { setShowAppliedFilters(isVisible); }; @@ -116,8 +128,9 @@ export const CloudPulseDashboardWithFilters = React.memo( (dashboard: Dashboard | undefined) => { setFilterData({ id: {}, label: {} }); setDashboard(dashboard); + setTimeDuration(defaultTimeDuration(timezone)); // clear time duration on dashboard change }, - [] + [timezone] ); const handleTimeRangeChange = React.useCallback( @@ -197,6 +210,7 @@ export const CloudPulseDashboardWithFilters = React.memo( gap={2} > diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx index b3539af8a8f..351b918ea32 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.test.tsx @@ -52,7 +52,7 @@ describe('Global filters component test', () => { it('Should have time range select with default value', () => { setup(); - const timeRangeSelect = screen.getByText('Start Date'); + const timeRangeSelect = screen.getByTestId('preset-button'); expect(timeRangeSelect).toBeInTheDocument(); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx index f63645f5688..caa1f259206 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx @@ -1,5 +1,6 @@ import { useProfile } from '@linode/queries'; -import { DateTimeRangePicker } from '@linode/ui'; +import { Box, Button, CalendarIcon, DateTimeRangePicker } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; import { DateTime } from 'luxon'; import React from 'react'; @@ -32,30 +33,44 @@ export const CloudPulseDateTimeRangePicker = React.memo( const { defaultValue, handleStatsChange, savePreferences } = props; const { data: profile } = useProfile(); let defaultSelected = defaultValue as DateTimeWithPreset; - + const RESET = 'Reset'; + const theme = useTheme(); const timezone = defaultSelected?.timeZone ?? - profile?.timezone ?? - DateTime.local().zoneName; + (profile?.timezone === 'GMT' + ? 'Etc/GMT' // this is present in timezone list for GMT + : (profile?.timezone ?? DateTime.local().zoneName)); if (!defaultSelected) { defaultSelected = defaultTimeDuration(timezone); } else { defaultSelected = getTimeFromPreset(defaultSelected, timezone); } + // Show button with preset value only if selected or default preset is not 'reset' + const [selectedPreset, setSelectedPreset] = React.useState< + string | undefined + >(defaultSelected.preset); + // Show calendar only if selected or default preset is 'reset' or button is clicked + const [openCalendar, setOpenCalendar] = React.useState(false); React.useEffect(() => { if (defaultSelected) { handleStatsChange(defaultSelected); } }, []); + const handleClose = (selectedPreset: string) => { + setOpenCalendar(false); + setSelectedPreset(selectedPreset); + }; + const handleDateChange = (params: DateChangeProps) => { const { endDate, selectedPreset, startDate, timeZone } = params; if (!endDate || !startDate || !selectedPreset || !timeZone) { return; } - + setOpenCalendar(selectedPreset !== RESET ? false : true); + setSelectedPreset(selectedPreset); handleStatsChange( { end: endDate, @@ -75,33 +90,66 @@ export const CloudPulseDateTimeRangePicker = React.memo( : end; return ( - + + {selectedPreset !== RESET && !openCalendar && ( + + )} + {(selectedPreset === RESET || openCalendar) && ( + + )} + ); } ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx index daec81168d5..c372790dece 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseMonitor/DatabaseMonitor.test.tsx @@ -40,7 +40,7 @@ describe('database monitor', () => { expect(loadingElement).toBeInTheDocument(); await waitForElementToBeRemoved(loadingElement); - const startDate = screen.getByText('Start Date'); - expect(startDate).toBeInTheDocument(); + const presetButton = screen.getByTestId('preset-button'); + expect(presetButton).toBeInTheDocument(); }); }); diff --git a/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx b/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx index 0fa38dad15e..b6a5a5f65ad 100644 --- a/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx +++ b/packages/ui/src/components/DatePicker/DateRangePicker/Presets.tsx @@ -120,6 +120,7 @@ export const Presets = ({ return ( { diff --git a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx index 4719d4f34d0..5b6b863c5d2 100644 --- a/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/packages/ui/src/components/DatePicker/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -52,6 +52,12 @@ export interface DateTimeRangePickerProps { timeZone: null | string; }) => void; + /** Callback when the popover is closed */ + onClose?: (selectedPreset: string) => void; + + /** Property to control whether the calendar popover is open */ + openCalendar?: boolean; + /** Additional settings for the presets dropdown */ presetsProps?: { /** Default value for the presets field */ @@ -108,6 +114,8 @@ export const DateTimeRangePicker = ({ startDateProps, sx, timeZoneProps, + openCalendar, + onClose, }: DateTimeRangePickerProps) => { const [startDate, setStartDate] = useState( startDateProps?.value ?? null, @@ -122,7 +130,7 @@ export const DateTimeRangePicker = ({ startDateProps?.errorMessage, ); const [endDateError, setEndDateError] = useState(endDateProps?.errorMessage); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(openCalendar ?? false); const [anchorEl, setAnchorEl] = useState(null); const [currentMonth, setCurrentMonth] = useState(DateTime.now()); const [focusedField, setFocusedField] = useState<'end' | 'start'>('start'); // Tracks focused input field @@ -170,6 +178,7 @@ export const DateTimeRangePicker = ({ setEndDateError(''); setOpen(false); setAnchorEl(null); + onClose?.(previousValues.current.selectedPreset ?? ''); }; const handleApply = () => { @@ -275,10 +284,18 @@ export const DateTimeRangePicker = ({ setEndDateError(''); }; + React.useEffect(() => { + if (!anchorEl && startDateInputRef.current) { + setAnchorEl( + startDateInputRef.current?.parentElement || startDateInputRef.current, + ); + } + }, []); + return ( - + { if (newTime) { + setSelectedPreset(PRESET_LABELS.RESET); // Reset preset on manual time change setStartDate((prev) => { const updatedValue = prev?.set({ @@ -404,6 +422,7 @@ export const DateTimeRangePicker = ({ label="End Time" onChange={(newTime: DateTime | null) => { if (newTime) { + setSelectedPreset(PRESET_LABELS.RESET); // Reset preset on manual time change setEndDate((prev) => { const updatedValue = prev?.set({ From e8035fad660c4128ff337ee582e6c82a2703e895 Mon Sep 17 00:00:00 2001 From: Ankita Date: Tue, 30 Dec 2025 12:29:42 +0530 Subject: [PATCH 38/60] upcoming: [DI-28503] - Add create notification channel page (#13225) * upcoming: [DI-28503] - Add initial changes for create notification channel page * upcoming: [DI-28503] - Update type of name field, update schema and fix linting issue * upcoming: [DI-28503] - Fix linting issue * upcoming: [DI-28503] - Make notification type select a reusable component, move controller to parent * upcoming: [DI-28503] - Use recipients filter, handle submit * upcoming: [DI-28503] - Add UT * upcoming: [DI-28503] - Use reusable component, remove redundancy * upcoming: [DI-28503] - Update type * upcoming: [DI-28503] - Add mocks * upcoming: [DI-28503] - Use reusable component, remove redundancy * upcoming: [DI-28503] - Handle errors gracefully for non-arrays * Revert "upcoming: [DI-28503] - Handle errors gracefully for non-arrays" This reverts commit fb0907e9351122e9179e89542fcab67146142b59. * upcoming: [DI-28503] - linting fix * upcoming: [DI-28503] - Add mocks * upcoming: [DI-28503] - Add changesets * upcoming: [DI-28503] - Remove unnecessery fallback --------- Co-authored-by: venkatmano-akamai --- ...r-13225-upcoming-features-1766563927589.md | 5 + packages/api-v4/src/cloudpulse/alerts.ts | 11 + packages/api-v4/src/cloudpulse/types.ts | 19 ++ ...r-13225-upcoming-features-1766563953980.md | 5 + .../CreateNotificationChannel.test.tsx | 232 ++++++++++++++++++ .../CreateNotificationChannel.tsx | 170 +++++++++++++ ...PulseCreateNotificationChannelLazyRoute.ts | 9 + .../CreateChannel/schemas.ts | 17 ++ .../CreateChannel/types.ts | 7 + .../CreateChannel/utilities.ts | 16 ++ .../NotificationChannelListing.tsx | 29 ++- .../features/CloudPulse/Alerts/constants.ts | 6 + packages/manager/src/mocks/serverHandlers.ts | 3 + .../manager/src/queries/cloudpulse/alerts.ts | 27 ++ packages/manager/src/routes/alerts/index.ts | 10 + packages/validation/src/cloudpulse.schema.ts | 15 ++ 16 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13225-upcoming-features-1766563927589.md create mode 100644 packages/manager/.changeset/pr-13225-upcoming-features-1766563953980.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts diff --git a/packages/api-v4/.changeset/pr-13225-upcoming-features-1766563927589.md b/packages/api-v4/.changeset/pr-13225-upcoming-features-1766563927589.md new file mode 100644 index 00000000000..746d1b880ad --- /dev/null +++ b/packages/api-v4/.changeset/pr-13225-upcoming-features-1766563927589.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse-Alerts: Add `CreateNotificationChannelPayload` in types.ts and add request function `createNotificationChannel` in alerts.ts ([#13225](https://github.com/linode/manager/pull/13225)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 4518b92c486..eb36d3fb5e4 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -1,5 +1,6 @@ import { createAlertDefinitionSchema, + createNotificationChannelPayloadSchema, editAlertDefinitionSchema, } from '@linode/validation'; @@ -17,6 +18,7 @@ import type { Alert, CloudPulseAlertsPayload, CreateAlertDefinitionPayload, + CreateNotificationChannelPayload, EditAlertDefinitionPayload, NotificationChannel, } from './types'; @@ -139,3 +141,12 @@ export const updateServiceAlerts = ( setMethod('PUT'), setData(payload), ); + +export const createNotificationChannel = ( + data: CreateNotificationChannelPayload, +) => + Request( + setURL(`${API_ROOT}/monitor/alert-channels`), + setMethod('POST'), + setData(data, createNotificationChannelPayloadSchema), + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 15fed6ab371..9f714a05b01 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -411,3 +411,22 @@ export interface CloudPulseAlertsPayload { */ user_alerts?: number[]; } + +export interface CreateNotificationChannelPayload { + /** + * The type of channel to create. + */ + channel_type: ChannelType; + /** + * The details of the channel to create. + */ + details: { + email: { + usernames: string[]; + }; + }; + /** + * The label of the channel to create. + */ + label: string; +} diff --git a/packages/manager/.changeset/pr-13225-upcoming-features-1766563953980.md b/packages/manager/.changeset/pr-13225-upcoming-features-1766563953980.md new file mode 100644 index 00000000000..d141c68e899 --- /dev/null +++ b/packages/manager/.changeset/pr-13225-upcoming-features-1766563953980.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Alerts: Add create notification channel page ([#13225](https://github.com/linode/manager/pull/13225)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx new file mode 100644 index 00000000000..2ad9a847ae6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx @@ -0,0 +1,232 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CREATE_CHANNEL_SUCCESS_MESSAGE } from '../../constants'; +import { CreateNotificationChannel } from './CreateNotificationChannel'; + +const queryMocks = vi.hoisted(() => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + navigate: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useCreateNotificationChannel: vi.fn(() => ({ + mutateAsync: queryMocks.mutateAsync, + })), + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: vi.fn(() => queryMocks.navigate), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUsersInfiniteQuery: vi.fn(() => ({ + data: { + pages: [ + { data: [{ username: 'testuser1' }, { username: 'testuser2' }] }, + ], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + })), + }; +}); + +const CHANNEL_TYPE_SELECT_TESTID = 'channel-type-select'; +const OPEN_BUTTON_LABEL = 'Open'; +const EMAIL_OPTION_LABEL = 'Email'; +const NAME_LABEL = 'Name'; +const REQUIRED_FIELD_ERROR = 'This field is required.'; +const CHANNEL_NAME_VALUE = 'My Email Channel'; + +describe('CreateNotificationChannel', () => { + beforeEach(() => { + vi.clearAllMocks(); + queryMocks.mutateAsync.mockResolvedValue({}); + }); + + it('should render the breadcrumb and form title', () => { + renderWithTheme(); + + expect(screen.getByText('Notification Channels')).toBeVisible(); + expect(screen.getByText('Channel Settings')).toBeVisible(); + }); + + it('should render the channel type select component', async () => { + renderWithTheme(); + + expect(screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID)).toBeVisible(); + expect(screen.getByText('Type')).toBeVisible(); + expect(screen.getByPlaceholderText('Select a Channel Type')).toBeVisible(); + // Verify that the options are rendered + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { name: EMAIL_OPTION_LABEL }) + ).toBeVisible(); + }); + + it('should render name field when a channel type is selected', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // verify the name field is not visible before a channel type is selected + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + // Name field should now be visible + expect(screen.getByLabelText(NAME_LABEL)).toBeVisible(); + expect( + screen.getByPlaceholderText('Enter a name for the channel') + ).toBeVisible(); + }); + + it('should be able to enter a value in the name field', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type first + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + // Type in the name field + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.type(nameInput, CHANNEL_NAME_VALUE); + + const textfieldInput = within(screen.getByTestId('alert-name')).getByTestId( + 'textfield-input' + ); + expect(textfieldInput).toHaveAttribute('value', CHANNEL_NAME_VALUE); + }); + + it('should display validation error for channel type field with no selection', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Trigger validation by blurring the channel type field + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + const combobox = within(channelTypeSelect).getByRole('combobox'); + await user.click(combobox); + await user.tab(); + + await screen.findByText(REQUIRED_FIELD_ERROR); + }); + + it('should display validation error for name field with no value', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + // Focus and blur the name field without entering a value + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.click(nameInput); + await user.tab(); + + await screen.findByText(REQUIRED_FIELD_ERROR); + }); + + it('should display validation error for recipients field with no value', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + const recipientsInput = screen.getByLabelText('Recipients'); + await user.click(recipientsInput); + await user.tab(); + + await screen.findByText(REQUIRED_FIELD_ERROR); + }); + + it('should be able to submit the form with valid values', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.type(nameInput, CHANNEL_NAME_VALUE); + + // Select a recipient from the autocomplete dropdown + const recipientsSelect = screen.getByTestId('recipients-select'); + await user.click( + within(recipientsSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: 'testuser1' })); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await screen.findByText(CREATE_CHANNEL_SUCCESS_MESSAGE); + + expect(queryMocks.mutateAsync).toHaveBeenCalled(); + expect(queryMocks.navigate).toHaveBeenCalledWith({ + to: '/alerts/notification-channels', + }); + }); + + it('should show error snackbar message when creating notification channel fails', async () => { + queryMocks.mutateAsync.mockRejectedValue([{ reason: 'There is an error' }]); + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.type(nameInput, CHANNEL_NAME_VALUE); + + // Select a recipient from the autocomplete dropdown + const recipientsSelect = screen.getByTestId('recipients-select'); + await user.click( + within(recipientsSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: 'testuser1' })); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await screen.findByText('There is an error'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx new file mode 100644 index 00000000000..d741b0fa820 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.tsx @@ -0,0 +1,170 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { ActionsPanel, TextField, Typography } from '@linode/ui'; +import { Paper } from '@mui/material'; +import { useNavigate } from '@tanstack/react-router'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; + +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useCreateNotificationChannel } from 'src/queries/cloudpulse/alerts'; + +import { + channelTypeOptions, + CREATE_CHANNEL_FAILED_MESSAGE, + CREATE_CHANNEL_SUCCESS_MESSAGE, +} from '../../constants'; +import { NotificationChannelTypeSelect } from './NotificationChannelTypeSelect'; +import { NotificationRecipients } from './NotificationRecipients'; +import { createNotificationChannelSchema } from './schemas'; +import { filterCreateChannelFormValues } from './utilities'; + +import type { CreateNotificationChannelForm } from './types'; +import type { ChannelType } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +const overrides: CrumbOverridesProps[] = [ + { + label: 'Notification Channels', + linkTo: '/alerts/notification-channels', + position: 1, + }, +]; + +const initialValues: CreateNotificationChannelForm = { + type: null, + name: '', + recipients: [], +}; + +export const CreateNotificationChannel = () => { + const navigate = useNavigate(); + // Navigate to the notification channels listing page on exit, e.g. on cancel or successful save + const createChannelExit = () => { + navigate({ to: '/alerts/notification-channels' }); + }; + + const formMethods = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + resolver: yupResolver(createNotificationChannelSchema), + }); + + const { + control, + resetField, + handleSubmit, + formState: { isSubmitting }, + setError, + } = formMethods; + + const channelTypeWatcher = useWatch({ control, name: 'type' }); + + const { mutateAsync: createChannel } = useCreateNotificationChannel(); + + const { enqueueSnackbar } = useSnackbar(); + + // submit the form and create the notification channel on success and show snackbar message on success or failure + const onSubmit = handleSubmit(async (values) => { + try { + await createChannel(filterCreateChannelFormValues(values)); + enqueueSnackbar(CREATE_CHANNEL_SUCCESS_MESSAGE, { + variant: 'success', + }); + createChannelExit(); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { + message: error.reason ?? CREATE_CHANNEL_FAILED_MESSAGE, + }); + } else { + enqueueSnackbar(error.reason ?? CREATE_CHANNEL_FAILED_MESSAGE, { + variant: 'error', + }); + } + } + } + }); + + return ( + + + +
+ + Channel Settings + + { + // Reset the name field when the channel type changes + const handleChannelTypeChange = (value: ChannelType | null) => { + field.onChange(value); + resetField('name', { defaultValue: '' }); + resetField('recipients', { defaultValue: [] }); + }; + + return ( + + ); + }} + /> + {channelTypeWatcher && ( + ( + + )} + /> + )} + {channelTypeWatcher === 'email' && ( + ( + + )} + /> + )} + + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute.ts new file mode 100644 index 00000000000..acd8099c759 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { CreateNotificationChannel } from './CreateNotificationChannel'; + +export const cloudPulseCreateNotificationChannelLazyRoute = createLazyRoute( + '/alerts/notification-channels/create' +)({ + component: CreateNotificationChannel, +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts new file mode 100644 index 00000000000..8a2cec032b6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts @@ -0,0 +1,17 @@ +import { array, mixed, object, string } from 'yup'; + +import type { ChannelType } from '@linode/api-v4'; + +const fieldErrorMessage = 'This field is required.'; + +export const createNotificationChannelSchema = object({ + name: string().required(fieldErrorMessage), + type: mixed() + .required(fieldErrorMessage) + .nullable() + .test('nonNull', fieldErrorMessage, (value) => value !== null), + recipients: array() + .of(string().defined()) + .required(fieldErrorMessage) + .min(1, fieldErrorMessage), +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts new file mode 100644 index 00000000000..680f1a919ed --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/types.ts @@ -0,0 +1,7 @@ +import type { ChannelType } from '@linode/api-v4'; + +export interface CreateNotificationChannelForm { + name: string; + recipients: string[]; + type: ChannelType | null; +} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts new file mode 100644 index 00000000000..239a148af5d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/utilities.ts @@ -0,0 +1,16 @@ +import type { CreateNotificationChannelForm } from './types'; +import type { CreateNotificationChannelPayload } from '@linode/api-v4'; + +export const filterCreateChannelFormValues = ( + formValues: CreateNotificationChannelForm +): CreateNotificationChannelPayload => { + return { + channel_type: formValues.type ?? 'email', + details: { + email: { + usernames: formValues.recipients, + }, + }, + label: formValues.name, + }; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx index 4423cadcde4..b47ada6f4c3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx @@ -1,4 +1,5 @@ -import { Box, Stack } from '@linode/ui'; +import { Box, Button, Stack } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -14,6 +15,8 @@ export const NotificationChannelListing = () => { isLoading, } = useAllAlertNotificationChannelsQuery(); + const navigate = useNavigate(); + const [searchText, setSearchText] = React.useState(''); const topRef = React.useRef(null); @@ -33,8 +36,11 @@ export const NotificationChannelListing = () => { return ( @@ -47,6 +53,25 @@ export const NotificationChannelListing = () => { sx={{ width: '270px' }} value={searchText} /> + { + return HttpResponse.json(notificationChannelFactory.build()); + }), ]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index c0ea1b86b13..b13f0e2ef04 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,6 +1,7 @@ import { addEntityToAlert, createAlertDefinition, + createNotificationChannel, deleteAlertDefinition, deleteEntityFromAlert, editAlertDefinition, @@ -21,6 +22,7 @@ import type { Alert, CloudPulseAlertsPayload, CreateAlertDefinitionPayload, + CreateNotificationChannelPayload, DeleteAlertPayload, EditAlertPayloadWithService, EntityAlertUpdatePayload, @@ -258,3 +260,28 @@ export const useServiceAlertsMutation = ( }, }); }; + +export const useCreateNotificationChannel = () => { + const queryClient = useQueryClient(); + return useMutation< + NotificationChannel, + APIError[], + CreateNotificationChannelPayload + >({ + mutationFn: (data) => createNotificationChannel(data), + onSuccess: async (newChannel) => { + const allChannelsKey = + queryFactory.notificationChannels._ctx.all().queryKey; + const oldChannels = + queryClient.getQueryData(allChannelsKey); + + // Use cached alerts list if available to avoid refetching from API. + if (oldChannels) { + queryClient.setQueryData(allChannelsKey, [ + ...oldChannels, + newChannel, + ]); + } + }, + }); +}; diff --git a/packages/manager/src/routes/alerts/index.ts b/packages/manager/src/routes/alerts/index.ts index 2edcd922c60..26aafab6213 100644 --- a/packages/manager/src/routes/alerts/index.ts +++ b/packages/manager/src/routes/alerts/index.ts @@ -86,6 +86,15 @@ const cloudPulseNotificationChannelDetailRoute = createRoute({ ).then((m) => m.cloudPulseAlertsNotificationChannelDetailLazyRoute) ); +export const cloudPulseNotificationChannelsCreateRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/create', +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/cloudPulseCreateNotificationChannelLazyRoute' + ).then((m) => m.cloudPulseCreateNotificationChannelLazyRoute) +); + export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsIndexRoute, cloudPulseAlertsDefinitionsRoute.addChildren([ @@ -96,5 +105,6 @@ export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsDefinitionsCatchAllRoute, cloudPulseNotificationChannelsRoute.addChildren([ cloudPulseNotificationChannelDetailRoute, + cloudPulseNotificationChannelsCreateRoute, ]), ]); diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index bc09c7b2b8d..69b2dbe268e 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -133,3 +133,18 @@ export const editAlertDefinitionSchema = object({ scope: string().oneOf(['entity', 'region', 'account']).nullable().optional(), regions: array().of(string().defined()).optional(), }); + +export const createNotificationChannelPayloadSchema = object({ + label: string().required(fieldErrorMessage), + channel_type: string() + .oneOf(['email', 'webhook', 'pagerduty', 'slack']) + .required(fieldErrorMessage), + details: object({ + email: object({ + usernames: array() + .of(string()) + .min(1, fieldErrorMessage) + .required(fieldErrorMessage), + }).required(), + }).required(), +}); From 8e5355432e80ceb3d5e8d823ce4a666654ff1170 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 30 Dec 2025 15:26:14 +0530 Subject: [PATCH 39/60] fix: [UIE-9531] - Exclude newly added unsaved rulesets from dropdown for the given Firewall (#13226) * Exclude newly added unsaved rulesets from dropdown * Added changeset: Exclude newly added unsaved Rule Sets from dropdown for the given Firewall --- packages/manager/.changeset/pr-13226-fixed-1767004688291.md | 5 +++++ .../Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx | 5 +---- 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-13226-fixed-1767004688291.md diff --git a/packages/manager/.changeset/pr-13226-fixed-1767004688291.md b/packages/manager/.changeset/pr-13226-fixed-1767004688291.md new file mode 100644 index 00000000000..0b5ad1fdc86 --- /dev/null +++ b/packages/manager/.changeset/pr-13226-fixed-1767004688291.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Exclude newly added unsaved Rule Sets from dropdown for the given Firewall ([#13226](https://github.com/linode/manager/pull/13226)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 517d7d90aeb..2ee368fe3d2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -547,10 +547,7 @@ export const FirewallRulesLanding = React.memo((props: Props) => { modeViewedFrom: ruleDrawer.mode, }); }} - inboundAndOutboundRules={[ - ...(rules.inbound ?? []), - ...(rules.outbound ?? []), - ]} + inboundAndOutboundRules={[...inboundRules, ...outboundRules]} isOpen={ location.pathname.endsWith('add/inbound') || location.pathname.endsWith('add/outbound') || From cb8fb31e258fdafb6af19a45713ac456f9749053 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:46:22 +0530 Subject: [PATCH 40/60] upcoming: [DI-29080] - Notification Channel Types (#13227) * upcoming: [DI-29080] - Notification Channel Types * add changesets --------- Co-authored-by: Ankita --- .../pr-13227-changed-1767012120929.md | 5 ++ packages/api-v4/src/cloudpulse/types.ts | 47 +++++++++++++++++-- .../pr-13227-fixed-1767012195840.md | 5 ++ .../alert-notification-channel-list.spec.ts | 2 +- .../AddChannelListing.test.tsx | 2 +- .../RenderChannelDetails.test.tsx | 2 +- .../RenderChannelDetails.tsx | 2 +- .../features/CloudPulse/Alerts/Utils/utils.ts | 8 ++-- 8 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13227-changed-1767012120929.md create mode 100644 packages/manager/.changeset/pr-13227-fixed-1767012195840.md diff --git a/packages/api-v4/.changeset/pr-13227-changed-1767012120929.md b/packages/api-v4/.changeset/pr-13227-changed-1767012120929.md new file mode 100644 index 00000000000..4a38409b2bf --- /dev/null +++ b/packages/api-v4/.changeset/pr-13227-changed-1767012120929.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +ACLP-Alerting: Notification Channel types to support API changes and backward compatibility ([#13227](https://github.com/linode/manager/pull/13227)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 9f714a05b01..c7bfddc7c8d 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -46,6 +46,11 @@ type AlertNotificationEmail = 'email'; type AlertNotificationSlack = 'slack'; type AlertNotificationPagerDuty = 'pagerduty'; type AlertNotificationWebHook = 'webhook'; +type EmailRecipientType = + | 'admin_users' + | 'read_users' + | 'read_write_users' + | 'user'; export interface Dashboard { created: string; group_by?: string[]; @@ -298,29 +303,48 @@ interface NotificationChannelBase { interface NotificationChannelEmail extends NotificationChannelBase { channel_type: AlertNotificationEmail; - content: { + content?: { email: { email_addresses: string[]; message: string; subject: string; }; }; + details?: { + email: { + recipient_type: EmailRecipientType; + usernames: string[]; + }; + }; } interface NotificationChannelSlack extends NotificationChannelBase { channel_type: AlertNotificationSlack; - content: { + content?: { slack: { message: string; slack_channel: string; slack_webhook_url: string; }; }; + details?: { + slack: { + slack_channel: string; + slack_webhook_url: string; + }; + }; } interface NotificationChannelPagerDuty extends NotificationChannelBase { channel_type: AlertNotificationPagerDuty; - content: { + content?: { + pagerduty: { + attributes: string[]; + description: string; + service_api_key: string; + }; + }; + details?: { pagerduty: { attributes: string[]; description: string; @@ -330,12 +354,27 @@ interface NotificationChannelPagerDuty extends NotificationChannelBase { } interface NotificationChannelWebHook extends NotificationChannelBase { channel_type: AlertNotificationWebHook; - content: { + content?: { + webhook: { + http_headers: { + header_key: string; + header_value: string; + }[]; + webhook_url: string; + }; + }; + details?: { webhook: { + alert_body: { + body: string; + subject: string; + }; http_headers: { header_key: string; header_value: string; }[]; + method: 'GET' | 'POST' | 'PUT'; + request_body: string; webhook_url: string; }; }; diff --git a/packages/manager/.changeset/pr-13227-fixed-1767012195840.md b/packages/manager/.changeset/pr-13227-fixed-1767012195840.md new file mode 100644 index 00000000000..2c641803d35 --- /dev/null +++ b/packages/manager/.changeset/pr-13227-fixed-1767012195840.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Null and Undefined checks in components and tests to support ACLP-Alerting: Notification Channel Type changes ([#13227](https://github.com/linode/manager/pull/13227)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index 01c9b8bfad5..c508f1cf3ee 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -101,7 +101,7 @@ const isEmailContent = ( message: string; subject: string; }; -} => 'email' in content; +} => content !== undefined && 'email' in content; const mockProfile = profileFactory.build({ timezone: 'gmt', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx index d6433bdd0ed..743e1503753 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx @@ -40,7 +40,7 @@ describe('Channel Listing component', () => { it('should render the notification channels ', () => { const emailAddresses = mockNotificationData[0].channel_type === 'email' && - mockNotificationData[0].content.email + mockNotificationData[0].content?.email ? mockNotificationData[0].content.email.email_addresses : []; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx index 7c3968c928a..37f4160d13d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.test.tsx @@ -12,7 +12,7 @@ const mockData: NotificationChannel = notificationChannelFactory.build(); describe('RenderChannelDetails component', () => { it('should render the email channel type notification details', () => { const emailAddresses = - mockData.channel_type === 'email' && mockData.content.email + mockData.channel_type === 'email' && mockData.content?.email ? mockData.content.email.email_addresses : []; const container = renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx index 59a163551d8..373724fc164 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/RenderChannelDetails.tsx @@ -12,7 +12,7 @@ interface RenderChannelDetailProps { export const RenderChannelDetails = (props: RenderChannelDetailProps) => { const { template } = props; if (template.channel_type === 'email') { - return template.content.email.email_addresses.map((value, index) => ( + return template.content?.email.email_addresses.map((value, index) => ( )); } diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 91808118179..78e9c495eb2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -241,22 +241,22 @@ export const getChipLabels = ( if (value.channel_type === 'email') { return { label: 'To', - values: value.content.email.email_addresses, + values: value.content?.email.email_addresses ?? [], }; } else if (value.channel_type === 'slack') { return { label: 'Slack Webhook URL', - values: [value.content.slack.slack_webhook_url], + values: [value.content?.slack.slack_webhook_url ?? ''], }; } else if (value.channel_type === 'pagerduty') { return { label: 'Service API Key', - values: [value.content.pagerduty.service_api_key], + values: [value.content?.pagerduty.service_api_key ?? ''], }; } else { return { label: 'Webhook URL', - values: [value.content.webhook.webhook_url], + values: [value.content?.webhook.webhook_url ?? ''], }; } }; From 1ba4648b5a1dbd3d2f55514d859f7b6145c7cb6a Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Thu, 1 Jan 2026 21:02:30 +0530 Subject: [PATCH 41/60] Added: [DI-29070] - New feature marker in navigation menu and primary breadcrumbs of CloudPulse metrics (#13230) * Added: [DI-29070] - new feature marker in navigation menu and primary breadcrumbs * Added: [DI-29070] - Changeset * Added: [DI-29069] - new feature chip refined control * Added: [DI-29069] - fix test --- .../pr-13230-added-1767073910229.md | 5 ++ .../cloudpulse/cloudpulse-navigation.spec.ts | 7 ++- .../components/PrimaryNav/PrimaryNav.test.tsx | 48 ++++++++++++++++++- .../src/components/PrimaryNav/PrimaryNav.tsx | 1 + packages/manager/src/featureFlags.ts | 5 ++ .../CloudPulseDashboardLanding.test.tsx | 13 ++++- .../Dashboard/CloudPulseDashboardLanding.tsx | 11 ++++- 7 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-13230-added-1767073910229.md diff --git a/packages/manager/.changeset/pr-13230-added-1767073910229.md b/packages/manager/.changeset/pr-13230-added-1767073910229.md new file mode 100644 index 00000000000..d9dd537e3a1 --- /dev/null +++ b/packages/manager/.changeset/pr-13230-added-1767073910229.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +New feature marker in navigation menu and primary breadcrumbs of `CloudPulse metrics` ([#13230](https://github.com/linode/manager/pull/13230)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts index e86622b72bf..3a561db9645 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts @@ -22,14 +22,17 @@ describe('Moniter navigation', () => { it('can navigate to metrics landing page', () => { mockAppendFeatureFlags({ aclp: { - beta: true, + beta: false, enabled: true, + new: true, }, }).as('getFeatureFlags'); cy.visitWithLogin('/linodes'); cy.wait('@getFeatureFlags'); - + cy.get('[data-testid="menu-item-Metrics"]').within(() => { + cy.get('[data-testid="newFeatureChip"]').should('be.visible'); // check for new feature chip + }); cy.get('[data-testid="menu-item-Metrics"]').should('be.visible').click(); cy.url().should('endWith', '/metrics'); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 774bcdc49c1..d5a863c094b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -228,6 +228,7 @@ describe('PrimaryNav', () => { aclp: { beta: true, enabled: true, + new: true, }, aclpAlerting: { accountAlertLimit: 10, @@ -239,7 +240,7 @@ describe('PrimaryNav', () => { }, }; - const { findAllByTestId, findByText } = renderWithTheme( + const { findAllByTestId, findByText, queryByTestId } = renderWithTheme( , { flags, @@ -249,12 +250,57 @@ describe('PrimaryNav', () => { const monitorMetricsDisplayItem = await findByText('Metrics'); const monitorAlertsDisplayItem = await findByText('Alerts'); const betaChip = await findAllByTestId('betaChip'); + const newFeatureChip = queryByTestId('newFeatureChip'); + expect(newFeatureChip).toBeNull(); // when beta is true, only beta chip is shown not new chip expect(monitorMetricsDisplayItem).toBeVisible(); expect(monitorAlertsDisplayItem).toBeVisible(); expect(betaChip).toHaveLength(2); }); + it('shoud show beta chip next to Metrics menu item if the user has the account capability and aclp feature flag has new true', async () => { + const account = accountFactory.build({ + capabilities: ['Akamai Cloud Pulse'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags = { + aclp: { + beta: false, + enabled: true, + new: true, + }, + aclpAlerting: { + accountAlertLimit: 10, + accountMetricLimit: 10, + alertDefinitions: true, + beta: true, + notificationChannels: false, + recentActivity: false, + }, + }; + + const { findByText, findByTestId } = renderWithTheme( + , + { + flags, + } + ); + + const monitorMetricsDisplayItem = await findByText('Metrics'); + const monitorAlertsDisplayItem = await findByText('Alerts'); + const newFeatureChip = await findByTestId('newFeatureChip'); + + expect(monitorMetricsDisplayItem).toBeVisible(); + expect(monitorAlertsDisplayItem).toBeVisible(); + expect(newFeatureChip).toBeVisible(); + }); + it('should not show Metrics and Alerts menu items if the user has the account capability but the aclp feature flag is not enabled', async () => { const account = accountFactory.build({ capabilities: ['Akamai Cloud Pulse'], diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index d42cfe40962..2fe1f4bffdb 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -243,6 +243,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isACLPEnabled, to: '/metrics', isBeta: flags.aclp?.beta, + isNew: !flags.aclp?.beta && flags.aclp?.new, }, { display: 'Alerts', diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index dc70ad3bbfa..ac3b6c7d149 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -104,6 +104,11 @@ interface AclpFlag { */ humanizableUnits?: string[]; + /** + * This property indicates whether the feature is new or not + */ + new?: boolean; + /** * This property indicates whether to show widget dimension filters or not */ diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index 75b0a314f59..515923c3fbc 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -50,7 +50,15 @@ vi.spyOn(utils, 'getAllDashboards').mockReturnValue({ }); describe('CloudPulseDashboardFilterBuilder component tests', () => { it('should render error placeholder if dashboard not selected', () => { - renderWithTheme(); + renderWithTheme(, { + flags: { + aclp: { + new: true, + beta: false, + enabled: true, + }, + }, + }); const text = screen.getByText('metrics'); expect(text).toBeInTheDocument(); @@ -61,6 +69,9 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { const messageComponent = screen.getByText(message); expect(messageComponent).toBeDefined(); + + const newFeatureChip = screen.getByTestId('newFeatureChip'); + expect(newFeatureChip).toBeVisible(); }); it('should render error placeholder if some dashboard is selected and filter config is not present', async () => { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index 0079167f476..a6bb489a610 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,5 +1,5 @@ import { useProfile } from '@linode/queries'; -import { Box, Paper } from '@linode/ui'; +import { Box, NewFeatureChip, Paper } from '@linode/ui'; import { GridLegacy } from '@mui/material'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -7,6 +7,7 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useFlags } from 'src/hooks/useFlags'; import { GlobalFilters } from '../Overview/GlobalFilters'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; @@ -33,6 +34,7 @@ export interface DashboardProp { export const CloudPulseDashboardLanding = () => { const { data: profile } = useProfile(); + const flags = useFlags(); const [filterData, setFilterData] = React.useState({ id: {}, label: {}, @@ -101,7 +103,12 @@ export const CloudPulseDashboardLanding = () => { }> : undefined, + }, + }} docsLabel="Docs" docsLink="https://techdocs.akamai.com/cloud-computing/docs/akamai-cloud-pulse" /> From 6dce6ccfbd6febdab0e627d7b8c26bce7bf297e4 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Mon, 5 Jan 2026 10:09:12 +0530 Subject: [PATCH 42/60] change: [DI-29069] - Move to v4 endpoint instead of v4beta for metrics in CloudPulse (#13239) * Added: [DI-29069] - V4 endpoint instead of V4 beat * Added: [DI-29069] - changeset * Added: [DI-29069] - changeset --- packages/api-v4/.changeset/pr-13239-changed-1767357712757.md | 5 +++++ packages/api-v4/src/cloudpulse/dashboards.ts | 2 +- packages/api-v4/src/cloudpulse/services.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13239-changed-1767357712757.md diff --git a/packages/api-v4/.changeset/pr-13239-changed-1767357712757.md b/packages/api-v4/.changeset/pr-13239-changed-1767357712757.md new file mode 100644 index 00000000000..8f1e9a44420 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13239-changed-1767357712757.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Move to `v4 endpoint` instead of v4beta for `CloudPulse metrics` api calls ([#13239](https://github.com/linode/manager/pull/13239)) diff --git a/packages/api-v4/src/cloudpulse/dashboards.ts b/packages/api-v4/src/cloudpulse/dashboards.ts index 9a84a74aabd..83aee84da58 100644 --- a/packages/api-v4/src/cloudpulse/dashboards.ts +++ b/packages/api-v4/src/cloudpulse/dashboards.ts @@ -1,4 +1,4 @@ -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { API_ROOT } from 'src/constants'; import Request, { setMethod, setURL } from '../request'; diff --git a/packages/api-v4/src/cloudpulse/services.ts b/packages/api-v4/src/cloudpulse/services.ts index f3a0e311f64..364ca3ea121 100644 --- a/packages/api-v4/src/cloudpulse/services.ts +++ b/packages/api-v4/src/cloudpulse/services.ts @@ -1,4 +1,4 @@ -import { BETA_API_ROOT as API_ROOT } from 'src/constants'; +import { API_ROOT } from 'src/constants'; import Request, { setData, From 5513dbd9e14b3f050fa8d350a20945312ec58d53 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:46:16 +0530 Subject: [PATCH 43/60] fix: [UIE-9891] - Fix logic to remove linode interface from firewall's device page (#13238) --- .../manager/.changeset/pr-13238-fixed-1767338454581.md | 5 +++++ .../FirewallDetail/Devices/FirewallDeviceTable.tsx | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13238-fixed-1767338454581.md diff --git a/packages/manager/.changeset/pr-13238-fixed-1767338454581.md b/packages/manager/.changeset/pr-13238-fixed-1767338454581.md new file mode 100644 index 00000000000..656b0b2a1cf --- /dev/null +++ b/packages/manager/.changeset/pr-13238-fixed-1767338454581.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Fix logic to remove linode interface from firewall's device page ([#13238](https://github.com/linode/manager/pull/13238)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index ac68229bf9d..a24429049f7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -181,9 +181,12 @@ export const FirewallDeviceTable = React.memo( disabled={disabled} handleRemoveDevice={handleRemoveDevice} isLinodeRelatedDevice={isLinodeRelatedDevice} - isLinodeUpdatable={updatableLinodes?.some( - (linode) => linode.id === thisDevice.entity.id - )} + isLinodeUpdatable={updatableLinodes?.some((linode) => { + if (thisDevice.entity.type === 'linode_interface') { + return linode.id === thisDevice.entity.parent_entity?.id; + } + return linode.id === thisDevice.entity.id; + })} isNodebalancerUpdatable={updatableNodebalancers?.some( (nodebalancer) => nodebalancer.id === thisDevice.entity.id )} From ceb6de890e228fc6b9d4b22835dadcff87060831 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:22:06 +0100 Subject: [PATCH 44/60] change: [UIE-9910] - Allow line breaks in Support Tickets markdown (#13228) * Allow line breask in support ticket markdown * Added changeset: Allow line breaks in Support Tickets markdown --- .../manager/.changeset/pr-13228-changed-1767015583414.md | 5 +++++ packages/manager/src/components/Markdown/Markdown.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-13228-changed-1767015583414.md diff --git a/packages/manager/.changeset/pr-13228-changed-1767015583414.md b/packages/manager/.changeset/pr-13228-changed-1767015583414.md new file mode 100644 index 00000000000..40f88d07db2 --- /dev/null +++ b/packages/manager/.changeset/pr-13228-changed-1767015583414.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Allow line breaks in Support Tickets markdown ([#13228](https://github.com/linode/manager/pull/13228)) diff --git a/packages/manager/src/components/Markdown/Markdown.tsx b/packages/manager/src/components/Markdown/Markdown.tsx index 749711532e1..aa50332d8a6 100644 --- a/packages/manager/src/components/Markdown/Markdown.tsx +++ b/packages/manager/src/components/Markdown/Markdown.tsx @@ -43,6 +43,7 @@ export const Markdown = (props: HighlightedMarkdownProps) => { }); } }, + breaks: true, html: true, linkify: true, }); From 73c91d1ab765dfaa92d9cb0f91a9592a665dbd20 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:53:35 +0100 Subject: [PATCH 45/60] fix: [UIE-9833] IAM Assigned Entities - Increase MAX_ITEMS_TO_RENDER to 25 (#13231) * Increase MAX_ITEMS_TO_RENDER to 25 * Added changeset: IAM Assigned Entities - Increase MAX_ITEMS_TO_RENDER to 25 --- packages/manager/.changeset/pr-13231-fixed-1767087157441.md | 5 +++++ .../src/features/IAM/Users/UserRoles/AssignedEntities.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13231-fixed-1767087157441.md diff --git a/packages/manager/.changeset/pr-13231-fixed-1767087157441.md b/packages/manager/.changeset/pr-13231-fixed-1767087157441.md new file mode 100644 index 00000000000..a67f1bdbeb2 --- /dev/null +++ b/packages/manager/.changeset/pr-13231-fixed-1767087157441.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM Assigned Entities - Increase MAX_ITEMS_TO_RENDER to 25 ([#13231](https://github.com/linode/manager/pull/13231)) diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 375c5fe2808..cb8b603dd70 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -38,7 +38,7 @@ export const AssignedEntities = ({ // We don't need to send all items to the TruncatedList component for performance reasons, // since past a certain count they will be hidden within the row. - const MAX_ITEMS_TO_RENDER = 15; + const MAX_ITEMS_TO_RENDER = 25; const entitiesToRender = sortedEntities.slice(0, MAX_ITEMS_TO_RENDER); const totalCount = sortedEntities.length; From 47c84684af302230251d849d87564f1633644f6a Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:22:30 -0500 Subject: [PATCH 46/60] =?UTF-8?q?upcoming:=20[M3-10212]=20=E2=80=93=20Add?= =?UTF-8?q?=20VPC=20IPv6=20support=20in=20Linode=20Add/Edit=20Config=20dia?= =?UTF-8?q?log=20(#13209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-13209-upcoming-features-1766118917091.md | 5 + packages/api-v4/src/linodes/types.ts | 2 +- ...r-13209-upcoming-features-1765924066272.md | 5 + .../LinodeEntityDetailRowConfigFirewall.tsx | 2 +- .../LinodeConfigs/LinodeConfigDialog.test.tsx | 7 +- .../LinodeConfigs/LinodeConfigDialog.tsx | 129 +++++-------- .../LinodeConfigs/LinodeConfigs.tsx | 17 +- .../{constants.ts => constants.tsx} | 17 ++ .../{utilities.test.ts => utilities.test.tsx} | 0 .../LinodesDetail/LinodeConfigs/utilities.ts | 81 -------- .../LinodesDetail/LinodeConfigs/utilities.tsx | 174 ++++++++++++++++++ .../LinodeInterfaceIPs.utils.ts | 4 +- .../LinodeSettings/InterfaceSelect.test.tsx | 1 + .../LinodeSettings/InterfaceSelect.tsx | 171 ++++++++++++++++- .../LinodeSettings/VPCPanel.test.tsx | 13 +- .../LinodesDetail/LinodeSettings/VPCPanel.tsx | 122 +++++++++--- .../features/VPCs/components/PublicAccess.tsx | 54 ++++-- .../pr-13209-changed-1765924184891.md | 5 + packages/validation/src/linodes.schema.ts | 27 ++- 19 files changed, 597 insertions(+), 239 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md create mode 100644 packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md rename packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/{constants.ts => constants.tsx} (87%) rename packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/{utilities.test.ts => utilities.test.tsx} (100%) delete mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts create mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx create mode 100644 packages/validation/.changeset/pr-13209-changed-1765924184891.md diff --git a/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md new file mode 100644 index 00000000000..417c8d56f0f --- /dev/null +++ b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Change range property of IPv6SLAAC to be optional ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index db79101e5bc..0d298c662a9 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -192,7 +192,7 @@ export interface ConfigInterfaceIPv4 { export interface IPv6SLAAC { address?: string; - range: string; + range?: string; } // The legacy interface type - for Configuration Profile Interfaces diff --git a/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md new file mode 100644 index 00000000000..88106a0863f --- /dev/null +++ b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add VPC IPv6 support in Linode Add/Edit Config dialog ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx index 808a3b927b4..a66f11dfaac 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx @@ -16,7 +16,7 @@ import { FirewallCell, LKEClusterCell, } from './LinodeEntityDetailRowInterfaceFirewall'; -import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/LinodeConfigs'; +import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/constants'; import { getUnableToUpgradeTooltipText } from './LinodesDetail/LinodeConfigs/UpgradeInterfaces/utils'; import type { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx index c870d9c0c3b..6710d8f776e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx @@ -10,11 +10,8 @@ import { import 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - LinodeConfigDialog, - padList, - unrecommendedConfigNoticeSelector, -} from './LinodeConfigDialog'; +import { LinodeConfigDialog } from './LinodeConfigDialog'; +import { padList, unrecommendedConfigNoticeSelector } from './utilities'; import type { MemoryLimit } from './LinodeConfigDialog'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 2fdd949f2cf..5795a49cadb 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -46,11 +46,6 @@ import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; import { DeviceSelection } from 'src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection'; import { titlecase } from 'src/features/Linodes/presentation'; -import { - LINODE_UNREACHABLE_HELPER_TEXT, - NATTED_PUBLIC_IP_HELPER_TEXT, - NOT_NATTED_HELPER_TEXT, -} from 'src/features/VPCs/constants'; import { handleFieldErrors, handleGeneralErrors, @@ -68,7 +63,11 @@ import { StyledFormGroup, StyledRadioGroup, } from './LinodeConfigDialog.styles'; -import { getPrimaryInterfaceIndex, useGetDeviceLimit } from './utilities'; +import { + getPrimaryInterfaceIndex, + unrecommendedConfigNoticeSelector, + useGetDeviceLimit, +} from './utilities'; import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect'; import type { @@ -92,7 +91,7 @@ type RunLevel = 'binbash' | 'default' | 'single'; type VirtMode = 'fullvirt' | 'paravirt'; export type MemoryLimit = 'no_limit' | 'set_limit'; -interface EditableFields { +export interface EditableFields { comments?: string; devices: DevicesAsStrings; helpers: Helpers; @@ -181,6 +180,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => { ip_ranges, ipam_address, ipv4, + ipv6, label, primary, purpose, @@ -191,6 +191,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => { ip_ranges, ipam_address, ipv4, + ipv6, label, primary, purpose, @@ -1049,7 +1050,7 @@ export const LinodeConfigDialog = (props: Props) => { /> {values.interfaces?.map((thisInterface, idx) => { - const thisInterfaceIPRanges: ExtendedIP[] = ( + const thisInterfaceIPv4Ranges: ExtendedIP[] = ( thisInterface.ip_ranges ?? [] ).map((ip_range, index) => { // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid" @@ -1069,6 +1070,26 @@ export const LinodeConfigDialog = (props: Props) => { }; }); + const thisInterfaceIPv6Ranges: ExtendedIP[] = ( + thisInterface.ipv6?.ranges ?? [] + ).map((ipv6Range, index) => { + // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ipv6.ranges[1] is invalid" + // @ts-expect-error this form intentionally breaks formik's error type + const errorString: string = formik.errors[ + `interfaces[${idx}].ipv6.ranges[${index}].range` + ]?.includes('is invalid') + ? 'Invalid IPv6 range' + : // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.ranges[${index}].range` + ]; + + return { + address: ipv6Range.range, + error: errorString, + }; + }); + return ( {unrecommendedConfigNoticeSelector({ @@ -1078,7 +1099,8 @@ export const LinodeConfigDialog = (props: Props) => { values, })} { publicIPv4Error: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].ipv4.nat_1_1`], + publicIPv6Error: + // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.is_public` + ], subnetError: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].subnet_id`], @@ -1104,6 +1131,11 @@ export const LinodeConfigDialog = (props: Props) => { vpcIPv4Error: // @ts-expect-error this form intentionally breaks formik's error type formik.errors[`interfaces[${idx}].ipv4.vpc`], + vpcIPv6Error: + // @ts-expect-error this form intentionally breaks formik's error type + formik.errors[ + `interfaces[${idx}].ipv6.slaac[0].range` + ], }} handleChange={(newInterface: ExtendedInterface) => { handleInterfaceChange(idx, newInterface); @@ -1122,6 +1154,12 @@ export const LinodeConfigDialog = (props: Props) => { subnetId={thisInterface.subnet_id} vpcId={thisInterface.vpc_id} vpcIPv4={thisInterface.ipv4?.vpc ?? undefined} + vpcIPv6={ + thisInterface.ipv6?.slaac?.[0]?.range ?? undefined + } + vpcIPv6IsPublic={ + thisInterface.ipv6?.is_public ?? false + } /> ); @@ -1243,76 +1281,3 @@ const DialogContent = (props: ConfigFormProps) => { const isUsingCustomRoot = (value: string) => pathsOptionsLabels.includes(value) === false; - -const noticeForScenario = (scenarioText: string) => ( - -); - -/** - * Returns a JSX warning notice if the current network interface configuration - * is unrecommended and may lead to undesired or unsupported behavior. - * - * @param _interface the current config interface being passed in - * @param primaryInterfaceIndex the index of the primary interface - * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object - * @param values the values held in Formik state, having a type of `EditableFields` - * @returns JSX.Element | null - */ -export const unrecommendedConfigNoticeSelector = ({ - _interface, - primaryInterfaceIndex, - thisIndex, - values, -}: { - _interface: ExtendedInterface; - primaryInterfaceIndex: null | number; - thisIndex: number; - values: EditableFields; -}): JSX.Element | null => { - const vpcInterface = _interface.purpose === 'vpc'; - const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1); - - const filteredInterfaces = - values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ?? - []; - - // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done - const primaryInterfaceIsVPC = - primaryInterfaceIndex !== null && - values.interfaces && - values.interfaces[primaryInterfaceIndex].purpose === 'vpc'; - - /* - Scenario 1: - - the interface passed in to this function is a VPC interface - - the index of the primary interface !== the index of the interface passed in to this function - - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked) - - Scenario 2: - - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked) - - Scenario 3: - - only eth0 populated, and it is a VPC interface - - If not one of the above scenarios, do not display a warning notice re: configuration - */ - if ( - vpcInterface && - primaryInterfaceIndex !== thisIndex && - !primaryInterfaceIsVPC - ) { - return nattedIPv4Address - ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT) - : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT); - } - - if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) { - return noticeForScenario(NOT_NATTED_HELPER_TEXT); - } - - return null; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index b543c9048c9..9314ea4ed61 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -3,7 +3,7 @@ import { useGrants, useLinodeQuery, } from '@linode/queries'; -import { Box, Button, Typography } from '@linode/ui'; +import { Box, Button } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -28,24 +28,11 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { useLinodeDetailContext } from '../LinodesDetailContext'; import { BootConfigDialog } from './BootConfigDialog'; import { ConfigRow } from './ConfigRow'; +import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './constants'; import { DeleteConfigDialog } from './DeleteConfigDialog'; import { LinodeConfigDialog } from './LinodeConfigDialog'; import { getUnableToUpgradeTooltipText } from './UpgradeInterfaces/utils'; -export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = ( - <> - - Configuration Profile interfaces from a single profile can be upgraded to - Linode Interfaces. - - - After the upgrade, the Linode can only use Linode Interfaces and cannot - revert to Configuration Profile interfaces. Use the dry-run feature to - review the changes before committing. - - -); - const LinodeConfigs = () => { const theme = useTheme(); const navigate = useNavigate(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx similarity index 87% rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx index d1cb140afbb..4c140df6420 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx @@ -1,3 +1,6 @@ +import { Typography } from '@linode/ui'; +import * as React from 'react'; + export const deviceSlots = [ 'sda', 'sdb', @@ -133,3 +136,17 @@ export const pathsOptions = [ ]; export const pathsOptionsLabels = pathsOptions.map((path) => path.label); + +export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = ( + <> + + Configuration Profile interfaces from a single profile can be upgraded to + Linode Interfaces. + + + After the upgrade, the Linode can only use Linode Interfaces and cannot + revert to Configuration Profile interfaces. Use the dry-run feature to + review the changes before committing. + + +); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx similarity index 100% rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts deleted file mode 100644 index faed50c71af..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isEmpty } from '@linode/api-v4'; - -import { DEFAULT_DEVICE_LIMIT } from 'src/constants'; -import { useFlags } from 'src/hooks/useFlags'; - -import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4'; - -/** - * Gets the index of the primary Linode interface - * - * The function does more than just look for `primary: true`. It will also return the index - * of the implicit primary interface. (The API does not enforce that a Linode config always - * has an interface that is marked as primary) - * - * This is the general logic we follow in this function: - * - If an interface is primary we know that's the primary - * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary. - * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface - * - * @returns the index of the primary interface or `null` if there is not a primary interface - */ -export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { - const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary); - - // If an interface has `primary: true` we know thats the primary so just return it. - if (indexOfPrimaryInterface !== -1) { - return indexOfPrimaryInterface; - } - - // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface, - // and it will be eth0 on the Linode. This interface will be primary. - // This case isn't really nessesary because this form is built so that the interfaces state will be - // populated even if the API returns an empty interfaces array, but I'm including it for completeness. - if (isEmpty(interfaces)) { - return null; - } - - // If a config has interfaces but none of them are marked as primary, - // then the first interface in the list that’s not a VLAN will shown as the primary interface. - const inherentIndexOfPrimaryInterface = interfaces.findIndex( - (i) => i.purpose !== 'vlan' - ); - - if (inherentIndexOfPrimaryInterface !== -1) { - // If we're able to find the inherent primary interface, just return it. - return inherentIndexOfPrimaryInterface; - } - - // If we haven't been able to find the primary interface by this point, the Linode doesn't have one. - // As an example, this is the case when a Linode only has a VLAN interface. - return null; -}; - -/** - * Determines the maximum available Linodes allowed for a configuration profile - * - * returns MAX(8, MIN(ram / 1024, 64)) - * - * @param ram the Linode's available ram - * @returns the device limit allowed - */ -export const useGetDeviceLimit = (ram: number) => { - const flags = useFlags(); - if (flags.blockStorageVolumeLimit) { - return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64)); - } - - return DEFAULT_DEVICE_LIMIT; -}; - -export const isDiskDevice = ( - device: DiskDevice | VolumeDevice -): device is DiskDevice => { - return 'disk_id' in device && device.disk_id !== null; -}; - -export const isVolumeDevice = ( - device: DiskDevice | VolumeDevice -): device is VolumeDevice => { - return 'volume_id' in device && device.volume_id !== null; -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx new file mode 100644 index 00000000000..02ab3260baa --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx @@ -0,0 +1,174 @@ +import { isEmpty } from '@linode/api-v4'; +import { Notice } from '@linode/ui'; +import * as React from 'react'; +import type { JSX } from 'react'; + +import { DEFAULT_DEVICE_LIMIT } from 'src/constants'; +import { + LINODE_UNREACHABLE_HELPER_TEXT, + NATTED_PUBLIC_IP_HELPER_TEXT, + NOT_NATTED_HELPER_TEXT, +} from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; + +import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect'; +import type { EditableFields } from './LinodeConfigDialog'; +import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4'; + +/** + * Gets the index of the primary Linode interface + * + * The function does more than just look for `primary: true`. It will also return the index + * of the implicit primary interface. (The API does not enforce that a Linode config always + * has an interface that is marked as primary) + * + * This is the general logic we follow in this function: + * - If an interface is primary we know that's the primary + * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary. + * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface + * + * @returns the index of the primary interface or `null` if there is not a primary interface + */ +export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => { + const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary); + + // If an interface has `primary: true` we know thats the primary so just return it. + if (indexOfPrimaryInterface !== -1) { + return indexOfPrimaryInterface; + } + + // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface, + // and it will be eth0 on the Linode. This interface will be primary. + // This case isn't really nessesary because this form is built so that the interfaces state will be + // populated even if the API returns an empty interfaces array, but I'm including it for completeness. + if (isEmpty(interfaces)) { + return null; + } + + // If a config has interfaces but none of them are marked as primary, + // then the first interface in the list that’s not a VLAN will shown as the primary interface. + const inherentIndexOfPrimaryInterface = interfaces.findIndex( + (i) => i.purpose !== 'vlan' + ); + + if (inherentIndexOfPrimaryInterface !== -1) { + // If we're able to find the inherent primary interface, just return it. + return inherentIndexOfPrimaryInterface; + } + + // If we haven't been able to find the primary interface by this point, the Linode doesn't have one. + // As an example, this is the case when a Linode only has a VLAN interface. + return null; +}; + +/** + * Determines the maximum available Linodes allowed for a configuration profile + * + * returns MAX(8, MIN(ram / 1024, 64)) + * + * @param ram the Linode's available ram + * @returns the device limit allowed + */ +export const useGetDeviceLimit = (ram: number) => { + const flags = useFlags(); + if (flags.blockStorageVolumeLimit) { + return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64)); + } + + return DEFAULT_DEVICE_LIMIT; +}; + +export const isDiskDevice = ( + device: DiskDevice | VolumeDevice +): device is DiskDevice => { + return 'disk_id' in device && device.disk_id !== null; +}; + +export const isVolumeDevice = ( + device: DiskDevice | VolumeDevice +): device is VolumeDevice => { + return 'volume_id' in device && device.volume_id !== null; +}; + +/** + * We want to pad the interface list in the UI with purpose.none + * interfaces up to the maximum (currently 3); any purpose.none + * interfaces will be removed from the payload before submission, + * they are only used as placeholders presented to the user as empty selects. + */ +export const padList = (list: T[], filler: T, size: number = 3): T[] => { + return [...list, ...Array(Math.max(0, size - list.length)).fill(filler)]; +}; + +export const noticeForScenario = (scenarioText: string) => ( + +); + +/** + * Returns a JSX warning notice if the current network interface configuration + * is unrecommended and may lead to undesired or unsupported behavior. + * + * @param _interface the current config interface being passed in + * @param primaryInterfaceIndex the index of the primary interface + * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object + * @param values the values held in Formik state, having a type of `EditableFields` + * @returns JSX.Element | null + */ +export const unrecommendedConfigNoticeSelector = ({ + _interface, + primaryInterfaceIndex, + thisIndex, + values, +}: { + _interface: ExtendedInterface; + primaryInterfaceIndex: null | number; + thisIndex: number; + values: EditableFields; +}): JSX.Element | null => { + const vpcInterface = _interface.purpose === 'vpc'; + const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1); + + const filteredInterfaces = + values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ?? + []; + + // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done + const primaryInterfaceIsVPC = + primaryInterfaceIndex !== null && + values.interfaces && + values.interfaces[primaryInterfaceIndex].purpose === 'vpc'; + + /* + Scenario 1: + - the interface passed in to this function is a VPC interface + - the index of the primary interface !== the index of the interface passed in to this function + - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked) + + Scenario 2: + - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked) + + Scenario 3: + - only eth0 populated, and it is a VPC interface + + If not one of the above scenarios, do not display a warning notice re: configuration + */ + if ( + vpcInterface && + primaryInterfaceIndex !== thisIndex && + !primaryInterfaceIsVPC + ) { + return nattedIPv4Address + ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT) + : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT); + } + + if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) { + return noticeForScenario(NOT_NATTED_HELPER_TEXT); + } + + return null; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts index b00e20ed925..74a0ba9105f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts @@ -64,7 +64,9 @@ export function getLinodeInterfaceIPs(linodeInterface: LinodeInterface) { for (const slaacs of linodeInterface.vpc.ipv6.slaac) { if (slaacs.address) { ips.push(slaacs.address); - ips.push(slaacs.range); + if (slaacs.range) { + ips.push(slaacs.range); + } } } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx index df20f1f0b54..307469b6b06 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx @@ -27,6 +27,7 @@ describe('InterfaceSelect', () => { region: 'us-east', regionHasVLANs: true, slotNumber: 0, + vpcIPv6IsPublic: false, }; it('should display helper text regarding VPCs not being available in the region in the Linode Add/Edit Config dialog if applicable', async () => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index daac7de0eef..be8d35b2281 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -1,4 +1,4 @@ -import { useVlansQuery } from '@linode/queries'; +import { useSubnetQuery, useVlansQuery } from '@linode/queries'; import { Autocomplete, Divider, @@ -14,6 +14,7 @@ import * as React from 'react'; import type { JSX } from 'react'; import { VPCPanel } from 'src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel'; +import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; import type { @@ -27,6 +28,7 @@ interface InterfaceErrors extends VPCInterfaceErrors, OtherInterfaceErrors {} interface InterfaceSelectProps extends VPCState { additionalIPv4RangesForVPC?: ExtendedIP[]; + additionalIPv6RangesForVPC?: ExtendedIP[]; errors: InterfaceErrors; fromAddonsPanel?: boolean; handleChange: (updatedInterface: ExtendedInterface) => void; @@ -37,14 +39,17 @@ interface InterfaceSelectProps extends VPCState { regionHasVLANs?: boolean; regionHasVPCs?: boolean; slotNumber: number; + vpcIPv6IsPublic: boolean; } interface VPCInterfaceErrors { ipRangeError?: string; labelError?: string; publicIPv4Error?: string; + publicIPv6Error?: string; subnetError?: string; vpcError?: string; vpcIPv4Error?: string; + vpcIPv6Error?: string; } interface OtherInterfaceErrors { @@ -57,6 +62,7 @@ interface VPCState { subnetId?: null | number; vpcId?: null | number; vpcIPv4?: string; + vpcIPv6?: string; } // To allow for empty slots, which the API doesn't account for @@ -69,6 +75,7 @@ export interface ExtendedInterface export const InterfaceSelect = (props: InterfaceSelectProps) => { const { additionalIPv4RangesForVPC, + additionalIPv6RangesForVPC, errors, fromAddonsPanel, handleChange, @@ -82,7 +89,9 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { slotNumber, subnetId, vpcIPv4, + vpcIPv6, vpcId, + vpcIPv6IsPublic, } = props; const theme = useTheme(); @@ -90,8 +99,24 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { theme.breakpoints.down(fromAddonsPanel ? 'sm' : 1015) ); + const { isDualStackEnabled } = useVPCDualStack(); + + const { data: selectedSubnet } = useSubnetQuery( + vpcId ?? -1, + subnetId ?? -1, + isDualStackEnabled && Boolean(vpcId) && Boolean(subnetId) + ); + + // Show IPv6 content if Dual Stack is enabled and the VPC of the selected subnet is Dual Stack + const showIPv6Content = + isDualStackEnabled && + Boolean(selectedSubnet?.ipv6?.length && selectedSubnet?.ipv6?.length > 0); + const [newVlan, setNewVlan] = React.useState(''); + const [autoassignIPv6VPCAddress, setAutoassignIPv6VPCAddress] = + React.useState(false); + const purposeOptions: SelectOption[] = [ { label: 'Public Internet', @@ -131,6 +156,10 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { (ip_range) => ip_range.address ); + const _additionalIPv6RangesForVPC = additionalIPv6RangesForVPC?.map( + (ip_range) => ({ range: ip_range.address }) + ); + const handlePurposeChange = (selectedValue: ExtendedPurpose) => { const purpose = selectedValue; handleChange({ @@ -150,6 +179,12 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { purpose, }); + const slaacFieldValue = autoassignIPv6VPCAddress + ? [{ range: 'auto' }] + : vpcIPv6 + ? [{ range: vpcIPv6 }] + : undefined; + const handleVPCLabelChange = (selectedVPCId: number) => { // Only clear VPC related fields if VPC selection changes if (selectedVPCId !== vpcId) { @@ -159,6 +194,13 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, vpc_id: selectedVPCId, }); @@ -172,6 +214,31 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }); + }; + + const handleIPv6RangeChange = (ipv6Ranges: ExtendedIP[]) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: vpcIPv6IsPublic, + ranges: ipv6Ranges.map((ip_range) => ({ range: ip_range.address })), + slaac: slaacFieldValue, + }, purpose, subnet_id: subnetId, vpc_id: vpcId, @@ -185,35 +252,110 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { nat_1_1: nattedIPv4Address, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, subnet_id: selectedSubnetId, vpc_id: vpcId, }); - const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) => - handleChange({ + const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) => { + const obj = { ip_ranges: _additionalIPv4RangesForVPC, ipv4: { nat_1_1: nattedIPv4Address, vpc: vpcIPv4Input, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }; + + handleChange(obj); + }; + + const handleVPCIPv6Input = (vpcIPv6Input: string | undefined) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: + vpcIPv6Input !== undefined && vpcIPv6Input !== '' + ? [{ range: vpcIPv6Input }] + : undefined, + }, purpose, subnet_id: subnetId, vpc_id: vpcId, }); + }; - const handleIPv4Input = (IPv4Input: null | string) => + const handleIPv4Input = (ipv4Input: null | string) => handleChange({ ip_ranges: _additionalIPv4RangesForVPC, ipv4: { - nat_1_1: IPv4Input, + nat_1_1: ipv4Input, vpc: vpcIPv4, }, + ipv6: showIPv6Content + ? { + is_public: vpcIPv6IsPublic, + ranges: _additionalIPv6RangesForVPC, + slaac: slaacFieldValue, + } + : undefined, purpose, subnet_id: subnetId, vpc_id: vpcId, }); + const handleIPv6IsPublicChange = (vpcIPv6IsPublic: boolean) => { + handleChange({ + ip_ranges: _additionalIPv4RangesForVPC, + ipv4: { + nat_1_1: nattedIPv4Address, + vpc: vpcIPv4, + }, + ipv6: { + is_public: !vpcIPv6IsPublic, + slaac: slaacFieldValue, + ranges: _additionalIPv6RangesForVPC, + }, + purpose, + subnet_id: subnetId, + vpc_id: vpcId, + }); + }; + + const handleToggleAutoassignIPv6WithinVPCEnabled = () => { + const newValue = !autoassignIPv6VPCAddress; + + setAutoassignIPv6VPCAddress(newValue); + + if (newValue) { + handleVPCIPv6Input('auto'); + } else { + handleVPCIPv6Input(undefined); + } + }; + const handleCreateOption = (_newVlan: string) => { setNewVlan(_newVlan); handleChange({ @@ -400,25 +542,42 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { + handleIPv4Input(nattedIPv4Address === undefined ? 'any' : null) + } + toggleAssignPublicIPv6Address={() => + handleIPv6IsPublicChange(vpcIPv6IsPublic) + } toggleAutoassignIPv4WithinVPCEnabled={() => handleVPCIPv4Input(vpcIPv4 === undefined ? '' : undefined) } + toggleAutoassignIPv6WithinVPCEnabled={ + handleToggleAutoassignIPv6WithinVPCEnabled + } vpcIdError={errors.vpcError} vpcIPRangesError={errors.ipRangeError} vpcIPv4AddressOfLinode={vpcIPv4} vpcIPv4Error={errors.vpcIPv4Error} + vpcIPv6AddressOfLinode={vpcIPv6} + vpcIPv6Error={errors.vpcIPv6Error} /> )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx index a9ec9ba9a1e..385a43fe9ca 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx @@ -13,18 +13,27 @@ beforeAll(() => mockMatchMedia()); const props = { additionalIPv4RangesForVPC: [], + additionalIPv6RangesForVPC: [], assignPublicIPv4Address: false, + assignPublicIPv6Address: false, autoassignIPv4WithinVPC: true, + autoassignIPv6WithinVPC: false, handleIPv4RangeChange: vi.fn(), + handleIPv6RangeChange: vi.fn(), handleSelectVPC: vi.fn(), handleSubnetChange: vi.fn(), handleVPCIPv4Change: vi.fn(), + handleVPCIPv6Change: vi.fn(), region: 'us-east', selectedSubnetId: undefined, selectedVPCId: undefined, + showIPv6Content: false, toggleAssignPublicIPv4Address: vi.fn(), + toggleAssignPublicIPv6Address: vi.fn(), toggleAutoassignIPv4WithinVPCEnabled: vi.fn(), + toggleAutoassignIPv6WithinVPCEnabled: vi.fn(), vpcIPv4AddressOfLinode: undefined, + vpcIPv6AddressOfLinode: undefined, }; const vpcPanelTestId = 'vpc-panel'; @@ -149,9 +158,7 @@ describe('VPCPanel', () => { await waitFor(() => { expect( - wrapper.getByLabelText( - 'Auto-assign a VPC IPv4 address for this Linode in the VPC' - ) + wrapper.getByLabelText('Auto-assign VPC IPv4 address') ).not.toBeChecked(); // Using regex here to account for the "(required)" indicator. expect(wrapper.getByLabelText(/^VPC IPv4.*/)).toHaveValue('10.0.4.3'); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx index 42e9ab02fdc..d555b64b689 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx @@ -18,10 +18,11 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; -import { PublicIPv4Access } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/PublicIPv4Access'; +import { PublicAccess } from 'src/features/VPCs/components/PublicAccess'; import { REGION_CAVEAT_HELPER_TEXT, VPC_AUTO_ASSIGN_IPV4_TOOLTIP, + VPC_AUTO_ASSIGN_IPV6_TOOLTIP, } from 'src/features/VPCs/constants'; import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -30,23 +31,34 @@ import type { ExtendedIP } from 'src/utilities/ipUtils'; export interface VPCPanelProps { additionalIPv4RangesForVPC: ExtendedIP[]; + additionalIPv6RangesForVPC: ExtendedIP[]; assignPublicIPv4Address: boolean; + assignPublicIPv6Address: boolean; autoassignIPv4WithinVPC: boolean; + autoassignIPv6WithinVPC: boolean; handleIPv4RangeChange: (ranges: ExtendedIP[]) => void; + handleIPv6RangeChange: (ranges: ExtendedIP[]) => void; handleSelectVPC: (vpcId: number) => void; handleSubnetChange: (subnetId: number | undefined) => void; handleVPCIPv4Change: (IPv4: string) => void; + handleVPCIPv6Change: (IPv6: string) => void; publicIPv4Error?: string; + publicIPv6Error?: string; region: string | undefined; selectedSubnetId: null | number | undefined; selectedVPCId: null | number | undefined; + showIPv6Content: boolean; subnetError?: string; toggleAssignPublicIPv4Address: (ipv4Access: null | string) => void; + toggleAssignPublicIPv6Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; + toggleAutoassignIPv6WithinVPCEnabled: () => void; vpcIdError?: string; vpcIPRangesError?: string; vpcIPv4AddressOfLinode: string | undefined; vpcIPv4Error?: string; + vpcIPv6AddressOfLinode: string | undefined; + vpcIPv6Error?: string; } const ERROR_GROUP_STRING = 'vpc-errors'; @@ -54,23 +66,34 @@ const ERROR_GROUP_STRING = 'vpc-errors'; export const VPCPanel = (props: VPCPanelProps) => { const { additionalIPv4RangesForVPC, + additionalIPv6RangesForVPC, assignPublicIPv4Address, + assignPublicIPv6Address, autoassignIPv4WithinVPC, + autoassignIPv6WithinVPC, handleIPv4RangeChange, + handleIPv6RangeChange, handleSelectVPC, handleSubnetChange, handleVPCIPv4Change, + handleVPCIPv6Change, publicIPv4Error, + publicIPv6Error, region, selectedSubnetId, selectedVPCId, + showIPv6Content, subnetError, toggleAssignPublicIPv4Address, + toggleAssignPublicIPv6Address, toggleAutoassignIPv4WithinVPCEnabled, + toggleAutoassignIPv6WithinVPCEnabled, vpcIPRangesError, vpcIPv4AddressOfLinode, vpcIPv4Error, vpcIdError, + vpcIPv6AddressOfLinode, + vpcIPv6Error, } = props; const theme = useTheme(); @@ -95,10 +118,10 @@ export const VPCPanel = (props: VPCPanelProps) => { }); React.useEffect(() => { - if (subnetError || vpcIPv4Error) { + if (subnetError || vpcIPv4Error || vpcIPv6Error) { scrollErrorIntoView(ERROR_GROUP_STRING); } - }, [subnetError, vpcIPv4Error]); + }, [subnetError, vpcIPv4Error, vpcIPv6Error]); const vpcs = vpcsData ?? []; @@ -204,8 +227,7 @@ export const VPCPanel = (props: VPCPanelProps) => { flexDirection="row" > - Auto-assign a VPC IPv4 address for this Linode in the - VPC + Auto-assign VPC IPv4 address { errorGroup={ERROR_GROUP_STRING} errorText={vpcIPv4Error} label="VPC IPv4" + noMarginTop={showIPv6Content} onChange={(e) => handleVPCIPv4Change(e.target.value)} required={!autoassignIPv4WithinVPC} value={vpcIPv4AddressOfLinode} /> )} - ({ - marginLeft: '2px', - marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0, - })} - > - - - {assignPublicIPv4Address && publicIPv4Error && ( - ({ - color: theme.color.red, - })} - > - {publicIPv4Error} - + {showIPv6Content && ( + <> + ({ + marginLeft: '2px', + paddingTop: theme.spacingFunction(8), + })} + > + + } + data-testid="vpc-ipv6-checkbox" + label={ + + + Auto-assign VPC IPv6 address + + + + } + /> + + {!autoassignIPv6WithinVPC && ( + handleVPCIPv6Change(e.target.value)} + value={vpcIPv6AddressOfLinode} + /> + )} + )} + + ) => void // The type conversion is not ideal, but seems to be the least disruptive option + } + handleAllowPublicIPv6AccessChange={ + toggleAssignPublicIPv6Address + } + publicIPv4Error={publicIPv4Error} + publicIPv6Error={publicIPv6Error} + showIPv6Content={showIPv6Content} + sx={{ margin: `${theme.spacingFunction(16)} 0` }} + userCannotAssignLinodes={false} + /> )} diff --git a/packages/manager/src/features/VPCs/components/PublicAccess.tsx b/packages/manager/src/features/VPCs/components/PublicAccess.tsx index 3cac4a4ab50..c3727cbb20c 100644 --- a/packages/manager/src/features/VPCs/components/PublicAccess.tsx +++ b/packages/manager/src/features/VPCs/components/PublicAccess.tsx @@ -24,6 +24,8 @@ interface Props { handleAllowPublicIPv6AccessChange: ( e: React.ChangeEvent ) => void; + publicIPv4Error?: string; + publicIPv6Error?: string; showIPv6Content: boolean; sx?: SxProps; userCannotAssignLinodes: boolean; @@ -35,6 +37,8 @@ export const PublicAccess = (props: Props) => { allowPublicIPv6Access, handleAllowPublicIPv4AccessChange, handleAllowPublicIPv6AccessChange, + publicIPv4Error, + publicIPv6Error, showIPv6Content, sx, userCannotAssignLinodes, @@ -61,22 +65,42 @@ export const PublicAccess = (props: Props) => { } onChange={handleAllowPublicIPv4AccessChange} /> + {allowPublicIPv4Access && publicIPv4Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv4Error} + + )} {showIPv6Content && ( - } - disabled={userCannotAssignLinodes} - label={ - - Allow public IPv6 access - - - } - onChange={handleAllowPublicIPv6AccessChange} - /> + <> + } + disabled={userCannotAssignLinodes} + label={ + + Allow public IPv6 access + + + } + onChange={handleAllowPublicIPv6AccessChange} + /> + {allowPublicIPv6Access && publicIPv6Error && ( + ({ + color: theme.color.red, + })} + > + {publicIPv6Error} + + )} + )} ); diff --git a/packages/validation/.changeset/pr-13209-changed-1765924184891.md b/packages/validation/.changeset/pr-13209-changed-1765924184891.md new file mode 100644 index 00000000000..8f0a0aceea9 --- /dev/null +++ b/packages/validation/.changeset/pr-13209-changed-1765924184891.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Use UpdateConfigProfileInterfacesSchema in UpdateLinodeConfigSchema for interfaces property ([#13209](https://github.com/linode/manager/pull/13209)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 4de83f20225..05e4c460a5b 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -141,7 +141,7 @@ const ipv4ConfigInterface = object().when('purpose', { const slaacSchema = object().shape({ range: string() - .required('VPC IPv6 is required.') + .optional() .test({ name: 'IPv6 prefix length', message: 'Must be a /64 IPv6 network CIDR', @@ -321,6 +321,29 @@ export const ConfigProfileInterfacesSchema = array() }, ); +// This was created specifically for use in UpdateLinodeConfigSchema. +// Altering `ConfigProfileInterfaceSchema` results in issues related to the `interfaces` property +// that bubble up to `CreateLinodeSchema` in LinodeCreate/schemas.ts +export const UpdateConfigProfileInterfacesSchema = array() + .of( + ConfigProfileInterfaceSchema.clone().shape({ + ipv6: ipv6Interface.notRequired().nullable(), + }), + ) + .test( + 'unique-public-interface', + 'Only one public interface per config is allowed.', + (list?: any[] | null) => { + if (!list) { + return true; + } + + return ( + list.filter((thisSlot) => thisSlot.purpose === 'public').length <= 1 + ); + }, + ); + export const UpdateConfigInterfaceOrderSchema = object({ ids: array().of(number()).required('The list of interface IDs is required.'), }); @@ -593,7 +616,7 @@ export const UpdateLinodeConfigSchema = object({ virt_mode: mixed().oneOf(['paravirt', 'fullvirt']), helpers, root_device: string(), - interfaces: ConfigProfileInterfacesSchema, + interfaces: UpdateConfigProfileInterfacesSchema, }); export const CreateLinodeDiskSchema = object({ From b7106001be85ec628fde3c373faa6f92dbfa45d9 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Tue, 6 Jan 2026 12:29:40 +0530 Subject: [PATCH 47/60] upcoming: [UIE-9811] - Add API endpoints and mocks for `Marketplace` (#13215) * upcoming: [UIE-9811] - Add API endpoints and mocks for Marketplace * import fixes * Added changeset: Add API endpoints for `Marketplace` * Added changeset: Add factory for Marketplace Partner Referral --- ...r-13215-upcoming-features-1766392680570.md | 5 ++ packages/api-v4/src/index.ts | 2 + packages/api-v4/src/marketplace/index.ts | 1 + .../api-v4/src/marketplace/marketplace.ts | 57 +++++++++++++++++++ packages/api-v4/src/marketplace/types.ts | 55 ++++++++++++++++++ packages/manager/src/mocks/serverHandlers.ts | 35 ++++++++++++ ...r-13215-upcoming-features-1766392727735.md | 5 ++ packages/utilities/src/factories/index.ts | 1 + .../utilities/src/factories/marketplace.ts | 45 +++++++++++++++ packages/validation/src/index.ts | 1 + packages/validation/src/marketplace.schema.ts | 32 +++++++++++ 11 files changed, 239 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13215-upcoming-features-1766392680570.md create mode 100644 packages/api-v4/src/marketplace/index.ts create mode 100644 packages/api-v4/src/marketplace/marketplace.ts create mode 100644 packages/api-v4/src/marketplace/types.ts create mode 100644 packages/utilities/.changeset/pr-13215-upcoming-features-1766392727735.md create mode 100644 packages/utilities/src/factories/marketplace.ts create mode 100644 packages/validation/src/marketplace.schema.ts diff --git a/packages/api-v4/.changeset/pr-13215-upcoming-features-1766392680570.md b/packages/api-v4/.changeset/pr-13215-upcoming-features-1766392680570.md new file mode 100644 index 00000000000..b108a4f591c --- /dev/null +++ b/packages/api-v4/.changeset/pr-13215-upcoming-features-1766392680570.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add API endpoints for `Marketplace` ([#13215](https://github.com/linode/manager/pull/13215)) diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 5581d74b015..1691900b86c 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -30,6 +30,8 @@ export * from './longview'; export * from './managed'; +export * from './marketplace'; + export * from './netloadbalancers'; export * from './network-transfer'; diff --git a/packages/api-v4/src/marketplace/index.ts b/packages/api-v4/src/marketplace/index.ts new file mode 100644 index 00000000000..fcb073fefcd --- /dev/null +++ b/packages/api-v4/src/marketplace/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/api-v4/src/marketplace/marketplace.ts b/packages/api-v4/src/marketplace/marketplace.ts new file mode 100644 index 00000000000..72aaeed2c26 --- /dev/null +++ b/packages/api-v4/src/marketplace/marketplace.ts @@ -0,0 +1,57 @@ +import { createPartnerReferralSchema } from '@linode/validation'; + +import { BETA_API_ROOT } from 'src/constants'; +import Request, { + setData, + setMethod, + setParams, + setURL, + setXFilter, +} from 'src/request'; + +import type { + MarketplacePartnerReferralPayload, + MarketplaceProduct, +} from './types'; +import type { Filter, ResourcePage as Page, Params } from 'src/types'; + +export const getMarketplaceProducts = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/products`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const getMarketplaceProduct = (productId: number) => + Request( + setURL( + `${BETA_API_ROOT}/marketplace/products/${encodeURIComponent(productId)}`, + ), + setMethod('GET'), + ); + +export const getMarketplaceCategories = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/categories`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const getMarketplaceTypes = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/types`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +export const createPartnerReferral = ( + data: MarketplacePartnerReferralPayload, +) => + Request<{}>( + setURL(`${BETA_API_ROOT}/marketplace/referral`), + setMethod('POST'), + setData(data, createPartnerReferralSchema), + ); diff --git a/packages/api-v4/src/marketplace/types.ts b/packages/api-v4/src/marketplace/types.ts new file mode 100644 index 00000000000..430f4dc395d --- /dev/null +++ b/packages/api-v4/src/marketplace/types.ts @@ -0,0 +1,55 @@ +export interface MarketplaceProductDetail { + documentation: string; + overview: { + description: string; + }; + pricing: string; + support: string; +} + +export interface MarketplaceProduct { + category_ids: number[]; + details?: MarketplaceProductDetail; + id: number; + info_banner?: string; + name: string; + partner_id: number; + product_tags?: string[]; + short_description: string; + title_tag?: string; + type_id: number; +} + +export interface MarketplaceCategory { + category: string; + id: number; + product_count: number; +} + +export interface MarketplaceType { + id: number; + name: string; + product_count: number; +} + +export interface MarketplacePartner { + id: number; + logo_url_light_mode: string; + logo_url_night_mode?: string; + name: string; + url: string; +} + +export interface MarketplacePartnerReferralPayload { + account_executive_email?: string; + additional_emails?: string[]; + comments?: string; + company_name?: string; + country_code: string; + email: string; + name: string; + partner_id: number; + phone: string; + phone_country_code: string; + tc_consent_given: boolean; +} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 79aa9ae194c..809905f438f 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -21,6 +21,7 @@ import { linodeStatsFactory, linodeTransferFactory, linodeTypeFactory, + marketplaceProductFactory, nodeBalancerConfigFactory, nodeBalancerConfigNodeFactory, nodeBalancerFactory, @@ -618,6 +619,39 @@ const netLoadBalancers = [ ), ]; +const marketplace = [ + http.get('*/v4beta/marketplace/products', () => { + const marketplaceProduct = marketplaceProductFactory.buildList(10); + return HttpResponse.json(makeResourcePage([...marketplaceProduct])); + }), + http.get('*/v4beta/marketplace/products/:productId', () => { + const marketplaceProductDetail = marketplaceProductFactory.buildList(10, { + details: { + overview: { + description: + 'This is a detailed description of the marketplace product.', + }, + pricing: 'Pricing information goes here.', + documentation: 'Documentation link or information goes here.', + support: 'Support information goes here.', + }, + }); + return HttpResponse.json(...marketplaceProductDetail); + }), + http.get('*/v4beta/marketplace/categories', () => { + const marketplaceCategory = marketplaceProductFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceCategory])); + }), + http.get('*/v4beta/marketplace/types', () => { + const marketplaceType = marketplaceProductFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceType])); + }), + http.post('*/v4beta/marketplace/referral', async () => { + await sleep(2000); + return HttpResponse.json({}); + }), +]; + const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); @@ -4435,6 +4469,7 @@ export const handlers = [ ...vpc, ...entities, ...netLoadBalancers, + ...marketplace, http.get('*/v4beta/maintenance/policies', () => { return HttpResponse.json( makeResourcePage(maintenancePolicyFactory.buildList(2)) diff --git a/packages/utilities/.changeset/pr-13215-upcoming-features-1766392727735.md b/packages/utilities/.changeset/pr-13215-upcoming-features-1766392727735.md new file mode 100644 index 00000000000..50d715cf2d1 --- /dev/null +++ b/packages/utilities/.changeset/pr-13215-upcoming-features-1766392727735.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Upcoming Features +--- + +Add factory for Marketplace Partner Referral ([#13215](https://github.com/linode/manager/pull/13215)) diff --git a/packages/utilities/src/factories/index.ts b/packages/utilities/src/factories/index.ts index e410363d74e..ea8d7a8d500 100644 --- a/packages/utilities/src/factories/index.ts +++ b/packages/utilities/src/factories/index.ts @@ -8,6 +8,7 @@ export * from './images'; export * from './linodeConfigInterface'; export * from './linodeInterface'; export * from './linodes'; +export * from './marketplace'; export * from './nodebalancer'; export * from './profile'; export * from './regions'; diff --git a/packages/utilities/src/factories/marketplace.ts b/packages/utilities/src/factories/marketplace.ts new file mode 100644 index 00000000000..b9f8adae80c --- /dev/null +++ b/packages/utilities/src/factories/marketplace.ts @@ -0,0 +1,45 @@ +import { Factory } from './factoryProxy'; + +import type { + MarketplaceCategory, + MarketplacePartner, + MarketplaceProduct, + MarketplaceType, +} from '@linode/api-v4'; + +export const marketplaceProductFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-product-${id}`), + partner_id: Factory.each((id) => id), + type_id: Factory.each((id) => id), + category_ids: [1, 2], + short_description: + 'This is a short description of the marketplace product.', + title_tag: 'Marketplace Product Title Tag', + product_tags: ['tag1', 'tag2'], + }); + +export const marketplaceCategoryFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + category: Factory.each((id) => `marketplace-category-${id}`), + product_count: Factory.each((id) => id * 10), + }); + +export const marketplaceTypeFactory = Factory.Sync.makeFactory( + { + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-type-${id}`), + product_count: Factory.each((id) => id * 5), + }, +); + +export const marketplacePartnersFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + name: Factory.each((id) => `marketplace-partner-${id}`), + url: 'https://www.example.com', + logo_url_light_mode: 'https://www.example.com/logo-light-mode.png', + logo_url_night_mode: 'https://www.example.com/logo-night-mode.png', + }); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index d5bee34ba22..2db71303d66 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -11,6 +11,7 @@ export * from './kubernetes.schema'; export * from './linodes.schema'; export * from './longview.schema'; export * from './managed.schema'; +export * from './marketplace.schema'; export * from './networking.schema'; export * from './nodebalancers.schema'; export * from './objectStorageKeys.schema'; diff --git a/packages/validation/src/marketplace.schema.ts b/packages/validation/src/marketplace.schema.ts new file mode 100644 index 00000000000..bb1daa7dad3 --- /dev/null +++ b/packages/validation/src/marketplace.schema.ts @@ -0,0 +1,32 @@ +import { array, boolean, number, object, string } from 'yup'; + +const AKAMAI_EMAIL_VALIDATION_REGEX = new RegExp( + /^[A-Za-z0-9._%+-]+@akamai\.com$/, +); + +export const createPartnerReferralSchema = object({ + partner_id: number().required('Partner ID is required.'), + name: string().required('Name is required.'), + email: string() + .email('Must be a valid email address.') + .required('Email is required.'), + additional_emails: array() + .of(string().email('Must be a valid email address')) + .max(2, 'You can only provide up to 2 emails') + .optional(), + country_code: string().required('Country code is required.'), + phone_country_code: string().required('Phone country code is required.'), + phone: string().required('Phone number is required.'), + company_name: string().nullable(), + account_executive_email: string() + .email('Must be a valid email address.') + .matches(AKAMAI_EMAIL_VALIDATION_REGEX, `Must be an akamai email address.`) + .optional(), + comments: string() + .nullable() + .trim() + .max(500, 'Comments must contain 500 characters or less.'), + tc_consent_given: boolean() + .oneOf([true], 'You must agree to the terms and conditions.') + .required('Terms and conditions consent is required.'), +}); From a3af683285f3ac8dd7231bc9474420c68b7ab706 Mon Sep 17 00:00:00 2001 From: Ankita Date: Tue, 6 Jan 2026 12:58:34 +0530 Subject: [PATCH 48/60] upcoming: [DI-28750] - Add edit feature for notification channels (#13235) * upcoming: [DI-28750] - Add edit feature for notification channels * upcoming: [DI-28750] - Add edit landing and corresponding schema, utils * upcoming: [DI-28750] - Adress comments, add validation for name field * upcoming: [DI-28750] - Update utility * upcoming: [DI-28750] - Address comments, refactor landing * upcoming: [DI-28750] - Add test case for edit channel landing * upcoming: [DI-28750] - Remove unnecessary invalidation * upcoming: [DI-28750] - Fix failing tests * upcoming: [DI-28750] - Update pr for new channel type with both details and content * upcoming: [DI-28750] - Keep the action items order same as alerts * upcoming: [DI-28750] - Keep error first and import svg * upcoming: [DI-28750] - Add changesets * upcoming: [DI-28750] - Fix missing type * upcoming: [DI-28750] - Address suggestions --------- Co-authored-by: venkatmano-akamai --- ...r-13235-upcoming-features-1767195633246.md | 5 + packages/api-v4/src/cloudpulse/alerts.ts | 22 ++ packages/api-v4/src/cloudpulse/types.ts | 31 ++- ...r-13235-upcoming-features-1767197686198.md | 5 + .../CreateNotificationChannel.test.tsx | 20 ++ .../NotificationChannelTypeSelect.tsx | 15 +- .../CreateChannel/schemas.ts | 17 +- .../EditChannel/EditChannelLanding.test.tsx | 111 +++++++++ .../EditChannel/EditChannelLanding.tsx | 89 +++++++ .../EditNotificationChannel.test.tsx | 225 ++++++++++++++++++ .../EditChannel/EditNotificationChannel.tsx | 167 +++++++++++++ ...AlertsNotificationChannelsEditLazyRoute.ts | 9 + .../EditChannel/utilities.ts | 17 ++ .../NotificationChannelActionMenu.tsx | 4 + .../NotificationChannelListTable.tsx | 8 + .../NotificationChannelTableRow.test.tsx | 6 +- .../NotificationChannels/Utils/utils.ts | 5 + .../features/CloudPulse/Alerts/constants.ts | 6 + packages/manager/src/mocks/serverHandlers.ts | 21 ++ .../manager/src/queries/cloudpulse/alerts.ts | 55 +++++ .../manager/src/queries/cloudpulse/queries.ts | 5 + packages/manager/src/routes/alerts/index.ts | 15 ++ packages/validation/src/cloudpulse.schema.ts | 40 +++- 23 files changed, 886 insertions(+), 12 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13235-upcoming-features-1767195633246.md create mode 100644 packages/manager/.changeset/pr-13235-upcoming-features-1767197686198.md create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute.ts create mode 100644 packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/utilities.ts diff --git a/packages/api-v4/.changeset/pr-13235-upcoming-features-1767195633246.md b/packages/api-v4/.changeset/pr-13235-upcoming-features-1767195633246.md new file mode 100644 index 00000000000..19a3c481aec --- /dev/null +++ b/packages/api-v4/.changeset/pr-13235-upcoming-features-1767195633246.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse-Alerts: Add type for edition of notification channel payload ([#13235](https://github.com/linode/manager/pull/13235)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index eb36d3fb5e4..33e59842ada 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -2,6 +2,7 @@ import { createAlertDefinitionSchema, createNotificationChannelPayloadSchema, editAlertDefinitionSchema, + editNotificationChannelPayloadSchema, } from '@linode/validation'; import { BETA_API_ROOT as API_ROOT } from '../constants'; @@ -20,6 +21,7 @@ import type { CreateAlertDefinitionPayload, CreateNotificationChannelPayload, EditAlertDefinitionPayload, + EditNotificationChannelPayload, NotificationChannel, } from './types'; @@ -150,3 +152,23 @@ export const createNotificationChannel = ( setMethod('POST'), setData(data, createNotificationChannelPayloadSchema), ); + +export const getNotificationChannelById = (channelId: number) => + Request( + setURL( + `${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`, + ), + setMethod('GET'), + ); + +export const updateNotificationChannel = ( + channelId: number, + data: EditNotificationChannelPayload, +) => + Request( + setURL( + `${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`, + ), + setMethod('PUT'), + setData(data, editNotificationChannelPayloadSchema), + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index c7bfddc7c8d..f9895f6cd46 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -451,6 +451,12 @@ export interface CloudPulseAlertsPayload { user_alerts?: number[]; } +interface EmailDetail { + email: { + usernames: string[]; + }; +} + export interface CreateNotificationChannelPayload { /** * The type of channel to create. @@ -459,13 +465,28 @@ export interface CreateNotificationChannelPayload { /** * The details of the channel to create. */ - details: { - email: { - usernames: string[]; - }; - }; + details: EmailDetail; /** * The label of the channel to create. */ label: string; } + +export interface EditNotificationChannelPayload { + /** + * The details of the channel to edit. + */ + details: EmailDetail; + /** + * The label of the channel to edit. + */ + label: string; +} + +export interface EditNotificationChannelPayloadWithId + extends EditNotificationChannelPayload { + /** + * The ID of the channel to edit. + */ + channelId: number; +} diff --git a/packages/manager/.changeset/pr-13235-upcoming-features-1767197686198.md b/packages/manager/.changeset/pr-13235-upcoming-features-1767197686198.md new file mode 100644 index 00000000000..87f1dcceb84 --- /dev/null +++ b/packages/manager/.changeset/pr-13235-upcoming-features-1767197686198.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Alerts: Add edit feature for notification channels ([#13235](https://github.com/linode/manager/pull/13235)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx index 2ad9a847ae6..a729e37fbd9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/CreateNotificationChannel.test.tsx @@ -155,6 +155,26 @@ describe('CreateNotificationChannel', () => { await screen.findByText(REQUIRED_FIELD_ERROR); }); + it('should display validation error for name field with special characters', async () => { + const user = userEvent.setup(); + renderWithTheme(); + + // Select a channel type + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + await user.click( + within(channelTypeSelect).getByRole('button', { name: OPEN_BUTTON_LABEL }) + ); + await user.click(screen.getByRole('option', { name: EMAIL_OPTION_LABEL })); + + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.type(nameInput, '*#&+:<>"?@%'); + await user.tab(); + + await screen.findByText( + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.' + ); + }); + it('should display validation error for recipients field with no value', async () => { const user = userEvent.setup(); renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx index 52de2604463..1009a83320e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/NotificationChannelTypeSelect.tsx @@ -5,6 +5,10 @@ import type { Item } from '../../constants'; import type { ChannelType } from '@linode/api-v4'; export interface NotificationChannelTypeSelectProps { + /** + * Whether the channel type select is disabled + */ + disabled?: boolean; /** * Error text to display when the field has a validation error */ @@ -12,7 +16,7 @@ export interface NotificationChannelTypeSelectProps { /** * Function to handle the change of the channel type */ - handleChannelTypeChange: (value: ChannelType | null) => void; + handleChannelTypeChange?: (value: ChannelType | null) => void; /** * Function to handle the blur event */ @@ -29,20 +33,23 @@ export interface NotificationChannelTypeSelectProps { export const NotificationChannelTypeSelect = React.memo( (props: NotificationChannelTypeSelectProps) => { - const { error, handleChannelTypeChange, value, options, onBlur } = props; + const { disabled, error, handleChannelTypeChange, value, options, onBlur } = + props; return ( { if (selected) { - handleChannelTypeChange(selected.value); + handleChannelTypeChange?.(selected.value); } if (reason === 'clear') { - handleChannelTypeChange(null); + handleChannelTypeChange?.(null); } }} options={options} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts index 8a2cec032b6..b6e0e951445 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/CreateChannel/schemas.ts @@ -4,8 +4,23 @@ import type { ChannelType } from '@linode/api-v4'; const fieldErrorMessage = 'This field is required.'; +const specialStartEndRegex = /^[^a-zA-Z0-9]/; + export const createNotificationChannelSchema = object({ - name: string().required(fieldErrorMessage), + name: string() + .required(fieldErrorMessage) + .matches( + /^[^*#&+:<>"?@%{}\\/]+$/, + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.' + ) + .max(100, 'Name must be 100 characters or less.') + .test( + 'no-special-start-end', + 'Name cannot start or end with a special character.', + (value) => { + return !specialStartEndRegex.test(value ?? ''); + } + ), type: mixed() .required(fieldErrorMessage) .nullable() diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.test.tsx new file mode 100644 index 00000000000..09a926e665e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.test.tsx @@ -0,0 +1,111 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditChannelLanding } from './EditChannelLanding'; + +const NOTIFICATION_CHANNELS_TEXT = 'Notification Channels'; +const EDIT_CHANNEL_ROUTE = '/alerts/notification-channels/edit/1'; + +const channelData = notificationChannelFactory.build({ + id: 1, + label: 'Test Channel', +}); + +const queryMocks = vi.hoisted(() => ({ + useNotificationChannelQuery: vi.fn(), + useParams: vi.fn(), + useUpdateNotificationChannel: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useNotificationChannelQuery: queryMocks.useNotificationChannelQuery, + useUpdateNotificationChannel: queryMocks.useUpdateNotificationChannel, +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +describe('EditChannelLanding component tests', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ + channelId: 1, + }); + queryMocks.useUpdateNotificationChannel.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + reset: vi.fn(), + }); + }); + + it('should render loading state when data is loading', () => { + queryMocks.useNotificationChannelQuery.mockReturnValue({ + data: channelData, + isError: false, + isLoading: true, + }); + + renderWithTheme(, { + initialRoute: EDIT_CHANNEL_ROUTE, + }); + + expect(screen.getByText(NOTIFICATION_CHANNELS_TEXT)).toBeVisible(); + expect(screen.getByTestId('circle-progress')).toBeVisible(); + }); + + it('should render error state when there is an error loading the channel', () => { + queryMocks.useNotificationChannelQuery.mockReturnValue({ + data: channelData, + isError: true, + isLoading: false, + }); + + renderWithTheme(, { + initialRoute: EDIT_CHANNEL_ROUTE, + }); + + expect(screen.getByText(NOTIFICATION_CHANNELS_TEXT)).toBeVisible(); + expect( + screen.getByText( + 'An error occurred while loading the notification channel. Please try again later.' + ) + ).toBeVisible(); + }); + + it('should render empty state when channel data is not available', () => { + queryMocks.useNotificationChannelQuery.mockReturnValue({ + data: null, + isError: false, + isLoading: false, + }); + + renderWithTheme(, { + initialRoute: EDIT_CHANNEL_ROUTE, + }); + + expect(screen.getByText(NOTIFICATION_CHANNELS_TEXT)).toBeVisible(); + expect(screen.getByText('No Data to display.')).toBeVisible(); + }); + + it('should render EditNotificationChannel when channel data is successfully loaded', () => { + queryMocks.useNotificationChannelQuery.mockReturnValue({ + data: channelData, + isError: false, + isLoading: false, + }); + + renderWithTheme(, { + initialRoute: EDIT_CHANNEL_ROUTE, + }); + + expect(screen.getByText(NOTIFICATION_CHANNELS_TEXT)).toBeVisible(); + expect(screen.getByText('Channel Settings')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.tsx new file mode 100644 index 00000000000..9702f3d16cf --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditChannelLanding.tsx @@ -0,0 +1,89 @@ +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import React from 'react'; + +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useNotificationChannelQuery } from 'src/queries/cloudpulse/alerts'; + +import { StyledPlaceholder } from '../../AlertsDetail/AlertDetail'; +import { EditNotificationChannel } from './EditNotificationChannel'; + +import type { NotificationChannel } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +const overrides: CrumbOverridesProps[] = [ + { + label: 'Notification Channels', + linkTo: '/alerts/notification-channels', + position: 1, + }, +]; + +const getLoadingOrErrorState = ( + channelData: NotificationChannel | undefined, + isLoading: boolean, + isError: boolean +): null | React.JSX.Element => { + if (isLoading) { + return ; + } + if (isError) { + return ( + + ); + } + if (!channelData) { + return ; + } + return null; +}; + +export const EditChannelLanding = () => { + const { channelId } = useParams({ + from: '/alerts/notification-channels/edit/$channelId', + }); + const { + data: channelData, + isError, + isLoading, + } = useNotificationChannelQuery(channelId); + const pathname = '/Notification Channels/Edit'; + + if (!channelData || isLoading || isError) { + return ( + + {getLoadingOrErrorState(channelData, isLoading, isError)} + + ); + } + + return ( + + ); +}; + +/** + * A component that renders a common UI structure for loading, error, or empty states. + * @param pathname - The current pathname to be provided in breadcrumb + * @param crumbOverrides - The overrides to be provided in breadcrumb + * @param children - The message component (e.g., CircleProgress, ErrorState, or Placeholder) + */ +const EditChannelContainer = ({ + children, + overrides, + pathname, +}: { + children: React.ReactNode; + overrides: CrumbOverridesProps[]; + pathname: string; +}) => { + return ( + <> + + + {children} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx new file mode 100644 index 00000000000..b0982ed4e21 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.test.tsx @@ -0,0 +1,225 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { UPDATE_CHANNEL_SUCCESS_MESSAGE } from '../../constants'; +import { EditNotificationChannel } from './EditNotificationChannel'; + +const navigate = vi.fn(); +const queryMocks = vi.hoisted(() => ({ + mutateAsync: vi.fn(), + useNavigate: vi.fn(() => navigate), + useUpdateNotificationChannel: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useUpdateNotificationChannel: queryMocks.useUpdateNotificationChannel, +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAccountUsersInfiniteQuery: vi.fn(() => ({ + data: { + pages: [ + { data: [{ username: 'testuser1' }, { username: 'testuser2' }] }, + ], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetching: false, + isLoading: false, + })), + }; +}); + +beforeEach(() => { + queryMocks.mutateAsync.mockResolvedValue({}); + queryMocks.useUpdateNotificationChannel.mockReturnValue({ + mutateAsync: queryMocks.mutateAsync, + reset: vi.fn(), + }); +}); + +const CHANNEL_TYPE_SELECT_TESTID = 'channel-type-select'; +const NAME_LABEL = 'Name'; +const UPDATED_CHANNEL_NAME = 'Updated Channel Name'; +const LABEL = 'Test Email Channel'; + +const channelData = notificationChannelFactory.build({ + channel_type: 'email', + details: { + email: { + usernames: ['testuser1', 'testuser2'], + }, + }, + id: 1, + label: LABEL, +}); + +describe('EditNotificationChannel component', () => { + it('should render the breadcrumb, form components, and initial values', async () => { + renderWithTheme( + + ); + + // Breadcrumb and title + expect(screen.getByText('Notification Channels')).toBeVisible(); + expect(screen.getByText('Channel Settings')).toBeVisible(); + + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + + // Verify channel type is populated and disabled + const channelTypeSelect = screen.getByTestId(CHANNEL_TYPE_SELECT_TESTID); + const combobox = within(channelTypeSelect).getByRole('combobox'); + expect(combobox).toHaveAttribute('value', 'Email'); + expect(combobox).toBeDisabled(); + + // Verify recipients field is visible + expect(screen.getByLabelText('Recipients')).toBeVisible(); + }); + + it('should be able to update the name field', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + await user.clear(nameInput); + await user.type(nameInput, UPDATED_CHANNEL_NAME); + + const textfieldInput = within( + screen.getByTestId('channel-name') + ).getByTestId('textfield-input'); + expect(textfieldInput).toHaveAttribute('value', UPDATED_CHANNEL_NAME); + }); + + it('should display validation error for name field with special characters', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + const nameInput = screen.getByLabelText(NAME_LABEL); + await user.type(nameInput, '*#&+:<>"?@%'); + await user.tab(); + + await screen.findByText( + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.' + ); + }); + + it('should submit form data correctly and show success message', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + // Update the name + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + + await user.clear(nameInput); + await user.type(nameInput, UPDATED_CHANNEL_NAME); + // Submit the form + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(queryMocks.mutateAsync).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith({ + to: '/alerts/notification-channels', + }); + }); + + expect(screen.getByText(UPDATE_CHANNEL_SUCCESS_MESSAGE)).toBeVisible(); + }); + + it('should display validation errors for empty fields', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + // Clear the name field and blur to trigger validation + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + + await user.clear(nameInput); + await user.tab(); + + expect(screen.getByText('This field is required.')).toBeVisible(); + }); + + it('should navigate back when Cancel button is clicked', async () => { + const user = userEvent.setup(); + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(navigate).toHaveBeenCalledWith({ + to: '/alerts/notification-channels', + }); + }); + + it('should show error messages when update fails', async () => { + queryMocks.mutateAsync.mockRejectedValue([ + { reason: 'Failed to update channel' }, + ]); + + const user = userEvent.setup(); + renderWithTheme( + + ); + + // Update the name + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + + await user.clear(nameInput); + await user.type(nameInput, UPDATED_CHANNEL_NAME); + // Submit the form + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Failed to update channel')).toBeVisible(); + }); + + it('should show field-specific error when API returns field error', async () => { + queryMocks.mutateAsync.mockRejectedValue([ + { field: 'name', reason: 'Name already exists' }, + ]); + + const user = userEvent.setup(); + renderWithTheme( + + ); + + // Update the name + const nameInput = screen.getByLabelText(NAME_LABEL); + expect(nameInput).toHaveValue(LABEL); + + await user.clear(nameInput); + await user.type(nameInput, UPDATED_CHANNEL_NAME); + // Submit the form + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Name already exists')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx new file mode 100644 index 00000000000..175082968d9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/EditNotificationChannel.tsx @@ -0,0 +1,167 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { ActionsPanel, Paper, TextField, Typography } from '@linode/ui'; +import { useNavigate } from '@tanstack/react-router'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; + +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useUpdateNotificationChannel } from 'src/queries/cloudpulse/alerts'; + +import { + channelTypeOptions, + UPDATE_CHANNEL_FAILED_MESSAGE, + UPDATE_CHANNEL_SUCCESS_MESSAGE, +} from '../../constants'; +import { NotificationChannelTypeSelect } from '../CreateChannel/NotificationChannelTypeSelect'; +import { NotificationRecipients } from '../CreateChannel/NotificationRecipients'; +import { createNotificationChannelSchema } from '../CreateChannel/schemas'; +import { filterEditChannelFormValues } from './utilities'; + +import type { CreateNotificationChannelForm } from '../CreateChannel/types'; +import type { NotificationChannel } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +const CHANNEL_LANDING = '/alerts/notification-channels'; +const pathname = '/Notification Channels/Edit'; + +const overrides: CrumbOverridesProps[] = [ + { + label: 'Notification Channels', + linkTo: CHANNEL_LANDING, + position: 1, + }, +]; + +export interface EditNotificationChannelProps { + /** + * The details of the notification channel being edited. + */ + channelData: NotificationChannel; + /** + * The channel ID being edited. + */ + channelId: number; +} + +export const EditNotificationChannel = ( + props: EditNotificationChannelProps +) => { + const { channelData, channelId } = props; + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + + const { mutateAsync: updateChannel } = useUpdateNotificationChannel(); + + const formMethods = useForm({ + defaultValues: { + name: channelData.label, + type: channelData.channel_type, + recipients: + channelData.channel_type === 'email' + ? (channelData.details?.email.usernames ?? []) + : [], + }, + mode: 'onBlur', + resolver: yupResolver(createNotificationChannelSchema), + }); + + const { control, handleSubmit, formState } = formMethods; + + const onSubmit = handleSubmit(async (values) => { + try { + await updateChannel(filterEditChannelFormValues(channelId, values)); + enqueueSnackbar(UPDATE_CHANNEL_SUCCESS_MESSAGE, { + variant: 'success', + }); + navigate({ to: CHANNEL_LANDING }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + formMethods.setError(error.field, { + message: error.reason ?? UPDATE_CHANNEL_FAILED_MESSAGE, + }); + } else { + enqueueSnackbar(error.reason ?? UPDATE_CHANNEL_FAILED_MESSAGE, { + variant: 'error', + }); + } + } + } + }); + + const handleCancel = () => { + navigate({ to: CHANNEL_LANDING }); + }; + + return ( + + + +
+ + Channel Settings + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute.ts new file mode 100644 index 00000000000..3d09b81a8ef --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { EditChannelLanding } from './EditChannelLanding'; + +export const cloudPulseAlertsNotificationChannelEditLazyRoute = createLazyRoute( + '/alerts/notification-channels/edit/$channelId' +)({ + component: EditChannelLanding, +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/utilities.ts new file mode 100644 index 00000000000..5438348cf97 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/utilities.ts @@ -0,0 +1,17 @@ +import type { CreateNotificationChannelForm } from '../CreateChannel/types'; +import type { EditNotificationChannelPayloadWithId } from '@linode/api-v4'; + +export const filterEditChannelFormValues = ( + channelId: number, + formValues: CreateNotificationChannelForm +): EditNotificationChannelPayloadWithId => { + return { + channelId, + label: formValues.name, + details: { + email: { + usernames: formValues.recipients, + }, + }, + }; +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx index 8935de1616a..17d502b814a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelActionMenu.tsx @@ -11,6 +11,10 @@ export interface NotificationChannelActionHandlers { * Callback for show details action */ handleDetails: () => void; + /** + * Callback for edit action + */ + handleEdit: () => void; } export interface NotificationChannelActionMenuProps { diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx index caebac62069..e44b2027e08 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -53,6 +53,13 @@ export const NotificationChannelListTable = React.memo( params: { channelId: String(id) }, }); }; + + const handleEdit = ({ id }: NotificationChannel) => { + navigate({ + to: '/alerts/notification-channels/edit/$channelId', + params: { channelId: id }, + }); + }; const _error = error ? getAPIErrorOrDefault( error, @@ -172,6 +179,7 @@ export const NotificationChannelListTable = React.memo( handleDetails(channel), + handleEdit: () => handleEdit(channel), }} key={channel.id} notificationChannel={channel} diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx index 655049953d8..a014bad71bb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -9,7 +9,11 @@ import { NotificationChannelTableRow } from './NotificationChannelTableRow'; describe('NotificationChannelTableRow', () => { const mockHandleDetails = vi.fn(); - const handlers = { handleDetails: mockHandleDetails }; + const mockHandleEdit = vi.fn(); + const handlers = { + handleDetails: mockHandleDetails, + handleEdit: mockHandleEdit, + }; it('should render a notification channel row with all fields', () => { const updated = new Date().toISOString(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts index dc563f68137..84e8357f4e9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/Utils/utils.ts @@ -4,6 +4,7 @@ import type { Action } from 'src/components/ActionMenu/ActionMenu'; export const getNotificationChannelActionsList = ({ handleDetails, + handleEdit, }: NotificationChannelActionHandlers): Record< AlertNotificationType, Action[] @@ -19,5 +20,9 @@ export const getNotificationChannelActionsList = ({ onClick: handleDetails, title: 'Show Details', }, + { + onClick: handleEdit, + title: 'Edit', + }, ], }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 6140b807a45..e5a2525a576 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -307,3 +307,9 @@ export const CREATE_CHANNEL_SUCCESS_MESSAGE = export const CREATE_CHANNEL_FAILED_MESSAGE = 'Failed to create the notification channel. Verify the configuration details and try again.'; + +export const UPDATE_CHANNEL_SUCCESS_MESSAGE = + 'Notification channel updated successfully. All changes have been saved.'; + +export const UPDATE_CHANNEL_FAILED_MESSAGE = + 'Failed to update the notification channel. Verify the details and try again.'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 809905f438f..54bce1d7e1b 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -4478,4 +4478,25 @@ export const handlers = [ http.post('*/v4beta/monitor/alert-channels', () => { return HttpResponse.json(notificationChannelFactory.build()); }), + http.put('*/monitor/alert-channels/:id', () => { + return HttpResponse.json(notificationChannelFactory.build()); + }), + http.get('*/monitor/alert-channels/:id', () => { + return HttpResponse.json( + notificationChannelFactory.build({ + id: 5, + label: 'Email test channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user3', + created_by: 'admin', + type: 'user', + channel_type: 'email', + details: { + email: { + usernames: ['ChildUser', 'NonAdminUser'], + }, + }, + }) + ); + }), ]; diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index b13f0e2ef04..71e5d75ba30 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -5,6 +5,7 @@ import { deleteAlertDefinition, deleteEntityFromAlert, editAlertDefinition, + updateNotificationChannel, updateServiceAlerts, } from '@linode/api-v4/lib/cloudpulse'; import { queryPresets } from '@linode/queries'; @@ -25,6 +26,7 @@ import type { CreateNotificationChannelPayload, DeleteAlertPayload, EditAlertPayloadWithService, + EditNotificationChannelPayloadWithId, EntityAlertUpdatePayload, NotificationChannel, } from '@linode/api-v4/lib/cloudpulse'; @@ -285,3 +287,56 @@ export const useCreateNotificationChannel = () => { }, }); }; + +export const useUpdateNotificationChannel = () => { + const queryClient = useQueryClient(); + return useMutation< + NotificationChannel, + APIError[], + EditNotificationChannelPayloadWithId + >({ + mutationFn: async (payload: EditNotificationChannelPayloadWithId) => { + const { channelId, details, label } = payload; + return updateNotificationChannel(channelId, { + details, + label, + }); + }, + onSuccess: (updatedChannel) => { + const allChannelsKey = + queryFactory.notificationChannels._ctx.all().queryKey; + + queryClient.setQueryData( + allChannelsKey, + (prev) => { + // nothing cached yet + if (!prev) return prev; + + const idx = prev.findIndex( + (channel) => channel.id === updatedChannel.id + ); + if (idx === -1) return prev; + + // if no change keep referential equality + if (prev[idx] === updatedChannel) return prev; + + const next = prev.slice(); + next[idx] = updatedChannel; + return next; + } + ); + + queryClient.setQueryData( + queryFactory.notificationChannels._ctx.channelById(updatedChannel.id) + .queryKey, + updatedChannel + ); + }, + }); +}; + +export const useNotificationChannelQuery = (channelId: number) => { + return useQuery( + queryFactory.notificationChannels._ctx.channelById(channelId) + ); +}; diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts index 024e680db08..2f993cffebe 100644 --- a/packages/manager/src/queries/cloudpulse/queries.ts +++ b/packages/manager/src/queries/cloudpulse/queries.ts @@ -6,6 +6,7 @@ import { getDashboards, getJWEToken, getMetricDefinitionsByServiceType, + getNotificationChannelById, } from '@linode/api-v4'; import { databaseQueries, @@ -109,6 +110,10 @@ export const queryFactory = createQueryKeys(key, { queryFn: () => getAllNotificationChannels(params, filter), queryKey: [params, filter], }), + channelById: (channelId: number) => ({ + queryFn: () => getNotificationChannelById(channelId), + queryKey: [channelId], + }), }, queryKey: null, }, diff --git a/packages/manager/src/routes/alerts/index.ts b/packages/manager/src/routes/alerts/index.ts index 26aafab6213..12ebb19a48d 100644 --- a/packages/manager/src/routes/alerts/index.ts +++ b/packages/manager/src/routes/alerts/index.ts @@ -95,6 +95,20 @@ export const cloudPulseNotificationChannelsCreateRoute = createRoute({ ).then((m) => m.cloudPulseCreateNotificationChannelLazyRoute) ); +const cloudPulseNotificationChannelEditRoute = createRoute({ + getParentRoute: () => cloudPulseAlertsRoute, + path: 'notification-channels/edit/$channelId', + params: { + parse: (rawParams) => ({ + channelId: Number(rawParams.channelId), + }), + }, +}).lazy(() => + import( + 'src/features/CloudPulse/Alerts/NotificationChannels/EditChannel/cloudPulseAlertsNotificationChannelsEditLazyRoute' + ).then((m) => m.cloudPulseAlertsNotificationChannelEditLazyRoute) +); + export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseAlertsIndexRoute, cloudPulseAlertsDefinitionsRoute.addChildren([ @@ -106,5 +120,6 @@ export const cloudPulseAlertsRouteTree = cloudPulseAlertsRoute.addChildren([ cloudPulseNotificationChannelsRoute.addChildren([ cloudPulseNotificationChannelDetailRoute, cloudPulseNotificationChannelsCreateRoute, + cloudPulseNotificationChannelEditRoute, ]), ]); diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index 69b2dbe268e..89bca0b6d99 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -135,7 +135,20 @@ export const editAlertDefinitionSchema = object({ }); export const createNotificationChannelPayloadSchema = object({ - label: string().required(fieldErrorMessage), + label: string() + .required(fieldErrorMessage) + .matches( + /^[^*#&+:<>"?@%{}\\/]+$/, + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.', + ) + .max(100, 'Name must be 100 characters or less.') + .test( + 'no-special-start-end', + 'Name cannot start or end with a special character.', + (value) => { + return !specialStartEndRegex.test(value ?? ''); + }, + ), channel_type: string() .oneOf(['email', 'webhook', 'pagerduty', 'slack']) .required(fieldErrorMessage), @@ -148,3 +161,28 @@ export const createNotificationChannelPayloadSchema = object({ }).required(), }).required(), }); + +export const editNotificationChannelPayloadSchema = object({ + label: string() + .required(fieldErrorMessage) + .matches( + /^[^*#&+:<>"?@%{}\\/]+$/, + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.', + ) + .max(100, 'Name must be 100 characters or less.') + .test( + 'no-special-start-end', + 'Name cannot start or end with a special character.', + (value) => { + return !specialStartEndRegex.test(value ?? ''); + }, + ), + details: object({ + email: object({ + usernames: array() + .of(string()) + .min(1, fieldErrorMessage) + .required(fieldErrorMessage), + }).required(), + }).required(), +}); From 426427ca1a60865df98183d581f71187683c63b3 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 6 Jan 2026 16:33:44 +0530 Subject: [PATCH 49/60] feat: [UIE-9805] - Extract EUUID from /profile header and send to Adobe analytics (#13229) * feat: [UIE-9805] - Extract EUUID from /profile header and send to Adobe analytics * Added mock data for validating EUUID extraction in local. * Added changeset: Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event * Address review comments * Address review comments by Alban. * Use Extended Profile type for euuid alongwith a custom hook for type assertion. --- .../pr-13229-added-1767110711119.md | 5 ++ .../manager/src/hooks/useAdobeAnalytics.ts | 9 ++- .../src/hooks/useEuuidFromHttpHeader.test.ts | 55 +++++++++++++++++++ .../src/hooks/useEuuidFromHttpHeader.ts | 16 ++++++ packages/manager/src/mocks/serverHandlers.ts | 6 +- packages/manager/src/request.test.tsx | 38 ++++++++++++- packages/manager/src/request.tsx | 39 +++++++++++++ .../manager/src/utilities/analytics/types.ts | 1 + 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13229-added-1767110711119.md create mode 100644 packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts create mode 100644 packages/manager/src/hooks/useEuuidFromHttpHeader.ts diff --git a/packages/manager/.changeset/pr-13229-added-1767110711119.md b/packages/manager/.changeset/pr-13229-added-1767110711119.md new file mode 100644 index 00000000000..1a250a7954f --- /dev/null +++ b/packages/manager/.changeset/pr-13229-added-1767110711119.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event ([#13229](https://github.com/linode/manager/pull/13229)) diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 30cbe4fc320..2ed49184f36 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -5,11 +5,15 @@ import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + /** * Initializes our Adobe Analytics script on mount and subscribes to page view events. + * The EUUID is read from the profile data (injected by the injectEuuidToProfile interceptor). */ export const useAdobeAnalytics = () => { const location = useLocation(); + const { euuid } = useEuuidFromHttpHeader(); React.useEffect(() => { // Load Adobe Analytics Launch Script @@ -26,6 +30,7 @@ export const useAdobeAnalytics = () => { // Fire the first page view for the landing page window._satellite.track('page view', { url: window.location.pathname, + ...(euuid && { euuid }), }); }) .catch(() => { @@ -36,11 +41,13 @@ export const useAdobeAnalytics = () => { React.useEffect(() => { /** - * Send pageviews when location changes + * Send pageviews when location changes. + * Includes EUUID (Enterprise UUID) if available from the profile response. */ if (window._satellite) { window._satellite.track('page view', { url: location.pathname, + ...(euuid && { euuid }), }); } }, [location.pathname]); // Listen to location changes diff --git a/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts new file mode 100644 index 00000000000..eff361103e4 --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + +describe('useEuuidFromHttpHeader', () => { + it('returns EUUID when the header is included', async () => { + const mockEuuid = 'test-euuid-12345'; + + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: { 'X-Customer-Uuid': mockEuuid }, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBe(mockEuuid); + }); + }); + + it('returns undefined when the header is not included', async () => { + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: {}, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBeUndefined(); + }); + }); + + it('returns undefined when profile is loading', () => { + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + // Before the profile loads, euuid should be undefined + expect(result.current.euuid).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/hooks/useEuuidFromHttpHeader.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts new file mode 100644 index 00000000000..5d066dc31dd --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts @@ -0,0 +1,16 @@ +import { useProfile } from '@linode/queries'; + +import type { UseQueryResult } from '@tanstack/react-query'; +import type { ProfileWithEuuid } from 'src/request'; + +/** + * Hook to get the customer EUUID (Enterprise UUID) from the profile data. + * The EUUID is injected by the injectEuuidToProfile interceptor from the + * X-Customer-Uuid header. + * + * NOTE: this won't work locally (only staging and prod return this header) + */ +export const useEuuidFromHttpHeader = () => ({ + euuid: (useProfile() as UseQueryResult).data + ?._euuidFromHttpHeader, +}); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 54bce1d7e1b..6710414e142 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -716,7 +716,11 @@ export const handlers = [ // restricted: true, // user_type: 'default', }); - return HttpResponse.json(profile); + return HttpResponse.json(profile, { + headers: { + 'X-Customer-UUID': '51C68049-266E-451B-80ABFC92B5B9D576', + }, + }); }), http.put('*/profile', async ({ request }) => { diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index c92d8a0df8c..c3f847cbd60 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -2,7 +2,12 @@ import { profileFactory } from '@linode/utilities'; import { AxiosHeaders } from 'axios'; import { setAuthDataInLocalStorage } from './OAuth/oauth'; -import { getURL, handleError, injectAkamaiAccountHeader } from './request'; +import { + getURL, + handleError, + injectAkamaiAccountHeader, + injectEuuidToProfile, +} from './request'; import { storeFactory } from './store'; import { storage } from './utilities/storage'; @@ -106,3 +111,34 @@ describe('injectAkamaiAccountHeader', () => { ); }); }); + +describe('injectEuuidToProfile', () => { + const profile = profileFactory.build(); + const response: AxiosResponse = { + data: profile, + status: 200, + statusText: 'OK', + config: { headers: new AxiosHeaders(), url: '/profile', method: 'get' }, + headers: { 'x-customer-uuid': '1234' }, + }; + + it('injects the euuid on successful GET profile response ', () => { + const results = injectEuuidToProfile(response); + expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + const { _euuidFromHttpHeader, ...originalData } = results.data; + expect(originalData).toEqual(profile); + }); + + it('returns the original profile data if no header is present', () => { + const responseWithNoHeaders: AxiosResponse = { ...response, headers: {} }; + expect(injectEuuidToProfile(responseWithNoHeaders).data).toEqual(profile); + }); + + it("doesn't inject the euuid on other endpoints", () => { + const accountResponse: AxiosResponse = { + ...response, + config: { ...response.config, url: '/account' }, + }; + expect(injectEuuidToProfile(accountResponse).data).toEqual(profile); + }); +}); diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 54e65a3280d..247c3fc4f64 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -102,6 +102,14 @@ export type ProfileWithAkamaiAccountHeader = Profile & { _akamaiAccount: boolean; }; +// A user's external UUID can be found on the response to /account. +// Since that endpoint is not available to restricted users, the API also +// returns it as an HTTP header ("X-Customer-Uuid"). This header is injected +// in the response to `/profile` so that it's available in Redux. +export type ProfileWithEuuid = Profile & { + _euuidFromHttpHeader?: string; +}; + export const injectAkamaiAccountHeader = ( response: AxiosResponse ): AxiosResponse => { @@ -133,6 +141,34 @@ export const isSuccessfulGETProfileResponse = ( ); }; +/** + * A user's external UUID can be found on the response to /account. + * Since that endpoint is not available to restricted users, the API also + * returns it as an HTTP header ("X-Customer-Uuid"). This middleware injects + * the value of the header to the GET /profile response so it can be added to + * the Redux store and used throughout the app. + */ +export const injectEuuidToProfile = ( + response: AxiosResponse +): AxiosResponse => { + if (isSuccessfulGETProfileResponse(response)) { + const xCustomerUuidHeader = response.headers['x-customer-uuid']; + // NOTE: this won't work locally (only staging and prod allow this header) + if (xCustomerUuidHeader) { + const profileWithEuuid: ProfileWithEuuid = { + ...response.data, + _euuidFromHttpHeader: xCustomerUuidHeader, + }; + + return { + ...response, + data: profileWithEuuid, + }; + } + } + return response; +}; + export const setupInterceptors = (store: ApplicationStore) => { baseRequest.interceptors.request.use(async (config) => { if ( @@ -176,4 +212,7 @@ export const setupInterceptors = (store: ApplicationStore) => { ); baseRequest.interceptors.response.use(injectAkamaiAccountHeader); + + // Inject the EUUID from the X-Customer-Uuid header into the profile response + baseRequest.interceptors.response.use(injectEuuidToProfile); }; diff --git a/packages/manager/src/utilities/analytics/types.ts b/packages/manager/src/utilities/analytics/types.ts index c4d635f122e..7aa13b56a47 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -15,6 +15,7 @@ type DTMSatellite = { }; interface PageViewPayload { + euuid?: string; url: string; } From 5d1d25071665b9b02a00a30eb634991c9d397cf3 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Tue, 6 Jan 2026 18:21:19 +0530 Subject: [PATCH 50/60] upcoming: [UIE-9813] - Implement routing for Cloud Manager Marketplace (#13222) * upcoming: [UIE-9813] - Implement routing for Cloud Manager Marketplace * fix failing test cases and other marketplace references * Added changeset: Implement routing for Cloud Manager Marketplace * fix variable name * renamed marketplace feature flag for semantic correctness * PR feedback and pendo addition * missed changes --- packages/api-v4/src/marketplace/index.ts | 1 + .../api-v4/src/marketplace/marketplace.ts | 15 +++++++-- ...r-13222-upcoming-features-1766495037845.md | 5 +++ packages/manager/src/GoTo.tsx | 9 ++++-- .../components/PrimaryNav/PrimaryNav.test.tsx | 20 ++++++++++-- .../src/components/PrimaryNav/PrimaryNav.tsx | 22 +++++++++++-- .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 3 +- .../Marketplace/MarketplaceLanding.tsx | 8 +++++ .../Marketplace/marketplaceLazyRoute.tsx | 9 ++++++ .../manager/src/features/Marketplace/utils.ts | 23 +++++++++++++ .../TopMenu/CreateMenu/CreateMenu.tsx | 9 ++++-- packages/manager/src/mocks/serverHandlers.ts | 15 ++++++--- packages/manager/src/routes/index.tsx | 2 ++ .../routes/marketplace/MarketplaceRoute.tsx | 23 +++++++++++++ .../manager/src/routes/marketplace/index.ts | 32 +++++++++++++++++++ 16 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-13222-upcoming-features-1766495037845.md create mode 100644 packages/manager/src/features/Marketplace/MarketplaceLanding.tsx create mode 100644 packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx create mode 100644 packages/manager/src/features/Marketplace/utils.ts create mode 100644 packages/manager/src/routes/marketplace/MarketplaceRoute.tsx create mode 100644 packages/manager/src/routes/marketplace/index.ts diff --git a/packages/api-v4/src/marketplace/index.ts b/packages/api-v4/src/marketplace/index.ts index fcb073fefcd..a16c03c32d8 100644 --- a/packages/api-v4/src/marketplace/index.ts +++ b/packages/api-v4/src/marketplace/index.ts @@ -1 +1,2 @@ +export * from './marketplace'; export * from './types'; diff --git a/packages/api-v4/src/marketplace/marketplace.ts b/packages/api-v4/src/marketplace/marketplace.ts index 72aaeed2c26..e253226a592 100644 --- a/packages/api-v4/src/marketplace/marketplace.ts +++ b/packages/api-v4/src/marketplace/marketplace.ts @@ -10,8 +10,11 @@ import Request, { } from 'src/request'; import type { + MarketplaceCategory, + MarketplacePartner, MarketplacePartnerReferralPayload, MarketplaceProduct, + MarketplaceType, } from './types'; import type { Filter, ResourcePage as Page, Params } from 'src/types'; @@ -32,7 +35,7 @@ export const getMarketplaceProduct = (productId: number) => ); export const getMarketplaceCategories = (params?: Params, filters?: Filter) => - Request>( + Request>( setURL(`${BETA_API_ROOT}/marketplace/categories`), setMethod('GET'), setParams(params), @@ -40,13 +43,21 @@ export const getMarketplaceCategories = (params?: Params, filters?: Filter) => ); export const getMarketplaceTypes = (params?: Params, filters?: Filter) => - Request>( + Request>( setURL(`${BETA_API_ROOT}/marketplace/types`), setMethod('GET'), setParams(params), setXFilter(filters), ); +export const getMarketplacePartners = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/marketplace/partners`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + export const createPartnerReferral = ( data: MarketplacePartnerReferralPayload, ) => diff --git a/packages/manager/.changeset/pr-13222-upcoming-features-1766495037845.md b/packages/manager/.changeset/pr-13222-upcoming-features-1766495037845.md new file mode 100644 index 00000000000..ac1b9b2170c --- /dev/null +++ b/packages/manager/.changeset/pr-13222-upcoming-features-1766495037845.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement routing for Cloud Manager Marketplace ([#13222](https://github.com/linode/manager/pull/13222)) diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index e64ef845be7..93a0bddf0b7 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { usePermissions } from './features/IAM/hooks/usePermissions'; +import { useIsMarketplaceV2Enabled } from './features/Marketplace/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useFlags } from './hooks/useFlags'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; @@ -24,6 +25,8 @@ export const GoTo = React.memo(() => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); const onClose = () => { @@ -99,9 +102,10 @@ export const GoTo = React.memo(() => { display: 'Longview', href: '/longview', }, - { - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', href: '/linodes/create/marketplace', }, ...(iamRbacPrimaryNavChanges @@ -133,6 +137,7 @@ export const GoTo = React.memo(() => { permissions.is_account_admin, isDatabasesEnabled, isManagedAccount, + isMarketplaceV2FeatureEnabled, isPlacementGroupsEnabled, iamRbacPrimaryNavChanges, ] diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index d5a863c094b..7c078ccc248 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -630,10 +630,26 @@ describe('PrimaryNav', () => { flags, }); - const databaseNavItem = await findByTestId( + const networkLoadbalancerNavItem = await findByTestId( 'menu-item-Network Load Balancer' ); - expect(databaseNavItem).toBeVisible(); + expect(networkLoadbalancerNavItem).toBeVisible(); + }); + + it('should show Partner Referral menu item if the user has the account capability and the flag is enabled', async () => { + const flags: Partial = { + marketplaceV2: true, + }; + + const { findByTestId } = renderWithTheme(, { + flags, + }); + + const partnerReferralNavItem = await findByTestId( + 'menu-item-Partner Referrals' + ); + + expect(partnerReferralNavItem).toBeVisible(); }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 2fe1f4bffdb..1a4d0499baa 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -22,6 +22,7 @@ import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -54,13 +55,15 @@ export type NavEntity = | 'Longview' | 'Maintenance' | 'Managed' - | 'Marketplace' + | 'Marketplace' // TODO: Cloud Manager Marketplace - Remove marketplace references once 'Quick Deploy Apps' is fully rolled out | 'Metrics' | 'Monitor' | 'Network Load Balancer' | 'NodeBalancers' | 'Object Storage' + | 'Partner Referrals' | 'Placement Groups' + | 'Quick Deploy Apps' | 'Quotas' | 'Service Transfers' | 'StackScripts' @@ -121,6 +124,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + const { data: preferences, error: preferencesError, @@ -176,9 +181,21 @@ export const PrimaryNav = (props: PrimaryNavProps) => { }, { attr: { 'data-qa-one-click-nav-btn': true }, - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', to: '/linodes/create/marketplace', }, + { + attr: { + 'data-qa-one-click-nav-btn': true, + 'data-pendo-id': 'menu-item-Cloud Marketplace', + }, + display: 'Partner Referrals', + hide: !isMarketplaceV2FeatureEnabled, + isBeta: isMarketplaceV2FeatureEnabled, + to: '/cloud-marketplace/catalog', + }, ], name: 'Compute', }, @@ -353,6 +370,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isIAMBeta, isIAMEnabled, iamRbacPrimaryNavChanges, + isMarketplaceV2FeatureEnabled, isNetworkLoadBalancerEnabled, limitsEvolution, ] diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index e017a81f19c..a3b79b4945d 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -40,6 +40,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, + { flag: 'marketplaceV2', label: 'MarketplaceV2' }, { flag: 'networkLoadBalancer', label: 'Network Load Balancer' }, { flag: 'nodebalancerIpv6', label: 'NodeBalancer Dual Stack (IPv6)' }, { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ac3b6c7d149..c0bfac6e2fa 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -236,6 +236,7 @@ export interface Flags { lkeEnterprise2: LkeEnterpriseFlag; mainContentBanner: MainContentBanner; marketplaceAppOverrides: MarketplaceAppOverride[]; + marketplaceV2: boolean; metadata: boolean; mtc: MTC; networkLoadBalancer: boolean; @@ -356,12 +357,12 @@ export type ProductInformationBannerLocation = | 'Identity and Access' | 'Images' | 'Kubernetes' - | 'LinodeCreate' // Use for Marketplace banners | 'Linodes' | 'LoadBalancers' | 'Logs' | 'Longview' | 'Managed' + | 'Marketplace' | 'Network LoadBalancers' | 'NodeBalancers' | 'Object Storage' diff --git a/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx b/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx new file mode 100644 index 00000000000..10c9c37b848 --- /dev/null +++ b/packages/manager/src/features/Marketplace/MarketplaceLanding.tsx @@ -0,0 +1,8 @@ +import { Notice } from '@linode/ui'; +import * as React from 'react'; + +export const MarketplaceLanding = () => { + return ( + Partner Referral Catalog is coming soon... + ); +}; diff --git a/packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx b/packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx new file mode 100644 index 00000000000..e6784df0a68 --- /dev/null +++ b/packages/manager/src/features/Marketplace/marketplaceLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { MarketplaceLanding } from './MarketplaceLanding'; + +export const marketplaceLazyRoute = createLazyRoute( + '/cloud-marketplace/catalog' +)({ + component: MarketplaceLanding, +}); diff --git a/packages/manager/src/features/Marketplace/utils.ts b/packages/manager/src/features/Marketplace/utils.ts new file mode 100644 index 00000000000..e942cb496c2 --- /dev/null +++ b/packages/manager/src/features/Marketplace/utils.ts @@ -0,0 +1,23 @@ +import { useFlags } from 'src/hooks/useFlags'; + +/** + * Returns whether or not features related to the Marketplace project + * should be enabled. + * + * Note: Currently, this just uses the `marketplaceV2` feature flag as a source of truth, + * but will eventually also look at account capabilities if available. + */ +export const useIsMarketplaceV2Enabled = () => { + const flags = useFlags(); + + if (!flags) { + return { + isMarketplaceV2FeatureEnabled: false, + }; + } + + // @TODO: Cloud Manager Marketplace - check for customer tag/account capability when it exists + return { + isMarketplaceV2FeatureEnabled: flags.marketplaceV2, + }; +}; diff --git a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx index 25935ebaee0..cba276d3914 100644 --- a/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx +++ b/packages/manager/src/features/TopMenu/CreateMenu/CreateMenu.tsx @@ -7,6 +7,7 @@ import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import StorageIcon from 'src/assets/icons/entityIcons/storage.svg'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { @@ -30,10 +31,11 @@ export type CreateEntity = | 'Image' | 'Kubernetes' | 'Linode' - | 'Marketplace' + | 'Marketplace' // TODO: Cloud Manager Marketplace - Remove marketplace references once 'Quick Deploy Apps' is fully rolled out | 'NodeBalancer' | 'Object Storage' | 'Placement Group' + | 'Quick Deploy Apps' | 'Volume' | 'VPC'; @@ -52,6 +54,7 @@ export const CreateMenu = () => { const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -90,7 +93,9 @@ export const CreateMenu = () => { { attr: { 'data-qa-one-click-add-new': true }, description: 'Deploy applications with ease', - display: 'Marketplace', + display: !isMarketplaceV2FeatureEnabled + ? 'Marketplace' + : 'Quick Deploy Apps', to: '/linodes/create/marketplace', }, ], diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6710414e142..d5a1defaee9 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -21,7 +21,10 @@ import { linodeStatsFactory, linodeTransferFactory, linodeTypeFactory, + marketplaceCategoryFactory, + marketplacePartnersFactory, marketplaceProductFactory, + marketplaceTypeFactory, nodeBalancerConfigFactory, nodeBalancerConfigNodeFactory, nodeBalancerFactory, @@ -625,7 +628,7 @@ const marketplace = [ return HttpResponse.json(makeResourcePage([...marketplaceProduct])); }), http.get('*/v4beta/marketplace/products/:productId', () => { - const marketplaceProductDetail = marketplaceProductFactory.buildList(10, { + const marketplaceProductDetail = marketplaceProductFactory.build({ details: { overview: { description: @@ -636,14 +639,18 @@ const marketplace = [ support: 'Support information goes here.', }, }); - return HttpResponse.json(...marketplaceProductDetail); + return HttpResponse.json(marketplaceProductDetail); }), http.get('*/v4beta/marketplace/categories', () => { - const marketplaceCategory = marketplaceProductFactory.buildList(5); + const marketplaceCategory = marketplaceCategoryFactory.buildList(5); return HttpResponse.json(makeResourcePage([...marketplaceCategory])); }), http.get('*/v4beta/marketplace/types', () => { - const marketplaceType = marketplaceProductFactory.buildList(5); + const marketplaceType = marketplaceTypeFactory.buildList(5); + return HttpResponse.json(makeResourcePage([...marketplaceType])); + }), + http.get('*/v4beta/marketplace/partners', () => { + const marketplaceType = marketplacePartnersFactory.buildList(5); return HttpResponse.json(makeResourcePage([...marketplaceType])); }), http.post('*/v4beta/marketplace/referral', async () => { diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 3c2193f36fa..e24aed3b464 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -29,6 +29,7 @@ import { loginHistoryRouteTree } from './loginHistory/'; import { longviewRouteTree } from './longview'; import { maintenanceRouteTree } from './maintenance'; import { managedRouteTree } from './managed'; +import { marketplaceRouteTree } from './marketplace'; import { cloudPulseMetricsRouteTree } from './metrics'; import { networkLoadBalancersRouteTree } from './networkLoadBalancer'; import { nodeBalancersRouteTree } from './nodeBalancers'; @@ -80,6 +81,7 @@ export const routeTree = rootRoute.addChildren([ longviewRouteTree, maintenanceRouteTree, managedRouteTree, + marketplaceRouteTree, networkLoadBalancersRouteTree, nodeBalancersRouteTree, objectStorageRouteTree, diff --git a/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx b/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx new file mode 100644 index 00000000000..f394a0e2be6 --- /dev/null +++ b/packages/manager/src/routes/marketplace/MarketplaceRoute.tsx @@ -0,0 +1,23 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsMarketplaceV2Enabled } from 'src/features/Marketplace/utils'; + +export const MarketplaceRoute = () => { + const { isMarketplaceV2FeatureEnabled } = useIsMarketplaceV2Enabled(); + + if (!isMarketplaceV2FeatureEnabled) { + return ; + } + return ( + }> + + + + + ); +}; diff --git a/packages/manager/src/routes/marketplace/index.ts b/packages/manager/src/routes/marketplace/index.ts new file mode 100644 index 00000000000..9ef9c903270 --- /dev/null +++ b/packages/manager/src/routes/marketplace/index.ts @@ -0,0 +1,32 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { MarketplaceRoute } from './MarketplaceRoute'; + +export const marketplaceRoute = createRoute({ + component: MarketplaceRoute, + getParentRoute: () => rootRoute, + path: 'cloud-marketplace', +}); + +export const marketplaceLandingRoute = createRoute({ + beforeLoad: async () => { + throw redirect({ to: '/cloud-marketplace/catalog' }); + }, + getParentRoute: () => marketplaceRoute, + path: '/', +}); + +export const marketplaceCatlogRoute = createRoute({ + getParentRoute: () => marketplaceRoute, + path: '/catalog', +}).lazy(() => + import('src/features/Marketplace/marketplaceLazyRoute').then( + (m) => m.marketplaceLazyRoute + ) +); + +export const marketplaceRouteTree = marketplaceRoute.addChildren([ + marketplaceLandingRoute, + marketplaceCatlogRoute, +]); From 594f30df7f068b2200fe25aecb740ba31fd68312 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:57:11 -0500 Subject: [PATCH 51/60] upcoming: [M3-10708] - Display maintenance type and config in linode_migrate event messages (#13191) * upcoming: [M3-10708] - Enhance Linode Migration Event Messages with Maintenance Type and Config Information * Add two changesets * Use the field for upcoming tables start date * add max reason constant --------- Co-authored-by: Jaalah Ramos Co-authored-by: dmcintyr-akamai Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../pr-13084-changed-1762970902694.md | 5 + packages/api-v4/src/account/events.ts | 6 +- ...r-13084-upcoming-features-1762970850861.md | 5 + .../components/ExtraPresetEvents.tsx | 59 ++++++++ .../components/ExtraPresetMaintenance.tsx | 37 ++++- .../src/dev-tools/components/JsonTextArea.tsx | 19 ++- .../Maintenance/MaintenanceTableRow.tsx | 8 +- .../features/Account/Maintenance/utilities.ts | 2 +- .../src/features/Events/factories/linode.tsx | 141 +++++++++++++++--- 9 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13084-changed-1762970902694.md create mode 100644 packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md diff --git a/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md b/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md new file mode 100644 index 00000000000..b3e1bd0b7c7 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Use v4beta endpoints for /events and /events/ ([#13084](https://github.com/linode/manager/pull/13084)) diff --git a/packages/api-v4/src/account/events.ts b/packages/api-v4/src/account/events.ts index 4f8c8d21e4b..9f2359d42c3 100644 --- a/packages/api-v4/src/account/events.ts +++ b/packages/api-v4/src/account/events.ts @@ -1,4 +1,4 @@ -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import type { Filter, Params, ResourcePage } from '../types'; @@ -12,7 +12,7 @@ import type { Event, Notification } from './types'; */ export const getEvents = (params: Params = {}, filter: Filter = {}) => Request>( - setURL(`${API_ROOT}/account/events`), + setURL(`${BETA_API_ROOT}/account/events`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -26,7 +26,7 @@ export const getEvents = (params: Params = {}, filter: Filter = {}) => */ export const getEvent = (eventId: number) => Request( - setURL(`${API_ROOT}/account/events/${encodeURIComponent(eventId)}`), + setURL(`${BETA_API_ROOT}/account/events/${encodeURIComponent(eventId)}`), setMethod('GET'), ); diff --git a/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md b/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md new file mode 100644 index 00000000000..894835a1327 --- /dev/null +++ b/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Display maintenance type (emergency/scheduled) and config information in linode_migrate event messages ([#13084](https://github.com/linode/manager/pull/13084)) diff --git a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx index 6ac26ce24fb..8982d6ea2db 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx @@ -287,6 +287,65 @@ const eventTemplates = { status: 'finished', }), + 'Linode Migration In Progress': () => + eventFactory.build({ + action: 'linode_migrate', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Linode migration in progress.', + percent_complete: 45, + status: 'started', + }), + + 'Linode Migration - Emergency': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'emergency', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Emergency linode migration in progress.', + percent_complete: 30, + status: 'started', + }), + + 'Linode Migration - Scheduled Started': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'scheduled', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Scheduled linode migration in progress.', + percent_complete: 10, + status: 'started', + }), + + 'Linode Migration - Scheduled': () => + eventFactory.build({ + action: 'linode_migrate', + description: 'scheduled', + entity: { + id: 1, + label: 'linode-1', + type: 'linode', + url: '/v4/linode/instances/1', + }, + message: 'Scheduled linode migration in progress.', + percent_complete: 0, + status: 'scheduled', + }), + 'Completed Event': () => eventFactory.build({ action: 'account_update', diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index c257d22db49..d1a4eae094b 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -210,7 +210,42 @@ const maintenanceTemplates = { Canceled: () => accountMaintenanceFactory.build({ status: 'canceled' }), Completed: () => accountMaintenanceFactory.build({ status: 'completed' }), 'In Progress': () => - accountMaintenanceFactory.build({ status: 'in_progress' }), + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + }), + 'In Progress - Emergency Migration': () => + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + description: 'emergency', + reason: 'Emergency maintenance migration', + }), + 'In Progress - Scheduled Migration': () => + accountMaintenanceFactory.build({ + status: 'in_progress', + entity: { + type: 'linode', + id: 1, + label: 'linode-1', + url: '/v4/linode/instances/1', + }, + type: 'migrate', + description: 'scheduled', + reason: 'Scheduled maintenance migration', + }), Pending: () => accountMaintenanceFactory.build({ status: 'pending' }), Scheduled: () => accountMaintenanceFactory.build({ status: 'scheduled' }), Started: () => accountMaintenanceFactory.build({ status: 'started' }), diff --git a/packages/manager/src/dev-tools/components/JsonTextArea.tsx b/packages/manager/src/dev-tools/components/JsonTextArea.tsx index 6eb65224f9f..1bc713bb65e 100644 --- a/packages/manager/src/dev-tools/components/JsonTextArea.tsx +++ b/packages/manager/src/dev-tools/components/JsonTextArea.tsx @@ -24,6 +24,23 @@ export const JsonTextArea = ({ const debouncedUpdate = React.useMemo( () => debounce((text: string) => { + // Handle empty/whitespace text as null + if (!text.trim()) { + const event = { + currentTarget: { + name, + value: null, + }, + target: { + name, + value: null, + }, + } as unknown as React.ChangeEvent; + + onChange(event); + return; + } + try { const parsedJson = JSON.parse(text); const event = { @@ -35,7 +52,7 @@ export const JsonTextArea = ({ name, value: parsedJson, }, - } as React.ChangeEvent; + } as unknown as React.ChangeEvent; onChange(event); } catch (err) { diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 9645722c188..3fc62a6fb2c 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -47,6 +47,8 @@ const statusIconMap: Record = { scheduled: 'active', }; +const MAX_REASON_DISPLAY_LENGTH = 93; + interface MaintenanceTableRowProps { maintenance: AccountMaintenance; tableType: MaintenanceTableType; @@ -74,9 +76,11 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const eventProgress = recentEvent && formatProgressEvent(recentEvent); - const truncatedReason = truncate(reason, 93); + const truncatedReason = reason + ? truncate(reason, MAX_REASON_DISPLAY_LENGTH) + : ''; - const isTruncated = reason !== truncatedReason; + const isTruncated = reason ? reason !== truncatedReason : false; const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index bb1c7cbe883..f202f33691d 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -40,7 +40,7 @@ export const maintenanceDateColumnMap: Record< > = { completed: ['complete_time', 'End Date'], 'in progress': ['start_time', 'Start Date'], - upcoming: ['start_time', 'Start Date'], + upcoming: ['when', 'Start Date'], pending: ['when', 'Date'], }; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index 2d80ed84d26..f65948586e0 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -5,6 +5,23 @@ import { Link } from 'src/components/Link'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; +import type { AccountMaintenance } from '@linode/api-v4'; + +/** + * Normalizes the event description to a valid maintenance type. + * Only accepts 'emergency' or 'scheduled' from AccountMaintenance.description, + * defaults to 'maintenance' for any other value or null/undefined. + */ +type MaintenanceDescription = 'maintenance' | AccountMaintenance['description']; + +const getMaintenanceDescription = ( + description: null | string | undefined +): MaintenanceDescription => { + if (description === 'emergency' || description === 'scheduled') { + return description; + } + return 'maintenance'; +}; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -241,30 +258,106 @@ export const linode: PartialEventMap<'linode'> = { ), }, linode_migrate: { - failed: (e) => ( - <> - Migration failed for Linode{' '} - . - - ), - finished: (e) => ( - <> - Linode has been{' '} - migrated. - - ), - scheduled: (e) => ( - <> - Linode is scheduled to be{' '} - migrated. - - ), - started: (e) => ( - <> - Linode is being{' '} - migrated. - - ), + failed: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Migration failed for Linode{' '} + for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + finished: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode has been{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + scheduled: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode is scheduled to be{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, + started: (e) => { + const maintenanceType = getMaintenanceDescription(e.description); + return ( + <> + Linode is being{' '} + migrated for{' '} + {maintenanceType === 'maintenance' ? ( + maintenance + ) : ( + <> + {maintenanceType} maintenance + + )} + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ); + }, }, linode_migrate_datacenter: { failed: (e) => ( From 1991b9c4b8b1265dccdda00d90f4f082bac00598 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:03:57 -0500 Subject: [PATCH 52/60] tests: Temporarily skip timerange verification tests (#13244) * Temporarily skip timerange verification tests to avoid release disruptions * Added changeset: Temporarily skip time range verification Cypress tests --- .../.changeset/pr-13244-tests-1767717203129.md | 5 +++++ .../cloudpulse/timerange-verification.spec.ts | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13244-tests-1767717203129.md diff --git a/packages/manager/.changeset/pr-13244-tests-1767717203129.md b/packages/manager/.changeset/pr-13244-tests-1767717203129.md new file mode 100644 index 00000000000..c531be277d3 --- /dev/null +++ b/packages/manager/.changeset/pr-13244-tests-1767717203129.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Temporarily skip time range verification Cypress tests ([#13244](https://github.com/linode/manager/pull/13244)) 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 71705981c27..416da6e8546 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -187,8 +187,22 @@ const formatToUtcDateTime = (dateStr: string): string => { .toFormat('yyyy-MM-dd HH:mm'); }; -// It is going to be modified -describe('Integration tests for verifying Cloudpulse custom and preset configurations', () => { +/* + * TODO Fix or migrate the tests in `timerange-verification.spec.ts`. + * + * The tests in this spec frequently fail during specific dates and time periods + * throughout the day and year. Because there are so many tests in this spec, the + * timeouts and subsequent failures can delay test runs by several (45+) minutes + * which frequently interferes with unrelated test runs. + * + * Other considerations: + * + * - Would unit tests or component tests be a better fit for this? + * + * - Are these tests adding any value? They fail frequently and the failures do + * not get reviewed. They do not seem to be protecting us from regressions. + */ +describe.skip('Integration tests for verifying Cloudpulse custom and preset configurations', () => { /* * - Mocks user preferences for dashboard details (dashboard, engine, resources, and region). * - Simulates loading test data without real API calls. From 94c5c04d4f0429774ecfad6bcda5839cb08055b4 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:29:33 -0500 Subject: [PATCH 53/60] upcoming: [UIE-9430] - Delete Database Connection Pool dialog (#13236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Add ability to delete a Connection Pool in the Database details -> Networking tab -> Manage PgBouncer Connection Pools section ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure the `Database PgBouncer` feature flag is on - Turn on the legacy MSW ### Verification steps (How to verify changes) - [ ] Go to the networking tab of a postgresql Database Cluster (`http://localhost:3000/databases/postgresql/1/networking`) - [ ] Try deleting a mocked connection pool via action menu --- ...r-13236-upcoming-features-1767383949809.md | 5 ++ .../TypeToConfirmDialog.tsx | 1 + packages/manager/src/factories/databases.ts | 2 +- .../DatabaseConnectionPoolDeleteDialog.tsx | 65 ++++++++++++++++++ .../DatabaseConnectionPoolRow.tsx | 63 ++++++++++++++++++ .../DatabaseConnectionPools.tsx | 66 ++++++------------- .../src/features/Databases/constants.ts | 4 ++ 7 files changed, 158 insertions(+), 48 deletions(-) create mode 100644 packages/manager/.changeset/pr-13236-upcoming-features-1767383949809.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx diff --git a/packages/manager/.changeset/pr-13236-upcoming-features-1767383949809.md b/packages/manager/.changeset/pr-13236-upcoming-features-1767383949809.md new file mode 100644 index 00000000000..18ffd4a5544 --- /dev/null +++ b/packages/manager/.changeset/pr-13236-upcoming-features-1767383949809.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Delete Database Connection Pool dialog ([#13236](https://github.com/linode/manager/pull/13236)) diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 9a4c36b1985..fc3f9e1337f 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -27,6 +27,7 @@ interface EntityInfo { | 'Alert' | 'Bucket' | 'Database' + | 'Database Connection Pool' | 'Domain' | 'Image' | 'Kubernetes' diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index ac10c20c467..438422d0905 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -292,7 +292,7 @@ export const databaseConnectionPoolFactory = Factory.Sync.makeFactory({ database: 'defaultdb', mode: 'transaction', - label: Factory.each((i) => `pool/${i}`), + label: Factory.each((i) => `test-pool-${i}`), size: 10, username: null, }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx new file mode 100644 index 00000000000..29d3954f05e --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolDeleteDialog.tsx @@ -0,0 +1,65 @@ +import { useDeleteDatabaseConnectionPoolMutation } from '@linode/queries'; +import { ActionsPanel, Notice } from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; + +interface Props { + databaseId: number; + onClose: () => void; + open: boolean; + poolLabel: string; +} + +export const DatabaseConnectionPoolDeleteDialog = (props: Props) => { + const { onClose, open, databaseId, poolLabel } = props; + const { enqueueSnackbar } = useSnackbar(); + const { + error, + isPending, + reset, + mutateAsync: deleteConnectionPool, + } = useDeleteDatabaseConnectionPoolMutation(databaseId, poolLabel); + + const onDelete = () => { + deleteConnectionPool().then(() => { + enqueueSnackbar(`Connection Pool ${poolLabel} deleted successfully.`, { + variant: 'success', + }); + onClose(); + }); + }; + + const clearErrorAndClose = () => { + reset(); + onClose(); + }; + + const actions = ( + + ); + + return ( + clearErrorAndClose()} + open={open} + title={`Delete Connection Pool ${poolLabel}?`} + > + + Warning: Deletion will break the service URI for any + clients using this pool. + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx new file mode 100644 index 00000000000..714b18bb197 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPoolRow.tsx @@ -0,0 +1,63 @@ +import { Hidden } from '@linode/ui'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; +import { StyledActionMenuWrapper } from 'src/features/Databases/shared.styles'; + +import type { ConnectionPool } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface Props { + /** + * Function called when the delete button in the Action Menu is pressed. + */ + onDelete: (pool: ConnectionPool) => void; + /** + * Payment method type and data. + */ + pool: ConnectionPool; +} + +export const DatabaseConnectionPoolRow = (props: Props) => { + const { pool, onDelete } = props; + + const connectionPoolActions: Action[] = [ + { + onClick: () => null, + title: 'Edit', // TODO: UIE-9395 Implement edit functionality + }, + { + onClick: () => onDelete(pool), + title: 'Delete', + }, + ]; + + return ( + + + {pool.label} + + + + {`${pool.mode.charAt(0).toUpperCase()}${pool.mode.slice(1)}`} + + + + {pool.size} + + + + {pool.username === null ? 'Reuse inbound user' : pool.username} + + + + + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index 7ff53ab74ac..74a3c3c4270 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -19,21 +19,19 @@ import { } from 'akamai-cds-react-components/Table'; import React from 'react'; -import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { MIN_PAGE_SIZE, PAGE_SIZES, } from 'src/components/PaginationFooter/PaginationFooter.constants'; +import { CONNECTION_POOL_LABEL_CELL_STYLES } from 'src/features/Databases/constants'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { - makeSettingsItemStyles, - StyledActionMenuWrapper, -} from '../../shared.styles'; +import { makeSettingsItemStyles } from '../../shared.styles'; import { ServiceURI } from '../ServiceURI'; +import { DatabaseConnectionPoolDeleteDialog } from './DatabaseConnectionPoolDeleteDialog'; +import { DatabaseConnectionPoolRow } from './DatabaseConnectionPoolRow'; import type { Database } from '@linode/api-v4'; -import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface Props { database: Database; @@ -43,9 +41,9 @@ interface Props { export const DatabaseConnectionPools = ({ database }: Props) => { const { classes } = makeSettingsItemStyles(); const theme = useTheme(); - const poolLabelCellStyles = { - flex: '.5 1 20.5%', - }; + + const [deletePoolLabelSelection, setDeletePoolLabelSelection] = + React.useState(); const pagination = usePaginationV2({ currentRoute: '/databases/$engine/$databaseId/networking', @@ -62,17 +60,6 @@ export const DatabaseConnectionPools = ({ database }: Props) => { page_size: pagination.pageSize, }); - const connectionPoolActions: Action[] = [ - { - onClick: () => null, - title: 'Edit', // TODO: UIE-9395 Implement edit functionality - }, - { - onClick: () => null, // TODO: UIE-9430 Implement delete functionality - title: 'Delete', - }, - ]; - if (connectionPoolsLoading) { return ; } @@ -127,7 +114,7 @@ export const DatabaseConnectionPools = ({ database }: Props) => { } headerborder > - + Pool Label @@ -156,32 +143,11 @@ export const DatabaseConnectionPools = ({ database }: Props) => { ) : ( connectionPools?.data.map((pool) => ( - - - {pool.label} - - - - {`${pool.mode.charAt(0).toUpperCase()}${pool.mode.slice(1)}`} - - - - {pool.size} - - - - {pool.username === null - ? 'Reuse inbound user' - : pool.username} - - - - - - + setDeletePoolLabelSelection(pool.label)} + pool={pool} + /> )) )} @@ -207,6 +173,12 @@ export const DatabaseConnectionPools = ({ database }: Props) => { }} /> )} + setDeletePoolLabelSelection(null)} + open={Boolean(deletePoolLabelSelection)} + poolLabel={deletePoolLabelSelection ?? ''} + /> ); }; diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index a1c3d7e3e77..3e3845fc9b1 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -71,3 +71,7 @@ export const ADVANCED_CONFIG_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/advanced-configuration-parameters'; export const MANAGE_NETWORKING_LEARN_MORE_LINK = 'https://techdocs.akamai.com/cloud-computing/docs/aiven-manage-database#manage-networking'; + +export const CONNECTION_POOL_LABEL_CELL_STYLES = { + flex: '.5 1 20.5%', +}; From b3c4612c66067e3f341c63ac9c928f6adf9f55a5 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:45:08 +0100 Subject: [PATCH 54/60] fix: [UIE-9894] - IAM: enable own email editing (#13214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: [UIE-9894] - IAM: enable own email editing * Added changeset: IAM: User can’t edit their own email on the user details page --- .../manager/.changeset/pr-13214-fixed-1766145130482.md | 5 +++++ .../IAM/Users/UserDetails/UserEmailPanel.test.tsx | 10 +++++----- .../features/IAM/Users/UserDetails/UserEmailPanel.tsx | 9 ++++----- .../src/features/IAM/Users/UserDetails/UserProfile.tsx | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-13214-fixed-1766145130482.md diff --git a/packages/manager/.changeset/pr-13214-fixed-1766145130482.md b/packages/manager/.changeset/pr-13214-fixed-1766145130482.md new file mode 100644 index 00000000000..62c9ef0ebf8 --- /dev/null +++ b/packages/manager/.changeset/pr-13214-fixed-1766145130482.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: User can’t edit their own email on the user details page ([#13214](https://github.com/linode/manager/pull/13214)) diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index aa9377a1490..0de888dd239 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -26,7 +26,7 @@ describe('UserEmailPanel', () => { const user = accountUserFactory.build(); const { getByLabelText } = renderWithTheme( - + ); const emailTextField = getByLabelText('Email'); @@ -45,7 +45,7 @@ describe('UserEmailPanel', () => { ); const { findByLabelText, getByLabelText, getByText } = renderWithTheme( - + ); const warning = await findByLabelText( @@ -70,7 +70,7 @@ describe('UserEmailPanel', () => { }); const { getByLabelText, getByText } = renderWithTheme( - + ); const warning = getByLabelText('This field can’t be modified.'); @@ -94,7 +94,7 @@ describe('UserEmailPanel', () => { username: 'user-1', }); - renderWithTheme(); + renderWithTheme(); const emailInput = screen.getByLabelText('Email'); @@ -114,7 +114,7 @@ describe('UserEmailPanel', () => { }); const { getByRole, findByDisplayValue } = renderWithTheme( - + ); await findByDisplayValue(user.email); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 799651e29e3..613476c11a2 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -14,10 +14,9 @@ import type { User } from '@linode/api-v4'; interface Props { activeUser: User; - canUpdateUser: boolean; } -export const UserEmailPanel = ({ canUpdateUser, activeUser }: Props) => { +export const UserEmailPanel = ({ activeUser }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { profileUserName } = useDelegationRole(); @@ -54,7 +53,7 @@ export const UserEmailPanel = ({ canUpdateUser, activeUser }: Props) => { // This should be disabled if this is NOT the current user or if the proxy user is viewing their own profile. const disableEmailField = - profileUserName !== activeUser.username || isProxyUser || !canUpdateUser; + profileUserName !== activeUser.username || isProxyUser; return ( @@ -79,11 +78,11 @@ export const UserEmailPanel = ({ canUpdateUser, activeUser }: Props) => { />