From d83b3ea8b8dc7d119eed2127b4f2e51ba936a384 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 10 Nov 2025 23:04:49 +0530 Subject: [PATCH 01/78] Save progress --- packages/api-v4/src/firewalls/types.ts | 7 +- packages/manager/src/factories/firewalls.ts | 38 ++++- .../Rules/FirewallRuleActionMenu.test.tsx | 28 ++++ .../Rules/FirewallRuleActionMenu.tsx | 33 ++-- .../Rules/FirewallRuleDrawer.tsx | 2 +- .../Rules/FirewallRuleDrawer.types.ts | 15 +- .../Rules/FirewallRuleDrawer.utils.ts | 10 +- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 6 +- .../Rules/FirewallRuleTable.tsx | 142 +++++++++++++----- packages/manager/src/mocks/serverHandlers.ts | 45 ++++++ packages/validation/src/firewalls.schema.ts | 31 ++-- 11 files changed, 275 insertions(+), 82 deletions(-) diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 372d1f0d616..f4936df7d94 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -37,15 +37,16 @@ export type UpdateFirewallRules = Omit< export type FirewallTemplateRules = UpdateFirewallRules; export interface FirewallRuleType { - action: FirewallPolicyType; + action?: FirewallPolicyType | null; addresses?: null | { ipv4?: null | string[]; ipv6?: null | string[]; }; description?: null | string; label?: null | string; - ports?: string; - protocol: FirewallRuleProtocol; + ports?: null | string; + protocol?: FirewallRuleProtocol | null; + ruleset?: null | number; } export interface FirewallDeviceEntity { diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index 419ecc21c6f..b77dfedde1e 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -10,7 +10,11 @@ import { } from '@linode/api-v4/lib/firewalls/types'; import { Factory } from '@linode/utilities'; -import type { FirewallDeviceEntity } from '@linode/api-v4/lib/firewalls/types'; +import type { + FirewallDeviceEntity, + FirewallPrefixList, + FirewallRuleSet, +} from '@linode/api-v4/lib/firewalls/types'; export const firewallRuleFactory = Factory.Sync.makeFactory({ action: 'DROP', @@ -97,3 +101,35 @@ export const firewallSettingsFactory = vpc_interface: 1, }, }); + +export const firewallRuleSetFactory = Factory.Sync.makeFactory( + { + created: '2025-11-05T00:00:00', + deleted: null, + description: Factory.each((i) => `firewall-ruleset-${i} description`), + label: Factory.each((i) => `firewall-ruleset-${i}`), + is_service_defined: false, + id: Factory.each((i) => i), + type: 'inbound', + rules: firewallRuleFactory.buildList(3), + updated: '2025-11-05T00:00:00', + version: 1, + } +); + +export const firewallPrefixListFactory = + Factory.Sync.makeFactory({ + created: '2025-11-05T00:00:00', + updated: '2025-11-05T00:00:00', + description: Factory.each((i) => `firewall-prefixlist-${i} description`), + id: Factory.each((i) => i), + name: Factory.each((i) => `pl:system:resolvers:test-${i}`), + version: 1, + visibility: 'public', + ipv4: Factory.each((i) => + Array.from({ length: 5 }, (_, j) => `139.144.${i}.${j}`) + ), + ipv6: Factory.each((i) => + Array.from({ length: 5 }, (_, j) => `2600:3c05:e001:bc::${i}${j}`) + ), + }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx index 3888e665cb8..d1964a26105 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx @@ -12,6 +12,7 @@ const props: FirewallRuleActionMenuProps = { handleCloneFirewallRule: vi.fn(), handleDeleteFirewallRule: vi.fn(), handleOpenRuleDrawerForEditing: vi.fn(), + isRuleSet: false, idx: 1, }; @@ -25,8 +26,35 @@ describe('Firewall rule action menu', () => { await userEvent.click(actionMenuButton); + // "Edit", "Clone" and "Delete" are all visible and enabled for (const action of ['Edit', 'Clone', 'Delete']) { expect(getByText(action)).toBeVisible(); } }); + + it('should include the correct actions when Firewall rules row is a RuleSet', async () => { + const { getByText, queryByText, queryByLabelText, findByRole } = + renderWithTheme(); + + const actionMenuButton = queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + // "Edit" is visible but disabled, "Clone" is not present, and "Delete" is visible and enabled + for (const action of ['Edit', 'Delete']) { + expect(getByText(action)).toBeVisible(); + } + expect(queryByText('Clone')).toBeNull(); + + expect(getByText('Edit')).toBeDisabled(); + expect(getByText('Delete')).toBeEnabled(); + + // Hover over "Edit" and assert tooltip text + const editButton = getByText('Edit'); + await userEvent.hover(editButton); + const tooltip = await findByRole('tooltip'); + expect(tooltip).toHaveTextContent( + 'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.' + ); + }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 3f3313022a3..36bcf19a155 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -13,10 +13,11 @@ import type { export interface FirewallRuleActionMenuProps extends Partial { disabled: boolean; - handleCloneFirewallRule: (idx: number) => void; + handleCloneFirewallRule?: (idx: number) => void; // Cloning is NOT applicable in the case of ruleset handleDeleteFirewallRule: (idx: number) => void; - handleOpenRuleDrawerForEditing: (idx: number) => void; + handleOpenRuleDrawerForEditing?: (idx: number) => void; // Editing is NOT applicable in the case of ruleset idx: number; + isRuleSet: boolean; } export const FirewallRuleActionMenu = React.memo( @@ -24,30 +25,39 @@ export const FirewallRuleActionMenu = React.memo( const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const rulesetEditActionToolTipText = + 'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.'; + const { disabled, handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx, + isRuleSet, ...actionMenuProps } = props; const actions: Action[] = [ { - disabled, + disabled: disabled || isRuleSet, onClick: () => { - handleOpenRuleDrawerForEditing(idx); + handleOpenRuleDrawerForEditing?.(idx); }, title: 'Edit', + tooltip: isRuleSet ? rulesetEditActionToolTipText : undefined, }, - { - disabled, - onClick: () => { - handleCloneFirewallRule(idx); - }, - title: 'Clone', - }, + ...(!isRuleSet + ? [ + { + disabled, + onClick: () => { + handleCloneFirewallRule?.(idx); + }, + title: 'Clone', + }, + ] + : []), { disabled, onClick: () => { @@ -67,6 +77,7 @@ export const FirewallRuleActionMenu = React.memo( disabled={action.disabled} key={action.title} onClick={action.onClick} + tooltip={action.tooltip} /> ); })} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 1829f4fc21c..1a644bb9359 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -96,7 +96,7 @@ export const FirewallRuleDrawer = React.memo( const onSubmit = (values: FormState) => { const ports = itemsToPortString(presetPorts, values.ports); const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses, ips); + const addresses = formValueToIPs(values.addresses as string, ips); const payload: FirewallRuleType = { action: values.action, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index d344e3146ab..324672ef71a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -20,13 +20,14 @@ export interface FirewallRuleDrawerProps { } export interface FormState { - action: FirewallPolicyType; - addresses: string; - description: string; - label: string; - ports?: string; - protocol: string; - type: string; + action?: FirewallPolicyType | null; + addresses?: null | string; + description?: null | string; + label?: null | string; + ports?: null | string; + protocol?: null | string; + ruleset?: null | number; + type?: null | string; } export interface FirewallRuleFormProps extends FormikProps { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index 25a7c9ac36d..19e81930908 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -52,7 +52,7 @@ export const deriveTypeFromValuesAndIPs = ( const predefinedFirewall = predefinedFirewallFromRule({ action: 'ACCEPT', - addresses: formValueToIPs(values.addresses, ips), + addresses: formValueToIPs(values.addresses as string, ips), ports: values.ports, protocol, }); @@ -60,9 +60,9 @@ export const deriveTypeFromValuesAndIPs = ( if (predefinedFirewall) { return predefinedFirewall; } else if ( - values.protocol?.length > 0 || + (values.protocol && values.protocol?.length > 0) || (values.ports && values.ports?.length > 0) || - values.addresses?.length > 0 + (values.addresses && values.addresses?.length > 0) ) { return 'custom'; } @@ -240,7 +240,7 @@ export const getInitialIPs = ( */ export const itemsToPortString = ( items: FirewallOptionItem[], - portInput?: string + portInput?: null | string ): string | undefined => { // If a user has selected ALL, just return that; anything else in the string // will be redundant. @@ -264,7 +264,7 @@ export const itemsToPortString = ( * and converts it to FirewallOptionItem[] and a custom input string. */ export const portStringToItems = ( - portString?: string + portString?: null | string ): [FirewallOptionItem[], string] => { // Handle empty input if (!portString) { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 2476c155bfc..26bc0c6db29 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -101,7 +101,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { if (!touched.label) { setFieldValue( 'label', - `${values.action.toLocaleLowerCase()}-${category}-${item?.label}` + `${values.action?.toLocaleLowerCase()}-${category}-${item?.label}` ); } @@ -259,7 +259,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { /> { dataAttrs: { 'data-qa-port-select': true, }, - helperText: ['ICMP', 'IPENCAP'].includes(values.protocol) + helperText: ['ICMP', 'IPENCAP'].includes(values.protocol ?? '') ? `Ports are not allowed for ${values.protocol} protocols.` : undefined, }} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 661625a14d8..2a9ff97803a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -14,7 +14,8 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Box, LinkButton, Typography } from '@linode/ui'; +import { useFirewallRuleSetQuery } from '@linode/queries'; +import { Box, Chip, LinkButton, Typography } from '@linode/ui'; import { Autocomplete } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize } from '@linode/utilities'; @@ -22,9 +23,12 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { prop, uniqBy } from 'ramda'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import Undo from 'src/assets/icons/undo.svg'; -import { MaskableText } from 'src/components/MaskableText/MaskableText'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { Link } from 'src/components/Link'; +// import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -35,6 +39,7 @@ import { generateAddressesLabel, generateRuleLabel, predefinedFirewallFromRule as ruleToPredefinedFirewall, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; @@ -55,19 +60,21 @@ import type { ExtendedFirewallRule, RuleStatus } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; import type { DragEndEvent } from '@dnd-kit/core'; import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; +import type { Theme } from '@linode/ui'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; interface RuleRow { - action?: string; - addresses: string; + action?: null | string; + addresses?: string; description?: null | string; errors?: FirewallRuleError[]; id: number; index: number; label?: null | string; originalIndex: number; - ports: string; - protocol: string; + ports?: string; + protocol?: null | string; + ruleset?: null | number; status: RuleStatus; type: string; } @@ -300,6 +307,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { ports, protocol, status, + ruleset, } = props; const actionMenuProps = { @@ -308,9 +316,14 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx: index, + isRuleSet: Boolean(ruleset), }; const theme = useTheme(); + const lgDown = useMediaQuery(theme.breakpoints.down('lg')); + const smDown = useMediaQuery(theme.breakpoints.down('sm')); + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); const { active, @@ -344,6 +357,29 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; + const isRuleSetRow = Boolean(ruleset); + + const isRuleSetRowEnabled = + isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; + const { data: rulesetDetails } = useFirewallRuleSetQuery( + ruleset ?? -1, + ruleset !== undefined && isRuleSetRowEnabled + ); + + const useStyles = makeStyles()((theme: Theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + }, + })); + + const { classes } = useStyles(); + return ( { {...listeners} sx={rowStyles} > - - - {label || ( - handleOpenRuleDrawerForEditing(index)} - > - Add a label - - )} - - - - {protocol} - - - - - - {ports === '1-65535' ? 'All Ports' : ports} - - - - - - - - - {capitalize(action?.toLocaleLowerCase() ?? '')} - + {!isRuleSetRowEnabled && ( + <> + + + {label || ( + handleOpenRuleDrawerForEditing(index)} + > + Add a label + + )} + + + + {protocol} + + + + + + {ports === '1-65535' ? 'All Ports' : ports} + + + + {/* */} + {addresses} + + + + + {capitalize(action?.toLocaleLowerCase() ?? '')} + + + )} + + {isRuleSetRowEnabled && ( + <> + + + {rulesetDetails && ( + {}}>{rulesetDetails?.label} + )} + ({ + background: theme.tokens.alias.Accent.Info.Secondary, + color: theme.tokens.alias.Accent.Info.Primary, + font: theme.font.bold, + marginLeft: theme.spacingFunction(12), + })} + /> + + ID: {ruleset} + + + + + + )} + {status !== 'NOT_MODIFIED' ? ( diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index dfe2e18e2f5..89be06ecd4d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -64,6 +64,10 @@ import { firewallFactory, firewallMetricDefinitionsResponse, firewallMetricRulesFactory, + firewallPrefixListFactory, + firewallRuleFactory, + firewallRuleSetFactory, + firewallRulesFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -586,6 +590,18 @@ const parentAccountNonAdminUser = accountUserFactory.build({ username: 'NonAdminUser', }); +// Firewall with the Rule and RuleSet Reference +const firewall1001WithRuleAndRuleSetRef = firewallFactory.build({ + id: 1001, + label: 'firewall with rule and ruleset reference', + rules: firewallRulesFactory.build({ + inbound: [ + firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall + ...firewallRuleFactory.buildList(2), + ], + }), +}); + export const handlers = [ ...iam, http.get('*/profile', () => { @@ -1231,6 +1247,8 @@ export const handlers = [ }), ], }), + // Firewall with the Rule and RuleSet Reference + firewall1001WithRuleAndRuleSetRef, ]; firewallFactory.resetSequenceNumber(); return HttpResponse.json(makeResourcePage(firewalls)); @@ -1239,6 +1257,33 @@ export const handlers = [ const devices = firewallDeviceFactory.buildList(10); return HttpResponse.json(makeResourcePage(devices)); }), + http.get('*/v4beta/networking/firewalls/rulesets', () => { + const rulesets = firewallRuleSetFactory.buildList(10); + return HttpResponse.json(makeResourcePage(rulesets)); + }), + http.get('*/v4beta/networking/prefixlists', () => { + const prefixlists = firewallPrefixListFactory.buildList(10); + return HttpResponse.json(makeResourcePage(prefixlists)); + }), + http.get( + '*/v4beta/networking/firewalls/rulesets/:rulesetId', + ({ params }) => { + const firewallRuleSet = + params.rulesetId === '123' + ? firewallRuleSetFactory.build({ + id: 123, + }) + : firewallRuleSetFactory.build(); + return HttpResponse.json(firewallRuleSet); + } + ), + http.get('*/v4beta/networking/firewalls/:firewallId', ({ params }) => { + const firewall = + params.firewallId === '1001' + ? firewall1001WithRuleAndRuleSetRef + : firewallFactory.build(); + return HttpResponse.json(firewall); + }), http.get('*/v4beta/networking/firewalls/:firewallId', () => { const firewall = firewallFactory.build(); return HttpResponse.json(firewall); diff --git a/packages/validation/src/firewalls.schema.ts b/packages/validation/src/firewalls.schema.ts index efa0489bf8b..db7437dbb83 100644 --- a/packages/validation/src/firewalls.schema.ts +++ b/packages/validation/src/firewalls.schema.ts @@ -140,23 +140,23 @@ const validateFirewallPorts = string().test({ }); export const FirewallRuleTypeSchema = object().shape({ - action: string().oneOf(['ACCEPT', 'DROP']).required('Action is required'), + action: string().oneOf(['ACCEPT', 'DROP']).nullable(), description: string().nullable(), label: string().nullable(), - protocol: string() - .oneOf(['ALL', 'TCP', 'UDP', 'ICMP', 'IPENCAP']) - .required('Protocol is required.'), - ports: string().when('protocol', { - is: (val: any) => val !== 'ICMP' && val !== 'IPENCAP', - then: () => validateFirewallPorts, - // Workaround to get the test to fail if ports is defined when protocol === ICMP or IPENCAP - otherwise: (schema) => - schema.test({ - name: 'protocol', - message: 'Ports are not allowed for ICMP and IPENCAP protocols.', - test: (value) => typeof value === 'undefined', - }), - }), + protocol: string().oneOf(['ALL', 'TCP', 'UDP', 'ICMP', 'IPENCAP']).nullable(), + ports: string() + .when('protocol', { + is: (val: any) => val !== 'ICMP' && val !== 'IPENCAP', + then: () => validateFirewallPorts, + // Workaround to get the test to fail if ports is defined when protocol === ICMP or IPENCAP + otherwise: (schema) => + schema.test({ + name: 'protocol', + message: 'Ports are not allowed for ICMP and IPENCAP protocols.', + test: (value) => typeof value === 'undefined', + }), + }) + .nullable(), addresses: object() .shape({ ipv4: array().of(ipAddress).nullable(), @@ -165,6 +165,7 @@ export const FirewallRuleTypeSchema = object().shape({ .strict(true) .notRequired() .nullable(), + ruleset: number().nullable(), }); export const FirewallRuleSchema = object().shape({ From 9c0f0e7e4c8f876d262b2807a39f83c812c4e593 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 10 Nov 2025 23:05:41 +0530 Subject: [PATCH 02/78] Update tests --- .../firewalls/firewall-rule-table.spec.tsx | 18 ++++++++++++++---- .../e2e/core/firewalls/update-firewall.spec.ts | 17 +++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index ebbd8f752ee..064563eb7cd 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -13,7 +13,11 @@ import { import { firewallRuleFactory } from 'src/factories'; import { FirewallRulesLanding } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding'; -import type { FirewallPolicyType, FirewallRuleType } from '@linode/api-v4'; +import type { + FirewallPolicyType, + FirewallRuleProtocol, + FirewallRuleType, +} from '@linode/api-v4'; interface MoveFocusedElementViaKeyboard { direction: 'DOWN' | 'UP'; @@ -122,13 +126,19 @@ const verifyFirewallWithRules = ({ .within(() => { if (isSmallViewport) { // Column 'Protocol' is not visible for smaller screens. - cy.findByText(rule.protocol).should('not.exist'); + cy.findByText(rule.protocol as FirewallRuleProtocol).should( + 'not.exist' + ); } else { - cy.findByText(rule.protocol).should('be.visible'); + cy.findByText(rule.protocol as FirewallRuleProtocol).should( + 'be.visible' + ); } cy.findByText(rule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(rule.action)).should('be.visible'); + cy.findByText( + getRuleActionLabel(rule.action as FirewallPolicyType) + ).should('be.visible'); }); }); }; diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 60999c917d6..d4ca744b5d3 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -22,6 +22,7 @@ import type { CreateLinodeRequest, Firewall, FirewallPolicyType, + FirewallRuleProtocol, FirewallRuleType, Linode, } from '@linode/api-v4'; @@ -227,11 +228,13 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(inboundRule.protocol).should('be.visible'); - cy.findByText(inboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(inboundRule.action)).should( + cy.findByText(inboundRule.protocol as FirewallRuleProtocol).should( 'be.visible' ); + cy.findByText(inboundRule.ports!).should('be.visible'); + cy.findByText( + getRuleActionLabel(inboundRule.action as FirewallPolicyType) + ).should('be.visible'); }); // Add outbound rules @@ -242,11 +245,13 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(outboundRule.protocol).should('be.visible'); - cy.findByText(outboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(outboundRule.action)).should( + cy.findByText(outboundRule.protocol as FirewallRuleProtocol).should( 'be.visible' ); + cy.findByText(outboundRule.ports!).should('be.visible'); + cy.findByText( + getRuleActionLabel(outboundRule.action as FirewallPolicyType) + ).should('be.visible'); }); // Save configuration From 065f133816e503845dd57df13a8e680d0981b585 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 10 Nov 2025 23:18:36 +0530 Subject: [PATCH 03/78] Few more changes --- .../Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 2a9ff97803a..c5ec9a5b449 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -65,14 +65,14 @@ import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; interface RuleRow { action?: null | string; - addresses?: string; + addresses?: null | string; description?: null | string; errors?: FirewallRuleError[]; id: number; index: number; label?: null | string; originalIndex: number; - ports?: string; + ports?: null | string; protocol?: null | string; ruleset?: null | number; status: RuleStatus; From 6292e35b58a3343a04f74fccb3af7cfcacd673d8 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 10 Nov 2025 23:24:44 +0530 Subject: [PATCH 04/78] Add more changes --- .../Rules/FirewallRuleActionMenu.test.tsx | 6 ++++-- .../Rules/FirewallRuleActionMenu.tsx | 10 +++++----- .../FirewallDetail/Rules/FirewallRuleTable.tsx | 15 ++++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx index d1964a26105..c7b1f62f03a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx @@ -12,7 +12,7 @@ const props: FirewallRuleActionMenuProps = { handleCloneFirewallRule: vi.fn(), handleDeleteFirewallRule: vi.fn(), handleOpenRuleDrawerForEditing: vi.fn(), - isRuleSet: false, + isRuleSetRowEnabled: false, idx: 1, }; @@ -34,7 +34,9 @@ describe('Firewall rule action menu', () => { it('should include the correct actions when Firewall rules row is a RuleSet', async () => { const { getByText, queryByText, queryByLabelText, findByRole } = - renderWithTheme(); + renderWithTheme( + + ); const actionMenuButton = queryByLabelText(/^Action menu for/)!; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 36bcf19a155..6f8212ab6de 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -17,7 +17,7 @@ export interface FirewallRuleActionMenuProps extends Partial { handleDeleteFirewallRule: (idx: number) => void; handleOpenRuleDrawerForEditing?: (idx: number) => void; // Editing is NOT applicable in the case of ruleset idx: number; - isRuleSet: boolean; + isRuleSetRowEnabled: boolean; } export const FirewallRuleActionMenu = React.memo( @@ -34,20 +34,20 @@ export const FirewallRuleActionMenu = React.memo( handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx, - isRuleSet, + isRuleSetRowEnabled, ...actionMenuProps } = props; const actions: Action[] = [ { - disabled: disabled || isRuleSet, + disabled: disabled || isRuleSetRowEnabled, onClick: () => { handleOpenRuleDrawerForEditing?.(idx); }, title: 'Edit', - tooltip: isRuleSet ? rulesetEditActionToolTipText : undefined, + tooltip: isRuleSetRowEnabled ? rulesetEditActionToolTipText : undefined, }, - ...(!isRuleSet + ...(!isRuleSetRowEnabled ? [ { disabled, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index c5ec9a5b449..0936b016731 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -310,20 +310,25 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { ruleset, } = props; + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const isRuleSetRow = Boolean(ruleset); + const isRuleSetRowEnabled = + isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; + const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx: index, - isRuleSet: Boolean(ruleset), + isRuleSetRowEnabled, }; const theme = useTheme(); const lgDown = useMediaQuery(theme.breakpoints.down('lg')); const smDown = useMediaQuery(theme.breakpoints.down('sm')); - const { isFirewallRulesetsPrefixlistsEnabled } = - useIsFirewallRulesetsPrefixlistsEnabled(); const { active, @@ -357,10 +362,6 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; - const isRuleSetRow = Boolean(ruleset); - - const isRuleSetRowEnabled = - isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; const { data: rulesetDetails } = useFirewallRuleSetQuery( ruleset ?? -1, ruleset !== undefined && isRuleSetRowEnabled From 743e7b732507160133181885869fac2c26c57781 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 02:22:20 +0530 Subject: [PATCH 05/78] Clean up tests --- .../firewalls/firewall-rule-table.spec.tsx | 18 ++++-------------- .../e2e/core/firewalls/update-firewall.spec.ts | 8 +++----- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index 064563eb7cd..537f6fccf89 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -13,11 +13,7 @@ import { import { firewallRuleFactory } from 'src/factories'; import { FirewallRulesLanding } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding'; -import type { - FirewallPolicyType, - FirewallRuleProtocol, - FirewallRuleType, -} from '@linode/api-v4'; +import type { FirewallPolicyType, FirewallRuleType } from '@linode/api-v4'; interface MoveFocusedElementViaKeyboard { direction: 'DOWN' | 'UP'; @@ -126,19 +122,13 @@ const verifyFirewallWithRules = ({ .within(() => { if (isSmallViewport) { // Column 'Protocol' is not visible for smaller screens. - cy.findByText(rule.protocol as FirewallRuleProtocol).should( - 'not.exist' - ); + cy.findByText(rule.protocol!).should('not.exist'); } else { - cy.findByText(rule.protocol as FirewallRuleProtocol).should( - 'be.visible' - ); + cy.findByText(rule.protocol!).should('be.visible'); } cy.findByText(rule.ports!).should('be.visible'); - cy.findByText( - getRuleActionLabel(rule.action as FirewallPolicyType) - ).should('be.visible'); + cy.findByText(getRuleActionLabel(rule.action!)).should('be.visible'); }); }); }; diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index d4ca744b5d3..7f86a506704 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -228,13 +228,11 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(inboundRule.protocol as FirewallRuleProtocol).should( + cy.findByText(inboundRule.protocol!).should('be.visible'); + cy.findByText(inboundRule.ports!).should('be.visible'); + cy.findByText(getRuleActionLabel(inboundRule.action!)).should( 'be.visible' ); - cy.findByText(inboundRule.ports!).should('be.visible'); - cy.findByText( - getRuleActionLabel(inboundRule.action as FirewallPolicyType) - ).should('be.visible'); }); // Add outbound rules From dd7bd2a1593c86fcf8d8366a2e16d460ae1d15b0 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 02:37:22 +0530 Subject: [PATCH 06/78] Few changes --- .../Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx | 6 +++--- .../FirewallDetail/Rules/FirewallRuleDrawer.utils.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 1a644bb9359..fce7f71f829 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -76,7 +76,7 @@ export const FirewallRuleDrawer = React.memo( }); setIPs(validatedIPs); - const _ports = itemsToPortString(presetPorts, ports); + const _ports = itemsToPortString(presetPorts, ports!); return { ...validateForm({ @@ -94,9 +94,9 @@ export const FirewallRuleDrawer = React.memo( }; const onSubmit = (values: FormState) => { - const ports = itemsToPortString(presetPorts, values.ports); + const ports = itemsToPortString(presetPorts, values.ports!); const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses as string, ips); + const addresses = formValueToIPs(values.addresses!, ips); const payload: FirewallRuleType = { action: values.action, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index 19e81930908..c044e98a54a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -52,7 +52,7 @@ export const deriveTypeFromValuesAndIPs = ( const predefinedFirewall = predefinedFirewallFromRule({ action: 'ACCEPT', - addresses: formValueToIPs(values.addresses as string, ips), + addresses: formValueToIPs(values.addresses!, ips), ports: values.ports, protocol, }); @@ -240,7 +240,7 @@ export const getInitialIPs = ( */ export const itemsToPortString = ( items: FirewallOptionItem[], - portInput?: null | string + portInput?: string ): string | undefined => { // If a user has selected ALL, just return that; anything else in the string // will be redundant. From 2024d14f4ba46a709bc3a265710f3980027c8a7f Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 04:29:28 +0530 Subject: [PATCH 07/78] Layout updates --- .../Rules/FirewallRuleTable.tsx | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 0936b016731..d0566e9663a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -15,7 +15,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useFirewallRuleSetQuery } from '@linode/queries'; -import { Box, Chip, LinkButton, Typography } from '@linode/ui'; +import { Box, Chip, LinkButton, Stack, Typography } from '@linode/ui'; import { Autocomplete } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize } from '@linode/utilities'; @@ -376,6 +376,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { color: theme.palette.primary.main, display: 'inline-block', position: 'relative', + marginTop: theme.spacingFunction(2), }, })); @@ -433,26 +434,42 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {isRuleSetRowEnabled && ( <> - - {rulesetDetails && ( - {}}>{rulesetDetails?.label} - )} - ({ - background: theme.tokens.alias.Accent.Info.Secondary, - color: theme.tokens.alias.Accent.Info.Primary, - font: theme.font.bold, - marginLeft: theme.spacingFunction(12), - })} - /> - - ID: {ruleset} - - + + + + {rulesetDetails && ( + {}}>{rulesetDetails?.label} + )} + + + + ({ + background: theme.tokens.alias.Accent.Info.Secondary, + color: theme.tokens.alias.Accent.Info.Primary, + font: theme.font.bold, + })} + /> + + + ID: {ruleset} + + + + From c2e869a7f3b86c2a9370a996908393cd9827208b Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 04:47:22 +0530 Subject: [PATCH 08/78] Update tests --- .../cypress/e2e/core/firewalls/update-firewall.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 7f86a506704..0869a42dddb 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -22,7 +22,6 @@ import type { CreateLinodeRequest, Firewall, FirewallPolicyType, - FirewallRuleProtocol, FirewallRuleType, Linode, } from '@linode/api-v4'; @@ -243,13 +242,11 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(outboundRule.protocol as FirewallRuleProtocol).should( + cy.findByText(outboundRule.protocol!).should('be.visible'); + cy.findByText(outboundRule.ports!).should('be.visible'); + cy.findByText(getRuleActionLabel(outboundRule.action!)).should( 'be.visible' ); - cy.findByText(outboundRule.ports!).should('be.visible'); - cy.findByText( - getRuleActionLabel(outboundRule.action as FirewallPolicyType) - ).should('be.visible'); }); // Save configuration From e3a138e7c5722c32546ec18b461dbbf3178ac2c2 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 05:19:53 +0530 Subject: [PATCH 09/78] Add ruleset loading state --- .../Rules/FirewallRuleTable.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index d0566e9663a..cda68aed4ba 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -28,13 +28,14 @@ import { makeStyles } from 'tss-react/mui'; import Undo from 'src/assets/icons/undo.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; -// import { MaskableText } from 'src/components/MaskableText/MaskableText'; +import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { generateAddressesLabel, generateRuleLabel, @@ -317,6 +318,12 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { const isRuleSetRowEnabled = isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; + const { data: rulesetDetails, isLoading: isRuleSetLoading } = + useFirewallRuleSetQuery( + ruleset ?? -1, + ruleset !== undefined && isRuleSetRowEnabled + ); + const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, handleCloneFirewallRule, @@ -362,11 +369,6 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; - const { data: rulesetDetails } = useFirewallRuleSetQuery( - ruleset ?? -1, - ruleset !== undefined && isRuleSetRowEnabled - ); - const useStyles = makeStyles()((theme: Theme) => ({ copyIcon: { '& svg': { @@ -382,6 +384,10 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { const { classes } = useStyles(); + if (isRuleSetLoading) { + return ; + } + return ( { - {/* */} - {addresses} + From 46ab03b40ea4a87ef2ef4079a2e438a07b41b916 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 16:07:00 +0530 Subject: [PATCH 10/78] Clean up mocks --- packages/manager/src/mocks/serverHandlers.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 304da62b5db..97776dfdcd8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1218,7 +1218,7 @@ export const handlers = [ }), http.get('*/v4beta/networking/firewalls', () => { const firewalls = [ - ...firewallFactory.buildList(9), + // ...firewallFactory.buildList(9), firewallFactory.build({ entities: [ firewallEntityfactory.build({ @@ -1248,7 +1248,7 @@ export const handlers = [ ], }), // Firewall with the Rule and RuleSet Reference - firewall1001WithRuleAndRuleSetRef, + // firewall1001WithRuleAndRuleSetRef, ]; firewallFactory.resetSequenceNumber(); return HttpResponse.json(makeResourcePage(firewalls)); @@ -1284,10 +1284,6 @@ export const handlers = [ : firewallFactory.build(); return HttpResponse.json(firewall); }), - http.get('*/v4beta/networking/firewalls/:firewallId', () => { - const firewall = firewallFactory.build(); - return HttpResponse.json(firewall); - }), http.put<{}, { status: FirewallStatus }>( '*/v4beta/networking/firewalls/:firewallId', async ({ request }) => { From d7e0e9667ae6cef89da6e91e597c35da87fbaa98 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 16:18:46 +0530 Subject: [PATCH 11/78] Fix mocks --- packages/manager/src/mocks/serverHandlers.ts | 36 ++++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 97776dfdcd8..d356f014557 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -590,18 +590,6 @@ const parentAccountNonAdminUser = accountUserFactory.build({ username: 'NonAdminUser', }); -// Firewall with the Rule and RuleSet Reference -const firewall1001WithRuleAndRuleSetRef = firewallFactory.build({ - id: 1001, - label: 'firewall with rule and ruleset reference', - rules: firewallRulesFactory.build({ - inbound: [ - firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall - ...firewallRuleFactory.buildList(2), - ], - }), -}); - export const handlers = [ ...iam, http.get('*/profile', () => { @@ -1218,7 +1206,7 @@ export const handlers = [ }), http.get('*/v4beta/networking/firewalls', () => { const firewalls = [ - // ...firewallFactory.buildList(9), + ...firewallFactory.buildList(9), firewallFactory.build({ entities: [ firewallEntityfactory.build({ @@ -1248,7 +1236,16 @@ export const handlers = [ ], }), // Firewall with the Rule and RuleSet Reference - // firewall1001WithRuleAndRuleSetRef, + firewallFactory.build({ + id: 1001, + label: 'firewall with rule and ruleset reference', + rules: firewallRulesFactory.build({ + inbound: [ + firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall + ...firewallRuleFactory.buildList(2), + ], + }), + }), ]; firewallFactory.resetSequenceNumber(); return HttpResponse.json(makeResourcePage(firewalls)); @@ -1280,7 +1277,16 @@ export const handlers = [ http.get('*/v4beta/networking/firewalls/:firewallId', ({ params }) => { const firewall = params.firewallId === '1001' - ? firewall1001WithRuleAndRuleSetRef + ? firewallFactory.build({ + id: 1001, + label: 'firewall with rule and ruleset reference', + rules: firewallRulesFactory.build({ + inbound: [ + firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall + ...firewallRuleFactory.buildList(2), + ], + }), + }) : firewallFactory.build(); return HttpResponse.json(firewall); }), From c4976e66784c9a49d4622c84ffbdef85a4343a0f Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 17:13:19 +0530 Subject: [PATCH 12/78] Add comments to the type --- packages/api-v4/src/firewalls/types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index f4936df7d94..8e163648cf9 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -36,6 +36,12 @@ export type UpdateFirewallRules = Omit< export type FirewallTemplateRules = UpdateFirewallRules; +/** + * The API may return either a full firewall rule object or a ruleset reference + * containing only the `ruleset` field. This interface supports both formats + * to ensure backward compatibility with existing implementations and avoid + * widespread refactoring. + */ export interface FirewallRuleType { action?: FirewallPolicyType | null; addresses?: null | { @@ -46,6 +52,9 @@ export interface FirewallRuleType { label?: null | string; ports?: null | string; protocol?: FirewallRuleProtocol | null; + /** + * Present when the object represents a ruleset reference. + */ ruleset?: null | number; } From 36beab9556a8ae535ee078e3996802c6f88d615d Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 17:20:26 +0530 Subject: [PATCH 13/78] Added changeset: Update FirewallRuleType to support ruleset --- .../.changeset/pr-13079-upcoming-features-1762861826541.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md diff --git a/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md b/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md new file mode 100644 index 00000000000..a9fa484079f --- /dev/null +++ b/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Update FirewallRuleType to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) From 2cdbeae75d28063a9f1e66126fffd559b0574fb5 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 17:21:31 +0530 Subject: [PATCH 14/78] Added changeset: Update FirewallRuleTypeSchema to support ruleset --- .../.changeset/pr-13079-upcoming-features-1762861891552.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md diff --git a/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md b/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md new file mode 100644 index 00000000000..419f08d8ecf --- /dev/null +++ b/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Update FirewallRuleTypeSchema to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) From d32b0c0aa7f976a66402f5ac52641bf8ef28f29f Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 11 Nov 2025 17:22:37 +0530 Subject: [PATCH 15/78] Added changeset: Add new Firewall RuleSet row layout --- .../.changeset/pr-13079-upcoming-features-1762861957439.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md diff --git a/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md b/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md new file mode 100644 index 00000000000..63b2dd22823 --- /dev/null +++ b/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new Firewall RuleSet row layout ([#13079](https://github.com/linode/manager/pull/13079)) From c7df617a7d6244b3307d1828ec7b585164b40b37 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 12 Nov 2025 18:04:26 +0530 Subject: [PATCH 16/78] Update ruleset action text - Delete to Remove --- .../FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx | 6 +++--- .../FirewallDetail/Rules/FirewallRuleActionMenu.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx index c7b1f62f03a..945ef63c842 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx @@ -42,14 +42,14 @@ describe('Firewall rule action menu', () => { await userEvent.click(actionMenuButton); - // "Edit" is visible but disabled, "Clone" is not present, and "Delete" is visible and enabled - for (const action of ['Edit', 'Delete']) { + // "Edit" is visible but disabled, "Clone" is not present, and "Remove" is visible and enabled + for (const action of ['Edit', 'Remove']) { expect(getByText(action)).toBeVisible(); } expect(queryByText('Clone')).toBeNull(); expect(getByText('Edit')).toBeDisabled(); - expect(getByText('Delete')).toBeEnabled(); + expect(getByText('Remove')).toBeEnabled(); // Hover over "Edit" and assert tooltip text const editButton = getByText('Edit'); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 6f8212ab6de..5cae29bedc6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -63,7 +63,7 @@ export const FirewallRuleActionMenu = React.memo( onClick: () => { handleDeleteFirewallRule(idx); }, - title: 'Delete', + title: isRuleSetRowEnabled ? 'Remove' : 'Delete', }, ]; From 1751ca04a016f6f5264f1cd4907fde94bcf698ba Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 02:12:05 +0530 Subject: [PATCH 17/78] Save progress... --- .../Rules/AssignRuleSetToFirewall.tsx | 57 ++++ .../Rules/FirewallRuleDrawer.tsx | 75 +++- .../Rules/FirewallRuleDrawer.types.ts | 8 + .../FirewallDetail/Rules/FirewallRuleForm.tsx | 322 ++++++++++-------- .../Firewalls/FirewallDetail/Rules/shared.ts | 13 + 5 files changed, 326 insertions(+), 149 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx new file mode 100644 index 00000000000..f513f86bc72 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx @@ -0,0 +1,57 @@ +import { useAllFirewallRuleSetsQuery } from '@linode/queries'; +import { Autocomplete, Box, Typography } from '@linode/ui'; +import * as React from 'react'; + +interface AssignRuleSetToFirewallProps { + errorText?: string; + handleRuleSetChange: (ruleSetId: number) => void; + selectedRuleSetId: number; +} + +export const AssignRuleSetToFirewall = React.memo( + (props: AssignRuleSetToFirewallProps) => { + const { errorText, handleRuleSetChange, selectedRuleSetId } = props; + // @TODO - Enable this query only when Firewall RS & PS feature flag is enabled + const { data, error, isLoading } = useAllFirewallRuleSetsQuery(); + + const ruleSets = data ?? []; + + const ruleSetDropdownOptions: { label: string; value: number }[] = + React.useMemo(() => { + return ruleSets.map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })); + }, [ruleSets]); + + const selectedOption = React.useMemo(() => { + return ( + ruleSetDropdownOptions.find( + (option) => option.value === selectedRuleSetId + ) ?? null + ); + }, [ruleSetDropdownOptions, selectedRuleSetId]); + + return ( + + ({ marginTop: theme.spacingFunction(16) })}> + 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. + + { + handleRuleSetChange(selectedRuleSet?.value ?? -1); + }} + options={ruleSetDropdownOptions} + placeholder="Select Rule Set" + value={selectedOption} + /> + + ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index fce7f71f829..0f8db537a6f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -16,6 +16,7 @@ import { FirewallRuleForm } from './FirewallRuleForm'; import type { FirewallOptionItem } from '../../shared'; import type { + FirewallCreateEntityType, FirewallRuleDrawerProps, FormState, } from './FirewallRuleDrawer.types'; @@ -32,6 +33,16 @@ export const FirewallRuleDrawer = React.memo( (props: FirewallRuleDrawerProps) => { const { category, isOpen, mode, onClose, ruleToModify } = props; + /** + * State for the type of entity being created: either a firewall 'rule' or + * referencing an existing 'ruleset' in the firewall. + * Only relevant when `mode === 'create'`. + * Optional: undefined in edit or view flows. + */ + const [createEntityType, setCreateEntityType] = React.useState< + FirewallCreateEntityType | undefined + >(undefined); + // Custom IPs are tracked separately from the form. The // component consumes this state. We use this on form submission if the // `addresses` form value is "ip/netmask", which indicates the user has @@ -45,9 +56,14 @@ export const FirewallRuleDrawer = React.memo( FirewallOptionItem[] >([]); - // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying - // (along with any errors we may have). React.useEffect(() => { + // If we're in CREATE mode, set 'rule' as a default create mode + if (mode === 'create') { + setCreateEntityType('rule'); + } + + // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying + // (along with any errors we may have). if (mode === 'edit' && ruleToModify) { setIPs(getInitialIPs(ruleToModify)); setPresetPorts(portStringToItems(ruleToModify.ports)[0]); @@ -69,7 +85,17 @@ export const FirewallRuleDrawer = React.memo( label, ports, protocol, + ruleset, }: FormState) => { + // If we're in add 'ruleset' mode, only validate the ruleset field + if (mode === 'create' && createEntityType === 'ruleset') { + const errors: Record = {}; + if (ruleset === undefined || ruleset === null || isNaN(ruleset)) { + errors.ruleset = 'Ruleset is required.'; + } + return errors; + } + // The validated IPs may have errors, so set them to state so we see the errors. const validatedIPs = validateIPs(ips, { allowEmptyAddress: addresses !== 'ip/netmask', @@ -94,22 +120,33 @@ export const FirewallRuleDrawer = React.memo( }; const onSubmit = (values: FormState) => { - const ports = itemsToPortString(presetPorts, values.ports!); - const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses!, ips); - - const payload: FirewallRuleType = { - action: values.action, - addresses, - ports, - protocol, - }; + const isCreateRuleSetMode = + mode === 'create' && createEntityType === 'ruleset'; + + if (isCreateRuleSetMode) { + const payload: Pick = { + ruleset: values.ruleset, + }; + props.onSubmit(category, payload); + } else { + const ports = itemsToPortString(presetPorts, values.ports!); + const protocol = values.protocol as FirewallRuleProtocol; + const addresses = formValueToIPs(values.addresses!, ips); + + const payload: FirewallRuleType = { + action: values.action, + addresses, + ports, + protocol, + }; + + payload.label = values.label === '' ? null : values.label; + payload.description = + values.description === '' ? null : values.description; - payload.label = values.label === '' ? null : values.label; - payload.description = - values.description === '' ? null : values.description; + props.onSubmit(category, payload); + } - props.onSubmit(category, payload); onClose(); }; @@ -127,8 +164,14 @@ export const FirewallRuleDrawer = React.memo( { + setCreateEntityType(newType); + // Clear Formik errors when createMode changes + formikProps.setErrors({}); + }} presetPorts={presetPorts} ruleErrors={ruleToModify?.errors} setIPs={setIPs} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index 324672ef71a..43ff4b093a2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -30,11 +30,19 @@ export interface FormState { type?: null | string; } +export type FirewallCreateEntityType = 'rule' | 'ruleset'; + export interface FirewallRuleFormProps extends FormikProps { addressesLabel: string; category: Category; + createEntityType?: FirewallCreateEntityType; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; + /** + * Optional callback to notify the parent of the current create entity type. + * Called when the user switches between creating a 'rule' or referencing a 'ruleset'. + */ + onCreateEntityTypeChange?: (type: FirewallCreateEntityType) => void; presetPorts: FirewallOptionItem[]; ruleErrors?: FirewallRuleError[]; setIPs: (ips: ExtendedIP[]) => void; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 26bc0c6db29..2a61d4fb4e9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -10,10 +10,12 @@ import { Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; +import { Grid } from '@mui/material'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { addressOptions, firewallOptionItemsShort, @@ -22,8 +24,13 @@ import { } from 'src/features/Firewalls/shared'; import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; +import { AssignRuleSetToFirewall } from './AssignRuleSetToFirewall'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; -import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; +import { + firewallRuleCreateOptions, + PORT_PRESETS, + PORT_PRESETS_ITEMS, +} from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; import type { @@ -39,12 +46,14 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const { addressesLabel, category, + createEntityType, errors, handleBlur, handleChange, handleSubmit, ips, mode, + onCreateEntityTypeChange, presetPorts, ruleErrors, setFieldError, @@ -200,6 +209,13 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { ); }, [values]); + const handleRuleSetChange = React.useCallback( + (id: number) => { + setFieldValue('ruleset', id); + }, + [setFieldValue] + ); + return (
{status && ( @@ -210,140 +226,180 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { variant="error" /> )} - handleTypeChange(selected)} - options={firewallOptionItemsShort} - placeholder="Select a rule preset..." - textFieldProps={{ - dataAttrs: { - 'data-qa-rule-select': true, - }, - }} - /> - - - { - handleAddressesChange(selected.value); - }} - options={addressOptions} - placeholder={`Select ${addressesLabel}s...`} - textFieldProps={{ - InputProps: { - required: true, - }, - dataAttrs: { - 'data-qa-address-source-select': true, - }, - }} - value={addressesValue} - /> - {/* Show this field only if "IP / Netmask has been selected." */} - {values.addresses === 'ip/netmask' && ( - + {firewallRuleCreateOptions.map((option) => ( + onCreateEntityTypeChange(option.purpose)} + renderIcon={() => ( + + )} + subheadings={[]} + sxCardBaseIcon={{ svg: { fontSize: '20px' } }} + /> + ))} + + )} + + {(mode === 'edit' || createEntityType === 'rule') && ( + <> + handleTypeChange(selected)} + options={firewallOptionItemsShort} + placeholder="Select a rule preset..." + textFieldProps={{ + dataAttrs: { + 'data-qa-rule-select': true, + }, + }} + /> + + + { + handleAddressesChange(selected.value); + }} + options={addressOptions} + placeholder={`Select ${addressesLabel}s...`} + textFieldProps={{ + InputProps: { + required: true, + }, + dataAttrs: { + 'data-qa-address-source-select': true, + }, + }} + value={addressesValue} + /> + {/* Show this field only if "IP / Netmask has been selected." */} + {values.addresses === 'ip/netmask' && ( + + )} + + + Action + + + } + label="Accept" + value="ACCEPT" + /> + } label="Drop" value="DROP" /> + + This will take precedence over the Firewall’s {category}{' '} + policy. + + + + + )} + + {createEntityType === 'ruleset' && ( + )} - - - Action - - - } label="Accept" value="ACCEPT" /> - } label="Drop" value="DROP" /> - - This will take precedence over the Firewall’s {category}{' '} - policy. - - - { const stripHyphen = (str: string) => { return str.match(/-/) ? str.split('-')[0] : str; }; + +export const firewallRuleCreateOptions = [ + { + label: 'Create a Rule', + purpose: 'rule', + description: 'Create a new firewall rule', + }, + { + label: 'Reference ruleset', + purpose: 'ruleset', + description: 'Reference a ruleset to the firewall', + }, +] as const; From 904eee71794e8eecbd400fbd0d415c61a0b6c969 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 02:23:28 +0530 Subject: [PATCH 18/78] Update comment --- .../Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 0f8db537a6f..561232a8a20 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -57,7 +57,7 @@ export const FirewallRuleDrawer = React.memo( >([]); React.useEffect(() => { - // If we're in CREATE mode, set 'rule' as a default create mode + // If we're in CREATE mode, set 'rule' as a default create entity type if (mode === 'create') { setCreateEntityType('rule'); } From 4171b7896c77a1c73b7c2b6453cbb92a8a3f1015 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 03:41:36 +0530 Subject: [PATCH 19/78] Exclude 'addresses' from rulesets reference payloads --- .../Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts index 2e07ec070e1..963335d11d5 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts @@ -250,6 +250,11 @@ export const removeICMPPort = ( const removeEmptyAddressArrays = (rules: ExtendedFirewallRule[]) => { return rules.map((rule) => { + // Ruleset references do not have addresses + if (rule.ruleset !== null) { + return { ...rule }; + } + const keepIPv4 = rule.addresses?.ipv4 && rule.addresses.ipv4.length > 0; const keepIPv6 = rule.addresses?.ipv6 && rule.addresses.ipv6.length > 0; From 65762ca933dcb1327ccd7d3bafe70fe217212b65 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 04:17:25 +0530 Subject: [PATCH 20/78] Some fixes --- .../FirewallDetail/Rules/AssignRuleSetToFirewall.tsx | 4 ++-- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 6 +++++- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 11 +++-------- .../FirewallDetail/Rules/firewallRuleEditor.ts | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx index f513f86bc72..6a23ed55ed7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; interface AssignRuleSetToFirewallProps { errorText?: string; - handleRuleSetChange: (ruleSetId: number) => void; + handleRuleSetChange: (ruleSetId?: number) => void; selectedRuleSetId: number; } @@ -45,7 +45,7 @@ export const AssignRuleSetToFirewall = React.memo( label="Rule Set" loading={isLoading} onChange={(_, selectedRuleSet) => { - handleRuleSetChange(selectedRuleSet?.value ?? -1); + handleRuleSetChange(selectedRuleSet?.value); }} options={ruleSetDropdownOptions} placeholder="Select Rule Set" diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 561232a8a20..a67c08af178 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -90,7 +90,11 @@ export const FirewallRuleDrawer = React.memo( // If we're in add 'ruleset' mode, only validate the ruleset field if (mode === 'create' && createEntityType === 'ruleset') { const errors: Record = {}; - if (ruleset === undefined || ruleset === null || isNaN(ruleset)) { + if ( + ruleset === undefined || + ruleset === null || + typeof ruleset !== 'number' + ) { errors.ruleset = 'Ruleset is required.'; } return errors; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 2a61d4fb4e9..b7e85ee8f57 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -209,13 +209,6 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { ); }, [values]); - const handleRuleSetChange = React.useCallback( - (id: number) => { - setFieldValue('ruleset', id); - }, - [setFieldValue] - ); - return ( {status && ( @@ -396,7 +389,9 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { {createEntityType === 'ruleset' && ( + setFieldValue('ruleset', ruleSetId) + } selectedRuleSetId={values.ruleset as number} /> )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts index 963335d11d5..7614b2e8371 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts @@ -251,7 +251,7 @@ export const removeICMPPort = ( const removeEmptyAddressArrays = (rules: ExtendedFirewallRule[]) => { return rules.map((rule) => { // Ruleset references do not have addresses - if (rule.ruleset !== null) { + if (rule.ruleset !== null && rule.ruleset !== undefined) { return { ...rule }; } From 7452795bf22e45a870a05481fa82f996dee2d382 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 05:17:37 +0530 Subject: [PATCH 21/78] Add more details to the drawer for rulset --- .../Rules/AssignRuleSetToFirewall.tsx | 74 +++++++++++++++---- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 1 + 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx index 6a23ed55ed7..4faa44ed198 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx @@ -1,8 +1,14 @@ import { useAllFirewallRuleSetsQuery } from '@linode/queries'; -import { Autocomplete, Box, Typography } from '@linode/ui'; +import { Autocomplete, Box, Chip, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import * as React from 'react'; +import { generateAddressesLabel } from '../../shared'; + +import type { Category } from './shared'; + interface AssignRuleSetToFirewallProps { + category: Category; errorText?: string; handleRuleSetChange: (ruleSetId?: number) => void; selectedRuleSetId: number; @@ -10,27 +16,33 @@ interface AssignRuleSetToFirewallProps { export const AssignRuleSetToFirewall = React.memo( (props: AssignRuleSetToFirewallProps) => { - const { errorText, handleRuleSetChange, selectedRuleSetId } = props; + const { category, errorText, handleRuleSetChange, selectedRuleSetId } = + props; // @TODO - Enable this query only when Firewall RS & PS feature flag is enabled const { data, error, isLoading } = useAllFirewallRuleSetsQuery(); const ruleSets = data ?? []; - const ruleSetDropdownOptions: { label: string; value: number }[] = - React.useMemo(() => { - return ruleSets.map((ruleSet) => ({ + // Find the selected ruleset once + const selectedRuleSet = React.useMemo( + () => ruleSets.find((r) => r.id === selectedRuleSetId) ?? null, + [ruleSets, selectedRuleSetId] + ); + + // Build dropdown options + const ruleSetDropdownOptions = React.useMemo( + () => + ruleSets.map((ruleSet) => ({ label: ruleSet.label, value: ruleSet.id, - })); - }, [ruleSets]); + })), + [ruleSets] + ); - const selectedOption = React.useMemo(() => { - return ( - ruleSetDropdownOptions.find( - (option) => option.value === selectedRuleSetId - ) ?? null - ); - }, [ruleSetDropdownOptions, selectedRuleSetId]); + // The dropdown's selected option can be derived from the selectedRuleSet itself + const selectedOption = selectedRuleSet + ? { label: selectedRuleSet.label, value: selectedRuleSet.id } + : null; return ( @@ -51,6 +63,40 @@ export const AssignRuleSetToFirewall = React.memo( placeholder="Select Rule Set" value={selectedOption} /> + {selectedRuleSet && ( + + Label: {selectedRuleSet?.label} + ID: {selectedRuleSet?.id} + {selectedRuleSet?.description} + + Service Defined: {selectedRuleSet?.is_service_defined} + + Version: {selectedRuleSet?.version} + Created: {selectedRuleSet?.created} + Updated: {selectedRuleSet?.updated} + {capitalize(category)} Rules + {selectedRuleSet.rules.map((rule, idx) => ( + + ({ + background: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Background.Positivesubtle + : theme.tokens.alias.Background.Negativesubtle, + color: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Content.Text.Positive + : theme.tokens.alias.Content.Text.Negative, + font: theme.font.bold, + })} + /> + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + + ))} + + )} ); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index b7e85ee8f57..3d11c8669cd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -388,6 +388,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { {createEntityType === 'ruleset' && ( setFieldValue('ruleset', ruleSetId) From 6291906d03b3b18b0df2f587a0d9f915477b9bf8 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 05:20:39 +0530 Subject: [PATCH 22/78] More changes... --- .../Rules/AssignRuleSetToFirewall.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx index 4faa44ed198..9656e465250 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetToFirewall.tsx @@ -3,7 +3,10 @@ import { Autocomplete, Box, Chip, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; -import { generateAddressesLabel } from '../../shared'; +import { + generateAddressesLabel, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; import type { Category } from './shared'; @@ -18,8 +21,13 @@ export const AssignRuleSetToFirewall = React.memo( (props: AssignRuleSetToFirewallProps) => { const { category, errorText, handleRuleSetChange, selectedRuleSetId } = props; - // @TODO - Enable this query only when Firewall RS & PS feature flag is enabled - const { data, error, isLoading } = useAllFirewallRuleSetsQuery(); + + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const { data, error, isLoading } = useAllFirewallRuleSetsQuery( + isFirewallRulesetsPrefixlistsEnabled + ); const ruleSets = data ?? []; From e89a24b366118a212994ce4aa5661df1b348c4df Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 13 Nov 2025 21:29:50 +0530 Subject: [PATCH 23/78] Move Action column and improve table responsiveness for long labels --- .../Rules/FirewallRuleActionMenu.tsx | 32 +++++----- .../Rules/FirewallRuleTable.tsx | 61 ++++++++----------- packages/manager/src/mocks/serverHandlers.ts | 41 ++++++++++--- 3 files changed, 77 insertions(+), 57 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 5cae29bedc6..0cfecd14a0a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -1,3 +1,4 @@ +import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -23,7 +24,7 @@ export interface FirewallRuleActionMenuProps extends Partial { export const FirewallRuleActionMenu = React.memo( (props: FirewallRuleActionMenuProps) => { const theme = useTheme(); - const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const matchesLgDown = useMediaQuery(theme.breakpoints.down('lg')); const rulesetEditActionToolTipText = 'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.'; @@ -69,19 +70,22 @@ export const FirewallRuleActionMenu = React.memo( return ( <> - {!matchesSmDown && - actions.map((action) => { - return ( - - ); - })} - {matchesSmDown && ( + {!matchesLgDown && ( + + {actions.map((action) => { + return ( + + ); + })} + + )} + {matchesLgDown && (
- + {protocol} + + {ports === '1-65535' ? 'All Ports' : ports} - - @@ -442,23 +442,15 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {}}>{rulesetDetails?.label} )} - ({ - background: theme.tokens.alias.Accent.Info.Secondary, - color: theme.tokens.alias.Accent.Info.Primary, - font: theme.font.bold, - })} - /> - ID: {ruleset} + ID:  + {ruleset} Date: Mon, 17 Nov 2025 16:18:26 +0530 Subject: [PATCH 34/78] Update cypress component tests --- .../features/firewalls/firewall-rule-table.spec.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index 134442b0ecf..537f6fccf89 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -120,7 +120,13 @@ const verifyFirewallWithRules = ({ .should('be.visible') .closest('tr') .within(() => { - cy.findByText(rule.protocol!).should('be.visible'); + if (isSmallViewport) { + // Column 'Protocol' is not visible for smaller screens. + cy.findByText(rule.protocol!).should('not.exist'); + } else { + cy.findByText(rule.protocol!).should('be.visible'); + } + cy.findByText(rule.ports!).should('be.visible'); cy.findByText(getRuleActionLabel(rule.action!)).should('be.visible'); }); From fb5f83848bb229a7ac4746be6fe8b74f8d7453e5 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 17 Nov 2025 17:54:53 +0530 Subject: [PATCH 35/78] More changes --- .../FirewallDetail/Rules/AssignRuleSetSection.tsx | 12 +----------- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 5 +++-- .../FirewallDetail/Rules/FirewallRuleDrawer.types.ts | 1 + .../FirewallDetail/Rules/FirewallRuleSetForm.tsx | 7 ++++++- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx index bf2497649a4..ff8e097f6c9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx @@ -74,16 +74,6 @@ export const AssignRuleSetSection = React.memo( const ruleSets = data ?? []; - // Auto-select the first rule set if none selected yet - React.useEffect(() => { - if ( - ruleSets.length > 0 && - (selectedRuleSetId === null || selectedRuleSetId === undefined) - ) { - handleRuleSetChange(ruleSets[0].id); - } - }, [ruleSets, selectedRuleSetId, handleRuleSetChange]); - // Find the selected ruleset once const selectedRuleSet = React.useMemo( () => ruleSets.find((r) => r.id === selectedRuleSetId) ?? null, @@ -117,7 +107,7 @@ export const AssignRuleSetSection = React.memo( handleRuleSetChange(selectedRuleSet?.value); }} options={ruleSetDropdownOptions} - placeholder="Select Rule Set" + placeholder="Select a Rule Set" renderOption={(props, option, { selected }) => { const { key, ...rest } = props; return ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index ddf694e7697..855929e85a7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -82,10 +82,10 @@ export const FirewallRuleDrawer = React.memo( if (mode === 'create' && createEntityType === 'ruleset') { const errors: Record = {}; if (!('ruleset' in values)) { - errors.ruleset = 'Ruleset is required.'; + errors.ruleset = 'Rule Set is required.'; } if ('ruleset' in values && typeof values.ruleset !== 'number') { - errors.ruleset = 'Ruleset shloud be a number.'; + errors.ruleset = 'Rule Set should be a number.'; } return errors; } @@ -181,6 +181,7 @@ export const FirewallRuleDrawer = React.memo( {createEntityType === 'ruleset' && ( )} /> diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index 23bebb7e4e2..074ab257aea 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -49,5 +49,6 @@ export interface FirewallRuleFormProps extends FormikProps { export interface FirewallRuleSetFormProps extends FormikProps { category: Category; + closeDrawer: () => void; ruleErrors?: FirewallRuleError[]; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index f4e2f704b6f..60590adf430 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -14,6 +14,7 @@ export const FirewallRuleSetForm = React.memo( ruleErrors, setFieldError, setFieldValue, + closeDrawer, values, } = props; @@ -38,9 +39,13 @@ export const FirewallRuleSetForm = React.memo( ); From 3673196ce201df1c137063fda32ba04c3a41139e Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 17 Nov 2025 20:46:54 +0530 Subject: [PATCH 36/78] Update Add rulesets button copy --- .../Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 60590adf430..1f91e373683 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -39,7 +39,7 @@ export const FirewallRuleSetForm = React.memo( Date: Mon, 17 Nov 2025 23:04:08 +0530 Subject: [PATCH 37/78] More Updates --- .../FirewallDetail/Rules/CreateEntitySelection.tsx | 8 ++++---- .../FirewallDetail/Rules/FirewallRuleSetForm.tsx | 2 +- .../src/features/Firewalls/FirewallDetail/Rules/shared.ts | 8 +++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx index ac31e2765bc..c83fabe5a02 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx @@ -37,17 +37,17 @@ export const CreateEntitySelection = (props: CreateEntitySelectionProps) => { {firewallRuleCreateOptions.map((option) => ( setCreateEntityType(option.purpose)} + key={option.value} + onClick={() => setCreateEntityType(option.value)} renderIcon={() => ( - + )} subheadings={[]} sxCardBase={(theme) => ({ diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1f91e373683..6c9d46491c5 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -39,7 +39,7 @@ export const FirewallRuleSetForm = React.memo( { export const firewallRuleCreateOptions = [ { label: 'Create a Rule', - purpose: 'rule', - description: 'Create a new firewall rule', + value: 'rule', }, { - label: 'Reference ruleset', - purpose: 'ruleset', - description: 'Reference a ruleset to the firewall', + label: 'Reference Rule Set', + value: 'ruleset', }, ] as const; From 6ed18a41c862a59ad94c87eeea486e07d6475e28 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 17 Nov 2025 23:48:49 +0530 Subject: [PATCH 38/78] Feature flag create entity selection for ruleset --- .../Rules/CreateEntitySelection.tsx | 2 +- .../Rules/FirewallRuleDrawer.tsx | 39 +++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx index c83fabe5a02..7cf24114e79 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx @@ -23,7 +23,7 @@ export const CreateEntitySelection = (props: CreateEntitySelectionProps) => { const formik = useFormikContext(); - // Reset form & erros when switching between "rule" and "ruleset" + // Reset form & errors when switching between "rule" and "ruleset" React.useEffect(() => { if (mode !== 'create') return; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 855929e85a7..d460d7fdfde 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -3,6 +3,10 @@ import { capitalize } from '@linode/utilities'; import { Formik } from 'formik'; import * as React from 'react'; +import { + type FirewallOptionItem, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; import { CreateEntitySelection } from './CreateEntitySelection'; import { formValueToIPs, @@ -16,7 +20,6 @@ import { import { FirewallRuleForm } from './FirewallRuleForm'; import { FirewallRuleSetForm } from './FirewallRuleSetForm'; -import type { FirewallOptionItem } from '../../shared'; import type { FirewallCreateEntityType, FirewallRuleDrawerProps, @@ -37,6 +40,9 @@ export const FirewallRuleDrawer = React.memo( (props: FirewallRuleDrawerProps) => { const { category, isOpen, mode, onClose, ruleToModify } = props; + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + /** * State for the type of entity being created: either a firewall 'rule' or * referencing an existing 'ruleset' in the firewall. @@ -69,7 +75,17 @@ export const FirewallRuleDrawer = React.memo( } else { setIPs([{ address: '' }]); } - }, [mode, isOpen, ruleToModify]); + + // Reset the Create entity selection to 'rule' in two cases: + // 1. The ruleset feature flag is disabled - 'ruleset' is not allowed. + // 2. The drawer is closed - ensures the next time it opens, it starts with the default 'rule' selection. + if ( + mode === 'create' && + (!isFirewallRulesetsPrefixlistsEnabled || !isOpen) + ) { + setCreateEntityType('rule'); + } + }, [mode, isOpen, ruleToModify, isFirewallRulesetsPrefixlistsEnabled]); const title = mode === 'create' ? `Add an ${capitalize(category)} Rule` : 'Edit Rule'; @@ -170,7 +186,7 @@ export const FirewallRuleDrawer = React.memo( /> )} - {mode === 'create' && ( + {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( )} - {createEntityType === 'ruleset' && ( - )} - /> - )} + {createEntityType === 'ruleset' && + isFirewallRulesetsPrefixlistsEnabled && ( + )} + /> + )} {(mode === 'edit' || createEntityType === 'rule') && ( Date: Tue, 18 Nov 2025 03:24:35 +0530 Subject: [PATCH 39/78] More refactoring - separating form states --- .../Rules/AssignRuleSetSection.tsx | 216 ----------------- .../Rules/CreateEntitySelection.tsx | 66 ------ .../Rules/FirewallRuleDrawer.tsx | 174 +++++++++----- .../Rules/FirewallRuleDrawer.utils.ts | 8 +- .../Rules/FirewallRuleSetForm.tsx | 221 ++++++++++++++++-- 5 files changed, 316 insertions(+), 369 deletions(-) delete mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx delete mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx deleted file mode 100644 index ff8e097f6c9..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/AssignRuleSetSection.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useAllFirewallRuleSetsQuery } from '@linode/queries'; -import { - Autocomplete, - Box, - Chip, - Paper, - SelectedIcon, - Stack, - styled, - Typography, -} from '@linode/ui'; -import { capitalize } from '@linode/utilities'; -import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; - -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; - -import { - generateAddressesLabel, - useIsFirewallRulesetsPrefixlistsEnabled, -} from '../../shared'; - -import type { Category } from './shared'; -import type { Theme } from '@linode/ui'; - -interface AssignRuleSetToFirewallProps { - category: Category; - errorText?: string; - handleRuleSetChange: (ruleSetId?: number) => void; - selectedRuleSetId?: null | number; -} - -const StyledListItem = styled(Typography, { label: 'StyledTypography' })( - ({ theme }) => ({ - alignItems: 'center', - display: 'flex', - padding: `${theme.spacingFunction(4)} 0`, - }) -); - -export const StyledLabel = styled(Box, { - label: 'StyledLabelBox', -})(({ theme }) => ({ - font: theme.font.bold, - marginRight: theme.spacingFunction(4), -})); - -const useStyles = makeStyles()((theme: Theme) => ({ - copyIcon: { - '& svg': { - height: '1em', - width: '1em', - }, - color: theme.palette.primary.main, - display: 'inline-block', - position: 'relative', - marginTop: theme.spacingFunction(2), - }, -})); - -export const AssignRuleSetSection = React.memo( - (props: AssignRuleSetToFirewallProps) => { - const { category, errorText, handleRuleSetChange, selectedRuleSetId } = - props; - - const { classes } = useStyles(); - - const { isFirewallRulesetsPrefixlistsEnabled } = - useIsFirewallRulesetsPrefixlistsEnabled(); - - const { data, error, isLoading } = useAllFirewallRuleSetsQuery( - isFirewallRulesetsPrefixlistsEnabled - ); - - const ruleSets = data ?? []; - - // Find the selected ruleset once - const selectedRuleSet = React.useMemo( - () => ruleSets.find((r) => r.id === selectedRuleSetId) ?? null, - [ruleSets, selectedRuleSetId] - ); - - // Build dropdown options - const ruleSetDropdownOptions = React.useMemo( - () => - ruleSets.map((ruleSet) => ({ - label: ruleSet.label, - value: ruleSet.id, - })), - [ruleSets] - ); - - return ( - - ({ marginTop: theme.spacingFunction(16) })}> - 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. - - 0} - errorText={error?.[0].reason ?? errorText} - label="Rule Set" - loading={isLoading} - onChange={(_, selectedRuleSet) => { - handleRuleSetChange(selectedRuleSet?.value); - }} - options={ruleSetDropdownOptions} - placeholder="Select a Rule Set" - renderOption={(props, option, { selected }) => { - const { key, ...rest } = props; - return ( -
  • - - - {option.label} - ID: {option.value} - - {selected && } - -
  • - ); - }} - value={ - ruleSetDropdownOptions.find((o) => o.value === selectedRuleSetId) ?? - null - } - /> - - {selectedRuleSet && ( - - - Label: - {selectedRuleSet?.label} - - - ID: - {selectedRuleSet?.id} - - - {selectedRuleSet?.description} - - Service Defined: - {selectedRuleSet?.is_service_defined ? 'Yes' : 'No'} - - - Version: - {selectedRuleSet?.version} - - - Created: - {selectedRuleSet?.created} - - - Updated: - {selectedRuleSet?.updated} - - - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - padding: theme.spacingFunction(12), - marginTop: theme.spacingFunction(8), - })} - > - ({ marginBottom: theme.spacingFunction(4) })} - > - {capitalize(category)} Rules - - {selectedRuleSet.rules.map((rule, idx) => ( - ({ - padding: `${theme.spacingFunction(4)} 0`, - })} - > - ({ - background: - rule.action === 'ACCEPT' - ? theme.tokens.alias.Background.Positivesubtle - : theme.tokens.alias.Background.Negativesubtle, - color: - rule.action === 'ACCEPT' - ? theme.tokens.alias.Content.Text.Positive - : theme.tokens.alias.Content.Text.Negative, - font: theme.font.bold, - width: '58px', - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, - })} - /> - {rule.protocol}; {rule.ports};  - {generateAddressesLabel(rule.addresses)} - - ))} - - - )} -
    - ); - } -); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx deleted file mode 100644 index 7cf24114e79..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/CreateEntitySelection.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Grid, Radio } from '@mui/material'; -import { useFormikContext } from 'formik'; -import * as React from 'react'; - -import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; - -import { firewallRuleCreateOptions } from './shared'; - -import type { - FirewallCreateEntityType, - FormRuleSetState, - FormState, -} from './FirewallRuleDrawer.types'; - -interface CreateEntitySelectionProps { - createEntityType: FirewallCreateEntityType; - mode: string; - setCreateEntityType: (value: FirewallCreateEntityType) => void; -} - -export const CreateEntitySelection = (props: CreateEntitySelectionProps) => { - const { createEntityType, setCreateEntityType, mode } = props; - - const formik = useFormikContext(); - - // Reset form & errors when switching between "rule" and "ruleset" - React.useEffect(() => { - if (mode !== 'create') return; - - formik.setErrors({}); - formik.setTouched({}); - formik.setStatus(undefined); - formik.resetForm({}); - }, [createEntityType]); - - return ( - - {firewallRuleCreateOptions.map((option) => ( - setCreateEntityType(option.value)} - renderIcon={() => ( - - )} - subheadings={[]} - sxCardBase={(theme) => ({ - gap: 0, - '& .cardSubheadingTitle': { - fontSize: theme.tokens.font.FontSize.Xs, - }, - })} - sxCardBaseIcon={(theme) => ({ - svg: { fontSize: theme.tokens.font.FontSize.L }, - })} - /> - ))} - - ); -}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index d460d7fdfde..8d0fc35f699 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,13 +1,15 @@ -import { Drawer, Notice, Typography } from '@linode/ui'; +import { Drawer, Notice, Radio, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; +import { Grid } from '@mui/material'; import { Formik } from 'formik'; import * as React from 'react'; +import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; + import { type FirewallOptionItem, useIsFirewallRulesetsPrefixlistsEnabled, } from '../../shared'; -import { CreateEntitySelection } from './CreateEntitySelection'; import { formValueToIPs, getInitialFormValues, @@ -19,6 +21,7 @@ import { } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; import { FirewallRuleSetForm } from './FirewallRuleSetForm'; +import { firewallRuleCreateOptions } from './shared'; import type { FirewallCreateEntityType, @@ -30,7 +33,6 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { FormikProps } from 'formik'; import type { ExtendedIP } from 'src/utilities/ipUtils'; // ============================================================================= @@ -92,7 +94,7 @@ export const FirewallRuleDrawer = React.memo( const addressesLabel = category === 'inbound' ? 'source' : 'destination'; - const onValidate = (values: FormRuleSetState | FormState) => { + const onValidateRule = (values: FormRuleSetState | FormState) => { // Case 1: user chose CREATE -> RULESET mode // If we're in add 'ruleset' mode, only validate the ruleset field if (mode === 'create' && createEntityType === 'ruleset') { @@ -134,77 +136,85 @@ export const FirewallRuleDrawer = React.memo( }; }; - const onSubmit = (values: FormRuleSetState | FormState) => { - const isCreateRuleSetMode = - mode === 'create' && createEntityType === 'ruleset'; - - // Case 1: RULESET submission - if (isCreateRuleSetMode) { - const payload = { - ruleset: (values as FormRuleSetState).ruleset, - }; - props.onSubmit(category, payload); - onClose(); - return; - } - - // Case 2: RULE submission - const v = values as FormState; - const ports = itemsToPortString(presetPorts, v.ports!); - const protocol = v.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(v.addresses!, ips); + const onSubmitRule = (values: FormState) => { + const ports = itemsToPortString(presetPorts, values.ports!); + const protocol = values.protocol as FirewallRuleProtocol; + const addresses = formValueToIPs(values.addresses!, ips); const payload: FirewallRuleType = { - action: v.action, + action: values.action, addresses, ports, protocol, - label: v.label || null, - description: v.description || null, + label: values.label || null, + description: values.description || null, }; props.onSubmit(category, payload); onClose(); }; + const onValidateRuleSet = (values: FormRuleSetState) => { + const errors: Record = {}; + if (!values.ruleset || values.ruleset === -1) { + errors.ruleset = 'Rule Set is required.'; + } + if (typeof values.ruleset !== 'number') { + errors.ruleset = 'Rule Set should be a number.'; + } + return errors; + }; + return ( - - {(formikProps) => ( - <> - {formikProps.status && ( - - )} - - {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( - - )} + {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( + + {firewallRuleCreateOptions.map((option) => ( + setCreateEntityType(option.value)} + renderIcon={() => ( + + )} + subheadings={[]} + sxCardBase={(theme) => ({ + gap: 0, + '& .cardSubheadingTitle': { + fontSize: theme.tokens.font.FontSize.Xs, + }, + })} + sxCardBaseIcon={(theme) => ({ + svg: { fontSize: theme.tokens.font.FontSize.L }, + })} + /> + ))} + + )} - {createEntityType === 'ruleset' && - isFirewallRulesetsPrefixlistsEnabled && ( - )} + {(mode === 'edit' || createEntityType === 'rule') && ( + + initialValues={getInitialFormValues(ruleToModify)} + onSubmit={onSubmitRule} + validate={onValidateRule} + validateOnBlur={false} + validateOnChange={false} + > + {(formikProps) => ( + <> + {formikProps.status && ( + )} - - {(mode === 'edit' || createEntityType === 'rule') && ( )} + {...formikProps} /> + + )} + + )} + + {mode === 'create' && + createEntityType === 'ruleset' && + isFirewallRulesetsPrefixlistsEnabled && ( + + initialValues={{ ruleset: -1 }} + onSubmit={(values) => { + props.onSubmit(category, values); + onClose(); + }} + validate={onValidateRuleSet} + validateOnBlur={true} + validateOnChange={true} + > + {(formikProps) => ( + <> + {formikProps.status && ( + + )} + + {createEntityType === 'ruleset' && + isFirewallRulesetsPrefixlistsEnabled && ( + + )} + )} - + )} - Rule changes don’t take effect immediately. You can add or delete rules before saving all your changes to this Firewall. diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index 183e077ec2e..63e046e57a5 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -19,7 +19,7 @@ import { stringToExtendedIP } from 'src/utilities/ipUtils'; import { PORT_PRESETS, sortString } from './shared'; -import type { FormRuleSetState, FormState } from './FirewallRuleDrawer.types'; +import type { FormState } from './FirewallRuleDrawer.types'; import type { ExtendedFirewallRule } from './firewallRuleEditor'; import type { FirewallRuleProtocol, @@ -150,15 +150,11 @@ const initialValues: FormState = { export const getInitialFormValues = ( ruleToModify?: ExtendedFirewallRule -): FormRuleSetState | FormState => { +): FormState => { if (!ruleToModify) { return initialValues; } - if (ruleToModify.ruleset) { - return { ruleset: -1 } as FormRuleSetState; - } - return { action: ruleToModify.action, addresses: getInitialAddressFormValue(ruleToModify.addresses), diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 6c9d46491c5..9df58cc1a6c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -1,9 +1,56 @@ -import { ActionsPanel } from '@linode/ui'; +import { useAllFirewallRuleSetsQuery } from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Box, + Chip, + Paper, + SelectedIcon, + Stack, + styled, + Typography, +} from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; -import { AssignRuleSetSection } from './AssignRuleSetSection'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + +import { + generateAddressesLabel, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; +import type { Theme } from '@linode/ui'; + +const StyledListItem = styled(Typography, { label: 'StyledTypography' })( + ({ theme }) => ({ + alignItems: 'center', + display: 'flex', + padding: `${theme.spacingFunction(4)} 0`, + }) +); + +export const StyledLabel = styled(Box, { + label: 'StyledLabelBox', +})(({ theme }) => ({ + font: theme.font.bold, + marginRight: theme.spacingFunction(4), +})); + +const useStyles = makeStyles()((theme: Theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + marginTop: theme.spacingFunction(2), + }, +})); export const FirewallRuleSetForm = React.memo( (props: FirewallRuleSetFormProps) => { @@ -11,30 +58,168 @@ export const FirewallRuleSetForm = React.memo( category, errors, handleSubmit, - ruleErrors, - setFieldError, + setFieldTouched, setFieldValue, + touched, closeDrawer, values, } = props; - // Set form field errors for each error - React.useEffect(() => { - ruleErrors?.forEach((thisError) => { - setFieldError(thisError.formField, thisError.reason); - }); - }, [ruleErrors, setFieldError]); + const { classes } = useStyles(); + + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const { data, error, isLoading } = useAllFirewallRuleSetsQuery( + isFirewallRulesetsPrefixlistsEnabled + ); + + const ruleSets = data ?? []; + + // Find the selected ruleset once + const selectedRuleSet = React.useMemo( + () => ruleSets.find((r) => r.id === values.ruleset) ?? null, + [ruleSets, values.ruleset] + ); + + // Build dropdown options + const ruleSetDropdownOptions = React.useMemo( + () => + ruleSets.map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), + [ruleSets] + ); + + const errorText = + error?.[0].reason ?? (touched.ruleset ? errors.ruleset : undefined); return (
    - - setFieldValue('ruleset', ruleSetId) - } - selectedRuleSetId={values.ruleset} - /> + + ({ marginTop: theme.spacingFunction(16) })} + > + 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. + + 0} + errorText={errorText} + label="Rule Set" + loading={isLoading} + onBlur={() => setFieldTouched('ruleset')} + onChange={(_, selectedRuleSet) => { + setFieldValue('ruleset', selectedRuleSet?.value); + }} + options={ruleSetDropdownOptions} + placeholder="Select a Rule Set" + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + + + {option.label} + ID: {option.value} + + {selected && } + +
  • + ); + }} + value={ + ruleSetDropdownOptions.find((o) => o.value === values.ruleset) ?? + null + } + /> + + {selectedRuleSet && ( + + + Label: + {selectedRuleSet?.label} + + + ID: + {selectedRuleSet?.id} + + + {selectedRuleSet?.description} + + Service Defined: + {selectedRuleSet?.is_service_defined ? 'Yes' : 'No'} + + + Version: + {selectedRuleSet?.version} + + + Created: + {selectedRuleSet?.created} + + + Updated: + {selectedRuleSet?.updated} + + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + })} + > + ({ marginBottom: theme.spacingFunction(4) })} + > + {capitalize(category)} Rules + + {selectedRuleSet.rules.map((rule, idx) => ( + ({ + padding: `${theme.spacingFunction(4)} 0`, + })} + > + ({ + background: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Background.Positivesubtle + : theme.tokens.alias.Background.Negativesubtle, + color: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Content.Text.Positive + : theme.tokens.alias.Content.Text.Negative, + font: theme.font.bold, + width: '58px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + + ))} + + + )} +
    Date: Tue, 18 Nov 2025 05:05:29 +0530 Subject: [PATCH 40/78] Add ruleset details drawer --- .../Rules/FirewallRuleDrawer.tsx | 66 ++++++++++++++----- .../Rules/FirewallRuleDrawer.types.ts | 4 +- .../Rules/FirewallRuleTable.tsx | 13 +++- .../Rules/FirewallRulesLanding.tsx | 43 ++++++++---- .../manager/src/routes/firewalls/index.ts | 10 +++ 5 files changed, 106 insertions(+), 30 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 8d0fc35f699..5388a261cd2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,9 +1,10 @@ -import { Drawer, Notice, Radio, Typography } from '@linode/ui'; +import { Box, Drawer, Notice, Radio, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import { Grid } from '@mui/material'; import { Formik } from 'formik'; import * as React from 'react'; +import { Link } from 'src/components/Link'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { @@ -40,7 +41,7 @@ import type { ExtendedIP } from 'src/utilities/ipUtils'; // ============================================================================= export const FirewallRuleDrawer = React.memo( (props: FirewallRuleDrawerProps) => { - const { category, isOpen, mode, onClose, ruleToModify } = props; + const { category, isOpen, mode, onClose, ruleToModifyOrView } = props; const { isFirewallRulesetsPrefixlistsEnabled } = useIsFirewallRulesetsPrefixlistsEnabled(); @@ -69,9 +70,9 @@ export const FirewallRuleDrawer = React.memo( React.useEffect(() => { // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying // (along with any errors we may have). - if (mode === 'edit' && ruleToModify) { - setIPs(getInitialIPs(ruleToModify)); - setPresetPorts(portStringToItems(ruleToModify.ports)[0]); + if (mode === 'edit' && ruleToModifyOrView) { + setIPs(getInitialIPs(ruleToModifyOrView)); + setPresetPorts(portStringToItems(ruleToModifyOrView.ports)[0]); } else if (isOpen) { setPresetPorts([]); } else { @@ -87,10 +88,19 @@ export const FirewallRuleDrawer = React.memo( ) { setCreateEntityType('rule'); } - }, [mode, isOpen, ruleToModify, isFirewallRulesetsPrefixlistsEnabled]); + }, [ + mode, + isOpen, + ruleToModifyOrView, + isFirewallRulesetsPrefixlistsEnabled, + ]); const title = - mode === 'create' ? `Add an ${capitalize(category)} Rule` : 'Edit Rule'; + mode === 'create' + ? `Add an ${capitalize(category)} Rule` + : mode === 'edit' + ? 'Edit Rule' + : `${capitalize(category)} Rule Set details`; const addressesLabel = category === 'inbound' ? 'source' : 'destination'; @@ -164,6 +174,26 @@ export const FirewallRuleDrawer = React.memo( return errors; }; + const RuleSetViewContainer = () => { + const prefixLists = ['pl:system:1', 'pl:system:2']; + + return ( + <> + Rule Set ID: {ruleToModifyOrView?.ruleset} + + {prefixLists.map((pl) => ( + <> + {}}> + {pl} + +   + + ))} + + + ); + }; + return ( {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( @@ -197,9 +227,10 @@ export const FirewallRuleDrawer = React.memo( )} - {(mode === 'edit' || createEntityType === 'rule') && ( + {(mode === 'edit' || + (mode === 'create' && createEntityType === 'rule')) && ( - initialValues={getInitialFormValues(ruleToModify)} + initialValues={getInitialFormValues(ruleToModifyOrView)} onSubmit={onSubmitRule} validate={onValidateRule} validateOnBlur={false} @@ -221,7 +252,7 @@ export const FirewallRuleDrawer = React.memo( ips={ips} mode={mode} presetPorts={presetPorts} - ruleErrors={ruleToModify?.errors} + ruleErrors={ruleToModifyOrView?.errors} setIPs={setIPs} setPresetPorts={setPresetPorts} {...formikProps} @@ -260,7 +291,7 @@ export const FirewallRuleDrawer = React.memo( )} @@ -268,10 +299,15 @@ export const FirewallRuleDrawer = React.memo( )} )} - - Rule changes don’t take effect immediately. You can add or - delete rules before saving all your changes to this Firewall. - + + {mode === 'view' && RuleSetViewContainer()} + + {(mode === 'create' || mode === 'edit') && ( + + Rule changes don’t take effect immediately. You can add or + delete rules before saving all your changes to this Firewall. + + )} ); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index 074ab257aea..86d47f71801 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -8,7 +8,7 @@ import type { import type { FormikProps } from 'formik'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -export type FirewallRuleDrawerMode = 'create' | 'edit'; +export type FirewallRuleDrawerMode = 'create' | 'edit' | 'view'; export interface FirewallRuleDrawerProps { category: Category; @@ -16,7 +16,7 @@ export interface FirewallRuleDrawerProps { mode: FirewallRuleDrawerMode; onClose: () => void; onSubmit: (category: 'inbound' | 'outbound', rule: FirewallRuleType) => void; - ruleToModify?: ExtendedFirewallRule; + ruleToModifyOrView?: ExtendedFirewallRule; } export interface FormState { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 2999b46c527..0b4c32a17e2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -88,6 +88,7 @@ interface RowActionHandlers { handleCloneFirewallRule: (idx: number) => void; handleDeleteFirewallRule: (idx: number) => void; handleOpenRuleDrawerForEditing: (idx: number) => void; + handleOpenRuleSetDrawerForViewing?: (idx: number) => void; handleReorder: (startIdx: number, endIdx: number) => void; handleUndo: (idx: number) => void; } @@ -111,6 +112,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, handlePolicyChange, handleReorder, handleUndo, @@ -246,6 +248,9 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleOpenRuleDrawerForEditing={ handleOpenRuleDrawerForEditing } + handleOpenRuleSetDrawerForViewing={ + handleOpenRuleSetDrawerForViewing + } handleUndo={handleUndo} key={thisRuleRow.id} {...thisRuleRow} @@ -281,6 +286,7 @@ export interface FirewallRuleTableRowProps extends RuleRow { handleCloneFirewallRule: RowActionHandlersWithDisabled['handleCloneFirewallRule']; handleDeleteFirewallRule: RowActionHandlersWithDisabled['handleDeleteFirewallRule']; handleOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['handleOpenRuleDrawerForEditing']; + handleOpenRuleSetDrawerForViewing?: RowActionHandlersWithDisabled['handleOpenRuleSetDrawerForViewing']; handleUndo: RowActionHandlersWithDisabled['handleUndo']; } @@ -293,6 +299,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, handleUndo, id, index, @@ -439,7 +446,11 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { sx={{ flexShrink: 0 }} /> {rulesetDetails && ( - {}}>{rulesetDetails?.label} + handleOpenRuleSetDrawerForViewing?.(index)} + > + {rulesetDetails?.label} + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 913ea297f3d..090fe314783 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -105,16 +105,22 @@ export const FirewallRulesLanding = React.memo((props: Props) => { mode, ruleIdx: idx, }); + + let path: string; + + if (mode === 'create') { + path = `/firewalls/$id/rules/add/${category}`; + } else if (mode === 'edit') { + path = `/firewalls/$id/rules/${mode}/${category}/$ruleId`; + } else if (mode === 'view') { + path = `/firewalls/$id/rules/${mode}/${category}/ruleset/$ruleId`; + } else { + throw new Error(`Unknown mode: ${mode}`); + } + navigate({ params: { id: String(firewallID), ruleId: String(idx) }, - to: - category === 'inbound' && mode === 'create' - ? '/firewalls/$id/rules/add/inbound' - : category === 'inbound' && mode === 'edit' - ? `/firewalls/$id/rules/edit/inbound/$ruleId` - : category === 'outbound' && mode === 'create' - ? '/firewalls/$id/rules/add/outbound' - : `/firewalls/$id/rules/edit/outbound/$ruleId`, + to: path, }); }; @@ -293,7 +299,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { next.routeId === '/firewalls/$id/rules/add/inbound' || next.routeId === '/firewalls/$id/rules/add/outbound' || next.routeId === '/firewalls/$id/rules/edit/inbound/$ruleId' || - next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId'; + next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId' || + next.routeId === '/firewalls/$id/rules/view/$category/ruleset/$ruleId'; return !isNavigatingToAllowedRoute; }, @@ -325,7 +332,7 @@ export const FirewallRulesLanding = React.memo((props: Props) => { // This is for the Rule Drawer. If there is a rule to modify, // we need to pass it to the drawer to pre-populate the form fields. - const ruleToModify = + const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined ? ruleDrawer.category === 'inbound' ? inboundRules[ruleDrawer.ruleIdx] @@ -383,6 +390,9 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer('inbound', 'edit', idx) } + handleOpenRuleSetDrawerForViewing={(idx: number) => + openRuleDrawer('inbound', 'view', idx) + } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('inbound', startIdx, endIdx) @@ -404,6 +414,9 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer('outbound', 'edit', idx) } + handleOpenRuleSetDrawerForViewing={(idx: number) => + openRuleDrawer('outbound', 'view', idx) + } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('outbound', startIdx, endIdx) @@ -420,12 +433,18 @@ export const FirewallRulesLanding = React.memo((props: Props) => { location.pathname.endsWith('add/inbound') || location.pathname.endsWith('add/outbound') || location.pathname.endsWith(`edit/inbound/${ruleDrawer.ruleIdx}`) || - location.pathname.endsWith(`edit/outbound/${ruleDrawer.ruleIdx}`) + location.pathname.endsWith(`edit/outbound/${ruleDrawer.ruleIdx}`) || + location.pathname.endsWith( + `view/inbound/ruleset/${ruleDrawer.ruleIdx}` + ) || + location.pathname.endsWith( + `view/outbound/ruleset/${ruleDrawer.ruleIdx}` + ) } mode={ruleDrawer.mode} onClose={closeRuleDrawer} onSubmit={ruleDrawer.mode === 'create' ? handleAddRule : handleEditRule} - ruleToModify={ruleToModify} + ruleToModifyOrView={ruleToModifyOrView} /> firewallDetailRulesRoute, + path: 'view/$category/ruleset/$ruleId', +}).lazy(() => + import('src/features/Firewalls/FirewallDetail/firewallDetailLazyRoute').then( + (m) => m.firewallDetailLazyRoute + ) +); + const firewallDetailRulesAddInboundRuleRoute = createRoute({ getParentRoute: () => firewallDetailRulesAddRuleRoute, path: 'inbound', @@ -180,6 +189,7 @@ export const firewallsRouteTree = firewallsRoute.addChildren([ firewallDetailRulesEditOutboundRuleRoute, firewallDetailRulesAddInboundRuleRoute, firewallDetailRulesAddOutboundRuleRoute, + firewallDetailRulesViewRuleSetRoute, ]), firewallDetailNodebalancersRoute.addChildren([ firewallDetailNodebalancersAddNodebalancerRoute, From 81c11e976e6daa8c967e27f7eae52d928ba7ccd0 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 18 Nov 2025 05:09:03 +0530 Subject: [PATCH 41/78] Some clean up... --- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 8d0fc35f699..3fbdb0f675a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -254,16 +254,12 @@ export const FirewallRuleDrawer = React.memo( variant="error" /> )} - - {createEntityType === 'ruleset' && - isFirewallRulesetsPrefixlistsEnabled && ( - - )} + )} From 5544f7104eb57f0c8f2f7855a1dbfc682a91dfd8 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 18 Nov 2025 05:30:29 +0530 Subject: [PATCH 42/78] Save progress --- .../Rules/FirewallRuleDrawer.tsx | 28 ++++-------------- .../Rules/RuleSetDetailsView.tsx | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 23 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 24c8899479d..3dc3e89fea7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,10 +1,9 @@ -import { Box, Drawer, Notice, Radio, Typography } from '@linode/ui'; +import { Drawer, Notice, Radio, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import { Grid } from '@mui/material'; import { Formik } from 'formik'; import * as React from 'react'; -import { Link } from 'src/components/Link'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { @@ -22,6 +21,7 @@ import { } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; import { FirewallRuleSetForm } from './FirewallRuleSetForm'; +import { RuleSetDetailsView } from './RuleSetDetailsView'; import { firewallRuleCreateOptions } from './shared'; import type { @@ -174,26 +174,6 @@ export const FirewallRuleDrawer = React.memo( return errors; }; - const RuleSetViewContainer = () => { - const prefixLists = ['pl:system:1', 'pl:system:2']; - - return ( - <> - Rule Set ID: {ruleToModifyOrView?.ruleset} - - {prefixLists.map((pl) => ( - <> - {}}> - {pl} - -   - - ))} - - - ); - }; - return ( {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( @@ -296,7 +276,9 @@ export const FirewallRuleDrawer = React.memo( )} - {mode === 'view' && RuleSetViewContainer()} + {mode === 'view' && ( + + )} {(mode === 'create' || mode === 'edit') && ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx new file mode 100644 index 00000000000..50a7547212a --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx @@ -0,0 +1,29 @@ +import { Box } from '@linode/ui'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +interface RuleSetDetailsViewProps { + ruleset: number; +} + +export const RuleSetDetailsView = (props: RuleSetDetailsViewProps) => { + const { ruleset } = props; + const prefixLists = ['pl:system:1', 'pl:system:2']; + + return ( + <> + Rule Set ID: {ruleset} + + {prefixLists.map((pl) => ( + <> + {}}> + {pl} + +   + + ))} + + + ); +}; From 3bf385826f5ece3551f5c86c338392505602390b Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 18 Nov 2025 17:13:34 +0530 Subject: [PATCH 43/78] Add more changes --- .../Rules/FirewallRuleDrawer.tsx | 7 +- .../Rules/FirewallRuleSetDetailsView.tsx | 162 ++++++++++++++++++ .../Rules/FirewallRuleSetForm.tsx | 32 +--- .../Rules/RuleSetDetailsView.tsx | 29 ---- .../FirewallDetail/Rules/shared.styles.ts | 50 ++++++ 5 files changed, 218 insertions(+), 62 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx delete mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 3dc3e89fea7..e6d6e95d77a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -20,8 +20,8 @@ import { validateIPs, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; +import { FirewallRuleSetDetailsView } from './FirewallRuleSetDetailsView'; import { FirewallRuleSetForm } from './FirewallRuleSetForm'; -import { RuleSetDetailsView } from './RuleSetDetailsView'; import { firewallRuleCreateOptions } from './shared'; import type { @@ -277,7 +277,10 @@ export const FirewallRuleDrawer = React.memo( )} {mode === 'view' && ( - + )} {(mode === 'create' || mode === 'edit') && ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx new file mode 100644 index 00000000000..20980d776a1 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -0,0 +1,162 @@ +import { useFirewallRuleSetQuery } from '@linode/queries'; +import { Box, Chip, Paper, TooltipIcon } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + +import { + generateAddressesLabel, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { + StyledLabel, + StyledListItem, + StyledWarningIcon, + useStyles, +} from './shared.styles'; + +import type { Category } from './shared'; + +interface FirewallRuleSetDetailsViewProps { + category: Category; + ruleset: number; +} + +export const FirewallRuleSetDetailsView = ( + props: FirewallRuleSetDetailsViewProps +) => { + const { category, ruleset } = props; + + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const { classes } = useStyles(); + + const { data: ruleSetDetails } = useFirewallRuleSetQuery( + ruleset, + isFirewallRulesetsPrefixlistsEnabled + ); + + return ( + + + Label: + {ruleSetDetails?.label} + + + ID: + {ruleSetDetails?.id} + + + + Description + {ruleSetDetails?.description} + + + Service Defined: + {ruleSetDetails?.is_service_defined ? 'Yes' : 'No'} + + + Version: + {ruleSetDetails?.version} + + + Created: + {ruleSetDetails?.created} + + + Updated: + {ruleSetDetails?.updated} + + + {ruleSetDetails?.deleted && ( + ({ color: theme.tokens.alias.Content.Text.Negative })} + > + + Marked for deletion: + {ruleSetDetails?.deleted} + + + )} + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + })} + > + ({ marginBottom: theme.spacingFunction(4) })} + > + {capitalize(category)} Rules + + ({ + color: theme.tokens.alias.Content.Text.Negative, + marginBottom: theme.spacingFunction(12), + })} + > + ({ + background: theme.tokens.alias.Background.Neutralsubtle, + color: theme.tokens.alias.Content.Text.Negative, + font: theme.font.bold, + width: '58px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + Protocol;  Ports (if any);  Sources (IPs, PLs) + + {ruleSetDetails?.rules.map((rule, idx) => ( + ({ + padding: `${theme.spacingFunction(4)} 0`, + })} + > + ({ + background: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Background.Positivesubtle + : theme.tokens.alias.Background.Negativesubtle, + color: + rule.action === 'ACCEPT' + ? theme.tokens.alias.Content.Text.Positive + : theme.tokens.alias.Content.Text.Negative, + font: theme.font.bold, + width: '58px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + + ))} + + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 9df58cc1a6c..d2daea6eef2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -7,12 +7,10 @@ import { Paper, SelectedIcon, Stack, - styled, Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -20,37 +18,9 @@ import { generateAddressesLabel, useIsFirewallRulesetsPrefixlistsEnabled, } from '../../shared'; +import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; -import type { Theme } from '@linode/ui'; - -const StyledListItem = styled(Typography, { label: 'StyledTypography' })( - ({ theme }) => ({ - alignItems: 'center', - display: 'flex', - padding: `${theme.spacingFunction(4)} 0`, - }) -); - -export const StyledLabel = styled(Box, { - label: 'StyledLabelBox', -})(({ theme }) => ({ - font: theme.font.bold, - marginRight: theme.spacingFunction(4), -})); - -const useStyles = makeStyles()((theme: Theme) => ({ - copyIcon: { - '& svg': { - height: '1em', - width: '1em', - }, - color: theme.palette.primary.main, - display: 'inline-block', - position: 'relative', - marginTop: theme.spacingFunction(2), - }, -})); export const FirewallRuleSetForm = React.memo( (props: FirewallRuleSetFormProps) => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx deleted file mode 100644 index 50a7547212a..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/RuleSetDetailsView.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box } from '@linode/ui'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; - -interface RuleSetDetailsViewProps { - ruleset: number; -} - -export const RuleSetDetailsView = (props: RuleSetDetailsViewProps) => { - const { ruleset } = props; - const prefixLists = ['pl:system:1', 'pl:system:2']; - - return ( - <> - Rule Set ID: {ruleset} - - {prefixLists.map((pl) => ( - <> - {}}> - {pl} - -   - - ))} - - - ); -}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts new file mode 100644 index 00000000000..ecdce3532a6 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -0,0 +1,50 @@ +import { Box, styled, Typography, WarningIcon } from '@linode/ui'; +import { makeStyles } from 'tss-react/mui'; + +import type { Theme } from '@linode/ui'; + +interface StyledListItemProps { + paddingMultiplier?: number; // optional, default 1 +} + +export const StyledListItem = styled(Typography, { + label: 'StyledTypography', +})(({ theme, paddingMultiplier = 1 }) => ({ + alignItems: 'center', + display: 'flex', + padding: `${theme.spacingFunction(4 * paddingMultiplier)} 0`, +})); + +export const StyledLabel = styled(Box, { + label: 'StyledLabelBox', +})(({ theme }) => ({ + font: theme.font.bold, + marginRight: theme.spacingFunction(4), +})); + +export const StyledWarningIcon = styled(WarningIcon, { + label: 'StyledWarningIcon', +})(({ theme }) => ({ + '& > path:nth-of-type(1)': { + fill: theme.tokens.alias.Content.Icon.Warning, + }, + '& > path:nth-of-type(2)': { + fill: theme.tokens.color.Neutrals[90], + }, + marginRight: theme.spacingFunction(4), + width: '16px', + height: '16px', +})); + +export const useStyles = makeStyles()((theme: Theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + marginTop: theme.spacingFunction(2), + }, +})); From 42cceb1f3cfc2b5d37b797f5e728e59d65b68f29 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 18 Nov 2025 23:40:11 +0530 Subject: [PATCH 44/78] Show only rulsets in dropdown applicable to the given catergory --- .../Rules/FirewallRuleSetForm.tsx | 43 ++++--------------- .../FirewallDetail/Rules/shared.styles.ts | 31 +++++++++++++ 2 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 9df58cc1a6c..f7e66a51789 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -7,12 +7,10 @@ import { Paper, SelectedIcon, Stack, - styled, Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -20,37 +18,9 @@ import { generateAddressesLabel, useIsFirewallRulesetsPrefixlistsEnabled, } from '../../shared'; +import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; -import type { Theme } from '@linode/ui'; - -const StyledListItem = styled(Typography, { label: 'StyledTypography' })( - ({ theme }) => ({ - alignItems: 'center', - display: 'flex', - padding: `${theme.spacingFunction(4)} 0`, - }) -); - -export const StyledLabel = styled(Box, { - label: 'StyledLabelBox', -})(({ theme }) => ({ - font: theme.font.bold, - marginRight: theme.spacingFunction(4), -})); - -const useStyles = makeStyles()((theme: Theme) => ({ - copyIcon: { - '& svg': { - height: '1em', - width: '1em', - }, - color: theme.palette.primary.main, - display: 'inline-block', - position: 'relative', - marginTop: theme.spacingFunction(2), - }, -})); export const FirewallRuleSetForm = React.memo( (props: FirewallRuleSetFormProps) => { @@ -85,10 +55,12 @@ export const FirewallRuleSetForm = React.memo( // Build dropdown options const ruleSetDropdownOptions = React.useMemo( () => - ruleSets.map((ruleSet) => ({ - label: ruleSet.label, - value: ruleSet.id, - })), + ruleSets + .filter((ruleSet) => ruleSet.type === category) // Display only rule sets applicable to the given category + .map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), [ruleSets] ); @@ -192,6 +164,7 @@ export const FirewallRuleSetForm = React.memo( key={`firewall-ruleset-rule-${idx}`} sx={(theme) => ({ padding: `${theme.spacingFunction(4)} 0`, + alignItems: 'flex-start', })} > ({ + alignItems: 'center', + display: 'flex', + padding: `${theme.spacingFunction(4)} 0`, + }) +); + +export const StyledLabel = styled(Box, { + label: 'StyledLabelBox', +})(({ theme }) => ({ + font: theme.font.bold, + marginRight: theme.spacingFunction(4), +})); + +export const useStyles = makeStyles()((theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + marginTop: theme.spacingFunction(2), + }, +})); From 0137d0650a353b1b53dbd233c64b7c0239dc823e Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 00:15:08 +0530 Subject: [PATCH 45/78] Update Date format and some minor changes --- .../Rules/FirewallRuleSetDetailsView.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 20980d776a1..969ddf4b1ff 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -4,6 +4,7 @@ import { capitalize } from '@linode/utilities'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { generateAddressesLabel, @@ -71,11 +72,15 @@ export const FirewallRuleSetDetailsView = ( Created: - {ruleSetDetails?.created} + {ruleSetDetails?.created && ( + + )} Updated: - {ruleSetDetails?.updated} + {ruleSetDetails?.updated && ( + + )} {ruleSetDetails?.deleted && ( @@ -118,7 +123,7 @@ export const FirewallRuleSetDetailsView = ( background: theme.tokens.alias.Background.Neutralsubtle, color: theme.tokens.alias.Content.Text.Negative, font: theme.font.bold, - width: '58px', + width: '51px', fontSize: theme.tokens.font.FontSize.Xxxs, marginRight: theme.spacingFunction(6), flexShrink: 0, @@ -132,10 +137,11 @@ export const FirewallRuleSetDetailsView = ( key={`firewall-ruleset-rule-${idx}`} sx={(theme) => ({ padding: `${theme.spacingFunction(4)} 0`, + alignItems: 'flex-start', })} > ({ background: rule.action === 'ACCEPT' @@ -146,7 +152,7 @@ export const FirewallRuleSetDetailsView = ( ? theme.tokens.alias.Content.Text.Positive : theme.tokens.alias.Content.Text.Negative, font: theme.font.bold, - width: '58px', + width: '51px', fontSize: theme.tokens.font.FontSize.Xxxs, marginRight: theme.spacingFunction(6), flexShrink: 0, From 9cc0aa288e1d617a607cd9de910d19c25b603b69 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 00:27:39 +0530 Subject: [PATCH 46/78] Update mark for deletion date format --- .../Rules/FirewallRuleSetDetailsView.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 969ddf4b1ff..c591bd7c19a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -84,13 +84,22 @@ export const FirewallRuleSetDetailsView = ( {ruleSetDetails?.deleted && ( - ({ color: theme.tokens.alias.Content.Text.Negative })} - > + - Marked for deletion: - {ruleSetDetails?.deleted} + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + > + Marked for deletion: + + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + value={ruleSetDetails.deleted} + /> Date: Wed, 19 Nov 2025 00:33:05 +0530 Subject: [PATCH 47/78] Update date format --- .../FirewallDetail/Rules/FirewallRuleSetForm.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index f7e66a51789..7465eeed29e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -13,6 +13,7 @@ import { capitalize } from '@linode/utilities'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { generateAddressesLabel, @@ -139,11 +140,15 @@ export const FirewallRuleSetForm = React.memo( Created: - {selectedRuleSet?.created} + {selectedRuleSet?.created && ( + + )} Updated: - {selectedRuleSet?.updated} + {selectedRuleSet?.updated && ( + + )} Date: Wed, 19 Nov 2025 17:59:41 +0530 Subject: [PATCH 48/78] Update badge color tokens --- .../FirewallDetail/Rules/FirewallRuleSetForm.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 7465eeed29e..a9c910e687b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -177,12 +177,14 @@ export const FirewallRuleSetForm = React.memo( sx={(theme) => ({ background: rule.action === 'ACCEPT' - ? theme.tokens.alias.Background.Positivesubtle - : theme.tokens.alias.Background.Negativesubtle, + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Negative.Subtle + .Background, color: rule.action === 'ACCEPT' - ? theme.tokens.alias.Content.Text.Positive - : theme.tokens.alias.Content.Text.Negative, + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Negative.Subtle.Text, font: theme.font.bold, width: '58px', fontSize: theme.tokens.font.FontSize.Xxxs, From d624461321d1a22d4e13c0f3b93d39a2fc0bc7b7 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 18:04:10 +0530 Subject: [PATCH 49/78] Capitalize action label in chip --- .../Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index a9c910e687b..1db76a701cd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -173,7 +173,7 @@ export const FirewallRuleSetForm = React.memo( })} > ({ background: rule.action === 'ACCEPT' From 0e1f3e07fa5078f930d3555dda6797a0846030d3 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 18:09:02 +0530 Subject: [PATCH 50/78] Update Chip width --- .../Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1db76a701cd..1b0401d56fc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -186,7 +186,7 @@ export const FirewallRuleSetForm = React.memo( ? theme.tokens.component.Badge.Positive.Subtle.Text : theme.tokens.component.Badge.Negative.Subtle.Text, font: theme.font.bold, - width: '58px', + width: '51px', fontSize: theme.tokens.font.FontSize.Xxxs, marginRight: theme.spacingFunction(6), flexShrink: 0, From 1d5fc461c03ddb8bbeef63934c27bec00ee63e2d Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 18:55:31 +0530 Subject: [PATCH 51/78] Added changeset: Update Firewall Rule Drawer to support referencing Rule Set --- .../.changeset/pr-13094-upcoming-features-1763558731421.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md diff --git a/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md b/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md new file mode 100644 index 00000000000..825c65ad24d --- /dev/null +++ b/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Firewall Rule Drawer to support referencing Rule Set ([#13094](https://github.com/linode/manager/pull/13094)) From ba5de42b8cd7a84b4e0b89f8730dd58c5141be6d Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 19:13:02 +0530 Subject: [PATCH 52/78] Use right color tokens for badge --- .../FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index c591bd7c19a..a4d3f05ff8a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -154,12 +154,12 @@ export const FirewallRuleSetDetailsView = ( sx={(theme) => ({ background: rule.action === 'ACCEPT' - ? theme.tokens.alias.Background.Positivesubtle - : theme.tokens.alias.Background.Negativesubtle, + ? theme.tokens.component.Badge.Positive.Subtle.Background + : theme.tokens.component.Badge.Negative.Subtle.Background, color: rule.action === 'ACCEPT' - ? theme.tokens.alias.Content.Text.Positive - : theme.tokens.alias.Content.Text.Negative, + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Negative.Subtle.Text, font: theme.font.bold, width: '51px', fontSize: theme.tokens.font.FontSize.Xxxs, From 3e1d0b62a98095b8cba8518ee7d81f45fd550df5 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 19:33:34 +0530 Subject: [PATCH 53/78] Update placeholder for Select Rule Set --- .../Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1b0401d56fc..bf8bc009258 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -89,7 +89,7 @@ export const FirewallRuleSetForm = React.memo( setFieldValue('ruleset', selectedRuleSet?.value); }} options={ruleSetDropdownOptions} - placeholder="Select a Rule Set" + placeholder="Type to search or select a Rule Set" renderOption={(props, option, { selected }) => { const { key, ...rest } = props; return ( From c560f64383a176208b763d534a4fbabdf1b601db Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 19 Nov 2025 19:42:14 +0530 Subject: [PATCH 54/78] Some clean up --- .../Rules/FirewallRuleSetDetailsView.tsx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index a4d3f05ff8a..3ee3424aae2 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -119,27 +119,6 @@ export const FirewallRuleSetDetailsView = ( > {capitalize(category)} Rules - ({ - color: theme.tokens.alias.Content.Text.Negative, - marginBottom: theme.spacingFunction(12), - })} - > - ({ - background: theme.tokens.alias.Background.Neutralsubtle, - color: theme.tokens.alias.Content.Text.Negative, - font: theme.font.bold, - width: '51px', - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, - })} - /> - Protocol;  Ports (if any);  Sources (IPs, PLs) - {ruleSetDetails?.rules.map((rule, idx) => ( Date: Thu, 20 Nov 2025 14:25:27 +0530 Subject: [PATCH 55/78] Few updates and clean up --- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 6 +++++- .../FirewallDetail/Rules/FirewallRuleTable.tsx | 16 +--------------- .../FirewallDetail/Rules/shared.styles.ts | 2 +- packages/manager/src/mocks/serverHandlers.ts | 2 +- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 3fbdb0f675a..556ae3d532f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -90,7 +90,11 @@ export const FirewallRuleDrawer = React.memo( }, [mode, isOpen, ruleToModify, isFirewallRulesetsPrefixlistsEnabled]); const title = - mode === 'create' ? `Add an ${capitalize(category)} Rule` : 'Edit Rule'; + mode === 'create' + ? `Add an ${capitalize(category)} Rule ${ + isFirewallRulesetsPrefixlistsEnabled ? ' or Rule Set' : '' + }` + : 'Edit Rule'; const addressesLabel = category === 'inbound' ? 'source' : 'destination'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index 2999b46c527..eedfe2cd508 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -23,7 +23,6 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { prop, uniqBy } from 'ramda'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import Undo from 'src/assets/icons/undo.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -55,13 +54,13 @@ import { StyledTableRow, } from './FirewallRuleTable.styles'; import { sortPortString } from './shared'; +import { useStyles } from './shared.styles'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; import type { ExtendedFirewallRule, RuleStatus } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; import type { DragEndEvent } from '@dnd-kit/core'; import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; -import type { Theme } from '@linode/ui'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; interface RuleRow { @@ -362,19 +361,6 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; - const useStyles = makeStyles()((theme: Theme) => ({ - copyIcon: { - '& svg': { - height: '1em', - width: '1em', - }, - color: theme.palette.primary.main, - display: 'inline-block', - position: 'relative', - marginTop: theme.spacingFunction(2), - }, - })); - const { classes } = useStyles(); if (isRuleSetLoading) { 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 863a5d1aa11..deace8a6d08 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -26,6 +26,6 @@ export const useStyles = makeStyles()((theme) => ({ color: theme.palette.primary.main, display: 'inline-block', position: 'relative', - marginTop: theme.spacingFunction(2), + marginTop: theme.spacingFunction(4), }, })); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4c98a005962..812395ad5f4 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1312,7 +1312,7 @@ export const handlers = [ inbound: [ firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall (ID 123) firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) - ...firewallRuleFactory.buildList(2), + ...firewallRuleFactory.buildList(1), ], }), }), From c0c9bc7f8ab925491b23710c732957d6a85f224c Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 20 Nov 2025 15:27:52 +0530 Subject: [PATCH 56/78] Make cy test work --- .../Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 556ae3d532f..81be722ccf1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -91,7 +91,7 @@ export const FirewallRuleDrawer = React.memo( const title = mode === 'create' - ? `Add an ${capitalize(category)} Rule ${ + ? `Add an ${capitalize(category)} Rule${ isFirewallRulesetsPrefixlistsEnabled ? ' or Rule Set' : '' }` : 'Edit Rule'; From 58d5247799ac524cc4908a5e3394dbfa0d4bf606 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 20 Nov 2025 15:40:46 +0530 Subject: [PATCH 57/78] Clean up: remove duplicate validation --- .../Rules/FirewallRuleDrawer.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 81be722ccf1..3e0bdeb2b5a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -98,23 +98,7 @@ export const FirewallRuleDrawer = React.memo( const addressesLabel = category === 'inbound' ? 'source' : 'destination'; - const onValidateRule = (values: FormRuleSetState | FormState) => { - // Case 1: user chose CREATE -> RULESET mode - // If we're in add 'ruleset' mode, only validate the ruleset field - if (mode === 'create' && createEntityType === 'ruleset') { - const errors: Record = {}; - if (!('ruleset' in values)) { - errors.ruleset = 'Rule Set is required.'; - } - if ('ruleset' in values && typeof values.ruleset !== 'number') { - errors.ruleset = 'Rule Set should be a number.'; - } - return errors; - } - - // Case 2: RULE mode - if (!('action' in values)) return {}; // safety fallback - + const onValidateRule = (values: FormState) => { const { addresses, description, label, ports, protocol } = values; // The validated IPs may have errors, so set them to state so we see the errors. From 24c870a604e59167d748ebcedeec2e99dd1f7228 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 20 Nov 2025 18:40:23 +0530 Subject: [PATCH 58/78] Add cancel btn for rules form + some design tokens for dropdown options --- .../Rules/FirewallRuleDrawer.tsx | 1 + .../Rules/FirewallRuleDrawer.types.ts | 1 + .../FirewallDetail/Rules/FirewallRuleForm.tsx | 5 +++++ .../Rules/FirewallRuleSetForm.tsx | 18 ++++++++++++++++-- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 3e0bdeb2b5a..cdf612bef2c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -206,6 +206,7 @@ export const FirewallRuleDrawer = React.memo( { addressesLabel: string; category: Category; + closeDrawer: () => void; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; presetPorts: FirewallOptionItem[]; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 58cc0e37e7b..c94082dd44b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -38,6 +38,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const { addressesLabel, category, + closeDrawer, errors, handleBlur, handleChange, @@ -341,6 +342,10 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { label: mode === 'create' ? 'Add Rule' : 'Add Changes', onClick: () => handleSubmit(), }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: closeDrawer, + }} /> ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index bf8bc009258..1c17492ad7e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -101,8 +101,22 @@ export const FirewallRuleSetForm = React.memo( width="100%" > - {option.label} - ID: {option.value} + ({ + // eslint-disable-next-line @linode/cloud-manager/no-custom-fontWeight + fontWeight: theme.tokens.font.FontWeight.Semibold, + })} + > + {option.label} + + ({ + color: + theme.tokens.component.Dropdown.Text.Description, + })} + > + ID: {option.value} + {selected && } From 43b48070e7412224da481bb325d6a9b37158c838 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 20 Nov 2025 23:14:23 +0530 Subject: [PATCH 59/78] Add cancel btn and some styling fixes --- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 1 + .../Rules/FirewallRuleSetDetailsView.tsx | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 6facc331d94..656b238c15e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -266,6 +266,7 @@ export const FirewallRuleDrawer = React.memo( {mode === 'view' && ( )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 3ee3424aae2..1b466b1eee9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -1,5 +1,5 @@ import { useFirewallRuleSetQuery } from '@linode/queries'; -import { Box, Chip, Paper, TooltipIcon } from '@linode/ui'; +import { ActionsPanel, Box, Chip, Paper, TooltipIcon } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; @@ -21,13 +21,14 @@ import type { Category } from './shared'; interface FirewallRuleSetDetailsViewProps { category: Category; + closeDrawer: () => void; ruleset: number; } export const FirewallRuleSetDetailsView = ( props: FirewallRuleSetDetailsViewProps ) => { - const { category, ruleset } = props; + const { category, closeDrawer, ruleset } = props; const { isFirewallRulesetsPrefixlistsEnabled } = useIsFirewallRulesetsPrefixlistsEnabled(); @@ -97,11 +98,16 @@ export const FirewallRuleSetDetailsView = ( ({ color: theme.tokens.alias.Content.Text.Negative, + marginRight: theme.spacingFunction(4), })} value={ruleSetDetails.deleted} /> @@ -151,6 +157,13 @@ export const FirewallRuleSetDetailsView = ( ))} + +
    ); }; From 904930597598fa19fc9704e774228bf33d60c5a9 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 20 Nov 2025 23:44:34 +0530 Subject: [PATCH 60/78] Add mocks for Marked for deletion status --- packages/manager/src/mocks/serverHandlers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 812395ad5f4..8daa94d9467 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1343,10 +1343,12 @@ export const handlers = [ id: 123, }); case 123456789: - // Ruleset with larger ID 123456789 & Longer label with 32 chars + // Ruleset with larger ID 123456789, Longer label with 32 chars, and + // Marked for deletion status return firewallRuleSetFactory.build({ id: 123456789, label: 'ruleset-with-a-longer-32ch-label', + deleted: '2025-11-18T18:51:11', }); default: return firewallRuleSetFactory.build(); From 042842bcdc188d32dd53c2e5cfaa92dd57b832d6 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 03:53:47 +0530 Subject: [PATCH 61/78] Add unit tests for Add Rule Set Drawer --- .../Rules/FirewallRuleDrawer.test.tsx | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) 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 30b54ba63bf..158afcaa5f5 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -40,7 +40,8 @@ const props: FirewallRuleDrawerProps = { describe('AddRuleDrawer', () => { it('renders the title', () => { const { getByText } = renderWithTheme( - + , + { flags: { firewallRulesetsPrefixlists: false } } ); getByText('Add an Inbound Rule'); }); @@ -66,6 +67,79 @@ describe('AddRuleDrawer', () => { }); }); +describe('AddRuleSetDrawer', () => { + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + , + { flags: { firewallRulesetsPrefixlists: true } } + ); + + expect(getByText('Add an Inbound Rule or Rule Set')).toBeVisible(); + }); + + it('renders the selection cards', () => { + const { getByText } = renderWithTheme( + , + { flags: { firewallRulesetsPrefixlists: true } } + ); + + expect(getByText(/Create a Rule/i)).toBeVisible(); + expect(getByText(/Reference Rule Set/i)).toBeVisible(); + }); + + it('renders the Rule Set form and its elements when selection card is clicked', async () => { + const { getByText, getByPlaceholderText, getByRole } = renderWithTheme( + , + { flags: { firewallRulesetsPrefixlists: true } } + ); + + const ruleSetCard = getByText(/Reference Rule Set/i); + await userEvent.click(ruleSetCard); + + // 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.' + ) + ).toBeVisible(); + + // Autocomplete field + expect(getByText('Rule Set')).toBeVisible(); + expect( + getByPlaceholderText('Type to search or select a Rule Set') + ).toBeVisible(); + + // Action buttons + expect(getByRole('button', { name: 'Add Rule' })).toBeVisible(); + expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); + + // Footer text + expect( + getByText( + 'Rule changes don’t take effect immediately. You can add or delete rules before saving all your changes to this Firewall.' + ) + ).toBeVisible(); + }); + + it('shows validation message when Rule Set field is blurred without a value', async () => { + const { getByText, getByRole } = renderWithTheme( + , + { flags: { firewallRulesetsPrefixlists: true } } + ); + + // Click the Rule Set Selection card to open the Rule Set form + const ruleSetCard = getByText(/Reference Rule Set/i); + await userEvent.click(ruleSetCard); + + // Click the "Add Rule" button without selecting the Autocomplete field + const addRuleButton = getByRole('button', { name: 'Add Rule' }); + await userEvent.click(addRuleButton); + + // Expect the validation message to appear + getByText('Rule Set is required.'); + }); +}); + describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { From 05d9d94fe586a7dfa410a5b1539fa285ecf8c792 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 04:09:46 +0530 Subject: [PATCH 62/78] Update test title --- .../Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 158afcaa5f5..ce371b4aa70 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -121,7 +121,7 @@ describe('AddRuleSetDrawer', () => { ).toBeVisible(); }); - it('shows validation message when Rule Set field is blurred without a value', async () => { + it('shows validation message when Rule Set form is submitted without selecting a value', async () => { const { getByText, getByRole } = renderWithTheme( , { flags: { firewallRulesetsPrefixlists: true } } From 21f481c19efec28eb808c167b936f9fbd3517af0 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 04:38:26 +0530 Subject: [PATCH 63/78] Mock useIsFirewallRulesetsPrefixlistsEnabled instead of feature flag --- .../Rules/FirewallRuleDrawer.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 ce371b4aa70..10f2c67b2a6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -5,6 +5,7 @@ import { allIPs } from 'src/features/Firewalls/shared'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import * as shared from '../../shared'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; import { classifyIPs, @@ -37,11 +38,14 @@ const props: FirewallRuleDrawerProps = { onSubmit: mockOnSubmit, }; +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + describe('AddRuleDrawer', () => { it('renders the title', () => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: false }); + const { getByText } = renderWithTheme( - , - { flags: { firewallRulesetsPrefixlists: false } } + ); getByText('Add an Inbound Rule'); }); @@ -68,10 +72,13 @@ describe('AddRuleDrawer', () => { }); describe('AddRuleSetDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); + }); + it('renders the drawer title', () => { const { getByText } = renderWithTheme( - , - { flags: { firewallRulesetsPrefixlists: true } } + ); expect(getByText('Add an Inbound Rule or Rule Set')).toBeVisible(); @@ -79,8 +86,7 @@ describe('AddRuleSetDrawer', () => { it('renders the selection cards', () => { const { getByText } = renderWithTheme( - , - { flags: { firewallRulesetsPrefixlists: true } } + ); expect(getByText(/Create a Rule/i)).toBeVisible(); @@ -89,8 +95,7 @@ describe('AddRuleSetDrawer', () => { it('renders the Rule Set form and its elements when selection card is clicked', async () => { const { getByText, getByPlaceholderText, getByRole } = renderWithTheme( - , - { flags: { firewallRulesetsPrefixlists: true } } + ); const ruleSetCard = getByText(/Reference Rule Set/i); @@ -123,8 +128,7 @@ describe('AddRuleSetDrawer', () => { it('shows validation message when Rule Set form is submitted without selecting a value', async () => { const { getByText, getByRole } = renderWithTheme( - , - { flags: { firewallRulesetsPrefixlists: true } } + ); // Click the Rule Set Selection card to open the Rule Set form From a2be4421ba3652f65cc2886fac4b1a56d6f5d5a5 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 13:09:43 +0530 Subject: [PATCH 64/78] Fix styling and a bit of clean up --- .../Rules/FirewallRuleSetForm.tsx | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index 1c17492ad7e..0b1283ba3a0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -131,39 +131,42 @@ export const FirewallRuleSetForm = React.memo( {selectedRuleSet && ( - - Label: - {selectedRuleSet?.label} - - - ID: - {selectedRuleSet?.id} - - - {selectedRuleSet?.description} - - Service Defined: - {selectedRuleSet?.is_service_defined ? 'Yes' : 'No'} - - - Version: - {selectedRuleSet?.version} - - - Created: - {selectedRuleSet?.created && ( - - )} - - - Updated: - {selectedRuleSet?.updated && ( - - )} - + {[ + { label: 'Label', value: selectedRuleSet.label }, + { label: 'ID', value: selectedRuleSet.id, copy: true }, + { label: null, value: selectedRuleSet.description }, + { + label: 'Service Defined', + value: selectedRuleSet.is_service_defined ? 'Yes' : 'No', + }, + { label: 'Version', value: selectedRuleSet.version }, + { + label: 'Created', + value: selectedRuleSet.created && ( + + ), + }, + { + label: 'Updated', + value: selectedRuleSet.updated && ( + + ), + }, + ].map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + {item.value} + + {item.copy && ( + + )} + + ))} ({ @@ -183,7 +186,6 @@ export const FirewallRuleSetForm = React.memo( key={`firewall-ruleset-rule-${idx}`} sx={(theme) => ({ padding: `${theme.spacingFunction(4)} 0`, - alignItems: 'flex-start', })} > - {rule.protocol}; {rule.ports};  - {generateAddressesLabel(rule.addresses)} + + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + ))} From b7467b5fad2aad0e5d718dbbe33d178c8fd1a7ed Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 16:16:05 +0530 Subject: [PATCH 65/78] Clean up and refactor --- .../Rules/FirewallRuleSetDetailsView.tsx | 90 ++++++++++--------- packages/manager/src/mocks/serverHandlers.ts | 2 + 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 1b466b1eee9..7ad9f5f3651 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -41,48 +41,53 @@ export const FirewallRuleSetDetailsView = ( return ( - - Label: - {ruleSetDetails?.label} - - - ID: - {ruleSetDetails?.id} - - - - Description - {ruleSetDetails?.description} - - - Service Defined: - {ruleSetDetails?.is_service_defined ? 'Yes' : 'No'} - - - Version: - {ruleSetDetails?.version} - - - Created: - {ruleSetDetails?.created && ( - - )} - - - Updated: - {ruleSetDetails?.updated && ( - - )} - + {[ + { label: 'Label', value: ruleSetDetails?.label }, + { label: 'ID', value: ruleSetDetails?.id, copy: true }, + { + label: 'Description', + value: ruleSetDetails?.description, + column: true, + }, + { + label: 'Service Defined', + value: ruleSetDetails?.is_service_defined ? 'Yes' : 'No', + }, + { label: 'Version', value: ruleSetDetails?.version }, + { + label: 'Created', + value: ruleSetDetails?.created && ( + + ), + }, + { + label: 'Updated', + value: ruleSetDetails?.updated && ( + + ), + }, + ].map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + {item.value} + {item.copy && ( + + )} + + ))} {ruleSetDetails?.deleted && ( @@ -107,6 +112,7 @@ export const FirewallRuleSetDetailsView = ( sxTooltipIcon={{ '& svg': { width: '16px', height: '16px' }, padding: 0, + mb: 0.1, }} text="This rule set will be automatically deleted when it’s no longer referenced by other firewalls." /> diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 8daa94d9467..1222f4987e7 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1349,6 +1349,8 @@ export const handlers = [ id: 123456789, label: 'ruleset-with-a-longer-32ch-label', deleted: '2025-11-18T18:51:11', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a fermentum quam. Mauris posuere dapibus aliquet. Ut id dictum magna, vitae congue turpis. Curabitur sollicitudin odio vel lacus vehicula maximus.', }); default: return firewallRuleSetFactory.build(); From 65c29aacf80d96f3e561bd62b42b2e9f16f18dae Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 17:19:18 +0530 Subject: [PATCH 66/78] Minor styling fixes --- .../FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 7ad9f5f3651..cc0c5a2ae59 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -137,7 +137,6 @@ export const FirewallRuleSetDetailsView = ( key={`firewall-ruleset-rule-${idx}`} sx={(theme) => ({ padding: `${theme.spacingFunction(4)} 0`, - alignItems: 'flex-start', })} > - {rule.protocol}; {rule.ports};  - {generateAddressesLabel(rule.addresses)} + + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + ))} From 5973080926f549657c40326c45ed602a3a0f5e5c Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 17:47:43 +0530 Subject: [PATCH 67/78] Add unit tests --- .../Rules/FirewallRuleDrawer.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 10f2c67b2a6..dccf475a318 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -144,6 +144,44 @@ describe('AddRuleSetDrawer', () => { }); }); +describe('ViewRuleSetDetailsDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); + }); + + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Inbound Rule Set details')).toBeVisible(); + }); + + it('renders Rule Set details Drawer labels and cancel button', () => { + const { getByText, getByRole } = renderWithTheme( + + ); + + const labels = [ + 'Label', + 'ID', + 'Description', + 'Service Defined', + 'Version', + 'Created', + 'Updated', + ]; + + labels.map((label) => expect(getByText(`${label}:`)).toBeVisible()); + + // Rule Set rules section label + expect(getByText(`Inbound Rules`)).toBeVisible(); + + // Cancel button + expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); + }); +}); + describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { From d4424e05af7bddcb746f7a359564d1d12ba9e1cf Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 18:43:20 +0530 Subject: [PATCH 68/78] Add omitted props for StyledListItem --- .../features/Firewalls/FirewallDetail/Rules/shared.styles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ea031b65c9e..72c73351fb4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -1,4 +1,4 @@ -import { Box, styled, Typography, WarningIcon } from '@linode/ui'; +import { Box, omittedProps, styled, Typography, WarningIcon } from '@linode/ui'; import { makeStyles } from 'tss-react/mui'; import type { Theme } from '@linode/ui'; @@ -9,6 +9,7 @@ interface StyledListItemProps { export const StyledListItem = styled(Typography, { label: 'StyledTypography', + shouldForwardProp: omittedProps(['paddingMultiplier']), })(({ theme, paddingMultiplier = 1 }) => ({ alignItems: 'center', display: 'flex', From 80a8a1d8d65dcaa8bae5298a1cd0450761038396 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 21 Nov 2025 23:10:38 +0530 Subject: [PATCH 69/78] Added changeset: New Rule Set Details drawer with Marked for Deletion status --- .../.changeset/pr-13108-upcoming-features-1763746838424.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md diff --git a/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md b/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md new file mode 100644 index 00000000000..cd7c25bc363 --- /dev/null +++ b/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +New Rule Set Details drawer with Marked for Deletion status ([#13108](https://github.com/linode/manager/pull/13108)) From a0e78d30dbd46f3a245b382d1bc091eb3f82dffb Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 13:09:45 +0530 Subject: [PATCH 70/78] Some clean up --- .../Rules/FirewallRuleSetDetailsView.tsx | 25 ++++------------ .../FirewallDetail/Rules/shared.styles.ts | 29 ++++++++++++++++++- .../Firewalls/FirewallDetail/Rules/shared.ts | 3 ++ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index cc0c5a2ae59..4d435b3067c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -1,5 +1,5 @@ import { useFirewallRuleSetQuery } from '@linode/queries'; -import { ActionsPanel, Box, Chip, Paper, TooltipIcon } from '@linode/ui'; +import { ActionsPanel, Box, Paper, TooltipIcon } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; @@ -10,7 +10,9 @@ import { generateAddressesLabel, useIsFirewallRulesetsPrefixlistsEnabled, } from '../../shared'; +import { RULESET_MARKED_FOR_DELETION_TEXT } from './shared'; import { + StyledChip, StyledLabel, StyledListItem, StyledWarningIcon, @@ -114,7 +116,7 @@ export const FirewallRuleSetDetailsView = ( padding: 0, mb: 0.1, }} - text="This rule set will be automatically deleted when it’s no longer referenced by other firewalls." + text={RULESET_MARKED_FOR_DELETION_TEXT} /> )} @@ -139,24 +141,9 @@ export const FirewallRuleSetDetailsView = ( padding: `${theme.spacingFunction(4)} 0`, })} > - ({ - background: - rule.action === 'ACCEPT' - ? theme.tokens.component.Badge.Positive.Subtle.Background - : theme.tokens.component.Badge.Negative.Subtle.Background, - color: - rule.action === 'ACCEPT' - ? theme.tokens.component.Badge.Positive.Subtle.Text - : theme.tokens.component.Badge.Negative.Subtle.Text, - font: theme.font.bold, - width: '51px', - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, - alignSelf: 'flex-start', - })} /> {rule.protocol}; {rule.ports};  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 72c73351fb4..391bf1ace37 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -1,6 +1,14 @@ -import { Box, omittedProps, styled, Typography, WarningIcon } from '@linode/ui'; +import { + Box, + Chip, + omittedProps, + styled, + Typography, + WarningIcon, +} from '@linode/ui'; import { makeStyles } from 'tss-react/mui'; +import type { FirewallPolicyType } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface StyledListItemProps { @@ -37,6 +45,25 @@ export const StyledWarningIcon = styled(WarningIcon, { height: '16px', })); +export const StyledChip = styled(Chip, { + shouldForwardProp: omittedProps(['action']), +})<{ action?: FirewallPolicyType | null }>(({ theme, action }) => ({ + background: + action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle.Background + : theme.tokens.component.Badge.Negative.Subtle.Background, + color: + action === 'ACCEPT' + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Negative.Subtle.Text, + font: theme.font.bold, + width: '51px', + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + alignSelf: 'flex-start', +})); + export const useStyles = makeStyles()((theme: Theme) => ({ copyIcon: { '& svg': { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 46d9b8aba2a..58cfe9f0a0c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -32,6 +32,9 @@ export const PORT_PRESETS_ITEMS = sortBy( Object.values(PORT_PRESETS) ); +export const RULESET_MARKED_FOR_DELETION_TEXT = + 'This rule set will be automatically deleted when it’s no longer referenced by other firewalls.'; + /** * The API returns very good Firewall error messages that look like this: * From afb285183f3713591440df71da97af46b0f9e333 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 13:53:51 +0530 Subject: [PATCH 71/78] Update unit tests --- .../Rules/FirewallRuleDrawer.test.tsx | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) 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 dccf475a318..a510072b2e3 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -22,7 +23,7 @@ import { PORT_PRESETS } from './shared'; import type { FirewallRuleDrawerProps } from './FirewallRuleDrawer.types'; import type { ExtendedFirewallRule } from './firewallRuleEditor'; -import type { FirewallRuleError } from './shared'; +import type { Category, FirewallRuleError } from './shared'; import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; const mockOnClose = vi.fn(); @@ -149,37 +150,38 @@ describe('ViewRuleSetDetailsDrawer', () => { spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); }); - it('renders the drawer title', () => { - const { getByText } = renderWithTheme( - - ); - - expect(getByText('Inbound Rule Set details')).toBeVisible(); - }); - - it('renders Rule Set details Drawer labels and cancel button', () => { - const { getByText, getByRole } = renderWithTheme( - - ); + it.each(['inbound', 'outbound'] as Category[])( + 'renders the %s view ruleset drawer', + (catergory) => { + const { getByText, getByRole } = renderWithTheme( + + ); - const labels = [ - 'Label', - 'ID', - 'Description', - 'Service Defined', - 'Version', - 'Created', - 'Updated', - ]; + // Renders the drawer title + expect( + getByText(`${capitalize(catergory)} Rule Set details`) + ).toBeVisible(); + + // Renders Rule Set details Drawer labels and cancel button + const labels = [ + 'Label', + 'ID', + 'Description', + 'Service Defined', + 'Version', + 'Created', + 'Updated', + ]; - labels.map((label) => expect(getByText(`${label}:`)).toBeVisible()); + labels.map((label) => expect(getByText(`${label}:`)).toBeVisible()); - // Rule Set rules section label - expect(getByText(`Inbound Rules`)).toBeVisible(); + // Rule Set rules section label + expect(getByText(`${capitalize(catergory)} Rules`)).toBeVisible(); - // Cancel button - expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); - }); + // Cancel button + expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); + } + ); }); describe('utilities', () => { From e02e98de5e12542ee7b12c18be1739ae84d66b26 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 13:56:36 +0530 Subject: [PATCH 72/78] Fix typo --- .../FirewallDetail/Rules/FirewallRuleDrawer.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 a510072b2e3..076f087e3ce 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -152,14 +152,14 @@ describe('ViewRuleSetDetailsDrawer', () => { it.each(['inbound', 'outbound'] as Category[])( 'renders the %s view ruleset drawer', - (catergory) => { + (category) => { const { getByText, getByRole } = renderWithTheme( - + ); // Renders the drawer title expect( - getByText(`${capitalize(catergory)} Rule Set details`) + getByText(`${capitalize(category)} Rule Set details`) ).toBeVisible(); // Renders Rule Set details Drawer labels and cancel button @@ -176,7 +176,7 @@ describe('ViewRuleSetDetailsDrawer', () => { labels.map((label) => expect(getByText(`${label}:`)).toBeVisible()); // Rule Set rules section label - expect(getByText(`${capitalize(catergory)} Rules`)).toBeVisible(); + expect(getByText(`${capitalize(category)} Rules`)).toBeVisible(); // Cancel button expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); From c3c26fdb864f5561537e9ba845ee373472c3e234 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 20:52:40 +0530 Subject: [PATCH 73/78] Make Rule Drawer accessible via routes --- .../Rules/FirewallRuleDrawer.tsx | 14 ++- .../Rules/FirewallRuleSetDetailsView.tsx | 27 ++++-- .../Rules/FirewallRuleTable.tsx | 18 ++-- .../Rules/FirewallRulesLanding.tsx | 90 ++++++++++++++----- 4 files changed, 115 insertions(+), 34 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 730deb3673c..20038d77a7b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -158,8 +158,18 @@ export const FirewallRuleDrawer = React.memo( return errors; }; + const drawerViewOrEditNotFoundError = + mode !== 'create' && ruleToModifyOrView === undefined + ? 'Not Found' + : null; + return ( - + {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( {firewallRuleCreateOptions.map((option) => ( @@ -265,7 +275,7 @@ export const FirewallRuleDrawer = React.memo( )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 4d435b3067c..d92ad53ece0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -1,5 +1,11 @@ import { useFirewallRuleSetQuery } from '@linode/queries'; -import { ActionsPanel, Box, Paper, TooltipIcon } from '@linode/ui'; +import { + ActionsPanel, + Box, + CircleProgress, + Paper, + TooltipIcon, +} from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; @@ -20,11 +26,12 @@ import { } from './shared.styles'; import type { Category } from './shared'; +import type { FirewallRuleType } from '@linode/api-v4'; interface FirewallRuleSetDetailsViewProps { category: Category; closeDrawer: () => void; - ruleset: number; + ruleset: FirewallRuleType['ruleset']; } export const FirewallRuleSetDetailsView = ( @@ -36,11 +43,21 @@ export const FirewallRuleSetDetailsView = ( useIsFirewallRulesetsPrefixlistsEnabled(); const { classes } = useStyles(); - const { data: ruleSetDetails } = useFirewallRuleSetQuery( - ruleset, - isFirewallRulesetsPrefixlistsEnabled + const { data: ruleSetDetails, isFetching } = useFirewallRuleSetQuery( + ruleset ?? -1, + ruleset !== undefined && + ruleset !== null && + isFirewallRulesetsPrefixlistsEnabled ); + if (isFetching) { + return ( + + + + ); + } + return ( {[ diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index adf608536bb..c7e2a2aeef0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -87,7 +87,7 @@ interface RowActionHandlers { handleCloneFirewallRule: (idx: number) => void; handleDeleteFirewallRule: (idx: number) => void; handleOpenRuleDrawerForEditing: (idx: number) => void; - handleOpenRuleSetDrawerForViewing?: (idx: number) => void; + handleOpenRuleSetDrawerForViewing?: (ruleset: number) => void; handleReorder: (startIdx: number, endIdx: number) => void; handleUndo: (idx: number) => void; } @@ -320,7 +320,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { const { data: rulesetDetails, isLoading: isRuleSetLoading } = useFirewallRuleSetQuery( ruleset ?? -1, - ruleset !== undefined && isRuleSetRowEnabled + ruleset !== undefined && ruleset !== null && isRuleSetRowEnabled ); const actionMenuProps = { @@ -425,7 +425,11 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {isRuleSetRowEnabled && ( <> - + { /> {rulesetDetails && ( handleOpenRuleSetDrawerForViewing?.(index)} + onClick={() => + handleOpenRuleSetDrawerForViewing?.(rulesetDetails.id) + } > {rulesetDetails?.label} )} - + - ID:  + {rulesetDetails ? 'ID:' : 'Rule Set ID:'}  {ruleset} { const location = useLocation(); const { enqueueSnackbar } = useSnackbar(); + const defaultDrawerModeFromPathName = location.pathname.includes('/edit') + ? 'edit' + : location.pathname.includes('/view') + ? 'view' + : 'create'; + + const params = useParams({ strict: false }); + /** * inbound and outbound policy aren't part of any particular rule * so they are managed separately rather than through the reducer. @@ -84,8 +100,9 @@ export const FirewallRulesLanding = React.memo((props: Props) => { * Component state and handlers */ const [ruleDrawer, setRuleDrawer] = React.useState({ - category: 'inbound', - mode: 'create', + category: (params.category ?? 'inbound') as Category, + mode: defaultDrawerModeFromPathName, + ruleIdx: Number(params.ruleId ?? -1), }); const [submitting, setSubmitting] = React.useState(false); // @todo fine-grained error handling. @@ -95,15 +112,19 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const [discardChangesModalOpen, setDiscardChangesModalOpen] = React.useState(false); - const openRuleDrawer = ( - category: Category, - mode: FirewallRuleDrawerMode, - idx?: number - ) => { + const openRuleDrawer = (options: { + category: Category; + entityType?: RulesDrawerEntityType; + idx?: number; + mode: FirewallRuleDrawerMode; + }) => { + const { category, mode, idx, entityType = 'rule' } = options; + setRuleDrawer({ category, mode, ruleIdx: idx, + entityType, }); let path: string; @@ -330,13 +351,16 @@ export const FirewallRulesLanding = React.memo((props: Props) => { [outboundState] ); - // This is for the Rule Drawer. If there is a rule to modify, + // This is for the Rule Drawer. If there is a rule to modify or view, // we need to pass it to the drawer to pre-populate the form fields. + const rulesByCategory = + ruleDrawer.category === 'inbound' ? inboundRules : outboundRules; + const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined - ? ruleDrawer.category === 'inbound' - ? inboundRules[ruleDrawer.ruleIdx] - : outboundRules[ruleDrawer.ruleIdx] + ? ruleDrawer.entityType === 'rule' + ? rulesByCategory[ruleDrawer.ruleIdx] // find rule by rule index + : rulesByCategory.find((r) => r.ruleset === ruleDrawer.ruleIdx) // Find ruleset by ruleset id : undefined; return ( @@ -388,17 +412,29 @@ export const FirewallRulesLanding = React.memo((props: Props) => { } handleDeleteFirewallRule={(idx) => handleDeleteRule('inbound', idx)} handleOpenRuleDrawerForEditing={(idx: number) => - openRuleDrawer('inbound', 'edit', idx) + openRuleDrawer({ + category: 'inbound', + mode: 'edit', + idx, + entityType: 'rule', + }) } - handleOpenRuleSetDrawerForViewing={(idx: number) => - openRuleDrawer('inbound', 'view', idx) + handleOpenRuleSetDrawerForViewing={(ruleset: number) => + openRuleDrawer({ + category: 'inbound', + mode: 'view', + idx: ruleset, + entityType: 'ruleset', + }) } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('inbound', startIdx, endIdx) } handleUndo={(idx) => handleUndo('inbound', idx)} - openRuleDrawer={openRuleDrawer} + openRuleDrawer={(category, mode) => { + openRuleDrawer({ category, mode }); + }} policy={policy.inbound} rulesWithStatus={inboundRules} /> @@ -412,17 +448,29 @@ export const FirewallRulesLanding = React.memo((props: Props) => { } handleDeleteFirewallRule={(idx) => handleDeleteRule('outbound', idx)} handleOpenRuleDrawerForEditing={(idx: number) => - openRuleDrawer('outbound', 'edit', idx) + openRuleDrawer({ + category: 'outbound', + mode: 'edit', + idx, + entityType: 'rule', + }) } - handleOpenRuleSetDrawerForViewing={(idx: number) => - openRuleDrawer('outbound', 'view', idx) + handleOpenRuleSetDrawerForViewing={(ruleset: number) => + openRuleDrawer({ + category: 'outbound', + mode: 'view', + idx: ruleset, + entityType: 'ruleset', + }) } handlePolicyChange={handlePolicyChange} handleReorder={(startIdx: number, endIdx: number) => handleReorder('outbound', startIdx, endIdx) } handleUndo={(idx) => handleUndo('outbound', idx)} - openRuleDrawer={openRuleDrawer} + openRuleDrawer={(category, mode) => { + openRuleDrawer({ category, mode }); + }} policy={policy.outbound} rulesWithStatus={outboundRules} /> From f6187866fb9f36afa5cded6c94ca5eac4dc50740 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 22:38:09 +0530 Subject: [PATCH 74/78] Improve tests --- .../Rules/FirewallRuleDrawer.test.tsx | 91 ++++++++++++++++--- .../Rules/FirewallRulesLanding.tsx | 4 +- 2 files changed, 79 insertions(+), 16 deletions(-) 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 076f087e3ce..4ef1dcc01ea 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -2,6 +2,7 @@ import { capitalize } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { firewallRuleSetFactory } from 'src/factories'; import { allIPs } from 'src/features/Firewalls/shared'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -19,12 +20,27 @@ import { validateForm, validateIPs, } from './FirewallRuleDrawer.utils'; -import { PORT_PRESETS } from './shared'; +import { PORT_PRESETS, RULESET_MARKED_FOR_DELETION_TEXT } from './shared'; import type { FirewallRuleDrawerProps } from './FirewallRuleDrawer.types'; import type { ExtendedFirewallRule } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; -import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; +import type { + FirewallPolicyType, + FirewallRuleSet, +} from '@linode/api-v4/lib/firewalls/types'; + +const queryMocks = vi.hoisted(() => ({ + useFirewallRuleSetQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useFirewallRuleSetQuery: queryMocks.useFirewallRuleSetQuery, + }; +}); const mockOnClose = vi.fn(); const mockOnSubmit = vi.fn(); @@ -150,19 +166,46 @@ describe('ViewRuleSetDetailsDrawer', () => { spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); }); - it.each(['inbound', 'outbound'] as Category[])( - 'renders the %s view ruleset drawer', - (category) => { - const { getByText, getByRole } = renderWithTheme( - - ); + const activeRuleSet = firewallRuleSetFactory.build({ id: 123 }); + const deletedRuleSet = firewallRuleSetFactory.build({ + id: 456, + deleted: '2025-11-18T18:51:11', + }); - // Renders the drawer title + it.each([ + ['inbound', activeRuleSet], + ['outbound', activeRuleSet], + ['inbound', deletedRuleSet], + ['outbound', deletedRuleSet], + ] as [Category, FirewallRuleSet][])( + 'renders %s ruleset drawer (%s)', + async (category, mockData) => { + queryMocks.useFirewallRuleSetQuery.mockReturnValue({ + data: mockData, + isFetching: false, + error: null, + }); + + const { getByText, getByRole, getByTestId, findByText, queryByText } = + renderWithTheme( + + ); + + // Drawer title expect( getByText(`${capitalize(category)} Rule Set details`) ).toBeVisible(); - // Renders Rule Set details Drawer labels and cancel button + // Labels const labels = [ 'Label', 'ID', @@ -172,10 +215,30 @@ describe('ViewRuleSetDetailsDrawer', () => { 'Created', 'Updated', ]; - - labels.map((label) => expect(getByText(`${label}:`)).toBeVisible()); - - // Rule Set rules section label + labels.forEach((label) => expect(getByText(`${label}:`)).toBeVisible()); + + // Check ID value + expect(getByText(`${mockData.id}`)).toBeVisible(); + + if (mockData.deleted) { + // Marked for deletion status section + expect(getByText('Marked for deletion:')).toBeVisible(); + expect(getByText('2025-11-19 00:21')).toBeVisible(); + // Tooltip icon should exist + const tooltipIcon = getByTestId('tooltip-info-icon'); + expect(tooltipIcon).toBeInTheDocument(); + + // Tooltip text should exist + await userEvent.hover(tooltipIcon); + expect( + await findByText(RULESET_MARKED_FOR_DELETION_TEXT) + ).toBeVisible(); + } else { + // Marked for deletion status section should not exist + expect(queryByText('Marked for deletion:')).not.toBeInTheDocument(); + } + + // Rules section expect(getByText(`${capitalize(category)} Rules`)).toBeVisible(); // Cancel button diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 480885c0707..012b1ac1d09 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -351,11 +351,11 @@ export const FirewallRulesLanding = React.memo((props: Props) => { [outboundState] ); - // This is for the Rule Drawer. If there is a rule to modify or view, - // we need to pass it to the drawer to pre-populate the form fields. const rulesByCategory = ruleDrawer.category === 'inbound' ? inboundRules : outboundRules; + // This is for the Rule Drawer. If there is a rule to modify or view, + // we need to pass it to the drawer to pre-populate the form fields. const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined ? ruleDrawer.entityType === 'rule' From 27cf8419771e8b6e80df793e832b5558d5abf580 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 24 Nov 2025 23:15:02 +0530 Subject: [PATCH 75/78] Add error state --- .../Rules/FirewallRuleSetDetailsView.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index d92ad53ece0..03429ae6138 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -3,6 +3,7 @@ import { ActionsPanel, Box, CircleProgress, + ErrorState, Paper, TooltipIcon, } from '@linode/ui'; @@ -43,7 +44,12 @@ export const FirewallRuleSetDetailsView = ( useIsFirewallRulesetsPrefixlistsEnabled(); const { classes } = useStyles(); - const { data: ruleSetDetails, isFetching } = useFirewallRuleSetQuery( + const { + data: ruleSetDetails, + isFetching, + isError, + error, + } = useFirewallRuleSetQuery( ruleset ?? -1, ruleset !== undefined && ruleset !== null && @@ -58,6 +64,10 @@ export const FirewallRuleSetDetailsView = ( ); } + if (isError) { + return ; + } + return ( {[ From 17a0058e6265dcfa26c2212d01eb6783f1f857cd Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 25 Nov 2025 00:59:39 +0530 Subject: [PATCH 76/78] Mock getUserTimezone --- .../FirewallDetail/Rules/FirewallRuleDrawer.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 4ef1dcc01ea..f9c1ab27b22 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -42,6 +42,14 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); + return { + ...actual, + getUserTimezone: vi.fn().mockReturnValue('utc'), + }; +}); + const mockOnClose = vi.fn(); const mockOnSubmit = vi.fn(); @@ -169,7 +177,7 @@ describe('ViewRuleSetDetailsDrawer', () => { const activeRuleSet = firewallRuleSetFactory.build({ id: 123 }); const deletedRuleSet = firewallRuleSetFactory.build({ id: 456, - deleted: '2025-11-18T18:51:11', + deleted: '2025-07-24T04:23:17', }); it.each([ @@ -223,7 +231,7 @@ describe('ViewRuleSetDetailsDrawer', () => { if (mockData.deleted) { // Marked for deletion status section expect(getByText('Marked for deletion:')).toBeVisible(); - expect(getByText('2025-11-19 00:21')).toBeVisible(); + expect(getByText('2025-07-24 04:23')).toBeVisible(); // Tooltip icon should exist const tooltipIcon = getByTestId('tooltip-info-icon'); expect(tooltipIcon).toBeInTheDocument(); From 1af2bb82fd33c5560b25745b6970ac789edde6b8 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 25 Nov 2025 02:39:25 +0530 Subject: [PATCH 77/78] Some Clean up and allow route-based access only for view and create modes --- .../Rules/FirewallRulesLanding.tsx | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 012b1ac1d09..ae20a00fcb7 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -67,13 +67,17 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const location = useLocation(); const { enqueueSnackbar } = useSnackbar(); - const defaultDrawerModeFromPathName = location.pathname.includes('/edit') - ? 'edit' - : location.pathname.includes('/view') - ? 'view' - : 'create'; + const getCategoryFromPath = (pathname: string): Category => + pathname.includes('inbound') ? 'inbound' : 'outbound'; + + const getDrawerEntityTypeFromPath = ( + pathname: string + ): RulesDrawerEntityType => + pathname.includes('/ruleset') ? 'ruleset' : 'rule'; const params = useParams({ strict: false }); + const category = getCategoryFromPath(location.pathname); + const entityType = getDrawerEntityTypeFromPath(location.pathname); /** * inbound and outbound policy aren't part of any particular rule @@ -99,11 +103,19 @@ export const FirewallRulesLanding = React.memo((props: Props) => { /** * Component state and handlers */ - const [ruleDrawer, setRuleDrawer] = React.useState({ - category: (params.category ?? 'inbound') as Category, - mode: defaultDrawerModeFromPathName, - ruleIdx: Number(params.ruleId ?? -1), - }); + + // - Initialize the drawer state based on the current route. + // - Drawers can be accessed via the route ONLY for viewing rulesets or adding rules/rulesets. + // - Accessing the Edit Rule drawer via the route is not allowed (for now), + // since individual rules don't have unique IDs and are part of drag-and-drop feature. + const initialDrawer: Drawer = { + category, + mode: entityType === 'ruleset' ? 'view' : 'create', + entityType: entityType === 'ruleset' ? entityType : undefined, + ruleIdx: entityType === 'ruleset' ? Number(params.ruleId) : undefined, + }; + + const [ruleDrawer, setRuleDrawer] = React.useState(initialDrawer); const [submitting, setSubmitting] = React.useState(false); // @todo fine-grained error handling. const [generalErrors, setGeneralErrors] = React.useState< @@ -358,9 +370,9 @@ export const FirewallRulesLanding = React.memo((props: Props) => { // we need to pass it to the drawer to pre-populate the form fields. const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined - ? ruleDrawer.entityType === 'rule' - ? rulesByCategory[ruleDrawer.ruleIdx] // find rule by rule index - : rulesByCategory.find((r) => r.ruleset === ruleDrawer.ruleIdx) // Find ruleset by ruleset id + ? ruleDrawer.entityType === 'ruleset' + ? rulesByCategory.find((r) => r.ruleset === ruleDrawer.ruleIdx) // Find ruleset by ruleset id + : rulesByCategory[ruleDrawer.ruleIdx] // find rule by rule index : undefined; return ( From 19f4b8bdd0c833df197601dbf248910da60973e7 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 25 Nov 2025 03:09:29 +0530 Subject: [PATCH 78/78] Few changes --- .../FirewallDetail/Rules/FirewallRuleDrawer.tsx | 12 +----------- .../Rules/FirewallRuleSetDetailsView.tsx | 11 ++++++++--- .../FirewallDetail/Rules/FirewallRuleTable.tsx | 4 +++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 20038d77a7b..5000f6c192f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -158,18 +158,8 @@ export const FirewallRuleDrawer = React.memo( return errors; }; - const drawerViewOrEditNotFoundError = - mode !== 'create' && ruleToModifyOrView === undefined - ? 'Not Found' - : null; - return ( - + {mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && ( {firewallRuleCreateOptions.map((option) => ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 03429ae6138..167163726ae 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -4,6 +4,7 @@ import { Box, CircleProgress, ErrorState, + NotFound, Paper, TooltipIcon, } from '@linode/ui'; @@ -44,6 +45,8 @@ export const FirewallRuleSetDetailsView = ( useIsFirewallRulesetsPrefixlistsEnabled(); const { classes } = useStyles(); + const isValidRuleSetId = ruleset !== undefined && ruleset !== null; + const { data: ruleSetDetails, isFetching, @@ -51,11 +54,13 @@ export const FirewallRuleSetDetailsView = ( error, } = useFirewallRuleSetQuery( ruleset ?? -1, - ruleset !== undefined && - ruleset !== null && - isFirewallRulesetsPrefixlistsEnabled + isValidRuleSetId && isFirewallRulesetsPrefixlistsEnabled ); + if (!isValidRuleSetId) { + return ; + } + if (isFetching) { return ( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index c7e2a2aeef0..9f24a04580f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -317,10 +317,12 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { const isRuleSetRowEnabled = isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; + const isValidRuleSetId = ruleset !== undefined && ruleset !== null; + const { data: rulesetDetails, isLoading: isRuleSetLoading } = useFirewallRuleSetQuery( ruleset ?? -1, - ruleset !== undefined && ruleset !== null && isRuleSetRowEnabled + isValidRuleSetId && isRuleSetRowEnabled ); const actionMenuProps = {