From 0a318080dc890b1c94781d874b2b439eb9be02e3 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:20:47 +0100 Subject: [PATCH 01/91] fix: [UIE-9633] - IAM: users table aria-label fix (#13082) * fix: [UIE-9633] - IAM: users table aria-label fix * Added changeset: IAM: the aria-label for the Users table action menu displays an incorrect username --------- Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- packages/manager/.changeset/pr-13082-fixed-1762957507469.md | 5 +++++ .../src/features/IAM/Users/UsersTable/UsersActionMenu.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13082-fixed-1762957507469.md diff --git a/packages/manager/.changeset/pr-13082-fixed-1762957507469.md b/packages/manager/.changeset/pr-13082-fixed-1762957507469.md new file mode 100644 index 00000000000..004e1cb6473 --- /dev/null +++ b/packages/manager/.changeset/pr-13082-fixed-1762957507469.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: the aria-label for the Users table action menu displays an incorrect username ([#13082](https://github.com/linode/manager/pull/13082)) diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index defaca1680f..fcf5920de61 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -109,7 +109,7 @@ export const UsersActionMenu = (props: Props) => { return ( ); }; From dab739db4db8daa9fa82f3b0dd6b17bb873a112c Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Thu, 13 Nov 2025 13:15:56 -0500 Subject: [PATCH 02/91] refactor: [UIE-9324] - Replace Formik with React Hook Form in MaintenanceWindow (#13060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Refactor Database MaintenanceWindow to use React Hook Form instead of Formik ### Verification steps (How to verify changes) - [ ] Go to a Database Cluster's details page, then click on the Settings tab - [ ] Test setting the maintenance window for Aiven and legacy dbs, error messages, etc. There should be no regressions compared to Prod --- packages/api-v4/src/databases/types.ts | 4 +- .../pr-13060-tech-stories-1762357716157.md | 5 + .../DatabaseSettings/DatabaseSettings.tsx | 2 +- .../DatabaseSettings/MaintenanceWindow.tsx | 553 +++++++++--------- packages/validation/src/databases.schema.ts | 7 + 5 files changed, 289 insertions(+), 282 deletions(-) create mode 100644 packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 694493768e3..80611783fa2 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -163,12 +163,10 @@ interface ConnectionStrings { value: string; } -export type UpdatesFrequency = 'monthly' | 'weekly'; - export interface UpdatesSchedule { day_of_week: number; duration: number; - frequency: UpdatesFrequency; + frequency: 'monthly' | 'weekly'; hour_of_day: number; pending?: PendingUpdates[]; week_of_month: null | number; diff --git a/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md b/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md new file mode 100644 index 00000000000..04211c454c6 --- /dev/null +++ b/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Formik with React Hook Form in MaintenanceWindow ([#13060](https://github.com/linode/manager/pull/13060)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 5b559edd92e..d9b8c332039 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -27,7 +27,7 @@ import { DatabaseSettingsMaintenance } from './DatabaseSettingsMaintenance'; import DatabaseSettingsMenuItem from './DatabaseSettingsMenuItem'; import DatabaseSettingsResetPasswordDialog from './DatabaseSettingsResetPasswordDialog'; import { DatabaseSettingsSuspendClusterDialog } from './DatabaseSettingsSuspendClusterDialog'; -import MaintenanceWindow from './MaintenanceWindow'; +import { MaintenanceWindow } from './MaintenanceWindow'; export const DatabaseSettings = () => { const { database, disabled } = useDatabaseDetailContext(); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index a40bfd06db4..ba944b7927e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -1,3 +1,4 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { Autocomplete, @@ -6,64 +7,23 @@ import { Notice, Radio, RadioGroup, + Stack, TooltipIcon, Typography, } from '@linode/ui'; +import { updateMaintenanceSchema } from '@linode/validation'; +import { styled } from '@mui/material/styles'; import { Button } from 'akamai-cds-react-components'; -import { useFormik } from 'formik'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { useWatch } from 'react-hook-form'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import type { Database, UpdatesSchedule } from '@linode/api-v4/lib/databases'; -import type { APIError } from '@linode/api-v4/lib/types'; import type { SelectOption } from '@linode/ui'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - formControlDropdown: { - '& label': { - overflow: 'visible', - }, - marginRight: '3rem', - minWidth: '125px', - }, - sectionButton: { - alignSelf: 'end', - marginBottom: '1rem', - marginTop: '1rem', - minWidth: 214, - [theme.breakpoints.down('md')]: { - alignSelf: 'flex-start', - }, - }, - sectionText: { - [theme.breakpoints.down('md')]: { - marginBottom: '1rem', - }, - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '65%', - }, - sectionTitle: { - marginBottom: '0.25rem', - }, - sectionTitleAndText: { - width: '100%', - }, - topSection: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - [theme.breakpoints.down('lg')]: { - flexDirection: 'column', - }, - }, -})); interface Props { database: Database; @@ -74,17 +34,9 @@ interface Props { export const MaintenanceWindow = (props: Props) => { const { database, disabled, timezone } = props; - const [maintenanceUpdateError, setMaintenanceUpdateError] = - React.useState(); - - // This will be set to `true` once a form field has been touched. This is used to disable the - // "Save Changes" button unless there have been changes to the form. - const [formTouched, setFormTouched] = React.useState(false); - const [modifiedWeekSelectionMap, setModifiedWeekSelectionMap] = React.useState[]>([]); - const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: updateDatabase } = useDatabaseMutation( @@ -116,32 +68,21 @@ export const MaintenanceWindow = (props: Props) => { weekSelectionModifier(dayOfWeek.label, weekSelectionMap); }, []); - const handleSaveMaintenanceWindow = ( - values: Omit, - { - setSubmitting, - }: { - setSubmitting: (isSubmitting: boolean) => void; - } - ) => { + const onSubmit = async (values: Partial) => { // @TODO Update this to only send 'updates' and not 'allow_list' when the API supports it. // Additionally, at that time, enable the validationSchema which currently does not work // because allow_list is a required field in the schema. - updateDatabase({ - allow_list: database.allow_list, - updates: values as UpdatesSchedule, - }) - .then(() => { - setSubmitting(false); - enqueueSnackbar('Maintenance Window settings saved successfully.', { - variant: 'success', - }); - setFormTouched(false); - }) - .catch((e: APIError[]) => { - setMaintenanceUpdateError(e); - setSubmitting(false); + try { + await updateDatabase({ + allow_list: database.allow_list, + updates: values as UpdatesSchedule, + }); + enqueueSnackbar('Maintenance Window settings saved successfully.', { + variant: 'success', }); + } catch (errors) { + setError('root', { message: errors[0].reason }); + } }; const utcOffsetInHours = timezone @@ -155,17 +96,29 @@ export const MaintenanceWindow = (props: Props) => { return null; }; - const { errors, handleSubmit, isSubmitting, setFieldValue, touched, values } = - useFormik({ - initialValues: { - day_of_week: database.updates?.day_of_week ?? 1, - frequency: database.updates?.frequency ?? 'weekly', - hour_of_day: database.updates?.hour_of_day ?? 20, - week_of_month: getInitialWeekOfMonth(), - }, - // validationSchema: updateDatabaseSchema, - onSubmit: handleSaveMaintenanceWindow, - }); + const form = useForm>({ + defaultValues: { + day_of_week: database.updates?.day_of_week ?? 1, + frequency: database.updates?.frequency ?? 'weekly', + hour_of_day: database.updates?.hour_of_day ?? 20, + week_of_month: getInitialWeekOfMonth(), + }, + mode: 'onBlur', + resolver: yupResolver(updateMaintenanceSchema), + }); + + const { + control, + formState: { isSubmitting, isDirty, errors }, + handleSubmit, + setValue, + setError, + } = form; + + const [dayOfWeek, hourOfDay, frequency, weekOfMonth] = useWatch({ + control, + name: ['day_of_week', 'hour_of_day', 'frequency', 'week_of_month'], + }); const isLegacy = database.platform === 'rdbms-legacy'; @@ -176,202 +129,215 @@ export const MaintenanceWindow = (props: Props) => { "OS and database engine updates will be performed on the schedule below. Select the frequency, day, and time you'd prefer maintenance to occur."; return ( -
-
-
- - {isLegacy - ? 'Maintenance Window' - : 'Set a Weekly Maintenance Window'} - - {maintenanceUpdateError ? ( - - {maintenanceUpdateError[0].reason} - - ) : null} - - {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} - {database.cluster_size !== 3 - ? 'For non-HA plans, expect downtime during this window.' - : null} - -
- - - option.value === value.value - } - label="Day of Week" - noMarginTop - onChange={(_, day) => { - setFormTouched(true); - setFieldValue('day_of_week', day.value); - weekSelectionModifier(day.label, weekSelectionMap); - // If week_of_month is not null (i.e., the user has selected a value for "Repeats on" already), - // refresh the field value so that the selected option displays the chosen day. - if (values.week_of_month) { - setFieldValue('week_of_month', values.week_of_month); - } - }} - options={daySelectionMap} - placeholder="Choose a day" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-weekday-select': true, - }, - }} - value={daySelectionMap.find( - (thisOption) => thisOption.value === values.day_of_week - )} - /> -
    - -
    - option.value === 20 - )} - disableClearable - disabled={disabled} - errorText={ - touched.hour_of_day ? errors.hour_of_day : undefined - } - label="Time" - noMarginTop - onChange={(_, hour) => { - setFormTouched(true); - setFieldValue('hour_of_day', hour?.value); - }} - options={hourSelectionMap} - placeholder="Choose a time" - renderOption={(props, option) => ( -
  • {option.label}
  • + + + + + + {isLegacy + ? 'Maintenance Window' + : 'Set a Weekly Maintenance Window'} + + {errors.root?.message && ( + + {errors.root?.message} + + )} + + {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} + {database.cluster_size !== 3 && + 'For non-HA plans, expect downtime during this window.'} + + + + ( + + option.value === value.value + } + label="Day of Week" + noMarginTop + onChange={(_, day) => { + field.onChange(day.value); + weekSelectionModifier(day.label, weekSelectionMap); + }} + options={daySelectionMap} + placeholder="Choose a day" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-weekday-select': true, + }, + }} + value={daySelectionMap.find( + (thisOption) => thisOption.value === dayOfWeek + )} + /> )} - textFieldProps={{ - dataAttrs: { - 'data-qa-time-select': true, - }, - }} - value={hourSelectionMap.find( - (thisOption) => thisOption.value === values.hour_of_day - )} - /> - - UTC is {utcOffsetText(utcOffsetInHours)} hours compared to - your local timezone. Click{' '} - here to view or change - your timezone settings. - - } /> -
    -
    -
    - {isLegacy && ( - ) => { - setFormTouched(true); - setFieldValue('frequency', e.target.value); - if (e.target.value === 'weekly') { - // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. - setFieldValue('week_of_month', null); - } + + +
    + ( + option.value === 20 + )} + disableClearable + disabled={disabled} + errorText={fieldState.error?.message} + label="Time" + noMarginTop + onChange={(_, hour) => { + field.onChange(hour?.value); + }} + options={hourSelectionMap} + placeholder="Choose a time" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-time-select': true, + }, + }} + value={hourSelectionMap.find( + (thisOption) => thisOption.value === hourOfDay + )} + /> + )} + /> + + UTC is {utcOffsetText(utcOffsetInHours)} hours compared + to your local timezone. Click{' '} + here to view or + change your timezone settings. + + } + /> +
    +
    + + {isLegacy && ( + ( + ) => { + field.onChange(e.target.value); + if (e.target.value === 'weekly') { + // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. + setValue('week_of_month', null); + } - if (e.target.value === 'monthly') { - const dayOfWeek = - daySelectionMap.find( - (option) => option.value === values.day_of_week - ) ?? daySelectionMap[0]; + if (e.target.value === 'monthly') { + const _dayOfWeek = + daySelectionMap.find( + (option) => option.value === dayOfWeek + ) ?? daySelectionMap[0]; - weekSelectionModifier(dayOfWeek.label, weekSelectionMap); - setFieldValue( - 'week_of_month', - modifiedWeekSelectionMap[0].value - ); - } - }} - > - - {maintenanceFrequencyMap.map((option) => ( - } - key={option.value} - label={option.key} - value={option.value} - /> - ))} - - - )} -
    - {values.frequency === 'monthly' ? ( - - { - setFormTouched(true); - setFieldValue('week_of_month', week?.value); - }} - options={modifiedWeekSelectionMap} - placeholder="Repeats on" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-week-in-month-select': true, - }, - }} - value={modifiedWeekSelectionMap.find( - (thisOption) => thisOption.value === values.week_of_month + weekSelectionModifier( + _dayOfWeek.label, + weekSelectionMap + ); + setValue( + 'week_of_month', + modifiedWeekSelectionMap[0].value + ); + } + }} + > + + {maintenanceFrequencyMap.map((option) => ( + } + key={option.value} + label={option.key} + value={option.value} + /> + ))} + +
    + )} + /> + )} +
    + {frequency === 'monthly' && ( + ( + + { + field.onChange(week.value); + }} + options={modifiedWeekSelectionMap} + placeholder="Repeats on" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-week-in-month-select': true, + }, + }} + value={modifiedWeekSelectionMap.find( + (thisOption) => thisOption.value === weekOfMonth + )} + /> +
    )} /> - - ) : null} -
    -
    - -
    - + )} +
    + + + + + + + ); }; @@ -436,4 +402,35 @@ const utcOffsetText = (utcOffsetInHours: number) => { : `-${utcOffsetInHours}`; }; -export default MaintenanceWindow; +const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginBottom: '1rem', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + width: '65%', +})); + +const StyledStack = styled(Stack, { + label: 'StyledStack', +})(({ theme }) => ({ + flexDirection: 'row', + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + }, +})); + +const StyledButtonStack = styled(Stack, { + label: 'StyledButtonStack', +})(({ theme }) => ({ + alignSelf: 'end', + marginBottom: '1rem', + marginTop: '1rem', + minWidth: 214, + [theme.breakpoints.down('md')]: { + alignSelf: 'flex-start', + }, +})); diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 6e1dc16382a..5cbead4508d 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -28,6 +28,13 @@ export const getDynamicDatabaseSchema = (isVPCSelected: boolean) => { }); }; +export const updateMaintenanceSchema = object({ + frequency: string().oneOf(['weekly', 'monthly']).optional(), + hour_of_day: number(), + day_of_week: number(), + week_of_month: number().nullable(), +}); + export const updateDatabaseSchema = object({ label: string().notRequired().min(3, LABEL_MESSAGE).max(32, LABEL_MESSAGE), allow_list: array().of(string()).notRequired(), From 616459fa57ddeb5b056e7b9237c67fcfc21a33ff Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:49:15 +0530 Subject: [PATCH 03/91] Upcoming: [UIE-9538]- Disable premium plan tab if corresponding g7 dedicated plans are available (#13081) * upcoming: [UIE-9538]- Disable premium plan tab if g7 dedicated plans available * Added changeset * Making use of new GCP flag and address review comments --- ...r-13081-upcoming-features-1762935895154.md | 5 +++ .../e2e/core/linodes/clone-linode.spec.ts | 1 + .../e2e/core/linodes/plan-selection.spec.ts | 13 ++++++-- .../manager/src/dev-tools/FeatureFlagTool.tsx | 2 +- packages/manager/src/featureFlags.ts | 6 +++- .../KubernetesPlansPanel.tsx | 33 ++++++++++++++++++- .../features/Linodes/LinodeCreate/Plan.tsx | 11 +++++++ .../components/PlansPanel/PlansPanel.tsx | 20 ++++++++++- .../features/components/PlansPanel/utils.ts | 18 ++++++++++ .../presets/crud/handlers/linodes/linodes.ts | 8 +++-- .../manager/src/utilities/linodes.test.ts | 8 +++-- packages/manager/src/utilities/linodes.ts | 3 +- packages/utilities/src/factories/linodes.ts | 14 ++++++++ 13 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md diff --git a/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md b/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md new file mode 100644 index 00000000000..b65763c0fc7 --- /dev/null +++ b/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Disable premium plan tab if corresponding g7 dedicated plans are available ([#13081](https://github.com/linode/manager/pull/13081)) diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 6fbb52b8e7d..43dce38bf2c 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -70,6 +70,7 @@ describe('clone linode', () => { beforeEach(() => { mockAppendFeatureFlags({ linodeInterfaces: { enabled: false }, + generationalPlansv2: { enabled: false, allowedPlans: [] }, }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 8b47a8bb2ed..b6c680627cc 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -86,6 +86,14 @@ const mockGPUType = [ }), ]; +const mockPremiumType = [ + linodeTypeFactory.build({ + class: 'premium', + id: 'premium-8', + label: 'Premium 8GB', + }), +]; + const mockAcceleratedType = [ linodeTypeFactory.build({ class: 'accelerated', @@ -99,6 +107,7 @@ const mockLinodeTypes = [ ...mockHighMemoryLinodeTypes, ...mockSharedLinodeTypes, ...mockGPUType, + ...mockPremiumType, ...mockAcceleratedType, ]; @@ -225,7 +234,7 @@ describe('displays linode plans panel based on availability', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[id="g7-premium-64"]').should('be.disabled'); cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); @@ -355,7 +364,7 @@ describe('displays kubernetes plans panel based on availability', () => { cy.get(notices.unavailable).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 2); + cy.findAllByRole('row').should('have.length', 3); cy.get('[data-qa-plan-row="Premium 512 GB"]').should( 'have.attr', 'disabled' diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index b6c0bf419fb..d38d32995a2 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -35,7 +35,7 @@ const options: { flag: keyof Flags; label: string }[] = [ label: 'Firewall Rulesets & Prefixlists', }, { flag: 'gecko2', label: 'Gecko' }, - { flag: 'generationalPlans', label: 'Generational compute plans' }, + { flag: 'generationalPlansv2', label: 'Generational compute plans' }, { flag: 'limitsEvolution', label: 'Limits Evolution' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ce42d10a2e6..d3aec9ff50f 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -202,7 +202,7 @@ export interface Flags { disableLargestGbPlans: boolean; firewallRulesetsPrefixlists: boolean; gecko2: GeckoFeatureFlag; - generationalPlans: boolean; + generationalPlansv2: GenerationalPlansFlag; gpuv2: GpuV2; iam: BetaFeatureFlag; iamDelegation: BaseFeatureFlag; @@ -386,3 +386,7 @@ export type AclpServices = { metrics?: AclpFlag; }; }; + +interface GenerationalPlansFlag extends BaseFeatureFlag { + allowedPlans: string[]; +} diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 6ded2e38371..4d7b7255034 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -11,6 +11,7 @@ import { isMTCPlan, planTabInfoContent, replaceOrAppendPlaceholder512GbPlans, + useShouldDisablePremiumPlansTab, } from 'src/features/components/PlansPanel/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -82,6 +83,10 @@ export const KubernetesPlansPanel = (props: Props) => { Boolean(flags.soldOutChips) && Boolean(selectedRegionId) ); + const shouldDisablePremiumPlansTab = useShouldDisablePremiumPlansTab({ + types, + }); + const isPlanDisabledByAPL = (plan: 'shared' | LinodeTypeClass) => plan === 'shared' && Boolean(isAPLEnabled); @@ -109,7 +114,7 @@ export const KubernetesPlansPanel = (props: Props) => { { isLKE: true } ); - const tabs = Object.keys(plans).map( + const tabs = Object.keys(plans)?.map( (plan: Exclude) => { const plansMap: PlanSelectionType[] = plans[plan]!; const { @@ -125,6 +130,7 @@ export const KubernetesPlansPanel = (props: Props) => { }); return { + disabled: false, render: () => { return ( <> @@ -170,6 +176,26 @@ export const KubernetesPlansPanel = (props: Props) => { currentPlanHeading ); + // If there are no premium plans available, plans table will hide the premium tab. + // To override this behavior, we add the tab again and then disable it. + // If there are plans but they should be disabled, we disable the existing tab. + if ( + shouldDisablePremiumPlansTab && + !tabs.some((tab) => tab.title === planTabInfoContent.premium?.title) + ) { + tabs.push({ + disabled: true, + render: () =>
    , + title: planTabInfoContent.premium?.title, + }); + } else if (shouldDisablePremiumPlansTab) { + tabs.forEach((tab) => { + if (tab.title === planTabInfoContent.premium?.title) { + tab.disabled = true; + } + }); + } + return ( { initTab={initialTab >= 0 ? initialTab : 0} notice={notice} sx={{ padding: 0 }} + tabDisabledMessage={ + shouldDisablePremiumPlansTab + ? 'Premium CPUs are now called Dedicated G7 Plans.' + : undefined + } tabs={tabs} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx index 497c04cd73b..f6996a7120a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Plan.tsx @@ -4,6 +4,7 @@ import { useController, useWatch } from 'react-hook-form'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; +import { useShouldDisablePremiumPlansTab } from 'src/features/components/PlansPanel/utils'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { sendLinodeCreateFlowDocsClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; @@ -28,10 +29,15 @@ export const Plan = () => { const { data: permissions } = usePermissions('account', ['create_linode']); + const shouldDisablePremiumPlansTab = useShouldDisablePremiumPlansTab({ + types, + }); + return ( { selectedId={field.value} selectedRegionID={regionId} showLimits + tabDisabledMessage={ + shouldDisablePremiumPlansTab + ? 'Premium CPUs are now called Dedicated G7 Plans.' + : undefined + } types={types?.map(extendType) ?? []} // @todo don't extend type /> ); diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 4a38be20d5d..7b1491c52ff 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -26,6 +26,7 @@ import { planTabInfoContent, replaceOrAppendPlaceholder512GbPlans, useIsAcceleratedPlansEnabled, + useShouldDisablePremiumPlansTab, } from './utils'; import type { PlanSelectionType } from './types'; @@ -111,6 +112,10 @@ export const PlansPanel = (props: PlansPanelProps) => { Boolean(flags.soldOutChips) && Boolean(selectedRegionID) ); + const shouldDisablePremiumPlansTab = useShouldDisablePremiumPlansTab({ + types, + }); + const _types = types.filter((type) => { if (!isAcceleratedLinodePlansEnabled && type.class === 'accelerated') { return false; @@ -162,7 +167,7 @@ export const PlansPanel = (props: PlansPanelProps) => { const isDatabaseResize = flow === 'database' && isResize; - const tabs = Object.keys(plans).map( + const tabs = Object.keys(plans)?.map( (plan: Exclude) => { const plansMap: PlanSelectionType[] = plans[plan]!; const { @@ -255,6 +260,19 @@ export const PlansPanel = (props: PlansPanelProps) => { ); } + // If there are no premium plans available, plans table will hide the premium tab. + // To override this behavior, we add the tab again and then disable it. + if ( + shouldDisablePremiumPlansTab && + !tabs.some((tab) => tab.title === planTabInfoContent.premium?.title) + ) { + tabs.push({ + disabled: true, + render: () =>
    , + title: planTabInfoContent.premium?.title, + }); + } + return ( { + const { isGenerationalPlansEnabled, allowedPlans } = + useIsGenerationalPlansEnabled(); + // Check if any public premium plans are available. + // We can omit "Premium HT" and "Premium nested" plans as customers don't deploy them using cloud manager. + const arePublicPremiumPlansAvailable = types?.some( + (plan) => plan.class === 'premium' && allowedPlans.includes(plan.id) + ); + + return Boolean(isGenerationalPlansEnabled) && !arePublicPremiumPlansAvailable; +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts index 9188a5478fb..310ba9c0dc5 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes/linodes.ts @@ -17,7 +17,8 @@ import { linodeStatsFactory, linodeTransferFactory, linodeTypeFactory, - premiumTypeFactory, + premiumHTTypeFactory, + premiumNestedTypeFactory, } from '@linode/utilities'; import { DateTime } from 'luxon'; import { http } from 'msw'; @@ -105,7 +106,10 @@ export const getLinodePlans = () => [ const gpuTypesAda = gpuTypeAdaFactory.buildList(20); const gpuTypesRtx = gpuTypeRtxFactory.buildList(20); const gpuTypesRtxPro = gpuTypeRtxProFactory.buildList(20); - const premiumTypes = premiumTypeFactory.buildList(6); + const premiumTypes = [ + premiumNestedTypeFactory.build(), + premiumHTTypeFactory.build(), + ]; const acceleratedType = acceleratedTypeFactory.buildList(7); const mockPlans = [ nanodeType, diff --git a/packages/manager/src/utilities/linodes.test.ts b/packages/manager/src/utilities/linodes.test.ts index 2bc56c34d14..3716e3929ea 100644 --- a/packages/manager/src/utilities/linodes.test.ts +++ b/packages/manager/src/utilities/linodes.test.ts @@ -82,7 +82,9 @@ describe('useIsLinodeCloneFirewallEnabled', () => { describe('useIsGenerationalPlansEnabled', () => { it('returns isGenerationalPlansEnabled: true if the feature is enabled', () => { - const options = { flags: { generationalPlans: true } }; + const options = { + flags: { generationalPlansv2: { enabled: true, allowedPlans: [] } }, + }; const { result } = renderHook(() => useIsGenerationalPlansEnabled(), { wrapper: (ui) => wrapWithTheme(ui, options), @@ -92,7 +94,9 @@ describe('useIsGenerationalPlansEnabled', () => { }); it('returns isGenerationalPlansEnabled: false if the feature is NOT enabled', () => { - const options = { flags: { generationalPlans: false } }; + const options = { + flags: { generationalPlansv2: { enabled: false, allowedPlans: [] } }, + }; const { result } = renderHook(() => useIsGenerationalPlansEnabled(), { wrapper: (ui) => wrapWithTheme(ui, options), diff --git a/packages/manager/src/utilities/linodes.ts b/packages/manager/src/utilities/linodes.ts index 35061e9b6bf..d2357b7dd0f 100644 --- a/packages/manager/src/utilities/linodes.ts +++ b/packages/manager/src/utilities/linodes.ts @@ -97,6 +97,7 @@ export const useIsGenerationalPlansEnabled = () => { const flags = useFlags(); return { - isGenerationalPlansEnabled: Boolean(flags.generationalPlans), + isGenerationalPlansEnabled: Boolean(flags.generationalPlansv2?.enabled), + allowedPlans: flags.generationalPlansv2?.allowedPlans || [], }; }; diff --git a/packages/utilities/src/factories/linodes.ts b/packages/utilities/src/factories/linodes.ts index 7e66d2e7c95..09db77265fc 100644 --- a/packages/utilities/src/factories/linodes.ts +++ b/packages/utilities/src/factories/linodes.ts @@ -444,6 +444,20 @@ export const gpuTypeRtxProFactory = linodeTypeFactory.extend({ export const premiumTypeFactory = linodeTypeFactory.extend({ class: 'premium', + id: Factory.each((i) => `g7-premium-${i}`), + label: Factory.each((i) => `Premium ${i}GB`), +}); + +export const premiumNestedTypeFactory = linodeTypeFactory.extend({ + class: 'premium', + id: 'g7-premium-112', + label: 'Premium Nested 112GB', +}); + +export const premiumHTTypeFactory = linodeTypeFactory.extend({ + class: 'premium', + id: 'g7-premium-ht-256', + label: 'Premium HT 256GB', }); export const acceleratedTypeFactory = linodeTypeFactory.extend({ From b167091669a9579c123a5ab5490adae563f3136f Mon Sep 17 00:00:00 2001 From: dmcintyr-akamai Date: Fri, 14 Nov 2025 09:12:31 -0500 Subject: [PATCH 04/91] test [UIE-9634-]: Fix flakey vm-host test (#13083) * fix flakey test * Added changeset: Fix flakey vm-host test --- .../pr-13083-tests-1762969859293.md | 5 +++ .../vm-host-maintenance-linode.spec.ts | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 packages/manager/.changeset/pr-13083-tests-1762969859293.md diff --git a/packages/manager/.changeset/pr-13083-tests-1762969859293.md b/packages/manager/.changeset/pr-13083-tests-1762969859293.md new file mode 100644 index 00000000000..9b91e05e390 --- /dev/null +++ b/packages/manager/.changeset/pr-13083-tests-1762969859293.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix flakey vm-host test ([#13083](https://github.com/linode/manager/pull/13083)) diff --git a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts index 47a0266d53f..6c6cab74d15 100644 --- a/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/vm-host-maintenance-linode.spec.ts @@ -4,6 +4,7 @@ import { mockGetNotifications } from 'support/intercepts/events'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetLinode, mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -34,6 +35,8 @@ const mockMaintenanceScheduled = accountMaintenanceFactory.build({ type: 'reboot', description: 'scheduled', maintenance_policy_set: 'linode/power_off_on', + reason: + "Your Linode's host has reached the end of its life cycle and will be retired.", status: 'scheduled', start_time: '2022-01-17T23:45:46.960', }); @@ -48,6 +51,7 @@ const mockMaintenanceEmergency = accountMaintenanceFactory.build({ type: 'cold_migration', description: 'emergency', maintenance_policy_set: 'linode/power_off_on', + reason: "We must upgrade the OS of your Linode's host.", status: 'scheduled', }); @@ -107,6 +111,38 @@ describe('Host & VM maintenance notification banner', () => { }); }); + it('maintenance notification banner does not display platform maintenance messages', function () { + const mockPlatformMaintenance = accountMaintenanceFactory.build({ + entity: { + id: mockLinodes[1].id, + label: mockLinodes[1].label, + type: 'linode', + url: `/v4/linode/instances/${mockLinodes[1].id}`, + }, + type: 'reboot', + description: 'emergency', + maintenance_policy_set: 'linode/power_off_on', + // 'critical security update' in reason prevents message from displaying + reason: "We must apply a critical security update to your Linode's host.", + status: 'scheduled', + }); + mockGetMaintenance([mockPlatformMaintenance], []).as('getMaintenances'); + cy.visitWithLogin('/linodes'); + cy.wait([ + '@getLinodes', + '@getFeatureFlags', + '@getNotifications', + '@getMaintenances', + ]); + cy.get('#main-content').within(() => { + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + cy.get('[data-testid="maintenance-banner').should('not.exist'); + }); + }); + it('banner present on details page when linode has pending maintenance', function () { const mockLinode = mockLinodes[0]; mockGetLinode(mockLinode.id, mockLinode).as('getLinode'); From b52ecdf20bc27e7c3c97d966c3220deaf0e163e2 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:08:43 -0500 Subject: [PATCH 05/91] fix: DBaaS Maintenance Window Save Changes not disabled (#13096) Fix `update-database.spec.ts` test failing due to the Save Changes button staying enabled after submitting changes (missed during the refactor to react-hook-form in https://github.com/linode/manager/pull/13060). Also included a minor styling fix. --- .../DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index ba944b7927e..880d90e4152 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -80,6 +80,8 @@ export const MaintenanceWindow = (props: Props) => { enqueueSnackbar('Maintenance Window settings saved successfully.', { variant: 'success', }); + // reset dirty state to disable Save Changes button + reset(getValues(), { keepValues: true, keepDirty: false }); } catch (errors) { setError('root', { message: errors[0].reason }); } @@ -110,7 +112,9 @@ export const MaintenanceWindow = (props: Props) => { const { control, formState: { isSubmitting, isDirty, errors }, + getValues, handleSubmit, + reset, setValue, setError, } = form; @@ -131,7 +135,7 @@ export const MaintenanceWindow = (props: Props) => { return (
    - + {isLegacy @@ -417,6 +421,7 @@ const StyledTypography = styled(Typography, { const StyledStack = styled(Stack, { label: 'StyledStack', })(({ theme }) => ({ + justifyContent: 'space-between', flexDirection: 'row', [theme.breakpoints.down('md')]: { flexDirection: 'column', From 51a3b2f43e1d7e4cb0da609812924efc318cb75f Mon Sep 17 00:00:00 2001 From: Ankita Date: Mon, 17 Nov 2025 09:53:49 +0530 Subject: [PATCH 06/91] upcoming: [DI-28175] - Add linode_id to label conversion for blockstorage for display in widget legend rows (#13092) * upcoming: [DI-28175] - Add linode_id to label conversion for blockstorage * upcoming: [DI-28175] - fallback fix * upcoming: [DI-28175] - Add mocks * upcoming: [DI-28175] - Add changeset --- ...r-13092-upcoming-features-1763093527692.md | 5 +++ .../Utils/CloudPulseWidgetUtils.test.ts | 35 +++++++++++++++++++ .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 14 ++++++-- .../shared/CloudPulseResourcesSelect.tsx | 1 + packages/manager/src/mocks/serverHandlers.ts | 33 ++++++++++++----- .../src/queries/cloudpulse/resources.ts | 1 + 6 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md diff --git a/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md b/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md new file mode 100644 index 00000000000..3d6050b3391 --- /dev/null +++ b/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Enhance `CloudPulseWidgetUtils.ts` to handle id to label conversion of linode associated with volume in volumes service ([#13092](https://github.com/linode/manager/pull/13092)) diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index a4af7fa538f..7caed886877 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -255,6 +255,41 @@ describe('getDimensionName method', () => { const result = getDimensionName(props); expect(result).toBe('linode-1 | test | primary-1'); }); + + it('returns the linode label when key is linode_id and service type is firewall', () => { + const props: DimensionNameProperties = { + ...baseProps, + metric: { linode_id: '123' }, + serviceType: 'firewall', + resources: [ + { + id: '123', + label: 'Firewall-1', + entities: { '123': 'linode-1' }, + }, + ], + }; + const result = getDimensionName(props); + expect(result).toBe('linode-1'); + }); + + it('returns the volume linode label when key is linode_id and service type is blockstorage', () => { + const props: DimensionNameProperties = { + ...baseProps, + metric: { linode_id: '123' }, + serviceType: 'blockstorage', + resources: [ + { + id: '123', + label: 'Volume-1', + volumeLinodeId: '123', + volumeLinodeLabel: 'linode-1', + }, + ], + }; + const result = getDimensionName(props); + expect(result).toBe('linode-1'); + }); }); it('test mapResourceIdToName method', () => { diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 337d225dd5b..786cc2906ea 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -456,9 +456,17 @@ export const getDimensionName = (props: DimensionNameProperties): string => { } if (key === 'linode_id') { - const linodeLabel = - resources.find((resource) => resource.entities?.[value] !== undefined) - ?.entities?.[value] ?? value; + let linodeLabel = value; + if (serviceType === 'firewall') { + linodeLabel = + resources.find((resource) => resource.entities?.[value] !== undefined) + ?.entities?.[value] ?? linodeLabel; + } + if (serviceType === 'blockstorage') { + linodeLabel = + resources.find((resource) => resource.volumeLinodeId === value) + ?.volumeLinodeLabel ?? linodeLabel; + } const index = groupBy.indexOf('linode_id'); if (index !== -1) { labels[index] = linodeLabel; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index a6b234c03cc..13c07000735 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -24,6 +24,7 @@ export interface CloudPulseResources { region?: string; tags?: string[]; volumeLinodeId?: string; + volumeLinodeLabel?: null | string; } export interface CloudPulseResourcesSelectProps { diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 28833ad984d..be460a52f25 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1676,22 +1676,37 @@ export const handlers = [ 'resizing', ]; const volumes = statuses.map((status) => - volumeFactory.build({ status, region: 'ap-west', linode_id: 1 }) + volumeFactory.build({ + status, + id: 1, + region: 'ap-west', + linode_id: 1, + linode_label: 'linode-1', + }) ); volumes.push( - ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 2 }) + ...volumeFactory.buildList(1, { region: 'us-east', linode_id: 2 }) ); volumes.push( - ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 3 }) + ...volumeFactory.buildList(1, { region: 'us-east', linode_id: 3 }) ); volumes.push( - ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 4 }) + ...volumeFactory.buildList(1, { region: 'us-east', linode_id: 4 }) ); volumes.push( - ...volumeFactory.buildList(2, { region: 'us-east', linode_id: 5 }) + ...volumeFactory.buildList(1, { region: 'us-east', linode_id: 5 }) ); volumes.push( - ...volumeFactory.buildList(5, { region: 'eu-central', linode_id: 1 }) + ...volumeFactory.buildList(1, { region: 'eu-central', linode_id: 6 }) + ); + volumes.push( + ...volumeFactory.buildList(1, { + id: 7, + label: 'volume-7', + region: 'ap-west', + linode_id: 7, + linode_label: 'linode-7', + }) ); return HttpResponse.json(makeResourcePage(volumes)); }), @@ -3873,7 +3888,7 @@ export const handlers = [ result: [ { metric: { - entity_id: '123', + entity_id: '1', metric_name: 'average_cpu_usage', linode_id: '1', node_id: 'primary-1', @@ -3917,9 +3932,9 @@ export const handlers = [ // })), { metric: { - entity_id: '456', + entity_id: '7', metric_name: 'average_cpu_usage', - linode_id: '123', + linode_id: '7', node_id: 'primary-2', }, values: [ diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 405b5ea8820..0f629381345 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -63,6 +63,7 @@ export const useResourcesQuery = ( entities, clusterSize: resource.cluster_size, volumeLinodeId: String(resource.linode_id), + volumeLinodeLabel: resource.linode_label, }; }); }, From 98018bfb3454334a2e6e5e095abdfa673cc3da53 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 17 Nov 2025 12:35:17 +0530 Subject: [PATCH 07/91] =?UTF-8?q?upcoming:=20[UIE-9558]=20-=20Add=20new=20?= =?UTF-8?q?API=20endpoints,=20types=20and=20queries=20for=20N=E2=80=A6=20(?= =?UTF-8?q?#13078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * upcoming: [UIE-9558] - Add new API endpoints, types and queries for Network Load Balancer * Adding changesets * Fix type specification for NLB. * Use named parameter interfaces for api-v4 client functions with 3+ arguments. --- ...r-13078-upcoming-features-1762869732236.md | 5 + packages/api-v4/src/index.ts | 2 + packages/api-v4/src/netloadbalancers/index.ts | 4 + .../api-v4/src/netloadbalancers/listeners.ts | 45 ++++++++ .../src/netloadbalancers/netloadbalancers.ts | 33 ++++++ packages/api-v4/src/netloadbalancers/nodes.ts | 62 +++++++++++ packages/api-v4/src/netloadbalancers/types.ts | 97 +++++++++++++++++ ...r-13078-upcoming-features-1762869790174.md | 5 + packages/queries/src/index.ts | 1 + .../queries/src/netloadbalancers/index.ts | 5 + packages/queries/src/netloadbalancers/keys.ts | 101 ++++++++++++++++++ .../queries/src/netloadbalancers/listeners.ts | 48 +++++++++ .../src/netloadbalancers/netloadbalancers.ts | 82 ++++++++++++++ .../queries/src/netloadbalancers/nodes.ts | 52 +++++++++ .../queries/src/netloadbalancers/requests.ts | 53 +++++++++ 15 files changed, 595 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md create mode 100644 packages/api-v4/src/netloadbalancers/index.ts create mode 100644 packages/api-v4/src/netloadbalancers/listeners.ts create mode 100644 packages/api-v4/src/netloadbalancers/netloadbalancers.ts create mode 100644 packages/api-v4/src/netloadbalancers/nodes.ts create mode 100644 packages/api-v4/src/netloadbalancers/types.ts create mode 100644 packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md create mode 100644 packages/queries/src/netloadbalancers/index.ts create mode 100644 packages/queries/src/netloadbalancers/keys.ts create mode 100644 packages/queries/src/netloadbalancers/listeners.ts create mode 100644 packages/queries/src/netloadbalancers/netloadbalancers.ts create mode 100644 packages/queries/src/netloadbalancers/nodes.ts create mode 100644 packages/queries/src/netloadbalancers/requests.ts diff --git a/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md b/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md new file mode 100644 index 00000000000..f671a53b5a9 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add new API endpoints and types for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index 00e09bce480..3b90f57ec71 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -28,6 +28,8 @@ export * from './longview'; export * from './managed'; +export * from './netloadbalancers'; + export * from './network-transfer'; export * from './networking'; diff --git a/packages/api-v4/src/netloadbalancers/index.ts b/packages/api-v4/src/netloadbalancers/index.ts new file mode 100644 index 00000000000..7ef573ac031 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/index.ts @@ -0,0 +1,4 @@ +export * from './listeners'; +export * from './netloadbalancers'; +export * from './nodes'; +export * from './types'; diff --git a/packages/api-v4/src/netloadbalancers/listeners.ts b/packages/api-v4/src/netloadbalancers/listeners.ts new file mode 100644 index 00000000000..4f9db29fae8 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/listeners.ts @@ -0,0 +1,45 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancerListener } from './types'; + +/** + * getNetworkLoadBalancerListeners + * + * Returns a paginated list of listeners for a Network Load Balancer. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + */ +export const getNetworkLoadBalancerListeners = ( + networkLoadBalancerId: number, + params?: Params, + filters?: Filter, +) => + Request>( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancerListener + * + * Returns detailed information about a single listener. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener to retrieve. + */ +export const getNetworkLoadBalancerListener = ( + networkLoadBalancerId: number, + listenerId: number, +) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/netloadbalancers.ts b/packages/api-v4/src/netloadbalancers/netloadbalancers.ts new file mode 100644 index 00000000000..faca6f073b5 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/netloadbalancers.ts @@ -0,0 +1,33 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancer } from './types'; + +/** + * getNetworkLoadBalancers + * + * Returns a paginated list of Network Load Balancers on your account. + */ +export const getNetworkLoadBalancers = (params?: Params, filters?: Filter) => + Request>( + setURL(`${BETA_API_ROOT}/netloadbalancers`), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancer + * + * Returns detailed information about a single Network Load Balancer. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer to retrieve. + */ +export const getNetworkLoadBalancer = (networkLoadBalancerId: number) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/nodes.ts b/packages/api-v4/src/netloadbalancers/nodes.ts new file mode 100644 index 00000000000..e7ad1045469 --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/nodes.ts @@ -0,0 +1,62 @@ +import { BETA_API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; + +import type { Filter, ResourcePage as Page, Params } from '../types'; +import type { NetworkLoadBalancerNode } from './types'; + +/** + * getNetworkLoadBalancerNodes + * + * Returns a paginated list of nodes for a listener. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener. + */ +interface GetNetworkLoadBalancerNodesOptions { + filters?: Filter; + listenerId: number; + networkLoadBalancerId: number; + params?: Params; +} + +export const getNetworkLoadBalancerNodes = ({ + networkLoadBalancerId, + listenerId, + params, + filters, +}: GetNetworkLoadBalancerNodesOptions) => + Request>( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}/nodes`, + ), + setMethod('GET'), + setParams(params), + setXFilter(filters), + ); + +/** + * getNetworkLoadBalancerNode + * + * Returns detailed information about a single node. + * + * @param networkLoadBalancerId { number } The ID of the Network Load Balancer. + * @param listenerId { number } The ID of the listener. + * @param nodeId { number } The ID of the node to retrieve. + */ +interface GetNetworkLoadBalancerNodeOptions { + listenerId: number; + networkLoadBalancerId: number; + nodeId: number; +} + +export const getNetworkLoadBalancerNode = ({ + listenerId, + networkLoadBalancerId, + nodeId, +}: GetNetworkLoadBalancerNodeOptions) => + Request( + setURL( + `${BETA_API_ROOT}/netloadbalancers/${encodeURIComponent(networkLoadBalancerId)}/listeners/${encodeURIComponent(listenerId)}/nodes/${encodeURIComponent(nodeId)}`, + ), + setMethod('GET'), + ); diff --git a/packages/api-v4/src/netloadbalancers/types.ts b/packages/api-v4/src/netloadbalancers/types.ts new file mode 100644 index 00000000000..6feb36d197b --- /dev/null +++ b/packages/api-v4/src/netloadbalancers/types.ts @@ -0,0 +1,97 @@ +import type { LKEClusterInfo } from '../nodebalancers/types'; + +export interface LinodeInfo { + id: number; + label: string; + type: 'linode'; + url: string; +} + +export type NetworkLoadBalancerStatus = 'active' | 'canceled' | 'suspended'; + +export type NetworkLoadBalancerListenerProtocol = 'tcp' | 'udp'; + +export interface NetworkLoadBalancerListener { + created: string; + /** + * The unique ID of this listener + */ + id: number; + /** + * The label for this listener + */ + label: string; + /** + * The port the listener is configured to listen on + */ + port: number; + /** + * The protocol used by this listener + */ + protocol: NetworkLoadBalancerListenerProtocol; + updated: string; +} + +export interface NetworkLoadBalancerNode { + /** + * The IPv6 address of the node + */ + address_v6: string; + created: string; + /** + * The unique ID of this node + */ + id: number; + /** + * The label for this node + */ + label: string; + /** + * Information about the Linode this node is associated with (if available) + */ + linode_id: number; + updated: string; + weight: number; + weight_updated: string; +} + +export interface NetworkLoadBalancer { + /** + * Virtual IP addresses assigned to this Network Load Balancer + */ + address_v4: string; + address_v6: string; + /** + * When this Network Load Balancer was created + */ + created: string; + /** + * The unique ID of this Network Load Balancer + */ + id: number; + /** + * The label for this Network Load Balancer + */ + label: string; + last_composite_updated: string; + /** + * Listeners configured on this Network Load Balancer + */ + listeners: NetworkLoadBalancerListener[]; + /** + * Information about the LKE cluster this NLB is associated with + */ + lke_cluster?: LKEClusterInfo; + /** + * The region where this Network Load Balancer is deployed + */ + region: string; + /** + * The current status of this Network Load Balancer + */ + status: NetworkLoadBalancerStatus; + /** + * When this Network Load Balancer was last updated + */ + updated: string; +} diff --git a/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md b/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md new file mode 100644 index 00000000000..6ad1e3662f2 --- /dev/null +++ b/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add new queries for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) diff --git a/packages/queries/src/index.ts b/packages/queries/src/index.ts index d55c9a3eeff..6c61dab7f07 100644 --- a/packages/queries/src/index.ts +++ b/packages/queries/src/index.ts @@ -10,6 +10,7 @@ export * from './firewalls'; export * from './iam'; export * from './images'; export * from './linodes'; +export * from './netloadbalancers'; export * from './networking'; export * from './networktransfer'; export * from './nodebalancers'; diff --git a/packages/queries/src/netloadbalancers/index.ts b/packages/queries/src/netloadbalancers/index.ts new file mode 100644 index 00000000000..88f45ea208a --- /dev/null +++ b/packages/queries/src/netloadbalancers/index.ts @@ -0,0 +1,5 @@ +export * from './keys'; +export * from './listeners'; +export * from './netloadbalancers'; +export * from './nodes'; +export * from './requests'; diff --git a/packages/queries/src/netloadbalancers/keys.ts b/packages/queries/src/netloadbalancers/keys.ts new file mode 100644 index 00000000000..dc3e2455825 --- /dev/null +++ b/packages/queries/src/netloadbalancers/keys.ts @@ -0,0 +1,101 @@ +import { + getNetworkLoadBalancer, + getNetworkLoadBalancerListener, + getNetworkLoadBalancerListeners, + getNetworkLoadBalancerNode, + getNetworkLoadBalancerNodes, + getNetworkLoadBalancers, +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +import { + getAllNetworkLoadBalancerListeners, + getAllNetworkLoadBalancerNodes, + getAllNetworkLoadBalancers, +} from './requests'; + +import type { Filter, Params } from '@linode/api-v4'; + +export const networkLoadBalancerQueries = createQueryKeys('netloadbalancers', { + netloadbalancer: (id: number) => ({ + contextQueries: { + listener: (listenerId: number) => ({ + contextQueries: { + node: (nodeId: number) => ({ + queryFn: () => + getNetworkLoadBalancerNode({ + networkLoadBalancerId: id, + listenerId, + nodeId, + }), + queryKey: [nodeId], + }), + nodes: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getAllNetworkLoadBalancerNodes( + id, + listenerId, + params, + filter, + ), + queryKey: [params, filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getNetworkLoadBalancerNodes({ + networkLoadBalancerId: id, + listenerId, + params, + filters: filter, + }), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryFn: () => getNetworkLoadBalancerListener(id, listenerId), + queryKey: [listenerId], + }), + listeners: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => + getAllNetworkLoadBalancerListeners(id, params, filter), + queryKey: [params, filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getNetworkLoadBalancerListeners(id, params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + }, + queryFn: () => getNetworkLoadBalancer(id), + queryKey: [id], + }), + netloadbalancers: { + contextQueries: { + all: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAllNetworkLoadBalancers(params, filter), + queryKey: [params, filter], + }), + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getNetworkLoadBalancers( + { page: pageParam as number, page_size: 25 }, + filter, + ), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getNetworkLoadBalancers(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); diff --git a/packages/queries/src/netloadbalancers/listeners.ts b/packages/queries/src/netloadbalancers/listeners.ts new file mode 100644 index 00000000000..b20f968f84d --- /dev/null +++ b/packages/queries/src/netloadbalancers/listeners.ts @@ -0,0 +1,48 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { networkLoadBalancerQueries } from './keys'; + +import type { + APIError, + Filter, + NetworkLoadBalancerListener, + Params, + ResourcePage, +} from '@linode/api-v4'; + +/** + * useNetworkLoadBalancerListenersQuery + * + * Returns a paginated list of listeners for a Network Load Balancer + */ +export const useNetworkLoadBalancerListenersQuery = ( + networkLoadBalancerId: number, + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...networkLoadBalancerQueries + .netloadbalancer(networkLoadBalancerId) + ._ctx.listeners._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +/** + * useNetworkLoadBalancerListenerQuery + * + * Returns a single listener by ID for a Network Load Balancer + */ +export const useNetworkLoadBalancerListenerQuery = ( + networkLoadBalancerId: number, + listenerId: number, + enabled: boolean = true, +) => { + return useQuery({ + ...networkLoadBalancerQueries + .netloadbalancer(networkLoadBalancerId) + ._ctx.listener(listenerId), + enabled, + }); +}; diff --git a/packages/queries/src/netloadbalancers/netloadbalancers.ts b/packages/queries/src/netloadbalancers/netloadbalancers.ts new file mode 100644 index 00000000000..57ed3a1918e --- /dev/null +++ b/packages/queries/src/netloadbalancers/netloadbalancers.ts @@ -0,0 +1,82 @@ +import { + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query'; + +import { networkLoadBalancerQueries } from './keys'; + +import type { + APIError, + Filter, + NetworkLoadBalancer, + Params, + ResourcePage, +} from '@linode/api-v4'; + +/** + * useNetworkLoadBalancersQuery + * + * Returns a paginated list of Network Load Balancers + */ +export const useNetworkLoadBalancersQuery = (params: Params, filter: Filter) => + useQuery, APIError[]>({ + ...networkLoadBalancerQueries.netloadbalancers._ctx.paginated( + params, + filter, + ), + placeholderData: keepPreviousData, + }); + +/** + * useAllNetworkLoadBalancersQuery + * + * Returns all Network Load Balancers (not paginated) + * Please use sparingly - prefer paginated queries when possible + */ +export const useAllNetworkLoadBalancersQuery = ( + enabled: boolean = true, + params: Params = {}, + filter: Filter = {}, +) => + useQuery({ + ...networkLoadBalancerQueries.netloadbalancers._ctx.all(params, filter), + enabled, + }); + +/** + * useNetworkLoadBalancerQuery + * + * Returns a single Network Load Balancer by ID + */ +export const useNetworkLoadBalancerQuery = ( + id: number, + enabled: boolean = true, +) => { + return useQuery({ + ...networkLoadBalancerQueries.netloadbalancer(id), + enabled, + }); +}; + +/** + * useInfiniteNetworkLoadBalancersQuery + * + * Returns an infinite query for Network Load Balancers + */ +export const useInfiniteNetworkLoadBalancersQuery = ( + filter: Filter, + enabled: boolean = true, +) => + useInfiniteQuery, APIError[]>({ + ...networkLoadBalancerQueries.netloadbalancers._ctx.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + initialPageParam: 1, + retry: false, + }); diff --git a/packages/queries/src/netloadbalancers/nodes.ts b/packages/queries/src/netloadbalancers/nodes.ts new file mode 100644 index 00000000000..4c248c8e35d --- /dev/null +++ b/packages/queries/src/netloadbalancers/nodes.ts @@ -0,0 +1,52 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import { networkLoadBalancerQueries } from './keys'; + +import type { + APIError, + Filter, + NetworkLoadBalancerNode, + Params, + ResourcePage, +} from '@linode/api-v4'; + +/** + * useNetworkLoadBalancerNodesQuery + * + * Returns a paginated list of nodes for a Network Load Balancer listener + */ +export const useNetworkLoadBalancerNodesQuery = ( + networkLoadBalancerId: number, + listenerId: number, + params: Params = {}, + filter: Filter = {}, + enabled: boolean = true, +) => + useQuery, APIError[]>({ + ...networkLoadBalancerQueries + .netloadbalancer(networkLoadBalancerId) + ._ctx.listener(listenerId) + ._ctx.nodes._ctx.paginated(params, filter), + enabled, + placeholderData: keepPreviousData, + }); + +/** + * useNetworkLoadBalancerNodeQuery + * + * Returns a single node by ID for a Network Load Balancer listener + */ +export const useNetworkLoadBalancerNodeQuery = ( + networkLoadBalancerId: number, + listenerId: number, + nodeId: number, + enabled: boolean = true, +) => { + return useQuery({ + ...networkLoadBalancerQueries + .netloadbalancer(networkLoadBalancerId) + ._ctx.listener(listenerId) + ._ctx.node(nodeId), + enabled, + }); +}; diff --git a/packages/queries/src/netloadbalancers/requests.ts b/packages/queries/src/netloadbalancers/requests.ts new file mode 100644 index 00000000000..6599459b8eb --- /dev/null +++ b/packages/queries/src/netloadbalancers/requests.ts @@ -0,0 +1,53 @@ +import { + getNetworkLoadBalancerListeners, + getNetworkLoadBalancerNodes, + getNetworkLoadBalancers, +} from '@linode/api-v4'; +import { getAll } from '@linode/utilities'; + +import type { + Filter, + NetworkLoadBalancer, + NetworkLoadBalancerListener, + NetworkLoadBalancerNode, + Params, +} from '@linode/api-v4'; + +export const getAllNetworkLoadBalancers = ( + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getNetworkLoadBalancers( + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const getAllNetworkLoadBalancerListeners = ( + networkLoadBalancerId: number, + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getNetworkLoadBalancerListeners( + networkLoadBalancerId, + { ...params, ...passedParams }, + { ...filter, ...passedFilter }, + ), + )().then((data) => data.data); + +export const getAllNetworkLoadBalancerNodes = ( + networkLoadBalancerId: number, + listenerId: number, + passedParams: Params = {}, + passedFilter: Filter = {}, +) => + getAll((params, filter) => + getNetworkLoadBalancerNodes({ + networkLoadBalancerId, + listenerId, + params: { ...params, ...passedParams }, + filters: { ...filter, ...passedFilter }, + }), + )().then((data) => data.data); From d5d28d188af5fccb62abe2109d485f1947542582 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Mon, 17 Nov 2025 14:27:18 +0530 Subject: [PATCH 08/91] upcoming: [UIE-9559, UIE-9560] - Introduce Network Load Balancer feature to CM (#13068) * upcoming: [UIE-9559, UIE-9560] - Implement routing for Network Load Balancer * added support for feature flag * PR feedback @tanushree-akamai * add unit test for Network Load Balancers Item in primary nav component * Added changeset: Implement feature flag and routing for NLB * correct routing for nlb * fix failing unit test * PR feedback * fix nlb item visibility in side nav * removed nlb routes that are not yet implemented * ux writing fixes --- packages/api-v4/src/account/types.ts | 1 + ...r-13068-upcoming-features-1762930872294.md | 5 +++ .../components/PrimaryNav/PrimaryNav.test.tsx | 26 ++++++++++++ .../src/components/PrimaryNav/PrimaryNav.tsx | 9 ++++ .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 2 + .../NetworkLoadBalancersLanding.tsx | 20 +++++++++ .../networkLoadBalancersLazyRoute.tsx | 9 ++++ .../NetworkLoadBalancers/utils.test.ts | 42 +++++++++++++++++++ .../features/NetworkLoadBalancers/utils.ts | 27 ++++++++++++ packages/manager/src/routes/index.tsx | 2 + .../src/routes/networkLoadBalancer/index.ts | 22 ++++++++++ .../networkLoadBalancersRoute.tsx | 21 ++++++++++ 13 files changed, 187 insertions(+) create mode 100644 packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/utils.test.ts create mode 100644 packages/manager/src/features/NetworkLoadBalancers/utils.ts create mode 100644 packages/manager/src/routes/networkLoadBalancer/index.ts create mode 100644 packages/manager/src/routes/networkLoadBalancer/networkLoadBalancersRoute.tsx diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 73d008377cf..e6b4a27c25c 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -80,6 +80,7 @@ export const accountCapabilities = [ 'Managed Databases', 'Managed Databases Beta', 'NETINT Quadra T1U', + 'Network LoadBalancer', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md b/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md new file mode 100644 index 00000000000..47909ba6d28 --- /dev/null +++ b/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement feature flag and routing for NLB ([#13068](https://github.com/linode/manager/pull/13068)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 220bf0121c8..835b35727eb 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -561,4 +561,30 @@ describe('PrimaryNav', () => { ).toBeNull(); }); }); + + it('should show Network Load Balancers menu item if the user has the account capability and the flag is enabled', async () => { + const account = accountFactory.build({ + capabilities: ['Network LoadBalancer'], + }); + + queryMocks.useAccount.mockReturnValue({ + data: account, + isLoading: false, + error: null, + }); + + const flags: Partial = { + networkLoadBalancer: true, + }; + + const { findByTestId } = renderWithTheme(, { + flags, + }); + + const databaseNavItem = await findByTestId( + 'menu-item-Network Load Balancer' + ); + + expect(databaseNavItem).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 191648a49b1..f2b0ef1ab18 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -22,6 +22,7 @@ import { useIsACLPEnabled } from 'src/features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useIsACLPLogsEnabled } from 'src/features/Delivery/deliveryUtils'; import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils'; import { useFlags } from 'src/hooks/useFlags'; @@ -56,6 +57,7 @@ export type NavEntity = | 'Marketplace' | 'Metrics' | 'Monitor' + | 'Network Load Balancer' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' @@ -115,6 +117,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); const { data: preferences, @@ -202,6 +205,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Firewalls', to: '/firewalls', }, + { + display: 'Network Load Balancer', + hide: !isNetworkLoadBalancerEnabled, + to: '/netloadbalancers', + }, { display: 'NodeBalancers', to: '/nodebalancers', @@ -340,6 +348,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isIAMBeta, isIAMEnabled, iamRbacPrimaryNavChanges, + isNetworkLoadBalancerEnabled, limitsEvolution, ] ); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index d38d32995a2..51a36260c38 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -40,6 +40,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'linodeInterfaces', label: 'Linode Interfaces' }, { flag: 'lkeEnterprise2', label: 'LKE-Enterprise' }, + { flag: 'networkLoadBalancer', label: 'Network Load Balancer' }, { flag: 'nodebalancerIpv6', label: 'NodeBalancer Dual Stack (IPv6)' }, { flag: 'nodebalancerVpc', label: 'NodeBalancer-VPC Integration' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index d3aec9ff50f..097fbcaf01b 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -218,6 +218,7 @@ export interface Flags { marketplaceAppOverrides: MarketplaceAppOverride[]; metadata: boolean; mtc: MTC; + networkLoadBalancer: boolean; nodebalancerIpv6: boolean; nodebalancerVpc: boolean; objectStorageGen2: BaseFeatureFlag; @@ -341,6 +342,7 @@ export type ProductInformationBannerLocation = | 'Logs' | 'Longview' | 'Managed' + | 'Network LoadBalancers' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx new file mode 100644 index 00000000000..373a1e0e883 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx @@ -0,0 +1,20 @@ +import { Notice } from '@linode/ui'; +import * as React from 'react'; + +import { LandingHeader } from 'src/components/LandingHeader'; + +export const NetworkLoadBalancersLanding = () => { + return ( + <> + + Network Load Balancer is coming soon... + + ); +}; diff --git a/packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx b/packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx new file mode 100644 index 00000000000..def65acf048 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { NetworkLoadBalancersLanding } from './NetworkLoadBalancersLanding'; + +export const networkLoadBalancersLazyRoute = createLazyRoute( + '/netloadbalancers' +)({ + component: NetworkLoadBalancersLanding, +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/utils.test.ts b/packages/manager/src/features/NetworkLoadBalancers/utils.test.ts new file mode 100644 index 00000000000..aa679304b4b --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/utils.test.ts @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { accountFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useIsNetworkLoadBalancerEnabled } from './utils'; + +describe('useIsNetworkLoadBalancerEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { flags: { networkLoadBalancer: true } }; + const account = accountFactory.build({ + capabilities: ['Network LoadBalancer'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { result } = renderHook(() => useIsNetworkLoadBalancerEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isNetworkLoadBalancerEnabled).toBe(true); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { flags: { networkLoadBalancer: false } }; + + const { result } = renderHook(() => useIsNetworkLoadBalancerEnabled(), { + wrapper: (ui) => wrapWithTheme(ui, options), + }); + + await waitFor(() => { + expect(result.current.isNetworkLoadBalancerEnabled).toBe(false); + }); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/utils.ts b/packages/manager/src/features/NetworkLoadBalancers/utils.ts new file mode 100644 index 00000000000..66c9a2a2865 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/utils.ts @@ -0,0 +1,27 @@ +import { useAccount } from '@linode/queries'; +import { isFeatureEnabledV2 } from '@linode/utilities'; + +import { useFlags } from 'src/hooks/useFlags'; + +/** + * + * @returns an object that contains boolean property to check whether Network LoadBalancer is enabled or not + */ +export const useIsNetworkLoadBalancerEnabled = (): { + isNetworkLoadBalancerEnabled: boolean; +} => { + const { data: account } = useAccount(); + const flags = useFlags(); + + if (!flags) { + return { isNetworkLoadBalancerEnabled: false }; + } + + const isNetworkLoadBalancerEnabled = isFeatureEnabledV2( + 'Network LoadBalancer', + Boolean(flags.networkLoadBalancer), + account?.capabilities ?? [] + ); + + return { isNetworkLoadBalancerEnabled }; +}; diff --git a/packages/manager/src/routes/index.tsx b/packages/manager/src/routes/index.tsx index 2647d63ab12..3c2193f36fa 100644 --- a/packages/manager/src/routes/index.tsx +++ b/packages/manager/src/routes/index.tsx @@ -30,6 +30,7 @@ import { longviewRouteTree } from './longview'; import { maintenanceRouteTree } from './maintenance'; import { managedRouteTree } from './managed'; import { cloudPulseMetricsRouteTree } from './metrics'; +import { networkLoadBalancersRouteTree } from './networkLoadBalancer'; import { nodeBalancersRouteTree } from './nodeBalancers'; import { objectStorageRouteTree } from './objectStorage'; import { placementGroupsRouteTree } from './placementGroups'; @@ -79,6 +80,7 @@ export const routeTree = rootRoute.addChildren([ longviewRouteTree, maintenanceRouteTree, managedRouteTree, + networkLoadBalancersRouteTree, nodeBalancersRouteTree, objectStorageRouteTree, placementGroupsRouteTree, diff --git a/packages/manager/src/routes/networkLoadBalancer/index.ts b/packages/manager/src/routes/networkLoadBalancer/index.ts new file mode 100644 index 00000000000..80427760f2f --- /dev/null +++ b/packages/manager/src/routes/networkLoadBalancer/index.ts @@ -0,0 +1,22 @@ +import { createRoute } from '@tanstack/react-router'; + +import { rootRoute } from '../root'; +import { NetworkLoadBalancersRoute } from './networkLoadBalancersRoute'; + +const networkLoadBalancersRoute = createRoute({ + component: NetworkLoadBalancersRoute, + getParentRoute: () => rootRoute, + path: 'netloadbalancers', +}); + +const networkLoadBalancersIndexRoute = createRoute({ + getParentRoute: () => networkLoadBalancersRoute, + path: '/', +}).lazy(() => + import( + 'src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute' + ).then((m) => m.networkLoadBalancersLazyRoute) +); + +export const networkLoadBalancersRouteTree = + networkLoadBalancersRoute.addChildren([networkLoadBalancersIndexRoute]); diff --git a/packages/manager/src/routes/networkLoadBalancer/networkLoadBalancersRoute.tsx b/packages/manager/src/routes/networkLoadBalancer/networkLoadBalancersRoute.tsx new file mode 100644 index 00000000000..85ca98e8304 --- /dev/null +++ b/packages/manager/src/routes/networkLoadBalancer/networkLoadBalancersRoute.tsx @@ -0,0 +1,21 @@ +import { NotFound } from '@linode/ui'; +import { Outlet } from '@tanstack/react-router'; +import React from 'react'; + +import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useIsNetworkLoadBalancerEnabled } from 'src/features/NetworkLoadBalancers/utils'; + +export const NetworkLoadBalancersRoute = () => { + const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); + + if (!isNetworkLoadBalancerEnabled) { + return ; + } + return ( + }> + + + + ); +}; From f49e9f06bd2b98eb4413c4ffe6c2d4caa3e7be7c Mon Sep 17 00:00:00 2001 From: aaleksee-akamai Date: Mon, 17 Nov 2025 12:00:13 +0100 Subject: [PATCH 09/91] feat: [UIE-9423] - IAM Parent/Child: permissions switch account (#13075) * feat: [UIE-9423] - IAM Parent/Child: permissions switch account * update a condition * Added changeset: IAM Parent/Child: permissions switch account --------- Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../pr-13075-added-1762776269124.md | 5 ++ .../src/features/Account/AccountLanding.tsx | 8 +- .../Account/SwitchAccountButton.test.tsx | 77 ++++++++++++++++++- .../features/Account/SwitchAccountButton.tsx | 17 ++++ .../Billing/BillingLanding/BillingLanding.tsx | 8 +- .../TopMenu/UserMenu/UserMenuPopover.tsx | 14 +++- 6 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-13075-added-1762776269124.md diff --git a/packages/manager/.changeset/pr-13075-added-1762776269124.md b/packages/manager/.changeset/pr-13075-added-1762776269124.md new file mode 100644 index 00000000000..ed452d6cbe3 --- /dev/null +++ b/packages/manager/.changeset/pr-13075-added-1762776269124.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM Parent/Child: permissions switch account ([#13075](https://github.com/linode/manager/pull/13075)) diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 8a702687d98..0f626cc9850 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -24,6 +24,7 @@ import { useTabs } from 'src/hooks/useTabs'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner'; +import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled'; import { usePermissions } from '../IAM/hooks/usePermissions'; import { SwitchAccountButton } from './SwitchAccountButton'; import { SwitchAccountDrawer } from './SwitchAccountDrawer'; @@ -60,6 +61,8 @@ export const AccountLanding = () => { globalGrantType: 'child_account_access', }); + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([ @@ -124,8 +127,9 @@ export const AccountLanding = () => { }; const isBillingTabSelected = getTabIndex('/account/billing') === tabIndex; - const canSwitchBetweenParentOrProxyAccount = - (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; + const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled + ? isParentUser + : (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { diff --git a/packages/manager/src/features/Account/SwitchAccountButton.test.tsx b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx index b043e9f825c..2749e17c495 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.test.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.test.tsx @@ -1,10 +1,30 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { renderWithTheme } from 'src/utilities/testHelpers'; +const queryMocks = vi.hoisted(() => ({ + userPermissions: vi.fn(() => ({ + data: { + create_child_account_token: true, + }, + })), + useFlags: vi.fn().mockReturnValue({}), +})); +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.userPermissions, +})); + +vi.mock('src/hooks/useFlags', () => { + const actual = vi.importActual('src/hooks/useFlags'); + return { + ...actual, + useFlags: queryMocks.useFlags, + }; +}); + describe('SwitchAccountButton', () => { test('renders Switch Account button with SwapIcon', () => { renderWithTheme(); @@ -22,4 +42,59 @@ describe('SwitchAccountButton', () => { expect(onClickMock).toHaveBeenCalledTimes(1); }); + + test('enables the button when user has create_child_account_token permission', () => { + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: true }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeEnabled(); + }); + + test('disables the button when user does not have create_child_account_token permission', async () => { + queryMocks.userPermissions.mockReturnValue({ + data: { + create_child_account_token: false, + }, + }); + + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: true }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeDisabled(); + + // Check that the tooltip is properly configured + expect(button).toHaveAttribute('aria-describedby', 'button-tooltip'); + + // Hover over the button to show the tooltip + await userEvent.hover(button); + + // Wait for tooltip to appear and check its content + await waitFor(() => { + screen.getByRole('tooltip'); + }); + + expect( + screen.getByText('You do not have permission to switch accounts.') + ).toBeVisible(); + }); + + test('enables the button when iamDelegation flag is off', async () => { + queryMocks.useFlags.mockReturnValue({ + iamDelegation: { enabled: false }, + }); + + renderWithTheme(); + + const button = screen.getByRole('button', { name: /switch account/i }); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute('aria-describedby', 'button-tooltip'); + }); }); diff --git a/packages/manager/src/features/Account/SwitchAccountButton.tsx b/packages/manager/src/features/Account/SwitchAccountButton.tsx index 33944ec21e5..ab1dd3b3e2b 100644 --- a/packages/manager/src/features/Account/SwitchAccountButton.tsx +++ b/packages/manager/src/features/Account/SwitchAccountButton.tsx @@ -3,11 +3,23 @@ import * as React from 'react'; import SwapIcon from 'src/assets/icons/swapSmall.svg'; +import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled'; +import { usePermissions } from '../IAM/hooks/usePermissions'; + import type { ButtonProps } from '@linode/ui'; export const SwitchAccountButton = (props: ButtonProps) => { + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + + const { data: permissions } = usePermissions('account', [ + 'create_child_account_token', + ]); + return ( )} diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index feec0feee2a..244cedde76d 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -61,7 +61,7 @@ position: absolute; z-index: 2; right: 0px; - bottom: 16px; + bottom: 22px; color: white; cursor: ew-resize; background: transparent; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx index 083cf5cc6d1..6882e4c8337 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeMetrics/LinodeSummary/LinodeSummary.tsx @@ -196,7 +196,11 @@ const LinodeSummary = (props: Props) => { return ( ( + + )} CustomIconStyles={{ height: 64, width: 64 }} errorText={ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 0634919110a..1c89b01e6a5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -138,7 +138,15 @@ export const TransferHistory = React.memo((props: Props) => { return ( ( + + ) + : undefined + } errorText={ areStatsNotReady ? STATS_NOT_READY_MESSAGE : statsErrorString } diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 76106256be1..7aacd7ad18a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -2,6 +2,7 @@ import { useTypeQuery } from '@linode/queries'; import { Tooltip, TooltipIcon, Typography } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { formatStorageUnits, getFormattedStatus } from '@linode/utilities'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import Flag from 'src/assets/icons/flag.svg'; @@ -204,11 +205,12 @@ export const RenderFlag: React.FC<{ * precedent over notifications */ const { mutationAvailable } = props; + const theme = useTheme(); if (mutationAvailable) { return ( - + ); } diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx index 9c539b2c10b..0984a1b6342 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx @@ -1,5 +1,6 @@ import { Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import MonitorFailed from 'src/assets/icons/monitor-failed.svg'; @@ -19,6 +20,7 @@ export interface MonitorStatusProps { } export const MonitorStatus = (props: MonitorStatusProps) => { + const theme = useTheme(); const { monitors } = props; const failedMonitors = getFailedMonitors(monitors); @@ -34,9 +36,17 @@ export const MonitorStatus = (props: MonitorStatusProps) => { {failedMonitors.length === 0 ? ( - + ) : ( - + )} diff --git a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx index bb014efa15f..71211e46c25 100644 --- a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx +++ b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx @@ -1,5 +1,6 @@ import { Tooltip } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import type { JSX } from 'react'; @@ -64,18 +65,31 @@ const iconStyles = { export const IssueDay = (props: IssueDayProps) => { const { day, issues } = props; + const theme = useTheme(); const issueLinks = issues.map((thisIssue) => thisIssue.entity.id); if (issues.length === 0) { // No issues for today - return } />; + return ( + + } + /> + ); } return ( } + icon={} // For now, not worrying about the possibility of multiple tickets opened in a single day ticketUrl={`/support/tickets/${issueLinks[0]}`} /> diff --git a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx index 0522d1f27ce..601beefa33d 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx @@ -1,12 +1,13 @@ import { Tooltip, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import TicketIcon from 'src/assets/icons/ticket.svg'; import { TableCell } from 'src/components/TableCell'; import { MonitorActionMenu } from './MonitorActionMenu'; -import { statusIconMap, statusTextMap } from './monitorMaps'; +import { getStatusColorMap, statusIconMap, statusTextMap } from './monitorMaps'; import { StyledGrid, StyledLink, @@ -25,8 +26,10 @@ interface MonitorRowProps { export const MonitorRow = (props: MonitorRowProps) => { const { issues, monitor } = props; + const theme = useTheme(); const Icon = statusIconMap[monitor.status]; + const statusColors = getStatusColorMap(theme); // For now, only include a ticket icon in this view if the ticket is still open (per Jay). const openIssues = issues.filter((thisIssue) => !thisIssue.dateClosed); @@ -50,7 +53,11 @@ export const MonitorRow = (props: MonitorRowProps) => { wrap="nowrap" > - + {monitor.label} diff --git a/packages/manager/src/features/Managed/Monitors/monitorMaps.ts b/packages/manager/src/features/Managed/Monitors/monitorMaps.ts index ab9948ac318..43a77035430 100644 --- a/packages/manager/src/features/Managed/Monitors/monitorMaps.ts +++ b/packages/manager/src/features/Managed/Monitors/monitorMaps.ts @@ -4,6 +4,7 @@ import Good from 'src/assets/icons/monitor-ok.svg'; import Pending from 'src/assets/icons/pending.svg'; import type { MonitorStatus } from '@linode/api-v4/lib/managed'; +import type { Theme } from '@mui/material/styles'; export const statusIconMap: Record = { disabled: Disabled, @@ -18,3 +19,12 @@ export const statusTextMap: Record = { pending: 'Pending', problem: 'Failed', }; + +export const getStatusColorMap = ( + theme: Theme +): Record => ({ + disabled: theme.palette.text.disabled, + ok: theme.tokens.alias.Content.Icon.Positive, + pending: theme.tokens.alias.Content.Icon.Positive, + problem: theme.tokens.alias.Content.Icon.Negative, +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index 5c0a92af7cd..80ea72c3481 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -53,7 +53,11 @@ export const TablesPanel = () => { if (statsNotReadyError) { return ( ( + + )} CustomIconStyles={{ height: 64, width: 64 }} errorText={ <> diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx index 1dd4f622a19..e77708c9560 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessCell.tsx @@ -69,10 +69,11 @@ export const AccessCell = React.memo((props: AccessCellProps) => { const StyledCheckIcon = styled('span', { label: 'StyledCheckIcon', -})(() => ({ +})(({ theme }) => ({ '& svg': { height: 25, width: 25, + color: theme.tokens.alias.Content.Icon.Positive, }, alignItems: 'center', display: 'flex', diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx index 0a5614a4fc2..b592c5fc6fd 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TPAProviders.tsx @@ -1,5 +1,6 @@ import { Box, Divider, Notice, Paper, Stack, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import EnabledIcon from 'src/assets/icons/checkmark-enabled.svg'; @@ -33,6 +34,7 @@ const linode = { export const TPAProviders = (props: Props) => { const flags = useFlags(); + const theme = useTheme(); // Get list of providers from LaunchDarkly const providers = flags.tpaProviders ?? []; @@ -83,7 +85,15 @@ export const TPAProviders = (props: Props) => { onClick={() => handleProviderChange(thisProvider.name)} renderIcon={() => } renderVariant={ - isProviderEnabled ? () => : undefined + isProviderEnabled + ? () => ( + + ) + : undefined } subheadings={isProviderEnabled ? ['Enabled'] : []} tooltip={ From 0888fb0f656147f96dcc9f789b92c3ab586e5745 Mon Sep 17 00:00:00 2001 From: tvijay-akamai <51293194+tvijay-akamai@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:13:52 +0530 Subject: [PATCH 12/91] fix: [UIE-9656] - Plans panel pagination bug fix (#13100) * fix: [UIE-9659] Plans panel pagination bug fix * fix: [UIE-9659] adding changeset --- .../pr-13100-fixed-1764265664121.md | 5 +++++ packages/manager/src/components/Paginate.ts | 20 ++++++++++++++++++- .../KubernetesPlanContainer.tsx | 12 ++++++++++- .../components/PlansPanel/PlanContainer.tsx | 12 ++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13100-fixed-1764265664121.md diff --git a/packages/manager/.changeset/pr-13100-fixed-1764265664121.md b/packages/manager/.changeset/pr-13100-fixed-1764265664121.md new file mode 100644 index 00000000000..4371d0b1da5 --- /dev/null +++ b/packages/manager/.changeset/pr-13100-fixed-1764265664121.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Plans panel pagination bug fix ([#13100](https://github.com/linode/manager/pull/13100)) diff --git a/packages/manager/src/components/Paginate.ts b/packages/manager/src/components/Paginate.ts index 449cc00e0e6..a8e74be77be 100644 --- a/packages/manager/src/components/Paginate.ts +++ b/packages/manager/src/components/Paginate.ts @@ -39,6 +39,20 @@ interface State { interface Props { children: (p: PaginationProps) => React.ReactNode; data: T[]; + /** + * When true, prevents page size changes from being persisted to the global PAGE_SIZE + * localStorage key. This is critical for components with custom page size options + * (e.g., plans panel with 15, 25, 50) to ensure they don't override the standard + * page size preference (25, 50, 75, 100) used by other tables across the application. + * + * Use this flag when: + * - Component has non-standard page size options (anything other than 25, 50, 75, 100) + * - Page size should be ephemeral (not persisted across sessions) + * - Component uses customOptions prop in PaginationFooter + * + * @default false + */ + noPageSizeOverride?: boolean; page?: number; pageSize?: number; pageSizeSetter?: (v: number) => void; @@ -69,9 +83,13 @@ export default class Paginate extends React.Component, State> { // Use the custom setter if one has been supplied. if (this.props.pageSizeSetter) { this.props.pageSizeSetter(pageSize); - } else { + } else if (!this.props.noPageSizeOverride) { + // Only persist to global PAGE_SIZE storage if noPageSizeOverride is not set. + // This ensures components with non-standard page sizes (e.g., 15, 25, 50) + // don't override the standard preference (25, 50, 75, 100) used across the app. storage.pageSize.set(pageSize); } + // If noPageSizeOverride is true, page size change is kept in local state only }; render() { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index c8e8c7501b3..f15d2c79c98 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -202,7 +202,17 @@ export const KubernetesPlanContainer = ( // Pagination enabled: use new paginated rendering return ( - + {({ count, data: paginatedPlans, diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index 39ccc3944fd..c20b39df527 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -253,7 +253,17 @@ export const PlanContainer = (props: PlanContainerProps) => { // Pagination enabled: use new paginated rendering return ( - + {({ count, data: paginatedPlans, From 81e2a189d6285837963e6a1ca9b770e3ddc9b298 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:59:46 +0530 Subject: [PATCH 13/91] upcoming:[UIE-9509] - Add tooltip for Rules column header in Firewall Rules table (#13090) * upcoming:[UIE-9509] - Add tooltip for Rules column in Firewall Rules table * Added changeset: Add tooltip for Rules column header in Firewall Rules table --- ...r-13090-upcoming-features-1763045734612.md | 5 ++++ .../FirewallLanding/FirewallLanding.tsx | 28 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md diff --git a/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md b/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md new file mode 100644 index 00000000000..85af714871c --- /dev/null +++ b/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add tooltip for Rules column header in Firewall Rules table ([#13090](https://github.com/linode/manager/pull/13090)) diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index f115b326263..b32df9a0adf 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -1,5 +1,12 @@ import { useFirewallsQuery } from '@linode/queries'; -import { Button, CircleProgress, ErrorState, Hidden } from '@linode/ui'; +import { + Button, + CircleProgress, + ErrorState, + Hidden, + TooltipIcon, + useTheme, +} from '@linode/ui'; import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -21,6 +28,7 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { useIsFirewallRulesetsPrefixlistsEnabled } from '../shared'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; import { FirewallDialog } from './FirewallDialog'; import { FirewallLandingEmptyState } from './FirewallLandingEmptyState'; @@ -48,6 +56,7 @@ const FirewallLanding = () => { }, preferenceKey, }); + const theme = useTheme(); const filter = { ['+order']: order, @@ -66,7 +75,11 @@ const FirewallLanding = () => { const [isModalOpen, setIsModalOpen] = React.useState(false); const [dialogMode, setDialogMode] = React.useState('enable'); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = React.useState(false); + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const rulesColumnTooltipText = + 'Includes both rules and Rule Sets in the count. Each Rule Set is counted as one rule, regardless of how many rules it contains.'; const flags = useFlags(); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); @@ -193,7 +206,18 @@ const FirewallLanding = () => { Status - Rules + + Rules + {isFirewallRulesetsPrefixlistsEnabled && ( + + )} + Services From a481f5136017fb0933866e32446bb4f518d182b1 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:32:45 -0500 Subject: [PATCH 14/91] feat: [UIE-9381] - Add region select to Database Backups tab (#13097) * add region select to database backups tab * remove region radios, use rhf * move errors to rhf * clean up * Added changeset: Region select to Database Backups tab * Added changeset: Update database restoreWithBackup data to include region * Added changeset: Update database useRestoreFromBackupMutation data to include region * clean up DatabaseBackupDialog --- .../pr-13097-changed-1763149227691.md | 5 + packages/api-v4/src/databases/databases.ts | 9 +- packages/api-v4/src/databases/types.ts | 5 + .../pr-13097-added-1763149132736.md | 5 + .../DatabaseBackups/DatabaseBackups.style.ts | 26 +- .../DatabaseBackups/DatabaseBackups.tsx | 323 ++++++++++-------- .../DatabaseBackups/DatabaseBackupsDialog.tsx | 24 +- .../pr-13097-changed-1763149256983.md | 5 + packages/queries/src/databases/databases.ts | 6 +- 9 files changed, 254 insertions(+), 154 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13097-changed-1763149227691.md create mode 100644 packages/manager/.changeset/pr-13097-added-1763149132736.md create mode 100644 packages/queries/.changeset/pr-13097-changed-1763149256983.md diff --git a/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md b/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md new file mode 100644 index 00000000000..a64d20f3418 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Update database restoreWithBackup data to include region ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 02f4a5b23ca..181fbb4a0a0 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -17,10 +17,10 @@ import type { CreateDatabasePayload, Database, DatabaseBackup, + DatabaseBackupsPayload, DatabaseCredentials, DatabaseEngine, DatabaseEngineConfig, - DatabaseFork, DatabaseInstance, DatabaseType, Engine, @@ -267,11 +267,14 @@ export const legacyRestoreWithBackup = ( * * Fully restore a backup to the cluster */ -export const restoreWithBackup = (engine: Engine, fork: DatabaseFork) => +export const restoreWithBackup = ( + engine: Engine, + data: DatabaseBackupsPayload, +) => Request( setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/instances`), setMethod('POST'), - setData({ fork }), + setData(data), ); /** diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 80611783fa2..3a07d9aadf6 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -79,6 +79,11 @@ export interface DatabaseFork { source: number; } +export interface DatabaseBackupsPayload { + fork: DatabaseFork; + region?: string; +} + export interface DatabaseCredentials { password: string; username: string; diff --git a/packages/manager/.changeset/pr-13097-added-1763149132736.md b/packages/manager/.changeset/pr-13097-added-1763149132736.md new file mode 100644 index 00000000000..8759fd74af1 --- /dev/null +++ b/packages/manager/.changeset/pr-13097-added-1763149132736.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Region select to Database Backups tab ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts index d3cf4debf14..d50b9360eb7 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style.ts @@ -1,4 +1,4 @@ -import { Typography } from '@linode/ui'; +import { Stack, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { DateCalendar } from '@mui/x-date-pickers'; @@ -36,7 +36,9 @@ export const StyledDateCalendar = styled(DateCalendar, { '.MuiYearCalendar-root': { width: '260px', }, - marginLeft: '0px', + height: 'auto', + margin: 0, + marginRight: theme.spacingFunction(40), width: '260px', })); @@ -44,3 +46,23 @@ export const StyledTypography = styled(Typography)(() => ({ lineHeight: '20px', marginTop: '4px', })); + +export const StyledDateTimeStack = styled(Stack, { + label: 'StyledDateTimeStack', +})(({ theme }) => ({ + flexDirection: 'row', + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + marginTop: theme.spacingFunction(24), + marginBottom: theme.spacingFunction(16), + }, +})); + +export const StyledRegionStack = styled(Stack, { label: 'StyledRegionStack' })( + ({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginTop: theme.spacingFunction(24), + marginBottom: theme.spacingFunction(16), + }, + }) +); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 8565492ed79..deae3d8f4da 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -1,4 +1,5 @@ -import { useDatabaseQuery } from '@linode/queries'; +import { useDatabaseQuery, useRegionsQuery } from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; import { Box, Button, @@ -14,15 +15,19 @@ import { Radio, RadioGroup, } from '@mui/material'; -import { GridLegacy } from '@mui/material'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { useParams } from '@tanstack/react-router'; +import { useFlags } from 'launchdarkly-react-client-sdk'; import { DateTime } from 'luxon'; import * as React from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { StyledDateCalendar, + StyledDateTimeStack, + StyledRegionStack, StyledTypography, } from 'src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.style'; import { @@ -38,9 +43,10 @@ import { BACKUPS_UNABLE_TO_RESTORE_TEXT, } from '../../constants'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; -import DatabaseBackupsDialog from './DatabaseBackupsDialog'; +import { DatabaseBackupsDialog } from './DatabaseBackupsDialog'; import DatabaseBackupsLegacy from './legacy/DatabaseBackupsLegacy'; +import type { DatabaseBackupsPayload } from '@linode/api-v4'; import type { TimeValidationError } from '@mui/x-date-pickers'; export interface TimeOption { @@ -50,6 +56,11 @@ export interface TimeOption { export type VersionOption = 'dateTime' | 'newest'; +export interface DatabaseBackupsValues extends DatabaseBackupsPayload { + date: DateTime | null; + time: DateTime | null; +} + export const DatabaseBackups = () => { const { disabled } = useDatabaseDetailContext(); const { databaseId, engine } = useParams({ @@ -57,13 +68,17 @@ export const DatabaseBackups = () => { }); const { isDatabasesV2GA } = useIsDatabasesEnabled(); + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const { data: regionsData } = useRegionsQuery(); + const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); - const [selectedDate, setSelectedDate] = React.useState(null); - const [selectedTime, setSelectedTime] = React.useState(null); const [versionOption, setVersionOption] = React.useState( isDatabasesV2GA ? 'newest' : 'dateTime' ); - const [timePickerError, setTimePickerError] = React.useState(''); const { data: database, @@ -81,10 +96,6 @@ export const DatabaseBackups = () => { ? BACKUPS_UNABLE_TO_RESTORE_TEXT : ''; - const onRestoreDatabase = () => { - setIsRestoreDialogOpen(true); - }; - /** * Check whether date and time are within the valid range of available backups by providing the selected date and time. * When the date and time selections are valid, clear any existing error messages for the time picker. @@ -98,48 +109,38 @@ export const DatabaseBackups = () => { ); if (!isSelectedTimeInvalid) { - setTimePickerError(''); + clearErrors('time'); } } }; const handleOnError = (error: TimeValidationError) => { - if (error) { - switch (error) { - case 'maxTime': - setTimePickerError(BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT); - break; - case 'minTime': - setTimePickerError(BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT); - break; - case 'invalidDate': - setSelectedTime(null); - setTimePickerError(BACKUPS_INVALID_TIME_VALIDATON_TEXT); - } + switch (error) { + case 'maxTime': + setError('time', { + message: BACKUPS_MAX_TIME_EXCEEDED_VALIDATON_TEXT, + }); + break; + case 'minTime': + setError('time', { + message: BACKUPS_MIN_TIME_EXCEEDED_VALIDATON_TEXT, + }); + break; + case 'invalidDate': + setValue('time', null); + setError('time', { message: BACKUPS_INVALID_TIME_VALIDATON_TEXT }); } }; - /** Stores changes to the year, month, and day of the DateTime object provided by the calendar */ - const handleDateChange = (newDate: DateTime) => { - validateDateTime(newDate, selectedTime); - setSelectedDate(newDate); - }; - - /** Stores changes to the hours, minutes, and seconds of the DateTime object provided by the time picker */ - const handleTimeChange = (newTime: DateTime | null) => { - validateDateTime(selectedDate, newTime); - setSelectedTime(newTime); - }; - const configureMinTime = () => { - const canApplyMinTime = !!oldestBackup && !!selectedDate; - const isOnMinDate = selectedDate?.day === oldestBackup?.day; + const canApplyMinTime = !!oldestBackup && !!date; + const isOnMinDate = date?.day === oldestBackup?.day; return canApplyMinTime && isOnMinDate ? oldestBackup : undefined; }; const configureMaxTime = () => { const today = DateTime.utc(); - const isOnMaxDate = today.day === selectedDate?.day; + const isOnMaxDate = today.day === date?.day; return isOnMaxDate ? today : undefined; }; @@ -148,12 +149,36 @@ export const DatabaseBackups = () => { value: VersionOption ) => { setVersionOption(value); - setSelectedDate(null); - // Resetting state used for time picker - setSelectedTime(null); - setTimePickerError(''); + setValue('date', null); + setValue('time', null); + clearErrors('time'); }; + const form = useForm({ + defaultValues: { + fork: { + source: database?.id, + restore_time: undefined, + }, + date: null, + time: null, + region: database?.region, + }, + }); + + const { + control, + setValue, + setError, + clearErrors, + formState: { errors }, + } = form; + + const [date, time, region] = useWatch({ + control, + name: ['date', 'time', 'region'], + }); + if (isDefaultDatabase) { return ( @@ -183,106 +208,130 @@ export const DatabaseBackups = () => { {unableToRestoreCopy && ( )} - {isDatabasesV2GA && ( - - } - data-qa-dbaas-radio="Newest" - disabled={disabled} - label="Newest full backup plus incremental" - value="newest" - /> - } - data-qa-dbaas-radio="DateTime" - disabled={disabled} - label="Specific date & time" - value="dateTime" - /> - - )} - - + + + {isDatabasesV2GA && ( + + } + data-qa-dbaas-radio="Newest" + disabled={disabled} + label="Newest full backup plus incremental" + value="newest" + /> + } + data-qa-dbaas-radio="DateTime" + disabled={disabled} + label="Specific date & time" + value="dateTime" + /> + + )} Date - - - isDateOutsideBackup(date, oldestBackup?.startOf('day')) - } - value={selectedDate} + + ( + + { + validateDateTime(newDate, time); + field.onChange(newDate); + }} + shouldDisableDate={(date) => + isDateOutsideBackup(date, oldestBackup?.startOf('day')) + } + value={field.value} + /> + + )} + /> + ( + + Time (UTC) + { + validateDateTime(date, newTime); + field.onChange(newTime); + }} + onError={handleOnError} + sx={{ + width: '220px', + }} + timeSteps={{ hours: 1, minutes: 1, seconds: 1 }} + value={field.value} + views={['hours', 'minutes', 'seconds']} + /> + + )} + /> + + + ( + field.onChange(region.id)} + regions={regionsData ?? []} + value={region ?? null} + /> + )} /> - - - - - Time (UTC) - + + + + {database && ( + setIsRestoreDialogOpen(false)} + open={isRestoreDialogOpen} /> - - - - - - - - - {database && ( - setIsRestoreDialogOpen(false)} - open={isRestoreDialogOpen} - selectedDate={selectedDate} - selectedTime={selectedTime} - /> - )} + )} + +
    ); } diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index 58e1875727a..a1ee61fdb2a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -4,34 +4,42 @@ import { useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { toDatabaseFork, toFormattedDate } from '../../utilities'; +import type { DatabaseBackupsValues } from './DatabaseBackups'; import type { Database } from '@linode/api-v4/lib/databases'; import type { DialogProps } from '@linode/ui'; -import type { DateTime } from 'luxon'; interface Props extends Omit { database: Database; onClose: () => void; open: boolean; - selectedDate?: DateTime | null; - selectedTime?: DateTime | null; } -export const DatabaseBackupDialog = (props: Props) => { - const { database, onClose, open, selectedDate, selectedTime } = props; +export const DatabaseBackupsDialog = (props: Props) => { + const { database, onClose, open } = props; const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const [isRestoring, setIsRestoring] = useState(false); - const formattedDate = toFormattedDate(selectedDate, selectedTime); + const { control } = useFormContext(); + const [date, time, region] = useWatch({ + control, + name: ['date', 'time', 'region'], + }); + + const formattedDate = toFormattedDate(date, time); const { error, mutateAsync: restore } = useRestoreFromBackupMutation( database.engine, - toDatabaseFork(database.id, selectedDate, selectedTime) + { + fork: toDatabaseFork(database.id, date, time), + region, + } ); const handleRestoreDatabase = () => { @@ -94,5 +102,3 @@ export const DatabaseBackupDialog = (props: Props) => { ); }; - -export default DatabaseBackupDialog; diff --git a/packages/queries/.changeset/pr-13097-changed-1763149256983.md b/packages/queries/.changeset/pr-13097-changed-1763149256983.md new file mode 100644 index 00000000000..4797066c966 --- /dev/null +++ b/packages/queries/.changeset/pr-13097-changed-1763149256983.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Changed +--- + +Update database useRestoreFromBackupMutation data to include region ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 5686f21e5f5..867ab791ba9 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -25,10 +25,10 @@ import type { CreateDatabasePayload, Database, DatabaseBackup, + DatabaseBackupsPayload, DatabaseCredentials, DatabaseEngine, DatabaseEngineConfig, - DatabaseFork, DatabaseInstance, DatabaseType, Engine, @@ -267,11 +267,11 @@ export const useLegacyRestoreFromBackupMutation = ( export const useRestoreFromBackupMutation = ( engine: Engine, - fork: DatabaseFork, + data: DatabaseBackupsPayload, ) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => restoreWithBackup(engine, fork), + mutationFn: () => restoreWithBackup(engine, data), onSuccess() { queryClient.invalidateQueries({ queryKey: databaseQueries.databases.queryKey, From b2a852e6ce4d6724700e1d19c1d94fcf3fce5f09 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 19 Nov 2025 15:14:31 +0530 Subject: [PATCH 15/91] upcoming: [DI-28222] - Scaffolding setup for widget level dimension filters and group by issue fix (#13088) * upcoming: [DI-28222] - Skeletal setup for widget level dimension filters * upcoming: [DI-28222] - changeset * upcoming: [DI-28222] - fix types * upcoming: [DI-28222] - use slot props * upcoming: [DI-28222] - add comments * upcoming: [DI-28222] - Scaffolding changes * upcoming: [DI-28222] - Quick changeset updates --- ...r-13088-upcoming-features-1763032920807.md | 5 + .../DimensionFilterValue/constants.ts | 20 ++ .../useFirewallFetchOptions.ts | 7 +- .../features/CloudPulse/GroupBy/utils.test.ts | 17 +- .../src/features/CloudPulse/GroupBy/utils.ts | 34 +++- .../features/CloudPulse/Utils/constants.ts | 9 + .../CloudPulse/Widget/CloudPulseWidget.tsx | 12 ++ .../CloudPulseDimensionFilterDrawer.test.tsx | 64 ++++++ .../CloudPulseDimensionFilterDrawer.tsx | 152 ++++++++++++++ .../CloudPulseDimensionFilterRenderer.tsx | 186 ++++++++++++++++++ .../CloudPulseDimensionFiltersSelect.test.tsx | 55 ++++++ .../CloudPulseDimensionFiltersSelect.tsx | 107 ++++++++++ .../CloudPulseFilterIconWithBadge.test.tsx | 31 +++ .../CloudPulseFilterIconWithBadge.tsx | 49 +++++ .../src/features/CloudPulse/shared/types.ts | 2 +- .../src/queries/cloudpulse/resources.ts | 11 +- 16 files changed, 738 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx diff --git a/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md b/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md new file mode 100644 index 00000000000..7e8f72e9126 --- /dev/null +++ b/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Scaffolding setup for widget level dimension filters in cloudpulse metrics and group by issue fix in cloudpulse metrics ([#13088](https://github.com/linode/manager/pull/13088)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index a4350f92b33..146eb7884ad 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -177,6 +177,26 @@ export const valueFieldConfig: ValueFieldConfigMap = { inputType: 'text', }, }, + nodebalancer_id: { + eq_neq: { + type: 'autocomplete', + multiple: false, + useCustomFetch: 'firewall', + }, + startswith_endswith: { + type: 'textfield', + inputType: 'text', + }, + in: { + type: 'autocomplete', + multiple: true, + useCustomFetch: 'firewall', + }, + '*': { + type: 'textfield', + inputType: 'text', + }, + }, region_id: { eq_neq: { type: 'autocomplete', diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts index 64d2371e15e..e452d640931 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts @@ -33,7 +33,7 @@ export function useFirewallFetchOptions( serviceType, type, scope, - associatedEntityType = 'both', + associatedEntityType, } = props; const supportedRegionIds = @@ -115,7 +115,7 @@ export function useFirewallFetchOptions( serviceType === 'firewall' && filterLabels.includes(dimensionLabel ?? '') && filteredFirewallParentEntityIds.length > 0 && - (associatedEntityType === 'linode' || associatedEntityType === 'both') && + associatedEntityType === 'linode' && supportedRegionIds?.length > 0 ); @@ -128,8 +128,7 @@ export function useFirewallFetchOptions( serviceType === 'firewall' && filterLabels.includes(dimensionLabel ?? '') && filteredFirewallParentEntityIds.length > 0 && - (associatedEntityType === 'nodebalancer' || - associatedEntityType === 'both') && + associatedEntityType === 'nodebalancer' && supportedRegionIds?.length > 0, {}, combinedFilterNodebalancer diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts index ca1459276e7..e880eaf6d17 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts @@ -9,7 +9,7 @@ import { useWidgetDimension, } from './utils'; -import type { MetricDefinition } from '@linode/api-v4'; +import type { Dashboard, MetricDefinition } from '@linode/api-v4'; const metricDefinitions: MetricDefinition[] = [ { @@ -228,13 +228,26 @@ describe('getCommonGroups method test', () => { }); describe('getMetricDimensions method test', () => { + const dashboard: Dashboard = dashboardFactory.build({ + widgets: [ + { + metric: 'Metric 1', + }, + { + metric: 'Metric 2', + }, + { + metric: 'Metric 3', + }, + ], + }); it('should return empty object if metric definitions are empty', () => { const result = getMetricDimensions([]); expect(result).toEqual({}); }); it('should return unique dimensions from metric definitions', () => { - const result = getMetricDimensions(metricDefinitions); + const result = getMetricDimensions(metricDefinitions, dashboard); expect(result).toEqual({ 'Metric 1': [ { label: 'Dim 1', dimension_label: 'Dim 1', values: [] }, diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts index df02fdcf110..67706cdf8c1 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -1,9 +1,13 @@ import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; +import { ASSOCIATED_ENTITY_METRIC_MAP } from '../Utils/constants'; +import { getAssociatedEntityType } from '../Utils/utils'; + import type { GroupByOption } from './CloudPulseGroupByDrawer'; import type { CloudPulseServiceType, + Dashboard, Dimension, MetricDefinition, } from '@linode/api-v4'; @@ -54,7 +58,10 @@ export const useGlobalDimensions = ( if (metricLoading || dashboardLoading) { return { options: [], defaultValue: [], isLoading: true }; } - const metricDimensions = getMetricDimensions(metricDefinition?.data ?? []); + const metricDimensions = getMetricDimensions( + metricDefinition?.data ?? [], + dashboard + ); const commonDimensions = [ defaultOption, ...getCommonDimensions(metricDimensions), @@ -167,14 +174,25 @@ export const useWidgetDimension = ( * @returns transform dimension object with metric as key and dimensions as value */ export const getMetricDimensions = ( - metricDefinition: MetricDefinition[] + metricDefinition: MetricDefinition[], + dashboard?: Dashboard ): MetricDimension => { - return metricDefinition.reduce((acc, { metric, dimensions }) => { - return { - ...acc, - [metric]: dimensions, - }; - }, {}); + if (!dashboard) { + return {}; + } + const associatedEntityType = getAssociatedEntityType(dashboard.id); + return metricDefinition + .filter(({ label }) => + associatedEntityType + ? label.includes(ASSOCIATED_ENTITY_METRIC_MAP[associatedEntityType]) // we need to filter metrics based on associated entity type for firewall dashboards, can be linode, nodebalancer, etc. + : true + ) + .reduce((acc, { metric, dimensions }) => { + return { + ...acc, + [metric]: dimensions, + }; + }, {}); }; /** diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index 82f9e6b4599..ab081c787eb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -1,3 +1,4 @@ +import type { AssociatedEntityType } from '../shared/types'; import type { Filter } from '@linode/api-v4'; export const DASHBOARD_ID = 'dashboardId'; @@ -137,3 +138,11 @@ export const RESOURCE_FILTER_MAP: Record = { ...ORDER_BY_LABLE_ASC, }, }; + +export const ASSOCIATED_ENTITY_METRIC_MAP: Record< + AssociatedEntityType, + string +> = { + linode: 'Linode', + nodebalancer: 'Node Balancer', +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index f3b72ac2f7e..e401c2876cc 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -25,6 +25,7 @@ import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; import { CloudPulseIntervalSelect } from './components/CloudPulseIntervalSelect'; import { CloudPulseLineGraph } from './components/CloudPulseLineGraph'; +import { CloudPulseDimensionFiltersSelect } from './components/DimensionFilters/CloudPulseDimensionFiltersSelect'; import { ZoomIcon } from './components/Zoomer'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; @@ -392,6 +393,17 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { /> )} + {flags.aclp?.showWidgetDimensionFilters && ( + {}} + selectedDimensions={[]} + selectedEntities={entityIds} + selectedRegions={linodeRegion ? [linodeRegion] : undefined} + serviceType={serviceType} + /> + )} { + it('renders the cloud pulse dimension filter drawer successfully', async () => { + const handleClose = vi.fn(); + const handleSelectionChange = vi.fn(); + renderWithTheme( + + ); + + const drawerOpen = screen.getByText('Test Metric'); + expect(drawerOpen).toBeInTheDocument(); + const selectText = screen.getByText('Select up to 5 filters.'); + expect(selectText).toBeInTheDocument(); + await userEvent.click(screen.getByText('Add Filter')); + const applyButton = screen.getByText('Apply'); + expect(applyButton).toBeInTheDocument(); + const cancelButton = screen.getByText('Cancel'); + expect(cancelButton).toBeInTheDocument(); + }); + + it('the clear all button in the drawer works correctly', async () => { + const handleClose = vi.fn(); + const handleSelectionChange = vi.fn(); + const ariaDisabled = 'aria-hidden'; + renderWithTheme( + + ); + + const drawerOpen = screen.getByText('Test Metric'); + expect(drawerOpen).toBeInTheDocument(); + const selectText = screen.getByText('Select up to 5 filters.'); + expect(selectText).toBeInTheDocument(); + expect(screen.getByText('Clear All')).toHaveAttribute(ariaDisabled, 'true'); // nothing is done in form + await userEvent.click(screen.getByText('Add Filter')); + expect(screen.getByText('Clear All')).toHaveAttribute( + ariaDisabled, + 'false' + ); // now we have added one filter field + // validate for form fields to be present + await userEvent.click(screen.getByText('Clear All')); + expect(screen.getByText('Clear All')).toHaveAttribute(ariaDisabled, 'true'); // means the fields are cleared again + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx new file mode 100644 index 00000000000..0db4f2d7921 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx @@ -0,0 +1,152 @@ +import { Button, Drawer, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { CloudPulseDimensionFilterRenderer } from './CloudPulseDimensionFilterRenderer'; + +import type { + MetricsDimensionFilter, + MetricsDimensionFilterForm, +} from './types'; +import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; + +interface CloudPulseDimensionFilterDrawerProps { + /** + * The list of dimensions associated with the selected metric + */ + dimensionOptions: Dimension[]; + /** + * The label for the drawer, typically the name of the metric + */ + drawerLabel: string; + /** + * @param selectedDimensions The list of selected dimension filters + * @param close Property to determine whether to close the drawer after selection + */ + handleSelectionChange: ( + selectedDimensions: MetricsDimensionFilter[], + close: boolean + ) => void; + /** + * The callback to close the drawer + */ + onClose: () => void; + /** + * The boolean value to control the drawer open state + */ + open: boolean; + /** + * The selected dimension filters for the metric + */ + selectedDimensions?: MetricsDimensionFilter[]; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} + +export const CloudPulseDimensionFilterDrawer = React.memo( + (props: CloudPulseDimensionFilterDrawerProps) => { + const { + onClose, + open, + dimensionOptions, + selectedDimensions, + handleSelectionChange, + drawerLabel, + selectedEntities, + serviceType, + selectedRegions, + } = props; + + const [clearAllTrigger, setClearAllTrigger] = React.useState(0); + const [hideClearAll, setHideClearAll] = React.useState( + !selectedDimensions?.length + ); + + const handleClose = React.useCallback(() => { + onClose(); + setClearAllTrigger(0); // After closing the drawer, reset the clear all trigger + }, [onClose]); + + const onDimensionChange = React.useCallback((isDirty: boolean) => { + setHideClearAll(!isDirty); + }, []); + + const handleFormSubmit = React.useCallback( + ({ dimension_filters: dimensionFilters }: MetricsDimensionFilterForm) => { + handleSelectionChange(dimensionFilters, true); + setClearAllTrigger(0); // After submission, reset the clear all trigger + }, + [handleSelectionChange] + ); + + return ( + handleClose()} + open={open} + title="Dimension Filters" + wide + > + + ({ marginTop: -2, font: theme.font.normal })} + variant="h3" + > + {drawerLabel} + + + ({ + font: theme.font.semibold, + marginTop: theme.spacingFunction(12), + })} + > + Select up to 5 filters. + + + + + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx new file mode 100644 index 00000000000..f4cbc390ce6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx @@ -0,0 +1,186 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { ActionsPanel, Box, Button } from '@linode/ui'; +import React from 'react'; +import { + FormProvider, + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; + +import { metricDimensionFiltersSchema } from './schema'; + +import type { + MetricsDimensionFilter, + MetricsDimensionFilterForm, +} from './types'; +import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; + +interface CloudPulseDimensionFilterRendererProps { + /** + * The clear all trigger to reset the form + */ + clearAllTrigger: number; + + /** + * The list of dimensions associated with the selected metric + */ + dimensionOptions: Dimension[]; + /** + * Callback triggered to close the drawer + */ + onClose: () => void; + + /** + * Callback to publish any change in form + * @param isDirty indicated the changes + */ + onDimensionChange: (isDirty: boolean) => void; + /** + * Callback triggered on form submission + * @param data The form data on submission + */ + onSubmit: (data: MetricsDimensionFilterForm) => void; + + /** + * The selected dimension filters for the metric + */ + selectedDimensions?: MetricsDimensionFilter[]; + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} +export const CloudPulseDimensionFilterRenderer = React.memo( + (props: CloudPulseDimensionFilterRendererProps) => { + const { + selectedDimensions, + onSubmit, + clearAllTrigger, + onClose, + onDimensionChange, + } = props; + + const formMethods = useForm({ + defaultValues: { + dimension_filters: + selectedDimensions && selectedDimensions.length > 0 + ? selectedDimensions + : [], + }, + mode: 'onBlur', + resolver: yupResolver(metricDimensionFiltersSchema), + }); + const { control, handleSubmit, formState, setValue, clearErrors } = + formMethods; + + const { isDirty } = formState; + + const formRef = React.useRef(null); + const handleFormSubmit = handleSubmit(async (values) => { + onSubmit({ + dimension_filters: values.dimension_filters, + }); + }); + + const { append, fields } = useFieldArray({ + control, + name: 'dimension_filters', + }); + + const dimensionFilterWatcher = useWatch({ + control, + name: 'dimension_filters', + }); + + React.useEffect(() => { + // set a single empty filter + if (clearAllTrigger > 0) { + setValue('dimension_filters', [], { + shouldDirty: true, + shouldValidate: false, + }); + clearErrors('dimension_filters'); + } + }, [clearAllTrigger, clearErrors, setValue]); + + React.useEffect(() => { + if (fields.length) { + onDimensionChange(true); + } else { + onDimensionChange(false); + } + }, [fields, onDimensionChange]); + + return ( + +
    + + {/* upcoming: Integrate with dimension filter row component */} + + + 0 && !isDirty, + sx: { + width: '65px', + }, + }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: () => { + onClose(); + }, + buttonType: 'outlined', + sx: { + width: '70px', + }, + }} + sx={(theme) => ({ + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacingFunction(12), + })} + /> + +
    + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx new file mode 100644 index 00000000000..60c7f25d0f8 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.test.tsx @@ -0,0 +1,55 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFiltersSelect } from './CloudPulseDimensionFiltersSelect'; + +import type { Dimension } from '@linode/api-v4'; + +const dimensionOptions: Dimension[] = [ + { + dimension_label: 'test', + values: ['XYZ', 'ZYX', 'YZX'], + label: 'Test', + }, + { + dimension_label: 'sample', + values: ['VALUE1', 'VALUE2', 'VALUE3'], + label: 'Sample', + }, +]; + +describe('Tests for CloudPulse Dimension Filters Select', () => { + it('renders the CloudPulse Dimension Filters with icon and drawer', async () => { + const handleSubmit = vi.fn(); + renderWithTheme( + + ); + const badge = screen.queryByText('1'); + expect(badge).toBeInTheDocument(); // should be there since we passed a selected filter + await userEvent.click(screen.getByTestId('dimension-filter')); // click on icon + // check for drawer fields + const drawerOpen = screen.getByText('Test Metric'); + expect(drawerOpen).toBeInTheDocument(); + const selectText = screen.getByText('Select up to 5 filters.'); + expect(selectText).toBeInTheDocument(); + const applyButton = screen.getByText('Apply'); + expect(applyButton).toBeInTheDocument(); + const cancelButton = screen.getByText('Cancel'); + expect(cancelButton).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx new file mode 100644 index 00000000000..e96bd0abea7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFiltersSelect.tsx @@ -0,0 +1,107 @@ +import { IconButton } from '@linode/ui'; +import React from 'react'; + +import { CloudPulseTooltip } from 'src/features/CloudPulse/shared/CloudPulseTooltip'; + +import { CloudPulseDimensionFilterDrawer } from './CloudPulseDimensionFilterDrawer'; +import { CloudPulseDimensionFilterIconWithBadge } from './CloudPulseFilterIconWithBadge'; + +import type { MetricsDimensionFilter } from './types'; +import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; + +interface CloudPulseDimensionFiltersSelectProps { + /** + * The list of available dimensions for the selected metric + */ + dimensionOptions: Dimension[]; + /** + * The label for the drawer, typically the name of the metric + */ + drawerLabel: string; + /** + * @param selectedDimensions The list of selected dimension filters + */ + handleSelectionChange: (selectedDimensions: MetricsDimensionFilter[]) => void; + /** + * The selected dimension filters for the metric + */ + selectedDimensions?: MetricsDimensionFilter[]; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} + +export const CloudPulseDimensionFiltersSelect = React.memo( + (props: CloudPulseDimensionFiltersSelectProps) => { + const { + dimensionOptions, + selectedDimensions, + handleSelectionChange, + drawerLabel, + selectedEntities, + serviceType, + selectedRegions, + } = props; + const [open, setOpen] = React.useState(false); + + const handleChangeInSelection = React.useCallback( + (selectedValue: MetricsDimensionFilter[], close: boolean) => { + if (close) { + handleSelectionChange(selectedValue); + setOpen(false); + } + }, + [handleSelectionChange] + ); + + const selectionCount = selectedDimensions?.length ?? 0; + + return ( + <> + + setOpen(true)} + size="small" + sx={(theme) => ({ + marginBlockEnd: 'auto', + color: selectionCount + ? theme.color.buttonPrimaryHover + : 'inherit', + padding: 0, + })} + > + + + + setOpen(false)} + open={open} + selectedDimensions={selectedDimensions} + selectedEntities={selectedEntities} + selectedRegions={selectedRegions} + serviceType={serviceType} + /> + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx new file mode 100644 index 00000000000..64d61c6688e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.test.tsx @@ -0,0 +1,31 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFilterIconWithBadge } from './CloudPulseFilterIconWithBadge'; + +describe('CloudPulseDimensionFilterIconWithBadge', () => { + it('renders the badge with correct count when count > 0', () => { + renderWithTheme(); + + // Badge content should be visible + const badge = screen.getByText('5'); + expect(badge).toBeInTheDocument(); + + const filter = screen.getByTestId('filled-filter'); + expect(filter).toBeInTheDocument(); + }); + + it('does not render the badge when count = 0', () => { + renderWithTheme(); + + // Badge should not be visible + const badge = screen.queryByText('0'); + expect(badge).not.toBeInTheDocument(); + + const filter = screen.getByTestId('filter'); + expect(filter).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx new file mode 100644 index 00000000000..73ae0d49522 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseFilterIconWithBadge.tsx @@ -0,0 +1,49 @@ +import Badge from '@mui/material/Badge'; +import React from 'react'; + +import FilterIcon from 'src/assets/icons/filter.svg'; +import FilledFilterIcon from 'src/assets/icons/filterfilled.svg'; +interface CloudPulseDimensionFilterIconWithBadgeProps { + /** + * The count to be displayed in the badge + */ + count: number; +} + +export const CloudPulseDimensionFilterIconWithBadge = React.memo( + ({ count }: CloudPulseDimensionFilterIconWithBadgeProps) => { + return ( + ({ + top: 3, // nudge up + right: 3, // nudge right + minWidth: 8, + width: 15, + height: 16, + borderRadius: '100%', + fontSize: 10, + lineHeight: 1, + color: theme.tokens.color.Neutrals.White, + backgroundColor: theme.palette.error.dark, + }), + }, + }} + > + {count === 0 ? ( + + ) : ( + + )} + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/types.ts b/packages/manager/src/features/CloudPulse/shared/types.ts index 083a65b056d..a81f969298d 100644 --- a/packages/manager/src/features/CloudPulse/shared/types.ts +++ b/packages/manager/src/features/CloudPulse/shared/types.ts @@ -9,7 +9,7 @@ export type TransformFunction = (value: string) => string; export type TransformFunctionMap = Record; -export type AssociatedEntityType = 'both' | 'linode' | 'nodebalancer'; +export type AssociatedEntityType = 'linode' | 'nodebalancer'; export interface FirewallEntity { /** diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 0f629381345..1dfbe72d55d 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -12,7 +12,7 @@ export const useResourcesQuery = ( resourceType: string | undefined, params?: Params, filters?: Filter, - associatedEntityType: AssociatedEntityType = 'both', + associatedEntityType?: AssociatedEntityType, filterFn?: (resources: QueryFunctionType) => QueryFunctionType ) => useQuery({ @@ -30,16 +30,11 @@ export const useResourcesQuery = ( // handle separately for firewall resource type if (resourceType === 'firewall') { resource.entities?.forEach((entity: FirewallDeviceEntity) => { - if ( - (entity.type === associatedEntityType || - associatedEntityType === 'both') && - entity.label - ) { + if (entity.type === associatedEntityType && entity.label) { entities[String(entity.id)] = entity.label; } if ( - (associatedEntityType === 'linode' || - associatedEntityType === 'both') && + associatedEntityType === 'linode' && entity.type === 'linode_interface' && entity.parent_entity?.label ) { From a69c687eb5055950358ddd6168f36782dadc1dcf Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Wed, 19 Nov 2025 15:43:00 +0530 Subject: [PATCH 16/91] upcoming: [UIE-9669] - Implement mocks and factories for Network LoadBalancer (#13104) * upcoming: [UIE-9669] - implement mocks and factories for Network LoadBalancer * Added changeset: Implement mocks and factories for Network LoadBalancer * PR feedback --- ...r-13104-upcoming-features-1763472761959.md | 5 ++ packages/manager/src/factories/account.ts | 1 + packages/manager/src/factories/index.ts | 1 + .../src/factories/networkLoadBalancer.ts | 43 +++++++++++++ packages/manager/src/mocks/serverHandlers.ts | 62 +++++++++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md create mode 100644 packages/manager/src/factories/networkLoadBalancer.ts diff --git a/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md b/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md new file mode 100644 index 00000000000..ff43e8785fe --- /dev/null +++ b/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + + Implement mocks and factories for Network LoadBalancer ([#13104](https://github.com/linode/manager/pull/13104)) diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 50b1d74f529..f463fe6f83c 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -48,6 +48,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Managed Databases', 'Managed Databases Beta', 'NETINT Quadra T1U', + 'Network LoadBalancer', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/src/factories/index.ts b/packages/manager/src/factories/index.ts index d075b7f42c7..03dd55cb4cf 100644 --- a/packages/manager/src/factories/index.ts +++ b/packages/manager/src/factories/index.ts @@ -33,6 +33,7 @@ export * from './longviewSubscription'; export * from './longviewTopProcesses'; export * from './managed'; export * from './networking'; +export * from './networkLoadBalancer'; export * from './notification'; export * from './oauth'; export * from './objectStorage'; diff --git a/packages/manager/src/factories/networkLoadBalancer.ts b/packages/manager/src/factories/networkLoadBalancer.ts new file mode 100644 index 00000000000..e404748a6bd --- /dev/null +++ b/packages/manager/src/factories/networkLoadBalancer.ts @@ -0,0 +1,43 @@ +import { Factory } from '@linode/utilities'; + +import type { + NetworkLoadBalancer, + NetworkLoadBalancerListener, + NetworkLoadBalancerNode, +} from '@linode/api-v4'; + +export const networkLoadBalancerFactory = + Factory.Sync.makeFactory({ + id: Factory.each((id) => id), + label: Factory.each((id) => `nlb-${id}`), + region: 'us-east', + address_v4: '192.168.1.1', + address_v6: '2001:db8:85a3::8a2e:370:7334', + created: '2023-01-01T00:00:00Z', + updated: '2023-01-02T00:00:00Z', + status: 'active', + last_composite_updated: '', + listeners: [], + }); + +export const networkLoadBalancerListenerFactory = + Factory.Sync.makeFactory({ + created: '2023-01-01T00:00:00Z', + id: Factory.each((id) => id), + label: Factory.each((id) => `nlb-listener-${id}`), + updated: '2023-01-01T00:00:00Z', + port: 80, + protocol: 'tcp', + }); + +export const networkLoadBalancerNodeFactory = + Factory.Sync.makeFactory({ + address_v6: '2001:db8:85a3::8a2e:370:7334', + created: '2023-01-01T00:00:00Z', + id: Factory.each((id) => id), + label: Factory.each((id) => `nlb-node-${id}`), + updated: '2023-01-01T00:00:00Z', + linode_id: Factory.each((id) => id), + weight: 0, + weight_updated: '', + }); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index be460a52f25..d6b182d9091 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -90,6 +90,9 @@ import { managedStatsFactory, monitorFactory, mysqlConfigResponse, + networkLoadBalancerFactory, + networkLoadBalancerListenerFactory, + networkLoadBalancerNodeFactory, nodeBalancerTypeFactory, nodePoolFactory, notificationChannelFactory, @@ -536,6 +539,64 @@ const entities = [ }), ]; +const netLoadBalancers = [ + http.get('*/v4beta/netloadbalancers', () => { + const nlbWithListener1 = networkLoadBalancerFactory.buildList(5, { + listeners: networkLoadBalancerListenerFactory.buildList( + Math.floor(Math.random() * 10) + 1 + ), + }); + const nlbWithListener2 = networkLoadBalancerFactory.buildList(5, { + region: 'eu-west', + lke_cluster: { + id: 1, + label: 'lke-e-123', + type: 'lkecluster', + url: 'v4/lke/clusters/1', + }, + listeners: networkLoadBalancerListenerFactory.buildList( + Math.floor(Math.random() * 20) + 1 + ), + }); + const nlbWithoutListener = networkLoadBalancerFactory.buildList(20); + return HttpResponse.json( + makeResourcePage([ + ...nlbWithListener1, + ...nlbWithListener2, + ...nlbWithoutListener, + ]) + ); + }), + http.get('*/v4beta/netloadbalancers/:id', () => { + return HttpResponse.json( + networkLoadBalancerFactory.build({ + lke_cluster: { + id: 1, + label: 'lke-e-123', + type: 'lkecluster', + url: 'v4/lke/clusters/1', + }, + listeners: networkLoadBalancerListenerFactory.buildList( + Math.floor(Math.random() * 10) + 1 + ), + }) + ); + }), + http.get('*/v4beta/netloadbalancers/:id/listeners', () => { + return HttpResponse.json( + makeResourcePage(networkLoadBalancerListenerFactory.buildList(30)) + ); + }), + http.get('*/v4beta/netloadbalancers/:id/listeners/:listenerId', () => { + return HttpResponse.json(networkLoadBalancerListenerFactory.build()); + }), + http.get('*/v4beta/netloadbalancers/:id/listeners/:listenerId/nodes', () => { + return HttpResponse.json( + makeResourcePage(networkLoadBalancerNodeFactory.buildList(30)) + ); + }), +]; + const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); @@ -3999,6 +4060,7 @@ export const handlers = [ ...databases, ...vpc, ...entities, + ...netLoadBalancers, http.get('*/v4beta/maintenance/policies', () => { return HttpResponse.json( makeResourcePage(maintenancePolicyFactory.buildList(2)) From bb15097d67cc87abf255725ad6fa334ce1114b23 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 19 Nov 2025 17:10:27 +0530 Subject: [PATCH 17/91] upcoming: [UIE-9507, UIE-9510] - Add New Firewall RuleSet Row Layout + Factories/Mocks (#13079) * Save progress * Update tests * Few more changes * Add more changes * Clean up tests * Few changes * Layout updates * Update tests * Add ruleset loading state * Clean up mocks * Fix mocks * Add comments to the type * Added changeset: Update FirewallRuleType to support ruleset * Added changeset: Update FirewallRuleTypeSchema to support ruleset * Added changeset: Add new Firewall RuleSet row layout * Update ruleset action text - Delete to Remove * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Revert Action column movement since its not yet confirmed * Few updates * Few fixes * Update cypress component tests --- ...r-13079-upcoming-features-1762861826541.md | 5 + packages/api-v4/src/firewalls/types.ts | 16 +- ...r-13079-upcoming-features-1762861957439.md | 5 + .../firewalls/firewall-rule-table.spec.tsx | 6 +- .../core/firewalls/update-firewall.spec.ts | 8 +- packages/manager/src/factories/firewalls.ts | 38 ++++- .../Rules/FirewallRuleActionMenu.test.tsx | 30 ++++ .../Rules/FirewallRuleActionMenu.tsx | 65 ++++--- .../Rules/FirewallRuleDrawer.tsx | 6 +- .../Rules/FirewallRuleDrawer.utils.ts | 10 +- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 6 +- .../Rules/FirewallRuleTable.tsx | 160 +++++++++++++----- packages/manager/src/mocks/serverHandlers.ts | 76 ++++++++- ...r-13079-upcoming-features-1762861891552.md | 5 + packages/validation/src/firewalls.schema.ts | 31 ++-- 15 files changed, 361 insertions(+), 106 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md create mode 100644 packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md create mode 100644 packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.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)) diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 372d1f0d616..8e163648cf9 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -36,16 +36,26 @@ 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; + 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; + /** + * Present when the object represents a ruleset reference. + */ + ruleset?: null | number; } export interface FirewallDeviceEntity { 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)) 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..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 @@ -122,13 +122,13 @@ const verifyFirewallWithRules = ({ .within(() => { if (isSmallViewport) { // Column 'Protocol' is not visible for smaller screens. - cy.findByText(rule.protocol).should('not.exist'); + cy.findByText(rule.protocol!).should('not.exist'); } else { - cy.findByText(rule.protocol).should('be.visible'); + cy.findByText(rule.protocol!).should('be.visible'); } cy.findByText(rule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(rule.action)).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 60999c917d6..0869a42dddb 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -227,9 +227,9 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(inboundRule.protocol).should('be.visible'); + cy.findByText(inboundRule.protocol!).should('be.visible'); cy.findByText(inboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(inboundRule.action)).should( + cy.findByText(getRuleActionLabel(inboundRule.action!)).should( 'be.visible' ); }); @@ -242,9 +242,9 @@ describe('update firewall', () => { .should('be.visible') .closest('tr') .within(() => { - cy.findByText(outboundRule.protocol).should('be.visible'); + cy.findByText(outboundRule.protocol!).should('be.visible'); cy.findByText(outboundRule.ports!).should('be.visible'); - cy.findByText(getRuleActionLabel(outboundRule.action)).should( + cy.findByText(getRuleActionLabel(outboundRule.action!)).should( 'be.visible' ); }); 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..945ef63c842 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(), + isRuleSetRowEnabled: false, idx: 1, }; @@ -25,8 +26,37 @@ 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 "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('Remove')).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..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'; @@ -13,16 +14,20 @@ 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; + isRuleSetRowEnabled: boolean; } 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.'; const { disabled, @@ -30,47 +35,57 @@ export const FirewallRuleActionMenu = React.memo( handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx, + isRuleSetRowEnabled, ...actionMenuProps } = props; const actions: Action[] = [ { - disabled, + disabled: disabled || isRuleSetRowEnabled, onClick: () => { - handleOpenRuleDrawerForEditing(idx); + handleOpenRuleDrawerForEditing?.(idx); }, title: 'Edit', + tooltip: isRuleSetRowEnabled ? rulesetEditActionToolTipText : undefined, }, - { - disabled, - onClick: () => { - handleCloneFirewallRule(idx); - }, - title: 'Clone', - }, + ...(!isRuleSetRowEnabled + ? [ + { + disabled, + onClick: () => { + handleCloneFirewallRule?.(idx); + }, + title: 'Clone', + }, + ] + : []), { disabled, onClick: () => { handleDeleteFirewallRule(idx); }, - title: 'Delete', + title: isRuleSetRowEnabled ? 'Remove' : 'Delete', }, ]; return ( <> - {!matchesSmDown && - actions.map((action) => { - return ( - - ); - })} - {matchesSmDown && ( + {!matchesLgDown && ( + + {actions.map((action) => { + return ( + + ); + })} + + )} + {matchesLgDown && ( { - const ports = itemsToPortString(presetPorts, values.ports); + const ports = itemsToPortString(presetPorts, values.ports!); const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses, 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 25a7c9ac36d..63e046e57a5 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!, 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'; } @@ -163,7 +163,7 @@ export const getInitialFormValues = ( ports: portStringToItems(ruleToModify.ports)[1], protocol: ruleToModify.protocol, type: predefinedFirewallFromRule(ruleToModify) || '', - }; + } as FormState; }; export const getInitialAddressFormValue = ( @@ -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..2999b46c527 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -14,6 +14,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useFirewallRuleSetQuery } from '@linode/queries'; import { Box, LinkButton, Typography } from '@linode/ui'; import { Autocomplete } from '@linode/ui'; import { Hidden } from '@linode/ui'; @@ -22,8 +23,11 @@ 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'; +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'; @@ -31,10 +35,12 @@ 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, predefinedFirewallFromRule as ruleToPredefinedFirewall, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; @@ -55,19 +61,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?: null | string; description?: null | string; errors?: FirewallRuleError[]; id: number; index: number; label?: null | string; originalIndex: number; - ports: string; - protocol: string; + ports?: null | string; + protocol?: null | string; + ruleset?: null | number; status: RuleStatus; type: string; } @@ -113,7 +121,6 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const theme = useTheme(); const smDown = useMediaQuery(theme.breakpoints.down('sm')); - const mdDown = useMediaQuery(theme.breakpoints.down('md')); const lgDown = useMediaQuery(theme.breakpoints.down('lg')); const addressColumnLabel = @@ -196,13 +203,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { Label @@ -300,17 +301,34 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { ports, protocol, status, + ruleset, } = props; + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const isRuleSetRow = Boolean(ruleset); + const isRuleSetRowEnabled = + isRuleSetRow && isFirewallRulesetsPrefixlistsEnabled; + + const { data: rulesetDetails, isLoading: isRuleSetLoading } = + useFirewallRuleSetQuery( + ruleset ?? -1, + ruleset !== undefined && isRuleSetRowEnabled + ); + const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, idx: index, + isRuleSetRowEnabled, }; const theme = useTheme(); + const lgDown = useMediaQuery(theme.breakpoints.down('lg')); + const smDown = useMediaQuery(theme.breakpoints.down('sm')); const { active, @@ -344,6 +362,25 @@ 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) { + return ; + } + 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} + + + + + + + + + {capitalize(action?.toLocaleLowerCase() ?? '')} + + + )} + + {isRuleSetRowEnabled && ( + <> + + + + + {rulesetDetails && ( + {}}>{rulesetDetails?.label} + )} + + + + ID:  + {ruleset} + + + + + + + + )} + {status !== 'NOT_MODIFIED' ? ( diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index d6b182d9091..4c98a005962 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -66,6 +66,10 @@ import { firewallFactory, firewallMetricDefinitionsResponse, firewallMetricRulesFactory, + firewallPrefixListFactory, + firewallRuleFactory, + firewallRuleSetFactory, + firewallRulesFactory, imageFactory, incidentResponseFactory, invoiceFactory, @@ -1300,6 +1304,18 @@ export const handlers = [ }), ], }), + // Firewall with the Rule and RuleSet Reference + firewallFactory.build({ + id: 1001, + label: 'firewall with rule and ruleset reference', + rules: firewallRulesFactory.build({ + 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), + ], + }), + }), ]; firewallFactory.resetSequenceNumber(); return HttpResponse.json(makeResourcePage(firewalls)); @@ -1308,8 +1324,64 @@ export const handlers = [ const devices = firewallDeviceFactory.buildList(10); return HttpResponse.json(makeResourcePage(devices)); }), - http.get('*/v4beta/networking/firewalls/:firewallId', () => { - const firewall = firewallFactory.build(); + 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 getRuleSetDetailById = (rulesetId: number) => { + switch (rulesetId) { + case 123: + // Ruleset with 123 Id + return firewallRuleSetFactory.build({ + id: 123, + }); + case 123456789: + // Ruleset with larger ID 123456789 & Longer label with 32 chars + return firewallRuleSetFactory.build({ + id: 123456789, + label: 'ruleset-with-a-longer-32ch-label', + }); + default: + return firewallRuleSetFactory.build(); + } + }; + const firewallRuleSet = getRuleSetDetailById(Number(params.rulesetId)); + return HttpResponse.json(firewallRuleSet); + } + ), + http.get('*/v4beta/networking/firewalls/:firewallId', ({ params }) => { + const firewall = + params.firewallId === '1001' + ? firewallFactory.build({ + id: 1001, + label: 'firewall with rule and ruleset reference', + rules: firewallRulesFactory.build({ + 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(1, { + addresses: { + ipv4: ['192.168.1.213', '172.31.255.255'], + ipv6: [ + '2001:db8:85a3::8a2e:370:7334/128', + '2001:db8:85a3::8a2e:371:7335/128', + ], + }, + ports: '22, 53, 80, 100, 443, 3306', + protocol: 'IPENCAP', + action: 'ACCEPT', + }), + ], + }), + }) + : firewallFactory.build(); return HttpResponse.json(firewall); }), http.put<{}, { status: FirewallStatus }>( 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)) 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 57def45aed5d7b76a2fd0ef9c16c0f0ccb25ace0 Mon Sep 17 00:00:00 2001 From: tvijay-akamai <51293194+tvijay-akamai@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:04:45 +0530 Subject: [PATCH 18/91] feat: [UIE-9539] Implemented Filtering for plans table (#13093) * feat: [UIE-9539] Implemented Filtering for plans table * feat: [UIE-9539] adding changeset * feat: [UIE-9539] fixing tests * feat: [UIE-9539] ux enhanancement and addressed code review comments * feat: [UIE-9539] removed context usage since tabs does not rerender --- .../pr-13093-added-1766788494029.md | 5 + .../PaginationFooter/PaginationFooter.tsx | 8 +- .../KubernetesPlanContainer.tsx | 157 +++++++++++- .../KubernetesPlanSelectionTable.tsx | 7 + .../KubernetesPlansPanel.tsx | 6 + .../PlansPanel/DedicatedPlanFilters.tsx | 231 ++++++++++++++++++ .../PlansPanel/PlanContainer.test.tsx | 43 +++- .../components/PlansPanel/PlanContainer.tsx | 160 +++++++++++- .../PlansPanel/PlanSelectionTable.tsx | 7 + .../components/PlansPanel/PlansPanel.tsx | 6 + .../components/PlansPanel/constants.ts | 47 ++++ .../features/components/PlansPanel/types.ts | 11 + .../PlansPanel/types/planFilters.ts | 155 ++++++++++++ .../PlansPanel/utils/planFilters.test.ts | 211 ++++++++++++++++ .../PlansPanel/utils/planFilters.ts | 207 ++++++++++++++++ 15 files changed, 1240 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-13093-added-1766788494029.md create mode 100644 packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx create mode 100644 packages/manager/src/features/components/PlansPanel/types/planFilters.ts create mode 100644 packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts create mode 100644 packages/manager/src/features/components/PlansPanel/utils/planFilters.ts diff --git a/packages/manager/.changeset/pr-13093-added-1766788494029.md b/packages/manager/.changeset/pr-13093-added-1766788494029.md new file mode 100644 index 00000000000..401b517416d --- /dev/null +++ b/packages/manager/.changeset/pr-13093-added-1766788494029.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement Filtering for Plans table ([#13093](https://github.com/linode/manager/pull/13093)) diff --git a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx index 98796549440..81ef208c458 100644 --- a/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx +++ b/packages/manager/src/components/PaginationFooter/PaginationFooter.tsx @@ -26,6 +26,7 @@ interface Props extends PaginationProps { customOptions?: PaginationOption[]; handlePageChange: (page: number) => void; handleSizeChange: (pageSize: number) => void; + minPageSize?: number; } const baseOptions = [ @@ -43,13 +44,14 @@ export const PaginationFooter = (props: Props) => { fixedSize, handlePageChange, handleSizeChange, + minPageSize = MIN_PAGE_SIZE, page, pageSize, showAll, sx, } = props; - if (count <= MIN_PAGE_SIZE && !fixedSize) { + if (count <= minPageSize && !fixedSize) { return null; } @@ -103,8 +105,8 @@ export const PaginationFooter = (props: Props) => { onChange={(_e, value) => handleSizeChange(Number(value.value))} options={finalOptions} value={{ - label: defaultPagination?.label ?? '', - value: defaultPagination?.value ?? '', + label: defaultPagination?.label ?? finalOptions[0]?.label ?? '', + value: defaultPagination?.value ?? finalOptions[0]?.value ?? '', }} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index f15d2c79c98..e17b3c48702 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -17,6 +17,57 @@ import type { KubernetesTier, LinodeTypeClass } from '@linode/api-v4'; import type { PlanSelectionDividers } from 'src/features/components/PlansPanel/PlanContainer'; import type { PlanWithAvailability } from 'src/features/components/PlansPanel/types'; +export interface PlanFilterRenderArgs { + /** + * Callback to notify parent of filter result + */ + onResult: (result: PlanFilterRenderResult) => void; + + /** + * All available plans (unfiltered) + */ + plans: PlanWithAvailability[]; + + /** + * Plan type/class (e.g., 'dedicated', 'gpu') + */ + planType: LinodeTypeClass | undefined; + + /** + * Reset pagination back to the first page + */ + resetPagination: () => void; + + /** + * Whether filters should be disabled (e.g., no region selected) + */ + shouldDisableFilters?: boolean; +} + +export interface PlanFilterRenderResult { + /** + * Optional empty state configuration for the table body + */ + emptyState?: null | { + message: string; + }; + + /** + * Filtered plans after applying filters + */ + filteredPlans: PlanWithAvailability[]; + + /** + * The filter UI component + */ + filterUI: React.ReactNode; + + /** + * Whether any filters are currently active + */ + hasActiveFilters: boolean; +} + export interface KubernetesPlanContainerProps { allDisabledPlans: PlanWithAvailability[]; getTypeCount: (planId: string) => number; @@ -24,6 +75,13 @@ export interface KubernetesPlanContainerProps { hasMajorityOfPlansDisabled: boolean; onAdd?: (key: string, value: number) => void; onSelect: (key: string) => void; + + /** + * Render prop for custom filter UI per tab + * Receives plan data and pagination helpers, returns a React element + */ + planFilters?: (args: PlanFilterRenderArgs) => React.ReactNode; + plans: PlanWithAvailability[]; planType?: LinodeTypeClass; selectedId?: string; @@ -42,6 +100,7 @@ export const KubernetesPlanContainer = ( onAdd, handleConfigurePool, onSelect, + planFilters, planType, plans, selectedId, @@ -53,9 +112,6 @@ export const KubernetesPlanContainer = ( const shouldDisplayNoRegionSelectedMessage = !selectedRegionId; const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled(); - // Feature gate for pagination functionality - const isK8PlanPaginationEnabled = isGenerationalPlansEnabled; - /** * This features allows us to divide the GPU plans into two separate tables. * This can be re-used for other plan types in the future. @@ -121,8 +177,74 @@ export const KubernetesPlanContainer = ( [planType] ); + // State to hold filter result from the filter component + const [filterResult, setFilterResult] = + React.useState(null); + + // Ref to store the pagination handler from Paginate component + // This allows us to reset pagination when filters change + const handlePageChangeRef = React.useRef<((page: number) => void) | null>( + null + ); + + // Callback for filter component to update result + const handleFilterResult = React.useCallback( + (result: PlanFilterRenderResult) => { + setFilterResult(result); + }, + [] + ); + + // Callback to reset pagination to page 1 + // Used by filter components when filters change + const resetPagination = React.useCallback(() => { + // Call the pagination handler to go to page 1 + handlePageChangeRef.current?.(1); + }, []); + + // Create filter state manager component if planFilters render prop is provided + // This component returns null but manages filter state via local React state + // State persists when switching tabs because Reach UI TabPanels stay mounted + // and communicates filtered results back to parent via the onResult callback + const filterStateManager = React.useMemo(() => { + if (isGenerationalPlansEnabled && planFilters) { + return planFilters({ + onResult: handleFilterResult, + planType, + plans, + resetPagination, + shouldDisableFilters: shouldDisplayNoRegionSelectedMessage, + }); + } + return null; + }, [ + isGenerationalPlansEnabled, + planFilters, + planType, + plans, + handleFilterResult, + resetPagination, + shouldDisplayNoRegionSelectedMessage, + ]); + + // Clear filter result when filters are disabled or removed + React.useEffect(() => { + if (!planFilters || !isGenerationalPlansEnabled) { + setFilterResult(null); + } + }, [isGenerationalPlansEnabled, planFilters]); + + // Use filtered plans if available, otherwise use all plans + const effectiveFilterResult = isGenerationalPlansEnabled + ? filterResult + : null; + const plansToDisplay = effectiveFilterResult?.filteredPlans ?? plans; + const tableEmptyState = shouldDisplayNoRegionSelectedMessage + ? null + : (effectiveFilterResult?.emptyState ?? null); + // Feature gate: if pagination is disabled, render the old way - if (!isK8PlanPaginationEnabled) { + if (!isGenerationalPlansEnabled) { return ( @@ -203,7 +325,7 @@ export const KubernetesPlanContainer = ( // Pagination enabled: use new paginated rendering return ( { - const shouldDisplayPagination = - !shouldDisplayNoRegionSelectedMessage && - count > PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value; + // Store the handlePageChange function in ref so filters can call it + handlePageChangeRef.current = handlePageChange; + + const shouldDisplayPagination = !shouldDisplayNoRegionSelectedMessage; const dividerTables = planSelectionDividers .map((divider) => ({ @@ -248,6 +371,13 @@ export const KubernetesPlanContainer = ( return ( <> + {filterStateManager} + + {/* Render filter UI that was passed via callback */} + {effectiveFilterResult?.filterUI && ( + {effectiveFilterResult.filterUI} + )} + {shouldDisplayNoRegionSelectedMessage ? ( + ) : tableEmptyState ? ( + ({ + '& p': { fontSize: theme.tokens.font.FontSize.Xs }, + })} + text={tableEmptyState.message} + variant="info" + /> ) : hasActiveGpuDivider ? ( activeDivider.tables.map(({ filterOptions, plans }, idx) => ( React.JSX.Element[]; @@ -33,6 +37,7 @@ export const KubernetesPlanSelectionTable = ( props: KubernetesPlanSelectionTableProps ) => { const { + filterEmptyStateMessage, filterOptions, plans, renderPlanSelection, @@ -79,6 +84,8 @@ export const KubernetesPlanSelectionTable = ( colSpan={9} message={PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE} /> + ) : filterEmptyStateMessage ? ( + ) : ( ((plans && renderPlanSelection?.(plans)) ?? null) )} diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 4d7b7255034..35fdc3e77f8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import type { JSX } from 'react'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; +import { createDedicatedPlanFiltersRenderProp } from 'src/features/components/PlansPanel/DedicatedPlanFilters'; import { PlanInformation } from 'src/features/components/PlansPanel/PlanInformation'; import { determineInitialPlanCategoryTab, @@ -152,6 +153,11 @@ export const KubernetesPlansPanel = (props: Props) => { hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} onAdd={onAdd} onSelect={onSelect} + planFilters={ + plan === 'dedicated' + ? createDedicatedPlanFiltersRenderProp() + : undefined + } plans={plansForThisLinodeTypeClass} planType={plan} selectedId={selectedId} diff --git a/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx b/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx new file mode 100644 index 00000000000..a7bc0818739 --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx @@ -0,0 +1,231 @@ +/** + * DedicatedPlanFilters Component + * + * Filter component for Dedicated CPU plans. Composes Select dropdowns and + * uses local React state to manage filter selections. + * + * Note: State persists when switching between plan tabs because Reach UI + * TabPanels keep all tabs mounted in the DOM (only visibility changes). + */ + +import { Select } from '@linode/ui'; +import * as React from 'react'; + +import { + PLAN_FILTER_GENERATION_ALL, + PLAN_FILTER_GENERATION_G6, + PLAN_FILTER_GENERATION_G7, + PLAN_FILTER_GENERATION_G8, + PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, + PLAN_FILTER_TYPE_GENERAL_PURPOSE, +} from './constants'; +import { applyPlanFilters, supportsTypeFiltering } from './utils/planFilters'; + +import type { + PlanFilterRenderArgs, + PlanFilterRenderResult, +} from './PlanContainer'; +import type { PlanWithAvailability } from './types'; +import type { PlanFilterGeneration, PlanFilterType } from './types/planFilters'; +import type { SelectOption } from '@linode/ui'; + +const GENERATION_OPTIONS: SelectOption[] = [ + { label: 'All', value: PLAN_FILTER_GENERATION_ALL }, + { label: 'G8 Dedicated', value: PLAN_FILTER_GENERATION_G8 }, + { label: 'G7 Dedicated', value: PLAN_FILTER_GENERATION_G7 }, + { label: 'G6 Dedicated', value: PLAN_FILTER_GENERATION_G6 }, +]; + +const TYPE_OPTIONS_WITH_SUBTYPES: SelectOption[] = [ + { label: 'All', value: PLAN_FILTER_TYPE_ALL }, + { label: 'Compute Optimized', value: PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED }, + { label: 'General Purpose', value: PLAN_FILTER_TYPE_GENERAL_PURPOSE }, +]; + +const TYPE_OPTIONS_ALL_ONLY: SelectOption[] = [ + { label: 'All', value: PLAN_FILTER_TYPE_ALL }, +]; + +interface DedicatedPlanFiltersComponentProps { + disabled?: boolean; + onResult: (result: PlanFilterRenderResult) => void; + plans: PlanWithAvailability[]; + resetPagination: () => void; +} + +const DedicatedPlanFiltersComponent = React.memo( + (props: DedicatedPlanFiltersComponentProps) => { + const { disabled = false, onResult, plans, resetPagination } = props; + + // Local state - persists automatically because component stays mounted + const [generation, setGeneration] = React.useState( + PLAN_FILTER_GENERATION_ALL + ); + + const [type, setType] = + React.useState(PLAN_FILTER_TYPE_ALL); + + const typeFilteringSupported = supportsTypeFiltering(generation); + + const typeOptions = typeFilteringSupported + ? TYPE_OPTIONS_WITH_SUBTYPES + : TYPE_OPTIONS_ALL_ONLY; + + // Disable type filter if: + // 1. Panel is disabled, OR + // 2. Selected generation doesn't support type filtering (G7, G6, All) + const isTypeSelectDisabled = disabled || !typeFilteringSupported; + + // Track previous filters to detect changes for pagination reset + const previousFilters = React.useRef(null); + + // Reset pagination when filters change (but not on initial mount) + React.useEffect(() => { + // Skip pagination reset on initial mount + if (previousFilters.current === null) { + previousFilters.current = { generation, type }; + return; + } + + const { generation: prevGeneration, type: prevType } = + previousFilters.current; + + if (prevGeneration !== generation || prevType !== type) { + resetPagination(); + } + + previousFilters.current = { generation, type }; + }, [generation, resetPagination, type]); + + const handleGenerationChange = React.useCallback( + ( + _event: React.SyntheticEvent, + option: null | SelectOption + ) => { + // When clearing, default to "All" instead of undefined + const newGeneration = + (option?.value as PlanFilterGeneration | undefined) ?? + PLAN_FILTER_GENERATION_ALL; + setGeneration(newGeneration); + + // Reset type filter when generation changes + setType(PLAN_FILTER_TYPE_ALL); + }, + [] + ); + + const handleTypeChange = React.useCallback( + ( + _event: React.SyntheticEvent, + option: null | SelectOption + ) => { + setType( + (option?.value as PlanFilterType | undefined) ?? PLAN_FILTER_TYPE_ALL + ); + }, + [] + ); + + const filteredPlans = React.useMemo(() => { + const normalizedType = typeFilteringSupported + ? type + : PLAN_FILTER_TYPE_ALL; + return applyPlanFilters(plans, generation, normalizedType); + }, [generation, plans, type, typeFilteringSupported]); + + const selectedGenerationOption = React.useMemo(() => { + return GENERATION_OPTIONS.find((opt) => opt.value === generation) ?? null; + }, [generation]); + + const selectedTypeOption = React.useMemo(() => { + const displayType = typeFilteringSupported ? type : PLAN_FILTER_TYPE_ALL; + return typeOptions.find((opt) => opt.value === displayType) ?? null; + }, [type, typeFilteringSupported, typeOptions]); + + const result = React.useMemo(() => { + const filterUI = ( +
    + +
    + ); + + return { + filteredPlans, + filterUI, + hasActiveFilters: generation !== PLAN_FILTER_GENERATION_ALL, + }; + }, [ + disabled, + filteredPlans, + generation, + handleGenerationChange, + handleTypeChange, + isTypeSelectDisabled, + selectedGenerationOption, + selectedTypeOption, + typeOptions, + ]); + + // Notify parent component whenever filter result changes + React.useEffect(() => { + onResult(result); + }, [onResult, result]); + + return null; + } +); + +DedicatedPlanFiltersComponent.displayName = 'DedicatedPlanFiltersComponent'; + +export const createDedicatedPlanFiltersRenderProp = () => { + return ({ + onResult, + plans, + resetPagination, + shouldDisableFilters = false, + }: PlanFilterRenderArgs): React.ReactNode => ( + + ); +}; diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.test.tsx index e9f8c440cc3..5d31d312c76 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.test.tsx @@ -5,10 +5,21 @@ import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing import { renderWithTheme } from 'src/utilities/testHelpers'; import { mockMatchMedia } from 'src/utilities/testHelpers'; -import { PlanContainer } from './PlanContainer'; +import { PLAN_FILTER_NO_RESULTS_MESSAGE } from './constants'; +import { PlanContainer, type PlanFilterRenderArgs } from './PlanContainer'; import type { PlanWithAvailability } from './types'; +const queryMocks = vi.hoisted(() => ({ + useIsGenerationalPlansEnabled: vi.fn(() => ({ + isGenerationalPlansEnabled: true, + })), +})); + +vi.mock('src/utilities/linodes', () => ({ + useIsGenerationalPlansEnabled: queryMocks.useIsGenerationalPlansEnabled, +})); + const mockPlans: PlanWithAvailability[] = planSelectionTypeFactory.buildList(2); beforeAll(() => mockMatchMedia()); @@ -27,4 +38,34 @@ describe('PlanContainer', () => { expect(getByText(PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE)).toBeVisible(); }); + + it('shows the no plans found message when filters return empty results', async () => { + let hasSentResult = false; + + const planFilters = ({ onResult }: PlanFilterRenderArgs) => { + if (!hasSentResult) { + hasSentResult = true; + onResult({ + filteredPlans: [], + filterUI:
    Filter UI
    , + hasActiveFilters: true, + }); + } + + return null; + }; + + const { findByText } = renderWithTheme( + {}} + planFilters={planFilters} + plans={mockPlans} + selectedRegionId="us-east" + /> + ); + + expect(await findByText(PLAN_FILTER_NO_RESULTS_MESSAGE)).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index c20b39df527..fe283fcf920 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -10,7 +10,10 @@ import { useFlags } from 'src/hooks/useFlags'; import { useIsGenerationalPlansEnabled } from 'src/utilities/linodes'; import { PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE } from 'src/utilities/pricing/constants'; -import { PLAN_PANEL_PAGE_SIZE_OPTIONS } from './constants'; +import { + PLAN_FILTER_NO_RESULTS_MESSAGE, + PLAN_PANEL_PAGE_SIZE_OPTIONS, +} from './constants'; import { PlanSelection } from './PlanSelection'; import { PlanSelectionTable } from './PlanSelectionTable'; @@ -29,6 +32,50 @@ export interface PlanSelectionDividers { tables: PlanSelectionFilterOptionsTable[]; } +export interface PlanFilterRenderArgs { + /** + * Callback to notify parent of filter result + */ + onResult: (result: PlanFilterRenderResult) => void; + + /** + * All available plans (unfiltered) + */ + plans: PlanWithAvailability[]; + + /** + * Plan type/class (e.g., 'dedicated', 'gpu') + */ + planType: LinodeTypeClass | undefined; + + /** + * Reset pagination back to the first page + */ + resetPagination: () => void; + + /** + * Whether filters should be disabled (e.g., no region selected) + */ + shouldDisableFilters?: boolean; +} + +export interface PlanFilterRenderResult { + /** + * Filtered plans after applying filters + */ + filteredPlans: PlanWithAvailability[]; + + /** + * The filter UI component + */ + filterUI: React.ReactNode; + + /** + * Whether any filters are currently active + */ + hasActiveFilters: boolean; +} + export interface PlanContainerProps { allDisabledPlans: PlanWithAvailability[]; currentPlanHeading?: string; @@ -36,6 +83,13 @@ export interface PlanContainerProps { isCreate?: boolean; linodeID?: number | undefined; onSelect: (key: string) => void; + + /** + * Render prop for custom filter UI per tab + * Receives plan data and pagination helpers, returns a React element + */ + planFilters?: (args: PlanFilterRenderArgs) => React.ReactNode; + plans: PlanWithAvailability[]; planType?: LinodeTypeClass; selectedDiskSize?: number; @@ -52,6 +106,7 @@ export const PlanContainer = (props: PlanContainerProps) => { isCreate, linodeID, onSelect, + planFilters, planType, plans, selectedId, @@ -63,9 +118,6 @@ export const PlanContainer = (props: PlanContainerProps) => { const flags = useFlags(); const { isGenerationalPlansEnabled } = useIsGenerationalPlansEnabled(); - // Feature gate for pagination functionality - const isPlanPaginationEnabled = isGenerationalPlansEnabled; - // Show the Transfer column if, for any plan, the api returned data and we're not in the Database Create flow const showTransfer = showLimits && @@ -155,8 +207,78 @@ export const PlanContainer = (props: PlanContainerProps) => { [planType] ); + // State to hold filter result from the filter component + const [filterResult, setFilterResult] = + React.useState(null); + + // Ref to store the pagination handler from Paginate component + // This allows us to reset pagination when filters change + const handlePageChangeRef = React.useRef<((page: number) => void) | null>( + null + ); + + // Callback for filter component to update result + const handleFilterResult = React.useCallback( + (result: PlanFilterRenderResult) => { + setFilterResult(result); + }, + [] + ); + + // Callback to reset pagination to page 1 + // Used by filter components when filters change + const resetPagination = React.useCallback(() => { + // Call the pagination handler to go to page 1 + handlePageChangeRef.current?.(1); + }, []); + + // Create filter state manager component if planFilters render prop is provided + // This component returns null but manages filter state via local React state + // State persists when switching tabs because Reach UI TabPanels stay mounted + // and communicates filtered results back to parent via the onResult callback + const filterStateManager = React.useMemo(() => { + if (isGenerationalPlansEnabled && planFilters) { + return planFilters({ + onResult: handleFilterResult, + planType, + plans, + resetPagination, + shouldDisableFilters: shouldDisplayNoRegionSelectedMessage, + }); + } + return null; + }, [ + isGenerationalPlansEnabled, + planFilters, + planType, + plans, + handleFilterResult, + resetPagination, + shouldDisplayNoRegionSelectedMessage, + ]); + + // Clear filter result when filters are disabled or removed + React.useEffect(() => { + if (!planFilters || !isGenerationalPlansEnabled) { + setFilterResult(null); + } + }, [isGenerationalPlansEnabled, planFilters]); + + // Use filtered plans if available, otherwise use all plans + const effectiveFilterResult = isGenerationalPlansEnabled + ? filterResult + : null; + const plansToDisplay = effectiveFilterResult?.filteredPlans ?? plans; + + // Automatically show empty state message when filters return no results + const tableEmptyState = shouldDisplayNoRegionSelectedMessage + ? null + : plansToDisplay.length === 0 + ? { message: PLAN_FILTER_NO_RESULTS_MESSAGE } + : null; + // Feature gate: if pagination is disabled, render the old way - if (!isPlanPaginationEnabled) { + if (!isGenerationalPlansEnabled) { return ( @@ -254,7 +376,7 @@ export const PlanContainer = (props: PlanContainerProps) => { // Pagination enabled: use new paginated rendering return ( { page, pageSize, }) => { - const shouldDisplayPagination = - !shouldDisplayNoRegionSelectedMessage && - count > PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value; + // Store the handlePageChange function in ref so filters can call it + handlePageChangeRef.current = handlePageChange; + + const shouldDisplayPagination = !shouldDisplayNoRegionSelectedMessage; return ( <> + {filterStateManager} + + {/* Render filter UI that was passed via callback */} + {effectiveFilterResult?.filterUI && ( + {effectiveFilterResult.filterUI} + )} + {isCreate && isDatabaseGA && ( { text={PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE} variant="info" /> + ) : tableEmptyState ? ( + ({ + '& p': { fontSize: theme.tokens.font.FontSize.Xs }, + })} + text={tableEmptyState.message} + variant="info" + /> ) : ( planSelectionDividers.map((planSelectionDivider) => planType === planSelectionDivider.planType @@ -356,6 +496,7 @@ export const PlanContainer = (props: PlanContainerProps) => { }) ) : ( { customOptions={PLAN_PANEL_PAGE_SIZE_OPTIONS} handlePageChange={handlePageChange} handleSizeChange={handlePageSizeChange} + minPageSize={PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value} page={page} pageSize={pageSize} sx={{ diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx index fbad4b69c45..289bb7b4ceb 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelectionTable.tsx @@ -18,6 +18,7 @@ import type { LinodeTypeClass } from '@linode/api-v4/'; import type { TooltipIconStatus } from '@linode/ui'; interface PlanSelectionTableProps { + filterEmptyStateMessage?: string; filterOptions?: PlanSelectionFilterOptionsTable; plans?: PlanWithAvailability[]; planType?: LinodeTypeClass; @@ -47,6 +48,7 @@ const tableCells = [ export const PlanSelectionTable = (props: PlanSelectionTableProps) => { const { + filterEmptyStateMessage, filterOptions, planType, plans, @@ -164,6 +166,11 @@ export const PlanSelectionTable = (props: PlanSelectionTableProps) => { colSpan={tableCells.length} message={PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE} /> + ) : filterEmptyStateMessage ? ( + ) : ( ((plans && renderPlanSelection?.(plans)) ?? null) )} diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 7b1491c52ff..fdce685c336 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -15,6 +15,7 @@ import { import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { useFlags } from 'src/hooks/useFlags'; +import { createDedicatedPlanFiltersRenderProp } from './DedicatedPlanFilters'; import { DistributedRegionPlanTable } from './DistributedRegionPlanTable'; import { PlanContainer } from './PlanContainer'; import { PlanInformation } from './PlanInformation'; @@ -223,6 +224,11 @@ export const PlansPanel = (props: PlansPanelProps) => { isCreate={isCreate} linodeID={linodeID} onSelect={onSelect} + planFilters={ + plan === 'dedicated' + ? createDedicatedPlanFiltersRenderProp() + : undefined + } plans={plansForThisLinodeTypeClass} planType={plan} selectedId={selectedId} diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index 24cf0350d08..f2eb165b686 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -41,6 +41,53 @@ export const PLAN_PANEL_PAGE_SIZE_OPTIONS = [ // List of plan types that belong to the MTC plan group. export const MTC_AVAILABLE_PLAN_TYPES = ['g8-premium-128-ht']; +// ============================================================================ +// Plan Filter Constants +// ============================================================================ + +export const PLAN_FILTER_NO_RESULTS_MESSAGE = 'No plans found.'; + +// G8 Dedicated CPU Plans - Compute Optimized (1:2 RAM:CPU ratio) +export const G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS = [ + 'g8-dedicated-4-2', // 4 GB RAM, 2 CPUs + 'g8-dedicated-8-4', // 8 GB RAM, 4 CPUs + 'g8-dedicated-16-8', // 16 GB RAM, 8 CPUs + 'g8-dedicated-32-16', // 32 GB RAM, 16 CPUs + 'g8-dedicated-64-32', // 64 GB RAM, 32 CPUs + 'g8-dedicated-96-48', // 96 GB RAM, 48 CPUs + 'g8-dedicated-128-64', // 128 GB RAM, 64 CPUs + 'g8-dedicated-256-128', // 256 GB RAM, 128 CPUs + 'g8-dedicated-512-256', // 512 GB RAM, 256 CPUs +] as const; + +// G8 Dedicated CPU Plans - General Purpose (1:4 RAM:CPU ratio) +export const G8_DEDICATED_GENERAL_PURPOSE_SLUGS = [ + 'g8-dedicated-8-2', // 8 GB RAM, 2 CPUs + 'g8-dedicated-16-4', // 16 GB RAM, 4 CPUs + 'g8-dedicated-32-8', // 32 GB RAM, 8 CPUs + 'g8-dedicated-64-16', // 64 GB RAM, 16 CPUs + 'g8-dedicated-96-24', // 96 GB RAM, 24 CPUs + 'g8-dedicated-128-32', // 128 GB RAM, 32 CPUs + 'g8-dedicated-256-64', // 256 GB RAM, 64 CPUs + 'g8-dedicated-512-128', // 512 GB RAM, 128 CPUs +] as const; + +// Combined G8 plans (All) +export const G8_DEDICATED_ALL_SLUGS = [ + ...G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS, + ...G8_DEDICATED_GENERAL_PURPOSE_SLUGS, +] as const; + +// Filter option values +export const PLAN_FILTER_GENERATION_ALL = 'all'; +export const PLAN_FILTER_GENERATION_G8 = 'g8'; +export const PLAN_FILTER_GENERATION_G7 = 'g7'; +export const PLAN_FILTER_GENERATION_G6 = 'g6'; + +export const PLAN_FILTER_TYPE_ALL = 'all'; +export const PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED = 'compute-optimized'; +export const PLAN_FILTER_TYPE_GENERAL_PURPOSE = 'general-purpose'; + export const DEDICATED_512_GB_PLAN: ExtendedType = { accelerated_devices: 0, addons: { diff --git a/packages/manager/src/features/components/PlansPanel/types.ts b/packages/manager/src/features/components/PlansPanel/types.ts index c0ce049a18d..4b41b9a9632 100644 --- a/packages/manager/src/features/components/PlansPanel/types.ts +++ b/packages/manager/src/features/components/PlansPanel/types.ts @@ -41,3 +41,14 @@ export interface PlanSelectionAvailabilityTypes { export interface DisabledTooltipReasons extends PlanSelectionAvailabilityTypes { wholePanelIsDisabled?: boolean; } + +export type { + FilterDropdownConfig, + FilterOption, + FilterPlansByGenerationFn, + FilterPlansByTypeFn, + PlanFilterGeneration, + PlanFilterResult, + PlanFilterState, + PlanFilterType, +} from './types/planFilters'; diff --git a/packages/manager/src/features/components/PlansPanel/types/planFilters.ts b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts new file mode 100644 index 00000000000..06de06e9855 --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts @@ -0,0 +1,155 @@ +/** + * Plan Filter Types + * + * This file contains TypeScript types and interfaces for the plan filtering system. + * Filters allow users to narrow down plans by generation (G8/G7/G6) and type (Compute Optimized/General Purpose). + */ + +import type { PlanWithAvailability } from '../types'; + +// ============================================================================ +// Filter Value Types +// ============================================================================ + +/** + * Available plan generations for Dedicated CPU filtering + */ +export type PlanFilterGeneration = 'all' | 'g6' | 'g7' | 'g8'; + +/** + * Available plan types for filtering within a generation + */ +export type PlanFilterType = 'all' | 'compute-optimized' | 'general-purpose'; + +// ============================================================================ +// Filter State +// ============================================================================ + +/** + * Current state of plan filters + */ +export interface PlanFilterState { + /** + * Selected generation (e.g., "g8", "g7", "g6") + */ + generation?: PlanFilterGeneration; + + /** + * Selected type within the generation (e.g., "compute-optimized", "general-purpose", "all") + */ + type?: PlanFilterType; +} + +// ============================================================================ +// Filter Options +// ============================================================================ + +/** + * A single filter option for dropdowns + */ +export interface FilterOption { + /** + * Whether this option should be disabled + */ + disabled?: boolean; + + /** + * Display label for the option + */ + label: string; + + /** + * Underlying value for the option + */ + value: T; +} + +/** + * Configuration for a filter dropdown + */ +export interface FilterDropdownConfig { + /** + * Whether the dropdown is disabled + */ + disabled?: boolean; + + /** + * Label displayed above the dropdown + */ + label: string; + + /** + * Callback when selection changes + */ + onChange: (value: T | undefined) => void; + + /** + * Available options for this dropdown + */ + options: FilterOption[]; + + /** + * Placeholder text when no option is selected + */ + placeholder: string; + + /** + * Currently selected value + */ + value?: T; + + /** + * Width of the dropdown in pixels + */ + width?: number; +} + +// ============================================================================ +// Filter Result +// ============================================================================ + +/** + * Result of applying filters to a list of plans + */ +export interface PlanFilterResult { + /** + * Total number of plans after filtering + */ + count: number; + + /** + * Whether filters are currently active + */ + hasActiveFilters: boolean; + + /** + * Whether the filter state results in no plans (empty state) + */ + isEmpty: boolean; + + /** + * Filtered list of plans + */ + plans: PlanWithAvailability[]; +} + +// ============================================================================ +// Filter Functions +// ============================================================================ + +/** + * Function type for filtering plans by generation + */ +export type FilterPlansByGenerationFn = ( + plans: PlanWithAvailability[], + generation: PlanFilterGeneration +) => PlanWithAvailability[]; + +/** + * Function type for filtering plans by type within a generation + */ +export type FilterPlansByTypeFn = ( + plans: PlanWithAvailability[], + generation: PlanFilterGeneration, + type: PlanFilterType +) => PlanWithAvailability[]; diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts new file mode 100644 index 00000000000..c6672f0fd64 --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts @@ -0,0 +1,211 @@ +import { planSelectionTypeFactory } from 'src/factories/types'; + +import { + PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, + PLAN_FILTER_TYPE_GENERAL_PURPOSE, +} from '../constants'; +import { + applyPlanFilters, + filterPlansByGeneration, + filterPlansByType, + getAvailableTypes, + supportsTypeFiltering, +} from './planFilters'; + +describe('planFilters utilities', () => { + const createPlan = ( + overrides: Partial> + ) => planSelectionTypeFactory.build(overrides); + + const g8ComputePlan = createPlan({ + id: 'g8-dedicated-8-4', + label: 'Dedicated G8 Compute Optimized', + }); + + const g8GeneralPlan = createPlan({ + id: 'g8-dedicated-16-4', + label: 'Dedicated G8 General Purpose', + }); + + const g7DedicatedPlan = createPlan({ + id: 'g7-dedicated-16-8', + label: 'Dedicated G7 Plan', + }); + + const g7PremiumPlan = createPlan({ + id: 'g7-premium-64-32', + label: 'Premium G7 Plan', + }); + + const g6Plan = createPlan({ + id: 'g6-dedicated-16-8', + label: 'Dedicated G6 Plan', + }); + + const rogueG8Plan = createPlan({ + id: 'g8-dedicated-99-999', + label: 'Unlisted G8 Plan', + }); + + describe('filterPlansByGeneration', () => { + it('returns only G8 plans that exist in the allow-list', () => { + const result = filterPlansByGeneration( + [g8ComputePlan, g8GeneralPlan, rogueG8Plan, g7DedicatedPlan], + 'g8' + ); + + expect(result).toEqual([g8ComputePlan, g8GeneralPlan]); + }); + + it('returns all G7 plans based on prefix matching', () => { + const result = filterPlansByGeneration( + [g7DedicatedPlan, g7PremiumPlan, g8ComputePlan], + 'g7' + ); + + expect(result).toEqual([g7DedicatedPlan, g7PremiumPlan]); + }); + + it('returns all G6 plans based on prefix matching', () => { + const result = filterPlansByGeneration( + [g6Plan, g7PremiumPlan, g8ComputePlan], + 'g6' + ); + + expect(result).toEqual([g6Plan]); + }); + + it('returns all dedicated plans (G6, G7, G8) when generation is "all"', () => { + const result = filterPlansByGeneration( + [g8ComputePlan, g8GeneralPlan, g7DedicatedPlan, g7PremiumPlan, g6Plan], + 'all' + ); + + expect(result).toEqual([ + g8ComputePlan, + g8GeneralPlan, + g7DedicatedPlan, + g7PremiumPlan, + g6Plan, + ]); + }); + }); + + describe('filterPlansByType', () => { + it('returns all plans when type is "all"', () => { + const result = filterPlansByType( + [g8ComputePlan, g8GeneralPlan], + 'g8', + PLAN_FILTER_TYPE_ALL + ); + + expect(result).toEqual([g8ComputePlan, g8GeneralPlan]); + }); + + it('returns compute optimized plans for G8', () => { + const result = filterPlansByType( + [g8ComputePlan, g8GeneralPlan], + 'g8', + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED + ); + + expect(result).toEqual([g8ComputePlan]); + }); + + it('returns general purpose plans for G8', () => { + const result = filterPlansByType( + [g8ComputePlan, g8GeneralPlan], + 'g8', + PLAN_FILTER_TYPE_GENERAL_PURPOSE + ); + + expect(result).toEqual([g8GeneralPlan]); + }); + + it('ignores type filtering for G7, G6, and "all" generation', () => { + const resultG7 = filterPlansByType( + [g7DedicatedPlan], + 'g7', + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED + ); + const resultG6 = filterPlansByType( + [g6Plan], + 'g6', + PLAN_FILTER_TYPE_GENERAL_PURPOSE + ); + const resultAll = filterPlansByType( + [g8ComputePlan, g8GeneralPlan], + 'all', + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED + ); + + expect(resultG7).toEqual([g7DedicatedPlan]); + expect(resultG6).toEqual([g6Plan]); + expect(resultAll).toEqual([g8ComputePlan, g8GeneralPlan]); + }); + }); + + describe('applyPlanFilters', () => { + it('returns an empty array when no generation is selected', () => { + const result = applyPlanFilters( + [g8ComputePlan, g8GeneralPlan], + undefined, + PLAN_FILTER_TYPE_ALL + ); + + expect(result).toEqual([]); + }); + + it('applies both generation and type filters', () => { + const result = applyPlanFilters( + [g8ComputePlan, g8GeneralPlan, g7DedicatedPlan, g7PremiumPlan, g6Plan], + 'g8', + PLAN_FILTER_TYPE_GENERAL_PURPOSE + ); + + expect(result).toEqual([g8GeneralPlan]); + }); + + it('returns all dedicated plans when generation is "all"', () => { + const result = applyPlanFilters( + [g8ComputePlan, g8GeneralPlan, g7DedicatedPlan, g7PremiumPlan, g6Plan], + 'all', + PLAN_FILTER_TYPE_ALL + ); + + expect(result).toEqual([ + g8ComputePlan, + g8GeneralPlan, + g7DedicatedPlan, + g7PremiumPlan, + g6Plan, + ]); + }); + }); + + describe('supportsTypeFiltering', () => { + it('only supports type filtering for G8', () => { + expect(supportsTypeFiltering('g8')).toBe(true); + expect(supportsTypeFiltering('g7')).toBe(false); + expect(supportsTypeFiltering('g6')).toBe(false); + expect(supportsTypeFiltering('all')).toBe(false); + }); + }); + + describe('getAvailableTypes', () => { + it('returns all type options for G8', () => { + expect(getAvailableTypes('g8')).toEqual([ + PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, + PLAN_FILTER_TYPE_GENERAL_PURPOSE, + ]); + }); + + it('returns only the "all" option for G7, G6, and "all" generation', () => { + expect(getAvailableTypes('g7')).toEqual([PLAN_FILTER_TYPE_ALL]); + expect(getAvailableTypes('g6')).toEqual([PLAN_FILTER_TYPE_ALL]); + expect(getAvailableTypes('all')).toEqual([PLAN_FILTER_TYPE_ALL]); + }); + }); +}); diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts new file mode 100644 index 00000000000..c62bcf6c7fb --- /dev/null +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts @@ -0,0 +1,207 @@ +/** + * Plan Filtering Utilities + * + * Functions for filtering plans by generation (G8/G7/G6) and type (Compute Optimized/General Purpose). + * Uses explicit slug mappings for precise filtering. + */ + +import { + G8_DEDICATED_ALL_SLUGS, + G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS, + G8_DEDICATED_GENERAL_PURPOSE_SLUGS, + PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, + PLAN_FILTER_TYPE_GENERAL_PURPOSE, +} from '../constants'; + +import type { + PlanFilterGeneration, + PlanFilterType, + PlanWithAvailability, +} from '../types'; + +// ============================================================================ +// Generation Filtering +// ============================================================================ + +/** + * Filter plans by generation (G8, G7, G6, or All) + * + * @param plans - Array of all plans (mostly pre-filtered by plan type/class) + * @param generation - The generation to filter by ('all', 'g8', 'g7', or 'g6') + * @returns Filtered array of plans matching the generation + * + * @example + * ```ts + * const g8Plans = filterPlansByGeneration(allPlans, 'g8'); + * // Returns all plans with IDs starting with 'g8-dedicated-' + * + * const allDedicatedPlans = filterPlansByGeneration(allPlans, 'all'); + * // Returns all plans as-is (already filtered by plan type in parent) + * ``` + */ +export const filterPlansByGeneration = ( + plans: PlanWithAvailability[], + generation: PlanFilterGeneration +): PlanWithAvailability[] => { + // For "All", return all plans as-is + // The plans array is already filtered to only dedicated plans by the parent component + if (generation === 'all') { + return plans; + } + + // For G8, use explicit slug list for precise filtering + if (generation === 'g8') { + const g8Slugs = new Set(G8_DEDICATED_ALL_SLUGS); + return plans.filter((plan) => g8Slugs.has(plan.id)); + } + + // For G7 and G6, use ID prefix matching + // G7: IDs start with 'g7-dedicated-' or 'g7-premium-' + // G6: IDs start with 'g6-dedicated-' + const prefix = `${generation}-`; + return plans.filter((plan) => plan.id.startsWith(prefix)); +}; + +// ============================================================================ +// Type Filtering +// ============================================================================ + +/** + * Filter plans by type within a generation + * + * @param plans - Array of plans (should be pre-filtered by generation) + * @param generation - The generation context ('all', 'g8', 'g7', or 'g6') + * @param type - The type to filter by ('all', 'compute-optimized', 'general-purpose') + * @returns Filtered array of plans matching the type + * + * @example + * ```ts + * // Get all G8 Compute Optimized plans + * const g8Plans = filterPlansByGeneration(allPlans, 'g8'); + * const g8CO = filterPlansByType(g8Plans, 'g8', 'compute-optimized'); + * ``` + */ +export const filterPlansByType = ( + plans: PlanWithAvailability[], + generation: PlanFilterGeneration, + type: PlanFilterType +): PlanWithAvailability[] => { + // "All" returns all plans unchanged + if (type === PLAN_FILTER_TYPE_ALL) { + return plans; + } + + // G7, G6, and "All" generation only have "All" option (no sub-types) + if (generation === 'g7' || generation === 'g6' || generation === 'all') { + return plans; + } + + // G8 has Compute Optimized and General Purpose sub-types + if (generation === 'g8') { + if (type === PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED) { + const computeOptimizedSlugs = new Set( + G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS + ); + return plans.filter((plan) => computeOptimizedSlugs.has(plan.id)); + } + + if (type === PLAN_FILTER_TYPE_GENERAL_PURPOSE) { + const generalPurposeSlugs = new Set( + G8_DEDICATED_GENERAL_PURPOSE_SLUGS + ); + return plans.filter((plan) => generalPurposeSlugs.has(plan.id)); + } + } + + // Default: return all plans + return plans; +}; + +// ============================================================================ +// Combined Filtering +// ============================================================================ + +/** + * Apply both generation and type filters to a list of plans + * + * @param plans - Array of all plans + * @param generation - The generation to filter by (optional) + * @param type - The type to filter by (optional, defaults to 'all') + * @returns Filtered array of plans, or all plans if no filters applied + * + * @example + * ```ts + * // Get G8 Compute Optimized plans + * const filtered = applyPlanFilters(allPlans, 'g8', 'compute-optimized'); + * + * // Get all G7 plans + * const g7All = applyPlanFilters(allPlans, 'g7', 'all'); + * + * // Get all dedicated plans (G6, G7, G8) + * const allDedicated = applyPlanFilters(allPlans, 'all', 'all'); + * + * // No filters - return empty array + * const none = applyPlanFilters(allPlans); + * ``` + */ +export const applyPlanFilters = ( + plans: PlanWithAvailability[], + generation?: PlanFilterGeneration, + type: PlanFilterType = PLAN_FILTER_TYPE_ALL +): PlanWithAvailability[] => { + // No filters - return empty array + if (!generation) { + return []; + } + + // Apply generation filter first + const generationFiltered = filterPlansByGeneration(plans, generation); + + // Then apply type filter + return filterPlansByType(generationFiltered, generation, type); +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Check if a generation supports type filtering + * + * @param generation - The generation to check + * @returns True if the generation has multiple types (G8), false otherwise + */ +export const supportsTypeFiltering = ( + generation: PlanFilterGeneration +): boolean => { + return generation === 'g8'; +}; + +/** + * Get available type options for a generation + * + * @param generation - The generation to get types for + * @returns Array of available type values + * + * @example + * ```ts + * getAvailableTypes('g8'); // ['all', 'compute-optimized', 'general-purpose'] + * getAvailableTypes('g7'); // ['all'] + * getAvailableTypes('all'); // ['all'] + * ``` + */ +export const getAvailableTypes = ( + generation: PlanFilterGeneration +): PlanFilterType[] => { + if (generation === 'g8') { + return [ + PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, + PLAN_FILTER_TYPE_GENERAL_PURPOSE, + ]; + } + + // G7, G6, and "All" only have "All" type option + return [PLAN_FILTER_TYPE_ALL]; +}; From ced04e1a50e76f51a3faa66ca0f9460d9b6c6d06 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:35:10 -0500 Subject: [PATCH 19/91] test: [UIE-9677] - Misc. Cypress test fixes following v1.155.0 release (#13107) * Mock `udp` feature flag to be enabled in NodeBalancer create form validation smoke tests * Remove obsolete LDE disk delete test now that deletion works as expected * Fix LKE-E field validation test when running against non-local Cloud by fixing intercept URL * Restore disk deletion test for LDE, update assertions for success response * Added changeset: Fixed various test failures when running tests against Prod environment * Avoid cleaning up Linodes that are busy * Swallow errors and log a warning on resource clean up errors --- .../pr-13107-tests-1763492424120.md | 5 + .../kubernetes/lke-enterprise-create.spec.ts | 28 ++-- .../e2e/core/linodes/linode-storage.spec.ts | 20 +-- .../nodebalancer-create-validation.spec.ts | 5 + .../manager/cypress/support/api/linodes.ts | 6 +- .../manager/cypress/support/intercepts/lke.ts | 6 +- .../cypress/support/setup/defer-command.ts | 145 +----------------- packages/manager/cypress/support/util/api.ts | 145 +++++++++++++++++- .../manager/cypress/support/util/cleanup.ts | 16 +- 9 files changed, 199 insertions(+), 177 deletions(-) create mode 100644 packages/manager/.changeset/pr-13107-tests-1763492424120.md diff --git a/packages/manager/.changeset/pr-13107-tests-1763492424120.md b/packages/manager/.changeset/pr-13107-tests-1763492424120.md new file mode 100644 index 00000000000..5f1b879f190 --- /dev/null +++ b/packages/manager/.changeset/pr-13107-tests-1763492424120.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fixed various test failures when running tests against Prod environment ([#13107](https://github.com/linode/manager/pull/13107)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts index 32d93f7b6e7..b1a5509a488 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-enterprise-create.spec.ts @@ -289,21 +289,19 @@ describe('LKE Cluster Creation with LKE-E', () => { */ it('surfaces field-level errors on VPC fields', () => { // Intercept the create cluster request and force an error response - cy.intercept('POST', '/v4beta/lke/clusters', { - statusCode: 400, - body: { - errors: [ - { - reason: 'There is an error configuring this VPC.', - field: 'vpc_id', - }, - { - reason: 'There is an error configuring this subnet.', - field: 'subnet_id', - }, - ], - }, - }).as('createClusterError'); + mockCreateClusterError( + [ + { + reason: 'There is an error configuring this VPC.', + field: 'vpc_id', + }, + { + reason: 'There is an error configuring this subnet.', + field: 'subnet_id', + }, + ], + 400 + ).as('createClusterError'); cy.findByLabelText('Cluster Label').type(clusterLabel); cy.findByText('LKE Enterprise').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 8814b12c700..50c52ea14bb 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -160,12 +160,10 @@ describe('linode storage tab', () => { }); /* - * - Confirms UI flow end-to-end when a user attempts to delete a Linode disk with encryption enabled. - * - Confirms that disk deletion fails and toast notification appears. + * - Confirms UI flow end-to-end when a user deletes a Linode disk with encryption enabled. + * - Confirms that disk deletion succeeds and toast notification appears. */ - // TODO: Disk cannot be deleted if disk_encryption is 'enabled' - // TODO: edit result of this test if/when behavior of backend is updated. uncertain what expected behavior is for this disk config - it('delete disk fails when Linode uses disk encryption', () => { + it('deletes a disk when Linode Disk Encryption is enabled', () => { const diskName = randomLabel(); cy.defer(() => createTestLinode({ @@ -195,19 +193,17 @@ describe('linode storage tab', () => { cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - // ui.toast.assertMessage( - // `Disk ${diskName} on Linode ${linode.label} has been deleted.` - // ); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been deleted.` + ); ui.toast .findByMessage( `Disk ${diskName} on Linode ${linode.label} has been deleted.` ) .should('not.exist'); - // cy.findByLabelText('List of Disks').within(() => { - // cy.contains(diskName).should('not.exist'); - // }); + cy.findByLabelText('List of Disks').within(() => { - cy.contains(diskName).should('be.visible'); + cy.contains(diskName).should('not.exist'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts index 458e437b44f..6a2f6d0b78e 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-create-validation.spec.ts @@ -1,3 +1,5 @@ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; + describe('NodeBalancer create page validation', () => { /** * This test ensures that the user sees a uniqueness error when @@ -5,6 +7,9 @@ describe('NodeBalancer create page validation', () => { * - they configure many UDP configs to use the same port */ it('renders a port uniqueness errors when you try to create a nodebalancer with configs using the same port and protocol', () => { + mockAppendFeatureFlags({ + udp: true, + }); cy.visitWithLogin('/nodebalancers/create'); // Configure the first config to use TCP on port 8080 diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 7d8a15e66e5..2e128b90ac0 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -30,7 +30,11 @@ export const deleteAllTestLinodes = async (): Promise => { ); const deletePromises = linodes - .filter((linode: Linode) => isTestLabel(linode.label)) + .filter( + (linode: Linode) => + isTestLabel(linode.label) && + ['offline', 'running'].includes(linode.status) + ) .map((linode: Linode) => deleteLinode(linode.id)); await Promise.all(deletePromises); diff --git a/packages/manager/cypress/support/intercepts/lke.ts b/packages/manager/cypress/support/intercepts/lke.ts index 4c2b6935e9a..280a5a7946e 100644 --- a/packages/manager/cypress/support/intercepts/lke.ts +++ b/packages/manager/cypress/support/intercepts/lke.ts @@ -10,7 +10,7 @@ import { latestEnterpriseTierKubernetesVersion, latestStandardTierKubernetesVersion, } from 'support/constants/lke'; -import { makeErrorResponse } from 'support/util/errors'; +import { APIErrorContents, makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { randomDomainName } from 'support/util/random'; @@ -185,7 +185,9 @@ export const mockCreateCluster = ( * @returns Cypress chainable. */ export const mockCreateClusterError = ( - errorMessage: string = 'An unknown error occurred.', + errorMessage: + | APIErrorContents + | APIErrorContents[] = 'An unknown error occurred.', statusCode: number = 500 ): Cypress.Chainable => { return cy.intercept( diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index 6196b6e976f..bc1c38bf225 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -1,150 +1,7 @@ import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { enhanceError, isAxiosError } from 'support/util/api'; import { timeout } from 'support/util/backoff'; -import type { APIError } from '@linode/api-v4'; -import type { AxiosError } from 'axios'; - -type LinodeApiV4Error = { - errors: APIError[]; -}; - -/** - * Returns `true` if the given error is a Linode API schema validation error. - * - * Type guards `e` as an array of `APIError` objects. - * - * @param e - Error. - * - * @returns `true` if `e` is a Linode API schema validation error. - */ -const isValidationError = (e: any): e is APIError[] => { - // When a Linode APIv4 schema validation error occurs, an array of `APIError` - // objects is thrown rather than a typical `Error` type. - return ( - Array.isArray(e) && - e.every((item: any) => { - return 'reason' in item; - }) - ); -}; - -/** - * Returns `true` if the given error is an Axios error. - * - * Type guards `e` as an `AxiosError` instance. - * - * @param e - Error. - * - * @returns `true` if `e` is an `AxiosError`. - */ -const isAxiosError = (e: any): e is AxiosError => { - return !!e.isAxiosError; -}; - -/** - * Returns `true` if the given error is a Linode API v4 request error. - * - * Type guards `e` as an `AxiosError` instance. - * - * @param e - Error. - * - * @returns `true` if `e` is a Linode API v4 request error. - */ -const isLinodeApiError = (e: any): e is AxiosError => { - if (isAxiosError(e)) { - const responseData = e.response?.data as any; - return ( - responseData.errors && - Array.isArray(responseData.errors) && - responseData.errors.every((item: any) => { - return 'reason' in item; - }) - ); - } - return false; -}; - -/** - * Detects known error types and returns a new Error with more detailed message. - * - * Unknown error types are returned without modification. - * - * @param e - Error. - * - * @returns A new error with added information in message, or `e`. - */ -const enhanceError = (e: Error) => { - // Check for most specific error types first. - if (isLinodeApiError(e)) { - // If `e` is a Linode APIv4 error response, show the status code, error messages, - // and request URL when applicable. - const summary = e.response?.status - ? `Linode APIv4 request failed with status code ${e.response.status}` - : `Linode APIv4 request failed`; - - const errorDetails = e.response!.data.errors.map((error: APIError) => { - return error.field - ? `- ${error.reason} (field '${error.field}')` - : `- ${error.reason}`; - }); - - const requestInfo = - !!e.request?.responseURL && !!e.config?.method - ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` - : ''; - - return new Error(`${summary}\n${errorDetails.join('\n')}${requestInfo}`); - } - - if (isAxiosError(e)) { - // If `e` is an Axios error (but not a Linode API error specifically), show the - // status code, error messages, and request URL when applicable. - const summary = e.response?.status - ? `Request failed with status code ${e.response.status}` - : `Request failed`; - - const requestInfo = - !!e.request?.responseURL && !!e.config?.method - ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` - : ''; - - return new Error(`${summary}${requestInfo}`); - } - - // Handle cases where a validation error is thrown. - // These are arrays containing `APIError` objects; no additional request context - // is included so we only have the validation error messages themselves to work with. - if (isValidationError(e)) { - // Validation errors do not contain any additional context (request URL, payload, etc.). - // Show the validation error messages instead. - const multipleErrors = e.length > 1; - const summary = multipleErrors - ? 'Request failed with Linode schema validation errors' - : 'Request failed with Linode schema validation error'; - - // Format, accounting for 0, 1, or more errors. - const validationErrorMessage = multipleErrors - ? e - .map((error) => - error.field - ? `- ${error.reason} (field '${error.field}')` - : `- ${error.reason}` - ) - .join('\n') - : e - .map((error) => - error.field - ? `${error.reason} (field '${error.field}')` - : `${error.reason}` - ) - .join('\n'); - - return new Error(`${summary}\n${validationErrorMessage}`); - } - // Return `e` unmodified if it's not handled by any of the above cases. - return e; -}; - /** * Describes an object which can contain a label. */ diff --git a/packages/manager/cypress/support/util/api.ts b/packages/manager/cypress/support/util/api.ts index fd55777be19..b90bbaab130 100644 --- a/packages/manager/cypress/support/util/api.ts +++ b/packages/manager/cypress/support/util/api.ts @@ -2,14 +2,155 @@ * @file Utilities to help configure @linode/api-v4 package. */ -import { baseRequest } from '@linode/api-v4'; -import { AxiosHeaders } from 'axios'; +import { APIError, baseRequest } from '@linode/api-v4'; +import { AxiosError, AxiosHeaders } from 'axios'; // Note: This file is imported by Cypress plugins, and indirectly by Cypress // tests. Because Cypress has not been initiated when plugins are executed, we // cannot use any Cypress functionality in this module without causing a crash // at startup. +type LinodeApiV4Error = { + errors: APIError[]; +}; + +/** + * Returns `true` if the given error is a Linode API schema validation error. + * + * Type guards `e` as an array of `APIError` objects. + * + * @param e - Error. + * + * @returns `true` if `e` is a Linode API schema validation error. + */ +export const isValidationError = (e: any): e is APIError[] => { + // When a Linode APIv4 schema validation error occurs, an array of `APIError` + // objects is thrown rather than a typical `Error` type. + return ( + Array.isArray(e) && + e.every((item: any) => { + return 'reason' in item; + }) + ); +}; + +/** + * Returns `true` if the given error is an Axios error. + * + * Type guards `e` as an `AxiosError` instance. + * + * @param e - Error. + * + * @returns `true` if `e` is an `AxiosError`. + */ +export const isAxiosError = (e: any): e is AxiosError => { + return !!e.isAxiosError; +}; + +/** + * Returns `true` if the given error is a Linode API v4 request error. + * + * Type guards `e` as an `AxiosError` instance. + * + * @param e - Error. + * + * @returns `true` if `e` is a Linode API v4 request error. + */ +export const isLinodeApiError = (e: any): e is AxiosError => { + if (isAxiosError(e)) { + const responseData = e.response?.data as any; + return ( + responseData.errors && + Array.isArray(responseData.errors) && + responseData.errors.every((item: any) => { + return 'reason' in item; + }) + ); + } + return false; +}; + +/** + * Detects known error types and returns a new Error with more detailed message. + * + * Unknown error types are returned without modification. + * + * @param e - Error. + * + * @returns A new error with added information in message, or `e`. + */ +export const enhanceError = (e: Error) => { + // Check for most specific error types first. + if (isLinodeApiError(e)) { + // If `e` is a Linode APIv4 error response, show the status code, error messages, + // and request URL when applicable. + const summary = e.response?.status + ? `Linode APIv4 request failed with status code ${e.response.status}` + : `Linode APIv4 request failed`; + + const errorDetails = e.response!.data.errors.map((error: APIError) => { + return error.field + ? `- ${error.reason} (field '${error.field}')` + : `- ${error.reason}`; + }); + + const requestInfo = + !!e.request?.responseURL && !!e.config?.method + ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` + : ''; + + return new Error(`${summary}\n${errorDetails.join('\n')}${requestInfo}`); + } + + if (isAxiosError(e)) { + // If `e` is an Axios error (but not a Linode API error specifically), show the + // status code, error messages, and request URL when applicable. + const summary = e.response?.status + ? `Request failed with status code ${e.response.status}` + : `Request failed`; + + const requestInfo = + !!e.request?.responseURL && !!e.config?.method + ? `\nRequest: ${e.config.method.toUpperCase()} ${e.request.responseURL}` + : ''; + + return new Error(`${summary}${requestInfo}`); + } + + // Handle cases where a validation error is thrown. + // These are arrays containing `APIError` objects; no additional request context + // is included so we only have the validation error messages themselves to work with. + if (isValidationError(e)) { + // Validation errors do not contain any additional context (request URL, payload, etc.). + // Show the validation error messages instead. + const multipleErrors = e.length > 1; + const summary = multipleErrors + ? 'Request failed with Linode schema validation errors' + : 'Request failed with Linode schema validation error'; + + // Format, accounting for 0, 1, or more errors. + const validationErrorMessage = multipleErrors + ? e + .map((error) => + error.field + ? `- ${error.reason} (field '${error.field}')` + : `- ${error.reason}` + ) + .join('\n') + : e + .map((error) => + error.field + ? `${error.reason} (field '${error.field}')` + : `${error.reason}` + ) + .join('\n'); + + return new Error(`${summary}\n${validationErrorMessage}`); + } + // Return `e` unmodified if it's not handled by any of the above cases. + return e; +}; + /** * Default API root URL to use for replacement logic when using a URL override. * diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index 11432ce0ff7..847e1f79408 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -15,6 +15,7 @@ import { deleteAllTestSSHKeys } from 'support/api/profile'; import { deleteAllTestStackScripts } from 'support/api/stackscripts'; import { deleteAllTestTags } from 'support/api/tags'; import { deleteAllTestVolumes } from 'support/api/volumes'; +import { enhanceError } from 'support/util/api'; /** Types of resources that can be cleaned up. */ export type CleanUpResource = @@ -75,7 +76,20 @@ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { for (const resource of resourcesArray) { const cleanFunction = cleanUpMap[resource]; // Perform clean-up sequentially to avoid API rate limiting. - await cleanFunction(); + try { + await cleanFunction(); + } catch (e: any) { + // Log a warning but otherwise swallow errors if any resources fail to + // be cleaned up. There are a few cases where this is especially helpful: + // + // - Unplanned API issues or outages resulting in 5xx errors + // - 400 errors when inadevertently attempting to delete resources that are still busy (e.g. cleaning up a Linode that is the target of a clone operation) + const enhancedError = enhanceError(e); + console.warn( + 'An API error occurred while attempting to clean up one or more resources:' + ); + console.warn(enhancedError.message); + } } }; return cy.defer( From 959e2c4b32f3ea8527f03b012898206c7e6990ef Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:14:04 +0530 Subject: [PATCH 20/91] upcoming: [DI-28227] - Integrating Firewall-nodebalancers to ACLP-Alerting (#13089) * upcoming: [DI-28227] - Integrating Firewall-nobalancers to ACLP-Alerting * fixing a file naming typo * add changeset * upcoming: [DI-28227] - addressing review comments --- ...r-13089-upcoming-features-1763100678421.md | 5 + .../src/factories/cloudpulse/alerts.ts | 95 +++++++++++-- .../RenderAlertsMetricsAndDimensions.tsx | 28 +++- .../Alerts/AlertsDetail/constants.ts | 1 + .../Alerts/AlertsDetail/utils.test.ts | 21 ++- .../CloudPulse/Alerts/AlertsDetail/utils.ts | 13 ++ .../AlertsResources/AlertsResources.tsx | 15 +- .../CreateAlert/CreateAlertDefinition.tsx | 24 +++- .../Criteria/DimensionFilterField.tsx | 5 + ...orageDimensionFilterAutocomplete.test.tsx} | 0 ...rewallDimensionFilterAutocomplete.test.tsx | 10 +- .../FirewallDimensionFilterAutocomplete.tsx | 21 +-- .../ValueFieldRenderer.tsx | 16 ++- .../DimensionFilterValue/constants.ts | 4 + .../useFirewallFetchOptions.ts | 24 +++- .../Criteria/DimensionFilterValue/utils.ts | 19 +++ .../Alerts/CreateAlert/Criteria/Metric.tsx | 17 ++- .../EntityTypeSelect.test.tsx | 132 ++++++++++++++++++ .../GeneralInformation/EntityTypeSelect.tsx | 66 +++++++++ .../CloudPulseModifyAlertResources.tsx | 3 +- .../CloudPulse/Alerts/CreateAlert/schemas.ts | 8 ++ .../CloudPulse/Alerts/CreateAlert/types.ts | 2 + .../Alerts/CreateAlert/utilities.ts | 1 + .../Alerts/EditAlert/EditAlertDefinition.tsx | 35 ++++- .../features/CloudPulse/Alerts/constants.ts | 5 + .../CloudPulse/shared/DimensionTransform.ts | 2 + packages/manager/src/mocks/serverHandlers.ts | 63 ++++++++- 27 files changed, 594 insertions(+), 41 deletions(-) create mode 100644 packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md rename packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/{BlockStorageDimensionFilterAutcomplete.test.tsx => BlockStorageDimensionFilterAutocomplete.test.tsx} (100%) create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx diff --git a/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md b/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md new file mode 100644 index 00000000000..0ee6419cf61 --- /dev/null +++ b/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Integrate Firewall-nodebalancer support for ACLP-Alerting ([#13089](https://github.com/linode/manager/pull/13089)) diff --git a/packages/manager/src/factories/cloudpulse/alerts.ts b/packages/manager/src/factories/cloudpulse/alerts.ts index bd5c92c220d..81e061542b0 100644 --- a/packages/manager/src/factories/cloudpulse/alerts.ts +++ b/packages/manager/src/factories/cloudpulse/alerts.ts @@ -245,7 +245,7 @@ export const alertFactory = Factory.Sync.makeFactory({ updated_by: 'system', }); -const firewallDimensions: Dimension[] = [ +const firewallLinodeDimensions: Dimension[] = [ { label: 'VPC-Subnet', dimension_label: 'vpc_subnet_id', values: [] }, { label: 'Interface Type', @@ -257,29 +257,84 @@ const firewallDimensions: Dimension[] = [ { label: 'Linode Region', dimension_label: 'region_id', values: [] }, ]; +const firewallNodebalancerDimensions: Dimension[] = [ + { label: 'Protocol', dimension_label: 'protocol', values: ['TCP', 'UDP'] }, + { label: 'IP Version', dimension_label: 'ip_version', values: ['v4', 'v6'] }, + { label: 'NodeBalancer', dimension_label: 'nodebalancer_id', values: [] }, +]; + export const firewallMetricDefinitionFactory = Factory.Sync.makeFactory({ - label: 'Firewall Metric', - metric: 'firewall_metric', - unit: 'metric_unit', + label: 'Current connections (Linode)', + metric: 'fw_active_connections', + unit: 'Count', metric_type: 'gauge', - scrape_interval: '300s', + scrape_interval: '60s', is_alertable: true, - available_aggregate_functions: ['avg', 'sum', 'max', 'min', 'count'], - dimensions: firewallDimensions, + available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, }); + export const firewallMetricDefinitionsResponse: MetricDefinition[] = [ firewallMetricDefinitionFactory.build({ - label: 'Current connections', + label: 'Current connections (Linode)', metric: 'fw_active_connections', - unit: 'count', + unit: 'Count', available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, }), firewallMetricDefinitionFactory.build({ - label: 'Ingress packets accepted', + label: 'Ingress Packets Accepted (Linode)', metric: 'fw_ingress_packets_accepted', - unit: 'packets_per_second', + unit: 'packets/s', available_aggregate_functions: ['sum'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Available Connections (Linode)', + metric: 'fw_available_connections', + unit: 'Count', + available_aggregate_functions: ['avg', 'max', 'min'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Accepted (Linode)', + metric: 'fw_ingress_bytes_accepted', + unit: 'Bps', + available_aggregate_functions: ['sum'], + dimensions: firewallLinodeDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Accepted (Node Balancer)', + metric: 'nb_ingress_bytes_accepted', + unit: 'Bps', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Bytes Dropped (Node Balancer)', + metric: 'nb_ingress_bytes_dropped', + unit: 'Bps', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Packets Accepted (Node Balancer)', + metric: 'nb_ingress_packets_accepted', + unit: 'packets/s', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, + }), + firewallMetricDefinitionFactory.build({ + label: 'Ingress Packets Dropped (Node Balancer)', + metric: 'nb_ingress_packets_dropped', + unit: 'packets/s', + scrape_interval: '300s', + available_aggregate_functions: ['sum'], + dimensions: firewallNodebalancerDimensions, }), ]; @@ -579,3 +634,21 @@ export const blockStorageMetricCriteria = }, ], }); + +export const firewallNodebalancerMetricCriteria = + Factory.Sync.makeFactory({ + label: 'Ingress Packets Dropped (Node Balancer)', + metric: 'nb_ingress_packets_dropped', + unit: 'packets/s', + aggregate_function: 'sum', + operator: 'gt', + threshold: 1000, + dimension_filters: [ + { + label: 'NodeBalancer', + dimension_label: 'nodebalancer_id', + operator: 'in', + value: '333', + }, + ], + }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx index 64852742b49..7d98225443f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/RenderAlertsMetricsAndDimensions.tsx @@ -2,7 +2,11 @@ import { type AlertDefinitionMetricCriteria, type CloudPulseServiceType, } from '@linode/api-v4'; -import { useAllLinodesQuery, useAllVPCsQuery } from '@linode/queries'; +import { + useAllLinodesQuery, + useAllNodeBalancersQuery, + useAllVPCsQuery, +} from '@linode/queries'; import { Divider } from '@linode/ui'; import { GridLegacy } from '@mui/material'; import React, { useMemo } from 'react'; @@ -17,6 +21,7 @@ import { import { getVPCSubnets } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; import { LINODE_DIMENSION_LABEL, + NODEBALANCER_DIMENSION_LABEL, VPC_SUBNET_DIMENSION_LABEL, } from './constants'; import { DisplayAlertDetailChips } from './DisplayAlertDetailChips'; @@ -47,9 +52,18 @@ export const RenderAlertMetricsAndDimensions = React.memo( ruleCriteria, VPC_SUBNET_DIMENSION_LABEL ); + const isNodebalancersRequired = isCheckRequired( + ruleCriteria, + NODEBALANCER_DIMENSION_LABEL + ); // Initialize the query, but only run when needed const { data: linodes } = useAllLinodesQuery({}, {}, isLinodeRequired); const { data: vpcs } = useAllVPCsQuery({ enabled: isVPCRequired }); + const { data: nodebalancers } = useAllNodeBalancersQuery( + isNodebalancersRequired, + {}, + {} + ); // create a map of id to labels for lookup const linodeMap = useMemo( @@ -73,6 +87,17 @@ export const RenderAlertMetricsAndDimensions = React.memo( }, {}); }, [vpcs]); + const nodebalancersMap = useMemo(() => { + return ( + nodebalancers?.reduce>((acc, nodebalancer) => { + return { + ...acc, + [String(nodebalancer.id)]: nodebalancer.label, + }; + }, {}) ?? {} + ); + }, [nodebalancers]); + if (!ruleCriteria.rules?.length) { return ; } @@ -125,6 +150,7 @@ export const RenderAlertMetricsAndDimensions = React.memo( serviceType, value, vpcSubnetMap, + nodebalancersMap, }), ] )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/constants.ts index af2ac6bbd96..46e1684ca22 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/constants.ts @@ -8,3 +8,4 @@ export const transformationAllowedOperators: DimensionFilterOperatorType[] = [ export const LINODE_DIMENSION_LABEL = 'linode_id'; export const VPC_SUBNET_DIMENSION_LABEL = 'vpc_subnet_id'; +export const NODEBALANCER_DIMENSION_LABEL = 'nodebalancer_id'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.test.ts index 5526f2b9401..6e8b8b339e6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.test.ts @@ -82,7 +82,10 @@ describe('getResolvedDimensionValue', () => { 'subnet-1': 'VPC-1_subnet-1', 'subnet-2': 'VPC-1_subnet-2', }; - + const nodebalancersMap = { + '33': 'nodebalancer-a', + '44': 'nodebalancer-b', + }; it('should return correct transformed value', () => { const linodeResult = getResolvedDimensionValue({ dimensionFilterKey: 'linode_id', @@ -91,6 +94,7 @@ describe('getResolvedDimensionValue', () => { serviceType: 'firewall', linodeMap, vpcSubnetMap, + nodebalancersMap, }); expect(linodeResult).toBe('linode-a, linode-b'); @@ -101,10 +105,23 @@ describe('getResolvedDimensionValue', () => { serviceType: 'firewall', linodeMap, vpcSubnetMap, + nodebalancersMap, }); expect(vpcResult).toBe('VPC-1_subnet-1'); }); + it('should return correct transformed value for nodebalancer_id', () => { + const nodebalancerResult = getResolvedDimensionValue({ + dimensionFilterKey: 'nodebalancer_id', + dimensionOperator: 'in', + value: '33,44', + serviceType: 'firewall', + linodeMap, + vpcSubnetMap, + nodebalancersMap, + }); + expect(nodebalancerResult).toBe('nodebalancer-a, nodebalancer-b'); + }); it('should not transform value if operator is not in allowed list', () => { const result = getResolvedDimensionValue({ dimensionFilterKey: 'linode_id', @@ -113,6 +130,7 @@ describe('getResolvedDimensionValue', () => { serviceType: 'firewall', linodeMap, vpcSubnetMap, + nodebalancersMap, }); expect(result).toBe('linode-c, linode-d'); }); @@ -125,6 +143,7 @@ describe('getResolvedDimensionValue', () => { serviceType: 'firewall', linodeMap, vpcSubnetMap, + nodebalancersMap, }); expect(nullResult).toBe(''); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts index d56f165a53f..12467410ed3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts @@ -1,6 +1,7 @@ import { transformDimensionValue } from '../Utils/utils'; import { LINODE_DIMENSION_LABEL, + NODEBALANCER_DIMENSION_LABEL, transformationAllowedOperators, VPC_SUBNET_DIMENSION_LABEL, } from './constants'; @@ -82,6 +83,10 @@ export interface ResolvedDimensionValueProps { * linode id to label map */ linodeMap: Record; + /** + * nodebalancer id to label map. + */ + nodebalancersMap: Record; /** * Service type of the alert. */ @@ -108,6 +113,7 @@ export const getResolvedDimensionValue = ( serviceType, value, vpcSubnetMap, + nodebalancersMap, } = props; if (!value) return ''; @@ -127,6 +133,13 @@ export const getResolvedDimensionValue = ( resolvedValue = resolveIds(value, vpcSubnetMap); } + if ( + dimensionFilterKey === NODEBALANCER_DIMENSION_LABEL && + transformationAllowedOperators.includes(dimensionOperator) + ) { + resolvedValue = resolveIds(value, nodebalancersMap); + } + return transformationAllowedOperators.includes(dimensionOperator) ? transformCommaSeperatedDimensionValues( resolvedValue, diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index cbc5daf1d74..837f6b45c1b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -7,6 +7,7 @@ import EntityIcon from 'src/assets/icons/entityIcons/alertsresources.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; +import { filterFirewallResources } from '../../Utils/utils'; import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; import { MULTILINE_ERROR_SEPARATOR } from '../constants'; import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages'; @@ -41,6 +42,7 @@ import type { AlertDefinitionType, CloudPulseServiceType, Filter, + Firewall, Region, } from '@linode/api-v4'; @@ -64,6 +66,11 @@ export interface AlertResourcesProp { */ alertType: AlertDefinitionType; + /** + * The entity type for firewall filtering (linode or nodebalancer) + */ + entityType?: 'linode' | 'nodebalancer'; + /** * The error text that needs to displayed incase needed */ @@ -106,6 +113,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { alertLabel, alertResourceIds = [], alertType, + entityType, errorText, handleResourcesSelection, hideLabel, @@ -194,7 +202,12 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { ), // Enable query only if serviceType and supportedRegionIds are available, in case of firewall only serviceType is needed serviceType, {}, - xFilterToBeApplied + xFilterToBeApplied, + serviceType === 'firewall' && entityType ? entityType : undefined, + serviceType === 'firewall' && entityType + ? (resources: Firewall[]) => + filterFirewallResources(resources, entityType) + : undefined ); const regionFilteredResources = React.useMemo(() => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 251f26c56cb..a920ed1a2a3 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -28,6 +28,7 @@ import { TriggerConditions } from './Criteria/TriggerConditions'; import { EntityScopeRenderer } from './EntityScopeRenderer'; import { AlertEntityScopeSelect } from './GeneralInformation/AlertEntityScopeSelect'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; +import { EntityTypeSelect } from './GeneralInformation/EntityTypeSelect'; import { CloudPulseServiceSelect } from './GeneralInformation/ServiceTypeSelect'; import { AddChannelListing } from './NotificationChannels/AddChannelListing'; import { alertDefinitionFormSchema } from './schemas'; @@ -56,16 +57,17 @@ const criteriaInitialValues: MetricCriteriaForm = { }; const initialValues: CreateAlertDefinitionForm = { channel_ids: [], + entity_ids: [], + entity_type: 'linode', label: '', rule_criteria: { rules: [criteriaInitialValues], }, + scope: null, serviceType: null, severity: null, tags: [''], trigger_conditions: triggerConditionInitialValues, - entity_ids: [], - scope: null, }; const overrides: CrumbOverridesProps[] = [ @@ -105,7 +107,7 @@ export const CreateAlertDefinition = () => { const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( - getValues('serviceType')! + getValues('serviceType') ?? '' ); const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); @@ -162,6 +164,14 @@ export const CreateAlertDefinition = () => { defaultValue: triggerConditionInitialValues, }); resetField('scope', { defaultValue: null }); + resetField('entity_type', { defaultValue: 'linode' }); + }, [resetField]); + + const handleEntityTypeChange = React.useCallback(() => { + // Reset the criteria when entity type changes + resetField('rule_criteria.rules', { + defaultValue: [{ ...criteriaInitialValues }], + }); }, [resetField]); React.useEffect(() => { @@ -220,6 +230,12 @@ export const CreateAlertDefinition = () => { handleServiceTypeChange={handleServiceTypeChange} name="serviceType" /> + {serviceTypeWatcher === 'firewall' && ( + + )} { setMaxScrapeInterval(interval) } diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx index d581d51a0d2..3f6212cc472 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -74,6 +74,10 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { control, name: 'entity_ids', }); + const entityType = useWatch({ + control, + name: 'entity_type', + }); const serviceType = useWatch({ control, name: 'serviceType', @@ -169,6 +173,7 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { dimensionLabel={dimensionFieldWatcher} disabled={!dimensionFieldWatcher} entities={entities} + entityType={entityType ?? undefined} errorText={fieldState.error?.message} name={name} onBlur={field.onBlur} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx similarity index 100% rename from packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutcomplete.test.tsx rename to packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx index 7a5dfee8293..0d178ecd584 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.test.tsx @@ -14,11 +14,10 @@ const queryMocks = vi.hoisted(() => ({ vi.mock('@linode/queries', async (importOriginal) => ({ ...(await importOriginal()), - useRegionsQuery: queryMocks.useRegionsQuery.mockReturnValue({ data: [] }), + useRegionsQuery: queryMocks.useRegionsQuery, })); vi.mock('./useFirewallFetchOptions', () => ({ - ...vi.importActual('./useFirewallFetchOptions'), useFirewallFetchOptions: queryMocks.useFirewallFetchOptions, })); @@ -39,10 +38,17 @@ describe('', () => { scope: 'account', serviceType: 'firewall', type: 'alerts', + entityType: 'linode', }; beforeEach(() => { vi.clearAllMocks(); + queryMocks.useRegionsQuery.mockReturnValue({ data: [] }); + queryMocks.useFirewallFetchOptions.mockReturnValue({ + values: [], + isLoading: false, + isError: false, + }); }); it('renders with options when values are provided', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx index 5dd6f3f1f2d..09b482f646d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx @@ -17,28 +17,31 @@ export const FirewallDimensionFilterAutocomplete = ( ) => { const { dimensionLabel, - serviceType, - scope, + disabled, entities, + entityType, + errorText, + fieldOnBlur, + fieldOnChange, + fieldValue, multiple, name, - fieldOnChange, - disabled, - fieldOnBlur, placeholderText, - errorText, - fieldValue, + scope, + serviceType, type, } = props; const { data: regions } = useRegionsQuery(); + const { values, isLoading, isError } = useFirewallFetchOptions({ + associatedEntityType: entityType, dimensionLabel, - regions, entities, + regions, + scope, serviceType, type, - scope, }); useCleanupStaleValues({ diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index 3bb1ce9d033..40a5e2ef8ed 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -19,6 +19,7 @@ import type { CloudPulseServiceType, DimensionFilterOperatorType, } from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; interface ValueFieldRendererProps { /** @@ -35,6 +36,10 @@ interface ValueFieldRendererProps { * List of entity IDs used to filter resources like firewalls. */ entities?: string[]; + /** + * The entity type for firewall filtering (linode or nodebalancer). + */ + entityType?: AssociatedEntityType; /** * Error message to be displayed under the input field, if any. */ @@ -87,20 +92,21 @@ interface ValueFieldRendererProps { export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { const { - serviceType, - scope, dimensionLabel, disabled, entities, + entityType, errorText, name, onBlur, onChange, operator, - value, - values, + scope, selectedRegions, + serviceType, type = 'alerts', + value, + values, } = props; // Use operator group for config lookup const operatorGroup = getOperatorGroup(operator); @@ -185,6 +191,8 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts index 146eb7884ad..ff218f9c9a1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/constants.ts @@ -408,6 +408,10 @@ export interface DimensionFilterAutocompleteProps { * List of entity IDs selected in the entity scope. */ entities?: string[]; + /** + * The entity type for firewall filtering (linode or nodebalancer). + */ + entityType?: AssociatedEntityType; /** * Optional error message to display beneath the input. */ diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts index e452d640931..9ccc7f688fc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions.ts @@ -5,19 +5,21 @@ import { } from '@linode/queries'; import { useMemo } from 'react'; +import { filterFirewallResources } from 'src/features/CloudPulse/Utils/utils'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { filterRegionByServiceType } from '../../../Utils/utils'; import { getFilteredFirewallParentEntities, getFirewallLinodes, + getFirewallNodebalancers, getLinodeRegions, getNodebalancerRegions, getVPCSubnets, } from './utils'; import type { FetchOptions, FetchOptionsProps } from './constants'; -import type { Filter } from '@linode/api-v4'; +import type { Filter, Firewall } from '@linode/api-v4'; /** * Custom hook to return selectable options based on the dimension type. @@ -58,6 +60,7 @@ export function useFirewallFetchOptions( 'linode_id', 'region_id', 'associated_entity_region', + 'nodebalancer_id', ]; // Fetch all firewall resources when dimension requires it @@ -70,7 +73,12 @@ export function useFirewallFetchOptions( 'firewall', {}, {}, - associatedEntityType // To avoid fetching resources for which the associated entity type is not supported + associatedEntityType, + associatedEntityType + ? (resources: Firewall[]) => + filterFirewallResources(resources, associatedEntityType) + : undefined + // To avoid fetching resources for which the associated entity type is not supported ); // Decide firewall resource IDs based on scope const filteredFirewallParentEntityIds = useMemo(() => { @@ -140,6 +148,12 @@ export function useFirewallFetchOptions( [linodes] ); + // Extract nodebalancers from filtered firewall resources + const firewallNodebalancers = useMemo( + () => getFirewallNodebalancers(nodebalancers ?? []), + [nodebalancers] + ); + // Extract unique regions from linodes const linodeRegions = useMemo( () => getLinodeRegions(linodes ?? []), @@ -187,6 +201,12 @@ export function useFirewallFetchOptions( isError: isLinodesError || isResourcesError, isLoading: isLinodesLoading || isResourcesLoading, }; + case 'nodebalancer_id': + return { + values: firewallNodebalancers, + isError: isNodebalancersError || isResourcesError, + isLoading: isNodebalancersLoading || isResourcesLoading, + }; case 'region_id': return { values: linodeRegions, diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index ab8be225fab..11498b8db95 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -132,6 +132,25 @@ export const getFirewallLinodes = ( })); }; +/** + * Extracts nodebalancer items from firewall resources. + * @param nodebalancers - List of nodebalancers. + * @returns - Flattened list of nodebalancer ID/label pairs as options. + */ +export const getFirewallNodebalancers = ( + nodebalancers: NodeBalancer[] +): Item[] => { + if (!nodebalancers) return []; + return nodebalancers.map((nodebalancer) => ({ + label: transformDimensionValue( + 'firewall', + 'nodebalancer_id', + nodebalancer.label + ), + value: String(nodebalancer.id), + })); +}; + /** * Extracts unique region values from a list of linodes. * @param linodes - Linode objects with region information. diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index e28bb635df9..d72bf2bdba0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -6,6 +6,7 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import type { FieldPathByValue } from 'react-hook-form'; import { + entityLabelMap, metricAggregationOptions, metricOperatorOptions, } from '../../constants'; @@ -78,15 +79,25 @@ export const Metric = (props: MetricCriteriaProps) => { resetField(name, { defaultValue: fieldValue }); } }; + const serviceType = useWatch({ control, name: 'serviceType' }); + const entityType = useWatch({ control, name: 'entity_type' }); const metricOptions = React.useMemo(() => { - return data - ? data.map((metric) => ({ + let filteredData = data; + + // Filter firewall metrics based on entity type + if (serviceType === 'firewall' && entityType) { + const entityLabel = entityLabelMap[entityType]; + filteredData = data.filter(({ label }) => label.includes(entityLabel)); + } + + return filteredData + ? filteredData.map((metric) => ({ label: metric.label, value: metric.metric, })) : []; - }, [data]); + }, [data, entityType, serviceType]); const metricWatcher = useWatch({ control, name: `${name}.metric` }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx new file mode 100644 index 00000000000..952574122dd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx @@ -0,0 +1,132 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { EntityTypeSelect } from './EntityTypeSelect'; + +describe('EntityTypeSelect component tests', () => { + const onEntityChange = vi.fn(); + const ENTITY_TYPE_SELECT_TEST_ID = 'entity-type-select'; + + it('should render the Autocomplete component', () => { + const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + getAllByText('Entity Type'); + }); + + it('should render entity type options', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + await screen.findByRole('option', { + name: 'Linodes', + }) + ).toBeVisible(); + expect( + screen.getByRole('option', { + name: 'NodeBalancers', + }) + ).toBeVisible(); + }); + + it('should be able to select an entity type', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Linodes' }) + ); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Linodes'); + }); + + it('should call onEntityTypeChange when selection changes', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'NodeBalancers' }) + ); + expect(onEntityChange).toHaveBeenCalled(); + }); + + it('should not have a clear button since disableClearable is true', () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + entity_type: 'linode', + }, + }, + }); + const entityTypeDropdown = screen.getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + expect( + within(entityTypeDropdown).queryByRole('button', { name: 'Clear' }) + ).not.toBeInTheDocument(); + }); + + it('should maintain selection and not allow clearing', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + entity_type: 'linode', + }, + }, + }); + expect(screen.getByRole('combobox')).toHaveAttribute('value', 'Linodes'); + + // Select a different option + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'NodeBalancers' }) + ); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'value', + 'NodeBalancers' + ); + + // Verify there's no clear button + const entityTypeDropdown = screen.getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + expect( + within(entityTypeDropdown).queryByRole('button', { name: 'Clear' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx new file mode 100644 index 00000000000..b5f00f52aed --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx @@ -0,0 +1,66 @@ +import { Autocomplete } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import type { ControllerRenderProps, FieldPathByValue } from 'react-hook-form'; + +import type { Item } from '../../constants'; +import type { CreateAlertDefinitionForm } from '../types'; + +interface EntityTypeSelectProps { + /** + * name used for the component in the form + */ + name: FieldPathByValue< + CreateAlertDefinitionForm, + 'linode' | 'nodebalancer' | undefined + >; + /** + * Callback function triggered when entity type changes + */ + onEntityTypeChange: () => void; +} + +const entityTypeOptions: Item[] = [ + { label: 'Linodes', value: 'linode' }, + { label: 'NodeBalancers', value: 'nodebalancer' }, +]; + +export const EntityTypeSelect = (props: EntityTypeSelectProps) => { + const { name, onEntityTypeChange } = props; + const { control } = useFormContext(); + + const handleAutocompleteChange = ( + field: ControllerRenderProps, + selected: null | { label: string; value: 'linode' | 'nodebalancer' } + ) => { + if (selected) { + field.onChange(selected.value); + onEntityTypeChange(); + } + }; + + return ( + ( + handleAutocompleteChange(field, selected)} + options={entityTypeOptions} + placeholder="Select an Entity Type" + sx={{ marginTop: '5px' }} + value={ + entityTypeOptions.find((option) => option.value === field.value) ?? + undefined + } + /> + )} + /> + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx index fb3b97f1da1..9abedb51d48 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx @@ -22,7 +22,7 @@ export const CloudPulseModifyAlertResources = React.memo( const { name } = props; const { control, setValue } = useFormContext(); const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); - + const entityTypeWatcher = useWatch({ control, name: 'entity_type' }); const flags = useFlags(); const maxSelectionCount = React.useMemo(() => { @@ -63,6 +63,7 @@ export const CloudPulseModifyAlertResources = React.memo( () + .oneOf(['linode', 'nodebalancer']) + .when('serviceType', { + is: 'firewall', + then: (schema) => schema.required(fieldErrorMessage), + otherwise: (schema) => schema.optional(), + }), rule_criteria: object({ rules: array() .of(metricCriteriaSchema) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts index 831fef99561..2e4f1319e06 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/types.ts @@ -1,3 +1,4 @@ +import type { AssociatedEntityType } from '../../shared/types'; import type { AlertDefinitionScope, AlertSeverityType, @@ -18,6 +19,7 @@ export interface CreateAlertDefinitionForm 'rule_criteria' | 'severity' | 'trigger_conditions' > { entity_ids?: string[]; + entity_type?: AssociatedEntityType; regions?: string[]; rule_criteria: { rules: MetricCriteriaForm[]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts index c97ab56fa34..d5a18e9cfca 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/utilities.ts @@ -25,6 +25,7 @@ export const filterFormValues = ( 'severity', 'rule_criteria', 'trigger_conditions', + 'entity_type', ]); const severity = formValues.severity ?? 1; const entityIds = formValues.entity_ids; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx index fde8e754be3..a9a39877065 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -14,6 +14,7 @@ import { useCloudPulseServiceByServiceType } from 'src/queries/cloudpulse/servic import { CREATE_ALERT_ERROR_FIELD_MAP as EDIT_ALERT_ERROR_FIELD_MAP, + entityLabelMap, MULTILINE_ERROR_SEPARATOR, SINGLELINE_ERROR_SEPARATOR, UPDATE_ALERT_SUCCESS_MESSAGE, @@ -23,6 +24,7 @@ import { TriggerConditions } from '../CreateAlert/Criteria/TriggerConditions'; import { EntityScopeRenderer } from '../CreateAlert/EntityScopeRenderer'; import { AlertEntityScopeSelect } from '../CreateAlert/GeneralInformation/AlertEntityScopeSelect'; import { CloudPulseAlertSeveritySelect } from '../CreateAlert/GeneralInformation/AlertSeveritySelect'; +import { EntityTypeSelect } from '../CreateAlert/GeneralInformation/EntityTypeSelect'; import { CloudPulseServiceSelect } from '../CreateAlert/GeneralInformation/ServiceTypeSelect'; import { AddChannelListing } from '../CreateAlert/NotificationChannels/AddChannelListing'; import { alertDefinitionFormSchema } from '../CreateAlert/schemas'; @@ -64,12 +66,23 @@ export const EditAlertDefinition = (props: EditAlertProps) => { alertDetails, serviceType ); + + const entityType = + serviceType === 'firewall' + ? alertDetails.rule_criteria.rules[0]?.label.includes( + entityLabelMap['nodebalancer'] + ) + ? 'nodebalancer' + : 'linode' + : undefined; + const flags = useFlags(); const formMethods = useForm({ defaultValues: { ...filteredAlertDefinitionValues, serviceType, scope: alertDetails.scope, + entity_type: entityType, }, mode: 'onBlur', resolver: yupResolver( @@ -135,7 +148,21 @@ export const EditAlertDefinition = (props: EditAlertProps) => { position: 1, }, ]; - + const { resetField } = formMethods; + const handleEntityTypeChange = React.useCallback(() => { + // Reset the criteria when entity type changes + resetField('rule_criteria.rules', { + defaultValue: [ + { + aggregate_function: null, + dimension_filters: [], + metric: null, + operator: null, + threshold: 0, + }, + ], + }); + }, [resetField]); const previousSubmitCount = React.useRef(0); React.useEffect(() => { if ( @@ -187,6 +214,12 @@ export const EditAlertDefinition = (props: EditAlertProps) => { )} /> + {serviceType === 'firewall' && ( + + )} > = { default: CONFIG_ERROR_MESSAGE, }, }; + +export const entityLabelMap = { + linode: 'Linode', + nodebalancer: 'Node Balancer', +}; diff --git a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts index d90b7c1d669..6546b576342 100644 --- a/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts +++ b/packages/manager/src/features/CloudPulse/shared/DimensionTransform.ts @@ -31,6 +31,8 @@ export const DIMENSION_TRANSFORM_CONFIG: Partial< interface_type: TRANSFORMS.uppercase, linode_id: TRANSFORMS.original, nodebalancer_id: TRANSFORMS.original, + ip_version: TRANSFORMS.original, + region_id: TRANSFORMS.original, }, nodebalancer: { protocol: TRANSFORMS.uppercase, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4c98a005962..ee7e446336b 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -66,6 +66,7 @@ import { firewallFactory, firewallMetricDefinitionsResponse, firewallMetricRulesFactory, + firewallNodebalancerMetricCriteria, firewallPrefixListFactory, firewallRuleFactory, firewallRuleSetFactory, @@ -1304,6 +1305,18 @@ export const handlers = [ }), ], }), + firewallFactory.build({ + label: 'Firewall - Nodebalancer', + id: 25, + entities: [ + firewallEntityfactory.build({ + type: 'nodebalancer', + label: 'Nodebalancer-test', + parent_entity: null, + id: 333, + }), + ], + }), // Firewall with the Rule and RuleSet Reference firewallFactory.build({ id: 1001, @@ -1407,6 +1420,13 @@ export const handlers = [ }), http.get('*/v4/nodebalancers', () => { const nodeBalancers = nodeBalancerFactory.buildList(3); + nodeBalancers.push( + nodeBalancerFactory.build({ + id: 333, + label: 'NodeBalancer-33', + region: 'ap-west', + }) + ); return HttpResponse.json(makeResourcePage(nodeBalancers)); }), http.get('*/v4/nodebalancers/types', () => { @@ -3189,6 +3209,16 @@ export const handlers = [ rules: [blockStorageMetricCriteria.build()], }, }), + alertFactory.build({ + id: 650, + label: 'Firewall - nodebalancer', + type: 'user', + service_type: 'firewall', + entity_ids: ['25'], + rule_criteria: { + rules: [firewallNodebalancerMetricCriteria.build()], + }, + }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), @@ -3198,11 +3228,12 @@ export const handlers = [ if (params.id === '999' && params.serviceType === 'firewall') { return HttpResponse.json( alertFactory.build({ + scope: 'entity', + entity_ids: ['1', '2', '3'], id: 999, label: 'Firewall - testing', service_type: 'firewall', type: 'user', - scope: 'account', rule_criteria: { rules: [firewallMetricRulesFactory.build()], }, @@ -3240,6 +3271,21 @@ export const handlers = [ }) ); } + if (params.id === '650' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 650, + label: 'Firewall - nodebalancer', + type: 'user', + scope: 'entity', + service_type: 'firewall', + entity_ids: ['25'], + rule_criteria: { + rules: [firewallNodebalancerMetricCriteria.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ @@ -3306,6 +3352,21 @@ export const handlers = [ }) ); } + if (params.id === '650' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 650, + label: 'Firewall - nodebalancer', + type: 'user', + scope: 'entity', + service_type: 'firewall', + entity_ids: ['25'], + rule_criteria: { + rules: [firewallNodebalancerMetricCriteria.build()], + }, + }) + ); + } const body: any = request.json(); return HttpResponse.json( alertFactory.build({ From ad6aaa2c43c0a71108f6da0bd04689d0c6ffd8a5 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:16:39 +0100 Subject: [PATCH 21/91] fix: [UIE-9657] - Alignment with Linode row backup cell icon (#13098) * fix alignment * Added changeset: Alignment with Linode row backup cell icon --- packages/manager/.changeset/pr-13098-fixed-1763371721536.md | 5 +++++ .../manager/src/components/BackupStatus/BackupStatus.tsx | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 packages/manager/.changeset/pr-13098-fixed-1763371721536.md diff --git a/packages/manager/.changeset/pr-13098-fixed-1763371721536.md b/packages/manager/.changeset/pr-13098-fixed-1763371721536.md new file mode 100644 index 00000000000..c7e5164cde8 --- /dev/null +++ b/packages/manager/.changeset/pr-13098-fixed-1763371721536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Alignment with Linode row backup cell icon ([#13098](https://github.com/linode/manager/pull/13098)) diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index 32b1a2751c0..95d5a831c44 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -12,11 +12,13 @@ const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ backupLink: { '&:hover': { + textDecoration: 'none', [`& .${classes.icon}`]: { fill: theme.palette.primary.main, }, }, display: 'flex', + alignItems: 'center', }, backupNotApplicable: { marginRight: theme.spacing(), @@ -30,6 +32,8 @@ const useStyles = makeStyles()( icon: { fill: theme.color.grey1, fontSize: 18, + top: -1, + position: 'relative', }, tooltip: { maxWidth: 275, From f9d97e8a3f2fe744025e07641688009e8cd8acb8 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 20 Nov 2025 14:31:35 +0530 Subject: [PATCH 22/91] fix: [M3-10709] - `firewall_id` error on LKE pool update (#13109) * Fix firewall_id error on LKE E pool update * Added changeset: The `firewall_id` error on LKE pool update * Add comments and update false check instances * Revert accidental removal of update_strategy --- .../manager/.changeset/pr-13109-fixed-1763535418420.md | 5 +++++ .../ConfigureNodePool/ConfigureNodePoolForm.tsx | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13109-fixed-1763535418420.md diff --git a/packages/manager/.changeset/pr-13109-fixed-1763535418420.md b/packages/manager/.changeset/pr-13109-fixed-1763535418420.md new file mode 100644 index 00000000000..5ed542147bf --- /dev/null +++ b/packages/manager/.changeset/pr-13109-fixed-1763535418420.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +The `firewall_id` error on LKE pool update ([#13109](https://github.com/linode/manager/pull/13109)) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx index e450fdfef2e..bd19c9844aa 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ConfigureNodePool/ConfigureNodePoolForm.tsx @@ -46,7 +46,13 @@ export const ConfigureNodePoolForm = (props: Props) => { // @TODO allow users to edit Node Pool `label` and `tags` because the API supports it. (ECE-353) // label: nodePool.label, // tags: nodePool.tags, - firewall_id: nodePool.firewall_id, + + /** + * We set the default value of the form field to `undefined` if `nodePool.firewall_id` is null. + * This ensures the field remains controlled in React Hook Form and is properly initialized, + * preventing unexpected validation errors when the initial value is null. + */ + firewall_id: nodePool.firewall_id ?? undefined, update_strategy: nodePool.update_strategy, k8s_version: nodePool.k8s_version, }, @@ -87,7 +93,7 @@ export const ConfigureNodePoolForm = (props: Props) => { clusterTier={clusterTier ?? 'standard'} firewallSelectOptions={{ allowFirewallRemoval: clusterTier === 'standard', - ...(nodePool.firewall_id !== 0 && { + ...(nodePool.firewall_id !== null && { disableDefaultFirewallRadio: true, defaultFirewallRadioTooltip: "You can't use this option once an existing Firewall has been selected.", From 73a7ca40d559ef88e1d3ec46f82d0e57a6a3da45 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Fri, 21 Nov 2025 10:09:47 +0530 Subject: [PATCH 23/91] upcoming: [UIE-6561] - Add NLB Detail Paper (#13103) * upcoming: [UIE-9559, UIE-9560] - Implement routing for Network Load Balancer * upcoming: [UIE-9558] - Add new API endpoints, types and queries for Network Load Balancer * added support for feature flag * PR feedback @tanushree-akamai * add unit test for Network Load Balancers Item in primary nav component * Adding changesets * Fix type specification for NLB. * Use named parameter interfaces for api-v4 client functions with 3+ arguments. * Added changeset: Implement feature flag and routing for NLB * upcoming: [UIE-6561] - Add NLB Detail Paper * remove body border from detail paper * small fixes * PR feedback * fixed failing unit test * fix responsiveness * remove template literals for variables * correct the kubernetes hyperlink --------- Co-authored-by: Tanushree Bhattacharji --- packages/api-v4/src/regions/types.ts | 1 + ...torageDimensionFilterAutocomplete.test.tsx | 2 +- .../NetworkLoadBalancerDetailBody.tsx | 139 ++++++++++++++++++ .../NetworkLoadBalancerDetailHeader.tsx | 53 +++++++ .../NetworkLoadBalancerDetailLazyRoute.ts | 9 ++ .../NetworkLoadBalancersDetail.test.tsx | 126 ++++++++++++++++ .../NetworkLoadBalancersDetail.tsx | 70 +++++++++ .../src/routes/networkLoadBalancer/index.ts | 35 ++++- 8 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailHeader.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute.ts create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 4da65b28c95..e4fa034d08a 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -19,6 +19,7 @@ export type Capabilities = | 'Managed Databases' | 'Metadata' | 'NETINT Quadra T1U' + | 'Network LoadBalancer' | 'NodeBalancers' | 'Object Storage' | 'Placement Group' diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx index 013d6248fc7..4cc6895b63f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/BlockStorageDimensionFilterAutocomplete.test.tsx @@ -317,4 +317,4 @@ describe('', () => { expect(fieldOnChange).toHaveBeenCalledWith(''); }); }); -}); \ No newline at end of file +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx new file mode 100644 index 00000000000..234d51b27a9 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailBody.tsx @@ -0,0 +1,139 @@ +import { useProfile, useRegionQuery } from '@linode/queries'; +import { Box, Stack, Typography } from '@linode/ui'; +import { Grid, styled } from '@mui/material'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { AccessTable } from 'src/features/Linodes/AccessTable'; +import { StyledBodyGrid } from 'src/features/Linodes/LinodeEntityDetail.styles'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { LKEClusterInfo } from '@linode/api-v4'; + +interface NetworkLoadBalancerDetailBodyInterface { + addressV4: string; + addressV6: string; + createdDate: string; + lkeCluster?: LKEClusterInfo; + nlbId: number; + region: string; + updatedDate: string; +} + +export const NetworkLoadBalancerDetailBody = ( + props: NetworkLoadBalancerDetailBodyInterface +) => { + const { + addressV4, + addressV6, + createdDate, + lkeCluster, + nlbId, + region, + updatedDate, + } = props; + + const { data: profile } = useProfile(); + const { data: regionData } = useRegionQuery(region); + + const regionLabel = regionData?.label ?? region; + + return ( + + + + + + Region + {regionLabel} + + + LKE-E Cluster + {lkeCluster ? ( + <> + + {lkeCluster.label} + + {` (ID: ${lkeCluster.id})`} + + ) : ( + 'N/A' + )} + + + Network Load Balancer ID + {nlbId} + + + + + + + Created + {formatDate(createdDate, { + timezone: profile?.timezone, + })} + + + Updated + {formatDate(updatedDate, { + timezone: profile?.timezone, + })} + + + + + + + + + + + ); +}; + +const StyledTypography = styled(Typography)(({ theme }) => ({ + font: theme.font.bold, +})); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailHeader.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailHeader.tsx new file mode 100644 index 00000000000..e03cfb13f28 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailHeader.tsx @@ -0,0 +1,53 @@ +import { Box, Stack, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; + +import type { NetworkLoadBalancerStatus } from '@linode/api-v4'; + +export const NetworkLoadBalancerDetailHeader = (props: { + status: NetworkLoadBalancerStatus; +}) => { + const { status } = props; + + const statusIcon = () => { + if (status === 'active') { + return 'active'; + } + if (['canceled', 'suspended'].includes(status)) { + return 'inactive'; + } + return 'other'; + }; + + return ( + + ({ + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + padding: `${theme.spacingFunction(13)} 0 ${theme.spacingFunction(13)} ${theme.spacingFunction(16)}`, + })} + > + + + ({ + font: theme.font.bold, + })} + > + {status.toUpperCase()} + + + + + ); +}; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute.ts b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute.ts new file mode 100644 index 00000000000..b8b45c46826 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import NetworkLoadBalancersDetail from './NetworkLoadBalancersDetail'; + +export const networkLoadBalancerDetailLazyRoute = createLazyRoute( + '/netloadbalancers/$id' +)({ + component: NetworkLoadBalancersDetail, +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx new file mode 100644 index 00000000000..e1da99e0a4e --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.test.tsx @@ -0,0 +1,126 @@ +import { regionFactory } from '@linode/utilities'; +import * as React from 'react'; + +import { networkLoadBalancerFactory } from 'src/factories/networkLoadBalancer'; +import { formatDate } from 'src/utilities/formatDate'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import NetworkLoadBalancersDetail from './NetworkLoadBalancersDetail'; + +const queryMocks = vi.hoisted(() => ({ + useNetworkLoadBalancerQuery: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({}), + useProfile: vi.fn().mockReturnValue({}), + useRegionQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useProfile: queryMocks.useProfile, + useRegionQuery: queryMocks.useRegionQuery, + useNetworkLoadBalancerQuery: queryMocks.useNetworkLoadBalancerQuery, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +beforeAll(() => mockMatchMedia()); + +const mockRegion = regionFactory.build({ + id: 'us-east', + capabilities: ['Network LoadBalancer'], + label: 'US, Newark, NJ', +}); + +describe('NetworkLoadBalancersDetail', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ + id: 1, + }); + queryMocks.useRegionQuery.mockReturnValue({ data: mockRegion }); + }); + + it('renders a loading state', () => { + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByTestId } = renderWithTheme(); + + expect(getByTestId('circle-progress')).toBeInTheDocument(); + }); + + it('renders an error state', () => { + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + error: true, + }); + + const { getByText } = renderWithTheme(); + + expect( + getByText('There was a problem retrieving your NLB. Please try again.') + ).toBeInTheDocument(); + }); + + it('renders the NLB details', () => { + const nlbFactory = networkLoadBalancerFactory.build(); + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + data: nlbFactory, + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('ACTIVE')).toBeInTheDocument(); + + expect(getByText('Virtual IP (IPv4)')).toBeInTheDocument(); + expect(getByText(nlbFactory.address_v4)).toBeInTheDocument(); + + expect(getByText('Virtual IP (IPv6)')).toBeInTheDocument(); + expect(getByText(nlbFactory.address_v6)).toBeInTheDocument(); + + expect(getByText('Region')).toBeInTheDocument(); + expect(getByText('US, Newark, NJ')).toBeInTheDocument(); + + expect(getByText('LKE-E Cluster')).toBeInTheDocument(); + expect(getByText('N/A')).toBeInTheDocument(); + + expect(getByText('Network Load Balancer ID')).toBeInTheDocument(); + expect(getByText(nlbFactory.id)).toBeInTheDocument(); + + expect(getByText('Created')).toBeInTheDocument(); + expect(getByText(formatDate(nlbFactory.created))).toBeInTheDocument(); + + expect(getByText('Updated')).toBeInTheDocument(); + expect(getByText(formatDate(nlbFactory.updated))).toBeInTheDocument(); + }); + + it('renders LKE Details if the NLB is associated with an LKE cluster', () => { + const nlbFactory = networkLoadBalancerFactory.build({ + lke_cluster: { id: 1, label: 'Test Cluster' }, + }); + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + data: nlbFactory, + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('LKE-E Cluster')).toBeInTheDocument(); + expect(getByText(nlbFactory.lke_cluster!.label)).toBeInTheDocument(); + expect( + getByText(`(ID: ${nlbFactory.lke_cluster!.id})`, { + exact: false, + }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx new file mode 100644 index 00000000000..545d4fcad59 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx @@ -0,0 +1,70 @@ +import { useNetworkLoadBalancerQuery } from '@linode/queries'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; +import { LandingHeader } from 'src/components/LandingHeader'; + +import { NetworkLoadBalancerDetailBody } from './NetworkLoadBalancerDetailBody'; +import { NetworkLoadBalancerDetailHeader } from './NetworkLoadBalancerDetailHeader'; + +const NetworkLoadBalancersDetail = () => { + const params = useParams({ strict: false }); + const { id } = params; + + const { + data: nlb, + error, + isLoading, + } = useNetworkLoadBalancerQuery(Number(id) || -1, true); + + if (isLoading) { + return ; + } + + if (!nlb || error) { + return ( + + ); + } + + return ( + <> + + + + } + header={} + noBodyBottomBorder={true} + /> + + ); +}; + +export default NetworkLoadBalancersDetail; diff --git a/packages/manager/src/routes/networkLoadBalancer/index.ts b/packages/manager/src/routes/networkLoadBalancer/index.ts index 80427760f2f..fb2421fef67 100644 --- a/packages/manager/src/routes/networkLoadBalancer/index.ts +++ b/packages/manager/src/routes/networkLoadBalancer/index.ts @@ -1,4 +1,4 @@ -import { createRoute } from '@tanstack/react-router'; +import { createRoute, redirect } from '@tanstack/react-router'; import { rootRoute } from '../root'; import { NetworkLoadBalancersRoute } from './networkLoadBalancersRoute'; @@ -18,5 +18,36 @@ const networkLoadBalancersIndexRoute = createRoute({ ).then((m) => m.networkLoadBalancersLazyRoute) ); +const networkLoadBalancerDetailRoute = createRoute({ + beforeLoad: async ({ params }) => { + throw redirect({ + params: { + id: params.id, + }, + to: '/netloadbalancers/$id/listeners', + }); + }, + getParentRoute: () => networkLoadBalancersRoute, + path: '$id', +}).lazy(() => + import( + 'src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute' + ).then((m) => m.networkLoadBalancerDetailLazyRoute) +); + +const networkLoadBalancerListenersRoute = createRoute({ + getParentRoute: () => networkLoadBalancersRoute, + path: '$id/listeners', +}).lazy(() => + import( + 'src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerDetailLazyRoute' + ).then((m) => m.networkLoadBalancerDetailLazyRoute) +); + export const networkLoadBalancersRouteTree = - networkLoadBalancersRoute.addChildren([networkLoadBalancersIndexRoute]); + networkLoadBalancersRoute.addChildren([ + networkLoadBalancersIndexRoute, + networkLoadBalancerDetailRoute.addChildren([ + networkLoadBalancerListenersRoute, + ]), + ]); From 1a4c5c0b1b108e7e6c59a1e7d2eccd6b3fb72cf9 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:29:15 +0100 Subject: [PATCH 24/91] fix: [UIE-9655] - Disabled Tab + Tooltip styles & accessibility (#13113) * save progress * styles and accessibility * hover styles * Added changeset: Disabled Tab + Tooltip styles & accessibility --- .../pr-13113-fixed-1763631318653.md | 5 ++ .../components/TabbedPanel/TabbedPanel.tsx | 48 ++++++++++++------- packages/manager/src/components/Tabs/Tab.tsx | 6 +++ 3 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 packages/manager/.changeset/pr-13113-fixed-1763631318653.md diff --git a/packages/manager/.changeset/pr-13113-fixed-1763631318653.md b/packages/manager/.changeset/pr-13113-fixed-1763631318653.md new file mode 100644 index 00000000000..5789752b6ca --- /dev/null +++ b/packages/manager/.changeset/pr-13113-fixed-1763631318653.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Disabled Tab + Tooltip styles & accessibility ([#13113](https://github.com/linode/manager/pull/13113)) diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index a882a95e0c4..be26da4d87e 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -54,13 +54,6 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { const [tabIndex, setTabIndex] = useState(initTab); - const sxHelpIcon = { - height: 20, - m: 0.5, - verticalAlign: 'sub', - width: 20, - }; - const tabChangeHandler = (index: number) => { setTabIndex(index); if (handleTabChange) { @@ -99,20 +92,39 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( - - {tab.title} + <> + + {tab.title} + {tab.disabled && props.tabDisabledMessage && ( - - - - + + ({ + marginLeft: `-${theme.spacingFunction(12)}`, + marginTop: theme.spacingFunction(10), + })} + > + ({ + height: 20, + m: 0.5, + width: 20, + color: theme.tokens.component.Tab.Disabled.Icon, + '&:hover': { + color: theme.tokens.component.Tab.Hover.Icon, + cursor: 'pointer', + }, + })} + /> + )} - + ))} diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index e990b44eea9..fa45b47d67d 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -14,6 +14,12 @@ const useStyles = makeStyles()((theme: Theme) => ({ '&:hover': { backgroundColor: theme.color.grey7, }, + '&:disabled': { + opacity: 1, + color: theme.tokens.component.Tab.Disabled.Text, + cursor: 'not-allowed', + pointerEvents: 'none', + }, alignItems: 'center', borderBottom: '2px solid transparent', color: theme.textColors.linkActiveLight, From 664bcde9e9880b8d27b2fa8b4d613b9f5787bcec Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:08:34 +0100 Subject: [PATCH 25/91] Bump glob from 10.4.5 to 10.5.0 (#13114) --- packages/manager/package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index d3b16ad2d23..11169d99914 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -171,7 +171,7 @@ "cypress-vite": "^1.8.0", "dotenv": "^16.0.3", "factory.ts": "^0.5.1", - "glob": "^10.3.1", + "glob": "^10.5.0", "globals": "^16.0.0", "history": "4", "jsdom": "^24.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bccdfcdc81..e99605e90e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,8 +492,8 @@ importers: specifier: ^0.5.1 version: 0.5.2 glob: - specifier: ^10.3.1 - version: 10.4.5 + specifier: ^10.5.0 + version: 10.5.0 globals: specifier: ^16.0.0 version: 16.0.0 @@ -4207,8 +4207,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true glob@7.2.3: @@ -7352,7 +7352,7 @@ snapshots: '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - glob: 10.4.5 + glob: 10.5.0 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.7.3) vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) @@ -10213,7 +10213,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: + glob@10.5.0: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 @@ -12096,7 +12096,7 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.8 commander: 4.1.1 - glob: 10.4.5 + glob: 10.5.0 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.6 @@ -12143,7 +12143,7 @@ snapshots: test-exclude@7.0.1: dependencies: '@istanbuljs/schema': 0.1.3 - glob: 10.4.5 + glob: 10.5.0 minimatch: 9.0.5 text-segmentation@1.0.3: From f76c020090fda2d4638b578c5cd305d61c96836f Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:04:15 +0100 Subject: [PATCH 26/91] change: [UIE-9700] - Await permissions before rendering Linode Detail Header (#13124) * Await permissions before loading Linode Detail Header * Added changeset: Await permissions before rendering Linode Detail Header --- .../manager/.changeset/pr-13124-changed-1763726824931.md | 5 +++++ .../LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13124-changed-1763726824931.md diff --git a/packages/manager/.changeset/pr-13124-changed-1763726824931.md b/packages/manager/.changeset/pr-13124-changed-1763726824931.md new file mode 100644 index 00000000000..6e3faa3a17b --- /dev/null +++ b/packages/manager/.changeset/pr-13124-changed-1763726824931.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Await permissions before rendering Linode Detail Header ([#13124](https://github.com/linode/manager/pull/13124)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index 22ef7bc94e3..6b603bea58a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -56,7 +56,7 @@ export const LinodeDetailHeader = () => { const { mutateAsync: updateLinode } = useLinodeUpdateMutation(matchedLinodeId); - const { data: permissions } = usePermissions( + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( 'linode', ['update_linode'], linodeId @@ -163,7 +163,7 @@ export const LinodeDetailHeader = () => { onOpenResizeDialog, }; - if (isLoading) { + if (isLoading || isPermissionsLoading) { return ; } From 095c9046cbbd58d976ff0c4c33f829ad556164b4 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 21 Nov 2025 22:39:56 +0530 Subject: [PATCH 27/91] upcoming: [UIE-9514] - Update Firewall Rule Drawer to support referencing Rule Set (#13094) * Save progress * Update tests * Few more changes * Add more changes * Clean up tests * Few changes * Layout updates * Update tests * Add ruleset loading state * Clean up mocks * Fix mocks * Add comments to the type * Added changeset: Update FirewallRuleType to support ruleset * Added changeset: Update FirewallRuleTypeSchema to support ruleset * Added changeset: Add new Firewall RuleSet row layout * Update ruleset action text - Delete to Remove * Save progress... * Update comment * Exclude 'addresses' from rulesets reference payloads * Some fixes * Add more details to the drawer for rulset * More changes... * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Add more changes to the drawer for rulesets * Update gap & fontsize of firwall add rules selection card * Fix Chip shrink issue * Revert Action column movement since its not yet confirmed * More Refactoring + better formstate typesaftey * Add renderOptions for Add ruleset Autocomplete * Fix typos * Few updates * Few fixes * Update cypress component tests * More changes * Update Add rulesets button copy * More Updates * Feature flag create entity selection for ruleset * More refactoring - separating form states * Some clean up... * Show only rulsets in dropdown applicable to the given catergory * Update date format * Update badge color tokens * Capitalize action label in chip * Update Chip width * Added changeset: Update Firewall Rule Drawer to support referencing Rule Set * Update placeholder for Select Rule Set * Few updates and clean up * Make cy test work * Clean up: remove duplicate validation * Add cancel btn for rules form + some design tokens for dropdown options * Add unit tests for Add Rule Set Drawer * Update test title * Mock useIsFirewallRulesetsPrefixlistsEnabled instead of feature flag * Fix styling and a bit of clean up --- ...r-13094-upcoming-features-1763558731421.md | 5 + .../Rules/FirewallRuleDrawer.test.tsx | 78 ++++++ .../Rules/FirewallRuleDrawer.tsx | 197 ++++++++++++--- .../Rules/FirewallRuleDrawer.types.ts | 14 ++ .../FirewallDetail/Rules/FirewallRuleForm.tsx | 19 +- .../Rules/FirewallRuleSetForm.tsx | 237 ++++++++++++++++++ .../Rules/FirewallRuleTable.tsx | 16 +- .../Rules/firewallRuleEditor.ts | 5 + .../FirewallDetail/Rules/shared.styles.ts | 31 +++ .../Firewalls/FirewallDetail/Rules/shared.ts | 11 + packages/manager/src/mocks/serverHandlers.ts | 2 +- 11 files changed, 546 insertions(+), 69 deletions(-) create mode 100644 packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts 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)) 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..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,8 +38,12 @@ const props: FirewallRuleDrawerProps = { onSubmit: mockOnSubmit, }; +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + describe('AddRuleDrawer', () => { it('renders the title', () => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: false }); + const { getByText } = renderWithTheme( ); @@ -66,6 +71,79 @@ describe('AddRuleDrawer', () => { }); }); +describe('AddRuleSetDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); + }); + + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Add an Inbound Rule or Rule Set')).toBeVisible(); + }); + + it('renders the selection cards', () => { + const { getByText } = renderWithTheme( + + ); + + 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( + + ); + + 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 form is submitted without selecting a value', async () => { + const { getByText, getByRole } = renderWithTheme( + + ); + + // 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', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index fce7f71f829..cdf612bef2c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,8 +1,15 @@ -import { Drawer, 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 { formValueToIPs, getInitialFormValues, @@ -13,10 +20,13 @@ import { validateIPs, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; +import { FirewallRuleSetForm } from './FirewallRuleSetForm'; +import { firewallRuleCreateOptions } from './shared'; -import type { FirewallOptionItem } from '../../shared'; import type { + FirewallCreateEntityType, FirewallRuleDrawerProps, + FormRuleSetState, FormState, } from './FirewallRuleDrawer.types'; import type { @@ -32,6 +42,17 @@ 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. + * Only relevant when `mode === 'create'`. + */ + const [createEntityType, setCreateEntityType] = + React.useState('rule'); + // 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 +66,9 @@ 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(() => { + // 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]); @@ -56,20 +77,30 @@ 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'; + mode === 'create' + ? `Add an ${capitalize(category)} Rule${ + isFirewallRulesetsPrefixlistsEnabled ? ' or Rule Set' : '' + }` + : 'Edit Rule'; const addressesLabel = category === 'inbound' ? 'source' : 'destination'; - const onValidate = ({ - addresses, - description, - label, - ports, - protocol, - }: FormState) => { + 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. const validatedIPs = validateIPs(ips, { allowEmptyAddress: addresses !== 'ip/netmask', @@ -93,7 +124,7 @@ export const FirewallRuleDrawer = React.memo( }; }; - const onSubmit = (values: FormState) => { + const onSubmitRule = (values: FormState) => { const ports = itemsToPortString(presetPorts, values.ports!); const protocol = values.protocol as FirewallRuleProtocol; const addresses = formValueToIPs(values.addresses!, ips); @@ -103,41 +134,125 @@ export const FirewallRuleDrawer = React.memo( addresses, ports, protocol, + label: values.label || null, + description: values.description || null, }; - - payload.label = values.label === '' ? null : values.label; - payload.description = - values.description === '' ? null : values.description; - 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) => { - 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 }, + })} /> - ); - }} - + ))} + + )} + + {(mode === 'edit' || createEntityType === 'rule') && ( + + initialValues={getInitialFormValues(ruleToModify)} + onSubmit={onSubmitRule} + validate={onValidateRule} + validateOnBlur={false} + validateOnChange={false} + > + {(formikProps) => ( + <> + {formikProps.status && ( + + )} + + + )} + + )} + + {mode === 'create' && + createEntityType === 'ruleset' && + isFirewallRulesetsPrefixlistsEnabled && ( + + initialValues={{ ruleset: -1 }} + onSubmit={(values) => { + props.onSubmit(category, values); + onClose(); + }} + validate={onValidateRuleSet} + validateOnBlur={true} + validateOnChange={true} + > + {(formikProps) => ( + <> + {formikProps.status && ( + + )} + + + )} + + )} 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 d344e3146ab..f08c91d190d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -29,9 +29,16 @@ export interface FormState { type: string; } +export interface FormRuleSetState { + ruleset: number; +} + +export type FirewallCreateEntityType = 'rule' | 'ruleset'; + export interface FirewallRuleFormProps extends FormikProps { addressesLabel: string; category: Category; + closeDrawer: () => void; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; presetPorts: FirewallOptionItem[]; @@ -39,3 +46,10 @@ export interface FirewallRuleFormProps extends FormikProps { setIPs: (ips: ExtendedIP[]) => void; setPresetPorts: (selected: FirewallOptionItem[]) => void; } + +export interface FirewallRuleSetFormProps + extends FormikProps { + category: Category; + closeDrawer: () => void; + ruleErrors?: FirewallRuleError[]; +} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 26bc0c6db29..c94082dd44b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -2,7 +2,6 @@ import { ActionsPanel, Autocomplete, FormControlLabel, - Notice, Radio, RadioGroup, Select, @@ -39,6 +38,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const { addressesLabel, category, + closeDrawer, errors, handleBlur, handleChange, @@ -51,7 +51,6 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { setFieldValue, setIPs, setPresetPorts, - status, touched, values, } = props; @@ -202,14 +201,6 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { return (
    - {status && ( - - )} { label: mode === 'create' ? 'Add Rule' : 'Add Changes', onClick: () => handleSubmit(), }} + secondaryButtonProps={{ + label: 'Cancel', + onClick: closeDrawer, + }} /> ); }); const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ - marginTop: theme.spacing(2), + marginTop: theme.spacingFunction(16), })); const StyledMultipleIPInput = styled(MultipleIPInput, { label: 'StyledMultipleIPInput', })(({ theme }) => ({ - marginTop: theme.spacing(2), + marginTop: theme.spacingFunction(16), })); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx new file mode 100644 index 00000000000..0b1283ba3a0 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -0,0 +1,237 @@ +import { useAllFirewallRuleSetsQuery } from '@linode/queries'; +import { + ActionsPanel, + Autocomplete, + Box, + Chip, + Paper, + SelectedIcon, + Stack, + Typography, +} from '@linode/ui'; +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, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { StyledLabel, StyledListItem, useStyles } from './shared.styles'; + +import type { FirewallRuleSetFormProps } from './FirewallRuleDrawer.types'; + +export const FirewallRuleSetForm = React.memo( + (props: FirewallRuleSetFormProps) => { + const { + category, + errors, + handleSubmit, + setFieldTouched, + setFieldValue, + touched, + closeDrawer, + values, + } = 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 === values.ruleset) ?? null, + [ruleSets, values.ruleset] + ); + + // Build dropdown options + const ruleSetDropdownOptions = React.useMemo( + () => + ruleSets + .filter((ruleSet) => ruleSet.type === category) // Display only rule sets applicable to the given category + .map((ruleSet) => ({ + label: ruleSet.label, + value: ruleSet.id, + })), + [ruleSets] + ); + + const errorText = + error?.[0].reason ?? (touched.ruleset ? errors.ruleset : undefined); + + 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={errorText} + label="Rule Set" + loading={isLoading} + onBlur={() => setFieldTouched('ruleset')} + onChange={(_, selectedRuleSet) => { + setFieldValue('ruleset', selectedRuleSet?.value); + }} + options={ruleSetDropdownOptions} + placeholder="Type to search or select a Rule Set" + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + + + ({ + // 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 && } + +
  • + ); + }} + value={ + ruleSetDropdownOptions.find((o) => o.value === values.ruleset) ?? + null + } + /> + + {selectedRuleSet && ( + + {[ + { 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 && ( + + )} + + ))} + + ({ + 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.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};  + {generateAddressesLabel(rule.addresses)} + + + ))} + + + )} +
    + + + + ); + } +); 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/firewallRuleEditor.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/firewallRuleEditor.ts index 2e07ec070e1..7614b2e8371 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 && rule.ruleset !== undefined) { + return { ...rule }; + } + const keepIPv4 = rule.addresses?.ipv4 && rule.addresses.ipv4.length > 0; const keepIPv6 = rule.addresses?.ipv6 && rule.addresses.ipv6.length > 0; 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..deace8a6d08 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.styles.ts @@ -0,0 +1,31 @@ +import { Box, Typography } from '@linode/ui'; +import { styled } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +export 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), +})); + +export const useStyles = makeStyles()((theme) => ({ + copyIcon: { + '& svg': { + height: '1em', + width: '1em', + }, + color: theme.palette.primary.main, + display: 'inline-block', + position: 'relative', + marginTop: theme.spacingFunction(4), + }, +})); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 45b3a0a0115..46d9b8aba2a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -115,3 +115,14 @@ export const sortString = (_a: string, _b: string) => { const stripHyphen = (str: string) => { return str.match(/-/) ? str.split('-')[0] : str; }; + +export const firewallRuleCreateOptions = [ + { + label: 'Create a Rule', + value: 'rule', + }, + { + label: 'Reference Rule Set', + value: 'ruleset', + }, +] as const; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index ee7e446336b..dcf176ec753 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1325,7 +1325,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 6b0b08323ff8aab3aab4cf5cb566aa1f85cb406d Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:42:08 -0500 Subject: [PATCH 28/91] fix: [VMP-2536] - Maintenance date from local to utc (#13059) * fix: maintenance local to utc * More tests * Adjust the when field logic * Added changeset: Fix incorrect maintenance time display in the Upcoming maintenance table --------- Co-authored-by: Jaalah Ramos --- .../pr-13059-fixed-1762972220664.md | 5 + .../Maintenance/MaintenanceTableRow.tsx | 25 ++-- .../Account/Maintenance/utilities.test.ts | 108 ++++++++++++------ .../features/Account/Maintenance/utilities.ts | 77 +++++++------ 4 files changed, 128 insertions(+), 87 deletions(-) create mode 100644 packages/manager/.changeset/pr-13059-fixed-1762972220664.md diff --git a/packages/manager/.changeset/pr-13059-fixed-1762972220664.md b/packages/manager/.changeset/pr-13059-fixed-1762972220664.md new file mode 100644 index 00000000000..8279d18f00f --- /dev/null +++ b/packages/manager/.changeset/pr-13059-fixed-1762972220664.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Fix incorrect maintenance time display in the Upcoming maintenance table ([#13059](https://github.com/linode/manager/pull/13059)) diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index d4fc3505a55..9645722c188 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -1,7 +1,4 @@ -import { - useAccountMaintenancePoliciesQuery, - useProfile, -} from '@linode/queries'; +import { useProfile } from '@linode/queries'; import { Stack, Tooltip } from '@linode/ui'; import { Hidden } from '@linode/ui'; import { capitalize, getFormattedStatus, truncate } from '@linode/utilities'; @@ -84,22 +81,18 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; - // Fetch policies to derive a start time when the API doesn't provide one - const { data: policies } = useAccountMaintenancePoliciesQuery(); - // Precompute for potential use; currently used via getUpcomingRelativeLabel React.useMemo( - () => deriveMaintenanceStartISO(props.maintenance, policies), - [policies, props.maintenance] + () => deriveMaintenanceStartISO(props.maintenance), + [props.maintenance] ); - const upcomingRelativeLabel = React.useMemo( - () => - tableType === 'upcoming' - ? getUpcomingRelativeLabel(props.maintenance, policies) - : undefined, - [policies, props.maintenance, tableType] - ); + const upcomingRelativeLabel = React.useMemo(() => { + if (tableType !== 'upcoming') { + return undefined; + } + return getUpcomingRelativeLabel(props.maintenance); + }, [props.maintenance, tableType]); return ( diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts index b2c19304751..a79abd20356 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.test.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -5,30 +5,12 @@ import { getUpcomingRelativeLabel, } from './utilities'; -import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; +import type { AccountMaintenance } from '@linode/api-v4'; // Freeze time to a stable reference so relative labels are deterministic const NOW_ISO = '2025-10-27T12:00:00.000Z'; describe('Account Maintenance utilities', () => { - const policies: MaintenancePolicy[] = [ - { - description: 'Migrate', - is_default: true, - label: 'Migrate', - notification_period_sec: 3 * 60 * 60, // 3 hours - slug: 'linode/migrate', - type: 'linode_migrate', - }, - { - description: 'Power Off / On', - is_default: false, - label: 'Power Off / Power On', - notification_period_sec: 72 * 60 * 60, // 72 hours - slug: 'linode/power_off_on', - type: 'linode_power_off_on', - }, - ]; const baseMaintenance: Omit & { when: string } = { complete_time: null, @@ -60,33 +42,33 @@ describe('Account Maintenance utilities', () => { ...baseMaintenance, start_time: '2025-10-27T12:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBe( + expect(deriveMaintenanceStartISO(m)).toBe( '2025-10-27T12:00:00.000Z' ); }); - it('derives start_time from when + policy seconds when missing', () => { + it('uses when directly as start time (when already accounts for notification period)', () => { const m: AccountMaintenance = { ...baseMaintenance, start_time: null, - when: '2025-10-27T09:00:00.000Z', // +3h -> 12:00Z + when: '2025-10-27T09:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBe( - '2025-10-27T12:00:00.000Z' + // `when` already accounts for notification_period_sec, so it IS the start time + expect(deriveMaintenanceStartISO(m)).toBe( + '2025-10-27T09:00:00.000Z' ); }); - it('returns undefined when policy cannot be found', () => { + it('uses when directly for all statuses without needing policies', () => { const m: AccountMaintenance = { ...baseMaintenance, start_time: null, - // Use an intentionally unknown slug to exercise the no-policy fallback path. - // Even though the API default is typically 'linode/migrate', the client may - // not have policies loaded yet or could encounter a fetch error; this ensures - // we verify the graceful fallback behavior. + status: 'pending', + // Policies not needed - when IS the start time maintenance_policy_set: 'unknown/policy' as any, + when: '2025-10-27T09:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m, policies)).toBeUndefined(); + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T09:00:00.000Z'); }); }); @@ -100,7 +82,7 @@ describe('Account Maintenance utilities', () => { when: '2025-10-27T10:00:00.000Z', }; // NOW=12:00Z, when=10:00Z => "2 hours ago" - expect(getUpcomingRelativeLabel(m, policies)).toContain('hour'); + expect(getUpcomingRelativeLabel(m)).toContain('hour'); }); it('uses derived start to express time until maintenance (hours when <1 day)', () => { @@ -110,16 +92,15 @@ describe('Account Maintenance utilities', () => { when: '2025-10-27T09:00:00.000Z', }; // Allow any non-empty string; exact phrasing depends on Luxon locale - expect(getUpcomingRelativeLabel(m, policies)).toBeTypeOf('string'); + expect(getUpcomingRelativeLabel(m)).toBeTypeOf('string'); }); it('shows days+hours when >= 1 day away (avoids day-only rounding)', () => { const m: AccountMaintenance = { ...baseMaintenance, - maintenance_policy_set: 'linode/power_off_on', // 72h - when: '2025-10-25T20:00:00.000Z', // +72h => 2025-10-28T20:00Z; from NOW (27 12:00Z) => 1 day 8 hours + when: '2025-10-28T20:00:00.000Z', // from NOW (27 12:00Z) => 1 day 8 hours }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 1 day 8 hours'); }); @@ -129,7 +110,7 @@ describe('Account Maintenance utilities', () => { ...baseMaintenance, start_time: '2025-10-30T04:00:00.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 2 days 16 hours'); }); @@ -139,7 +120,7 @@ describe('Account Maintenance utilities', () => { // NOW is 12:00Z; start in 37 minutes start_time: '2025-10-27T12:37:00.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 37 minutes'); }); @@ -149,8 +130,59 @@ describe('Account Maintenance utilities', () => { // NOW is 12:00Z; start in 30 seconds start_time: '2025-10-27T12:00:30.000Z', }; - const label = getUpcomingRelativeLabel(m, policies); + const label = getUpcomingRelativeLabel(m); expect(label).toBe('in 30 seconds'); }); + + it('uses when directly as start time (when already accounts for notification period)', () => { + // Real-world scenario: API returns when=2025-11-06T16:12:41 + // `when` already accounts for notification_period_sec, so it IS the start time + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', // No timezone indicator, should be parsed as UTC + }; + + const derivedStart = deriveMaintenanceStartISO(m); + // `when` equals start time (no addition needed) + expect(derivedStart).toBe('2025-11-06T16:12:41.000Z'); + }); + + it('shows correct relative time (when equals start)', () => { + // Scenario: when=2025-11-06T16:12:41 (when IS the start time) + // If now is 2025-11-06T16:14:41 (2 minutes after when), should show "2 minutes ago" + // Save original Date.now + const originalDateNow = Date.now; + + // Mock "now" to be 2 minutes after when (which is the start time) + const mockNow = '2025-11-06T16:14:41.000Z'; + Date.now = vi.fn(() => new Date(mockNow).getTime()); + + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', + }; + + const label = getUpcomingRelativeLabel(m); + // when=start=16:12:41, now=16:14:41, difference is 2 minutes in the past + expect(label).toContain('minute'); // Should show "2 minutes ago" or similar + + // Restore original Date.now + Date.now = originalDateNow; + }); + + it('handles date without timezone indicator correctly (parsed as UTC)', () => { + // Verify that dates without timezone are parsed as UTC + const m: AccountMaintenance = { + ...baseMaintenance, + start_time: null, + when: '2025-11-06T16:12:41', // No Z suffix or timezone + }; + + const derivedStart = deriveMaintenanceStartISO(m); + // `when` equals start time (no addition needed) + expect(derivedStart).toBe('2025-11-06T16:12:41.000Z'); + }); }); }); diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index 9d751506a38..bb1c7cbe883 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { parseAPIDate } from 'src/utilities/date'; import type { MaintenanceTableType } from './MaintenanceTable'; -import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4'; +import type { AccountMaintenance } from '@linode/api-v4'; export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({ status: { '+or': ['completed', 'canceled'] }, @@ -56,37 +56,36 @@ export const getMaintenanceDateLabel = (type: MaintenanceTableType): string => { }; /** - * Derive the maintenance start when API `start_time` is absent by adding the - * policy notification window to the `when` (notice publish time). + * Derive the maintenance start timestamp. + * + * The `when` and `start_time` fields are equivalent timestamps representing + * when the maintenance will happen (or has happened). Prefer `start_time` if + * available, otherwise use `when`. */ export const deriveMaintenanceStartISO = ( - maintenance: AccountMaintenance, - policies?: MaintenancePolicy[] + maintenance: AccountMaintenance ): string | undefined => { if (maintenance.start_time) { return maintenance.start_time; } - const notificationSecs = policies?.find( - (p) => p.slug === maintenance.maintenance_policy_set - )?.notification_period_sec; - if (maintenance.when && notificationSecs) { - try { - return parseAPIDate(maintenance.when) - .plus({ seconds: notificationSecs }) - .toISO(); - } catch { - return undefined; - } + + if (!maintenance.when) { + return undefined; + } + + // `when` is a timestamp equivalent to `start_time` + try { + return parseAPIDate(maintenance.when).toISO(); + } catch { + return undefined; } - return undefined; }; /** * Build a user-friendly relative label for the Upcoming table. * * Behavior: - * - Prefers the actual or policy-derived start time to express time until maintenance - * - Falls back to the notice relative time when the start cannot be determined + * - Uses `start_time` if available, otherwise uses `when` (both are equivalent timestamps) * - Avoids day-only rounding by showing days + hours when >= 1 day * * Formatting rules: @@ -96,28 +95,29 @@ export const deriveMaintenanceStartISO = ( * - "in N seconds" when < 1 minute */ export const getUpcomingRelativeLabel = ( - maintenance: AccountMaintenance, - policies?: MaintenancePolicy[] + maintenance: AccountMaintenance ): string => { - const startISO = deriveMaintenanceStartISO(maintenance, policies); + const startISO = deriveMaintenanceStartISO(maintenance); + + // Use the derived start timestamp (from start_time or when) + const targetDT = startISO + ? parseAPIDate(startISO) + : maintenance.when + ? parseAPIDate(maintenance.when) + : null; - // Fallback: when start cannot be determined, show the notice time relative to now - if (!startISO) { - return maintenance.when - ? (parseAPIDate(maintenance.when).toRelative() ?? '—') - : '—'; + if (!targetDT) { + return '—'; } - // Prefer the actual or policy-derived start time to express "time until maintenance" - const startDT = parseAPIDate(startISO); - const now = DateTime.local(); - if (startDT <= now) { - return startDT.toRelative() ?? '—'; + const now = DateTime.utc(); + if (targetDT <= now) { + return targetDT.toRelative() ?? '—'; } // Avoid day-only rounding near boundaries by including hours alongside days. // For times under an hour, show exact minutes remaining; under a minute, show seconds. - const diff = startDT + const diff = targetDT .diff(now, ['days', 'hours', 'minutes', 'seconds']) .toObject(); let days = Math.floor(diff.days ?? 0); @@ -135,6 +135,17 @@ export const getUpcomingRelativeLabel = ( hours = 0; } + // Round up hours when we have significant minutes (>= 30) for better accuracy + if (days >= 1 && minutes >= 30) { + hours += 1; + minutes = 0; + // Check if rounding caused hours to overflow + if (hours === 24) { + days += 1; + hours = 0; + } + } + if (days >= 1) { const dayPart = pluralize('day', 'days', days); const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : ''; From 3c9e18f178234ec281a7ee7af6d3f46a85d906b8 Mon Sep 17 00:00:00 2001 From: Ankita Date: Mon, 24 Nov 2025 10:05:45 +0530 Subject: [PATCH 29/91] upcoming: [DI-28270] - Remove firewall filtering and region filter dependency (#13111) * upcoming: [DI-28270] - Remove firewall filtering and region filter depdency * upcoming: [DI-28270] - Remove typo * upcoming: [DI-28270] - Add condition in error text * upcoming: [DI-28270] - Add changeset --- ...r-13111-upcoming-features-1763615562636.md | 5 ++ .../CloudPulse/Utils/FilterBuilder.test.ts | 24 ++---- .../CloudPulse/Utils/FilterBuilder.ts | 1 - .../features/CloudPulse/Utils/FilterConfig.ts | 13 +-- .../features/CloudPulse/Utils/utils.test.ts | 2 - .../shared/CloudPulseRegionSelect.test.tsx | 84 ++++--------------- .../shared/CloudPulseRegionSelect.tsx | 60 ++++--------- 7 files changed, 48 insertions(+), 141 deletions(-) create mode 100644 packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md diff --git a/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md b/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md new file mode 100644 index 00000000000..7fc2ab5ef65 --- /dev/null +++ b/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Remove filtering of firewalls and region filter dependency on firewall-select in Firewalls ([#13111](https://github.com/linode/manager/pull/13111)) diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 798a0e85fec..dab4641047f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -147,26 +147,20 @@ it('test getResourceSelectionProperties method for linode-firewall', () => { expect(resourceSelectionConfig).toBeDefined(); if (resourceSelectionConfig) { - const { - disabled, - handleResourcesSelection, - label, - savePreferences, - filterFn, - } = getResourcesProperties( - { - config: resourceSelectionConfig, - dashboard: { ...mockDashboard, id: 4 }, - isServiceAnalyticsIntegration: true, - }, - vi.fn() - ); + const { disabled, handleResourcesSelection, label, savePreferences } = + getResourcesProperties( + { + config: resourceSelectionConfig, + dashboard: { ...mockDashboard, id: 4 }, + isServiceAnalyticsIntegration: true, + }, + vi.fn() + ); const { name } = resourceSelectionConfig.configuration; expect(handleResourcesSelection).toBeDefined(); expect(savePreferences).toEqual(false); expect(disabled).toEqual(false); expect(label).toEqual(name); - expect(filterFn).toBeDefined(); } }); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index e692d4bb05d..b63fdd76ca7 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -143,7 +143,6 @@ export const getRegionProperties = ( dashboard ), xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), - selectedEntities: (dependentFilters?.[RESOURCE_ID] ?? []) as string[], }; }; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index bb52b6bdb2a..30f8922d5c2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -11,10 +11,10 @@ import { RESOURCE_ID, } from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import { filterFirewallResources, filterKubernetesClusters } from './utils'; +import { filterKubernetesClusters } from './utils'; import type { CloudPulseServiceTypeFilterMap } from './models'; -import type { Firewall, KubernetesCluster } from '@linode/api-v4'; +import type { KubernetesCluster } from '@linode/api-v4'; const TIME_DURATION = 'Time Range'; @@ -234,7 +234,6 @@ export const FIREWALL_CONFIG: Readonly = { { configuration: { filterKey: 'resource_id', - children: [PARENT_ENTITY_REGION], filterType: 'string', isFilterable: true, isMetricsFilter: true, @@ -244,14 +243,11 @@ export const FIREWALL_CONFIG: Readonly = { placeholder: 'Select Firewalls', priority: 1, associatedEntityType: 'linode', - filterFn: (resources: Firewall[]) => - filterFirewallResources(resources, 'linode'), }, name: 'Firewalls', }, { configuration: { - dependency: ['resource_id'], filterKey: PARENT_ENTITY_REGION, filterType: 'string', isFilterable: true, @@ -339,7 +335,7 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly - filterFirewallResources(resources, 'nodebalancer'), }, name: 'Firewall', }, { configuration: { - dependency: [RESOURCE_ID], children: [NODEBALANCER_ID], filterKey: PARENT_ENTITY_REGION, filterType: 'string', diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index 3c6d3be0e5f..e2258ec14bd 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -361,14 +361,12 @@ describe('getEnabledServiceTypes', () => { const resourcesFilterConfig = getResourcesFilterConfig(4); expect(resourcesFilterConfig).toBeDefined(); expect(resourcesFilterConfig?.associatedEntityType).toBe('linode'); - expect(resourcesFilterConfig?.filterFn).toBeDefined(); }); it('should return the resources filter configuration for the nodebalancer-firewall dashboard', () => { const resourcesFilterConfig = getResourcesFilterConfig(8); expect(resourcesFilterConfig).toBeDefined(); expect(resourcesFilterConfig?.associatedEntityType).toBe('nodebalancer'); - expect(resourcesFilterConfig?.filterFn).toBeDefined(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 14d53e1a7e8..1b2d232e68d 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -8,11 +8,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { - dashboardFactory, - databaseInstanceFactory, - firewallFactory, -} from 'src/factories'; +import { dashboardFactory, databaseInstanceFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { NO_REGION_MESSAGE } from '../Utils/constants'; @@ -24,7 +20,6 @@ import type { useRegionsQuery } from '@linode/queries'; const props: CloudPulseRegionSelectProps = { filterKey: 'region', - selectedEntities: [], handleRegionChange: vi.fn(), label: 'Region', selectedDashboard: undefined, @@ -327,26 +322,10 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'linode' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllLinodesQuery.mockReturnValue({ - data: [ - linodeFactory.build({ - id: 1, - region: 'ap-west', + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, @@ -362,7 +341,6 @@ describe('CloudPulseRegionSelect', () => { service_type: 'firewall', id: 4, })} - selectedEntities={['1']} /> ); await user.click(screen.getByRole('button', { name: 'Open' })); @@ -378,31 +356,16 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, isLoading: false, }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'linode' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllLinodesQuery.mockReturnValue({ - data: [ - linodeFactory.build({ - id: 1, - region: 'ap-west', - }), - ], - isError: false, - isLoading: false, - }); + renderWithTheme( { service_type: 'firewall', id: 4, })} - selectedEntities={['1']} /> ); expect(screen.getByDisplayValue('IN, Mumbai (ap-west)')).toBeVisible(); @@ -424,31 +386,16 @@ describe('CloudPulseRegionSelect', () => { id: 'ap-west', label: 'IN, Mumbai', capabilities: [capabilityServiceTypeMapping['firewall']], + monitors: { + metrics: [capabilityServiceTypeMapping['firewall']], + alerts: [], + }, }), ], isError: false, isLoading: false, }); - queryMocks.useResourcesQuery.mockReturnValue({ - data: [ - firewallFactory.build({ - id: 1, - entities: [{ id: 1, type: 'nodebalancer' }], - }), - ], - isError: false, - isLoading: false, - }); - queryMocks.useAllNodeBalancersQuery.mockReturnValue({ - data: [ - nodeBalancerFactory.build({ - id: 1, - region: 'ap-west', - }), - ], - isError: false, - isLoading: false, - }); + renderWithTheme( { service_type: 'firewall', id: 8, })} - selectedEntities={['1']} /> ); expect(screen.getByDisplayValue('IN, Mumbai (ap-west)')).toBeVisible(); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index c49ad4c0fd7..5b385bd2c2f 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -6,7 +6,6 @@ import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { useFirewallFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions'; import { filterRegionByServiceType } from '../Alerts/Utils/utils'; import { NO_REGION_MESSAGE, @@ -15,13 +14,9 @@ import { } from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { - getAssociatedEntityType, - getResourcesFilterConfig, -} from '../Utils/utils'; +import { getResourcesFilterConfig } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; -import type { Item } from '../Alerts/constants'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; @@ -39,7 +34,6 @@ export interface CloudPulseRegionSelectProps { placeholder?: string; savePreferences?: boolean; selectedDashboard: Dashboard | undefined; - selectedEntities: string[]; xFilter?: CloudPulseMetricsFilter; } @@ -53,7 +47,6 @@ export const CloudPulseRegionSelect = React.memo( placeholder, savePreferences, selectedDashboard, - selectedEntities, disabled = false, xFilter, } = props; @@ -69,7 +62,10 @@ export const CloudPulseRegionSelect = React.memo( isError: isResourcesError, isLoading: isResourcesLoading, } = useResourcesQuery( - !disabled && selectedDashboard !== undefined && Boolean(regions?.length), + filterKey !== PARENT_ENTITY_REGION && + !disabled && + selectedDashboard !== undefined && + Boolean(regions?.length), selectedDashboard?.service_type, {}, { @@ -93,50 +89,20 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setSelectedRegion] = React.useState(); - // Get the associated entity type for the dashboard - const associatedEntityType = getAssociatedEntityType(dashboardId); - const { - values: linodeRegions, - isLoading: isLinodeRegionIdLoading, - isError: isLinodeRegionIdError, - } = useFirewallFetchOptions({ - dimensionLabel: filterKey, - entities: selectedEntities, - regions, - serviceType, - associatedEntityType, - type: 'metrics', - }); - const linodeRegionIds = linodeRegions.map( - (option: Item) => option.value - ); - - const supportedLinodeRegions = React.useMemo(() => { - return ( - regions?.filter((region) => linodeRegionIds?.includes(region.id)) ?? [] - ); - }, [regions, linodeRegionIds]); - const supportedRegions = React.useMemo(() => { return filterRegionByServiceType('metrics', regions, serviceType); }, [regions, serviceType]); const supportedRegionsFromResources = React.useMemo(() => { if (filterKey === PARENT_ENTITY_REGION) { - return supportedLinodeRegions; + return supportedRegions; } return supportedRegions.filter(({ id }) => filterUsingDependentFilters(resources, xFilter)?.some( ({ region }) => region === id ) ); - }, [ - filterKey, - supportedLinodeRegions, - supportedRegions, - resources, - xFilter, - ]); + }, [supportedRegions, resources, xFilter, filterKey]); const dependencyKey = supportedRegionsFromResources .map((region) => region.id) @@ -192,9 +158,14 @@ export const CloudPulseRegionSelect = React.memo( currentCapability={capability} data-testid="region-select" disableClearable={false} - disabled={!selectedDashboard || !regions || disabled || !resources} + disabled={ + !selectedDashboard || + !regions || + disabled || + (!resources && filterKey !== PARENT_ENTITY_REGION) + } errorText={ - isError || isResourcesError || isLinodeRegionIdError + isError || (isResourcesError && filterKey !== PARENT_ENTITY_REGION) ? `Failed to fetch ${label || 'Regions'}.` : '' } @@ -203,7 +174,8 @@ export const CloudPulseRegionSelect = React.memo( label={label || 'Region'} loading={ !disabled && - (isLoading || isResourcesLoading || isLinodeRegionIdLoading) + (isLoading || + (isResourcesLoading && filterKey !== PARENT_ENTITY_REGION)) } noMarginTop noOptionsText={ From 765a0ba90f08d5d8d39d3d6076cf0bef495d8b60 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:21:37 +0530 Subject: [PATCH 30/91] Upcoming: [UIE-9540] - Implement filter for GPU plans in plans panel (#13115) * Upcoming: [UIE-9540] - Implement filter for GPU plans in plans panel * Added changeset: Implement filter for GPU plans in plans panel --- ...r-13115-upcoming-features-1763630712827.md | 5 + .../KubernetesPlanContainer.tsx | 69 +------- .../KubernetesPlansPanel.tsx | 16 +- .../PlansPanel/DedicatedPlanFilters.tsx | 39 ++--- .../components/PlansPanel/GpuFilters.tsx | 162 ++++++++++++++++++ .../components/PlansPanel/PlanContainer.tsx | 81 ++------- .../components/PlansPanel/PlansPanel.tsx | 16 +- .../components/PlansPanel/constants.ts | 7 +- .../PlansPanel/types/planFilters.ts | 9 + .../PlansPanel/utils/planFilters.test.ts | 58 +++++-- .../PlansPanel/utils/planFilters.ts | 53 ++++-- 11 files changed, 335 insertions(+), 180 deletions(-) create mode 100644 packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md create mode 100644 packages/manager/src/features/components/PlansPanel/GpuFilters.tsx diff --git a/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md b/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md new file mode 100644 index 00000000000..881f5d29a08 --- /dev/null +++ b/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Implement filter for GPU plans in plans panel ([#13115](https://github.com/linode/manager/pull/13115)) diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index e17b3c48702..9a798ea38ec 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -348,26 +348,6 @@ export const KubernetesPlanContainer = ( const shouldDisplayPagination = !shouldDisplayNoRegionSelectedMessage; - const dividerTables = planSelectionDividers - .map((divider) => ({ - planType: divider.planType, - tables: divider.tables - .map((table) => ({ - filterOptions: table, - plans: table.planFilter - ? paginatedPlans.filter(table.planFilter) - : paginatedPlans, - })) - .filter((table) => table.plans.length > 0), - })) - .filter((divider) => divider.tables.length > 0); - - const activeDivider = dividerTables.find( - (divider) => divider.planType === planType - ); - - const hasActiveGpuDivider = planType === 'gpu' && activeDivider; - return ( <> @@ -399,21 +379,6 @@ export const KubernetesPlanContainer = ( text={tableEmptyState.message} variant="info" /> - ) : hasActiveGpuDivider ? ( - activeDivider.tables.map(({ filterOptions, plans }, idx) => ( - - {filterOptions.header ? ( - - - {filterOptions.header} - - - ) : null} - {renderPlanSelection(plans)} - - )) ) : ( renderPlanSelection(paginatedPlans) )} @@ -425,31 +390,15 @@ export const KubernetesPlanContainer = ( xs: 12, }} > - {hasActiveGpuDivider ? ( - activeDivider.tables.map( - ({ filterOptions, plans }, idx) => ( - - ) - ) - ) : ( - - )} +
    diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 35fdc3e77f8..74afbe23aac 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -4,6 +4,7 @@ import type { JSX } from 'react'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { createDedicatedPlanFiltersRenderProp } from 'src/features/components/PlansPanel/DedicatedPlanFilters'; +import { createGPUPlanFilterRenderProp } from 'src/features/components/PlansPanel/GpuFilters'; import { PlanInformation } from 'src/features/components/PlansPanel/PlanInformation'; import { determineInitialPlanCategoryTab, @@ -153,11 +154,16 @@ export const KubernetesPlansPanel = (props: Props) => { hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} onAdd={onAdd} onSelect={onSelect} - planFilters={ - plan === 'dedicated' - ? createDedicatedPlanFiltersRenderProp() - : undefined - } + planFilters={(() => { + switch (plan) { + case 'dedicated': + return createDedicatedPlanFiltersRenderProp(); + case 'gpu': + return createGPUPlanFilterRenderProp(); + default: + return undefined; + } + })()} plans={plansForThisLinodeTypeClass} planType={plan} selectedId={selectedId} diff --git a/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx b/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx index a7bc0818739..6a6eb401d97 100644 --- a/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx +++ b/packages/manager/src/features/components/PlansPanel/DedicatedPlanFilters.tsx @@ -12,15 +12,17 @@ import { Select } from '@linode/ui'; import * as React from 'react'; import { - PLAN_FILTER_GENERATION_ALL, + PLAN_FILTER_ALL, PLAN_FILTER_GENERATION_G6, PLAN_FILTER_GENERATION_G7, PLAN_FILTER_GENERATION_G8, - PLAN_FILTER_TYPE_ALL, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, } from './constants'; -import { applyPlanFilters, supportsTypeFiltering } from './utils/planFilters'; +import { + applyDedicatedPlanFilters, + supportsTypeFiltering, +} from './utils/planFilters'; import type { PlanFilterRenderArgs, @@ -31,20 +33,20 @@ import type { PlanFilterGeneration, PlanFilterType } from './types/planFilters'; import type { SelectOption } from '@linode/ui'; const GENERATION_OPTIONS: SelectOption[] = [ - { label: 'All', value: PLAN_FILTER_GENERATION_ALL }, + { label: 'All', value: PLAN_FILTER_ALL }, { label: 'G8 Dedicated', value: PLAN_FILTER_GENERATION_G8 }, { label: 'G7 Dedicated', value: PLAN_FILTER_GENERATION_G7 }, { label: 'G6 Dedicated', value: PLAN_FILTER_GENERATION_G6 }, ]; const TYPE_OPTIONS_WITH_SUBTYPES: SelectOption[] = [ - { label: 'All', value: PLAN_FILTER_TYPE_ALL }, + { label: 'All', value: PLAN_FILTER_ALL }, { label: 'Compute Optimized', value: PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED }, { label: 'General Purpose', value: PLAN_FILTER_TYPE_GENERAL_PURPOSE }, ]; const TYPE_OPTIONS_ALL_ONLY: SelectOption[] = [ - { label: 'All', value: PLAN_FILTER_TYPE_ALL }, + { label: 'All', value: PLAN_FILTER_ALL }, ]; interface DedicatedPlanFiltersComponentProps { @@ -59,12 +61,10 @@ const DedicatedPlanFiltersComponent = React.memo( const { disabled = false, onResult, plans, resetPagination } = props; // Local state - persists automatically because component stays mounted - const [generation, setGeneration] = React.useState( - PLAN_FILTER_GENERATION_ALL - ); + const [generation, setGeneration] = + React.useState(PLAN_FILTER_ALL); - const [type, setType] = - React.useState(PLAN_FILTER_TYPE_ALL); + const [type, setType] = React.useState(PLAN_FILTER_ALL); const typeFilteringSupported = supportsTypeFiltering(generation); @@ -109,11 +109,11 @@ const DedicatedPlanFiltersComponent = React.memo( // When clearing, default to "All" instead of undefined const newGeneration = (option?.value as PlanFilterGeneration | undefined) ?? - PLAN_FILTER_GENERATION_ALL; + PLAN_FILTER_ALL; setGeneration(newGeneration); // Reset type filter when generation changes - setType(PLAN_FILTER_TYPE_ALL); + setType(PLAN_FILTER_ALL); }, [] ); @@ -124,17 +124,15 @@ const DedicatedPlanFiltersComponent = React.memo( option: null | SelectOption ) => { setType( - (option?.value as PlanFilterType | undefined) ?? PLAN_FILTER_TYPE_ALL + (option?.value as PlanFilterType | undefined) ?? PLAN_FILTER_ALL ); }, [] ); const filteredPlans = React.useMemo(() => { - const normalizedType = typeFilteringSupported - ? type - : PLAN_FILTER_TYPE_ALL; - return applyPlanFilters(plans, generation, normalizedType); + const normalizedType = typeFilteringSupported ? type : PLAN_FILTER_ALL; + return applyDedicatedPlanFilters(plans, generation, normalizedType); }, [generation, plans, type, typeFilteringSupported]); const selectedGenerationOption = React.useMemo(() => { @@ -142,7 +140,7 @@ const DedicatedPlanFiltersComponent = React.memo( }, [generation]); const selectedTypeOption = React.useMemo(() => { - const displayType = typeFilteringSupported ? type : PLAN_FILTER_TYPE_ALL; + const displayType = typeFilteringSupported ? type : PLAN_FILTER_ALL; return typeOptions.find((opt) => opt.value === displayType) ?? null; }, [type, typeFilteringSupported, typeOptions]); @@ -155,6 +153,7 @@ const DedicatedPlanFiltersComponent = React.memo( flexWrap: 'wrap', gap: '19px', marginBottom: 16, + marginTop: -16, }} > +
    + ); + + return { + filteredPlans, + filterUI, + hasActiveFilters: gpuType !== PLAN_FILTER_ALL, + }; + }, [ + GPU_OPTIONS_BASED_ON_AVAILABLE_PLANS, + filteredPlans, + gpuType, + handleGpuTypeChange, + selectedGpuType, + ]); + + // Notify parent component whenever filter result changes + // onResult is stable (created with useCallback in parent), so this is safe + React.useEffect(() => { + onResult(result); + }, [onResult, result]); + + return null; + } +); + +GPUPlanFilterComponent.displayName = 'GPUPlanFilterComponent'; + +export const createGPUPlanFilterRenderProp = () => { + return ({ + onResult, + plans, + resetPagination, + }: PlanFilterRenderArgs): React.ReactNode => ( + + ); +}; diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index fe283fcf920..e14ab6d10e9 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -443,75 +443,26 @@ export const PlanContainer = (props: PlanContainerProps) => { variant="info" /> ) : ( - planSelectionDividers.map((planSelectionDivider) => - planType === planSelectionDivider.planType - ? planSelectionDivider.tables.map((table) => { - const filteredPlans = table.planFilter - ? paginatedPlans.filter(table.planFilter) - : paginatedPlans; - const tableRows = renderPlanSelection(filteredPlans); - - return [ - filteredPlans.length > 0 && ( - - - {table.header} - - - ), - tableRows, - ]; - }) - : renderPlanSelection(paginatedPlans) - ) + renderPlanSelection(paginatedPlans) )} - {planSelectionDividers.map((planSelectionDivider) => - planType === planSelectionDivider.planType ? ( - planSelectionDivider.tables.map((table, idx) => { - const filteredPlans = table.planFilter - ? paginatedPlans.filter(table.planFilter) - : paginatedPlans; - if (filteredPlans.length === 0) { - return null; - } - - return ( - - ); - }) - ) : ( - - ) - )} + diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index fdce685c336..5a24b9c9b5a 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -17,6 +17,7 @@ import { useFlags } from 'src/hooks/useFlags'; import { createDedicatedPlanFiltersRenderProp } from './DedicatedPlanFilters'; import { DistributedRegionPlanTable } from './DistributedRegionPlanTable'; +import { createGPUPlanFilterRenderProp } from './GpuFilters'; import { PlanContainer } from './PlanContainer'; import { PlanInformation } from './PlanInformation'; import { @@ -224,11 +225,16 @@ export const PlansPanel = (props: PlansPanelProps) => { isCreate={isCreate} linodeID={linodeID} onSelect={onSelect} - planFilters={ - plan === 'dedicated' - ? createDedicatedPlanFiltersRenderProp() - : undefined - } + planFilters={(() => { + switch (plan) { + case 'dedicated': + return createDedicatedPlanFiltersRenderProp(); + case 'gpu': + return createGPUPlanFilterRenderProp(); + default: + return undefined; + } + })()} plans={plansForThisLinodeTypeClass} planType={plan} selectedId={selectedId} diff --git a/packages/manager/src/features/components/PlansPanel/constants.ts b/packages/manager/src/features/components/PlansPanel/constants.ts index f2eb165b686..b43ae5a3719 100644 --- a/packages/manager/src/features/components/PlansPanel/constants.ts +++ b/packages/manager/src/features/components/PlansPanel/constants.ts @@ -78,16 +78,19 @@ export const G8_DEDICATED_ALL_SLUGS = [ ...G8_DEDICATED_GENERAL_PURPOSE_SLUGS, ] as const; +export const PLAN_FILTER_ALL = 'all'; // Filter option values -export const PLAN_FILTER_GENERATION_ALL = 'all'; export const PLAN_FILTER_GENERATION_G8 = 'g8'; export const PLAN_FILTER_GENERATION_G7 = 'g7'; export const PLAN_FILTER_GENERATION_G6 = 'g6'; -export const PLAN_FILTER_TYPE_ALL = 'all'; export const PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED = 'compute-optimized'; export const PLAN_FILTER_TYPE_GENERAL_PURPOSE = 'general-purpose'; +export const PLAN_FILTER_GPU_RTX_PRO_6000 = 'gpu-rtxpro6000'; +export const PLAN_FILTER_GPU_RTX_6000 = 'gpu-rtx6000'; +export const PLAN_FILTER_GPU_RTX_4000_ADA = 'gpu-rtx4000'; + export const DEDICATED_512_GB_PLAN: ExtendedType = { accelerated_devices: 0, addons: { diff --git a/packages/manager/src/features/components/PlansPanel/types/planFilters.ts b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts index 06de06e9855..8e3f37b9eba 100644 --- a/packages/manager/src/features/components/PlansPanel/types/planFilters.ts +++ b/packages/manager/src/features/components/PlansPanel/types/planFilters.ts @@ -21,6 +21,15 @@ export type PlanFilterGeneration = 'all' | 'g6' | 'g7' | 'g8'; */ export type PlanFilterType = 'all' | 'compute-optimized' | 'general-purpose'; +/** + * Available plans for GPU + */ +export type PlanFilterGPU = + | 'all' + | 'gpu-rtx4000' + | 'gpu-rtx6000' + | 'gpu-rtxpro6000'; + // ============================================================================ // Filter State // ============================================================================ diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts index c6672f0fd64..1c4e99cf5c5 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.test.ts @@ -1,13 +1,14 @@ import { planSelectionTypeFactory } from 'src/factories/types'; import { - PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_ALL, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, } from '../constants'; import { - applyPlanFilters, + applyDedicatedPlanFilters, filterPlansByGeneration, + filterPlansByGpuType, filterPlansByType, getAvailableTypes, supportsTypeFiltering, @@ -48,6 +49,23 @@ describe('planFilters utilities', () => { label: 'Unlisted G8 Plan', }); + const rtx4000Plan = createPlan({ + id: 'g2-gpu-rtx4000a1-s', + label: 'RTX4000 Ada x1 Small', + }); + + const rtx6000Plan = createPlan({ + id: 'g8-gpu-rtx6000-1', + label: 'Dedicated 32GB + RTX6000 GPU x1', + }); + + const rtxPro6000Plan = createPlan({ + id: 'g3-gpu-rtxpro6000-blackwell-1', + label: 'RTX PRO 6000 Blackwell x1', + }); + + const gpuPlans = [rtx4000Plan, rtx6000Plan, rtxPro6000Plan]; + describe('filterPlansByGeneration', () => { it('returns only G8 plans that exist in the allow-list', () => { const result = filterPlansByGeneration( @@ -97,7 +115,7 @@ describe('planFilters utilities', () => { const result = filterPlansByType( [g8ComputePlan, g8GeneralPlan], 'g8', - PLAN_FILTER_TYPE_ALL + PLAN_FILTER_ALL ); expect(result).toEqual([g8ComputePlan, g8GeneralPlan]); @@ -146,19 +164,19 @@ describe('planFilters utilities', () => { }); }); - describe('applyPlanFilters', () => { + describe('applyDedicatedPlanFilters', () => { it('returns an empty array when no generation is selected', () => { - const result = applyPlanFilters( + const result = applyDedicatedPlanFilters( [g8ComputePlan, g8GeneralPlan], undefined, - PLAN_FILTER_TYPE_ALL + PLAN_FILTER_ALL ); expect(result).toEqual([]); }); it('applies both generation and type filters', () => { - const result = applyPlanFilters( + const result = applyDedicatedPlanFilters( [g8ComputePlan, g8GeneralPlan, g7DedicatedPlan, g7PremiumPlan, g6Plan], 'g8', PLAN_FILTER_TYPE_GENERAL_PURPOSE @@ -168,10 +186,10 @@ describe('planFilters utilities', () => { }); it('returns all dedicated plans when generation is "all"', () => { - const result = applyPlanFilters( + const result = applyDedicatedPlanFilters( [g8ComputePlan, g8GeneralPlan, g7DedicatedPlan, g7PremiumPlan, g6Plan], 'all', - PLAN_FILTER_TYPE_ALL + PLAN_FILTER_ALL ); expect(result).toEqual([ @@ -196,16 +214,30 @@ describe('planFilters utilities', () => { describe('getAvailableTypes', () => { it('returns all type options for G8', () => { expect(getAvailableTypes('g8')).toEqual([ - PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_ALL, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, ]); }); it('returns only the "all" option for G7, G6, and "all" generation', () => { - expect(getAvailableTypes('g7')).toEqual([PLAN_FILTER_TYPE_ALL]); - expect(getAvailableTypes('g6')).toEqual([PLAN_FILTER_TYPE_ALL]); - expect(getAvailableTypes('all')).toEqual([PLAN_FILTER_TYPE_ALL]); + expect(getAvailableTypes('g7')).toEqual([PLAN_FILTER_ALL]); + expect(getAvailableTypes('g6')).toEqual([PLAN_FILTER_ALL]); + expect(getAvailableTypes('all')).toEqual([PLAN_FILTER_ALL]); + }); + }); + + describe('filterPlansByGpuType', () => { + it('returns only RTX4000 plans that exist in the allow-list', () => { + const result = filterPlansByGpuType(gpuPlans, 'gpu-rtx4000'); + + expect(result).toEqual([rtx4000Plan]); + }); + + it('returns all GPU plans when type is "all"', () => { + const result = filterPlansByGpuType(gpuPlans, 'all'); + + expect(result).toEqual(gpuPlans); }); }); }); diff --git a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts index c62bcf6c7fb..9dd66d55a8d 100644 --- a/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts +++ b/packages/manager/src/features/components/PlansPanel/utils/planFilters.ts @@ -9,7 +9,7 @@ import { G8_DEDICATED_ALL_SLUGS, G8_DEDICATED_COMPUTE_OPTIMIZED_SLUGS, G8_DEDICATED_GENERAL_PURPOSE_SLUGS, - PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_ALL, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, } from '../constants'; @@ -19,6 +19,7 @@ import type { PlanFilterType, PlanWithAvailability, } from '../types'; +import type { PlanFilterGPU } from '../types/planFilters'; // ============================================================================ // Generation Filtering @@ -88,7 +89,7 @@ export const filterPlansByType = ( type: PlanFilterType ): PlanWithAvailability[] => { // "All" returns all plans unchanged - if (type === PLAN_FILTER_TYPE_ALL) { + if (type === PLAN_FILTER_ALL) { return plans; } @@ -133,22 +134,22 @@ export const filterPlansByType = ( * @example * ```ts * // Get G8 Compute Optimized plans - * const filtered = applyPlanFilters(allPlans, 'g8', 'compute-optimized'); + * const filtered = applyDedicatedPlanFilters(allPlans, 'g8', 'compute-optimized'); * * // Get all G7 plans - * const g7All = applyPlanFilters(allPlans, 'g7', 'all'); + * const g7All = applyDedicatedPlanFilters(allPlans, 'g7', 'all'); * * // Get all dedicated plans (G6, G7, G8) - * const allDedicated = applyPlanFilters(allPlans, 'all', 'all'); + * const allDedicated = applyDedicatedPlanFilters(allPlans, 'all', 'all'); * * // No filters - return empty array - * const none = applyPlanFilters(allPlans); + * const none = applyDedicatedPlanFilters(allPlans); * ``` */ -export const applyPlanFilters = ( +export const applyDedicatedPlanFilters = ( plans: PlanWithAvailability[], generation?: PlanFilterGeneration, - type: PlanFilterType = PLAN_FILTER_TYPE_ALL + type: PlanFilterType = PLAN_FILTER_ALL ): PlanWithAvailability[] => { // No filters - return empty array if (!generation) { @@ -162,6 +163,38 @@ export const applyPlanFilters = ( return filterPlansByType(generationFiltered, generation, type); }; +// ============================================================================ +// GPU Filtering +// ============================================================================ + +/** + * Filter plans by gpu type + * + * @param plans - Array of all plans (mostly pre-filtered by plan type/class) + * @param gpuType - The GPU type to filter by + * @returns Filtered array of plans matching the generation + * + * @example + * ```ts + * const rtx4000Plans = filterPlansByGpuType(allPlans, 'gpu-rtx4000'); + * // Returns all plans with GPU type 'gpu-rtx4000' + * + * const allDedicatedPlans = filterPlansByGpuType(allPlans, 'all'); + * // Returns all plans as-is (already filtered by plan type in parent) + * ``` + */ +export const filterPlansByGpuType = ( + plans: PlanWithAvailability[], + gpuType?: PlanFilterGPU +): PlanWithAvailability[] => { + // For "All", return all plans as-is + // The plans array is already filtered to only GPU plans by the parent component + if (!gpuType || gpuType === PLAN_FILTER_ALL) { + return plans; + } + return plans.filter((plan) => plan.id.includes(gpuType)); +}; + // ============================================================================ // Helper Functions // ============================================================================ @@ -196,12 +229,12 @@ export const getAvailableTypes = ( ): PlanFilterType[] => { if (generation === 'g8') { return [ - PLAN_FILTER_TYPE_ALL, + PLAN_FILTER_ALL, PLAN_FILTER_TYPE_COMPUTE_OPTIMIZED, PLAN_FILTER_TYPE_GENERAL_PURPOSE, ]; } // G7, G6, and "All" only have "All" type option - return [PLAN_FILTER_TYPE_ALL]; + return [PLAN_FILTER_ALL]; }; From eaff2e060c94a0c0a65c979d7dd0fb7eb3a4ca2f Mon Sep 17 00:00:00 2001 From: tvijay-akamai <51293194+tvijay-akamai@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:21:34 +0530 Subject: [PATCH 31/91] Fixed Pagination issue in kubernetes plans table (#13126) * fixed: [UIE-9656] fixed pagination issue in kubernetes --- .../src/features/Account/Maintenance/utilities.test.ts | 9 ++------- .../KubernetesPlansPanel/KubernetesPlanContainer.tsx | 1 + 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/manager/src/features/Account/Maintenance/utilities.test.ts b/packages/manager/src/features/Account/Maintenance/utilities.test.ts index a79abd20356..c6629ae3205 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.test.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.test.ts @@ -11,7 +11,6 @@ import type { AccountMaintenance } from '@linode/api-v4'; const NOW_ISO = '2025-10-27T12:00:00.000Z'; describe('Account Maintenance utilities', () => { - const baseMaintenance: Omit & { when: string } = { complete_time: null, description: 'scheduled', @@ -42,9 +41,7 @@ describe('Account Maintenance utilities', () => { ...baseMaintenance, start_time: '2025-10-27T12:00:00.000Z', }; - expect(deriveMaintenanceStartISO(m)).toBe( - '2025-10-27T12:00:00.000Z' - ); + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T12:00:00.000Z'); }); it('uses when directly as start time (when already accounts for notification period)', () => { @@ -54,9 +51,7 @@ describe('Account Maintenance utilities', () => { when: '2025-10-27T09:00:00.000Z', }; // `when` already accounts for notification_period_sec, so it IS the start time - expect(deriveMaintenanceStartISO(m)).toBe( - '2025-10-27T09:00:00.000Z' - ); + expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T09:00:00.000Z'); }); it('uses when directly for all statuses without needing policies', () => { diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx index 9a798ea38ec..dd627103896 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlanContainer.tsx @@ -408,6 +408,7 @@ export const KubernetesPlanContainer = ( customOptions={PLAN_PANEL_PAGE_SIZE_OPTIONS} handlePageChange={handlePageChange} handleSizeChange={handlePageSizeChange} + minPageSize={PLAN_PANEL_PAGE_SIZE_OPTIONS[0].value} page={page} pageSize={pageSize} sx={{ From 1e1f3b9fabe5aac90b537760560214e7460e9d57 Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Mon, 24 Nov 2025 13:08:40 +0100 Subject: [PATCH 32/91] upcoming: [DPS-35648] Destination Form - Sample path improvements (#13117) * upcoming: [DPS-35648] Destination Form - Sample path improvements --- .../pr-13117-upcoming-features-1763642787362.md | 5 +++++ .../DestinationForm/DestinationCreate.test.tsx | 15 ++++++++------- .../src/features/Delivery/Shared/PathSample.tsx | 17 +++++++++-------- 3 files changed, 22 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md diff --git a/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md b/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md new file mode 100644 index 00000000000..9467c4a06c1 --- /dev/null +++ b/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Destination Form: fixes and improvements for Sample Destination Object Name ([#13117](https://github.com/linode/manager/pull/13117)) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx index 37074f7da94..1d625731fa8 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect } from 'vitest'; +import { accountFactory } from 'src/factories'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; @@ -66,11 +67,11 @@ describe('DestinationCreate', () => { ); it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => { - const profileUid = 123; + const accountEuuid = 'XYZ-123'; const [month, day, year] = new Date().toLocaleDateString().split('/'); server.use( - http.get('*/profile', () => { - return HttpResponse.json(profileFactory.build({ uid: profileUid })); + http.get('*/account', () => { + return HttpResponse.json(accountFactory.build({ euuid: accountEuuid })); }) ); @@ -79,7 +80,7 @@ describe('DestinationCreate', () => { let samplePath; await waitFor(() => { samplePath = screen.getByText( - `/audit_logs/com.akamai.audit.login/${profileUid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597.gz` + `/audit_logs/com.akamai.audit/${accountEuuid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597-login.gz` ); expect(samplePath).toBeInTheDocument(); }); @@ -89,19 +90,19 @@ describe('DestinationCreate', () => { await userEvent.type(logPathPrefixInput, 'test'); // sample path should be created based on *log path* value expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597.gz' + '/test/akamai_log-000166-1756015362-319597-login.gz' ); await userEvent.clear(logPathPrefixInput); await userEvent.type(logPathPrefixInput, '/test'); expect(samplePath!.textContent).toEqual( - '/test/akamai_log-000166-1756015362-319597.gz' + '/test/akamai_log-000166-1756015362-319597-login.gz' ); await userEvent.clear(logPathPrefixInput); await userEvent.type(logPathPrefixInput, '/'); expect(samplePath!.textContent).toEqual( - '/akamai_log-000166-1756015362-319597.gz' + '/akamai_log-000166-1756015362-319597-login.gz' ); }); diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx index 3b594da1baa..85ee27521f4 100644 --- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx +++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx @@ -1,5 +1,5 @@ import { streamType, type StreamType } from '@linode/api-v4'; -import { useProfile } from '@linode/queries'; +import { useAccount } from '@linode/queries'; import { Box, InputLabel, Stack, TooltipIcon, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -15,8 +15,8 @@ const sxTooltipIcon = { }; const logType = { - [streamType.LKEAuditLogs]: 'com.akamai.audit.k8s', - [streamType.AuditLogs]: 'com.akamai.audit.login', + [streamType.LKEAuditLogs]: 'k8s', + [streamType.AuditLogs]: 'login', }; interface PathSampleProps { @@ -25,10 +25,9 @@ interface PathSampleProps { export const PathSample = (props: PathSampleProps) => { const { value } = props; - const fileName = 'akamai_log-000166-1756015362-319597.gz'; const sampleClusterId = useMemo( // eslint-disable-next-line sonarjs/pseudo-random - () => Math.floor(Math.random() * 90000) + 10000, + () => `lke${Math.floor(Math.random() * 90000) + 10000}`, [] ); @@ -43,7 +42,7 @@ export const PathSample = (props: PathSampleProps) => { name: 'stream.details.cluster_ids[0]', }); - const { data: profile } = useProfile(); + const { data: account } = useAccount(); const [month, day, year] = new Date().toLocaleDateString('en-US').split('/'); const setStreamType = (): StreamType => { @@ -51,6 +50,7 @@ export const PathSample = (props: PathSampleProps) => { }; const streamTypeValue = useMemo(setStreamType, [streamTypeFormValue]); + const fileName = `akamai_log-000166-1756015362-319597-${logType[streamTypeValue]}.gz`; const createSamplePath = (): string => { let partition = ''; @@ -59,11 +59,11 @@ export const PathSample = (props: PathSampleProps) => { partition = `${clusterId ?? sampleClusterId}/`; } - return `/${streamTypeValue}/${logType[streamTypeValue]}/${profile?.uid}/${partition}${year}/${month}/${day}`; + return `/${streamTypeValue}/com.akamai.audit/${account?.euuid}/${partition}${year}/${month}/${day}`; }; const defaultPath = useMemo(createSamplePath, [ - profile, + account, streamTypeValue, clusterId, ]); @@ -110,4 +110,5 @@ const StyledValue = styled('span', { label: 'StyledValue' })(({ theme }) => ({ minHeight: 34, padding: theme.spacingFunction(8), overflowWrap: 'anywhere', + wordBreak: 'break-all', })); From aac0a422c06967b3a2c29534dca520eed80b5c7e Mon Sep 17 00:00:00 2001 From: mduda-akamai Date: Mon, 24 Nov 2025 13:09:04 +0100 Subject: [PATCH 33/91] upcoming: [DPS-35317] Compare the saved list of clusters with the list of currently available clusters (#13095) * upcoming: [DPS-35317] Compare the saved list of clusters with the list of currently available clusters --- ...r-13095-upcoming-features-1763130670048.md | 5 + .../Clusters/StreamFormClusters.test.tsx | 104 +++++++++++-- .../Clusters/StreamFormClusters.tsx | 142 +++++++++++++----- ...tsx => StreamFormClustersTableContent.tsx} | 10 +- 4 files changed, 208 insertions(+), 53 deletions(-) create mode 100644 packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md rename packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/{StreamFormClustersTable.tsx => StreamFormClustersTableContent.tsx} (94%) diff --git a/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md b/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md new file mode 100644 index 00000000000..6236a2c67b8 --- /dev/null +++ b/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Edit Stream form: remove cluster IDs from the edited stream that no longer exist or have log generation disabled ([#13095](https://github.com/linode/manager/pull/13095)) diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index 315d63f3c7c..fde6a031dd8 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -1,5 +1,6 @@ import { screen, + waitFor, waitForElementToBeRemoved, within, } from '@testing-library/react'; @@ -14,10 +15,6 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormClusters } from './StreamFormClusters'; -const queryMocks = vi.hoisted(() => ({ - useOrderV2: vi.fn().mockReturnValue({}), -})); - const loadingTestId = 'circle-progress'; const testClustersDetails = [ { @@ -118,6 +115,48 @@ describe('StreamFormClusters', () => { ]); }); + it('should filter clusters by name', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'metrics'); + + await waitFor(() => + expect(getColumnsValuesFromTable()).toEqual(['metrics-stream-cluster']) + ); + }); + + it('should filter clusters by region', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'US,'); + + await waitFor(() => + expect(getColumnsValuesFromTable(2)).toEqual([ + 'US, Atalanta, GA', + 'US, Chicago, IL', + ]) + ); + }); + + it('should filter clusters by log generation status', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'enabled'); + + await waitFor(() => + expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) + ); + }); + it('should toggle clusters checkboxes and header checkbox', async () => { await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); @@ -211,6 +250,56 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).toBeChecked(); expect(prodClusterCheckbox).not.toBeChecked(); }); + + describe('and some of them are no longer eligible for log delivery', () => { + it('should remove non-eligible clusters and render table with properly selected clusters', async () => { + const modifiedClusters = clusters.map((cluster) => + cluster.id === 3 + ? { ...cluster, control_plane: { audit_logs_enabled: false } } + : cluster + ); + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(modifiedClusters)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + details: { + cluster_ids: [2, 3], + is_auto_add_all_clusters_enabled: false, + }, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + const table = screen.getByRole('table'); + const headerCheckbox = within(table).getAllByRole('checkbox')[0]; + const gkeProdCheckbox = getCheckboxByClusterName( + 'gke-prod-europe-west1' + ); + const metricsStreamCheckbox = getCheckboxByClusterName( + 'metrics-stream-cluster' + ); + const prodClusterCheckbox = getCheckboxByClusterName('prod-cluster-eu'); + + await waitFor(() => { + expectCheckboxStateToBe(headerCheckbox, 'checked'); + }); + expect(gkeProdCheckbox).not.toBeChecked(); + expect(metricsStreamCheckbox).toBeChecked(); + expect(prodClusterCheckbox).not.toBeChecked(); + }); + }); }); it('should disable all table checkboxes if "Automatically include all" checkbox is selected', async () => { @@ -285,13 +374,6 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).not.toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); - // Sort by Cluster Name descending - queryMocks.useOrderV2.mockReturnValue({ - order: 'desc', - orderBy: 'label', - sortedData: clusters.reverse(), - }); - await userEvent.click(sortHeader); expect(gkeProdCheckbox).not.toBeChecked(); expect(metricsStreamCheckbox).not.toBeChecked(); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index e4c828ad92f..60384dfd948 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -1,4 +1,3 @@ -import { getAPIFilterFromQuery } from '@linode/search'; import { Box, Checkbox, @@ -9,19 +8,22 @@ import { Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import React, { useEffect, useState } from 'react'; +import { enqueueSnackbar } from 'notistack'; +import React, { useEffect, useMemo, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { sortData } from 'src/components/OrderBy'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; import { Table } from 'src/components/Table'; -import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; -import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import type { KubernetesCluster } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; -import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; +import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; const controlPaths = { @@ -45,44 +47,68 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); const [searchText, setSearchText] = useState(''); - const { error: searchParseError, filter: searchFilter } = - getAPIFilterFromQuery(searchText, { - searchableFieldsWithoutOperator: ['label', 'region'], - }); - - const filter = { - ['+order']: order, - ['+order_by']: orderBy, - ...searchFilter, - }; - const { - data: clusters, + data: clusters = [], isLoading, error, - } = useKubernetesClustersQuery({ - filter, - params: { - page, - page_size: pageSize, - }, - }); + } = useAllKubernetesClustersQuery({ enabled: true }); - const idsWithLogsEnabled = clusters?.data - .filter((cluster) => cluster.control_plane.audit_logs_enabled) - .map(({ id }) => id); + const clusterIdsWithLogsEnabled = useMemo( + () => + clusters + ?.filter((cluster) => cluster.control_plane.audit_logs_enabled) + .map(({ id }) => id), + [clusters] + ); const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, name: [controlPaths.isAutoAddAllClustersEnabled, controlPaths.clusterIds], }); + const areArraysDifferent = (a: number[], b: number[]) => { + if (a.length !== b.length) { + return true; + } + + const setB = new Set(b); + + return !a.every((element) => setB.has(element)); + }; + + // Check for clusters that no longer have log generation enabled and remove them from cluster_ids useEffect(() => { - setValue( - controlPaths.clusterIds, - isAutoAddAllClustersEnabled ? idsWithLogsEnabled : clusterIds || [] - ); - }, [isLoading]); + if (!isLoading) { + const selectedClusterIds = clusterIds ?? []; + const filteredClusterIds = selectedClusterIds.filter((id) => + clusterIdsWithLogsEnabled.includes(id) + ); + + const nextValue = + (isAutoAddAllClustersEnabled + ? clusterIdsWithLogsEnabled + : filteredClusterIds) || []; + + if ( + !isAutoAddAllClustersEnabled && + areArraysDifferent(selectedClusterIds, filteredClusterIds) + ) { + enqueueSnackbar( + 'One or more clusters were removed from the selection because Log Generation is no longer enabled on them.', + { variant: 'info' } + ); + } + if (areArraysDifferent(selectedClusterIds, nextValue)) { + setValue(controlPaths.clusterIds, nextValue); + } + } + }, [ + isLoading, + clusterIds, + isAutoAddAllClustersEnabled, + setValue, + clusterIdsWithLogsEnabled, + ]); const handleOrderChange = (newOrderBy: OrderByKeys) => { if (orderBy === newOrderBy) { @@ -93,6 +119,46 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { } }; + const filteredClusters = !searchText + ? clusters + : clusters.filter((cluster) => { + const lowerSearch = searchText.toLowerCase(); + + return ( + cluster.label.toLowerCase().includes(lowerSearch) || + cluster.region.toLowerCase().includes(lowerSearch) || + (cluster.control_plane.audit_logs_enabled + ? 'enabled' + : 'disabled' + ).includes(lowerSearch) + ); + }); + + const sortedAndFilteredClusters = sortData( + orderBy, + order + )(filteredClusters); + + // Paginate clusters + const indexOfFirstClusterInPage = (page - 1) * pageSize; + const indexOfLastClusterInPage = indexOfFirstClusterInPage + pageSize; + const paginatedClusters = sortedAndFilteredClusters.slice( + indexOfFirstClusterInPage, + indexOfLastClusterInPage + ); + + // If the current page is out of range after filtering, change to the last available page + useEffect(() => { + if (indexOfFirstClusterInPage >= sortedAndFilteredClusters.length) { + const lastPage = Math.max( + 1, + Math.ceil(sortedAndFilteredClusters.length / pageSize) + ); + + setPage(lastPage); + } + }, [sortedAndFilteredClusters, indexOfFirstClusterInPage, pageSize]); + return ( Clusters @@ -120,7 +186,10 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { onChange={async (_, checked) => { field.onChange(checked); if (checked) { - setValue(controlPaths.clusterIds, idsWithLogsEnabled); + setValue( + controlPaths.clusterIds, + clusterIdsWithLogsEnabled + ); } else { setValue(controlPaths.clusterIds, []); } @@ -141,7 +210,6 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { }, }} debounceTime={250} - errorText={searchParseError?.message} hideLabel inputProps={{ 'data-pendo-id': `Logs Delivery Streams ${capitalize(mode)}-Clusters-Search`, @@ -165,9 +233,9 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { name={controlPaths.clusterIds} render={({ field }) => ( { /> | undefined; + clusters: KubernetesCluster[] | undefined; field: ControllerRenderProps< StreamAndDestinationFormType, 'stream.details.cluster_ids' @@ -62,7 +62,7 @@ export const StreamFormClusterTableContent = ({ - {!!clusters?.results && ( + {!!clusters && ( - {clusters?.results ? ( - clusters.data.map( + {clusters?.length ? ( + clusters.map( ({ label, region, From d63b7dc71334bd22eb2ca0fe5765acc5f29116c8 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 24 Nov 2025 19:39:19 +0530 Subject: [PATCH 34/91] upcoming: [UIE-9551] - Implement read-only NLB list table. (#13112) * upcoming: [UIE-9551] - Implement read-only NLB list table. * Added changeset: Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns * Using theme-sensitive tokens for ShowMore text. * Addressed review comments. --- ...r-13112-upcoming-features-1763627065100.md | 5 + .../src/components/ShowMore/ShowMore.tsx | 9 +- .../src/factories/networkLoadBalancer.ts | 4 +- .../NetworkLoadBalancersDetail.tsx | 3 +- .../NetworkLoadBalancersLanding.tsx | 20 -- .../NetworkLoadBalancerTableRow.test.tsx | 195 ++++++++++++++++++ .../NetworkLoadBalancerTableRow.tsx | 103 +++++++++ .../NetworkLoadBalancersLanding.styles.ts | 23 +++ .../NetworkLoadBalancersLanding.test.tsx | 170 +++++++++++++++ .../NetworkLoadBalancersLanding.tsx | 118 +++++++++++ .../NetworkLoadBalancersLandingEmptyState.tsx | 14 ++ .../PortsDisplay.test.tsx | 117 +++++++++++ .../PortsDisplay.tsx | 96 +++++++++ .../networkLoadBalancersLazyRoute.tsx | 0 .../NetworkLoadBalancers/constants.ts | 4 + .../src/routes/networkLoadBalancer/index.ts | 2 +- 16 files changed, 855 insertions(+), 28 deletions(-) create mode 100644 packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md delete mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.test.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.styles.ts create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.test.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.test.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.tsx rename packages/manager/src/features/NetworkLoadBalancers/{ => NetworkLoadBalancersLanding}/networkLoadBalancersLazyRoute.tsx (100%) create mode 100644 packages/manager/src/features/NetworkLoadBalancers/constants.ts diff --git a/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md b/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md new file mode 100644 index 00000000000..bc0da4f2abb --- /dev/null +++ b/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns ([#13112](https://github.com/linode/manager/pull/13112)) diff --git a/packages/manager/src/components/ShowMore/ShowMore.tsx b/packages/manager/src/components/ShowMore/ShowMore.tsx index 8a3466d31c2..d020f3c24cd 100644 --- a/packages/manager/src/components/ShowMore/ShowMore.tsx +++ b/packages/manager/src/components/ShowMore/ShowMore.tsx @@ -37,14 +37,15 @@ export const ShowMore = (props: ShowMoreProps) => { data-qa-show-more-chip label={`+${items.length}`} onClick={handleClick} - sx={ - anchorEl + sx={{ + ...(anchorEl ? { backgroundColor: theme.palette.primary.main, color: theme.tokens.color.Neutrals.White, } - : null - } + : {}), + ...(chipProps?.sx || {}), // caller-provided `chipProps.sx` takes precedence and will override the default active styling. + }} /> ({ id: Factory.each((id) => id), - label: Factory.each((id) => `nlb-${id}`), + label: Factory.each((id) => `netloadbalancer-${id}-test${id}`), region: 'us-east', address_v4: '192.168.1.1', address_v6: '2001:db8:85a3::8a2e:370:7334', @@ -26,7 +26,7 @@ export const networkLoadBalancerListenerFactory = id: Factory.each((id) => id), label: Factory.each((id) => `nlb-listener-${id}`), updated: '2023-01-01T00:00:00Z', - port: 80, + port: Factory.each((id) => 80 + id), protocol: 'tcp', }); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx index 545d4fcad59..8d1680f85f2 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersDetail.tsx @@ -7,6 +7,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { LandingHeader } from 'src/components/LandingHeader'; +import { NLB_API_DOCS_LINK } from '../constants'; import { NetworkLoadBalancerDetailBody } from './NetworkLoadBalancerDetailBody'; import { NetworkLoadBalancerDetailHeader } from './NetworkLoadBalancerDetailHeader'; @@ -45,7 +46,7 @@ const NetworkLoadBalancersDetail = () => { pathname: `/netloadbalancers/${nlb.id}`, }} docsLabel="Docs" - docsLink="https://techdocs.akamai.com/linode-api/changelog/network-load-balancers" + docsLink={NLB_API_DOCS_LINK} title={nlb.label} /> { - return ( - <> - - Network Load Balancer is coming soon... - - ); -}; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.test.tsx new file mode 100644 index 00000000000..006301d6070 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.test.tsx @@ -0,0 +1,195 @@ +import { breakpoints } from '@linode/ui'; +import * as React from 'react'; + +import { + networkLoadBalancerFactory, + networkLoadBalancerListenerFactory, +} from 'src/factories/networkLoadBalancer'; +import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; + +import { NetworkLoadBalancerTableRow } from './NetworkLoadBalancerTableRow'; + +import type { NetworkLoadBalancer } from '@linode/api-v4/lib/netloadbalancers'; + +// Use factory-built data. Do not hardcode properties in this file. +const mockNetworkLoadBalancer: NetworkLoadBalancer = (() => { + const base = networkLoadBalancerFactory.build(); + const listeners = networkLoadBalancerListenerFactory.buildList(2); + return { ...base, listeners }; +})(); + +describe('NetworkLoadBalancerTableRow', () => { + beforeEach(() => { + vi.resetAllMocks(); + resizeScreenSize(breakpoints.values.lg); + }); + + it('renders the NetworkLoadBalancer table row with label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(mockNetworkLoadBalancer.label)).toBeVisible(); + }); + + it('renders the status icon and status text', () => { + const { getByText } = renderWithTheme( + + ); + + // Status displayed is case-insensitive; match using the factory status value. + expect( + getByText(new RegExp(mockNetworkLoadBalancer.status, 'i')) + ).toBeVisible(); + }); + + it('renders the ID in hidden column on small screens', () => { + resizeScreenSize(breakpoints.values.lg); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(String(mockNetworkLoadBalancer.id))).toBeVisible(); + }); + + it('hides the ID column on small screens', () => { + resizeScreenSize(breakpoints.values.sm - 1); + const { queryByText } = renderWithTheme( + + ); + + expect( + queryByText(String(mockNetworkLoadBalancer.id)) + ).not.toBeInTheDocument(); + }); + + it('renders listener ports', () => { + const { getByText } = renderWithTheme( + + ); + + // Ensure at least one listener port from the factory is rendered + const firstPort = mockNetworkLoadBalancer.listeners?.[0]?.port; + expect(firstPort).toBeDefined(); + expect(getByText(new RegExp(String(firstPort)))).toBeInTheDocument(); + }); + + it('renders "None" when there are no listeners', () => { + const nlbWithNoListeners = networkLoadBalancerFactory.build(); + + const { container } = renderWithTheme( + + ); + + const portsCell = container.querySelector('[data-qa-ports]'); + expect(portsCell?.textContent?.trim()).toBe('None'); + }); + + it('renders IPv4 address', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(mockNetworkLoadBalancer.address_v4)).toBeVisible(); + }); + + it('renders IPv6 address', () => { + resizeScreenSize(breakpoints.values.lg); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(mockNetworkLoadBalancer.address_v6!)).toBeVisible(); + }); + + it('renders "None" for IPv6 when address_v6 is not set', () => { + const nlbWithoutIPv6: NetworkLoadBalancer = { + ...mockNetworkLoadBalancer, + address_v6: '', + }; + + resizeScreenSize(breakpoints.values.lg); + renderWithTheme(); + + const noneElements = document.querySelectorAll('td'); + const lastNoneElement = Array.from(noneElements).find( + (el) => el.textContent === 'None' + ); + expect(lastNoneElement).toBeInTheDocument(); + }); + + it('renders region', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(mockNetworkLoadBalancer.region)).toBeVisible(); + }); + + it('renders inactive status', () => { + const nlbInactive: NetworkLoadBalancer = { + ...mockNetworkLoadBalancer, + status: 'suspended', + }; + + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/suspended/i)).toBeVisible(); + }); + + it('renders the label as a link', () => { + const { getByRole } = renderWithTheme( + + ); + + const link = getByRole('link', { name: mockNetworkLoadBalancer.label }); + expect(link).toHaveAttribute( + 'href', + `/netloadbalancers/${mockNetworkLoadBalancer.id}/listeners` + ); + }); + + it('hides listener ports column on medium screens and below', () => { + resizeScreenSize(breakpoints.values.md - 1); + const { container } = renderWithTheme( + + ); + + // The ports should be present in the DOM for this implementation + // (component does not hide ports at md); verify ports are rendered + const ports = Array.from(container.querySelectorAll('td')); + const firstPort = String(mockNetworkLoadBalancer.listeners?.[0]?.port); + const hasPortsColumn = ports.some( + (el) => + el.textContent === firstPort || el.textContent?.includes(firstPort) + ); + expect(hasPortsColumn).toBe(true); + }); + + it('hides IPv6 column on medium screens and below', () => { + resizeScreenSize(breakpoints.values.md - 1); + renderWithTheme( + + ); + + // IPv6 should not be visible on md screens + const ipv6Cell = document.querySelector('td'); + expect(ipv6Cell?.textContent).not.toContain( + mockNetworkLoadBalancer.address_v6! + ); + }); + + it('hides region and ID columns on small screens and below', () => { + resizeScreenSize(breakpoints.values.sm - 1); + renderWithTheme( + + ); + + // ID and region should not be visible on sm screens + const cells = document.querySelectorAll('td'); + const cellTexts = Array.from(cells).map((el) => el.textContent); + expect(cellTexts.join('')).not.toContain(mockNetworkLoadBalancer.region); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx new file mode 100644 index 00000000000..baaa244831c --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancerTableRow.tsx @@ -0,0 +1,103 @@ +import { Hidden } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; +import { RegionIndicator } from 'src/features/Linodes/LinodesLanding/RegionIndicator'; + +import { PortsDisplay } from './PortsDisplay'; + +import type { NetworkLoadBalancer } from '@linode/api-v4/lib/netloadbalancers'; + +export const NetworkLoadBalancerTableRow = (props: NetworkLoadBalancer) => { + const { + id, + address_v4, + address_v6, + label, + region, + status, + listeners, + lke_cluster, + } = props; + + // Memoize port strings to avoid recalculation + const portStrings = React.useMemo(() => { + return listeners?.map((listener) => listener.port.toString()) ?? []; + }, [listeners]); + + const [isHovered, setIsHovered] = React.useState(false); + + const handleMouseEnter = React.useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = React.useCallback(() => { + setIsHovered(false); + }, []); + + return ( + + + + {label} + + + + + + {capitalize(status)} + + + + {id} + + + + + + + + + + {address_v6 ? ( + + ) : ( + 'None' + )} + + + + {lke_cluster ? ( + + {lke_cluster.label} + + ) : ( + 'None' + )} + + + + + + + + ); +}; + +export default NetworkLoadBalancerTableRow; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.styles.ts b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.styles.ts new file mode 100644 index 00000000000..a692da02b0d --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.styles.ts @@ -0,0 +1,23 @@ +import { Chip, styled } from '@linode/ui'; + +import LockIcon from 'src/assets/icons/lock.svg'; + +export const StyledManagedChip = styled(Chip, { + label: 'StyledManagedChip', +})(({ theme }) => ({ + backgroundColor: theme.tokens.component.Badge.Informative.Subtle.Background, + color: theme.tokens.component.Badge.Informative.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + '& .MuiChip-icon': { + marginRight: '0px', + }, +})); + +export const StyledLockIcon = styled(LockIcon)(({ theme }) => ({ + '& path': { + fill: theme.tokens.alias.Accent.Info.Primary, + }, + height: '12px', + width: '12px', +})); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.test.tsx new file mode 100644 index 00000000000..51c1a5dde1b --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.test.tsx @@ -0,0 +1,170 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; + +import { + networkLoadBalancerFactory, + networkLoadBalancerListenerFactory, +} from 'src/factories/networkLoadBalancer'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { NetworkLoadBalancersLanding } from './NetworkLoadBalancersLanding'; + +const queryMocks = vi.hoisted(() => ({ + useMatch: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useMatch: queryMocks.useMatch, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + }; +}); + +beforeAll(() => { + mockMatchMedia(); +}); + +const loadingTestId = 'circle-progress'; + +describe('NetworkLoadBalancersLanding', () => { + it('renders the NetworkLoadBalancer empty state if there are no NetworkLoadBalancers', async () => { + server.use( + http.get('*/v4beta/netloadbalancers', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId } = renderWithTheme(); + + // expect loading state and wait for it to disappear + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + }); + + it('renders the NetworkLoadBalancer table if there are NetworkLoadBalancers', async () => { + const mockNetworkLoadBalancers = [ + networkLoadBalancerFactory.build({ + listeners: networkLoadBalancerListenerFactory.buildList(2), + }), + ]; + + server.use( + http.get('*/v4beta/netloadbalancers', () => { + return HttpResponse.json(makeResourcePage(mockNetworkLoadBalancers)); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + // expect loading state and wait for it to disappear + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('Network Load Balancer')).toBeVisible(); + expect(getByText('netloadbalancer-1-test1')).toBeVisible(); + + // confirm table headers + expect(getByText('Label')).toBeVisible(); + expect(getByText('Status')).toBeVisible(); + expect(getByText('ID')).toBeVisible(); + expect(getByText('Listener Ports')).toBeVisible(); + expect(getByText('Virtual IP (IPv4)')).toBeVisible(); + expect(getByText('Virtual IP (IPv6)')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + }); + + it('renders the managed by badge with tooltip', async () => { + const mockNetworkLoadBalancers = [ + networkLoadBalancerFactory.build({ listeners: [] }), + ]; + + server.use( + http.get('*/v4beta/netloadbalancers', () => { + return HttpResponse.json(makeResourcePage(mockNetworkLoadBalancers)); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Check for the badge + expect(getByText('Managed by LKE Enterprise')).toBeVisible(); + + // Check for the Chip component (which contains the badge) + const chip = document.querySelector('[class*="MuiChip-root"]'); + expect(chip).toBeInTheDocument(); + }); + + it('displays error state when there is an error', async () => { + server.use( + http.get('*/v4beta/netloadbalancers', () => { + return HttpResponse.json( + { + errors: [ + { + reason: 'Internal Server Error', + }, + ], + }, + { status: 500 } + ); + }) + ); + + const { getByTestId } = renderWithTheme(); + + // Wait for the component to load and display the error + await waitForElementToBeRemoved(() => getByTestId(loadingTestId)).catch( + () => { + // Component might show error without removing the loading state + } + ); + + // The component should display an error message + const errorElement = document.body.querySelector('*'); + expect(errorElement).toBeTruthy(); + }); + + it('displays loading state initially', () => { + const { getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + }); + + it('displays pagination footer when data is loaded', async () => { + const mockNetworkLoadBalancers = [ + networkLoadBalancerFactory.build({ listeners: [] }), + ]; + + server.use( + http.get('*/v4beta/netloadbalancers', () => { + return HttpResponse.json(makeResourcePage(mockNetworkLoadBalancers)); + }) + ); + + const { getByTestId, container } = renderWithTheme( + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Check for pagination elements by finding the PaginationFooter component + // PaginationFooter is typically rendered with pagination controls + const paginationFooter = container.querySelector( + '[class*="PaginationFooter"]' + ); + expect(paginationFooter || container.innerHTML).toBeTruthy(); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx new file mode 100644 index 00000000000..97eed4c8244 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLanding.tsx @@ -0,0 +1,118 @@ +import { useNetworkLoadBalancersQuery } from '@linode/queries'; +import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +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 { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { NLB_API_DOCS_LINK } from '../constants'; +import { + StyledLockIcon, + StyledManagedChip, +} from './NetworkLoadBalancersLanding.styles'; +import { NetworkLoadBalancersLandingEmptyState } from './NetworkLoadBalancersLandingEmptyState'; +import { NetworkLoadBalancerTableRow } from './NetworkLoadBalancerTableRow'; + +const preferenceKey = 'netloadbalancers'; + +export const NetworkLoadBalancersLanding = () => { + const pagination = usePaginationV2({ + currentRoute: '/netloadbalancers', + initialPage: 1, + preferenceKey, + }); + + const { data, error, isLoading } = useNetworkLoadBalancersQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + {} + ); + + if (error) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + if (data?.results === 0) { + return ; + } + + return ( + <> + + } + label="Managed by LKE Enterprise" + size="small" + /> + } + title="Network Load Balancer" + /> + + + + Label + + Status + + + ID + + Listener Ports + Virtual IP (IPv4) + + Virtual IP (IPv6) + + LKE-E Cluster + + Region + + + + + {data?.data.map((networkLoadBalancer) => ( + + ))} + +
    + + + ); +}; + +export default NetworkLoadBalancersLanding; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx new file mode 100644 index 00000000000..8238a8c8282 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; + +// This will be implemented as part of a different story. +export const NetworkLoadBalancersLandingEmptyState = () => { + return ( + + + + + ); +}; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.test.tsx new file mode 100644 index 00000000000..d2c79711060 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { networkLoadBalancerListenerFactory } from 'src/factories/networkLoadBalancer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PortsDisplay } from './PortsDisplay'; + +describe('PortsDisplay', () => { + it('renders None when there are no ports', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('None')).toBeInTheDocument(); + }); + + it('renders all ports when within the display limit', () => { + const listeners = [ + networkLoadBalancerListenerFactory.build({ port: 80 }), + networkLoadBalancerListenerFactory.build({ port: 443 }), + ]; + + const ports = listeners.map((l) => String(l.port)); + + const { getByText, container } = renderWithTheme( + + ); + + // Both ports should be visible; match specifically a span to avoid duplicate matches + expect( + getByText((_, node) => { + if (!node) return false; + return ( + node.nodeName === 'SPAN' && + !!node.textContent && + node.textContent.includes(String(ports[0])) + ); + }) + ).toBeInTheDocument(); + expect( + getByText((_, node) => { + if (!node) return false; + return ( + node.nodeName === 'SPAN' && + !!node.textContent && + node.textContent.includes(String(ports[1])) + ); + }) + ).toBeInTheDocument(); + + // There should be no overflow badge like +N + expect(container.textContent).not.toMatch(/\+\d+/); + }); + + it('truncates ports and shows overflow badge when exceeding limit', async () => { + const listeners = [ + networkLoadBalancerListenerFactory.build({ port: 80 }), + networkLoadBalancerListenerFactory.build({ port: 443 }), + networkLoadBalancerListenerFactory.build({ port: 8080 }), + networkLoadBalancerListenerFactory.build({ port: 53 }), + ]; + + const ports = listeners.map((l) => String(l.port)); + + const { getByText, getByRole, container } = renderWithTheme( + + ); + + // First ports should be visible (match spans) + expect( + getByText((_, node) => { + if (!node) return false; + return ( + node.nodeName === 'SPAN' && + !!node.textContent && + node.textContent.includes(String(ports[0])) + ); + }) + ).toBeInTheDocument(); + expect( + getByText((_, node) => { + if (!node) return false; + return ( + node.nodeName === 'SPAN' && + !!node.textContent && + node.textContent.includes(String(ports[1])) + ); + }) + ).toBeInTheDocument(); + + // Overflow badge should be present (e.g. +2) + const match = container.textContent?.match(/\+(\d+)/); + expect(match).toBeTruthy(); + const hiddenCount = Number(match?.[1]); + expect(hiddenCount).toBeGreaterThan(0); + + // Open the ShowMore popover by clicking the chip button using its aria-label + const badgeText = `+${hiddenCount}`; + const badgeButton = getByRole('button', { name: `${badgeText} ports` }); + + fireEvent.click(badgeButton); + + // Hidden ports should now be visible in the popover; match spans + const hiddenPorts = ports.slice(2); + hiddenPorts.forEach((p) => { + expect( + getByText((_, node) => { + if (!node) return false; + return ( + node.nodeName === 'SPAN' && + !!node.textContent && + node.textContent.includes(p) + ); + }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.tsx new file mode 100644 index 00000000000..ec88333856d --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/PortsDisplay.tsx @@ -0,0 +1,96 @@ +import { Stack } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { ShowMore } from 'src/components/ShowMore/ShowMore'; + +import { MAX_PORT_DISPLAY_CHARS } from '../constants'; + +interface PortsDisplayProps { + ports: string[]; +} + +interface PortsPartition { + displayText: string; + hiddenPorts: string[]; +} + +/** + * Calculates how many ports can be displayed within the character limit. + * @param ports - Array of port strings to partition + * @returns Object containing displayText and hiddenPorts + */ +const partitionPorts = (ports: string[]): PortsPartition => { + if (ports.length === 0) { + return { displayText: 'None', hiddenPorts: [] }; + } + + let accumulatedLength = 0; + let visibleCount = 0; + + for (let i = 0; i < ports.length; i++) { + const portLength = ports[i].length; + const separatorLength = i < ports.length - 1 ? 2 : 0; // ', ' = 2 chars + const totalLength = accumulatedLength + portLength + separatorLength; + + if (totalLength > MAX_PORT_DISPLAY_CHARS && accumulatedLength > 0) { + break; + } + + accumulatedLength = totalLength; + visibleCount = i + 1; + } + + const visiblePorts = ports.slice(0, visibleCount); + const hiddenPorts = ports.slice(visibleCount); + + return { + displayText: visiblePorts.join(', '), + hiddenPorts, + }; +}; + +/** + * Formats and displays ports with truncation when exceeding character limit. + * React.memo ensures the component only re-renders when `ports` changes. + * Hidden ports are accessible via ShowMore popover. + */ +export const PortsDisplay = React.memo(({ ports }: PortsDisplayProps) => { + const theme = useTheme(); + const { displayText, hiddenPorts } = partitionPorts(ports); + + if (displayText === 'None') { + return None; + } + + return ( + + {displayText} + {hiddenPorts.length > 0 && ( + ( + + {hiddenPortsList.map((port) => ( + {port} + ))} + + )} + /> + )} + + ); +}); + +PortsDisplay.displayName = 'PortsDisplay'; diff --git a/packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/networkLoadBalancersLazyRoute.tsx similarity index 100% rename from packages/manager/src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute.tsx rename to packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/networkLoadBalancersLazyRoute.tsx diff --git a/packages/manager/src/features/NetworkLoadBalancers/constants.ts b/packages/manager/src/features/NetworkLoadBalancers/constants.ts new file mode 100644 index 00000000000..9b28fd7ac29 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/constants.ts @@ -0,0 +1,4 @@ +export const MAX_PORT_DISPLAY_CHARS = 12; + +export const NLB_API_DOCS_LINK = + 'https://techdocs.akamai.com/linode-api/changelog/network-load-balancers'; diff --git a/packages/manager/src/routes/networkLoadBalancer/index.ts b/packages/manager/src/routes/networkLoadBalancer/index.ts index fb2421fef67..d98ed4848eb 100644 --- a/packages/manager/src/routes/networkLoadBalancer/index.ts +++ b/packages/manager/src/routes/networkLoadBalancer/index.ts @@ -14,7 +14,7 @@ const networkLoadBalancersIndexRoute = createRoute({ path: '/', }).lazy(() => import( - 'src/features/NetworkLoadBalancers/networkLoadBalancersLazyRoute' + 'src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/networkLoadBalancersLazyRoute' ).then((m) => m.networkLoadBalancersLazyRoute) ); From a966e10528c5f5434f5c7295899dc940ecfb08e9 Mon Sep 17 00:00:00 2001 From: smans-akamai Date: Mon, 24 Nov 2025 13:56:28 -0500 Subject: [PATCH 35/91] fix: [UIE-9643] - DBaaS - Manage Networking VPC fields not handling error response (#13121) * fix: [UIE-9643] - DBaaS - Manage Networking VPC fields not handling error response * adding changeset --- .../pr-13121-fixed-1763734247008.md | 5 +++++ .../DatabaseManageNetworkingDrawer.tsx | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-13121-fixed-1763734247008.md diff --git a/packages/manager/.changeset/pr-13121-fixed-1763734247008.md b/packages/manager/.changeset/pr-13121-fixed-1763734247008.md new file mode 100644 index 00000000000..30842b6ced6 --- /dev/null +++ b/packages/manager/.changeset/pr-13121-fixed-1763734247008.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +DBaaS - Manage Networking VPC fields not handling error response ([#13121](https://github.com/linode/manager/pull/13121)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx index c3d9bca2806..c35d5bdf251 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworkingDrawer.tsx @@ -46,14 +46,16 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { }); const { - formState: { isDirty, isValid }, + formState: { isDirty, isValid, errors }, handleSubmit, reset, + setError, watch, } = form; - const onSubmit = (values: ManageNetworkingFormValues) => { - updateDatabase(values).then(() => { + const onSubmit = async (values: ManageNetworkingFormValues) => { + try { + await updateDatabase(values); enqueueSnackbar('Changes are being applied.', { variant: 'info', }); @@ -65,7 +67,11 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { databaseId: database.id, }, }); - }); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + } }; const [publicAccess, subnetId, vpcId] = watch([ @@ -84,7 +90,6 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { const isSaveDisabled = !isDirty || !isValid || !hasValidSelection; const { - error: manageNetworkingError, isPending: submitInProgress, mutateAsync: updateDatabase, reset: resetMutation, @@ -105,8 +110,8 @@ const DatabaseManageNetworkingDrawer = (props: Props) => { return ( - {manageNetworkingError && ( - + {errors.root?.message && ( + )}
    From d699296a19f08555dfd557ba458b5a3cf8a4ba01 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:03:49 -0500 Subject: [PATCH 36/91] chore: [M3-10715] - Bump Vite from `7.1.x` to `7.2.x` and update Vitest from `v3` to `v4` (#13119) * chore: update vite to latest and vitest to v4 * fix search tests and sdk build * addess unhanded promises in `TriggerConditions.test.tsx` * fix some circular imports in CloudPulse to try to fix unit test stability * lint * clean up * clean up * fix lockfile * fix unawaited fetches by mocking flags differently to hopefully fix test flake * fix unawaited fetches by mocking flags differently to hopefully fix test flake * mock flags with our built-in helper * revert some possibly unnessesary changes * lint * add some changesets Co-authored-by: Banks Nussman --- package.json | 4 +- .../pr-13119-tech-stories-1764007445902.md | 5 + packages/api-v4/package.json | 3 +- .../pr-13119-tech-stories-1764007553451.md | 5 + .../pr-13119-tech-stories-1764007583363.md | 5 + .../pr-13119-tech-stories-1764007617066.md | 5 + packages/manager/package.json | 2 +- .../CloudPulse/Alerts/AlertsDetail/utils.ts | 2 +- .../AlertInformationActionTable.tsx | 3 +- .../CreateAlertDefinition.test.tsx | 4 +- .../DimensionFilterValue/utils.test.ts | 2 +- .../Criteria/DimensionFilterValue/utils.ts | 24 +- .../Criteria/TriggerConditions.test.tsx | 16 +- .../EditAlert/EditAlertDefinition.test.tsx | 113 +-- .../CloudPulse/Alerts/Utils/utils.test.ts | 27 +- .../features/CloudPulse/Alerts/Utils/utils.ts | 44 - .../Dashboard/CloudPulseDashboard.tsx | 4 +- .../src/features/CloudPulse/GroupBy/utils.ts | 2 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 2 +- .../CloudPulse/Utils/FilterBuilder.ts | 69 +- .../CloudPulse/Utils/FilterConfig.test.ts | 36 + .../features/CloudPulse/Utils/FilterConfig.ts | 32 + .../features/CloudPulse/Utils/utils.test.ts | 59 +- .../src/features/CloudPulse/Utils/utils.ts | 120 ++- .../shared/CloudPulseEndpointsSelect.tsx | 3 +- .../CloudPulseFirewallNodebalancersSelect.tsx | 5 +- .../shared/CloudPulseRegionSelect.tsx | 6 +- .../shared/CloudPulseResourcesSelect.tsx | 3 +- .../src/features/Kubernetes/kubeUtils.test.ts | 140 +-- .../features/PlacementGroups/utils.test.ts | 18 +- .../src/features/PlacementGroups/utils.ts | 9 +- .../APITokens/CreateAPITokenDrawer.test.tsx | 4 +- .../VPCs/VPCDetail/VPCSubnetsTable.test.tsx | 4 +- .../components/PlansPanel/utils.test.ts | 104 +- .../src/utilities/analytics/utils.test.ts | 4 +- packages/manager/vite.config.ts | 2 +- packages/search/vitest.config.ts | 3 + .../pr-13119-tech-stories-1764007518381.md | 5 + .../helpers/scrollErrorIntoViewV2.test.tsx | 35 +- pnpm-lock.yaml | 932 +++++++++--------- vitest.config.ts | 14 + vitest.workspace.ts | 8 - 42 files changed, 929 insertions(+), 958 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md create mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md create mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md create mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md create mode 100644 packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts create mode 100644 packages/search/vitest.config.ts create mode 100644 packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md create mode 100644 vitest.config.ts delete mode 100644 vitest.workspace.ts diff --git a/package.json b/package.json index 388b9dd7ee5..7ea7b9bd378 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^3.2.4", - "@vitest/ui": "^3.2.4", + "vitest": "^4.0.10", + "@vitest/ui": "^4.0.10", "lint-staged": "^15.4.3", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", diff --git a/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md b/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md new file mode 100644 index 00000000000..db0f148184b --- /dev/null +++ b/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Add `@types/node` as a devDependency ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index f19a56eeb28..033074da041 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -59,7 +59,8 @@ "@linode/tsconfig": "workspace:*", "axios-mock-adapter": "^1.22.0", "concurrently": "^9.0.1", - "tsup": "^8.4.0" + "tsup": "^8.4.0", + "@types/node": "^22.13.14" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md new file mode 100644 index 00000000000..9141261a386 --- /dev/null +++ b/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update Vite from `7.1.11` to `7.2.2` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md new file mode 100644 index 00000000000..65133e69b95 --- /dev/null +++ b/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Fix circular imports in CloudPulse ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md new file mode 100644 index 00000000000..d79f961c70c --- /dev/null +++ b/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update vitest from `v3` to `v4` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 11169d99914..e67df571e5f 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -180,7 +180,7 @@ "pdfreader": "^3.0.7", "redux-mock-store": "^1.5.3", "storybook": "^9.0.12", - "vite": "^7.1.11", + "vite": "^7.2.2", "vite-plugin-svgr": "^4.5.0" }, "browserslist": [ diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts index 12467410ed3..2a644a2e435 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/utils.ts @@ -1,4 +1,4 @@ -import { transformDimensionValue } from '../Utils/utils'; +import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; import { LINODE_DIMENSION_LABEL, NODEBALANCER_DIMENSION_LABEL, diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index a457d7d79ab..a8968c2b6f2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -21,11 +21,10 @@ import { } from 'src/queries/cloudpulse/useAlertsMutation'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { useContextualAlertsState } from '../../Utils/utils'; +import { arraysEqual, useContextualAlertsState } from '../../Utils/utils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; import { ALERT_SCOPE_TOOLTIP_CONTEXTUAL } from '../constants'; import { scrollToElement } from '../Utils/AlertResourceUtils'; -import { arraysEqual } from '../Utils/utils'; import { AlertInformationActionRow } from './AlertInformationActionRow'; import type { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index 71dc62de48f..d0db461a823 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -139,6 +139,7 @@ describe('AlertDefinition Create', () => { it( 'should render client side validation errors for threshold and trigger occurences text field', + { timeout: 10000 }, async () => { const user = userEvent.setup(); const container = renderWithTheme(); @@ -173,8 +174,7 @@ describe('AlertDefinition Create', () => { expect( container.getAllByText('The value should be a number.').length ).toBe(2); - }, - { timeout: 10000 } + } ); it('should render the client side validation error messages for the form', async () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts index 80c6f9787a8..7162f14f7b1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts @@ -1,6 +1,5 @@ import { linodeFactory, nodeBalancerFactory } from '@linode/utilities'; -import { transformDimensionValue } from '../../../Utils/utils'; import { getFilteredFirewallParentEntities, getFirewallLinodes, @@ -11,6 +10,7 @@ import { handleValueChange, resolveSelectedValues, scopeBasedFilteredResources, + transformDimensionValue, } from './utils'; import type { Linode } from '@linode/api-v4'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts index 11498b8db95..0fe8f769e91 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.ts @@ -1,4 +1,7 @@ -import { transformDimensionValue } from '../../../Utils/utils'; +import { + DIMENSION_TRANSFORM_CONFIG, + TRANSFORMS, +} from 'src/features/CloudPulse/shared/DimensionTransform'; import type { Item } from '../../../constants'; import type { OperatorGroup } from './constants'; @@ -13,6 +16,25 @@ import type { import type { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; import type { FirewallEntity } from 'src/features/CloudPulse/shared/types'; +/** + * Transform a dimension value using the appropriate transform function + * @param serviceType - The cloud pulse service type + * @param dimensionLabel - The dimension label + * @param value - The value to transform + * @returns Transformed value + */ +export const transformDimensionValue = ( + serviceType: CloudPulseServiceType | null, + dimensionLabel: string, + value: string +): string => { + return ( + ( + serviceType && DIMENSION_TRANSFORM_CONFIG[serviceType]?.[dimensionLabel] + )?.(value) ?? TRANSFORMS.capitalize(value) + ); +}; + /** * Resolves the selected value(s) for the Autocomplete component from raw string. * @param options - List of selectable options. diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx index 3b900aeb9eb..3eb0ee64bf8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx @@ -35,8 +35,6 @@ const pollingIntervalOptions = })); describe('Trigger Conditions', () => { - const user = userEvent.setup(); - it('should render all the components and names', () => { renderWithThemeAndHookFormContext({ component: ( @@ -120,7 +118,7 @@ describe('Trigger Conditions', () => { { name: 'Open' } ); - user.click(evaluationPeriodInput); + await userEvent.click(evaluationPeriodInput); expect( await screen.findByRole('option', { @@ -133,7 +131,7 @@ describe('Trigger Conditions', () => { }) ).toBeVisible(); - await user.click( + await userEvent.click( screen.getByRole('option', { name: evaluationPeriodOptions[0].label, }) @@ -167,7 +165,7 @@ describe('Trigger Conditions', () => { { name: 'Open' } ); - user.click(pollingIntervalInput); + await userEvent.click(pollingIntervalInput); expect( await screen.findByRole('option', { @@ -181,7 +179,7 @@ describe('Trigger Conditions', () => { }) ).toBeVisible(); - await user.click( + await userEvent.click( screen.getByRole('option', { name: pollingIntervalOptions[0].label, }) @@ -191,7 +189,7 @@ describe('Trigger Conditions', () => { ).toHaveAttribute('value', pollingIntervalOptions[0].label); }); - it('should be able to show the options that are greater than or equal to max scraping Interval', () => { + it('should be able to show the options that are greater than or equal to max scraping Interval', async () => { renderWithThemeAndHookFormContext({ component: ( { { name: 'Open' } ); - user.click(evaluationPeriodInput); + await userEvent.click(evaluationPeriodInput); expect( screen.queryByText(evaluationPeriodOptions[0].label) @@ -227,7 +225,7 @@ describe('Trigger Conditions', () => { 'button', { name: 'Open' } ); - user.click(pollingIntervalInput); + await userEvent.click(pollingIntervalInput); expect( screen.queryByText(pollingIntervalOptions[0].label) ).not.toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx index 9c921f21bf2..05e19604265 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx @@ -48,72 +48,61 @@ const alertDetails = alertFactory.build({ scope: 'entity', }); describe('EditAlertDefinition component', () => { - it( - 'renders the components of the form', - async () => { - const { findByPlaceholderText, getByLabelText, getByText } = - renderWithTheme( - - ); - expect(getByText('1. General Information')).toBeVisible(); - expect(getByLabelText('Name')).toBeVisible(); - expect(getByLabelText('Description (optional)')).toBeVisible(); - expect(getByLabelText('Severity')).toBeVisible(); - expect(getByLabelText('Service')).toBeVisible(); - expect(getByText('2. Entities')).toBeVisible(); - expect( - await findByPlaceholderText('Search for a Region or Entity') - ).toBeInTheDocument(); - expect(await findByPlaceholderText('Select Regions')).toBeInTheDocument(); - expect(getByText('3. Criteria')).toBeVisible(); - expect(getByText('Metric Threshold')).toBeVisible(); - expect(getByLabelText('Data Field')).toBeVisible(); - expect(getByLabelText('Aggregation Type')).toBeVisible(); - expect(getByLabelText('Operator')).toBeVisible(); - expect(getByLabelText('Threshold')).toBeVisible(); - expect(getByLabelText('Evaluation Period')).toBeVisible(); - expect(getByLabelText('Polling Interval')).toBeVisible(); - expect(getByText('4. Notification Channels')).toBeVisible(); - }, - { timeout: 20000 } - ); - - it( - 'should submit form data correctly', - async () => { - navigate({ - to: '/alerts/definitions/edit/linode/1', - }); - const mutateAsyncSpy = queryMocks.useEditAlertDefinition().mutateAsync; - const { getByPlaceholderText, getByText } = renderWithTheme( + it('renders the components of the form', { timeout: 20000 }, async () => { + const { findByPlaceholderText, getByLabelText, getByText } = + renderWithTheme( ); - const descriptionValue = 'Updated Description'; - const nameValue = 'Updated Label'; - const nameInput = getByPlaceholderText('Enter a Name'); - const descriptionInput = getByPlaceholderText('Enter a Description'); - await userEvent.clear(nameInput); - await userEvent.clear(descriptionInput); - await userEvent.type(nameInput, nameValue); + expect(getByText('1. General Information')).toBeVisible(); + expect(getByLabelText('Name')).toBeVisible(); + expect(getByLabelText('Description (optional)')).toBeVisible(); + expect(getByLabelText('Severity')).toBeVisible(); + expect(getByLabelText('Service')).toBeVisible(); + expect(getByText('2. Entities')).toBeVisible(); + expect( + await findByPlaceholderText('Search for a Region or Entity') + ).toBeInTheDocument(); + expect(await findByPlaceholderText('Select Regions')).toBeInTheDocument(); + expect(getByText('3. Criteria')).toBeVisible(); + expect(getByText('Metric Threshold')).toBeVisible(); + expect(getByLabelText('Data Field')).toBeVisible(); + expect(getByLabelText('Aggregation Type')).toBeVisible(); + expect(getByLabelText('Operator')).toBeVisible(); + expect(getByLabelText('Threshold')).toBeVisible(); + expect(getByLabelText('Evaluation Period')).toBeVisible(); + expect(getByLabelText('Polling Interval')).toBeVisible(); + expect(getByText('4. Notification Channels')).toBeVisible(); + }); - await userEvent.type(descriptionInput, descriptionValue); + it('should submit form data correctly', { timeout: 10000 }, async () => { + navigate({ + to: '/alerts/definitions/edit/linode/1', + }); + const mutateAsyncSpy = queryMocks.useEditAlertDefinition().mutateAsync; + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + const descriptionValue = 'Updated Description'; + const nameValue = 'Updated Label'; + const nameInput = getByPlaceholderText('Enter a Name'); + const descriptionInput = getByPlaceholderText('Enter a Description'); + await userEvent.clear(nameInput); + await userEvent.clear(descriptionInput); + await userEvent.type(nameInput, nameValue); - await userEvent.click(getByText('Submit')); + await userEvent.type(descriptionInput, descriptionValue); - await waitFor(() => expect(mutateAsyncSpy).toHaveBeenCalledTimes(1)); + await userEvent.click(getByText('Submit')); - expect(navigate).toHaveBeenLastCalledWith({ - to: '/alerts/definitions', - }); - await waitFor(() => { - expect( - getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly - ).toBeInTheDocument(); - }); - }, - { timeout: 10000 } - ); + await waitFor(() => expect(mutateAsyncSpy).toHaveBeenCalledTimes(1)); + + expect(navigate).toHaveBeenLastCalledWith({ + to: '/alerts/definitions', + }); + await waitFor(() => { + expect( + getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly + ).toBeInTheDocument(); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 870ac5adbd4..4cb7b76999c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -4,10 +4,10 @@ import { act, renderHook } from '@testing-library/react'; import { alertFactory, serviceTypesFactory } from 'src/factories'; import { useContextualAlertsState } from '../../Utils/utils'; +import { transformDimensionValue } from '../CreateAlert/Criteria/DimensionFilterValue/utils'; import { alertDefinitionFormSchema } from '../CreateAlert/schemas'; import { alertsFromEnabledServices, - arraysEqual, convertAlertDefinitionValues, convertAlertsToTypeSet, convertSecondsToMinutes, @@ -17,7 +17,6 @@ import { getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, - transformDimensionValue, } from './utils'; import type { AlertValidationSchemaProps } from './utils'; @@ -498,27 +497,3 @@ describe('transformDimensionValue', () => { ).toBe('Test_value'); }); }); - -describe('arraysEqual', () => { - it('should return true when both arrays are empty', () => { - expect(arraysEqual([], [])).toBe(true); - }); - it('should return false when one array is empty and the other is not', () => { - expect(arraysEqual([], [1, 2, 3])).toBe(false); - }); - it('should return true when arrays are undefined', () => { - expect(arraysEqual(undefined, undefined)).toBe(true); - }); - it('should return false when one of the arrays is undefined', () => { - expect(arraysEqual(undefined, [1, 2, 3])).toBe(false); - }); - it('should return true when arrays are equal', () => { - expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true); - }); - it('should return false when arrays are not equal', () => { - expect(arraysEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false); - }); - it('should return true when arrays have same elements but in different order', () => { - expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true); - }); -}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index e756d6831e5..167b7a684f8 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -13,11 +13,6 @@ import { import type { FieldPath, FieldValues, UseFormSetError } from 'react-hook-form'; import { array, object, string } from 'yup'; -import { - DIMENSION_TRANSFORM_CONFIG, - TRANSFORMS, -} from '../../shared/DimensionTransform'; -import { compareArrays } from '../../Utils/FilterBuilder'; import { aggregationTypeMap, metricOperatorTypeMap } from '../constants'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; @@ -600,42 +595,3 @@ export const alertsFromEnabledServices = ( (alert) => aclpServices?.[alert.service_type]?.alerts?.enabled ?? false ); }; - -/** - * Transform a dimension value using the appropriate transform function - * @param serviceType - The cloud pulse service type - * @param dimensionLabel - The dimension label - * @param value - The value to transform - * @returns Transformed value - */ -export const transformDimensionValue = ( - serviceType: CloudPulseServiceType | null, - dimensionLabel: string, - value: string -): string => { - return ( - ( - serviceType && DIMENSION_TRANSFORM_CONFIG[serviceType]?.[dimensionLabel] - )?.(value) ?? TRANSFORMS.capitalize(value) - ); -}; - -/** - * Checks if two arrays are equal, ignores the order of the elements - * @param a The first array - * @param b The second array - * @returns True if the arrays are equal, false otherwise - */ -export const arraysEqual = ( - a: number[] | undefined, - b: number[] | undefined -) => { - if (a === undefined && b === undefined) return true; - if (a === undefined || b === undefined) return false; - if (a.length !== b.length) return false; - - return compareArrays( - [...a].sort((x, y) => x - y), - [...b].sort((x, y) => x - y) - ); -}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index e8148e9fcc6..876b4cc17db 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -10,11 +10,11 @@ import { } from 'src/queries/cloudpulse/services'; import { RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { useAclpPreference } from '../Utils/UserPreference'; import { getAssociatedEntityType, getResourcesFilterConfig, -} from '../Utils/utils'; +} from '../Utils/FilterConfig'; +import { useAclpPreference } from '../Utils/UserPreference'; import { renderPlaceHolder, RenderWidgets, diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts index 67706cdf8c1..7444f8bc916 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -2,7 +2,7 @@ import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboar import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; import { ASSOCIATED_ENTITY_METRIC_MAP } from '../Utils/constants'; -import { getAssociatedEntityType } from '../Utils/utils'; +import { getAssociatedEntityType } from '../Utils/FilterConfig'; import type { GroupByOption } from './CloudPulseGroupByDrawer'; import type { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index dab4641047f..5cc58111f72 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -10,7 +10,6 @@ import { import { RESOURCE_ID, RESOURCES } from './constants'; import { - deepEqual, filterBasedOnConfig, filterEndpointsUsingRegion, filterFirewallNodebalancers, @@ -33,6 +32,7 @@ import { } from './FilterBuilder'; import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import { deepEqual } from './utils'; import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index b63fdd76ca7..97fed2e4b40 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -6,9 +6,8 @@ import { RESOURCES, TAGS, } from './constants'; -import { FILTER_CONFIG } from './FilterConfig'; +import { FILTER_CONFIG, getAssociatedEntityType } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import { getAssociatedEntityType } from './utils'; import type { CloudPulseMetricsFilter, @@ -676,72 +675,6 @@ const getDependentFiltersByFilterKey = ( ); }; -/** - * @param obj1 The first object to be compared - * @param obj2 The second object to be compared - * @returns True if, both are equal else false - */ -export const deepEqual = (obj1: T, obj2: T): boolean => { - if (obj1 === obj2) { - return true; // Identical references or values - } - - // If either is null or undefined, or they are not of object type, return false - if ( - obj1 === null || - obj2 === null || - typeof obj1 !== 'object' || - typeof obj2 !== 'object' - ) { - return false; - } - - // Handle array comparison separately - if (Array.isArray(obj1) && Array.isArray(obj2)) { - return compareArrays(obj1, obj2); - } - - // Ensure both objects have the same number of keys - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) { - return false; - } - - // Recursively check each key - for (const key of keys1) { - if (!(key in obj2)) { - return false; - } - // Recursive deep equal check - if (!deepEqual((obj1 as any)[key], (obj2 as any)[key])) { - return false; - } - } - - return true; -}; - -/** - * @param arr1 Array for comparison - * @param arr2 Array for comparison - * @returns True if, both the arrays are equal, else false - */ -export const compareArrays = (arr1: T[], arr2: T[]): boolean => { - if (arr1.length !== arr2.length) { - return false; - } - - for (let i = 0; i < arr1.length; i++) { - if (!deepEqual(arr1[i], arr2[i])) { - return false; - } - } - - return true; -}; - /** * * @param dashboard dashboard for which filters to render diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts new file mode 100644 index 00000000000..1075a85fd5b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts @@ -0,0 +1,36 @@ +import { + getAssociatedEntityType, + getResourcesFilterConfig, +} from './FilterConfig'; + +describe('getResourcesFilterConfig', () => { + it('should return undefined if the dashboard id is not provided', () => { + expect(getResourcesFilterConfig(undefined)).toBeUndefined(); + }); + + it('should return the resources filter configuration for the linode-firewalldashboard', () => { + const resourcesFilterConfig = getResourcesFilterConfig(4); + expect(resourcesFilterConfig).toBeDefined(); + expect(resourcesFilterConfig?.associatedEntityType).toBe('linode'); + }); + + it('should return the resources filter configuration for the nodebalancer-firewall dashboard', () => { + const resourcesFilterConfig = getResourcesFilterConfig(8); + expect(resourcesFilterConfig).toBeDefined(); + expect(resourcesFilterConfig?.associatedEntityType).toBe('nodebalancer'); + }); +}); + +describe('getAssociatedEntityType', () => { + it('should return undefined if the dashboard id is not provided', () => { + expect(getAssociatedEntityType(undefined)).toBeUndefined(); + }); + + it('should return the associated entity type for the linode-firewall dashboard', () => { + expect(getAssociatedEntityType(4)).toBe('linode'); + }); + + it('should return the associated entity type for the nodebalancer-firewall dashboard', () => { + expect(getAssociatedEntityType(8)).toBe('nodebalancer'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 30f8922d5c2..1d4c19a56e0 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -13,6 +13,8 @@ import { import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import { filterKubernetesClusters } from './utils'; +import type { AssociatedEntityType } from '../shared/types'; +import type { CloudPulseServiceTypeFiltersConfiguration } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; import type { KubernetesCluster } from '@linode/api-v4'; @@ -521,3 +523,33 @@ export const FILTER_CONFIG: Readonly< [8, FIREWALL_NODEBALANCER_CONFIG], [9, LKE_CONFIG], ]); + +/** + * @param dashboardId The id of the dashboard + * @returns The resources filter configuration for the dashboard + */ +export const getResourcesFilterConfig = ( + dashboardId: number | undefined +): CloudPulseServiceTypeFiltersConfiguration | undefined => { + if (!dashboardId) { + return undefined; + } + // Get the associated entity type for the dashboard + const filterConfig = FILTER_CONFIG.get(dashboardId); + return filterConfig?.filters.find( + (filter) => filter.configuration.filterKey === RESOURCE_ID + )?.configuration; +}; + +/** + * @param dashboardId The id of the dashboard + * @returns The associated entity type for the dashboard + */ +export const getAssociatedEntityType = ( + dashboardId: number | undefined +): AssociatedEntityType | undefined => { + if (!dashboardId) { + return undefined; + } + return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index e2258ec14bd..dab6f0bea3f 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -24,12 +24,11 @@ import { import { arePortsValid, areValidInterfaceIds, + arraysEqual, filterFirewallResources, filterKubernetesClusters, - getAssociatedEntityType, getEnabledServiceTypes, getFilteredDimensions, - getResourcesFilterConfig, isValidFilter, isValidPort, useIsAclpSupportedRegion, @@ -352,38 +351,6 @@ describe('getEnabledServiceTypes', () => { expect(result).not.toContain('linode'); }); - describe('getResourcesFilterConfig', () => { - it('should return undefined if the dashboard id is not provided', () => { - expect(getResourcesFilterConfig(undefined)).toBeUndefined(); - }); - - it('should return the resources filter configuration for the linode-firewalldashboard', () => { - const resourcesFilterConfig = getResourcesFilterConfig(4); - expect(resourcesFilterConfig).toBeDefined(); - expect(resourcesFilterConfig?.associatedEntityType).toBe('linode'); - }); - - it('should return the resources filter configuration for the nodebalancer-firewall dashboard', () => { - const resourcesFilterConfig = getResourcesFilterConfig(8); - expect(resourcesFilterConfig).toBeDefined(); - expect(resourcesFilterConfig?.associatedEntityType).toBe('nodebalancer'); - }); - }); - - describe('getAssociatedEntityType', () => { - it('should return undefined if the dashboard id is not provided', () => { - expect(getAssociatedEntityType(undefined)).toBeUndefined(); - }); - - it('should return the associated entity type for the linode-firewall dashboard', () => { - expect(getAssociatedEntityType(4)).toBe('linode'); - }); - - it('should return the associated entity type for the nodebalancer-firewall dashboard', () => { - expect(getAssociatedEntityType(8)).toBe('nodebalancer'); - }); - }); - describe('filterFirewallResources', () => { it('should return the filtered firewall resources for linode', () => { const resources = [ @@ -718,3 +685,27 @@ describe('getFilteredDimensions', () => { expect(result).toEqual([]); }); }); + +describe('arraysEqual', () => { + it('should return true when both arrays are empty', () => { + expect(arraysEqual([], [])).toBe(true); + }); + it('should return false when one array is empty and the other is not', () => { + expect(arraysEqual([], [1, 2, 3])).toBe(false); + }); + it('should return true when arrays are undefined', () => { + expect(arraysEqual(undefined, undefined)).toBe(true); + }); + it('should return false when one of the arrays is undefined', () => { + expect(arraysEqual(undefined, [1, 2, 3])).toBe(false); + }); + it('should return true when arrays are equal', () => { + expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true); + }); + it('should return false when arrays are not equal', () => { + expect(arraysEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false); + }); + it('should return true when arrays have same elements but in different order', () => { + expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 2c2f6481d44..5978318658b 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -7,7 +7,6 @@ import { useFlags } from 'src/hooks/useFlags'; import { valueFieldConfig } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; import { getOperatorGroup } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/utils'; -import { arraysEqual } from '../Alerts/Utils/utils'; import { INTERFACE_ID, INTERFACE_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE, @@ -21,14 +20,11 @@ import { PORTS_LEADING_ZERO_ERROR_MESSAGE, PORTS_LIMIT_ERROR_MESSAGE, PORTS_RANGE_ERROR_MESSAGE, - RESOURCE_ID, } from './constants'; -import { FILTER_CONFIG } from './FilterConfig'; import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; import type { AssociatedEntityType } from '../shared/types'; import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; -import type { CloudPulseServiceTypeFiltersConfiguration } from './models'; import type { Alert, APIError, @@ -534,36 +530,6 @@ export const getFilteredDimensions = ( : []; }; -/** - * @param dashboardId The id of the dashboard - * @returns The resources filter configuration for the dashboard - */ -export const getResourcesFilterConfig = ( - dashboardId: number | undefined -): CloudPulseServiceTypeFiltersConfiguration | undefined => { - if (!dashboardId) { - return undefined; - } - // Get the associated entity type for the dashboard - const filterConfig = FILTER_CONFIG.get(dashboardId); - return filterConfig?.filters.find( - (filter) => filter.configuration.filterKey === RESOURCE_ID - )?.configuration; -}; - -/** - * @param dashboardId The id of the dashboard - * @returns The associated entity type for the dashboard - */ -export const getAssociatedEntityType = ( - dashboardId: number | undefined -): AssociatedEntityType | undefined => { - if (!dashboardId) { - return undefined; - } - return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; -}; - /** * * @param resources Firewall resources @@ -600,3 +566,89 @@ export const filterKubernetesClusters = ( .filter(({ tier }) => tier === 'enterprise') .sort((a, b) => a.label.localeCompare(b.label)); }; + +/** + * @param obj1 The first object to be compared + * @param obj2 The second object to be compared + * @returns True if, both are equal else false + */ +export const deepEqual = (obj1: T, obj2: T): boolean => { + if (obj1 === obj2) { + return true; // Identical references or values + } + + // If either is null or undefined, or they are not of object type, return false + if ( + obj1 === null || + obj2 === null || + typeof obj1 !== 'object' || + typeof obj2 !== 'object' + ) { + return false; + } + + // Handle array comparison separately + if (Array.isArray(obj1) && Array.isArray(obj2)) { + return compareArrays(obj1, obj2); + } + + // Ensure both objects have the same number of keys + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) { + return false; + } + + // Recursively check each key + for (const key of keys1) { + if (!(key in obj2)) { + return false; + } + // Recursive deep equal check + if (!deepEqual((obj1 as any)[key], (obj2 as any)[key])) { + return false; + } + } + + return true; +}; + +/** + * @param arr1 Array for comparison + * @param arr2 Array for comparison + * @returns True if, both the arrays are equal, else false + */ +export const compareArrays = (arr1: T[], arr2: T[]): boolean => { + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (!deepEqual(arr1[i], arr2[i])) { + return false; + } + } + + return true; +}; + +/** + * Checks if two arrays are equal, ignores the order of the elements + * @param a The first array + * @param b The second array + * @returns True if the arrays are equal, false otherwise + */ +export const arraysEqual = ( + a: number[] | undefined, + b: number[] | undefined +) => { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + if (a.length !== b.length) return false; + + return compareArrays( + [...a].sort((x, y) => x - y), + [...b].sort((x, y) => x - y) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index a0578ea08b3..382ed64eb92 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -5,7 +5,8 @@ import React, { useMemo } from 'react'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx index 0833c8d4d92..6c19faebee9 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterFirewallNodebalancers } from '../Utils/FilterBuilder'; -import { getAssociatedEntityType } from '../Utils/utils'; +import { filterFirewallNodebalancers } from '../Utils/FilterBuilder'; +import { getAssociatedEntityType } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 5b385bd2c2f..6dd24061ec6 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -12,9 +12,9 @@ import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP, } from '../Utils/constants'; -import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; -import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { filterUsingDependentFilters } from '../Utils/FilterBuilder'; +import { FILTER_CONFIG, getResourcesFilterConfig } from '../Utils/FilterConfig'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 13c07000735..c38398e5f3a 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -6,7 +6,8 @@ import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { RESOURCE_FILTER_MAP } from '../Utils/constants'; -import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; +import { filterUsingDependentFilters } from '../Utils/FilterBuilder'; +import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index ae1512add65..a3a7fcc59f2 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'; import { kubeLinodeFactory, nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; import { compareByKubernetesVersion, @@ -22,12 +23,11 @@ const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), useGrants: vi.fn().mockReturnValue({}), useAccountBetaQuery: vi.fn().mockReturnValue({}), - useFlags: vi.fn().mockReturnValue({}), useKubernetesTieredVersionsQuery: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', () => { - const actual = vi.importActual('@linode/queries'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccount: queryMocks.useAccount, @@ -36,14 +36,6 @@ vi.mock('@linode/queries', () => { }; }); -vi.mock('src/hooks/useFlags', () => { - const actual = vi.importActual('src/hooks/useFlags'); - return { - ...actual, - useFlags: queryMocks.useFlags, - }; -}); - vi.mock('src/queries/kubernetes', () => { const actual = vi.importActual('src/queries/kubernetes'); return { @@ -225,11 +217,10 @@ describe('helper functions', () => { queryMocks.useAccountBetaQuery.mockReturnValue({ data: accountBeta, }); - queryMocks.useFlags.mockReturnValue({ - apl: true, - }); - const { result } = renderHook(() => useAPLAvailability()); + const { result } = renderHook(() => useAPLAvailability(), { + wrapper: (ui) => wrapWithTheme(ui, { flags: { apl: true } }), + }); expect(result.current.showAPL).toBe(true); }); }); @@ -352,17 +343,21 @@ describe('hooks', () => { capabilities: [], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: true, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: true, @@ -380,17 +375,21 @@ describe('hooks', () => { capabilities: ['Kubernetes Enterprise'], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: false, dualStack: false }, - postLa: false, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: false, dualStack: false }, + postLa: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -412,17 +411,21 @@ describe('hooks', () => { ], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: false, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -440,17 +443,20 @@ describe('hooks', () => { capabilities: ['Kubernetes Enterprise'], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: false, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: false, - }, + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: false, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: false, + }, + }, + }), }); - - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: false, isLkeEnterpriseGAFlagEnabled: false, @@ -472,17 +478,21 @@ describe('hooks', () => { ], }, }); - queryMocks.useFlags.mockReturnValue({ - lkeEnterprise2: { - enabled: true, - ga: true, - la: true, - phase2Mtc: { byoVPC: true, dualStack: true }, - postLa: true, - }, - }); - const { result } = renderHook(() => useIsLkeEnterpriseEnabled()); + const { result } = renderHook(() => useIsLkeEnterpriseEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + lkeEnterprise2: { + enabled: true, + ga: true, + la: true, + phase2Mtc: { byoVPC: true, dualStack: true }, + postLa: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isLkeEnterpriseGAFeatureEnabled: true, isLkeEnterpriseGAFlagEnabled: true, diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts index c83ba1a4698..d66d4ced090 100644 --- a/packages/manager/src/features/PlacementGroups/utils.test.ts +++ b/packages/manager/src/features/PlacementGroups/utils.test.ts @@ -15,25 +15,16 @@ import { const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), - useFlags: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', () => { - const actual = vi.importActual('@linode/queries'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccount: queryMocks.useAccount, }; }); -vi.mock('src/hooks/useFlags', () => { - const actual = vi.importActual('src/hooks/useFlags'); - return { - ...actual, - useFlags: queryMocks.useFlags, - }; -}); - const initialLinodeData = [ { is_compliant: true, @@ -228,11 +219,6 @@ describe('useIsPlacementGroupsEnabled', () => { }); it('returns false if the account does not have the Placement Group capability', () => { - queryMocks.useFlags.mockReturnValue({ - placementGroups: { - enabled: true, - }, - }); queryMocks.useAccount.mockReturnValue({ data: { capabilities: [], diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index 0f951a04cf1..4980ae8a967 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -1,8 +1,6 @@ import { PLACEMENT_GROUP_TYPES } from '@linode/api-v4/lib/placement-groups'; import { useAccount } from '@linode/queries'; -import { useFlags } from 'src/hooks/useFlags'; - import type { CreatePlacementGroupPayload, Linode, @@ -125,12 +123,7 @@ export const getLinodesFromAllPlacementGroups = ( export const useIsPlacementGroupsEnabled = (): { isPlacementGroupsEnabled: boolean; } => { - const { data: account, error } = useAccount(); - const flags = useFlags(); - - if (error || !flags) { - return { isPlacementGroupsEnabled: false }; - } + const { data: account } = useAccount(); const isPlacementGroupsEnabled = Boolean( account?.capabilities?.includes('Placement Group') diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index c1a01cda5af..53e3f848a36 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -57,6 +57,7 @@ describe('Create API Token Drawer', () => { it( 'Should see secret modal with secret when you type a label and submit the form successfully', + { timeout: 15000 }, async () => { server.use( http.post('*/profile/tokens', () => { @@ -85,8 +86,7 @@ describe('Create API Token Drawer', () => { await waitFor(() => expect(props.showSecret).toBeCalledWith('secret-value') ); - }, - { timeout: 15000 } + } ); it('Should default to no selection for all scopes', () => { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index 27781782729..cb3cefbb795 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -269,6 +269,7 @@ describe('VPC Subnets table', () => { it( 'should show Nodebalancer table head data when table is expanded', + { timeout: 15_000 }, async () => { const subnet = subnetFactory.build(); @@ -293,8 +294,7 @@ describe('VPC Subnets table', () => { await findByText('NodeBalancer'); await findByText('Backend Status'); await findByText('VPC IPv4 Range'); - }, - { timeout: 15_000 } + } ); it('should disable "Create Subnet" button when user does not have create_vpc_subnet permission', async () => { diff --git a/packages/manager/src/features/components/PlansPanel/utils.test.ts b/packages/manager/src/features/components/PlansPanel/utils.test.ts index 13f7e2fdb8b..cbbd57b504e 100644 --- a/packages/manager/src/features/components/PlansPanel/utils.test.ts +++ b/packages/manager/src/features/components/PlansPanel/utils.test.ts @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'; import { extendedTypes } from 'src/__data__/ExtendedType'; import { planSelectionTypeFactory, typeFactory } from 'src/factories/types'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; import { PLAN_IS_CURRENTLY_UNAVAILABLE_COPY } from './constants'; import { @@ -20,25 +21,16 @@ import type { PlanSelectionType } from './types'; const queryMocks = vi.hoisted(() => ({ useAccount: vi.fn().mockReturnValue({}), - useFlags: vi.fn().mockReturnValue({}), })); -vi.mock('@linode/queries', () => { - const actual = vi.importActual('@linode/queries'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccount: queryMocks.useAccount, }; }); -vi.mock('src/hooks/useFlags', () => { - const actual = vi.importActual('src/hooks/useFlags'); - return { - ...actual, - useFlags: queryMocks.useFlags, - }; -}); - const standard = typeFactory.build({ class: 'standard', id: 'g6-standard-1' }); const metal = typeFactory.build({ class: 'metal', id: 'g6-metal-alpha-2' }); const dedicated = typeFactory.build({ @@ -563,14 +555,18 @@ describe('useIsAcceleratedPlansEnabled', () => { capabilities: [], }, }); - queryMocks.useFlags.mockReturnValue({ - acceleratedPlans: { - linodePlans: false, - lkePlans: false, - }, - }); - const { result } = renderHook(() => useIsAcceleratedPlansEnabled()); + const { result } = renderHook(() => useIsAcceleratedPlansEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + acceleratedPlans: { + linodePlans: false, + lkePlans: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isAcceleratedLKEPlansEnabled: false, isAcceleratedLinodePlansEnabled: false, @@ -583,14 +579,18 @@ describe('useIsAcceleratedPlansEnabled', () => { capabilities: [], }, }); - queryMocks.useFlags.mockReturnValue({ - acceleratedPlans: { - linodePlans: true, - lkePlans: true, - }, - }); - const { result } = renderHook(() => useIsAcceleratedPlansEnabled()); + const { result } = renderHook(() => useIsAcceleratedPlansEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + acceleratedPlans: { + linodePlans: true, + lkePlans: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isAcceleratedLKEPlansEnabled: false, isAcceleratedLinodePlansEnabled: false, @@ -603,14 +603,18 @@ describe('useIsAcceleratedPlansEnabled', () => { capabilities: ['NETINT Quadra T1U'], }, }); - queryMocks.useFlags.mockReturnValue({ - acceleratedPlans: { - linodePlans: false, - lkePlans: false, - }, - }); - const { result } = renderHook(() => useIsAcceleratedPlansEnabled()); + const { result } = renderHook(() => useIsAcceleratedPlansEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + acceleratedPlans: { + linodePlans: false, + lkePlans: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isAcceleratedLKEPlansEnabled: false, isAcceleratedLinodePlansEnabled: false, @@ -623,14 +627,18 @@ describe('useIsAcceleratedPlansEnabled', () => { capabilities: ['NETINT Quadra T1U'], }, }); - queryMocks.useFlags.mockReturnValue({ - acceleratedPlans: { - linodePlans: true, - lkePlans: true, - }, - }); - const { result } = renderHook(() => useIsAcceleratedPlansEnabled()); + const { result } = renderHook(() => useIsAcceleratedPlansEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + acceleratedPlans: { + linodePlans: true, + lkePlans: true, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isAcceleratedLKEPlansEnabled: true, isAcceleratedLinodePlansEnabled: true, @@ -644,14 +652,18 @@ describe('useIsAcceleratedPlansEnabled', () => { capabilities: ['NETINT Quadra T1U'], }, }); - queryMocks.useFlags.mockReturnValue({ - acceleratedPlans: { - linodePlans: true, - lkePlans: false, - }, - }); - const { result } = renderHook(() => useIsAcceleratedPlansEnabled()); + const { result } = renderHook(() => useIsAcceleratedPlansEnabled(), { + wrapper: (ui) => + wrapWithTheme(ui, { + flags: { + acceleratedPlans: { + linodePlans: true, + lkePlans: false, + }, + }, + }), + }); expect(result.current).toStrictEqual({ isAcceleratedLKEPlansEnabled: false, isAcceleratedLinodePlansEnabled: true, diff --git a/packages/manager/src/utilities/analytics/utils.test.ts b/packages/manager/src/utilities/analytics/utils.test.ts index d2ec1e9b138..916d16855f4 100644 --- a/packages/manager/src/utilities/analytics/utils.test.ts +++ b/packages/manager/src/utilities/analytics/utils.test.ts @@ -120,13 +120,13 @@ describe('waitForAdobeAnalyticsToBeLoaded', () => { it( 'should reject if adobe is not defined after 5 seconds', + { timeout: 7000 }, () => { vi.stubGlobal('_satellite', undefined); expect(waitForAdobeAnalyticsToBeLoaded()).rejects.toThrow( 'Adobe Analytics did not load after 5 seconds' ); - }, - { timeout: 7000 } + } ); }); diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index da562e52401..e09e6b07809 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ port: 3000, }, test: { + include: ['**/*.test.{js,jsx,ts,tsx}'], coverage: { exclude: [ 'src/**/*.constants.{js,jsx,ts,tsx}', @@ -44,7 +45,6 @@ export default defineConfig({ }, environment: 'jsdom', globals: true, - pool: 'forks', setupFiles: './src/testSetup.ts', }, }); diff --git a/packages/search/vitest.config.ts b/packages/search/vitest.config.ts new file mode 100644 index 00000000000..94ede10e225 --- /dev/null +++ b/packages/search/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({}); diff --git a/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md b/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md new file mode 100644 index 00000000000..00ff19841a8 --- /dev/null +++ b/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md @@ -0,0 +1,5 @@ +--- +"@linode/utilities": Tech Stories +--- + +Update `scrollErrorIntoViewV2.test.tsx‎` to not mock MutationObserver` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/utilities/src/helpers/scrollErrorIntoViewV2.test.tsx b/packages/utilities/src/helpers/scrollErrorIntoViewV2.test.tsx index d0e875c1a58..1259601381b 100644 --- a/packages/utilities/src/helpers/scrollErrorIntoViewV2.test.tsx +++ b/packages/utilities/src/helpers/scrollErrorIntoViewV2.test.tsx @@ -1,47 +1,32 @@ +import { waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { scrollErrorIntoViewV2 } from './scrollErrorIntoViewV2'; -import type { Mock } from 'vitest'; - describe('scrollErrorIntoViewV2', () => { - it('should scroll to the error element when it exists', () => { + it('should scroll to the error element when it exists', async () => { window.HTMLElement.prototype.scrollIntoView = vi.fn(); const errorElement = document.createElement('div'); errorElement.classList.add('error-for-scroll'); const formContainer = document.createElement('div'); - formContainer.appendChild(errorElement); const formContainerRef = { current: formContainer, }; - const observeMock = vi.fn(); - const disconnectMock = vi.fn(); - const takeRecords = vi.fn(); - window.MutationObserver = vi.fn(() => ({ - disconnect: disconnectMock, - observe: observeMock, - takeRecords, - })); - scrollErrorIntoViewV2(formContainerRef); - expect(observeMock).toHaveBeenCalledWith(formContainer, { - attributes: true, - childList: true, - subtree: true, - }); + expect(errorElement.scrollIntoView).not.toHaveBeenCalled(); - const mutationCallback = (window.MutationObserver as Mock).mock.calls[0][0]; - mutationCallback([{ target: formContainer, type: 'childList' }]); + formContainer.appendChild(errorElement); - expect(errorElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - inline: 'nearest', + await waitFor(() => { + expect(errorElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); }); - expect(disconnectMock).toHaveBeenCalled(); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e99605e90e6..2002db8f2e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: ^8.38.0 version: 8.38.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) '@vitest/ui': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + specifier: ^4.0.10 + version: 4.0.10(vitest@4.0.10) concurrently: specifier: 9.1.0 version: 9.1.0 @@ -85,8 +85,8 @@ importers: specifier: ^8.29.0 version: 8.29.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.7.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^4.0.10 + version: 4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) packages/api-v4: dependencies: @@ -106,6 +106,9 @@ importers: '@linode/tsconfig': specifier: workspace:* version: link:../tsconfig + '@types/node': + specifier: ^22.13.14 + version: 22.18.1 axios-mock-adapter: specifier: ^1.22.0 version: 1.22.0(axios@1.12.0) @@ -358,7 +361,7 @@ importers: version: 9.0.12(@types/react@19.1.6)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@swc/core': specifier: ^1.10.9 version: 1.10.11 @@ -442,10 +445,10 @@ importers: version: 4.4.5 '@vitejs/plugin-react-swc': specifier: ^4.0.1 - version: 4.0.1(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.0.1(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@4.0.10) '@vueless/storybook-dark-mode': specifier: ^9.0.5 version: 9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -454,7 +457,7 @@ importers: version: 4.10.2 chai-string: specifier: ^1.5.0 - version: 1.5.0(chai@5.2.0) + version: 1.5.0(chai@6.2.1) concurrently: specifier: ^9.1.0 version: 9.1.0 @@ -484,7 +487,7 @@ importers: version: 1.14.0(cypress@15.4.0) cypress-vite: specifier: ^1.8.0 - version: 1.8.0(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 1.8.0(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -519,11 +522,11 @@ importers: specifier: ^9.0.12 version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite: - specifier: ^7.1.11 - version: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + specifier: ^7.2.2 + version: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.53.3)(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/queries: dependencies: @@ -575,7 +578,7 @@ importers: version: 4.2.0 vite: specifier: '*' - version: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + version: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) devDependencies: '@linode/tsconfig': specifier: workspace:* @@ -607,7 +610,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -631,7 +634,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.53.3)(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/tsconfig: {} @@ -676,7 +679,7 @@ importers: version: link:../tsconfig '@storybook/react-vite': specifier: ^9.0.12 - version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.0 @@ -703,7 +706,7 @@ importers: version: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + version: 4.5.0(rollup@4.53.3)(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) packages/utilities: dependencies: @@ -1059,28 +1062,34 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.3': - resolution: {integrity: sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.9': - resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + '@esbuild/aix-ppc64@0.25.3': + resolution: {integrity: sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.3': resolution: {integrity: sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.9': - resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [arm] os: [android] '@esbuild/android-arm@0.25.3': @@ -1089,10 +1098,10 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.9': - resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} - cpu: [arm] + cpu: [x64] os: [android] '@esbuild/android-x64@0.25.3': @@ -1101,11 +1110,11 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.9': - resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} - cpu: [x64] - os: [android] + cpu: [arm64] + os: [darwin] '@esbuild/darwin-arm64@0.25.3': resolution: {integrity: sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==} @@ -1113,10 +1122,10 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.9': - resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [darwin] '@esbuild/darwin-x64@0.25.3': @@ -1125,11 +1134,11 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.9': - resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} - cpu: [x64] - os: [darwin] + cpu: [arm64] + os: [freebsd] '@esbuild/freebsd-arm64@0.25.3': resolution: {integrity: sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==} @@ -1137,10 +1146,10 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.9': - resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [freebsd] '@esbuild/freebsd-x64@0.25.3': @@ -1149,11 +1158,11 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.9': - resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] + cpu: [arm64] + os: [linux] '@esbuild/linux-arm64@0.25.3': resolution: {integrity: sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==} @@ -1161,10 +1170,10 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.9': - resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [arm] os: [linux] '@esbuild/linux-arm@0.25.3': @@ -1173,10 +1182,10 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.9': - resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} - cpu: [arm] + cpu: [ia32] os: [linux] '@esbuild/linux-ia32@0.25.3': @@ -1185,10 +1194,10 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.9': - resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} - cpu: [ia32] + cpu: [loong64] os: [linux] '@esbuild/linux-loong64@0.25.3': @@ -1197,10 +1206,10 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.9': - resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} - cpu: [loong64] + cpu: [mips64el] os: [linux] '@esbuild/linux-mips64el@0.25.3': @@ -1209,10 +1218,10 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.9': - resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} - cpu: [mips64el] + cpu: [ppc64] os: [linux] '@esbuild/linux-ppc64@0.25.3': @@ -1221,10 +1230,10 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.9': - resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} - cpu: [ppc64] + cpu: [riscv64] os: [linux] '@esbuild/linux-riscv64@0.25.3': @@ -1233,10 +1242,10 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.9': - resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} - cpu: [riscv64] + cpu: [s390x] os: [linux] '@esbuild/linux-s390x@0.25.3': @@ -1245,10 +1254,10 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.9': - resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} - cpu: [s390x] + cpu: [x64] os: [linux] '@esbuild/linux-x64@0.25.3': @@ -1257,11 +1266,11 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.9': - resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} - cpu: [x64] - os: [linux] + cpu: [arm64] + os: [netbsd] '@esbuild/netbsd-arm64@0.25.3': resolution: {integrity: sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==} @@ -1269,10 +1278,10 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.9': - resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [netbsd] '@esbuild/netbsd-x64@0.25.3': @@ -1281,11 +1290,11 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.9': - resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] + cpu: [arm64] + os: [openbsd] '@esbuild/openbsd-arm64@0.25.3': resolution: {integrity: sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==} @@ -1293,10 +1302,10 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.9': - resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [openbsd] '@esbuild/openbsd-x64@0.25.3': @@ -1305,62 +1314,56 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.9': - resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.9': - resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.3': - resolution: {integrity: sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.9': - resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + '@esbuild/sunos-x64@0.25.3': + resolution: {integrity: sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.3': - resolution: {integrity: sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.9': - resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + '@esbuild/win32-arm64@0.25.3': + resolution: {integrity: sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.3': - resolution: {integrity: sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.9': - resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + '@esbuild/win32-ia32@0.25.3': + resolution: {integrity: sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.3': - resolution: {integrity: sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.9': - resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + '@esbuild/win32-x64@0.25.3': + resolution: {integrity: sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1947,8 +1950,8 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.50.1': - resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] os: [android] @@ -1957,8 +1960,8 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.50.1': - resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} cpu: [arm64] os: [android] @@ -1967,8 +1970,8 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.50.1': - resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} cpu: [arm64] os: [darwin] @@ -1977,8 +1980,8 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.50.1': - resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} cpu: [x64] os: [darwin] @@ -1987,8 +1990,8 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.50.1': - resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} cpu: [arm64] os: [freebsd] @@ -1997,8 +2000,8 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.50.1': - resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} cpu: [x64] os: [freebsd] @@ -2007,8 +2010,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': - resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] @@ -2017,8 +2020,8 @@ packages: cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.50.1': - resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] @@ -2027,8 +2030,8 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.50.1': - resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] @@ -2037,18 +2040,18 @@ packages: cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.50.1': - resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': - resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': - resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + '@rollup/rollup-linux-loongarch64-gnu@4.40.1': + resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==} cpu: [loong64] os: [linux] @@ -2057,8 +2060,8 @@ packages: cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.50.1': - resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] @@ -2067,8 +2070,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.50.1': - resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] @@ -2077,8 +2080,8 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.50.1': - resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] @@ -2087,8 +2090,8 @@ packages: cpu: [s390x] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.50.1': - resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] @@ -2097,8 +2100,8 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.50.1': - resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] @@ -2107,13 +2110,13 @@ packages: cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.50.1': - resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.50.1': - resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] @@ -2122,8 +2125,8 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.50.1': - resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} cpu: [arm64] os: [win32] @@ -2132,18 +2135,23 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.50.1': - resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.40.1': resolution: {integrity: sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.50.1': - resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} cpu: [x64] os: [win32] @@ -2198,6 +2206,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@storybook/addon-a11y@9.0.12': resolution: {integrity: sha512-xdJPYNxYU6A3DA48h6y0o3XziCp4YDGXcFKkc5Ce1GPFCa7ebFFh2trHqzevoFSGdQxWc5M3W0A4dhQtkpT4Ww==} peerDependencies: @@ -2903,14 +2914,14 @@ packages: '@vitest/expect@3.0.9': resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.10': + resolution: {integrity: sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.0.10': + resolution: {integrity: sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true @@ -2920,31 +2931,31 @@ packages: '@vitest/pretty-format@3.0.9': resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.10': + resolution: {integrity: sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.10': + resolution: {integrity: sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.10': + resolution: {integrity: sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==} '@vitest/spy@3.0.9': resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.10': + resolution: {integrity: sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==} - '@vitest/ui@3.2.4': - resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==} + '@vitest/ui@4.0.10': + resolution: {integrity: sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==} peerDependencies: - vitest: 3.2.4 + vitest: 4.0.10 '@vitest/utils@3.0.9': resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.10': + resolution: {integrity: sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==} '@vueless/storybook-dark-mode@9.0.5': resolution: {integrity: sha512-JU0bQe+KHvmg04k2yprzVkM0d8xdKwqFaFuQmO7afIUm//ttroDpfHfPzwLZuTDW9coB5bt2+qMSHZOBbt0w4g==} @@ -3142,6 +3153,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@3.0.1: + resolution: {integrity: sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==} + engines: {node: '>= 16'} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -3169,6 +3184,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@4.0.1: + resolution: {integrity: sha512-YClrbvTCXGe70pU2JiEiPLYXO9gQkyxYeKpJIQHVS/gOs6EWMQP2RYBwjFLNT322Ji8TOC3IMPfsYCedNpzKfA==} + engines: {node: '>= 18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -3264,6 +3283,10 @@ packages: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -3636,6 +3659,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -3801,13 +3833,13 @@ packages: peerDependencies: esbuild: '>=0.12 <1' - esbuild@0.25.3: - resolution: {integrity: sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true - esbuild@0.25.9: - resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + esbuild@0.25.3: + resolution: {integrity: sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==} engines: {node: '>=18'} hasBin: true @@ -3973,8 +4005,8 @@ packages: resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} engines: {node: '>=4'} - expect-type@1.2.1: - resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} extend@3.0.2: @@ -4651,6 +4683,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} @@ -4881,6 +4917,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -5623,8 +5662,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.50.1: - resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5752,8 +5791,8 @@ packages: simple-git@3.27.0: resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==} - sirv@3.0.1: - resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} slice-ansi@3.0.0: @@ -5818,6 +5857,9 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -5907,9 +5949,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-literal@3.0.0: - resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} - stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -6004,28 +6043,20 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tinyspy@4.0.3: - resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} tldts-core@6.1.61: @@ -6300,58 +6331,13 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite-plugin-svgr@4.5.0: resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: vite: '>=2.6.0' - vite@7.1.11: - resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.3.0 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vite@7.1.3: - resolution: {integrity: sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==} + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6390,16 +6376,18 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.0.10: + resolution: {integrity: sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.10 + '@vitest/browser-preview': 4.0.10 + '@vitest/browser-webdriverio': 4.0.10 + '@vitest/ui': 4.0.10 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -6409,7 +6397,11 @@ packages: optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -6965,159 +6957,159 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.25.3': optional: true - '@esbuild/aix-ppc64@0.25.9': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.25.3': optional: true - '@esbuild/android-arm64@0.25.9': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.25.3': optional: true - '@esbuild/android-arm@0.25.9': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.25.3': optional: true - '@esbuild/android-x64@0.25.9': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.25.3': optional: true - '@esbuild/darwin-arm64@0.25.9': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.25.3': optional: true - '@esbuild/darwin-x64@0.25.9': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.25.3': optional: true - '@esbuild/freebsd-arm64@0.25.9': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.25.3': optional: true - '@esbuild/freebsd-x64@0.25.9': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.25.3': optional: true - '@esbuild/linux-arm64@0.25.9': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.25.3': optional: true - '@esbuild/linux-arm@0.25.9': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.25.3': optional: true - '@esbuild/linux-ia32@0.25.9': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.25.3': optional: true - '@esbuild/linux-loong64@0.25.9': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.25.3': optional: true - '@esbuild/linux-mips64el@0.25.9': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.25.3': optional: true - '@esbuild/linux-ppc64@0.25.9': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.25.3': optional: true - '@esbuild/linux-riscv64@0.25.9': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.25.3': optional: true - '@esbuild/linux-s390x@0.25.9': + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.25.3': optional: true - '@esbuild/linux-x64@0.25.9': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.3': optional: true - '@esbuild/netbsd-arm64@0.25.9': + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.25.3': optional: true - '@esbuild/netbsd-x64@0.25.9': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.3': optional: true - '@esbuild/openbsd-arm64@0.25.9': + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.25.3': optional: true - '@esbuild/openbsd-x64@0.25.9': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.9': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.25.3': optional: true - '@esbuild/sunos-x64@0.25.9': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.25.3': optional: true - '@esbuild/win32-arm64@0.25.9': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.25.3': optional: true - '@esbuild/win32-ia32@0.25.9': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.25.3': optional: true - '@esbuild/win32-x64@0.25.9': - optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.31.0(jiti@2.4.2))': dependencies: eslint: 9.31.0(jiti@2.4.2) @@ -7350,12 +7342,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.0(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: glob: 10.5.0 magic-string: 0.30.17 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) optionalDependencies: typescript: 5.7.3 @@ -7664,143 +7656,146 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/pluginutils@5.1.3(rollup@4.50.1)': + '@rollup/pluginutils@5.1.3(rollup@4.53.3)': dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.50.1 + rollup: 4.53.3 - '@rollup/pluginutils@5.2.0(rollup@4.50.1)': + '@rollup/pluginutils@5.2.0(rollup@4.53.3)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.50.1 + rollup: 4.53.3 '@rollup/rollup-android-arm-eabi@4.40.1': optional: true - '@rollup/rollup-android-arm-eabi@4.50.1': + '@rollup/rollup-android-arm-eabi@4.53.3': optional: true '@rollup/rollup-android-arm64@4.40.1': optional: true - '@rollup/rollup-android-arm64@4.50.1': + '@rollup/rollup-android-arm64@4.53.3': optional: true '@rollup/rollup-darwin-arm64@4.40.1': optional: true - '@rollup/rollup-darwin-arm64@4.50.1': + '@rollup/rollup-darwin-arm64@4.53.3': optional: true '@rollup/rollup-darwin-x64@4.40.1': optional: true - '@rollup/rollup-darwin-x64@4.50.1': + '@rollup/rollup-darwin-x64@4.53.3': optional: true '@rollup/rollup-freebsd-arm64@4.40.1': optional: true - '@rollup/rollup-freebsd-arm64@4.50.1': + '@rollup/rollup-freebsd-arm64@4.53.3': optional: true '@rollup/rollup-freebsd-x64@4.40.1': optional: true - '@rollup/rollup-freebsd-x64@4.50.1': + '@rollup/rollup-freebsd-x64@4.53.3': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.40.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true '@rollup/rollup-linux-arm-musleabihf@4.40.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.50.1': + '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true '@rollup/rollup-linux-arm64-gnu@4.40.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.50.1': + '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true '@rollup/rollup-linux-arm64-musl@4.40.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.50.1': + '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.40.1': + '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + '@rollup/rollup-linux-loongarch64-gnu@4.40.1': optional: true '@rollup/rollup-linux-powerpc64le-gnu@4.40.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.50.1': + '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true '@rollup/rollup-linux-riscv64-gnu@4.40.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.50.1': + '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true '@rollup/rollup-linux-riscv64-musl@4.40.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.50.1': + '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true '@rollup/rollup-linux-s390x-gnu@4.40.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.50.1': + '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true '@rollup/rollup-linux-x64-gnu@4.40.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.50.1': + '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true '@rollup/rollup-linux-x64-musl@4.40.1': optional: true - '@rollup/rollup-linux-x64-musl@4.50.1': + '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.50.1': + '@rollup/rollup-openharmony-arm64@4.53.3': optional: true '@rollup/rollup-win32-arm64-msvc@4.40.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.50.1': + '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true '@rollup/rollup-win32-ia32-msvc@4.40.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.50.1': + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true '@rollup/rollup-win32-x64-msvc@4.40.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.50.1': + '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true '@sentry-internal/browser-utils@9.19.0': @@ -7871,6 +7866,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.0.0': {} + '@storybook/addon-a11y@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': dependencies: '@storybook/global': 5.0.0 @@ -7890,12 +7887,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/builder-vite@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@storybook/csf-plugin': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3)) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) ts-dedent: 2.2.0 - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@storybook/csf-plugin@9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))': dependencies: @@ -7920,11 +7917,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) - '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.50.1)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@storybook/react-vite@9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.53.3)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@rollup/pluginutils': 5.1.3(rollup@4.50.1) - '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.0(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@rollup/pluginutils': 5.1.3(rollup@4.53.3) + '@storybook/builder-vite': 9.0.12(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) '@storybook/react': 9.0.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3))(typescript@5.7.3) find-up: 7.0.0 magic-string: 0.30.17 @@ -7934,7 +7931,7 @@ snapshots: resolve: 1.22.8 storybook: 9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3) tsconfig-paths: 4.2.0 - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color @@ -8602,15 +8599,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.0.1(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@4.0.1(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.32 '@swc/core': 1.13.5 - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' - '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + '@vitest/coverage-v8@3.2.4(vitest@4.0.10)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8625,7 +8622,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vitest: 4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -8636,61 +8633,59 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/expect@3.2.4': + '@vitest/expect@4.0.10': dependencies: + '@standard-schema/spec': 1.0.0 '@types/chai': 5.2.2 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.2.0 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + chai: 6.2.1 + tinyrainbow: 3.0.3 - '@vitest/mocker@3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': + '@vitest/mocker@4.0.10(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.0.10 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 optionalDependencies: msw: 2.6.5(@types/node@22.18.1)(typescript@5.7.3) - vite: 7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/pretty-format@3.0.9': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.0.10': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.0.3 - '@vitest/runner@3.2.4': + '@vitest/runner@4.0.10': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.0.10 pathe: 2.0.3 - strip-literal: 3.0.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.0.10': dependencies: - '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.17 + '@vitest/pretty-format': 4.0.10 + magic-string: 0.30.21 pathe: 2.0.3 '@vitest/spy@3.0.9': dependencies: tinyspy: 3.0.2 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.3 + '@vitest/spy@4.0.10': {} - '@vitest/ui@3.2.4(vitest@3.2.4)': + '@vitest/ui@4.0.10(vitest@4.0.10)': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.0.10 fflate: 0.8.2 flatted: 3.3.3 pathe: 2.0.3 - sirv: 3.0.1 - tinyglobby: 0.2.14 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) '@vitest/utils@3.0.9': dependencies: @@ -8698,11 +8693,10 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - '@vitest/utils@3.2.4': + '@vitest/utils@4.0.10': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.0.10 + tinyrainbow: 3.0.3 '@vueless/storybook-dark-mode@9.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: @@ -8931,6 +8925,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@3.0.1: {} + base64-arraybuffer@1.0.2: optional: true @@ -8954,6 +8950,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@4.0.1: + dependencies: + balanced-match: 3.0.1 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -9052,9 +9052,9 @@ snapshots: ccount@2.0.1: {} - chai-string@1.5.0(chai@5.2.0): + chai-string@1.5.0(chai@6.2.1): dependencies: - chai: 5.2.0 + chai: 6.2.1 chai@5.2.0: dependencies: @@ -9064,6 +9064,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.0 + chai@6.2.1: {} + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -9326,11 +9328,11 @@ snapshots: dependencies: cypress: 15.4.0 - cypress-vite@1.8.0(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + cypress-vite@1.8.0(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: chokidar: 3.6.0 debug: 4.4.1(supports-color@8.1.1) - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9467,6 +9469,12 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + decamelize@1.2.0: {} decamelize@4.0.0: {} @@ -9685,6 +9693,35 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.25.3: optionalDependencies: '@esbuild/aix-ppc64': 0.25.3 @@ -9713,35 +9750,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-x64': 0.25.3 - esbuild@0.25.9: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.9 - '@esbuild/android-arm': 0.25.9 - '@esbuild/android-arm64': 0.25.9 - '@esbuild/android-x64': 0.25.9 - '@esbuild/darwin-arm64': 0.25.9 - '@esbuild/darwin-x64': 0.25.9 - '@esbuild/freebsd-arm64': 0.25.9 - '@esbuild/freebsd-x64': 0.25.9 - '@esbuild/linux-arm': 0.25.9 - '@esbuild/linux-arm64': 0.25.9 - '@esbuild/linux-ia32': 0.25.9 - '@esbuild/linux-loong64': 0.25.9 - '@esbuild/linux-mips64el': 0.25.9 - '@esbuild/linux-ppc64': 0.25.9 - '@esbuild/linux-riscv64': 0.25.9 - '@esbuild/linux-s390x': 0.25.9 - '@esbuild/linux-x64': 0.25.9 - '@esbuild/netbsd-arm64': 0.25.9 - '@esbuild/netbsd-x64': 0.25.9 - '@esbuild/openbsd-arm64': 0.25.9 - '@esbuild/openbsd-x64': 0.25.9 - '@esbuild/openharmony-arm64': 0.25.9 - '@esbuild/sunos-x64': 0.25.9 - '@esbuild/win32-arm64': 0.25.9 - '@esbuild/win32-ia32': 0.25.9 - '@esbuild/win32-x64': 0.25.9 - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -9967,7 +9975,7 @@ snapshots: dependencies: pify: 2.3.0 - expect-type@1.2.1: {} + expect-type@1.2.2: {} extend@3.0.2: {} @@ -10026,10 +10034,6 @@ snapshots: optionalDependencies: picomatch: 4.0.2 - fdir@6.4.4(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -10638,6 +10642,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsbn@0.1.1: {} jsdom@24.1.3: @@ -10912,6 +10920,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: dependencies: '@babel/parser': 7.27.0 @@ -11005,7 +11017,7 @@ snapshots: minimatch@5.1.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 4.0.1 minimatch@9.0.5: dependencies: @@ -11033,13 +11045,13 @@ snapshots: ansi-colors: 4.1.3 browser-stdout: 1.3.1 chokidar: 3.6.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) diff: 5.2.0 escape-string-regexp: 4.0.0 find-up: 5.0.0 glob: 8.1.0 he: 1.2.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 log-symbols: 4.1.0 minimatch: 5.1.6 ms: 2.1.3 @@ -11709,31 +11721,32 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.40.1 fsevents: 2.3.3 - rollup@4.50.1: + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.1 - '@rollup/rollup-android-arm64': 4.50.1 - '@rollup/rollup-darwin-arm64': 4.50.1 - '@rollup/rollup-darwin-x64': 4.50.1 - '@rollup/rollup-freebsd-arm64': 4.50.1 - '@rollup/rollup-freebsd-x64': 4.50.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 - '@rollup/rollup-linux-arm-musleabihf': 4.50.1 - '@rollup/rollup-linux-arm64-gnu': 4.50.1 - '@rollup/rollup-linux-arm64-musl': 4.50.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 - '@rollup/rollup-linux-ppc64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-musl': 4.50.1 - '@rollup/rollup-linux-s390x-gnu': 4.50.1 - '@rollup/rollup-linux-x64-gnu': 4.50.1 - '@rollup/rollup-linux-x64-musl': 4.50.1 - '@rollup/rollup-openharmony-arm64': 4.50.1 - '@rollup/rollup-win32-arm64-msvc': 4.50.1 - '@rollup/rollup-win32-ia32-msvc': 4.50.1 - '@rollup/rollup-win32-x64-msvc': 4.50.1 + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 rrweb-cssom@0.7.1: {} @@ -11891,7 +11904,7 @@ snapshots: transitivePeerDependencies: - supports-color - sirv@3.0.1: + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.28 mrmime: 2.0.0 @@ -11962,6 +11975,8 @@ snapshots: statuses@2.0.1: {} + std-env@3.10.0: {} + std-env@3.9.0: {} storybook@9.0.12(@testing-library/dom@10.4.0)(prettier@3.5.3): @@ -12086,10 +12101,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-literal@3.0.0: - dependencies: - js-tokens: 9.0.1 - stylis@4.2.0: {} sucrase@3.35.0: @@ -12180,23 +12191,16 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 - tinyglobby@0.2.14: - dependencies: - fdir: 6.4.4(picomatch@4.0.3) - picomatch: 4.0.3 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} - tinyrainbow@2.0.0: {} - tinyspy@3.0.2: {} + tinyrainbow@3.0.3: {} - tinyspy@4.0.3: {} + tinyspy@3.0.2: {} tldts-core@6.1.61: {} @@ -12489,61 +12493,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): - dependencies: - cac: 6.7.14 - debug: 4.4.1(supports-color@8.1.1) - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.7.3)(vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): + vite-plugin-svgr@4.5.0(rollup@4.53.3)(typescript@5.7.3)(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)): dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.50.1) + '@rollup/pluginutils': 5.2.0(rollup@4.53.3) '@svgr/core': 8.1.0(typescript@5.7.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.7.3)) - vite: 7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@7.1.11(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): - dependencies: - esbuild: 0.25.9 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.50.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.18.1 - fsevents: 2.3.3 - jiti: 2.4.2 - terser: 5.36.0 - tsx: 4.19.3 - yaml: 2.6.1 - - vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - esbuild: 0.25.9 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.50.1 + rollup: 4.53.3 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.18.1 @@ -12553,35 +12520,32 @@ snapshots: tsx: 4.19.3 yaml: 2.6.1 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): + vitest@4.0.10(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/ui@4.0.10)(jiti@2.4.2)(jsdom@24.1.3)(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1): dependencies: - '@types/chai': 5.2.2 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.2.0 - debug: 4.4.1(supports-color@8.1.1) - expect-type: 1.2.1 - magic-string: 0.30.17 + '@vitest/expect': 4.0.10 + '@vitest/mocker': 4.0.10(msw@2.6.5(@types/node@22.18.1)(typescript@5.7.3))(vite@7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1)) + '@vitest/pretty-format': 4.0.10 + '@vitest/runner': 4.0.10 + '@vitest/snapshot': 4.0.10 + '@vitest/spy': 4.0.10 + '@vitest/utils': 4.0.10 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.9.0 + std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.1.3(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) - vite-node: 3.2.4(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@22.18.1)(jiti@2.4.2)(terser@5.36.0)(tsx@4.19.3)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.18.1 - '@vitest/ui': 3.2.4(vitest@3.2.4) + '@vitest/ui': 4.0.10(vitest@4.0.10) jsdom: 24.1.3 transitivePeerDependencies: - jiti diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000000..838d14d7957 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + "packages/api-v4", + "packages/manager", + "packages/search", + "packages/shared", + "packages/ui", + "packages/utilities", + ], + }, +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index d54bb838a3a..00000000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default [ - "packages/api-v4", - "packages/manager", - "packages/search", - "packages/shared", - "packages/ui", - "packages/utilities", -]; From 3660a040f6d767d30d1e67cf2259e3d69df5cbcb Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 25 Nov 2025 14:27:32 +0530 Subject: [PATCH 37/91] upcoming: [UIE-9512, UIE-9530] - New Rule Set Details Drawer with Marked for Deletion status (#13108) * Save progress * Update tests * Few more changes * Add more changes * Clean up tests * Few changes * Layout updates * Update tests * Add ruleset loading state * Clean up mocks * Fix mocks * Add comments to the type * Added changeset: Update FirewallRuleType to support ruleset * Added changeset: Update FirewallRuleTypeSchema to support ruleset * Added changeset: Add new Firewall RuleSet row layout * Update ruleset action text - Delete to Remove * Save progress... * Update comment * Exclude 'addresses' from rulesets reference payloads * Some fixes * Add more details to the drawer for rulset * More changes... * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Add more changes to the drawer for rulesets * Update gap & fontsize of firwall add rules selection card * Fix Chip shrink issue * Revert Action column movement since its not yet confirmed * More Refactoring + better formstate typesaftey * Add renderOptions for Add ruleset Autocomplete * Fix typos * Few updates * Few fixes * Update cypress component tests * More changes * Update Add rulesets button copy * More Updates * Feature flag create entity selection for ruleset * More refactoring - separating form states * Add ruleset details drawer * Some clean up... * Save progress * Add more changes * Show only rulsets in dropdown applicable to the given catergory * Update Date format and some minor changes * Update mark for deletion date format * Update date format * Update badge color tokens * Capitalize action label in chip * Update Chip width * Added changeset: Update Firewall Rule Drawer to support referencing Rule Set * Use right color tokens for badge * Update placeholder for Select Rule Set * Some clean up * Few updates and clean up * Make cy test work * Clean up: remove duplicate validation * Add cancel btn for rules form + some design tokens for dropdown options * Add cancel btn and some styling fixes * Add mocks for Marked for deletion status * Add unit tests for Add Rule Set Drawer * Update test title * Mock useIsFirewallRulesetsPrefixlistsEnabled instead of feature flag * Fix styling and a bit of clean up * Clean up and refactor * Minor styling fixes * Add unit tests * Add omitted props for StyledListItem * Added changeset: New Rule Set Details drawer with Marked for Deletion status * Some clean up * Update unit tests * Fix typo * Make Rule Drawer accessible via routes * Improve tests * Add error state * Mock getUserTimezone * Some Clean up and allow route-based access only for view and create modes * Few changes --- ...r-13108-upcoming-features-1763746838424.md | 5 + .../Rules/FirewallRuleDrawer.test.tsx | 117 ++++++++++- .../Rules/FirewallRuleDrawer.tsx | 54 +++-- .../Rules/FirewallRuleDrawer.types.ts | 4 +- .../Rules/FirewallRuleSetDetailsView.tsx | 196 ++++++++++++++++++ .../Rules/FirewallRuleTable.tsx | 29 ++- .../Rules/FirewallRulesLanding.tsx | 141 ++++++++++--- .../FirewallDetail/Rules/shared.styles.ts | 67 +++++- .../Firewalls/FirewallDetail/Rules/shared.ts | 3 + packages/manager/src/mocks/serverHandlers.ts | 6 +- .../manager/src/routes/firewalls/index.ts | 10 + 11 files changed, 562 insertions(+), 70 deletions(-) create mode 100644 packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx 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)) 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..f9c1ab27b22 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -1,6 +1,8 @@ +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'; @@ -18,12 +20,35 @@ 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 { FirewallRuleError } from './shared'; -import type { FirewallPolicyType } from '@linode/api-v4/lib/firewalls/types'; +import type { Category, FirewallRuleError } from './shared'; +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, + }; +}); + +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(); @@ -144,6 +169,92 @@ describe('AddRuleSetDrawer', () => { }); }); +describe('ViewRuleSetDetailsDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true }); + }); + + const activeRuleSet = firewallRuleSetFactory.build({ id: 123 }); + const deletedRuleSet = firewallRuleSetFactory.build({ + id: 456, + deleted: '2025-07-24T04:23:17', + }); + + 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(); + + // Labels + const labels = [ + 'Label', + 'ID', + 'Description', + 'Service Defined', + 'Version', + 'Created', + 'Updated', + ]; + 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-07-24 04:23')).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 + expect(getByRole('button', { name: 'Cancel' })).toBeVisible(); + } + ); +}); + describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index cdf612bef2c..5000f6c192f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -6,10 +6,7 @@ import * as React from 'react'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; -import { - type FirewallOptionItem, - useIsFirewallRulesetsPrefixlistsEnabled, -} from '../../shared'; +import { useIsFirewallRulesetsPrefixlistsEnabled } from '../../shared'; import { formValueToIPs, getInitialFormValues, @@ -20,9 +17,11 @@ import { validateIPs, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; +import { FirewallRuleSetDetailsView } from './FirewallRuleSetDetailsView'; import { FirewallRuleSetForm } from './FirewallRuleSetForm'; import { firewallRuleCreateOptions } from './shared'; +import type { FirewallOptionItem } from '../../shared'; import type { FirewallCreateEntityType, FirewallRuleDrawerProps, @@ -40,7 +39,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 +68,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,14 +86,21 @@ export const FirewallRuleDrawer = React.memo( ) { setCreateEntityType('rule'); } - }, [mode, isOpen, ruleToModify, isFirewallRulesetsPrefixlistsEnabled]); + }, [ + mode, + isOpen, + ruleToModifyOrView, + isFirewallRulesetsPrefixlistsEnabled, + ]); const title = mode === 'create' ? `Add an ${capitalize(category)} Rule${ isFirewallRulesetsPrefixlistsEnabled ? ' or Rule Set' : '' }` - : 'Edit Rule'; + : mode === 'edit' + ? 'Edit Rule' + : `${capitalize(category)} Rule Set details`; const addressesLabel = category === 'inbound' ? 'source' : 'destination'; @@ -185,9 +191,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} @@ -210,7 +217,7 @@ export const FirewallRuleDrawer = React.memo( ips={ips} mode={mode} presetPorts={presetPorts} - ruleErrors={ruleToModify?.errors} + ruleErrors={ruleToModifyOrView?.errors} setIPs={setIPs} setPresetPorts={setPresetPorts} {...formikProps} @@ -246,17 +253,28 @@ 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' && ( + + )} + + {(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 f08c91d190d..c48ff4a91a6 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/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx new file mode 100644 index 00000000000..167163726ae --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -0,0 +1,196 @@ +import { useFirewallRuleSetQuery } from '@linode/queries'; +import { + ActionsPanel, + Box, + CircleProgress, + ErrorState, + NotFound, + Paper, + TooltipIcon, +} from '@linode/ui'; +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, + useIsFirewallRulesetsPrefixlistsEnabled, +} from '../../shared'; +import { RULESET_MARKED_FOR_DELETION_TEXT } from './shared'; +import { + StyledChip, + StyledLabel, + StyledListItem, + StyledWarningIcon, + useStyles, +} from './shared.styles'; + +import type { Category } from './shared'; +import type { FirewallRuleType } from '@linode/api-v4'; + +interface FirewallRuleSetDetailsViewProps { + category: Category; + closeDrawer: () => void; + ruleset: FirewallRuleType['ruleset']; +} + +export const FirewallRuleSetDetailsView = ( + props: FirewallRuleSetDetailsViewProps +) => { + const { category, closeDrawer, ruleset } = props; + + const { isFirewallRulesetsPrefixlistsEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const { classes } = useStyles(); + + const isValidRuleSetId = ruleset !== undefined && ruleset !== null; + + const { + data: ruleSetDetails, + isFetching, + isError, + error, + } = useFirewallRuleSetQuery( + ruleset ?? -1, + isValidRuleSetId && isFirewallRulesetsPrefixlistsEnabled + ); + + if (!isValidRuleSetId) { + return ; + } + + if (isFetching) { + return ( + + + + ); + } + + if (isError) { + return ; + } + + return ( + + {[ + { 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 && ( + + + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + > + Marked for deletion: + + ({ + color: theme.tokens.alias.Content.Text.Negative, + marginRight: theme.spacingFunction(4), + })} + value={ruleSetDetails.deleted} + /> + + + )} + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + })} + > + ({ marginBottom: theme.spacingFunction(4) })} + > + {capitalize(category)} Rules + + {ruleSetDetails?.rules.map((rule, idx) => ( + ({ + padding: `${theme.spacingFunction(4)} 0`, + })} + > + + + {rule.protocol}; {rule.ports};  + {generateAddressesLabel(rule.addresses)} + + + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index eedfe2cd508..9f24a04580f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -87,6 +87,7 @@ interface RowActionHandlers { handleCloneFirewallRule: (idx: number) => void; handleDeleteFirewallRule: (idx: number) => void; handleOpenRuleDrawerForEditing: (idx: number) => void; + handleOpenRuleSetDrawerForViewing?: (ruleset: number) => void; handleReorder: (startIdx: number, endIdx: number) => void; handleUndo: (idx: number) => void; } @@ -110,6 +111,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, handlePolicyChange, handleReorder, handleUndo, @@ -245,6 +247,9 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleOpenRuleDrawerForEditing={ handleOpenRuleDrawerForEditing } + handleOpenRuleSetDrawerForViewing={ + handleOpenRuleSetDrawerForViewing + } handleUndo={handleUndo} key={thisRuleRow.id} {...thisRuleRow} @@ -280,6 +285,7 @@ export interface FirewallRuleTableRowProps extends RuleRow { handleCloneFirewallRule: RowActionHandlersWithDisabled['handleCloneFirewallRule']; handleDeleteFirewallRule: RowActionHandlersWithDisabled['handleDeleteFirewallRule']; handleOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['handleOpenRuleDrawerForEditing']; + handleOpenRuleSetDrawerForViewing?: RowActionHandlersWithDisabled['handleOpenRuleSetDrawerForViewing']; handleUndo: RowActionHandlersWithDisabled['handleUndo']; } @@ -292,6 +298,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { handleCloneFirewallRule, handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, + handleOpenRuleSetDrawerForViewing, handleUndo, id, index, @@ -310,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 && isRuleSetRowEnabled + isValidRuleSetId && isRuleSetRowEnabled ); const actionMenuProps = { @@ -418,24 +427,34 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {isRuleSetRowEnabled && ( <> - + {rulesetDetails && ( - {}}>{rulesetDetails?.label} + + handleOpenRuleSetDrawerForViewing?.(rulesetDetails.id) + } + > + {rulesetDetails?.label} + )} - + - ID:  + {rulesetDetails ? 'ID:' : 'Rule Set ID:'}  {ruleset} { const location = useLocation(); const { enqueueSnackbar } = useSnackbar(); + 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 * so they are managed separately rather than through the reducer. @@ -83,10 +103,19 @@ export const FirewallRulesLanding = React.memo((props: Props) => { /** * Component state and handlers */ - const [ruleDrawer, setRuleDrawer] = React.useState({ - category: 'inbound', - mode: 'create', - }); + + // - 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< @@ -95,26 +124,36 @@ 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; + + 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 +332,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; }, @@ -323,13 +363,16 @@ export const FirewallRulesLanding = React.memo((props: Props) => { [outboundState] ); - // This is for the Rule Drawer. If there is a rule to modify, + 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 ruleToModify = + const ruleToModifyOrView = ruleDrawer.ruleIdx !== undefined - ? ruleDrawer.category === 'inbound' - ? inboundRules[ruleDrawer.ruleIdx] - : outboundRules[ruleDrawer.ruleIdx] + ? 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 ( @@ -381,14 +424,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={(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} /> @@ -402,14 +460,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={(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} /> @@ -420,12 +493,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} /> ({ - alignItems: 'center', - display: 'flex', - padding: `${theme.spacingFunction(4)} 0`, - }) -); +import type { FirewallPolicyType } from '@linode/api-v4'; +import type { Theme } from '@linode/ui'; + +interface StyledListItemProps { + paddingMultiplier?: number; // optional, default 1 +} + +export const StyledListItem = styled(Typography, { + label: 'StyledTypography', + shouldForwardProp: omittedProps(['paddingMultiplier']), +})(({ theme, paddingMultiplier = 1 }) => ({ + alignItems: 'center', + display: 'flex', + padding: `${theme.spacingFunction(4 * paddingMultiplier)} 0`, +})); export const StyledLabel = styled(Box, { label: 'StyledLabelBox', @@ -17,7 +31,40 @@ export const StyledLabel = styled(Box, { marginRight: theme.spacingFunction(4), })); -export const useStyles = makeStyles()((theme) => ({ +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 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': { height: '1em', 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: * diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index dcf176ec753..debc8849708 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1356,10 +1356,14 @@ 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', + 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(); diff --git a/packages/manager/src/routes/firewalls/index.ts b/packages/manager/src/routes/firewalls/index.ts index 72fb1d374db..e9ec4de124c 100644 --- a/packages/manager/src/routes/firewalls/index.ts +++ b/packages/manager/src/routes/firewalls/index.ts @@ -95,6 +95,15 @@ const firewallDetailRulesEditOutboundRuleRoute = createRoute({ ) ); +const firewallDetailRulesViewRuleSetRoute = createRoute({ + getParentRoute: () => 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 b76293de2b1e7c3383fb308e1fcb1ec8957777b9 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Tue, 25 Nov 2025 10:45:22 +0100 Subject: [PATCH 38/91] change: [STORIF-106] Object Storage Summary page changed. (#13087) * change: [STORIF-106] Object Storage Summary page changed. * Added changeset: Object storage summary page migrated to use table view --- ...r-13087-upcoming-features-1763109384091.md | 5 + .../Partials/EndpointSummaryRow.test.tsx | 11 +- .../Partials/EndpointSummaryRow.tsx | 109 +++++++++--------- .../Partials/EndpointSummaryTable.tsx | 61 ++++++++++ .../SummaryLanding/SummaryLanding.tsx | 12 +- 5 files changed, 133 insertions(+), 65 deletions(-) create mode 100644 packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md create mode 100644 packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx diff --git a/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md b/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md new file mode 100644 index 00000000000..96635443a16 --- /dev/null +++ b/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Object storage summary page migrated to use table view ([#13087](https://github.com/linode/manager/pull/13087)) diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx index dd1829edc93..6a35e80827b 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx @@ -108,14 +108,15 @@ describe('EndpointSummaryRow', () => { isFetching: false, }); - const { findByText } = renderWithTheme( + const { findByText, findAllByText } = renderWithTheme( ); - expect(await findByText(testEndpoint)).toBeVisible(); - expect(await findByText('Number of Buckets')).toBeVisible(); - expect(await findByText('Total Capacity')).toBeVisible(); - expect(await findByText('Number of Objects')).toBeVisible(); + const cellEndpoints = await findAllByText(testEndpoint); + expect(cellEndpoints.length).toBe(3); + cellEndpoints.forEach((endpoint) => { + expect(endpoint).toBeVisible(); + }); expect(await findByText('3 of 10 Buckets used')).toBeVisible(); expect(await findByText('1 of 2 KB used')).toBeVisible(); expect(await findByText('5 of 10 Objects used')).toBeVisible(); diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx index 9d090602e20..5e85f1b9662 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx @@ -1,13 +1,11 @@ -import { - Box, - CircleProgress, - ErrorState, - Typography, - useTheme, -} from '@linode/ui'; +import { Typography, useTheme } from '@linode/ui'; import * as React from 'react'; import { QuotaUsageBar } from 'src/components/QuotaUsageBar/QuotaUsageBar'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { useGetObjUsagePerEndpoint } from '../hooks/useGetObjUsagePerEndpoint'; @@ -24,58 +22,61 @@ export const EndpointSummaryRow = ({ endpoint }: Props) => { isError, } = useGetObjUsagePerEndpoint(endpoint); + if (isFetching) { + return ; + } + if (isError) { return ( - <> -
    - - + ); } - return ( - <> -
    - - - -

    {endpoint}

    -
    - - - {isFetching && } + const capacityQuota = quotaWithUsage.find( + (quota) => quota.quota_name === 'Total Capacity' + ); + const objectsQuota = quotaWithUsage.find( + (quota) => quota.quota_name === 'Number of Objects' + ); + const bucketsQuota = quotaWithUsage.find( + (quota) => quota.quota_name === 'Number of Buckets' + ); - {!isFetching && - quotaWithUsage.map((quota, index) => { - return ( - - {quota.quota_name} - - - ); - })} - -
    - + return ( + + {!!capacityQuota && ( + + {endpoint} + + + )} + {!!objectsQuota && ( + + {endpoint} + + + )} + {!!bucketsQuota && ( + + {endpoint} + + + )} + ); }; diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx new file mode 100644 index 00000000000..3a9d1f24eb5 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx @@ -0,0 +1,61 @@ +import { Box, useTheme } from '@linode/ui'; +import React from 'react'; + +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +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 { EndpointSummaryRow } from './EndpointSummaryRow'; + +interface Props { + endpoints: string[]; +} + +const PAGE_SIZE = 6; + +export const EndpointSummaryTable = ({ endpoints }: Props) => { + const theme = useTheme(); + const [page, setPage] = React.useState(1); + const [paginatedEndpoints, setPaginatedEndpoints] = React.useState( + [] + ); + + React.useEffect(() => { + const offset = PAGE_SIZE * (page - 1); + setPaginatedEndpoints(endpoints.slice(offset, offset + PAGE_SIZE)); + }, [endpoints, page]); + + return ( + + + + + Content Stored + Objects + Buckets + + + + + {paginatedEndpoints.map((endpoint, index) => { + return ; + })} + +
    + + {}} + page={page} + pageSize={PAGE_SIZE} + sx={{ padding: theme.spacingFunction(4) }} + /> +
    + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx index 51f4e1c9f3d..646cfeab731 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Link } from 'src/components/Link'; import { EndpointMultiselect } from './Partials/EndpointMultiselect'; -import { EndpointSummaryRow } from './Partials/EndpointSummaryRow'; +import { EndpointSummaryTable } from './Partials/EndpointSummaryTable'; import type { EndpointMultiselectValue } from './Partials/EndpointMultiselect'; @@ -35,11 +35,11 @@ export const SummaryLanding = () => { values={selectedEndpoints} /> - - {selectedEndpoints.map((endpoint, index) => { - return ; - })} - + {!!selectedEndpoints.length && ( + endpoint.label)} + /> + )}
    ); }; From 39025ad4bb7546b6fa74d237b45296dc484e22a1 Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Tue, 25 Nov 2025 15:29:09 +0530 Subject: [PATCH 39/91] upcoming: [DI-28395] - Added enabling, disabling and provisioning statuses and updated messages in CloudPulse alerts (#13127) * upcoming: [DI-28395] - Added enabling, disabling and provisioning statuses in alerts * upcoming: [DI-28395] - Updated server handlers * upcoming: [DI-28395] - Code refactoring * upcoming: [DI-28395] - Add changeset * upcoming: [DI-28395] - Update status messages on enable, disable , create and update * upcoming: [DI-28395] - Code refactoring * upcoming: [DI-28395] - Cypress fixes * upcoming: [DI-28395] - Lint issue fix --------- Co-authored-by: nikhagra-akamai --- ...r-13127-upcoming-features-1763979506001.md | 5 +++++ packages/api-v4/src/cloudpulse/types.ts | 9 +++++++- ...r-13127-upcoming-features-1763979546789.md | 5 +++++ .../cloudpulse/alerts-listing-page.spec.ts | 8 +++++-- .../cypress/support/constants/alert.ts | 3 +++ .../manager/src/factories/featureFlags.ts | 7 +++++++ .../AlertsListing/AlertListTable.test.tsx | 7 ++++--- .../Alerts/AlertsListing/AlertListTable.tsx | 21 +++++++++++++------ .../Alerts/AlertsListing/constants.ts | 3 +++ .../Alerts/Utils/AlertsActionMenu.ts | 7 ++++++- .../features/CloudPulse/Alerts/Utils/utils.ts | 5 ++++- .../features/CloudPulse/Alerts/constants.ts | 21 +++++++++++++++++-- packages/manager/src/mocks/serverHandlers.ts | 12 +++++++++++ packages/validation/src/cloudpulse.schema.ts | 10 ++++++++- 14 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md create mode 100644 packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md diff --git a/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md b/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md new file mode 100644 index 00000000000..82561a7e436 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add additional status types `enabling`, `disabling`, `provisioning` in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 4ac6b19d7f8..87e1d9ed382 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -19,8 +19,15 @@ export type DimensionFilterOperatorType = | 'neq' | 'startswith'; export type AlertDefinitionType = 'system' | 'user'; -export type AlertStatusType = 'disabled' | 'enabled' | 'failed' | 'in progress'; export type AlertDefinitionScope = 'account' | 'entity' | 'region'; +export type AlertStatusType = + | 'disabled' + | 'disabling' + | 'enabled' + | 'enabling' + | 'failed' + | 'in progress' + | 'provisioning'; export type CriteriaConditionType = 'ALL'; export type MetricUnitType = | 'bit_per_second' diff --git a/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md b/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md new file mode 100644 index 00000000000..8ca707c6855 --- /dev/null +++ b/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add support for additional status types and handle action menu accordingly in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 7c1a0643c1f..adb9afa816c 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -29,7 +29,8 @@ import { import { alertStatuses, DELETE_ALERT_SUCCESS_MESSAGE, - UPDATE_ALERT_SUCCESS_MESSAGE, + DISABLE_ALERT_SUCCESS_MESSAGE, + ENABLE_ALERT_SUCCESS_MESSAGE, } from 'src/features/CloudPulse/Alerts/constants'; import { formatDate } from 'src/utilities/formatDate'; @@ -426,7 +427,10 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { alertName, alias, confirmationText: `Are you sure you want to ${action.toLowerCase()} this alert definition?`, - successMessage: UPDATE_ALERT_SUCCESS_MESSAGE, + successMessage: + action === 'Disable' + ? DISABLE_ALERT_SUCCESS_MESSAGE + : ENABLE_ALERT_SUCCESS_MESSAGE, }); }); }); diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index c8e0f7a2f0e..c7f4641bd6e 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -44,4 +44,7 @@ export const statusMap: Record = { enabled: 'Enabled', failed: 'Failed', 'in progress': 'In Progress', + disabling: 'Disabling', + enabling: 'Enabling', + provisioning: 'Provisioning', }; diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index d77be1679e6..9ff40bea222 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -24,6 +24,13 @@ export const flagsFactory = Factory.Sync.makeFactory>({ alertDefinitions: true, recentActivity: false, notificationChannels: false, + editDisabledStatuses: [ + 'in progress', + 'failed', + 'provisioning', + 'enabling', + 'disabling', + ], }, aclpServices: { linode: { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx index eaa75096a69..fedb9e6f09b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx @@ -8,7 +8,8 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DELETE_ALERT_SUCCESS_MESSAGE, - UPDATE_ALERT_SUCCESS_MESSAGE, + DISABLE_ALERT_SUCCESS_MESSAGE, + ENABLE_ALERT_SUCCESS_MESSAGE, } from '../constants'; import { AlertsListTable } from './AlertListTable'; @@ -117,7 +118,7 @@ describe('Alert List Table test', () => { await userEvent.click(getByRole('button', { name: 'Enable' })); - expect(getByText(UPDATE_ALERT_SUCCESS_MESSAGE)).toBeInTheDocument(); // validate whether snackbar is displayed properly + expect(getByText(ENABLE_ALERT_SUCCESS_MESSAGE)).toBeVisible(); // validate whether snackbar is displayed properly }); it('should show success snackbar when disabling alert succeeds', async () => { @@ -140,7 +141,7 @@ describe('Alert List Table test', () => { await userEvent.click(getByRole('button', { name: 'Disable' })); - expect(getByText(UPDATE_ALERT_SUCCESS_MESSAGE)).toBeInTheDocument(); // validate whether snackbar is displayed properly + expect(getByText(DISABLE_ALERT_SUCCESS_MESSAGE)).toBeVisible(); // validate whether snackbar is displayed properly }); it('should show error snackbar when enabling alert fails', async () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index e74c02b4bbc..ab210d68564 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -23,7 +23,10 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; import { DELETE_ALERT_SUCCESS_MESSAGE, - UPDATE_ALERT_SUCCESS_MESSAGE, + DISABLE_ALERT_FAILED_MESSAGE, + DISABLE_ALERT_SUCCESS_MESSAGE, + ENABLE_ALERT_FAILED_MESSAGE, + ENABLE_ALERT_SUCCESS_MESSAGE, } from '../constants'; import { AlertsTable } from './AlertsTable'; import { AlertListingTableLabelMap } from './constants'; @@ -126,7 +129,6 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { const handleConfirm = React.useCallback( (alert: Alert, currentStatus: boolean) => { const toggleStatus = currentStatus ? 'disabled' : 'enabled'; - const errorStatus = currentStatus ? 'Disabling' : 'Enabling'; setIsUpdating(true); editAlertDefinition({ alertId: alert.id, @@ -135,15 +137,22 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { }) .then(() => { // Handle success - enqueueSnackbar(UPDATE_ALERT_SUCCESS_MESSAGE, { - variant: 'success', - }); + enqueueSnackbar( + currentStatus + ? DISABLE_ALERT_SUCCESS_MESSAGE + : ENABLE_ALERT_SUCCESS_MESSAGE, + { + variant: 'success', + } + ); }) .catch((updateError: APIError[]) => { // Handle error const errorResponse = getAPIErrorOrDefault( updateError, - `${errorStatus} alert failed` + currentStatus + ? DISABLE_ALERT_FAILED_MESSAGE + : ENABLE_ALERT_FAILED_MESSAGE ); enqueueSnackbar(errorResponse[0].reason, { variant: 'error', diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts index 2980370be85..f9e8fd7f37a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -35,6 +35,9 @@ export const statusToActionMap: Record = enabled: 'Disable', failed: 'Disable', 'in progress': 'Disable', + provisioning: 'Disable', + disabling: 'Enable', + enabling: 'Disable', }; export const AlertContextualViewTableHeaderMap: TableColumnHeader[] = [ diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index 124d297bfa9..3ff182a8769 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -40,7 +40,12 @@ export const getAlertTypeToActionsList = ( title: 'Edit', }, { - disabled: alertStatus === 'in progress' || alertStatus === 'failed', + disabled: + alertStatus === 'in progress' || + alertStatus === 'failed' || + alertStatus === 'provisioning' || + alertStatus === 'enabling' || + alertStatus === 'disabling', onClick: handleStatusChange, title: getTitleForStatusChange(alertStatus), }, diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index 167b7a684f8..91808118179 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -274,7 +274,10 @@ export const filterAlerts = (props: FilterAlertsProps): Alert[] => { return ( alerts?.filter(({ label, status, type, scope, regions }) => { return ( - (status === 'enabled' || status === 'in progress') && + (status === 'enabled' || + status === 'in progress' || + status === 'provisioning' || + status === 'enabling') && (!selectedType || type === selectedType) && (!searchText || label.toLowerCase().includes(searchText.toLowerCase())) && diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index d796dad6c75..5414c10e684 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -134,6 +134,9 @@ export const alertStatusToIconStatusMap: Record = { enabled: 'active', failed: 'error', 'in progress': 'other', + provisioning: 'other', + disabling: 'other', + enabling: 'other', }; export const channelTypeOptions: Item[] = [ @@ -167,11 +170,15 @@ export const dimensionOperatorTypeMap: Record< startswith: 'starts with', in: 'in', }; + export const alertStatuses: Record = { disabled: 'Disabled', enabled: 'Enabled', failed: 'Failed', 'in progress': 'In Progress', + disabling: 'Disabling', + enabling: 'Enabling', + provisioning: 'Provisioning', }; export const alertStatusOptions: Item[] = @@ -198,10 +205,20 @@ export const MULTILINE_ERROR_SEPARATOR = '|'; export const SINGLELINE_ERROR_SEPARATOR = '\t'; export const CREATE_ALERT_SUCCESS_MESSAGE = - 'Alert successfully created. It may take a few minutes for your changes to take effect.'; + 'Alert created. It may take up to 5 minutes for your alert to be enabled.'; export const UPDATE_ALERT_SUCCESS_MESSAGE = - 'Alert successfully updated. It may take a few minutes for your changes to take effect.'; + 'Alert updated. It may take up to 5 minutes for changes to be applied.'; + +export const DISABLE_ALERT_SUCCESS_MESSAGE = + 'Alert disabled. It may take up to 5 minutes for your changes to take effect.'; + +export const ENABLE_ALERT_SUCCESS_MESSAGE = + 'Alert enabled. It may take up to 5 minutes for your changes to take effect.'; + +export const DISABLE_ALERT_FAILED_MESSAGE = 'Failed to disable an Alert.'; + +export const ENABLE_ALERT_FAILED_MESSAGE = 'Failed to enable an Alert.'; export const ACCOUNT_GROUP_INFO_MESSAGE = 'This alert applies to all entities associated with your account, and will be applied to any new entities that are added. The alert is triggered per entity rather than being based on the aggregated data for all entities.'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index debc8849708..c5bc0413b1d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3223,6 +3223,10 @@ export const handlers = [ rules: [firewallNodebalancerMetricCriteria.build()], }, }), + ...alertFactory.buildList(3, { status: 'enabling', type: 'user' }), + ...alertFactory.buildList(3, { status: 'disabling', type: 'user' }), + ...alertFactory.buildList(3, { status: 'provisioning', type: 'user' }), + ...alertFactory.buildList(3, { status: 'in progress', type: 'user' }), ]; return HttpResponse.json(makeResourcePage(alerts)); }), @@ -3305,6 +3309,14 @@ export const handlers = [ service_type: params.serviceType === 'linode' ? 'linode' : 'dbaas', type: 'user', scope: pickRandom(['account', 'region', 'entity']), + status: pickRandom([ + 'enabled', + 'disabled', + 'in progress', + 'enabling', + 'disabling', + 'provisioning', + ]), }) ); } diff --git a/packages/validation/src/cloudpulse.schema.ts b/packages/validation/src/cloudpulse.schema.ts index 32e68dd70ed..bc09c7b2b8d 100644 --- a/packages/validation/src/cloudpulse.schema.ts +++ b/packages/validation/src/cloudpulse.schema.ts @@ -120,7 +120,15 @@ export const editAlertDefinitionSchema = object({ trigger_conditions: triggerConditionValidation.optional().default(undefined), severity: number().oneOf([0, 1, 2, 3]).optional(), status: string() - .oneOf(['enabled', 'disabled', 'in progress', 'failed']) + .oneOf([ + 'enabled', + 'disabled', + 'in progress', + 'failed', + 'provisioning', + 'disabling', + 'enabling', + ]) .optional(), scope: string().oneOf(['entity', 'region', 'account']).nullable().optional(), regions: array().of(string().defined()).optional(), From 2cde3bcc88362386116054e553059e476c19eda7 Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 26 Nov 2025 11:39:36 +0530 Subject: [PATCH 40/91] upcoming: [DI-27569] - Integrate endpoints dashboard for object-storage service in CloudPulse Metrics (#13133) * upcoming: [DI-27569] - Integrate endpoints dashboard for object-storage service * upcoming: [DI-27569] - Update endpoints config for buckets dashboard * upcoming: [DI-27569] - add util for dashboard type identification, simplify props * upcoming: [DI-27569] - Add dimensionkey for endpoints filter in endpoints config * upcoming: [DI-27569] - Cleanup * upcoming: [DI-27569] - Simplify props * upcoming: [DI-27569] - Move computations outside * upcoming: [DI-27569] - Fix group_by bug * upcoming: [DI-27569] - Update imports * upcoming: [DI-27569] - Move test case * upcoming: [DI-27569] - Add changesets * upcoming: [DI-27569] - Simplify --- ...r-13133-upcoming-features-1764084018326.md | 5 + packages/api-v4/src/cloudpulse/types.ts | 2 +- ...r-13133-upcoming-features-1764084041956.md | 5 + .../Dashboard/CloudPulseDashboard.tsx | 2 +- .../features/CloudPulse/GroupBy/utils.test.ts | 20 ++- .../src/features/CloudPulse/GroupBy/utils.ts | 15 +- .../Utils/CloudPulseWidgetUtils.test.ts | 2 +- .../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 4 +- .../CloudPulse/Utils/FilterBuilder.test.ts | 5 +- .../CloudPulse/Utils/FilterBuilder.ts | 7 +- .../CloudPulse/Utils/FilterConfig.test.ts | 12 ++ .../features/CloudPulse/Utils/FilterConfig.ts | 73 +++++++- .../ReusableDashboardFilterUtils.test.ts | 12 ++ .../Utils/ReusableDashboardFilterUtils.ts | 18 +- .../src/features/CloudPulse/Utils/models.ts | 9 +- .../features/CloudPulse/Utils/utils.test.ts | 42 ++++- .../src/features/CloudPulse/Utils/utils.ts | 25 +++ .../Widget/CloudPulseWidgetRenderer.tsx | 2 - .../shared/CloudPulseCustomSelect.tsx | 1 + .../shared/CloudPulseEndpointsSelect.test.tsx | 168 +++++++++++++----- .../shared/CloudPulseEndpointsSelect.tsx | 117 ++++++++---- packages/manager/src/mocks/serverHandlers.ts | 10 ++ .../src/queries/cloudpulse/resources.ts | 2 +- 23 files changed, 450 insertions(+), 108 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md create mode 100644 packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md diff --git a/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md b/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md new file mode 100644 index 00000000000..3e22f0af003 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +CloudPulse-Metrics: Update `entity_ids` type in `CloudPulseMetricsRequest` for metrics api in endpoints dahsboard ([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 87e1d9ed382..07dca57b539 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -159,7 +159,7 @@ export interface Metric { export interface CloudPulseMetricsRequest { absolute_time_duration: DateTimeWithPreset | undefined; associated_entity_region?: string; - entity_ids: number[] | string[]; + entity_ids: number[] | string[] | undefined; entity_region?: string; filters?: Filters[]; group_by?: string[]; diff --git a/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md b/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md new file mode 100644 index 00000000000..3f2f8c5bc68 --- /dev/null +++ b/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Update `FilterConfig.ts` to handle integration of endpoints dashboard for object-storage service in metrics page([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 876b4cc17db..36974ed6071 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -150,7 +150,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { } = useCloudPulseJWEtokenQuery( dashboard?.service_type, getJweTokenPayload(), - Boolean(resources) && !isDashboardLoading && !isDashboardApiError + !isDashboardLoading && !isDashboardApiError ); if (isDashboardApiError) { diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts index e880eaf6d17..adfee38ea85 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.test.ts @@ -87,7 +87,7 @@ describe('useGlobalDimensions method test', () => { it('should return non-empty options and defaultValue if no common dimensions', () => { queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: dashboardFactory.build(), + data: dashboardFactory.build({ id: 1 }), isLoading: false, }); queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ @@ -106,7 +106,7 @@ describe('useGlobalDimensions method test', () => { it('should return non-empty options and defaultValue from preferences', () => { queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ - data: dashboardFactory.build(), + data: dashboardFactory.build({ id: 1 }), isLoading: false, }); queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ @@ -123,6 +123,22 @@ describe('useGlobalDimensions method test', () => { isLoading: false, }); }); + + it('should not return default option in case of endpoints-only dashboard', () => { + queryMocks.useCloudPulseDashboardByIdQuery.mockReturnValue({ + data: dashboardFactory.build({ id: 10 }), + isLoading: false, + }); + queryMocks.useGetCloudPulseMetricDefinitionsByServiceType.mockReturnValue({ + data: { + data: metricDefinitions, + }, + isLoading: false, + }); + const result = useGlobalDimensions(10, 'objectstorage'); + // Verify if options contain the default option - 'entityId' or not + expect(result.options).toEqual([{ label: 'Dim 2', value: 'Dim 2' }]); + }); }); describe('useWidgetDimension method test', () => { diff --git a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts index 7444f8bc916..6281b6a70ca 100644 --- a/packages/manager/src/features/CloudPulse/GroupBy/utils.ts +++ b/packages/manager/src/features/CloudPulse/GroupBy/utils.ts @@ -2,7 +2,10 @@ import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboar import { useGetCloudPulseMetricDefinitionsByServiceType } from 'src/queries/cloudpulse/services'; import { ASSOCIATED_ENTITY_METRIC_MAP } from '../Utils/constants'; -import { getAssociatedEntityType } from '../Utils/FilterConfig'; +import { + getAssociatedEntityType, + isEndpointsOnlyDashboard, +} from '../Utils/FilterConfig'; import type { GroupByOption } from './CloudPulseGroupByDrawer'; import type { @@ -62,10 +65,12 @@ export const useGlobalDimensions = ( metricDefinition?.data ?? [], dashboard ); - const commonDimensions = [ - defaultOption, - ...getCommonDimensions(metricDimensions), - ]; + const baseDimensions = getCommonDimensions(metricDimensions); + const shouldIncludeDefault = !isEndpointsOnlyDashboard(dashboardId ?? 0); + + const commonDimensions = shouldIncludeDefault + ? [defaultOption, ...baseDimensions] + : baseDimensions; const commonGroups = getCommonGroups( preference ? preference : (dashboard?.group_by ?? []), diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index 7caed886877..c94d37f5d19 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -328,7 +328,7 @@ describe('getTimeDurationFromPreset method', () => { expect(result).toEqual([123]); }); - it('should return entity ids for objectstorage service type', () => { + it('should return entity ids for objectstorage buckets dashboard', () => { const result = getEntityIds( [{ id: 'bucket-1', label: 'bucket-name-1' }], ['bucket-1'], diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index 786cc2906ea..fb0816b6c8d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -354,7 +354,9 @@ export const getCloudPulseMetricRequest = ( presetDuration === undefined ? { end: duration.end, start: duration.start } : undefined, - entity_ids: getEntityIds(resources, entityIds, widget, serviceType), + entity_ids: !entityIds.length + ? undefined + : getEntityIds(resources, entityIds, widget, serviceType), filters: undefined, group_by: !groupBy?.length ? undefined : groupBy, relative_time_duration: presetDuration, diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 5cc58111f72..6a6ddece891 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -34,7 +34,6 @@ import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; import { deepEqual } from './utils'; -import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { CloudPulseServiceTypeFilters } from './models'; @@ -686,13 +685,15 @@ describe('filterUsingDependentFilters', () => { }); describe('filterEndpointsUsingRegion', () => { - const mockData: CloudPulseEndpoints[] = [ + const mockData: CloudPulseResources[] = [ { ...objectStorageEndpointsFactory.build({ region: 'us-east' }), + id: 'us-east-1.linodeobjects.com', label: 'us-east-1.linodeobjects.com', }, { ...objectStorageEndpointsFactory.build({ region: 'us-west' }), + id: 'us-west-1.linodeobjects.com', label: 'us-west-1.linodeobjects.com', }, ]; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 97fed2e4b40..824c31a199a 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -15,7 +15,6 @@ import type { } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect'; -import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseFirewallNodebalancersSelectProps, CloudPulseNodebalancers, @@ -404,12 +403,14 @@ export const getEndpointsProperties = ( preferences ), handleEndpointsSelection: handleEndpointsChange, + dashboardId: dashboard.id, label, placeholder, serviceType: dashboard.service_type, region: dependentFilters?.[REGION], savePreferences: !isServiceAnalyticsIntegration, xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + hasRestrictedSelections: config.configuration.hasRestrictedSelections, }; }; @@ -733,9 +734,9 @@ export const filterUsingDependentFilters = ( * @returns The filtered endpoints */ export const filterEndpointsUsingRegion = ( - data?: CloudPulseEndpoints[], + data?: CloudPulseResources[], regionFilter?: CloudPulseMetricsFilter -): CloudPulseEndpoints[] | undefined => { +): CloudPulseResources[] | undefined => { if (!data) { return data; } diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts index 1075a85fd5b..606d68011b4 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.test.ts @@ -1,6 +1,7 @@ import { getAssociatedEntityType, getResourcesFilterConfig, + isEndpointsOnlyDashboard, } from './FilterConfig'; describe('getResourcesFilterConfig', () => { @@ -34,3 +35,14 @@ describe('getAssociatedEntityType', () => { expect(getAssociatedEntityType(8)).toBe('nodebalancer'); }); }); + +describe('isEndpointsOnlyDashboard', () => { + it('should return true when the dashboard is an endpoints only dashboard', () => { + // Dashboard ID 10 is an endpoints only dashboard + expect(isEndpointsOnlyDashboard(10)).toBe(true); + }); + it('should return false when the dashboard is not an endpoints only dashboard', () => { + // Dashboard ID 6 is not an endpoints only dashboard, rather a buckets dashboard + expect(isEndpointsOnlyDashboard(6)).toBe(false); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 1d4c19a56e0..0f6debbc6bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -11,12 +11,12 @@ import { RESOURCE_ID, } from './constants'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; -import { filterKubernetesClusters } from './utils'; +import { filterKubernetesClusters, getValidSortedEndpoints } from './utils'; import type { AssociatedEntityType } from '../shared/types'; import type { CloudPulseServiceTypeFiltersConfiguration } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; -import type { KubernetesCluster } from '@linode/api-v4'; +import type { KubernetesCluster, ObjectStorageBucket } from '@linode/api-v4'; const TIME_DURATION = 'Time Range'; @@ -420,6 +420,8 @@ export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly + getValidSortedEndpoints(resources), }, name: 'Endpoints', }, @@ -442,6 +444,45 @@ export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly = + { + capability: capabilityServiceTypeMapping['objectstorage'], + filters: [ + { + configuration: { + filterKey: REGION, + children: [ENDPOINT], + filterType: 'string', + isFilterable: true, + isMetricsFilter: true, + name: 'Region', + priority: 1, + neededInViews: [CloudPulseAvailableViews.central], + }, + name: 'Region', + }, + { + configuration: { + dimensionKey: 'endpoint', + dependency: [REGION], + filterKey: ENDPOINT, + filterType: 'string', + isFilterable: true, + isMetricsFilter: false, + isMultiSelect: true, + hasRestrictedSelections: true, + name: 'Endpoints', + priority: 2, + neededInViews: [CloudPulseAvailableViews.central], + filterFn: (resources: ObjectStorageBucket[]) => + getValidSortedEndpoints(resources), + }, + name: 'Endpoints', + }, + ], + serviceType: 'objectstorage', + }; + export const BLOCKSTORAGE_CONFIG: Readonly = { capability: capabilityServiceTypeMapping['blockstorage'], filters: [ @@ -522,6 +563,7 @@ export const FILTER_CONFIG: Readonly< [7, BLOCKSTORAGE_CONFIG], [8, FIREWALL_NODEBALANCER_CONFIG], [9, LKE_CONFIG], + [10, ENDPOINT_DASHBOARD_CONFIG], ]); /** @@ -534,8 +576,13 @@ export const getResourcesFilterConfig = ( if (!dashboardId) { return undefined; } - // Get the associated entity type for the dashboard + // Get the resources filter configuration for the dashboard const filterConfig = FILTER_CONFIG.get(dashboardId); + if (isEndpointsOnlyDashboard(dashboardId)) { + return filterConfig?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + )?.configuration; + } return filterConfig?.filters.find( (filter) => filter.configuration.filterKey === RESOURCE_ID )?.configuration; @@ -553,3 +600,23 @@ export const getAssociatedEntityType = ( } return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; }; + +/** + * @param dashboardId id of the dashboard + * @returns whether dashboard is an endpoints only dashboard + */ +export const isEndpointsOnlyDashboard = (dashboardId: number): boolean => { + const filterConfig = FILTER_CONFIG.get(dashboardId); + if (!filterConfig) { + return false; + } + const endpointsFilter = filterConfig?.filters.find( + (filter) => filter.name === 'Endpoints' + ); + if (endpointsFilter) { + // Verify if the dashboard has buckets filter, if not then it is an endpoints only dashboard + return !filterConfig.filters.some((filter) => filter.name === 'Buckets'); + } + + return false; +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts index 1f68344cf67..caff614be54 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts @@ -133,6 +133,18 @@ it('test constructDimensionFilters method', () => { expect(result[0].filterValue).toEqual('primary'); }); +it('test constructDimensionFilters method for endpoints only dashboard', () => { + const result = constructDimensionFilters({ + dashboardObj: { ...mockDashboard, id: 10, service_type: 'objectstorage' }, + filterValue: {}, + resource: 'us-east-1.linodeobjects.com', + groupBy: [], + }); + expect(result.length).toEqual(1); + expect(result[0].filterKey).toEqual('endpoint'); + expect(result[0].filterValue).toEqual(['us-east-1.linodeobjects.com']); +}); + it('test checkIfFilterNeededInMetricsCall method', () => { let result = checkIfFilterNeededInMetricsCall('region', 2); expect(result).toEqual(false); diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index edce614f656..b50354b1c52 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -1,5 +1,6 @@ import { defaultTimeDuration } from './CloudPulseDateTimePickerUtils'; -import { FILTER_CONFIG } from './FilterConfig'; +import { ENDPOINT } from './constants'; +import { FILTER_CONFIG, isEndpointsOnlyDashboard } from './FilterConfig'; import { CloudPulseAvailableViews } from './models'; import type { DashboardProperties } from '../Dashboard/CloudPulseDashboard'; @@ -55,7 +56,9 @@ export const getDashboardProperties = ( }), dashboardId: dashboardObj.id, duration: timeDuration ?? defaultTimeDuration(), - resources: [String(resource)], + resources: isEndpointsOnlyDashboard(dashboardObj.id) + ? [] + : [String(resource)], serviceType: dashboardObj.service_type, savePref: false, groupBy, @@ -148,13 +151,20 @@ export const checkIfFilterNeededInMetricsCall = ( export const constructDimensionFilters = ( props: ReusableDashboardFilterUtilProps ): CloudPulseMetricsAdditionalFilters[] => { - const { dashboardObj, filterValue } = props; - return Object.keys(filterValue) + const { dashboardObj, filterValue, resource } = props; + const filters = Object.keys(filterValue) .filter((key) => checkIfFilterNeededInMetricsCall(key, dashboardObj.id)) .map((key) => ({ filterKey: key, filterValue: filterValue[key], })); + if (isEndpointsOnlyDashboard(dashboardObj.id)) { + filters.push({ + filterKey: ENDPOINT, + filterValue: [String(resource)], + }); + } + return filters; }; /** diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index a4ef0497eb6..248210d1f9c 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -1,3 +1,4 @@ +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { AssociatedEntityType } from '../shared/types'; import type { Capabilities, @@ -56,6 +57,7 @@ export interface CloudPulseServiceTypeFilters { * As of now, the list of possible custom filters are engine, database type, this union type will be expanded if we start enhancing our custom select config */ export type QueryFunctionType = + | CloudPulseResources[] | DatabaseEngine[] | DatabaseInstance[] | DatabaseType[] @@ -144,6 +146,11 @@ export interface CloudPulseServiceTypeFiltersConfiguration { */ filterType: string; + /** + * If this is true, we will only allow users to select a certain threshold + */ + hasRestrictedSelections?: boolean; + /** * If this is true, we will pass the filter in the metrics api otherwise, we don't */ @@ -157,7 +164,6 @@ export interface CloudPulseServiceTypeFiltersConfiguration { * If this is true, multiselect will be enabled for the filter, only applicable for static and dynamic, not for predefined ones */ isMultiSelect?: boolean; - /** * If this is true, we will pass filter as an optional filter */ @@ -167,6 +173,7 @@ export interface CloudPulseServiceTypeFiltersConfiguration { * If this is true, we will only allow users to select a certain threshold, only applicable for static and dynamic, not for predefined ones */ maxSelections?: number; + /** * The name of the filter */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index dab6f0bea3f..c64562946a9 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -1,7 +1,11 @@ import { regionFactory } from '@linode/utilities'; import { describe, expect, it } from 'vitest'; -import { kubernetesClusterFactory, serviceTypesFactory } from 'src/factories'; +import { + kubernetesClusterFactory, + objectStorageBucketFactoryGen2, + serviceTypesFactory, +} from 'src/factories'; import { firewallEntityfactory, firewallFactory, @@ -29,6 +33,7 @@ import { filterKubernetesClusters, getEnabledServiceTypes, getFilteredDimensions, + getValidSortedEndpoints, isValidFilter, isValidPort, useIsAclpSupportedRegion, @@ -709,3 +714,38 @@ describe('arraysEqual', () => { expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true); }); }); + +describe('getValidSortedEndpoints', () => { + it('should return an empty array when buckets are undefined', () => { + expect(getValidSortedEndpoints(undefined)).toEqual([]); + }); + it('should return the valid and unique sorted endpoints', () => { + const buckets = [ + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'a', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'b', + region: undefined, + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'c', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: 'c', + region: 'us-east', + }), + objectStorageBucketFactoryGen2.build({ + s3_endpoint: undefined, + region: 'us-east', + }), + ]; + // Only a and c are valid, so they are sorted and returned + expect(getValidSortedEndpoints(buckets)).toEqual([ + { id: 'a', label: 'a', region: 'us-east' }, + { id: 'c', label: 'c', region: 'us-east' }, + ]); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 5978318658b..974a59bc158 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -23,6 +23,7 @@ import { } from './constants'; import type { FetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/constants'; +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; import type { AssociatedEntityType } from '../shared/types'; import type { MetricsDimensionFilter } from '../Widget/components/DimensionFilters/types'; import type { @@ -37,6 +38,7 @@ import type { FirewallDeviceEntity, KubernetesCluster, MonitoringCapabilities, + ObjectStorageBucket, ResourcePage, Service, ServiceTypesList, @@ -567,6 +569,29 @@ export const filterKubernetesClusters = ( .sort((a, b) => a.label.localeCompare(b.label)); }; +/** + * @param buckets The list of buckets + * @returns The valid sorted endpoints + */ +export const getValidSortedEndpoints = ( + buckets: ObjectStorageBucket[] | undefined +): CloudPulseResources[] => { + if (!buckets) return []; + + const visitedEndpoints = new Set(); + const uniqueEndpoints: CloudPulseResources[] = []; + + buckets.forEach(({ s3_endpoint: s3Endpoint, region }) => { + if (s3Endpoint && region && !visitedEndpoints.has(s3Endpoint)) { + visitedEndpoints.add(s3Endpoint); + uniqueEndpoints.push({ id: s3Endpoint, label: s3Endpoint, region }); + } + }); + + uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); + return uniqueEndpoints; +}; + /** * @param obj1 The first object to be compared * @param obj2 The second object to be compared diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 1941420d04b..0a3d1d7725a 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -142,8 +142,6 @@ export const RenderWidgets = React.memo( if ( !dashboard.service_type || - // eslint-disable-next-line sonarjs/no-inverted-boolean-check - !(resources.length > 0) || (!isJweTokenFetching && !jweToken?.token) || !resourceList?.length ) { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx index 556f1c5ad1a..f1b391996b1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseCustomSelect.tsx @@ -254,6 +254,7 @@ export const CloudPulseCustomSelect = React.memo( errorText={staticErrorText} isOptionEqualToValue={(option, value) => option.label === value.label} label={label || 'Select a Value'} + loading={isLoading} multiple={isMultiSelect} noMarginTop onChange={handleChange} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx index e38acc948bd..238f2c3c0f5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx @@ -2,7 +2,6 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { objectStorageBucketFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; @@ -24,37 +23,53 @@ vi.mock('src/queries/cloudpulse/resources', async () => { const mockEndpointHandler = vi.fn(); const SELECT_ALL = 'Select All'; const ARIA_SELECTED = 'aria-selected'; +const ARIA_DISABLED = 'aria-disabled'; -const mockBuckets: CloudPulseResources[] = [ +const mockEndpoints: CloudPulseResources[] = [ { - id: 'obj-bucket-1.us-east-1.linodeobjects.com', - label: 'obj-bucket-1.us-east-1.linodeobjects.com', + id: 'us-east-1.linodeobjects.com', + label: 'us-east-1.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-1.linodeobjects.com', }, { - id: 'obj-bucket-2.us-east-2.linodeobjects.com', - label: 'obj-bucket-2.us-east-2.linodeobjects.com', + id: 'us-east-2.linodeobjects.com', + label: 'us-east-2.linodeobjects.com', region: 'us-east', - endpoint: 'us-east-2.linodeobjects.com', }, { - id: 'obj-bucket-1.br-gru-1.linodeobjects.com', - label: 'obj-bucket-1.br-gru-1.linodeobjects.com', + id: 'br-gru-1.linodeobjects.com', + label: 'br-gru-1.linodeobjects.com', region: 'us-east', - endpoint: 'br-gru-1.linodeobjects.com', }, ]; +const exceedingmockEndpoints: CloudPulseResources[] = Array.from( + { length: 8 }, + (_, i) => { + const idx = i + 1; + return { + id: `us-east-bucket-${idx}.com`, + label: `us-east-bucket-${idx}.com`, + region: 'us-east', + }; + } +); + describe('CloudPulseEndpointsSelect component tests', () => { beforeEach(() => { vi.clearAllMocks(); - objectStorageBucketFactory.resetSequenceNumber(); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); }); it('renders with the correct label and placeholder', () => { renderWithTheme( { it('should render disabled component if the props are undefined', () => { renderWithTheme( { }); it('should render endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toBeVisible(); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toBeVisible(); }); it('should be able to deselect the selected endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { // Check that both endpoints are deselected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); }); it('should select multiple endpoints', async () => { - queryMocks.useResourcesQuery.mockReturnValue({ - data: mockBuckets, - isError: false, - isLoading: false, - status: 'success', - }); - renderWithTheme( { await userEvent.click(await screen.findByRole('button', { name: 'Open' })); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ); await userEvent.click( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ); // Check that the correct endpoints are selected/not selected expect( await screen.findByRole('option', { - name: mockBuckets[0].endpoint, + name: mockEndpoints[0].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[1].endpoint, + name: mockEndpoints[1].id, }) ).toHaveAttribute(ARIA_SELECTED, 'true'); expect( await screen.findByRole('option', { - name: mockBuckets[2].endpoint, + name: mockEndpoints[2].id, }) ).toHaveAttribute(ARIA_SELECTED, 'false'); expect( @@ -213,6 +211,7 @@ describe('CloudPulseEndpointsSelect component tests', () => { renderWithTheme( { }) ).toBeVisible(); }); + + it('should handle endpoints selection limits correctly', async () => { + const user = userEvent.setup(); + + const allmockEndpoints = [...mockEndpoints, ...exceedingmockEndpoints]; + + queryMocks.useResourcesQuery.mockReturnValue({ + data: allmockEndpoints, + isError: false, + isLoading: false, + status: 'success', + }); + + const { queryByRole } = renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText('Select up to 10 Endpoints')).toBeVisible(); + + // Select the first 10 endpoints + for (let i = 0; i < 10; i++) { + const option = await screen.findByRole('option', { + name: allmockEndpoints[i].id, + }); + await user.click(option); + } + + // Check if we have 10 selected endpoints + const selectedOptions = screen + .getAllByRole('option') + .filter((option) => option.getAttribute(ARIA_SELECTED) === 'true'); + expect(selectedOptions.length).toBe(10); + + // Check that the 11th endpoint is disabled + expect( + screen.getByRole('option', { name: allmockEndpoints[10].id }) + ).toHaveAttribute(ARIA_DISABLED, 'true'); + + // Check "Select All" is not available when there are more endpoints than the limit + expect(queryByRole('option', { name: SELECT_ALL })).not.toBeInTheDocument(); + }); + + it('should handle "Select All" when resource count equals limit', async () => { + const user = userEvent.setup(); + + queryMocks.useResourcesQuery.mockReturnValue({ + data: [...mockEndpoints, ...exceedingmockEndpoints.slice(0, 7)], + isError: false, + isLoading: false, + status: 'success', + }); + + renderWithTheme( + + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + await user.click(screen.getByRole('option', { name: SELECT_ALL })); + await user.click(screen.getByRole('option', { name: 'Deselect All' })); + + // Check all endpoints are deselected + mockEndpoints.forEach((endpoint) => { + expect(screen.getByRole('option', { name: endpoint.id })).toHaveAttribute( + ARIA_SELECTED, + 'false' + ); + }); + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx index 382ed64eb92..a2837c587da 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx @@ -1,11 +1,13 @@ import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; import { Box } from '@mui/material'; -import React, { useMemo } from 'react'; +import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { ENDPOINT, RESOURCE_FILTER_MAP } from '../Utils/constants'; import { filterEndpointsUsingRegion } from '../Utils/FilterBuilder'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { deepEqual } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; @@ -13,20 +15,15 @@ import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; -export interface CloudPulseEndpoints { - /** - * The label of the endpoint which is 's3_endpoint' in the response from the API - */ - label: string; +export interface CloudPulseEndpointsSelectProps { /** - * The region of the endpoint + * The dashboard id for the endpoints filter */ - region: string; -} - -export interface CloudPulseEndpointsSelectProps { + dashboardId: number; /** * The default value of the endpoints filter */ @@ -39,6 +36,10 @@ export interface CloudPulseEndpointsSelectProps { * The function to handle the endpoints selection */ handleEndpointsSelection: (endpoints: string[], savePref?: boolean) => void; + /** + * Whether to restrict the selections + */ + hasRestrictedSelections?: boolean; /** * The label of the endpoints filter */ @@ -70,6 +71,7 @@ export const CloudPulseEndpointsSelect = React.memo( const { defaultValue, disabled, + dashboardId, handleEndpointsSelection, label, placeholder, @@ -77,39 +79,32 @@ export const CloudPulseEndpointsSelect = React.memo( serviceType, savePreferences, xFilter, + hasRestrictedSelections, } = props; + const flags = useFlags(); + + // Get the endpoints filter configuration for the dashboard + const endpointsFilterConfig = FILTER_CONFIG.get(dashboardId)?.filters.find( + (filter) => filter.configuration.filterKey === ENDPOINT + ); + const filterFn = endpointsFilterConfig?.configuration.filterFn; + const { - data: buckets, + data: validSortedEndpoints, isError, isLoading, } = useResourcesQuery( disabled !== undefined ? !disabled : Boolean(region && serviceType), serviceType, {}, - - RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {} + RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {}, + undefined, + filterFn ); - const validSortedEndpoints = useMemo(() => { - if (!buckets) return []; - - const visitedEndpoints = new Set(); - const uniqueEndpoints: CloudPulseEndpoints[] = []; - - buckets.forEach(({ endpoint, region }) => { - if (endpoint && region && !visitedEndpoints.has(endpoint)) { - visitedEndpoints.add(endpoint); - uniqueEndpoints.push({ label: endpoint, region }); - } - }); - - uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label)); - return uniqueEndpoints; - }, [buckets]); - const [selectedEndpoints, setSelectedEndpoints] = - React.useState(); + React.useState(); /** * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below @@ -118,17 +113,49 @@ export const CloudPulseEndpointsSelect = React.memo( */ const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete - const getEndpointsList = React.useMemo(() => { + const getEndpointsList = React.useMemo(() => { return filterEndpointsUsingRegion(validSortedEndpoints, xFilter) ?? []; }, [validSortedEndpoints, xFilter]); + // Maximum endpoints selection limit is fetched from launchdarkly + const maxEndpointsSelectionLimit = React.useMemo(() => { + const obj = flags.aclpResourceTypeMap?.find( + (item: CloudPulseResourceTypeMapFlag) => + item.serviceType === serviceType + ); + return obj?.maxResourceSelections || 10; + }, [serviceType, flags.aclpResourceTypeMap]); + + const endpointsLimitReached = React.useMemo(() => { + return getEndpointsList.length > maxEndpointsSelectionLimit; + }, [getEndpointsList.length, maxEndpointsSelectionLimit]); + + // Disable Select All option if the number of available endpoints are greater than the limit + const disableSelectAll = hasRestrictedSelections + ? endpointsLimitReached + : false; + + const errorText = isError ? `Failed to fetch ${label || 'Endpoints'}.` : ''; + const helperText = + !isError && hasRestrictedSelections + ? `Select up to ${maxEndpointsSelectionLimit} ${label}` + : ''; + + // Check if the number of selected endpoints are greater than or equal to the limit + const maxSelectionsReached = React.useMemo(() => { + return ( + selectedEndpoints && + selectedEndpoints.length >= maxEndpointsSelectionLimit + ); + }, [selectedEndpoints, maxEndpointsSelectionLimit]); + // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { if (disabled && !selectedEndpoints) { return; } // To save default values, go through side effects if disabled is false - if (!buckets || !savePreferences || selectedEndpoints) { + if (!validSortedEndpoints || !savePreferences || selectedEndpoints) { if (selectedEndpoints) { setSelectedEndpoints([]); handleEndpointsSelection([]); @@ -146,7 +173,7 @@ export const CloudPulseEndpointsSelect = React.memo( setSelectedEndpoints(endpoints); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [buckets, region, xFilter, serviceType]); + }, [validSortedEndpoints, region, xFilter, serviceType]); return ( option.label === value.label} label={label || 'Endpoints'} limitTags={1} @@ -198,8 +227,20 @@ export const CloudPulseEndpointsSelect = React.memo( ? StyledListItem : 'li'; + const isMaxSelectionsReached = + maxSelectionsReached && + !isEndpointSelected && + !isSelectAllORDeslectAllOption; + return ( - + <> {option.label} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c5bc0413b1d..d0eb07d5767 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3563,6 +3563,13 @@ export const handlers = [ service_type: 'objectstorage', }) ); + response.data.push( + dashboardFactory.build({ + id: 10, + label: 'Endpoint Dashboard', + service_type: 'objectstorage', + }) + ); } if (params.serviceType === 'blockstorage') { @@ -3983,6 +3990,9 @@ export const handlers = [ } else if (id === '9') { serviceType = 'lke'; dashboardLabel = 'Kubernetes Enterprise Dashboard'; + } else if (id === '10') { + serviceType = 'objectstorage'; + dashboardLabel = 'Endpoint Dashboard'; } else { serviceType = 'linode'; dashboardLabel = 'Linode Service I/O Statistics'; diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index 1dfbe72d55d..9b4fd8a535a 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -45,7 +45,7 @@ export const useResourcesQuery = ( } const id = resourceType === 'objectstorage' - ? resource.hostname + ? resource.hostname || resource.id : String(resource.id); return { engineType: resource.engine, From a5e1f488ae777cac0cd51a2a954c182559c97fad Mon Sep 17 00:00:00 2001 From: venkatmano-akamai Date: Wed, 26 Nov 2025 12:53:21 +0530 Subject: [PATCH 41/91] upcoming: [DI-28222] : Integration for widget Level dimension filters in cloudpulse metrics (#13116) * upcoming: [DI-28222] - Integration changes for dimension filters * upcoming: [DI-28222] - Integration changes for dimension filters * upcoming: [DI-28222] - Fix key issue * upcoming: [DI-28222] - Code refactoring and issue fixes * upcoming: [DI-28222] - Code refactoring * upcoming: [DI-28222] - Add UT for renderers * upcoming: [DI-28222] - Code refactoring to continue support widget filters, incase of widget dimension filters not supported * upcoming: [DI-28222] - UT fix --------- Co-authored-by: agorthi-akamai --- ...r-13116-upcoming-features-1763642170773.md | 5 + .../dbaas-widgets-verification.spec.ts | 8 +- .../FirewallDimensionFilterAutocomplete.tsx | 5 +- .../ValueFieldRenderer.tsx | 1 + .../features/CloudPulse/Utils/FilterConfig.ts | 8 + .../src/features/CloudPulse/Utils/utils.ts | 11 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 161 +++++++++++- .../Widget/CloudPulseWidgetRenderer.tsx | 7 + .../CloudPulseDimensionFilterDrawer.test.tsx | 7 + .../CloudPulseDimensionFilterDrawer.tsx | 8 + .../CloudPulseDimensionFilterFields.test.tsx | 92 +++++++ .../CloudPulseDimensionFilterFields.tsx | 236 ++++++++++++++++++ ...CloudPulseDimensionFilterRenderer.test.tsx | 188 ++++++++++++++ .../CloudPulseDimensionFilterRenderer.tsx | 50 +++- .../CloudPulseDimensionFiltersSelect.test.tsx | 1 + .../CloudPulseDimensionFiltersSelect.tsx | 8 + .../CloudPulse/shared/DimensionTransform.ts | 1 + packages/manager/src/mocks/serverHandlers.ts | 24 ++ .../manager/src/queries/cloudpulse/metrics.ts | 3 +- .../utilities/src/__data__/regionsData.ts | 5 +- 20 files changed, 799 insertions(+), 30 deletions(-) create mode 100644 packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx create mode 100644 packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx diff --git a/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md b/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md new file mode 100644 index 00000000000..a3617688321 --- /dev/null +++ b/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add integration changes with `CloudPulseWidget` for widget level dimension support in CloudPulse metrics ([#13116](https://github.com/linode/manager/pull/13116)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index c4968097b77..4f3d43a6bb1 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -104,7 +104,7 @@ const dashboard = dashboardFactory.build({ widgets: metrics.map(({ name, title, unit, yLabel }) => widgetFactory.build({ entity_ids: [String(id)], - filters: [...dimensions], + filters: [], label: title, metric: name, unit, @@ -367,7 +367,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); }); @@ -462,7 +462,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { const nodeTypeFilter = filters.filter( (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); @@ -537,7 +537,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { const nodeTypeFilter = filters.filter( (filter: DimensionFilter) => filter.dimension_label === 'node_type' ); - expect(nodeTypeFilter).to.have.length(2); + expect(nodeTypeFilter).to.have.length(1); expect(nodeTypeFilter[0].operator).to.equal('eq'); expect(nodeTypeFilter[0].value).to.equal('secondary'); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx index 09b482f646d..6d5d2715c0f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/FirewallDimensionFilterAutocomplete.tsx @@ -30,6 +30,7 @@ export const FirewallDimensionFilterAutocomplete = ( scope, serviceType, type, + selectedRegions, } = props; const { data: regions } = useRegionsQuery(); @@ -37,8 +38,10 @@ export const FirewallDimensionFilterAutocomplete = ( const { values, isLoading, isError } = useFirewallFetchOptions({ associatedEntityType: entityType, dimensionLabel, + regions: selectedRegions + ? regions?.filter(({ id }) => selectedRegions.includes(id)) + : regions, entities, - regions, scope, serviceType, type, diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx index 40a5e2ef8ed..bb85115f0fd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer.tsx @@ -194,6 +194,7 @@ export const ValueFieldRenderer = (props: ValueFieldRendererProps) => { entityType={entityType} placeholderText={config.placeholder ?? autocompletePlaceholder} scope={scope} + selectedRegions={selectedRegions} /> ); case 'objectstorage': diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 0f6debbc6bb..9ab7b01b533 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -151,6 +151,7 @@ export const DBAAS_CONFIG: Readonly = { isMetricsFilter: false, // if it is false, it will go as a part of filter params, else global filter isMultiSelect: false, name: 'Node Type', + dimensionKey: 'node_type', neededInViews: [ CloudPulseAvailableViews.service, CloudPulseAvailableViews.central, @@ -203,6 +204,7 @@ export const NODEBALANCER_CONFIG: Readonly = { isMetricsFilter: false, isOptional: true, name: 'Ports', + dimensionKey: 'port', neededInViews: [ CloudPulseAvailableViews.central, CloudPulseAvailableViews.service, @@ -256,6 +258,7 @@ export const FIREWALL_CONFIG: Readonly = { isMetricsFilter: true, isMultiSelect: false, name: 'Linode Region', + dimensionKey: 'region_id', neededInViews: [ CloudPulseAvailableViews.central, CloudPulseAvailableViews.service, @@ -274,6 +277,7 @@ export const FIREWALL_CONFIG: Readonly = { isMultiSelect: true, name: 'Interface Types', isOptional: true, + dimensionKey: 'interface_type', neededInViews: [ CloudPulseAvailableViews.central, CloudPulseAvailableViews.service, @@ -302,6 +306,7 @@ export const FIREWALL_CONFIG: Readonly = { isMetricsFilter: false, isOptional: true, name: 'Interface IDs', + dimensionKey: 'interface_id', neededInViews: [ CloudPulseAvailableViews.central, CloudPulseAvailableViews.service, @@ -359,6 +364,7 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly getValidSortedEndpoints(resources), diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 974a59bc158..b6173898d61 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -481,7 +481,10 @@ export const isValidFilter = ( if (!dimension) return false; const dimensionConfig = - valueFieldConfig[filter.dimension_label] ?? valueFieldConfig['*']; + valueFieldConfig[filter.dimension_label] ?? + valueFieldConfig[ + !dimension.values || dimension.values.length === 0 ? 'emptyValue' : '*' + ]; const dimensionFieldConfig = dimensionConfig[operatorGroup]; @@ -493,11 +496,7 @@ export const isValidFilter = ( String(filter.value ?? ''), dimensionFieldConfig ); - } else if ( - dimensionFieldConfig.type === 'textfield' || - !dimension.values || - !dimension.values.length - ) { + } else if (dimensionFieldConfig.type === 'textfield') { return true; } diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index e401c2876cc..bba0b004af4 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -1,4 +1,4 @@ -import { useProfile } from '@linode/queries'; +import { useProfile, useRegionsQuery } from '@linode/queries'; import { Box, Paper, Typography } from '@linode/ui'; import { GridLegacy, Stack, useTheme } from '@mui/material'; import { DateTime } from 'luxon'; @@ -7,6 +7,8 @@ import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; +import { useBlockStorageFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useBlockStorageFetchOptions'; +import { useFirewallFetchOptions } from '../Alerts/CreateAlert/Criteria/DimensionFilterValue/useFirewallFetchOptions'; import { WidgetFilterGroupByRenderer } from '../GroupBy/WidgetFilterGroupByRenderer'; import { generateGraphData, @@ -18,10 +20,17 @@ import { SIZE, TIME_GRANULARITY, } from '../Utils/constants'; -import { constructAdditionalRequestFilters } from '../Utils/FilterBuilder'; +import { + constructAdditionalRequestFilters, + constructWidgetDimensionFilters, +} from '../Utils/FilterBuilder'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; import { generateCurrentUnit } from '../Utils/unitConversion'; import { useAclpPreference } from '../Utils/UserPreference'; -import { convertStringToCamelCasesWithSpaces } from '../Utils/utils'; +import { + convertStringToCamelCasesWithSpaces, + getFilteredDimensions, +} from '../Utils/utils'; import { CloudPulseAggregateFunction } from './components/CloudPulseAggregateFunction'; import { CloudPulseIntervalSelect } from './components/CloudPulseIntervalSelect'; import { CloudPulseLineGraph } from './components/CloudPulseLineGraph'; @@ -30,6 +39,7 @@ import { ZoomIcon } from './components/Zoomer'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { MetricsDimensionFilter } from './components/DimensionFilters/types'; import type { CloudPulseServiceType, DateTimeWithPreset, @@ -183,6 +193,9 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { dashboardId, region, } = props; + const [dimensionFilters, setDimensionFilters] = React.useState< + MetricsDimensionFilter[] | undefined + >(widget.filters); const timezone = duration.timeZone ?? profile?.timezone ?? DateTime.local().zoneName; @@ -191,13 +204,93 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const scaledWidgetUnit = React.useRef(generateCurrentUnit(unit)); const jweTokenExpiryError = 'Token expired'; - const filters: Filters[] | undefined = - additionalFilters?.length || widget?.filters?.length + const { data: regions } = useRegionsQuery(); + const linodesFetch = useFirewallFetchOptions({ + dimensionLabel: 'linode_id', + type: 'metrics', + entities: entityIds, + regions: regions?.filter((region) => region.id === linodeRegion) ?? [], + scope: 'entity', + serviceType, + associatedEntityType: FILTER_CONFIG.get(dashboardId)?.associatedEntityType, + }); + const vpcFetch = useFirewallFetchOptions({ + dimensionLabel: 'vpc_subnet_id', + type: 'metrics', + entities: entityIds, + regions: regions?.filter((region) => region.id === linodeRegion) ?? [], + scope: 'entity', + serviceType, + associatedEntityType: FILTER_CONFIG.get(dashboardId)?.associatedEntityType, + }); + const linodeFromVolumes = useBlockStorageFetchOptions({ + entities: entityIds, + dimensionLabel: 'linode_id', + regions: regions?.filter(({ id }) => id === region) ?? [], + type: 'metrics', + scope: 'entity', + serviceType, + }); + // Determine which fetch object is relevant for linodes + const activeLinodeFetch = + serviceType === 'blockstorage' ? linodeFromVolumes : linodesFetch; + + // Combine loading states + const isLoadingFilters = activeLinodeFetch.isLoading || vpcFetch.isLoading; + + const excludeDimensionFilters = React.useMemo(() => { + return ( + FILTER_CONFIG.get(dashboardId) + ?.filters.filter( + ({ configuration }) => configuration.dimensionKey !== undefined + ) + .map(({ configuration }) => configuration.dimensionKey) ?? [] + ); + }, [dashboardId]); + const filteredDimensions = React.useMemo(() => { + return excludeDimensionFilters && excludeDimensionFilters.length > 0 + ? availableMetrics?.dimensions.filter( + ({ dimension_label: dimensionLabel }) => + !excludeDimensionFilters.includes(dimensionLabel) + ) + : availableMetrics?.dimensions; + }, [availableMetrics?.dimensions, excludeDimensionFilters]); + + const filteredSelections = React.useMemo(() => { + if (isLoadingFilters || !flags.aclp?.showWidgetDimensionFilters) { + return dimensionFilters ?? []; + } + + return getFilteredDimensions({ + dimensions: filteredDimensions ?? [], + linodes: activeLinodeFetch, + vpcs: vpcFetch, + dimensionFilters, + }); + }, [ + activeLinodeFetch, + dimensionFilters, + filteredDimensions, + flags.aclp?.showWidgetDimensionFilters, + isLoadingFilters, + vpcFetch, + ]); + + const filters: Filters[] | undefined = React.useMemo(() => { + return additionalFilters?.length || + widget?.filters?.length || + dimensionFilters?.length ? [ ...constructAdditionalRequestFilters(additionalFilters ?? []), - ...(widget.filters ?? []), + ...[...(constructWidgetDimensionFilters(filteredSelections) ?? [])], // dashboard level filters followed by widget filters ] : undefined; + }, [ + additionalFilters, + widget?.filters?.length, + dimensionFilters?.length, + filteredSelections, + ]); /** * @@ -274,6 +367,34 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, [] ); + + const handleDimensionFiltersChange = React.useCallback( + (selectedFilters: MetricsDimensionFilter[]) => { + if (savePref) { + updatePreferences(widget.label, { + filters: selectedFilters + .map((filter) => { + if ( + filter.value !== null && + filter.dimension_label !== null && + filter.operator !== null + ) { + return { + dimension_label: filter.dimension_label, + operator: filter.operator, + value: filter.value, + }; + } else { + return undefined; + } + }) + .filter((filter) => filter !== undefined), + }); + } + setDimensionFilters(selectedFilters); + }, + [savePref, updatePreferences, widget.label] + ); const { data: metricsList, error, @@ -295,6 +416,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { filters, // any additional dimension filters will be constructed and passed here }, { + isFiltersLoading: isLoadingFilters, authToken, isFlags: Boolean(flags && !isJweTokenFetching), label: widget.label, @@ -332,6 +454,24 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const end = DateTime.fromISO(duration.end, { zone: 'GMT' }); const hours = end.diff(start, 'hours').hours; const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; + + React.useEffect(() => { + if ( + filteredSelections.length !== (dimensionFilters?.length ?? 0) && + !linodesFetch.isLoading && + !vpcFetch.isLoading && + !linodeFromVolumes.isLoading + ) { + handleDimensionFiltersChange(filteredSelections); + } + }, [ + filteredSelections, + dimensionFilters, + handleDimensionFiltersChange, + linodesFetch.isLoading, + vpcFetch.isLoading, + linodeFromVolumes.isLoading, + ]); return ( { )} {flags.aclp?.showWidgetDimensionFilters && ( - {}} - selectedDimensions={[]} + handleSelectionChange={handleDimensionFiltersChange} + selectedDimensions={filteredSelections} selectedEntities={entityIds} selectedRegions={linodeRegion ? [linodeRegion] : undefined} serviceType={serviceType} diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx index 0a3d1d7725a..d3917306c03 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx @@ -1,6 +1,8 @@ import { GridLegacy, Paper } from '@mui/material'; import React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { CloudPulseErrorPlaceholder } from '../shared/CloudPulseErrorPlaceholder'; import { createObjectCopy } from '../Utils/utils'; import { CloudPulseWidget } from './CloudPulseWidget'; @@ -75,6 +77,8 @@ export const RenderWidgets = React.memo( region, } = props; + const flags = useFlags(); + const getCloudPulseGraphProperties = ( widget: Widgets ): CloudPulseWidgetProperties => { @@ -124,6 +128,9 @@ export const RenderWidgets = React.memo( ...(pref.timeGranularity ?? autoIntervalOption), }, group_by: pref.groupBy, + filters: flags.aclp?.showWidgetDimensionFilters + ? (pref.filters ?? widgetObj.filters) + : widgetObj.filters, }; } else { return { diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.test.tsx index 0ccf393455a..a25ada18719 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.test.tsx @@ -26,6 +26,13 @@ describe('CloudPulse dimension filter drawer tests', () => { const selectText = screen.getByText('Select up to 5 filters.'); expect(selectText).toBeInTheDocument(); await userEvent.click(screen.getByText('Add Filter')); + // validate for form fields to be present + const dataFieldContainer = screen.queryByTestId('dimension-field'); + expect(dataFieldContainer).toBeInTheDocument(); + const operatorContainer = screen.getByTestId('operator'); + expect(operatorContainer).toBeInTheDocument(); + const valueContainer = screen.getByTestId('value'); + expect(valueContainer).toBeInTheDocument(); const applyButton = screen.getByText('Apply'); expect(applyButton).toBeInTheDocument(); const cancelButton = screen.getByText('Cancel'); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx index 0db4f2d7921..98314058003 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterDrawer.tsx @@ -8,8 +8,13 @@ import type { MetricsDimensionFilterForm, } from './types'; import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; interface CloudPulseDimensionFilterDrawerProps { + /** + * The entity type associated with the serviceType + */ + associatedEntityType?: AssociatedEntityType; /** * The list of dimensions associated with the selected metric */ @@ -34,6 +39,7 @@ interface CloudPulseDimensionFilterDrawerProps { * The boolean value to control the drawer open state */ open: boolean; + /** * The selected dimension filters for the metric */ @@ -67,6 +73,7 @@ export const CloudPulseDimensionFilterDrawer = React.memo( selectedEntities, serviceType, selectedRegions, + associatedEntityType, } = props; const [clearAllTrigger, setClearAllTrigger] = React.useState(0); @@ -135,6 +142,7 @@ export const CloudPulseDimensionFilterDrawer = React.memo( { + it('renders the filter fields based on the dimension options', async () => { + const handleDelete = vi.fn(); + renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + dimension_filters: [ + { + dimension_label: null, + operator: null, + value: null, + }, + ], + }, + }, + }); + const dataFieldContainer = screen.getByTestId('dimension-field'); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(dataFieldInput); + dimensionOptions.forEach(({ label }) => { + screen.getByRole('option', { + name: label, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { name: dimensionOptions[0].label }) + ); + const operatorContainer = screen.getByTestId('operator'); + const operatorFieldInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(operatorFieldInput); + screen.getByRole('option', { + name: 'In', + }); // implicit assertion + screen.getByRole('option', { + name: 'Equal', + }); + await userEvent.click(screen.getByRole('option', { name: 'Equal' })); + const valueContainer = screen.getByTestId('value'); + const valueFieldInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(valueFieldInput); + dimensionOptions[0].values.forEach((value) => { + screen.getByRole('option', { + name: value, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { name: dimensionOptions[0].values[2] }) + ); + + // click on delete and see if handle delete is called + await userEvent.click(screen.getByTestId('clear-icon')); + expect(handleDelete).toBeCalledTimes(1); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx new file mode 100644 index 00000000000..262336d9d1e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterFields.tsx @@ -0,0 +1,236 @@ +import { Autocomplete, Box } from '@linode/ui'; +import { GridLegacy } from '@mui/material'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import type { FieldPathByValue } from 'react-hook-form'; + +import { dimensionOperatorOptions } from 'src/features/CloudPulse/Alerts/constants'; +import { ClearIconButton } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/ClearIconButton'; +import { ValueFieldRenderer } from 'src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/ValueFieldRenderer'; + +import type { + MetricsDimensionFilter, + MetricsDimensionFilterForm, +} from './types'; +import type { + CloudPulseServiceType, + Dimension, + DimensionFilterOperatorType, +} from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; + +interface CloudPulseDimensionFilterFieldsProps { + /** + * The entity type associated with the service type + */ + associatedEntityType?: AssociatedEntityType; + /** + * The dimension filter data options to list in the Autocomplete component + */ + dimensionOptions: Dimension[]; + + /** + * The name (with the index) used for the component to set in form + */ + name: FieldPathByValue; + + /** + * Callback function to delete the DimensionFilter component + */ + onFilterDelete: () => void; + + /** + * The selected entities for the dimension filter + */ + selectedEntities?: string[]; + + /** + * The selected regions of the associated entities + */ + selectedRegions?: string[]; + + /** + * The service type of the associated metric + */ + serviceType: CloudPulseServiceType; +} + +export const CloudPulseDimensionFilterFields = React.memo( + (props: CloudPulseDimensionFilterFieldsProps) => { + const { + dimensionOptions, + name, + onFilterDelete, + selectedEntities, + serviceType, + selectedRegions, + associatedEntityType, + } = props; + + const { control, setValue } = useFormContext(); + + const dataFieldOptions = React.useMemo( + () => + dimensionOptions.map(({ label, dimension_label: dimensionLabel }) => ({ + label, + value: dimensionLabel, + })) ?? [], + [dimensionOptions] + ); + + const handleDataFieldChange = React.useCallback( + (selected: { label: string; value: string }, operation: string) => { + const fieldValue = { + dimension_label: null, + operator: null, + value: null, + }; + if (operation === 'selectOption') { + setValue(`${name}.dimension_label`, selected.value, { + shouldValidate: true, + shouldDirty: true, + }); + setValue(`${name}.operator`, fieldValue.operator); + setValue(`${name}.value`, fieldValue.value); + } else { + setValue(name, fieldValue); + } + }, + [name, setValue] + ); + + const dimensionFieldWatcher = useWatch({ + control, + name: `${name}.dimension_label`, + }); + + const dimensionOperatorWatcher = useWatch({ + control, + name: `${name}.operator`, + }); + + const selectedDimension = React.useMemo( + () => + dimensionOptions && dimensionFieldWatcher + ? (dimensionOptions.find( + ({ dimension_label: dimensionLabel }) => + dimensionLabel === dimensionFieldWatcher + ) ?? null) + : null, + [dimensionFieldWatcher, dimensionOptions] + ); + + return ( + + + ( + { + handleDataFieldChange(newValue, operation); + }} + options={dataFieldOptions} + placeholder="Select a Dimension" + value={ + dataFieldOptions.find( + (option) => option.value === field.value + ) ?? null + } + /> + )} + /> + + + ( + { + field.onChange( + operation === 'selectOption' ? newValue.value : null + ); + setValue(`${name}.value`, null); + }} + options={dimensionOperatorOptions} + placeholder="Select an Operator" + value={ + dimensionOperatorOptions.find( + (option) => option.value === field.value + ) ?? null + } + /> + )} + /> + + + ( + + )} + /> + + + ({ + marginTop: 6, + [theme.breakpoints.down('md')]: { + marginTop: 3, + }, + })} + > + + + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx new file mode 100644 index 00000000000..9a17bc814af --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.test.tsx @@ -0,0 +1,188 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseDimensionFilterRenderer } from './CloudPulseDimensionFilterRenderer'; + +import type { Dimension } from '@linode/api-v4'; + +const dimensionOptions: Dimension[] = [ + { + dimension_label: 'test', + values: ['XYZ', 'ZYX', 'YZX'], + label: 'Test', + }, + { + dimension_label: 'sample', + values: ['VALUE1', 'VALUE2', 'VALUE3'], + label: 'Sample', + }, +]; +const dimensionFilterForZerothIndex = 'dimension_filters.0-id'; +const addFilter = 'Add Filter'; + +describe('CloudPulse dimension filter field tests', () => { + it('renders the filter fields based on the dimension options', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + const handleDimensionChange = vi.fn(); + renderWithTheme( + + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId(dimensionFilterForZerothIndex), + 0, + 'Equal', + 2 + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId('dimension_filters.1-id'), + 1, + 'Not Equal', + 0 + ); + await userEvent.click(screen.getByText('Apply')); + expect(handleClose).not.toHaveBeenCalled(); + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(handleDimensionChange).toHaveBeenCalledTimes(3); + expect(handleSubmit).toHaveBeenLastCalledWith({ + dimension_filters: [ + { + dimension_label: 'test', + operator: 'eq', + value: 'YZX', + }, + { + dimension_label: 'sample', + operator: 'neq', + value: 'VALUE1', + }, + ], + }); + }); + it('handles the cancel button correctly', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + renderWithTheme( + + ); + await userEvent.click(screen.getByTestId(addFilter)); + await selectADimensionAndValue( + screen.getByTestId(dimensionFilterForZerothIndex), + 0, + 'Equal', + 2 + ); + await userEvent.click(screen.getByText('Cancel')); + expect(handleClose).toHaveBeenCalledTimes(1); + expect(handleSubmit).not.toHaveBeenCalled(); + }); + it('handles the case when a proper already selected dimension is passed', async () => { + const handleClose = vi.fn(); + const handleSubmit = vi.fn(); + const handleDimensionChange = vi.fn(); + renderWithTheme( + + ); + const dimensionContainer = screen.getByTestId( + dimensionFilterForZerothIndex + ); + const dimension = + within(dimensionContainer).getByPlaceholderText('Select a Dimension'); + expect(dimension).toHaveValue('Test'); + const operator = + within(dimensionContainer).getByPlaceholderText('Select an Operator'); + expect(operator).toHaveValue('Starts with'); + const value = + within(dimensionContainer).getByPlaceholderText('Enter a Value'); + expect(value).toHaveValue('ZYX'); + expect(screen.getByText('Apply')).toHaveAttribute('aria-disabled', 'true'); // form is not changed, so the apply button is disabled in this case + }); +}); + +const selectADimensionAndValue = async ( + dimensionFilterContainer: HTMLElement, + dimensionOptionIndex: number, + operator: string, + valueOptionIndex: number +) => { + const dataFieldContainer = within(dimensionFilterContainer).getByTestId( + 'dimension-field' + ); + const dataFieldInput = within(dataFieldContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(dataFieldInput); + dimensionOptions.forEach(({ label }) => { + screen.getByRole('option', { + name: label, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { + name: dimensionOptions[dimensionOptionIndex].label, + }) + ); + const operatorContainer = within(dimensionFilterContainer).getByTestId( + 'operator' + ); + const operatorFieldInput = within(operatorContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(operatorFieldInput); + screen.getByRole('option', { + name: 'In', + }); // implicit assertion + screen.getByRole('option', { + name: 'Equal', + }); + await userEvent.click(screen.getByRole('option', { name: operator })); + const valueContainer = within(dimensionFilterContainer).getByTestId('value'); + const valueFieldInput = within(valueContainer).getByRole('button', { + name: 'Open', + }); + await userEvent.click(valueFieldInput); + dimensionOptions[dimensionOptionIndex].values.forEach((value) => { + screen.getByRole('option', { + name: value, + }); // implicit assertion + }); + await userEvent.click( + screen.getByRole('option', { + name: dimensionOptions[dimensionOptionIndex].values[valueOptionIndex], + }) + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx index f4cbc390ce6..42d7d6d13d3 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/DimensionFilters/CloudPulseDimensionFilterRenderer.tsx @@ -1,5 +1,5 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { ActionsPanel, Box, Button } from '@linode/ui'; +import { ActionsPanel, Box, Button, Divider, Stack } from '@linode/ui'; import React from 'react'; import { FormProvider, @@ -8,6 +8,7 @@ import { useWatch, } from 'react-hook-form'; +import { CloudPulseDimensionFilterFields } from './CloudPulseDimensionFilterFields'; import { metricDimensionFiltersSchema } from './schema'; import type { @@ -15,37 +16,43 @@ import type { MetricsDimensionFilterForm, } from './types'; import type { CloudPulseServiceType, Dimension } from '@linode/api-v4'; +import type { AssociatedEntityType } from 'src/features/CloudPulse/shared/types'; interface CloudPulseDimensionFilterRendererProps { + /** + * The entity type associated with the service type + */ + associatedEntityType?: AssociatedEntityType; + /** * The clear all trigger to reset the form */ clearAllTrigger: number; - /** * The list of dimensions associated with the selected metric */ dimensionOptions: Dimension[]; + /** * Callback triggered to close the drawer */ onClose: () => void; - /** * Callback to publish any change in form * @param isDirty indicated the changes */ onDimensionChange: (isDirty: boolean) => void; + /** * Callback triggered on form submission * @param data The form data on submission */ onSubmit: (data: MetricsDimensionFilterForm) => void; - /** * The selected dimension filters for the metric */ selectedDimensions?: MetricsDimensionFilter[]; + /** * The selected entities for the dimension filter */ @@ -69,6 +76,11 @@ export const CloudPulseDimensionFilterRenderer = React.memo( clearAllTrigger, onClose, onDimensionChange, + dimensionOptions, + selectedEntities = [], + serviceType, + selectedRegions, + associatedEntityType, } = props; const formMethods = useForm({ @@ -93,7 +105,7 @@ export const CloudPulseDimensionFilterRenderer = React.memo( }); }); - const { append, fields } = useFieldArray({ + const { append, fields, remove } = useFieldArray({ control, name: 'dimension_filters', }); @@ -126,7 +138,33 @@ export const CloudPulseDimensionFilterRenderer = React.memo( - {/* upcoming: Integrate with dimension filter row component */} + + {fields?.length > 0 && + fields.map((field, index) => ( + + remove(index)} + selectedEntities={selectedEntities} + selectedRegions={selectedRegions} + serviceType={serviceType} + /> + ({ + display: 'none', + [theme.breakpoints.down('md')]: { + // only show the divider for smaller screens + display: + index === fields.length - 1 ? 'none' : 'flex', + }, + })} + /> + + ))} + + {formType === 'stream' && ( + + By using this service, you acknowledge your obligations under the + United States Department of Justice Bulk Sensitive Data Transaction + Rule ("BSD Rule"). You also agree that you will not use the + service to transfer, onward transfer, or otherwise make accessible + any United States government-related data or bulk United States + sensitive personal data to countries of concern or a covered person, + as each of those terms and concepts are defined in the{' '} + + BSD Rule + + . Anyone using the service is solely responsible for compliance with + the BSD Rule. + + )} ); From dcf82abd25d626ac409df925bdd068d0d66d9bd7 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 26 Nov 2025 16:45:36 +0530 Subject: [PATCH 45/91] upcoming: [UIE-9684, UIE-9699] - Add `generateAddressesLabelV2` utility to support PrefixLists (#13122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Save progress * Update tests * Few more changes * Add more changes * Clean up tests * Few changes * Layout updates * Update tests * Add ruleset loading state * Clean up mocks * Fix mocks * Add comments to the type * Added changeset: Update FirewallRuleType to support ruleset * Added changeset: Update FirewallRuleTypeSchema to support ruleset * Added changeset: Add new Firewall RuleSet row layout * Update ruleset action text - Delete to Remove * Save progress... * Update comment * Exclude 'addresses' from rulesets reference payloads * Some fixes * Add more details to the drawer for rulset * More changes... * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Add more changes to the drawer for rulesets * Update gap & fontsize of firwall add rules selection card * Fix Chip shrink issue * Revert Action column movement since its not yet confirmed * More Refactoring + better formstate typesaftey * Add renderOptions for Add ruleset Autocomplete * Fix typos * Few updates * Few fixes * Update cypress component tests * More changes * Update Add rulesets button copy * More Updates * Feature flag create entity selection for ruleset * More refactoring - separating form states * Add ruleset details drawer * Some clean up... * Save progress * Add more changes * Show only rulsets in dropdown applicable to the given catergory * Update Date format and some minor changes * Update mark for deletion date format * Update date format * Update badge color tokens * Capitalize action label in chip * Update Chip width * Added changeset: Update Firewall Rule Drawer to support referencing Rule Set * Use right color tokens for badge * Update placeholder for Select Rule Set * Some clean up * Few updates and clean up * Make cy test work * Clean up: remove duplicate validation * Add cancel btn for rules form + some design tokens for dropdown options * Add cancel btn and some styling fixes * Add mocks for Marked for deletion status * Add generateAddressesLabelV2 utility to support prefixlists * Add unit tests for Add Rule Set Drawer * Update test title * Mock useIsFirewallRulesetsPrefixlistsEnabled instead of feature flag * Save progress * Fix styling and a bit of clean up * Add more mocks * Add more mock Ips & PLs * Clean up and refactor * Minor styling fixes * Add unit tests * Add omitted props for StyledListItem * Added changeset: New Rule Set Details drawer with Marked for Deletion status * Update MaskableText to support JSX and Move Action col as a global change * Consolidate imports * Added changeset: Move Action column to the 2nd position in the Firewall Rules Table * Added changeset: Add `generateAddressesLabelV2` utility to support PrefixLists * Some clean up * Update unit tests * Fix typo * More changes and add tests * Make Rule Drawer accessible via routes * Improve tests * Add error state * Mock getUserTimezone * Some Clean up and allow route-based access only for view and create modes * Few changes * Update naming convention * Update Cancel button type to secondary * Add code comment for clarity * Update tooltip styling * Add MaskableText tests for JSX input --- .../pr-13122-changed-1763759617340.md | 5 + ...r-13122-upcoming-features-1763759704504.md | 5 + .../MaskableText/MaskableText.test.tsx | 62 ++ .../components/MaskableText/MaskableText.tsx | 21 +- .../Rules/FirewallRuleSetDetailsView.tsx | 9 +- .../Rules/FirewallRuleSetForm.tsx | 7 +- .../Rules/FirewallRuleTable.tsx | 32 +- .../src/features/Firewalls/shared.test.ts | 205 ------- .../src/features/Firewalls/shared.test.tsx | 405 +++++++++++++ .../manager/src/features/Firewalls/shared.ts | 282 --------- .../manager/src/features/Firewalls/shared.tsx | 562 ++++++++++++++++++ packages/manager/src/mocks/serverHandlers.ts | 56 +- .../manager/src/utilities/createMaskedText.ts | 9 +- 13 files changed, 1143 insertions(+), 517 deletions(-) create mode 100644 packages/manager/.changeset/pr-13122-changed-1763759617340.md create mode 100644 packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md delete mode 100644 packages/manager/src/features/Firewalls/shared.test.ts create mode 100644 packages/manager/src/features/Firewalls/shared.test.tsx delete mode 100644 packages/manager/src/features/Firewalls/shared.ts create mode 100644 packages/manager/src/features/Firewalls/shared.tsx diff --git a/packages/manager/.changeset/pr-13122-changed-1763759617340.md b/packages/manager/.changeset/pr-13122-changed-1763759617340.md new file mode 100644 index 00000000000..06175a04a56 --- /dev/null +++ b/packages/manager/.changeset/pr-13122-changed-1763759617340.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Move Action column to the 2nd position in the Firewall Rules Table ([#13122](https://github.com/linode/manager/pull/13122)) diff --git a/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md b/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md new file mode 100644 index 00000000000..e05470547d8 --- /dev/null +++ b/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add `generateAddressesLabelV2` utility to support PrefixLists ([#13122](https://github.com/linode/manager/pull/13122)) diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx index 6bd0d5c999b..de4e2bfcb53 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.test.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx @@ -98,4 +98,66 @@ describe('MaskableText', () => { // Original text should be unmasked expect(getByText(plainText)).toBeVisible(); }); + + it.each<[MaskableTextProps['length'], number]>([ + // length prop expected masked length + [undefined, 12], // default fallback + ['plaintext', 12], // DEFAULT_MASKED_TEXT_LENGTH for JSX + ['ipv4', 15], // from MASKABLE_TEXT_LENGTH_MAP + ['ipv6', 30], // from MASKABLE_TEXT_LENGTH_MAP + [8, 8], // custom numeric value + ])( + 'should mask JSX list correctly when masking is enabled (length=%s)', + (lengthProp, expectedLength) => { + queryMocks.usePreferences.mockReturnValue({ data: preference }); + + const jsxList = ( +
      +
    • item1
    • +
    • item2
    • +
    • secret-value
    • +
    + ); + + const expectedMasked = '•'.repeat(expectedLength); + + const { getByText, queryByText } = renderWithTheme( + + ); + + // Masking works + expect(getByText(expectedMasked)).toBeVisible(); + + // The JSX list content must NOT show + expect(queryByText('item1')).not.toBeInTheDocument(); + expect(queryByText('item2')).not.toBeInTheDocument(); + expect(queryByText('secret-value')).not.toBeInTheDocument(); + } + ); + + it('should render JSX list unmasked when masking preference is disabled', () => { + queryMocks.usePreferences.mockReturnValue({ data: false }); + + const jsxList = ( +
      +
    • item1
    • +
    • item2
    • +
    • secret-value
    • +
    + ); + + const { getByText, queryByText } = renderWithTheme( + + ); + + const maskedText = '•'.repeat(8); + + // Original JSX content should be visible + expect(getByText('item1')).toBeVisible(); + expect(getByText('item2')).toBeVisible(); + expect(getByText('secret-value')).toBeVisible(); + + // Masked text should NOT appear + expect(queryByText(maskedText)).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 537f5fdf4cd..fd266fd5a8f 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -37,9 +37,10 @@ export interface MaskableTextProps { */ sxVisibilityTooltip?: SxProps; /** - * The original, maskable text; if the text is not masked, render this text or the styled text via children. + * The original, maskable content; can be a string or any JSX/ReactNode. + * If the text is not masked, render this text or the styled text via children. */ - text: string | undefined; + text: React.ReactNode | string | undefined; } export const MaskableText = (props: MaskableTextProps) => { @@ -59,11 +60,13 @@ export const MaskableText = (props: MaskableTextProps) => { const [isMasked, setIsMasked] = React.useState(maskedPreferenceSetting); - const unmaskedText = children ? ( - children - ) : ( - {text} - ); + const unmaskedText = + children ?? + (typeof text === 'string' ? ( + {text} + ) : ( + text // JSX (ReactNode) + )); // Return early based on the preference setting and the original text. @@ -75,6 +78,8 @@ export const MaskableText = (props: MaskableTextProps) => { return unmaskedText; } + const maskedText = createMaskedText(text, length); + return ( { )} {isMasked ? ( - {createMaskedText(text, length)} + {maskedText} ) : ( unmaskedText diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 0411ad72b0c..8efe636bb6b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -15,7 +15,7 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { - generateAddressesLabel, + generateAddressesLabelV2, useIsFirewallRulesetsPrefixlistsEnabled, } from '../../shared'; import { RULESET_MARKED_FOR_DELETION_TEXT } from './shared'; @@ -179,14 +179,17 @@ export const FirewallRuleSetDetailsView = ( /> {rule.protocol}; {rule.ports};  - {generateAddressesLabel(rule.addresses)} + {generateAddressesLabelV2({ + addresses: rule.addresses, + showTruncateChip: false, + })} ))} {rule.protocol}; {rule.ports};  - {generateAddressesLabel(rule.addresses)} + {generateAddressesLabelV2({ + addresses: rule.addresses, + showTruncateChip: false, + })} ))} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index ccc4c856c23..c4c55f4ada1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -37,6 +37,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { generateAddressesLabel, + generateAddressesLabelV2, generateRuleLabel, predefinedFirewallFromRule as ruleToPredefinedFirewall, useIsFirewallRulesetsPrefixlistsEnabled, @@ -65,7 +66,7 @@ import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; interface RuleRow { action?: null | string; - addresses?: null | string; + addresses?: null | React.ReactNode | string; description?: null | string; errors?: FirewallRuleError[]; id: number; @@ -124,10 +125,16 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const smDown = useMediaQuery(theme.breakpoints.down('sm')); const lgDown = useMediaQuery(theme.breakpoints.down('lg')); + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const addressColumnLabel = category === 'inbound' ? 'sources' : 'destinations'; - const rowData = firewallRuleToRowData(rulesWithStatus); + const rowData = firewallRuleToRowData( + rulesWithStatus, + isFirewallRulesetsPrefixlistsFeatureEnabled + ); const openDrawerForCreating = React.useCallback(() => { openRuleDrawer(category, 'create'); @@ -209,16 +216,14 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { > Label
    + Action Protocol Port Range - - {capitalize(addressColumnLabel)} - + {capitalize(addressColumnLabel)} - Action @@ -402,6 +407,9 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { )} + + {capitalize(action?.toLocaleLowerCase() ?? '')} + {protocol} @@ -414,13 +422,10 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { - + - - {capitalize(action?.toLocaleLowerCase() ?? '')} - )} @@ -632,14 +637,17 @@ export const ConditionalError = React.memo((props: ConditionalErrorProps) => { * of data. This also allows us to sort each column of the RuleTable. */ export const firewallRuleToRowData = ( - firewallRules: ExtendedFirewallRule[] + firewallRules: ExtendedFirewallRule[], + isFirewallRulesetsPrefixlistsEnabled?: boolean ): RuleRow[] => { return firewallRules.map((thisRule, idx) => { const ruleType = ruleToPredefinedFirewall(thisRule); return { ...thisRule, - addresses: generateAddressesLabel(thisRule.addresses), + addresses: isFirewallRulesetsPrefixlistsEnabled + ? generateAddressesLabelV2({ addresses: thisRule.addresses }) + : generateAddressesLabel(thisRule.addresses), id: idx + 1, // ids are 1-indexed, as id given to the useSortable hook cannot be 0 index: idx, ports: sortPortString(thisRule.ports || ''), diff --git a/packages/manager/src/features/Firewalls/shared.test.ts b/packages/manager/src/features/Firewalls/shared.test.ts deleted file mode 100644 index 6346bdb9d4f..00000000000 --- a/packages/manager/src/features/Firewalls/shared.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; - -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import { - allIPv4, - allIPv6, - generateAddressesLabel, - predefinedFirewallFromRule, - useIsFirewallRulesetsPrefixlistsEnabled, -} from './shared'; - -import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; - -const addresses = { - ipv4: [allIPv4], - ipv6: [allIPv6], -}; - -const limitedAddresses = { - ipv4: ['1.1.1.1'], - ipv6: ['::'], -}; - -describe('predefinedFirewallFromRule', () => { - const rule: FirewallRuleType = { - action: 'ACCEPT', - addresses, - ports: '', - protocol: 'TCP', - }; - - it('handles SSH', () => { - rule.ports = '22'; - expect(predefinedFirewallFromRule(rule)).toBe('ssh'); - }); - it('handles HTTP', () => { - rule.ports = '80'; - expect(predefinedFirewallFromRule(rule)).toBe('http'); - }); - it('handles HTTPS', () => { - rule.ports = '443'; - expect(predefinedFirewallFromRule(rule)).toBe('https'); - }); - it('handles MySQL', () => { - rule.ports = '3306'; - expect(predefinedFirewallFromRule(rule)).toBe('mysql'); - }); - it('handles DNS', () => { - rule.ports = '53'; - expect(predefinedFirewallFromRule(rule)).toBe('dns'); - }); - - it('returns `undefined` when given an unrecognizable rule', () => { - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - addresses, - // Test another port - ports: '22-24', - protocol: 'TCP', - }) - ).toBeUndefined(); - - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - addresses, - ports: '22', - // Test another protocol - protocol: 'UDP', - }) - ).toBeUndefined(); - - expect( - predefinedFirewallFromRule({ - action: 'ACCEPT', - // Test other addresses - addresses: limitedAddresses, - ports: '22', - protocol: 'TCP', - }) - ).toBeUndefined(); - }); -}); - -describe('generateAddressLabel', () => { - it('includes the All IPv4 label if appropriate', () => { - expect(generateAddressesLabel(addresses).includes('All IPv4')).toBe(true); - expect(generateAddressesLabel(limitedAddresses).includes('All IPv4')).toBe( - false - ); - }); - - it("doesn't include other IPv4 addresses if ALL are also specified", () => { - const result = generateAddressesLabel({ - ...addresses, - ipv4: [allIPv4, '1.1.1.1'], - }); - expect(result.includes('All IPv4')).toBe(true); - expect(result.includes('1.1.1.1')).toBe(false); - }); - - it('includes the All IPv6 label if appropriate', () => { - expect(generateAddressesLabel(addresses).includes('All IPv6')).toBe(true); - }); - - it("doesn't include other IPv6 addresses if ALL are also specified", () => { - const result = generateAddressesLabel({ - ...addresses, - ipv6: [allIPv6, '::1'], - }); - expect(result.includes('All IPv6')).toBe(true); - expect(result.includes('::1')).toBe(false); - }); - - it('includes all appropriate addresses', () => { - expect(generateAddressesLabel(addresses)).toBe('All IPv4, All IPv6'); - expect(generateAddressesLabel({ ipv4: ['1.1.1.1'] })).toBe('1.1.1.1'); - expect(generateAddressesLabel({ ipv6: ['::1'] })).toBe('::1'); - expect( - generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: ['::1'] }) - ).toBe('1.1.1.1, 2.2.2.2, ::1'); - expect( - generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: [allIPv6] }) - ).toBe('All IPv6, 1.1.1.1, 2.2.2.2'); - }); - - it('truncates large lists', () => { - expect( - generateAddressesLabel({ - ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], - }) - ).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3, plus 2 more'); - }); - - it('should always display "All IPv4" and "All IPv6", even if the label is truncated', () => { - expect( - generateAddressesLabel({ - ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], - ipv6: ['::/0'], - }) - ).toBe('All IPv6, 1.1.1.1, 2.2.2.2, plus 3 more'); - }); - - it('returns "None" if necessary', () => { - expect(generateAddressesLabel({ ipv4: undefined, ipv6: undefined })).toBe( - 'None' - ); - }); -}); - -describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { - it('returns true if the feature is enabled', async () => { - const options = { - flags: { - fwRulesetsPrefixLists: { - enabled: true, - beta: false, - la: false, - ga: false, - }, - }, - }; - - const { result } = renderHook( - () => useIsFirewallRulesetsPrefixlistsEnabled(), - { - wrapper: (ui) => wrapWithTheme(ui, options), - } - ); - - await waitFor(() => { - expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( - true - ); - }); - }); - - it('returns false if the feature is NOT enabled', async () => { - const options = { - flags: { - fwRulesetsPrefixLists: { - enabled: false, - beta: false, - la: false, - ga: false, - }, - }, - }; - - const { result } = renderHook( - () => useIsFirewallRulesetsPrefixlistsEnabled(), - { - wrapper: (ui) => wrapWithTheme(ui, options), - } - ); - - await waitFor(() => { - expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( - false - ); - }); - }); -}); diff --git a/packages/manager/src/features/Firewalls/shared.test.tsx b/packages/manager/src/features/Firewalls/shared.test.tsx new file mode 100644 index 00000000000..ca1ef1befb6 --- /dev/null +++ b/packages/manager/src/features/Firewalls/shared.test.tsx @@ -0,0 +1,405 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { renderHook, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; + +import { + allIPv4, + allIPv6, + buildPrefixListReferenceMap, + generateAddressesLabel, + generateAddressesLabelV2, + predefinedFirewallFromRule, + useIsFirewallRulesetsPrefixlistsEnabled, +} from './shared'; + +import type { PrefixListReferenceMap } from './shared'; +import type { FirewallRuleType } from '@linode/api-v4/lib/firewalls/types'; + +const addresses = { + ipv4: [allIPv4], + ipv6: [allIPv6], +}; + +const limitedAddresses = { + ipv4: ['1.1.1.1'], + ipv6: ['::'], +}; + +describe('predefinedFirewallFromRule', () => { + const rule: FirewallRuleType = { + action: 'ACCEPT', + addresses, + ports: '', + protocol: 'TCP', + }; + + it('handles SSH', () => { + rule.ports = '22'; + expect(predefinedFirewallFromRule(rule)).toBe('ssh'); + }); + it('handles HTTP', () => { + rule.ports = '80'; + expect(predefinedFirewallFromRule(rule)).toBe('http'); + }); + it('handles HTTPS', () => { + rule.ports = '443'; + expect(predefinedFirewallFromRule(rule)).toBe('https'); + }); + it('handles MySQL', () => { + rule.ports = '3306'; + expect(predefinedFirewallFromRule(rule)).toBe('mysql'); + }); + it('handles DNS', () => { + rule.ports = '53'; + expect(predefinedFirewallFromRule(rule)).toBe('dns'); + }); + + it('returns `undefined` when given an unrecognizable rule', () => { + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + addresses, + // Test another port + ports: '22-24', + protocol: 'TCP', + }) + ).toBeUndefined(); + + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + addresses, + ports: '22', + // Test another protocol + protocol: 'UDP', + }) + ).toBeUndefined(); + + expect( + predefinedFirewallFromRule({ + action: 'ACCEPT', + // Test other addresses + addresses: limitedAddresses, + ports: '22', + protocol: 'TCP', + }) + ).toBeUndefined(); + }); +}); + +describe('generateAddressLabel', () => { + it('includes the All IPv4 label if appropriate', () => { + expect(generateAddressesLabel(addresses).includes('All IPv4')).toBe(true); + expect(generateAddressesLabel(limitedAddresses).includes('All IPv4')).toBe( + false + ); + }); + + it("doesn't include other IPv4 addresses if ALL are also specified", () => { + const result = generateAddressesLabel({ + ...addresses, + ipv4: [allIPv4, '1.1.1.1'], + }); + expect(result.includes('All IPv4')).toBe(true); + expect(result.includes('1.1.1.1')).toBe(false); + }); + + it('includes the All IPv6 label if appropriate', () => { + expect(generateAddressesLabel(addresses).includes('All IPv6')).toBe(true); + }); + + it("doesn't include other IPv6 addresses if ALL are also specified", () => { + const result = generateAddressesLabel({ + ...addresses, + ipv6: [allIPv6, '::1'], + }); + expect(result.includes('All IPv6')).toBe(true); + expect(result.includes('::1')).toBe(false); + }); + + it('includes all appropriate addresses', () => { + expect(generateAddressesLabel(addresses)).toBe('All IPv4, All IPv6'); + expect(generateAddressesLabel({ ipv4: ['1.1.1.1'] })).toBe('1.1.1.1'); + expect(generateAddressesLabel({ ipv6: ['::1'] })).toBe('::1'); + expect( + generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: ['::1'] }) + ).toBe('1.1.1.1, 2.2.2.2, ::1'); + expect( + generateAddressesLabel({ ipv4: ['1.1.1.1, 2.2.2.2'], ipv6: [allIPv6] }) + ).toBe('All IPv6, 1.1.1.1, 2.2.2.2'); + }); + + it('truncates large lists', () => { + expect( + generateAddressesLabel({ + ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], + }) + ).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3, plus 2 more'); + }); + + it('should always display "All IPv4" and "All IPv6", even if the label is truncated', () => { + expect( + generateAddressesLabel({ + ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', '5.5.5.5'], + ipv6: ['::/0'], + }) + ).toBe('All IPv6, 1.1.1.1, 2.2.2.2, plus 3 more'); + }); + + it('returns "None" if necessary', () => { + expect(generateAddressesLabel({ ipv4: undefined, ipv6: undefined })).toBe( + 'None' + ); + }); +}); + +describe('buildPrefixListMap', () => { + it('returns empty map if no input', () => { + const result = buildPrefixListReferenceMap({}); + expect(result).toEqual({}); + }); + + it('maps IPv4 prefix lists correctly', () => { + const ipv4 = ['pl:example1', '192.168.0.1', 'pl:example2']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: true, inIPv6Rule: false }, + 'pl:example2': { inIPv4Rule: true, inIPv6Rule: false }, + }); + }); + + it('maps IPv6 prefix lists correctly', () => { + const ipv6 = ['pl:example1', 'fe80::1', 'pl:example2']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv6, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: false, inIPv6Rule: true }, + 'pl:example2': { inIPv4Rule: false, inIPv6Rule: true }, + }); + }); + + it('maps both IPv4 and IPv6 for the same prefix list', () => { + const ipv4 = ['pl:example1']; + const ipv6 = ['pl:example1']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({ + 'pl:example1': { inIPv4Rule: true, inIPv6Rule: true }, + }); + }); + + it('ignores non-prefix list IPs', () => { + const ipv4 = ['192.168.0.1']; + const ipv6 = ['fe80::1']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({}); + }); + + it('handles duplicates correctly', () => { + const ipv4 = ['pl:duplicate-example', 'pl:duplicate-example']; + const ipv6 = ['pl:duplicate-example']; + const result: PrefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4, + ipv6, + }); + + expect(result).toEqual({ + 'pl:duplicate-example': { inIPv4Rule: true, inIPv6Rule: true }, + }); + }); +}); + +describe('generateAddressesLabelV2', () => { + const onPrefixListClick = vi.fn(); + + const addresses: FirewallRuleType['addresses'] = { + ipv4: [ + 'pl:system:test-1', // PL attached to both IPv4/IPv6 + 'pl::test-2', // PL attached to IPv4 only + '192.168.1.1', // individual IP + ], + ipv6: [ + 'pl:system:test-1', // same system PL + 'pl::test-3', // PL attached to IPv6 only + '2001:db8:85a3::8a2e:370:7334/128', // individual IP + ], + }; + + it('renders PLs with correct Firewall IP reference labels', () => { + const result = generateAddressesLabelV2({ + addresses, + onPrefixListClick, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + // Check PLs with proper suffixes + expect(getByText(/pl:system:test-1 \(IPv4, IPv6\)/)).toBeVisible(); + expect(getByText(/pl::test-2 \(IPv4\)/)).toBeVisible(); + expect(getByText(/pl::test-3 \(IPv6\)/)).toBeVisible(); + }); + + it('renders individual IP addresses correctly', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + expect(getByText('192.168.1.1')).toBeVisible(); + expect(getByText('2001:db8:85a3::8a2e:370:7334/128')).toBeVisible(); + }); + + it('triggers onPrefixListClick when PL is clicked', async () => { + const result = generateAddressesLabelV2({ + addresses, + onPrefixListClick, + showTruncateChip: false, + }); + const { getByText } = renderWithTheme(<>{result}); + + await userEvent.click(getByText(/pl:system:test-1/)); + expect(onPrefixListClick).toHaveBeenCalledWith( + 'pl:system:test-1', + '(IPv4, IPv6)' + ); + + await userEvent.click(getByText(/pl::test-2/)); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-2', '(IPv4)'); + + await userEvent.click(getByText(/pl::test-3/)); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-3', '(IPv6)'); + }); + + it('renders None if no addresses are provided', () => { + const result = generateAddressesLabelV2({ + addresses: { ipv4: [], ipv6: [] }, + }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('None')).toBeVisible(); + }); + + it('renders "All IPv4, All IPv6" when allowAll type is present in addresses', () => { + const addressesAll = { ipv4: ['0.0.0.0/0'], ipv6: ['::/0'] }; + const result = generateAddressesLabelV2({ addresses: addressesAll }); + const { getByText } = renderWithTheme(<>{result}); + expect(getByText('All IPv4, All IPv6')).toBeVisible(); + }); + + it('handles truncation and shows Chip for hidden items', () => { + const addressesMany = { + ipv4: ['1.1.1.1', '2.2.2.2', '3.3.3.3'], + ipv6: ['::1', '::2'], + }; + const result = generateAddressesLabelV2({ + addresses: addressesMany, + showTruncateChip: true, + truncateAt: 2, + }); + const { container } = renderWithTheme(<>{result}); + expect(container.textContent).toContain('+3'); // 5 total items (2 visible and 3 hidden) + }); + + it('renders only 1 visible item and shows Chip when showTruncateChip is true and truncateAt is 1', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: true, + truncateAt: 1, + }); + const { getByText, queryByText } = renderWithTheme(<>{result}); + + expect(getByText(/pl:system:test-1 \(IPv4, IPv6\)/)).toBeVisible(); + + expect(getByText('+4')).toBeVisible(); // 5 total elements (1 shown + 4 hidden) + + // Hidden items are not rendered outside tooltip + expect(queryByText(/pl::test-2 \(IPv4\)/)).toBeNull(); + expect(queryByText(/pl::test-3 \(IPv6\)/)).toBeNull(); + expect(queryByText('192.168.1.1')).toBeNull(); + expect(queryByText('2001:db8:85a3::8a2e:370:7334/128')).toBeNull(); + }); + + it('renders all items if showTruncateChip is false', () => { + const result = generateAddressesLabelV2({ + addresses, + showTruncateChip: false, + truncateAt: 1, // should be ignored + }); + const { getByText } = renderWithTheme(<>{result}); + + expect(getByText(/pl:system:test-1 \(IPv4, IPv6\)/)).toBeVisible(); + expect(getByText(/pl::test-2 \(IPv4\)/)).toBeVisible(); + expect(getByText(/pl::test-3 \(IPv6\)/)).toBeVisible(); + expect(getByText('192.168.1.1')).toBeVisible(); + expect(getByText('2001:db8:85a3::8a2e:370:7334/128')).toBeVisible(); + }); +}); + +describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { + it('returns true if the feature is enabled', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: true, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + true + ); + }); + }); + + it('returns false if the feature is NOT enabled', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: false, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + false + ); + }); + }); +}); diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts deleted file mode 100644 index 64ab820b8c9..00000000000 --- a/packages/manager/src/features/Firewalls/shared.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { truncateAndJoinList } from '@linode/utilities'; -import { capitalize } from '@linode/utilities'; - -import { useFlags } from 'src/hooks/useFlags'; - -import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; -import type { - Firewall, - FirewallRuleProtocol, - FirewallRuleType, -} from '@linode/api-v4/lib/firewalls/types'; - -export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; - -export interface FirewallOptionItem { - label: L; - value: T; -} -// Predefined Firewall options for Select components (long-form). -export const firewallOptionItemsLong = [ - { - label: 'SSH (TCP 22 - All IPv4, All IPv6)', - value: 'ssh', - }, - { - label: 'HTTP (TCP 80 - All IPv4, All IPv6)', - value: 'http', - }, - { - label: 'HTTPS (TCP 443 - All IPv4, All IPv6)', - value: 'https', - }, - { - label: 'MySQL (TCP 3306 - All IPv4, All IPv6)', - value: 'mysql', - }, - { - label: 'DNS (TCP 53 - All IPv4, All IPv6)', - value: 'dns', - }, -]; - -// Predefined Firewall options for Select components (short-form). -export const firewallOptionItemsShort = [ - { - label: 'SSH', - value: 'ssh', - }, - { - label: 'HTTP', - value: 'http', - }, - { - label: 'HTTPS', - value: 'https', - }, - { - label: 'MySQL', - value: 'mysql', - }, - { - label: 'DNS', - value: 'dns', - }, -] as const; - -export const protocolOptions: FirewallOptionItem[] = [ - { label: 'TCP', value: 'TCP' }, - { label: 'UDP', value: 'UDP' }, - { label: 'ICMP', value: 'ICMP' }, - { label: 'IPENCAP', value: 'IPENCAP' }, -]; - -export const addressOptions = [ - { label: 'All IPv4, All IPv6', value: 'all' }, - { label: 'All IPv4', value: 'allIPv4' }, - { label: 'All IPv6', value: 'allIPv6' }, - { label: 'IP / Netmask', value: 'ip/netmask' }, -]; - -export const portPresets: Record = { - dns: '53', - http: '80', - https: '443', - mysql: '3306', - ssh: '22', -}; - -export const allIPv4 = '0.0.0.0/0'; -export const allIPv6 = '::/0'; - -export const allIPs = { - ipv4: [allIPv4], - ipv6: [allIPv6], -}; - -export interface PredefinedFirewall { - inbound: FirewallRuleType[]; - label: string; -} - -export const predefinedFirewalls: Record = { - dns: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-DNS`, - ports: portPresets.dns, - protocol: 'TCP', - }, - ], - label: 'DNS', - }, - http: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-HTTP`, - ports: portPresets.http, - protocol: 'TCP', - }, - ], - label: 'HTTP', - }, - https: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-HTTPS`, - ports: portPresets.https, - protocol: 'TCP', - }, - ], - label: 'HTTPS', - }, - mysql: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-MYSQL`, - ports: portPresets.mysql, - protocol: 'TCP', - }, - ], - label: 'MySQL', - }, - ssh: { - inbound: [ - { - action: 'ACCEPT', - addresses: allIPs, - label: `accept-inbound-SSH`, - ports: portPresets.ssh, - protocol: 'TCP', - }, - ], - label: 'SSH', - }, -}; - -export const predefinedFirewallFromRule = ( - rule: FirewallRuleType -): FirewallPreset | undefined => { - const { addresses, ports, protocol } = rule; - - // All predefined Firewalls have a protocol of TCP. - if (protocol !== 'TCP') { - return undefined; - } - - // All predefined Firewalls allow all IPs. - if (!allowsAllIPs(addresses)) { - return undefined; - } - - switch (ports) { - case portPresets.dns: - return 'dns'; - case portPresets.http: - return 'http'; - case portPresets.https: - return 'https'; - case portPresets.mysql: - return 'mysql'; - case portPresets.ssh: - return 'ssh'; - default: - return undefined; - } -}; - -export const allowsAllIPs = (addresses: FirewallRuleType['addresses']) => - allowAllIPv4(addresses) && allowAllIPv6(addresses); - -export const allowAllIPv4 = (addresses: FirewallRuleType['addresses']) => - addresses?.ipv4?.includes(allIPv4); - -export const allowAllIPv6 = (addresses: FirewallRuleType['addresses']) => - addresses?.ipv6?.includes(allIPv6); - -export const allowNoneIPv4 = (addresses: FirewallRuleType['addresses']) => - !addresses?.ipv4?.length; - -export const allowNoneIPv6 = (addresses: FirewallRuleType['addresses']) => - !addresses?.ipv6?.length; - -export const generateRuleLabel = (ruleType?: FirewallPreset) => - ruleType ? predefinedFirewalls[ruleType].label : 'Custom'; - -export const generateAddressesLabel = ( - addresses: FirewallRuleType['addresses'] -) => { - const strBuilder: string[] = []; - - const allowedAllIPv4 = allowAllIPv4(addresses); - const allowedAllIPv6 = allowAllIPv6(addresses); - - // First add the "All IPvX" strings so they always appear, even if the list - // ends up being truncated. - if (allowedAllIPv4) { - strBuilder.push('All IPv4'); - } - - if (allowedAllIPv6) { - strBuilder.push('All IPv6'); - } - - // Now we can look at the rest of the rules: - if (!allowedAllIPv4) { - addresses?.ipv4?.forEach((thisIP) => { - strBuilder.push(thisIP); - }); - } - - if (!allowedAllIPv6) { - addresses?.ipv6?.forEach((thisIP) => { - strBuilder.push(thisIP); - }); - } - - if (strBuilder.length > 0) { - return truncateAndJoinList(strBuilder, 3); - } - - // If no IPs are allowed. - return 'None'; -}; - -export const getFirewallDescription = (firewall: Firewall) => { - const description = [ - `Status: ${capitalize(firewall.status)}`, - `Services Assigned: ${firewall.entities.length}`, - ]; - return description.join(', '); -}; - -/** - * Returns whether or not features related to the Firewall Rulesets & Prefix Lists project - * should be enabled, and whether they are in beta, LA, or GA. - * - * Note: Currently, this just uses the `fwRulesetsPrefixlists` feature flag as a source of truth, - * but will eventually also look at account capabilities if available. - */ -export const useIsFirewallRulesetsPrefixlistsEnabled = () => { - const flags = useFlags(); - - // @TODO: Firewall Rulesets & Prefix Lists - check for customer tag/account capability when it exists - return { - isFirewallRulesetsPrefixlistsFeatureEnabled: - flags.fwRulesetsPrefixLists?.enabled ?? false, - isFirewallRulesetsPrefixListsBetaEnabled: - flags.fwRulesetsPrefixLists?.beta ?? false, - isFirewallRulesetsPrefixListsLAEnabled: - flags.fwRulesetsPrefixLists?.la ?? false, - isFirewallRulesetsPrefixListsGAEnabled: - flags.fwRulesetsPrefixLists?.ga ?? false, - }; -}; diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx new file mode 100644 index 00000000000..c119735f991 --- /dev/null +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -0,0 +1,562 @@ +import { Box, Chip, Tooltip } from '@linode/ui'; +import { capitalize, truncateAndJoinList } from '@linode/utilities'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { useFlags } from 'src/hooks/useFlags'; + +import type { PORT_PRESETS } from './FirewallDetail/Rules/shared'; +import type { + Firewall, + FirewallRuleProtocol, + FirewallRuleType, +} from '@linode/api-v4/lib/firewalls/types'; + +export type FirewallPreset = 'dns' | 'http' | 'https' | 'mysql' | 'ssh'; +export interface FirewallOptionItem { + label: L; + value: T; +} +// Predefined Firewall options for Select components (long-form). +export const firewallOptionItemsLong = [ + { + label: 'SSH (TCP 22 - All IPv4, All IPv6)', + value: 'ssh', + }, + { + label: 'HTTP (TCP 80 - All IPv4, All IPv6)', + value: 'http', + }, + { + label: 'HTTPS (TCP 443 - All IPv4, All IPv6)', + value: 'https', + }, + { + label: 'MySQL (TCP 3306 - All IPv4, All IPv6)', + value: 'mysql', + }, + { + label: 'DNS (TCP 53 - All IPv4, All IPv6)', + value: 'dns', + }, +]; + +// Predefined Firewall options for Select components (short-form). +export const firewallOptionItemsShort = [ + { + label: 'SSH', + value: 'ssh', + }, + { + label: 'HTTP', + value: 'http', + }, + { + label: 'HTTPS', + value: 'https', + }, + { + label: 'MySQL', + value: 'mysql', + }, + { + label: 'DNS', + value: 'dns', + }, +] as const; + +export const protocolOptions: FirewallOptionItem[] = [ + { label: 'TCP', value: 'TCP' }, + { label: 'UDP', value: 'UDP' }, + { label: 'ICMP', value: 'ICMP' }, + { label: 'IPENCAP', value: 'IPENCAP' }, +]; + +export const addressOptions = [ + { label: 'All IPv4, All IPv6', value: 'all' }, + { label: 'All IPv4', value: 'allIPv4' }, + { label: 'All IPv6', value: 'allIPv6' }, + { label: 'IP / Netmask', value: 'ip/netmask' }, +]; + +export const portPresets: Record = { + dns: '53', + http: '80', + https: '443', + mysql: '3306', + ssh: '22', +}; + +export const allIPv4 = '0.0.0.0/0'; +export const allIPv6 = '::/0'; + +export const allIPs = { + ipv4: [allIPv4], + ipv6: [allIPv6], +}; + +export interface PredefinedFirewall { + inbound: FirewallRuleType[]; + label: string; +} + +export const predefinedFirewalls: Record = { + dns: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-DNS`, + ports: portPresets.dns, + protocol: 'TCP', + }, + ], + label: 'DNS', + }, + http: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-HTTP`, + ports: portPresets.http, + protocol: 'TCP', + }, + ], + label: 'HTTP', + }, + https: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-HTTPS`, + ports: portPresets.https, + protocol: 'TCP', + }, + ], + label: 'HTTPS', + }, + mysql: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-MYSQL`, + ports: portPresets.mysql, + protocol: 'TCP', + }, + ], + label: 'MySQL', + }, + ssh: { + inbound: [ + { + action: 'ACCEPT', + addresses: allIPs, + label: `accept-inbound-SSH`, + ports: portPresets.ssh, + protocol: 'TCP', + }, + ], + label: 'SSH', + }, +}; + +export const predefinedFirewallFromRule = ( + rule: FirewallRuleType +): FirewallPreset | undefined => { + const { addresses, ports, protocol } = rule; + + // All predefined Firewalls have a protocol of TCP. + if (protocol !== 'TCP') { + return undefined; + } + + // All predefined Firewalls allow all IPs. + if (!allowsAllIPs(addresses)) { + return undefined; + } + + switch (ports) { + case portPresets.dns: + return 'dns'; + case portPresets.http: + return 'http'; + case portPresets.https: + return 'https'; + case portPresets.mysql: + return 'mysql'; + case portPresets.ssh: + return 'ssh'; + default: + return undefined; + } +}; + +export const allowsAllIPs = (addresses: FirewallRuleType['addresses']) => + allowAllIPv4(addresses) && allowAllIPv6(addresses); + +export const allowAllIPv4 = (addresses: FirewallRuleType['addresses']) => + addresses?.ipv4?.includes(allIPv4); + +export const allowAllIPv6 = (addresses: FirewallRuleType['addresses']) => + addresses?.ipv6?.includes(allIPv6); + +export const allowNoneIPv4 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv4?.length; + +export const allowNoneIPv6 = (addresses: FirewallRuleType['addresses']) => + !addresses?.ipv6?.length; + +export const generateRuleLabel = (ruleType?: FirewallPreset) => + ruleType ? predefinedFirewalls[ruleType].label : 'Custom'; + +export const generateAddressesLabel = ( + addresses: FirewallRuleType['addresses'] +) => { + const strBuilder: string[] = []; + + const allowedAllIPv4 = allowAllIPv4(addresses); + const allowedAllIPv6 = allowAllIPv6(addresses); + + // First add the "All IPvX" strings so they always appear, even if the list + // ends up being truncated. + if (allowedAllIPv4) { + strBuilder.push('All IPv4'); + } + + if (allowedAllIPv6) { + strBuilder.push('All IPv6'); + } + + // Now we can look at the rest of the rules: + if (!allowedAllIPv4) { + addresses?.ipv4?.forEach((thisIP) => { + strBuilder.push(thisIP); + }); + } + + if (!allowedAllIPv6) { + addresses?.ipv6?.forEach((thisIP) => { + strBuilder.push(thisIP); + }); + } + + if (strBuilder.length > 0) { + return truncateAndJoinList(strBuilder, 3); + } + + // If no IPs are allowed. + return 'None'; +}; + +export type PrefixListReference = { inIPv4Rule: boolean; inIPv6Rule: boolean }; +export type PrefixListReferenceMap = Record; + +const isPrefixList = (ip: string) => ip.startsWith('pl:'); + +/** + * Builds a map of Prefix List (PL) labels to their firewall rule references. + * + * @param addresses - Object containing optional arrays of IPv4 and IPv6 addresses. + * Only addresses that are prefix lists (starting with 'pl:') are considered. + * @returns A map where each key is a PL label, and the value indicates whether + * the PL is referenced in the IPv4 and/or IPv6 firewall rule. + * + * @example + * const map = buildPrefixListReferenceMap({ + * ipv4: ['pl:system:test1', '1.2.3.4'], + * ipv6: ['pl:system:test1', '::1'] + * }); + * + * // Result: + * // { + * // 'pl:system:test1': { inIPv4Rule: true, inIPv6Rule: true } + * // } + */ +export const buildPrefixListReferenceMap = (addresses: { + ipv4?: string[]; + ipv6?: string[]; +}): PrefixListReferenceMap => { + const { ipv4 = [], ipv6 = [] } = addresses; + + const prefixListMap: PrefixListReferenceMap = {}; + + // Handle IPv4 + ipv4.forEach((ip) => { + if (isPrefixList(ip)) { + if (!prefixListMap[ip]) { + prefixListMap[ip] = { inIPv4Rule: false, inIPv6Rule: false }; + } + prefixListMap[ip].inIPv4Rule = true; + } + }); + + // Handle IPv6 + ipv6.forEach((ip) => { + if (isPrefixList(ip)) { + if (!prefixListMap[ip]) { + prefixListMap[ip] = { inIPv4Rule: false, inIPv6Rule: false }; + } + prefixListMap[ip].inIPv6Rule = true; + } + }); + + return prefixListMap; +}; + +/** + * Represents the Firewall Rule IP families to which a Prefix List (PL) is attached or referenced. + * + * Used for display and logic purposes, e.g., appending to a PL label in the UI as: + * "pl:system:example (IPv4)", "pl:system:example (IPv6)", or "pl:system:example (IPv4, IPv6)". + * + * The value indicates which firewall IPs the PL applies to: + * - "(IPv4)" -> PL is attached to Firewall Rule IPv4 only + * - "(IPv6)" -> PL is attached to Firewall Rule IPv6 only + * - "(IPv4, IPv6)" -> PL is attached to both Firewall Rule IPv4 and IPv6 + */ +export type FirewallRulePrefixListReferenceTag = + | '(IPv4)' + | '(IPv4, IPv6)' + | '(IPv6)'; + +interface GenerateAddressesLabelV2Options { + /** + * The list of addresses associated with a firewall rule. + */ + addresses: FirewallRuleType['addresses']; + /** + * Optional callback invoked when a prefix list label is clicked. + * + * @param prefixListLabel - The label of the clicked prefix list (e.g., "pl:system:test") + * @param plRuleRefTag - Indicates which firewall rule IP family(s) this PL belongs to: `(IPv4)`, `(IPv6)`, or `(IPv4, IPv6)` + */ + onPrefixListClick?: ( + prefixListLabel: string, + plRuleRefTag: FirewallRulePrefixListReferenceTag + ) => void; + /** + * Whether to show the truncation "+N" chip with a scrollable tooltip + * when there are more addresses than `truncateAt`. + * @default true + */ + showTruncateChip?: boolean; + /** + * Maximum number of addresses to show before truncation. + * Ignored if `showTruncateChip` is false. + * @default 1 + */ + truncateAt?: number; +} + +/** + * Generates IP addresses and clickable prefix lists (PLs) if available. + * Can render either a full list of elements or a truncated list with a "+N" chip and a full list tooltip. + * + * - Detects prefix lists (`pl::` or `pl:system:`) and renders them as clickable links. + * - Labels PLs across IPv4/IPv6 with rules reference suffixes: + * - Example PL: `pl:system:test` + * - Reference suffix: `(IPv4, IPv6)`, `(IPv4)`, or `(IPv6)` + * - Result: `pl:system:test (IPv4, IPv6)` or `pl:system:test (IPv4)` or `pl:system:test (IPv6)` + * - Supports optional truncation with a "+N" chip and a full list tooltip. + * - Reusable across components with configurable behavior. + * + * @param options - Configuration object including addresses, click handler, and truncation options. + * @returns React elements representing addresses and clickable PLs, or `'None'` if empty. + */ +export const generateAddressesLabelV2 = ( + options: GenerateAddressesLabelV2Options +) => { + const { + addresses, + onPrefixListClick, + showTruncateChip = true, + truncateAt = 1, + } = options; + const elements: React.ReactNode[] = []; + + const allowedAllIPv4 = allowAllIPv4(addresses); + const allowedAllIPv6 = allowAllIPv6(addresses); + + // First add "All IPvX" items + if (allowedAllIPv4 && allowedAllIPv6) { + elements.push('All IPv4, All IPv6'); + } else if (allowedAllIPv4) { + elements.push('All IPv4'); + } else if (allowedAllIPv6) { + elements.push('All IPv6'); + } + + // Build a map of prefix lists. + // NOTE: If "allowedAllIPv4" or "allowedAllIPv6" is true, we skip those IPs entirely + // because "All IPvX" is already represented, and there are no specific addresses to map. + const ipv4ForPLMapping = allowedAllIPv4 ? [] : (addresses?.ipv4 ?? []); + const ipv6ForPLMapping = allowedAllIPv6 ? [] : (addresses?.ipv6 ?? []); + + const prefixListReferenceMap = buildPrefixListReferenceMap({ + ipv4: ipv4ForPLMapping, + ipv6: ipv6ForPLMapping, + }); + + // Add prefix list links with merged labels (eg., "pl:system:test (IPv4, IPv6)") + Object.entries(prefixListReferenceMap).forEach(([pl, reference]) => { + let plRuleRefTag = '' as FirewallRulePrefixListReferenceTag; + if (reference.inIPv4Rule && reference.inIPv6Rule) { + plRuleRefTag = '(IPv4, IPv6)'; + } else if (reference.inIPv4Rule) { + plRuleRefTag = '(IPv4)'; + } else if (reference.inIPv6Rule) { + plRuleRefTag = '(IPv6)'; + } + + elements.push( + { + e.preventDefault(); + onPrefixListClick?.(pl, plRuleRefTag); + }} + > + {`${pl} ${plRuleRefTag}`} + + ); + }); + + // Add remaining IPv4 addresses that are not prefix lists + if (!allowedAllIPv4) { + addresses?.ipv4?.forEach((ip) => { + if (!isPrefixList(ip)) { + elements.push({ip}); + } + }); + } + + // Add remaining IPv6 addresses that are not prefix lists + if (!allowedAllIPv6) { + addresses?.ipv6?.forEach((ip) => { + if (!isPrefixList(ip)) { + elements.push({ip}); + } + }); + } + + // If no IPs are allowed + if (elements.length === 0) return 'None'; + + // Truncation / Chip logic + const truncated = showTruncateChip ? elements.slice(0, truncateAt) : elements; + const hidden = elements.length - truncateAt; + const hasMore = showTruncateChip && elements.length > truncateAt; + + const fullTooltip = ( + ({ + maxHeight: '40vh', + overflowY: 'auto', + // Extra space on the right to prevent scrollbar from overlapping content + paddingRight: theme.spacingFunction(8), + })} + > +
      + {elements.map((el, i) => ( +
    • + {el} +
    • + ))} +
    +
    + ); + + return ( + <> + ({ + // Only add gap if Chip is visible + marginRight: hasMore ? theme.spacingFunction(8) : 0, + })} + > + {truncated.map((el, idx) => ( + + {el} + {idx < truncated.length - 1 && ', '} + + ))} + + {hasMore && ( + ({ + minWidth: '248px', + padding: `${theme.spacingFunction(16)} !important`, + }), + }, + }} + title={fullTooltip} + > + ({ + cursor: 'pointer', + borderRadius: '12px', + minWidth: '33px', + borderColor: theme.tokens.component.Tag.Default.Border, + '&:hover': { + borderColor: theme.tokens.alias.Content.Icon.Primary.Hover, + }, + '& .MuiChip-label': { + // eslint-disable-next-line @linode/cloud-manager/no-custom-fontWeight + fontWeight: theme.tokens.font.FontWeight.Semibold, + }, + })} + variant="outlined" + /> + + )} + + ); +}; + +export const getFirewallDescription = (firewall: Firewall) => { + const description = [ + `Status: ${capitalize(firewall.status)}`, + `Services Assigned: ${firewall.entities.length}`, + ]; + return description.join(', '); +}; + +/** + * Returns whether or not features related to the Firewall Rulesets & Prefix Lists project + * should be enabled, and whether they are in beta, LA, or GA. + * + * Note: Currently, this just uses the `fwRulesetsPrefixlists` feature flag as a source of truth, + * but will eventually also look at account capabilities if available. + */ +export const useIsFirewallRulesetsPrefixlistsEnabled = () => { + const flags = useFlags(); + + // @TODO: Firewall Rulesets & Prefix Lists - check for customer tag/account capability when it exists + return { + isFirewallRulesetsPrefixlistsFeatureEnabled: + flags.fwRulesetsPrefixLists?.enabled ?? false, + isFirewallRulesetsPrefixListsBetaEnabled: + flags.fwRulesetsPrefixLists?.beta ?? false, + isFirewallRulesetsPrefixListsLAEnabled: + flags.fwRulesetsPrefixLists?.la ?? false, + isFirewallRulesetsPrefixListsGAEnabled: + flags.fwRulesetsPrefixLists?.ga ?? false, + }; +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 461ba32685f..4d1b36dae66 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1338,7 +1338,27 @@ export const handlers = [ return HttpResponse.json(makeResourcePage(devices)); }), http.get('*/v4beta/networking/firewalls/rulesets', () => { - const rulesets = firewallRuleSetFactory.buildList(10); + const rulesetWithPrefixLists = firewallRuleSetFactory.build({ + rules: firewallRuleFactory.buildList(1, { + addresses: { + ipv4: [ + 'pl:system:resolvers:test', + 'pl:system:test', + '192.168.1.200', + '192.168.1.201', + ], + ipv6: [ + '2001:db8:85a3::8a2e:371:7335/128', + 'pl:system:test', + 'pl::vpcs:test', + ], + }, + }), + }); + const rulesets = [ + rulesetWithPrefixLists, + ...firewallRuleSetFactory.buildList(9), + ]; return HttpResponse.json(makeResourcePage(rulesets)); }), http.get('*/v4beta/networking/prefixlists', () => { @@ -1356,7 +1376,7 @@ export const handlers = [ id: 123, }); case 123456789: - // Ruleset with larger ID 123456789, Longer label with 32 chars, and + // Ruleset with larger ID 123456789, Longer label with 32 chars, PrefixLists, and // Marked for deletion status return firewallRuleSetFactory.build({ id: 123456789, @@ -1364,6 +1384,21 @@ export const handlers = [ 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.', + rules: firewallRuleFactory.buildList(1, { + addresses: { + ipv4: [ + 'pl:system:resolvers:test', + 'pl:system:test', + '192.168.1.200', + '192.168.1.201', + ], + ipv6: [ + '2001:db8:85a3::8a2e:371:7335/128', + 'pl:system:test', + 'pl::vpcs:test', + ], + }, + }), }); default: return firewallRuleSetFactory.build(); @@ -1385,10 +1420,25 @@ export const handlers = [ firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) ...firewallRuleFactory.buildList(1, { addresses: { - ipv4: ['192.168.1.213', '172.31.255.255'], + ipv4: [ + 'pl:system:test-1', + 'pl::vpcs:test-1', + '192.168.1.213', + '192.168.1.214', + '192.168.1.215', + '192.168.1.216', + 'pl::vpcs:test-2', + '172.31.255.255', + ], ipv6: [ + 'pl:system:test-1', + 'pl::vpcs:test-3', '2001:db8:85a3::8a2e:370:7334/128', '2001:db8:85a3::8a2e:371:7335/128', + 'pl::vpcs:test-3', + 'pl::vpcs:test-4', + 'pl::vpcs:test-5', + '2001:db8:85a3::8a2e:372:7336/128', ], }, ports: '22, 53, 80, 100, 443, 3306', diff --git a/packages/manager/src/utilities/createMaskedText.ts b/packages/manager/src/utilities/createMaskedText.ts index be074b6bdf9..64b7b205585 100644 --- a/packages/manager/src/utilities/createMaskedText.ts +++ b/packages/manager/src/utilities/createMaskedText.ts @@ -9,15 +9,20 @@ export const MASKABLE_TEXT_LENGTH_MAP: Map = ]); export const createMaskedText = ( - plainText: string, + plainText: React.ReactNode | string, length?: MaskableTextLength | number ) => { + const textLength = + typeof plainText === 'string' + ? plainText.length + : DEFAULT_MASKED_TEXT_LENGTH; // JSX fallback + // Mask a default of 12 dots, unless the prop specifies a different default or the plaintext length. const MASKED_TEXT_LENGTH = !length ? DEFAULT_MASKED_TEXT_LENGTH : typeof length === 'number' ? length - : (MASKABLE_TEXT_LENGTH_MAP.get(length) ?? plainText.length); + : (MASKABLE_TEXT_LENGTH_MAP.get(length) ?? textLength); return '•'.repeat(MASKED_TEXT_LENGTH); }; From 55d3ff47067670541c55881d012379804619f5cf Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:42:41 +0530 Subject: [PATCH 46/91] upcoming: [DI-28227] - Firewall Alerts enhancements (#13110) * upcoming: [DI-28227] - Firewall Alerts enhancements * add changeset * upcoming: [DI-28227] - Use constant for the tooltip string --------- Co-authored-by: Ankita --- ...r-13110-upcoming-features-1763617588411.md | 5 ++ .../EntityTypeSelect.test.tsx | 22 +++++ .../GeneralInformation/EntityTypeSelect.tsx | 7 +- .../EditAlert/EditAlertDefinition.test.tsx | 80 +++++++++++++++++-- .../Alerts/EditAlert/EditAlertResources.tsx | 10 +++ .../features/CloudPulse/Alerts/constants.ts | 3 + packages/manager/src/mocks/serverHandlers.ts | 54 +++++++++++++ 7 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md diff --git a/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md b/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md new file mode 100644 index 00000000000..17bae8d23d4 --- /dev/null +++ b/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Alerting: Filtering entities for firewall system alerts, add tooltip text to Entity Type component ([#13110](https://github.com/linode/manager/pull/13110)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx index 952574122dd..4a4065dceff 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.test.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +import { entityTypeTooltipText } from '../../constants'; import { EntityTypeSelect } from './EntityTypeSelect'; describe('EntityTypeSelect component tests', () => { @@ -129,4 +130,25 @@ describe('EntityTypeSelect component tests', () => { within(entityTypeDropdown).queryByRole('button', { name: 'Clear' }) ).not.toBeInTheDocument(); }); + + it('should display tooltip text on hover of the help icon', async () => { + renderWithThemeAndHookFormContext({ + component: ( + + ), + }); + + const entityTypeContainer = screen.getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + const helpButton = + within(entityTypeContainer).getByTestId('tooltip-info-icon'); + + await userEvent.hover(helpButton); + + expect(await screen.findByText(entityTypeTooltipText)).toBeVisible(); + + await userEvent.unhover(helpButton); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx index b5f00f52aed..1ecef33f6b9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/EntityTypeSelect.tsx @@ -3,7 +3,8 @@ import * as React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import type { ControllerRenderProps, FieldPathByValue } from 'react-hook-form'; -import type { Item } from '../../constants'; +import { entityTypeTooltipText, type Item } from '../../constants'; + import type { CreateAlertDefinitionForm } from '../types'; interface EntityTypeSelectProps { @@ -55,6 +56,10 @@ export const EntityTypeSelect = (props: EntityTypeSelectProps) => { options={entityTypeOptions} placeholder="Select an Entity Type" sx={{ marginTop: '5px' }} + textFieldProps={{ + labelTooltipText: entityTypeTooltipText, + tooltipPosition: 'right', + }} value={ entityTypeOptions.find((option) => option.value === field.value) ?? undefined diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx index 05e19604265..a7f93ac3bb7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx @@ -1,8 +1,13 @@ -import { waitFor } from '@testing-library/react'; +import { waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { alertFactory, notificationChannelFactory } from 'src/factories'; +import { + alertFactory, + firewallMetricRulesFactory, + firewallNodebalancerMetricCriteria, + notificationChannelFactory, +} from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { UPDATE_ALERT_SUCCESS_MESSAGE } from '../constants'; @@ -47,6 +52,9 @@ const alertDetails = alertFactory.build({ service_type: 'linode', scope: 'entity', }); + +const ENTITY_TYPE_SELECT_TEST_ID = 'entity-type-select'; + describe('EditAlertDefinition component', () => { it('renders the components of the form', { timeout: 20000 }, async () => { const { findByPlaceholderText, getByLabelText, getByText } = @@ -96,13 +104,69 @@ describe('EditAlertDefinition component', () => { await waitFor(() => expect(mutateAsyncSpy).toHaveBeenCalledTimes(1)); - expect(navigate).toHaveBeenLastCalledWith({ - to: '/alerts/definitions', + expect(navigate).toHaveBeenLastCalledWith({ + to: '/alerts/definitions', + }); + await waitFor(() => { + expect( + getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly + ).toBeInTheDocument(); + }); }); - await waitFor(() => { - expect( - getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly - ).toBeInTheDocument(); + + it('should render EntityTypeSelect for firewall with Linode entity type', () => { + const linodeFirewallAlertDetails = alertFactory.build({ + id: 1, + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + scope: 'entity', + service_type: 'firewall', + }); + + const { getByTestId } = renderWithTheme( + + ); + + const entityTypeSelect = getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + expect(entityTypeSelect).toBeVisible(); + + const combobox = within(entityTypeSelect).getByRole('combobox'); + expect(combobox).toHaveAttribute('value', 'Linodes'); + }); + + it('should render EntityTypeSelect for firewall with NodeBalancer entity type', () => { + const nodebalancerFirewallAlertDetails = alertFactory.build({ + id: 2, + rule_criteria: { + rules: [firewallNodebalancerMetricCriteria.build()], + }, + scope: 'entity', + service_type: 'firewall', }); + + const { getByTestId } = renderWithTheme( + + ); + + const entityTypeSelect = getByTestId(ENTITY_TYPE_SELECT_TEST_ID); + expect(entityTypeSelect).toBeVisible(); + + const combobox = within(entityTypeSelect).getByRole('combobox'); + expect(combobox).toHaveAttribute('value', 'NodeBalancers'); + }); + + it('should not render EntityTypeSelect for non-firewall service types', () => { + const { queryByTestId } = renderWithTheme( + + ); + + expect(queryByTestId(ENTITY_TYPE_SELECT_TEST_ID)).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx index fb24c4b98c3..8672f9a92f9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -8,6 +8,7 @@ import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; import { useEditAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { AlertResources } from '../AlertsResources/AlertsResources'; +import { entityLabelMap } from '../constants'; import { isResourcesEqual } from '../Utils/AlertResourceUtils'; import { getAlertBoxStyles } from '../Utils/utils'; import { EditAlertResourcesConfirmDialog } from './EditAlertResourcesConfirmationDialog'; @@ -85,6 +86,14 @@ export const EditAlertResources = (props: EditAlertProps) => { type, } = alertDetails; + const entityType = + serviceType === 'firewall' + ? alertDetails.rule_criteria.rules[0]?.label.includes( + entityLabelMap['nodebalancer'] + ) + ? 'nodebalancer' + : 'linode' + : undefined; return ( <> @@ -100,6 +109,7 @@ export const EditAlertResources = (props: EditAlertProps) => { alertLabel={label} alertResourceIds={entity_ids} alertType={type} + entityType={entityType} handleResourcesSelection={handleResourcesSelection} isSelectionsNeeded serviceType={service_type} diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 5414c10e684..51becf87d3c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -291,3 +291,6 @@ export const entityLabelMap = { linode: 'Linode', nodebalancer: 'Node Balancer', }; + +export const entityTypeTooltipText = + 'Select a firewall entity type to filter the list in the Entities section. The metrics and dimensions in the Criteria section will update automatically based on your selection.'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4d1b36dae66..6e185699fc8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3273,6 +3273,28 @@ export const handlers = [ rules: [firewallNodebalancerMetricCriteria.build()], }, }), + alertFactory.build({ + id: 340, + label: 'Firewall-nodebalancer-system', + type: 'system', + service_type: 'firewall', + entity_ids: ['25'], + rule_criteria: { + rules: [ + firewallNodebalancerMetricCriteria.build({ dimension_filters: [] }), + ], + }, + }), + alertFactory.build({ + id: 123, + label: 'Firewall-linode-system', + type: 'system', + service_type: 'firewall', + entity_ids: ['1', '4'], + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }), ...alertFactory.buildList(3, { status: 'enabling', type: 'user' }), ...alertFactory.buildList(3, { status: 'disabling', type: 'user' }), ...alertFactory.buildList(3, { status: 'provisioning', type: 'user' }), @@ -3344,6 +3366,38 @@ export const handlers = [ }) ); } + if (params.id === '340' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 340, + label: 'Firewall - nodebalancer - system', + type: 'system', + service_type: 'firewall', + entity_ids: ['25'], + rule_criteria: { + rules: [ + firewallNodebalancerMetricCriteria.build({ + dimension_filters: [], + }), + ], + }, + }) + ); + } + if (params.id === '123' && params.serviceType === 'firewall') { + return HttpResponse.json( + alertFactory.build({ + id: 123, + label: 'Firewall-linode-system', + type: 'system', + service_type: 'firewall', + entity_ids: ['1', '4'], + rule_criteria: { + rules: [firewallMetricRulesFactory.build()], + }, + }) + ); + } if (params.id !== undefined) { return HttpResponse.json( alertFactory.build({ From b64386095fa18a866f54e8bead109105af13851c Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Wed, 26 Nov 2025 16:01:19 +0100 Subject: [PATCH 47/91] fix: [STORIF-173] - Volume card disappearing bug fixed (#13128) --- packages/manager/src/routes/volumes/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/routes/volumes/index.ts b/packages/manager/src/routes/volumes/index.ts index 5de98e4c584..817b819e9d8 100644 --- a/packages/manager/src/routes/volumes/index.ts +++ b/packages/manager/src/routes/volumes/index.ts @@ -59,6 +59,7 @@ const volumeDetailsMetricsRoute = createRoute({ 'src/features/Volumes/VolumeDetails/VolumeMetrics/volumeMetricsLazyRoute' ).then((m) => m.volumeMetricsLazyRoute) ); + const volumeDetailsSummaryActionRoute = createRoute({ path: 'summary/$action', getParentRoute: () => volumeDetailsRoute, @@ -81,7 +82,11 @@ const volumeDetailsSummaryActionRoute = createRoute({ }), }, validateSearch: (search: VolumesSearchParams) => search, -}); +}).lazy(() => + import( + 'src/features/Volumes/VolumeDetails/VolumeSummary/volumeSummaryLazyRoute' + ).then((m) => m.volumeSummaryLazyRoute) +); const volumesIndexRoute = createRoute({ getParentRoute: () => volumesRoute, From b5dcacdf5a5beb97b5d0983cbdcd78b6500319a8 Mon Sep 17 00:00:00 2001 From: smans-akamai Date: Wed, 26 Nov 2025 10:23:20 -0500 Subject: [PATCH 48/91] upcoming: [UIE-9383] - DBaaS - Add feature flag support for PgBouncer (#13134) * upcoming: [UIE-9383] - DBaaS - Add feature flag support for PgBouncer * Adding changeset for PgBouncer feature flag change --- .../.changeset/pr-13134-upcoming-features-1764110309888.md | 5 +++++ packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + 3 files changed, 7 insertions(+) create mode 100644 packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md diff --git a/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md b/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md new file mode 100644 index 00000000000..7f7b3ff0afc --- /dev/null +++ b/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add feature flag support for PgBouncer in DBaaS ([#13134](https://github.com/linode/manager/pull/13134)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index ce9d38cc1ed..ec87b33c2d8 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -50,6 +50,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, { flag: 'dbaasV2MonitorMetrics', label: 'Databases V2 Monitor' }, + { flag: 'databasePgBouncer', label: 'Database PgBouncer' }, { flag: 'databaseResize', label: 'Database Resize' }, { flag: 'databaseAdvancedConfig', label: 'Database Advanced Config' }, { flag: 'databaseVpc', label: 'Database VPC' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index f1b74bdf9e0..ba3b8b61dd6 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -196,6 +196,7 @@ export interface Flags { cloudNat: CloudNatFlag; databaseAdvancedConfig: boolean; databaseBeta: boolean; + databasePgBouncer: boolean; databasePremium: boolean; databaseResize: boolean; databaseRestrictPlanResize: boolean; From 2f0bee08bb7d9fbdee8709de72d92bfd0da29528 Mon Sep 17 00:00:00 2001 From: santoshp210-akamai <159890961+santoshp210-akamai@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:47:25 +0530 Subject: [PATCH 49/91] upcoming: [DI-28427] - Add and consume beta property to aclpAlerting flag (#13137) * upcoming: [DI-28427] - Add and consume beta property to aclpAlerting flag * add changeset --- .../.changeset/pr-13137-upcoming-features-1764156818321.md | 5 +++++ .../manager/src/components/PrimaryNav/PrimaryNav.test.tsx | 3 +++ packages/manager/src/components/PrimaryNav/PrimaryNav.tsx | 2 +- packages/manager/src/factories/featureFlags.ts | 1 + packages/manager/src/featureFlags.ts | 1 + .../CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx | 1 + 6 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md diff --git a/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md b/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md new file mode 100644 index 00000000000..b21f0da7b01 --- /dev/null +++ b/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP-Alerting: Update aclpAlerting flag to have beta marker control ([#13137](https://github.com/linode/manager/pull/13137)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index 835b35727eb..774bcdc49c1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -233,6 +233,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: true, notificationChannels: false, recentActivity: false, }, @@ -274,6 +275,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: false, notificationChannels: true, recentActivity: true, }, @@ -314,6 +316,7 @@ describe('PrimaryNav', () => { accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: false, + beta: true, notificationChannels: false, recentActivity: false, }, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index f2b0ef1ab18..7e35cbfb6ab 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -246,7 +246,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { display: 'Alerts', hide: !isAlertsEnabled, to: '/alerts', - isBeta: flags.aclp?.beta, + isBeta: flags.aclpAlerting?.beta, }, { display: 'Longview', diff --git a/packages/manager/src/factories/featureFlags.ts b/packages/manager/src/factories/featureFlags.ts index 9ff40bea222..d3a66e9c6ff 100644 --- a/packages/manager/src/factories/featureFlags.ts +++ b/packages/manager/src/factories/featureFlags.ts @@ -22,6 +22,7 @@ export const flagsFactory = Factory.Sync.makeFactory>({ accountAlertLimit: 10, accountMetricLimit: 10, alertDefinitions: true, + beta: true, recentActivity: false, notificationChannels: false, editDisabledStatuses: [ diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ba3b8b61dd6..0ab07445dee 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -149,6 +149,7 @@ interface AclpAlerting { accountAlertLimit: number; accountMetricLimit: number; alertDefinitions: boolean; + beta: boolean; editDisabledStatuses?: AlertStatusType[]; notificationChannels: boolean; recentActivity: boolean; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index a7a415aeeb2..fdc608e3f35 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -190,6 +190,7 @@ describe('Alert Row', () => { editDisabledStatuses: ['failed', 'in progress'], accountAlertLimit: 10, accountMetricLimit: 100, + beta: true, alertDefinitions: true, notificationChannels: false, recentActivity: false, From 855eabec7f6b263009f1aef4c74f5a0664e0fd21 Mon Sep 17 00:00:00 2001 From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:31:04 +0100 Subject: [PATCH 50/91] fix: [UIE-9671] - IAM: create Linode permission fix (#13118) * fix: [UIE-9671] - IAM: create Linode permission fix * fix: [UIE-9671] - test fix --- .../.changeset/pr-13118-fixed-1763722681223.md | 5 +++++ .../features/Linodes/LinodeCreate/Actions.tsx | 6 +++++- .../StackScripts/StackScriptSelectionRow.tsx | 17 +++++++++++++++-- .../shared/LinodeSelectTable.test.tsx | 1 + .../shared/LinodeSelectTableRow.test.tsx | 3 +++ .../shared/LinodeSelectTableRow.tsx | 8 +++++++- 6 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-13118-fixed-1763722681223.md diff --git a/packages/manager/.changeset/pr-13118-fixed-1763722681223.md b/packages/manager/.changeset/pr-13118-fixed-1763722681223.md new file mode 100644 index 00000000000..491ee99f5d2 --- /dev/null +++ b/packages/manager/.changeset/pr-13118-fixed-1763722681223.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +IAM: The StackScript/Linode selector is enabled in the Create Linode flow when the user doesn’t have the create_linode permission ([#13118](https://github.com/linode/manager/pull/13118)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx index 1852a581950..b7469edc159 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx @@ -83,7 +83,11 @@ export const Actions = ({ isAlertsBetaMode }: ActionProps) => { return ( - + setOpen(true)} + open={open} + titleSuffix={} + > + + I smirked at their Kale chips banh-mi fingerstache brunch in + Williamsburg. + + + Meanwhile in my closet-style flat in Red-Hook, my pour-over coffee + glitched on my vinyl record player while I styled the bottom left + corner of my beard. Those artisan tacos I ordered were infused + with turmeric and locally sourced honey, a true farm-to-table + vibe. Pabst Blue Ribbon in hand, I sat on my reclaimed wood bench + next to the macramĂ© plant holder. + + + Narwhal selfies dominated my Instagram feed, hashtagged with "slow + living" and "normcore aesthetics". My kombucha brewing kit arrived + just in time for me to ferment my own chai-infused blend. As I + adjusted my vintage round glasses, a tiny house documentary + started playing softly in the background. The retro typewriter + clacked as I typed out my minimalist poetry on sustainably sourced + paper. The sun glowed through the window, shining light on the + delightful cracks of my Apple watch. + + It was Saturday. + setOpen(false), + }} + /> + + + ); + }; + + return DrawerExampleWrapper(); + }, +}; + export default meta; diff --git a/packages/ui/src/components/Drawer/Drawer.test.tsx b/packages/ui/src/components/Drawer/Drawer.test.tsx index ca8e2023a01..c5abbcf7033 100644 --- a/packages/ui/src/components/Drawer/Drawer.test.tsx +++ b/packages/ui/src/components/Drawer/Drawer.test.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { renderWithTheme } from '../../utilities/testHelpers'; +import { BetaChip } from '../BetaChip'; import { Button } from '../Button'; import { Drawer } from './Drawer'; @@ -74,4 +75,12 @@ describe('Drawer', () => { expect(getByRole('progressbar')).toBeVisible(); }); + + it('should render a Dailog with beta chip if titleSuffix is set to betaChip', () => { + const { getByText } = renderWithTheme( + } />, + ); + + expect(getByText('beta')).toBeVisible(); + }); }); diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index 70f1ac85a06..8caad7bdd8a 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -33,6 +33,12 @@ export interface DrawerProps extends _DrawerProps { * Title that appears at the top of the drawer */ title: string; + /** + * Adds a suffix element to the drawer. + * + * Can be used to indicate special states, such as `BetaChip`, `NewFeatureChip`, or other UI elements next to the title. + */ + titleSuffix?: React.JSX.Element; /** * Increases the Drawers width from 480px to 700px on desktop-sized viewports * @default false @@ -56,6 +62,7 @@ export const Drawer = React.forwardRef( const { children, error, + titleSuffix, isFetching, onClose, open, @@ -147,18 +154,20 @@ export const Drawer = React.forwardRef( > {isFetching ? null : ( - ({ - marginRight: theme.spacing(2), - wordBreak: 'break-word', - })} - variant="h2" - > - {lastTitleRef.current} - + + + {lastTitleRef.current} + + {titleSuffix && {titleSuffix}} + )} From 545e47653c6cceac4577c0a3ddafb3bfd99b4c33 Mon Sep 17 00:00:00 2001 From: Harsh Shankar Rao Date: Tue, 2 Dec 2025 18:23:27 +0530 Subject: [PATCH 59/91] upcoming: [UIE-9565] - Implement Empty Landing State for Network Load Balancers (#13132) * upcoming: [UIE-9565] - Implement Empty Landing State for Network Load Balancers * Added changeset: Implement Empty Landing State for Network Load Balancers * switched the description variant from one-off h3 variant to subtitle1 --- ...r-13132-upcoming-features-1764056438332.md | 5 +++ .../NetworkLoadBalancersLandingEmptyState.tsx | 33 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md diff --git a/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md b/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md new file mode 100644 index 00000000000..5f5fea86ba1 --- /dev/null +++ b/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + + Implement Empty Landing State for Network Load Balancers ([#13132](https://github.com/linode/manager/pull/13132)) diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx index 8238a8c8282..7a9e6cacc19 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding/NetworkLoadBalancersLandingEmptyState.tsx @@ -1,14 +1,43 @@ +import { Typography } from '@linode/ui'; import * as React from 'react'; +import DocsIcon from 'src/assets/icons/docs.svg'; +import NetworkIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -// This will be implemented as part of a different story. +import { NLB_API_DOCS_LINK } from '../constants'; + export const NetworkLoadBalancersLandingEmptyState = () => { return ( - + , + children: 'Learn More', + href: NLB_API_DOCS_LINK, + target: '_blank', + sx: { + '&:hover, &:focus': { + textDecoration: 'none', + }, + }, + rel: 'noopener noreferrer', + }, + ]} + icon={NetworkIcon} + isEntity + subtitle="High Capacity load balancing service" + title="Network Load Balancer" + > + + Deliver real-time, high-volume traffic management for your most + demanding workloads. + + ); }; From 02e82e6b2a98fd2411e7a2279c35f14bf584f8ef Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Tue, 2 Dec 2025 18:30:02 +0530 Subject: [PATCH 60/91] upcoming: [UIE-9511, UIE-9513, UIE-9632] - Add and Integrate Prefix List Details Drawer (#13146) * Add and integrate PL details drawer * Update comment * Update some old references * Added changeset: Add and Integrate Prefix List Details Drawer * Added changeset: Add `deleted` property to `FirewallPrefixList` type after API update * Some refactor and clean up * Some refactor and update button text from cancel to close * Rename the 'reference' to 'context' prop for clarity and update all related instances --- ...r-13146-upcoming-features-1764600095421.md | 5 + packages/api-v4/src/firewalls/types.ts | 1 + ...r-13146-upcoming-features-1764599840190.md | 5 + .../manager/src/assets/icons/arrow-left.svg | 3 + packages/manager/src/factories/firewalls.ts | 15 +- .../Rules/FirewallPrefixListDrawer.test.tsx | 366 +++++++++++++++++ .../Rules/FirewallPrefixListDrawer.tsx | 369 ++++++++++++++++++ .../Rules/FirewallRuleDrawer.test.tsx | 2 + .../Rules/FirewallRuleDrawer.tsx | 22 +- .../Rules/FirewallRuleDrawer.types.ts | 12 +- .../Rules/FirewallRuleSetDetailsView.tsx | 8 +- .../Rules/FirewallRuleSetForm.tsx | 2 + .../Rules/FirewallRuleTable.tsx | 24 +- .../Rules/FirewallRulesLanding.tsx | 65 +++ .../Firewalls/FirewallDetail/Rules/shared.ts | 15 + .../src/features/Firewalls/shared.test.tsx | 24 +- .../manager/src/features/Firewalls/shared.tsx | 15 +- packages/manager/src/mocks/serverHandlers.ts | 72 +++- 18 files changed, 980 insertions(+), 45 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md create mode 100644 packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md create mode 100644 packages/manager/src/assets/icons/arrow-left.svg create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx diff --git a/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md b/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md new file mode 100644 index 00000000000..e0cb2c1aa43 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add `deleted` property to `FirewallPrefixList` type after API update ([#13146](https://github.com/linode/manager/pull/13146)) diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 8e163648cf9..7f27fbc914a 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -134,6 +134,7 @@ export type FirewallPrefixListVisibility = 'private' | 'public' | 'restricted'; export interface FirewallPrefixList { created: string; + deleted: null | string; description: string; id: number; ipv4?: null | string[]; diff --git a/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md b/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md new file mode 100644 index 00000000000..ebf9b5fe305 --- /dev/null +++ b/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add and Integrate Prefix List Details Drawer ([#13146](https://github.com/linode/manager/pull/13146)) diff --git a/packages/manager/src/assets/icons/arrow-left.svg b/packages/manager/src/assets/icons/arrow-left.svg new file mode 100644 index 00000000000..12643a41986 --- /dev/null +++ b/packages/manager/src/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index b77dfedde1e..4399e48663c 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -107,11 +107,11 @@ 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', + is_service_defined: false, + label: Factory.each((i) => `firewall-ruleset-${i}`), rules: firewallRuleFactory.buildList(3), + type: 'inbound', updated: '2025-11-05T00:00:00', version: 1, } @@ -120,16 +120,17 @@ export const firewallRuleSetFactory = Factory.Sync.makeFactory( export const firewallPrefixListFactory = Factory.Sync.makeFactory({ created: '2025-11-05T00:00:00', - updated: '2025-11-05T00:00:00', + deleted: null, 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}`) ), + name: Factory.each((i) => `pl:system:resolvers:test-${i}`), + updated: '2025-11-05T00:00:00', + version: 1, + visibility: 'public', }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx new file mode 100644 index 00000000000..118f52b8154 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.test.tsx @@ -0,0 +1,366 @@ +import { capitalize } from '@linode/utilities'; +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { firewallPrefixListFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import * as shared from '../../shared'; +import { FirewallPrefixListDrawer } from './FirewallPrefixListDrawer'; +import { PREFIXLIST_MARKED_FOR_DELETION_TEXT } from './shared'; + +import type { FirewallPrefixListDrawerProps } from './FirewallPrefixListDrawer'; +import type { FirewallPrefixList } from '@linode/api-v4'; + +const queryMocks = vi.hoisted(() => ({ + useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallPrefixListsQuery: queryMocks.useAllFirewallPrefixListsQuery, + }; +}); + +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); + return { + ...actual, + getUserTimezone: vi.fn().mockReturnValue('utc'), + }; +}); + +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + +// +// Helper to compute expected UI values/text +// +const computeExpectedElements = ( + category: 'inbound' | 'outbound', + context: FirewallPrefixListDrawerProps['context'] +) => { + let title = 'Prefix List details'; + let button = 'Close'; + let label = 'Name:'; + + if (context?.type === 'ruleset' && context.modeViewedFrom === 'create') { + title = `Add an ${capitalize(category)} Rule or Rule Set`; + button = `Back to ${capitalize(category)} Rule Set`; + label = 'Prefix List Name:'; + } + + if (context?.type === 'ruleset' && context.modeViewedFrom === 'view') { + title = `${capitalize(category)} Rule Set details`; + button = 'Back to the Rule Set'; + label = 'Prefix List Name:'; + } + + if (context?.type === 'rule' && context.modeViewedFrom === 'edit') { + title = 'Edit Rule'; + button = 'Back to Rule'; + label = 'Prefix List Name:'; + } + + // Default values when there is no specific drawer context + // (e.g., type === 'rule' and modeViewedFrom === undefined, + // meaning the drawer is opened directly from the Firewall Table row) + return { title, button, label }; +}; + +describe('PrefixListDrawer', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + // Default/base props + const baseProps: Omit = + { + isOpen: true, + onClose: () => {}, + selectedPrefixListLabel: 'pl-test', + }; + + const drawerProps: FirewallPrefixListDrawerProps[] = [ + { + ...baseProps, + category: 'inbound', + context: { + type: 'ruleset', + modeViewedFrom: 'create', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, + }, + }, + { + ...baseProps, + category: 'outbound', + context: { + type: 'ruleset', + modeViewedFrom: 'view', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: false }, + }, + }, + { + ...baseProps, + category: 'inbound', + context: { + type: 'rule', + modeViewedFrom: 'edit', + plRuleRef: { inIPv4Rule: false, inIPv6Rule: true }, + }, + }, + { + ...baseProps, + category: 'outbound', + context: { + type: 'rule', + plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, + }, + }, + ]; + + it.each(drawerProps)( + 'renders correct UI for category:$category, contextType:$context.type and modeViewedFrom:$context.modeViewedFrom', + ({ category, context }) => { + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [firewallPrefixListFactory.build()], + }); + + const { getByText, getByRole } = renderWithTheme( + + ); + + // Compute expectations + const { title, button, label } = computeExpectedElements( + category, + context + ); + + // Title + expect(getByText(title)).toBeVisible(); + + // First label (Prefix List Name: OR Name:) + expect(getByText(label)).toBeVisible(); + + // Static detail labels + [ + 'ID:', + 'Description:', + 'Type:', + 'Visibility:', + 'Version:', + 'Created:', + 'Updated:', + ].forEach((l) => expect(getByText(l)).toBeVisible()); + + // Back or Cancel button + expect(getByRole('button', { name: button })).toBeVisible(); + } + ); + + // Marked for deletion tests + const deletionTestCases = [ + [ + 'should not display "Marked for deletion" when prefix list is active', + null, + ], + [ + 'should display "Marked for deletion" when prefix list is deleted', + '2025-07-24T04:23:17', + ], + ]; + + it.each(deletionTestCases)('%s', async (_, deletedTimeStamp) => { + const mockPrefixList = firewallPrefixListFactory.build({ + deleted: deletedTimeStamp, + }); + + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [mockPrefixList], + }); + + const { getByText, getByTestId, findByText, queryByText } = renderWithTheme( + + ); + + if (deletedTimeStamp) { + expect(getByText('Marked for deletion:')).toBeVisible(); + const tooltip = getByTestId('tooltip-info-icon'); + await userEvent.hover(tooltip); + expect( + await findByText(PREFIXLIST_MARKED_FOR_DELETION_TEXT) + ).toBeVisible(); + } else { + expect(queryByText('Marked for deletion:')).not.toBeInTheDocument(); + } + }); + + const prefixListVariants: Partial[] = [ + { name: 'pl::supports-both', ipv4: ['1.1.1.0/24'], ipv6: ['::1/128'] }, + { name: 'pl::supports-only-ipv4', ipv4: ['1.1.1.0/24'], ipv6: null }, + { name: 'pl::supports-only-ipv6', ipv4: null, ipv6: ['::1/128'] }, + { name: 'pl::supports-both-but-ipv4-empty', ipv4: [], ipv6: ['::1/128'] }, + { + name: 'pl::supports-both-but-ipv6-empty', + ipv4: ['1.1.1.0/24'], + ipv6: [], + }, + { name: 'pl::supports-both-but-both-empty', ipv4: [], ipv6: [] }, + ]; + + const ruleReferences: FirewallPrefixListDrawerProps['context'][] = [ + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: false }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: false, inIPv6Rule: true }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, type: 'rule' }, + { plRuleRef: { inIPv4Rule: true, inIPv6Rule: true }, type: 'ruleset' }, + ]; + + const ipSectionTestCases = [ + // PL supports both + { + prefixList: prefixListVariants[0], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[2], + expectedIPv4: 'in use', + expectedIPv6: 'in use', + }, + { + prefixList: prefixListVariants[0], + context: ruleReferences[3], + expectedIPv4: 'in use', + expectedIPv6: 'in use', + }, + // PL supports only IPv4 + { + prefixList: prefixListVariants[1], + context: ruleReferences[0], + expectedIPv4: 'in use', + }, + // PL supports only IPv6 + { + prefixList: prefixListVariants[2], + context: ruleReferences[1], + expectedIPv6: 'in use', + }, + // PL IPv4 empty + { + prefixList: prefixListVariants[3], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[3], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + // PL IPv6 empty + { + prefixList: prefixListVariants[4], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[4], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + // PL both empty + { + prefixList: prefixListVariants[5], + context: ruleReferences[0], + expectedIPv4: 'in use', + expectedIPv6: 'not in use', + }, + { + prefixList: prefixListVariants[5], + context: ruleReferences[1], + expectedIPv4: 'not in use', + expectedIPv6: 'in use', + }, + ]; + + it.each(ipSectionTestCases)( + 'renders correct chip and IP addresses for Prefix List $prefixList.name with reference $context.plRuleRef', + ({ prefixList, context, expectedIPv4, expectedIPv6 }) => { + const selectedPrefixList = prefixList.name; + + const mockPrefixList = firewallPrefixListFactory.build({ ...prefixList }); + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: [mockPrefixList], + }); + + const { getByTestId } = renderWithTheme( + + ); + + if (prefixList.ipv4 && expectedIPv4) { + const ipv4Chip = getByTestId('ipv4-chip'); + expect(ipv4Chip).toBeVisible(); + expect(ipv4Chip).toHaveTextContent(expectedIPv4); + + // Check IPv4 addresses + const ipv4Section = getByTestId('ipv4-section'); + const ipv4Content = prefixList.ipv4.length + ? prefixList.ipv4.join(', ') + : 'no IP addresses'; + expect(within(ipv4Section).getByText(ipv4Content)).toBeVisible(); + } + + if (prefixList.ipv6 && expectedIPv6) { + const ipv6Chip = getByTestId('ipv6-chip'); + expect(ipv6Chip).toBeVisible(); + expect(ipv6Chip).toHaveTextContent(expectedIPv6); + + // Check IPv6 addresses + const ipv6Section = getByTestId('ipv6-section'); + const ipv6Content = prefixList.ipv6.length + ? prefixList.ipv6.join(', ') + : 'no IP addresses'; + expect(within(ipv6Section).getByText(ipv6Content)).toBeVisible(); + } + } + ); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx new file mode 100644 index 00000000000..37e9f464242 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -0,0 +1,369 @@ +import { useAllFirewallPrefixListsQuery } from '@linode/queries'; +import { Box, Button, Chip, Drawer, Paper, TooltipIcon } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import * as React from 'react'; + +import ArrowLeftIcon from 'src/assets/icons/arrow-left.svg'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; + +import { useIsFirewallRulesetsPrefixlistsEnabled } from '../../shared'; +import { + getPrefixListType, + PREFIXLIST_MARKED_FOR_DELETION_TEXT, +} from './shared'; +import { + StyledLabel, + StyledListItem, + StyledWarningIcon, + useStyles, +} from './shared.styles'; + +import type { PrefixListRuleReference } from '../../shared'; +import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; +import type { Category } from './shared'; + +export interface PrefixListDrawerContext { + modeViewedFrom?: FirewallRuleDrawerMode; // Optional in the case of normal rules + plRuleRef: PrefixListRuleReference; + type: 'rule' | 'ruleset'; +} + +export interface FirewallPrefixListDrawerProps { + category: Category; + context: PrefixListDrawerContext | undefined; + isOpen: boolean; + onClose: (options?: { closeAll: boolean }) => void; + selectedPrefixListLabel: string | undefined; +} + +export const FirewallPrefixListDrawer = React.memo( + (props: FirewallPrefixListDrawerProps) => { + const { category, context, onClose, isOpen, selectedPrefixListLabel } = + props; + + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const { classes } = useStyles(); + + const { data, error, isFetching } = useAllFirewallPrefixListsQuery( + isFirewallRulesetsPrefixlistsFeatureEnabled, + {}, + { name: selectedPrefixListLabel } + ); + + const prefixListDetails = data?.[0]; + + const isIPv4Supported = + prefixListDetails?.ipv4 !== null && prefixListDetails?.ipv4 !== undefined; + const isIPv6Supported = + prefixListDetails?.ipv6 !== null && prefixListDetails?.ipv6 !== undefined; + + const isIPv4InUse = context?.plRuleRef.inIPv4Rule; + const isIPv6InUse = context?.plRuleRef.inIPv6Rule; + + // Returns Prefix List drawer title and back button text based on category and reference + const getDrawerTexts = ( + category: Category, + context?: PrefixListDrawerContext + ) => { + const defaultTexts = { title: 'Prefix List details', backButton: null }; + + if (!context) return defaultTexts; + + const { type, modeViewedFrom } = context; + + if (type === 'ruleset' && modeViewedFrom === 'create') { + return { + title: `Add an ${capitalize(category)} Rule or Rule Set`, + backButton: `Back to ${capitalize(category)} Rule Set`, + }; + } + + if (type === 'ruleset' && modeViewedFrom === 'view') { + return { + title: `${capitalize(category)} Rule Set details`, + backButton: 'Back to the Rule Set', + }; + } + + if (type === 'rule' && modeViewedFrom === 'edit') { + return { title: 'Edit Rule', backButton: 'Back to Rule' }; + } + + return defaultTexts; + }; + + const { title: titleText, backButton: backButtonText } = getDrawerTexts( + category, + context + ); + + const plFieldLabel = + context?.type === 'rule' && context.modeViewedFrom === undefined + ? 'Name' + : 'Prefix List Name'; + + return ( + onClose({ closeAll: true })} + open={isOpen} + title={titleText} + > + + {prefixListDetails && ( + <> + {[ + { + label: plFieldLabel, + value: prefixListDetails.name, + }, + { + label: 'ID', + value: prefixListDetails.id, + copy: true, + }, + { + label: 'Description', + value: prefixListDetails.description, + column: true, + }, + { + label: 'Type', + value: getPrefixListType(prefixListDetails.name), + }, + { + label: 'Visibility', + value: capitalize(prefixListDetails.visibility), + }, + { + label: 'Version', + value: prefixListDetails.version, + }, + { + label: 'Created', + value: , + }, + { + label: 'Updated', + value: , + }, + ].map((item, idx) => ( + + {item.label && ( + {item.label}: + )} + + {item.value} + + {item.copy && ( + + )} + + ))} + + {prefixListDetails.deleted && ( + + + ({ + color: theme.tokens.alias.Content.Text.Negative, + })} + > + Marked for deletion: + + ({ + color: theme.tokens.alias.Content.Text.Negative, + marginRight: theme.spacingFunction(4), + })} + value={prefixListDetails.deleted} + /> + + + )} + + {isIPv4Supported && ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + ...(isIPv4InUse + ? { + border: `1px solid ${theme.tokens.alias.Border.Positive}`, + } + : {}), + })} + > + ({ + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!isIPv4InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary.Disabled, + } + : {}), + })} + > + IPv4 + ({ + background: isIPv4InUse + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Neutral.Subtle + .Background, + color: isIPv4InUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + + + ({ + ...(!isIPv4InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary.Disabled, + } + : {}), + })} + > + {prefixListDetails.ipv4!.length > 0 ? ( + prefixListDetails.ipv4!.join(', ') + ) : ( + no IP addresses + )} + + + )} + + {isIPv6Supported && ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + marginTop: theme.spacingFunction(8), + ...(isIPv6InUse + ? { + border: `1px solid ${theme.tokens.alias.Border.Positive}`, + } + : {}), + })} + > + ({ + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!isIPv6InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary.Disabled, + } + : {}), + })} + > + IPv6 + ({ + background: isIPv6InUse + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Neutral.Subtle + .Background, + color: isIPv6InUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + + ({ + ...(!isIPv6InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary.Disabled, + } + : {}), + })} + > + {prefixListDetails.ipv6!.length > 0 ? ( + prefixListDetails.ipv6!.join(', ') + ) : ( + no IP addresses + )} + + + )} + + )} + + ({ + marginTop: theme.spacingFunction(16), + display: 'flex', + justifyContent: backButtonText ? 'flex-start' : 'flex-end', + })} + > + + + + + ); + } +); 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 09719c261a7..f0d23ec98e3 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -50,6 +50,7 @@ vi.mock('@linode/utilities', async () => { }; }); +const mockHandleOpenPrefixListDrawer = vi.fn(); const mockOnClose = vi.fn(); const mockOnSubmit = vi.fn(); @@ -59,6 +60,7 @@ const props: FirewallRuleDrawerProps = { category: 'inbound', isOpen: true, mode: 'create', + handleOpenPrefixListDrawer: mockHandleOpenPrefixListDrawer, onClose: mockOnClose, onSubmit: mockOnSubmit, }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 10c16eef62a..cdd97596b06 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -39,7 +39,14 @@ import type { ExtendedIP } from 'src/utilities/ipUtils'; // ============================================================================= export const FirewallRuleDrawer = React.memo( (props: FirewallRuleDrawerProps) => { - const { category, isOpen, mode, onClose, ruleToModifyOrView } = props; + const { + category, + handleOpenPrefixListDrawer, + isOpen, + mode, + onClose, + ruleToModifyOrView, + } = props; const { isFirewallRulesetsPrefixlistsFeatureEnabled } = useIsFirewallRulesetsPrefixlistsEnabled(); @@ -253,6 +260,16 @@ export const FirewallRuleDrawer = React.memo( { + handleOpenPrefixListDrawer( + prefixListLabel, + plRuleRef, + 'ruleset' + ); + }} ruleErrors={ruleToModifyOrView?.errors} {...formikProps} /> @@ -265,6 +282,9 @@ export const FirewallRuleDrawer = React.memo( { + handleOpenPrefixListDrawer(prefixListLabel, plRuleRef, 'ruleset'); + }} ruleset={ruleToModifyOrView?.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 c48ff4a91a6..e0cfdfd8067 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -1,4 +1,5 @@ -import type { FirewallOptionItem } from '../../shared'; +import type { FirewallOptionItem, PrefixListRuleReference } from '../../shared'; +import type { PrefixListDrawerContext } from './FirewallPrefixListDrawer'; import type { ExtendedFirewallRule } from './firewallRuleEditor'; import type { Category, FirewallRuleError } from './shared'; import type { @@ -12,6 +13,11 @@ export type FirewallRuleDrawerMode = 'create' | 'edit' | 'view'; export interface FirewallRuleDrawerProps { category: Category; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference, + contextType: PrefixListDrawerContext['type'] + ) => void; isOpen: boolean; mode: FirewallRuleDrawerMode; onClose: () => void; @@ -51,5 +57,9 @@ export interface FirewallRuleSetFormProps extends FormikProps { category: Category; closeDrawer: () => void; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; ruleErrors?: FirewallRuleError[]; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx index 8efe636bb6b..f7a2ff5c33f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetDetailsView.tsx @@ -27,19 +27,24 @@ import { useStyles, } from './shared.styles'; +import type { PrefixListRuleReference } from '../../shared'; import type { Category } from './shared'; import type { FirewallRuleType } from '@linode/api-v4'; interface FirewallRuleSetDetailsViewProps { category: Category; closeDrawer: () => void; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; ruleset: FirewallRuleType['ruleset']; } export const FirewallRuleSetDetailsView = ( props: FirewallRuleSetDetailsViewProps ) => { - const { category, closeDrawer, ruleset } = props; + const { category, closeDrawer, handleOpenPrefixListDrawer, ruleset } = props; const { isFirewallRulesetsPrefixlistsFeatureEnabled } = useIsFirewallRulesetsPrefixlistsEnabled(); @@ -181,6 +186,7 @@ export const FirewallRuleSetDetailsView = ( {rule.protocol}; {rule.ports};  {generateAddressesLabelV2({ addresses: rule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, showTruncateChip: false, })} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index f49756695c3..eda6435e4f0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -28,6 +28,7 @@ export const FirewallRuleSetForm = React.memo( const { category, errors, + handleOpenPrefixListDrawer, handleSubmit, setFieldTouched, setFieldValue, @@ -213,6 +214,7 @@ export const FirewallRuleSetForm = React.memo( {rule.protocol}; {rule.ports};  {generateAddressesLabelV2({ addresses: rule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, showTruncateChip: false, })} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index c4c55f4ada1..ebeff8eec5b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -62,7 +62,10 @@ 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 { FirewallOptionItem } from 'src/features/Firewalls/shared'; +import type { + FirewallOptionItem, + PrefixListRuleReference, +} from 'src/features/Firewalls/shared'; interface RuleRow { action?: null | string; @@ -96,6 +99,10 @@ interface RowActionHandlers { interface FirewallRuleTableProps extends RowActionHandlers { category: Category; disabled: boolean; + handleOpenPrefixListDrawer: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void; handlePolicyChange: ( category: Category, newPolicy: FirewallPolicyType @@ -113,6 +120,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { handleDeleteFirewallRule, handleOpenRuleDrawerForEditing, handleOpenRuleSetDrawerForViewing, + handleOpenPrefixListDrawer, handlePolicyChange, handleReorder, handleUndo, @@ -133,7 +141,8 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const rowData = firewallRuleToRowData( rulesWithStatus, - isFirewallRulesetsPrefixlistsFeatureEnabled + isFirewallRulesetsPrefixlistsFeatureEnabled, + handleOpenPrefixListDrawer ); const openDrawerForCreating = React.useCallback(() => { @@ -638,7 +647,11 @@ export const ConditionalError = React.memo((props: ConditionalErrorProps) => { */ export const firewallRuleToRowData = ( firewallRules: ExtendedFirewallRule[], - isFirewallRulesetsPrefixlistsEnabled?: boolean + isFirewallRulesetsPrefixlistsEnabled?: boolean, + handleOpenPrefixListDrawer?: ( + prefixListLabel: string, + plRuleRef: PrefixListRuleReference + ) => void ): RuleRow[] => { return firewallRules.map((thisRule, idx) => { const ruleType = ruleToPredefinedFirewall(thisRule); @@ -646,7 +659,10 @@ export const firewallRuleToRowData = ( return { ...thisRule, addresses: isFirewallRulesetsPrefixlistsEnabled - ? generateAddressesLabelV2({ addresses: thisRule.addresses }) + ? generateAddressesLabelV2({ + addresses: thisRule.addresses, + onPrefixListClick: handleOpenPrefixListDrawer, + }) : generateAddressesLabel(thisRule.addresses), id: idx + 1, // ids are 1-indexed, as id given to the useSortable hook cannot be 0 index: idx, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index ae20a00fcb7..f72ac14d99c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -19,6 +19,7 @@ import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { FirewallPrefixListDrawer } from './FirewallPrefixListDrawer'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; import { hasModified as _hasModified, @@ -31,6 +32,7 @@ import { import { FirewallRuleTable } from './FirewallRuleTable'; import { parseFirewallRuleError } from './shared'; +import type { PrefixListDrawerContext } from './FirewallPrefixListDrawer'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; import type { Category } from './shared'; import type { @@ -116,6 +118,15 @@ export const FirewallRulesLanding = React.memo((props: Props) => { }; const [ruleDrawer, setRuleDrawer] = React.useState(initialDrawer); + const [prefixListDrawer, setPrefixListDrawer] = React.useState<{ + category: Category; + context: PrefixListDrawerContext | undefined; + selectedPrefixListLabel: string | undefined; + }>({ + category: 'inbound', + selectedPrefixListLabel: undefined, + context: undefined, + }); const [submitting, setSubmitting] = React.useState(false); // @todo fine-grained error handling. const [generalErrors, setGeneralErrors] = React.useState< @@ -165,6 +176,30 @@ export const FirewallRulesLanding = React.memo((props: Props) => { }); }; + const openPrefixListDrawer = ( + category: Category, + prefixListLabel: string, + context: PrefixListDrawerContext + ) => { + setPrefixListDrawer({ + category, + selectedPrefixListLabel: prefixListLabel, + context, + }); + }; + + const closePrefixListDrawer = (options?: { closeAll?: boolean }) => { + setPrefixListDrawer({ + selectedPrefixListLabel: undefined, + context: undefined, + category: prefixListDrawer.category, + }); + + if (options?.closeAll) { + closeRuleDrawer(); + } + }; + /** * Rule Editor state hand handlers */ @@ -423,6 +458,12 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleCloneRule('inbound', idx) } handleDeleteFirewallRule={(idx) => handleDeleteRule('inbound', idx)} + handleOpenPrefixListDrawer={(prefixListLabel, plRuleRef) => { + openPrefixListDrawer('inbound', prefixListLabel, { + type: 'rule', + plRuleRef, + }); + }} handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer({ category: 'inbound', @@ -459,6 +500,12 @@ export const FirewallRulesLanding = React.memo((props: Props) => { handleCloneRule('outbound', idx) } handleDeleteFirewallRule={(idx) => handleDeleteRule('outbound', idx)} + handleOpenPrefixListDrawer={(prefixListLabel, plRuleRef) => { + openPrefixListDrawer('outbound', prefixListLabel, { + type: 'rule', + plRuleRef, + }); + }} handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer({ category: 'outbound', @@ -489,6 +536,17 @@ export const FirewallRulesLanding = React.memo((props: Props) => { { + openPrefixListDrawer(ruleDrawer.category, prefixListLabel, { + plRuleRef, + type: contextType, + modeViewedFrom: ruleDrawer.mode, + }); + }} isOpen={ location.pathname.endsWith('add/inbound') || location.pathname.endsWith('add/outbound') || @@ -506,6 +564,13 @@ export const FirewallRulesLanding = React.memo((props: Props) => { onSubmit={ruleDrawer.mode === 'create' ? handleAddRule : handleEditRule} ruleToModifyOrView={ruleToModifyOrView} /> + { + if (name.startsWith('pl::')) { + return 'Account'; + } + if (name.startsWith('pl:system:')) { + return 'System'; + } + return 'Other'; // Safe fallback +}; diff --git a/packages/manager/src/features/Firewalls/shared.test.tsx b/packages/manager/src/features/Firewalls/shared.test.tsx index 3264d4cd59c..76ea95c6fe5 100644 --- a/packages/manager/src/features/Firewalls/shared.test.tsx +++ b/packages/manager/src/features/Firewalls/shared.test.tsx @@ -273,17 +273,23 @@ describe('generateAddressesLabelV2', () => { }); const { getByText } = renderWithTheme(<>{result}); - await userEvent.click(getByText(/pl:system:test-1/)); - expect(onPrefixListClick).toHaveBeenCalledWith( - 'pl:system:test-1', - '(IPv4, IPv6)' - ); + await userEvent.click(getByText('pl:system:test-1 (IPv4, IPv6)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl:system:test-1', { + inIPv4Rule: true, + inIPv6Rule: true, + }); - await userEvent.click(getByText(/pl::test-2/)); - expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-2', '(IPv4)'); + await userEvent.click(getByText('pl::test-2 (IPv4)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-2', { + inIPv4Rule: true, + inIPv6Rule: false, + }); - await userEvent.click(getByText(/pl::test-3/)); - expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-3', '(IPv6)'); + await userEvent.click(getByText('pl::test-3 (IPv6)')); + expect(onPrefixListClick).toHaveBeenCalledWith('pl::test-3', { + inIPv4Rule: false, + inIPv6Rule: true, + }); }); it('renders None if no addresses are provided', () => { diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index 13460cba613..675683ad504 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -251,8 +251,11 @@ export const generateAddressesLabel = ( return 'None'; }; -export type PrefixListReference = { inIPv4Rule: boolean; inIPv6Rule: boolean }; -export type PrefixListReferenceMap = Record; +export type PrefixListRuleReference = { + inIPv4Rule: boolean; + inIPv6Rule: boolean; +}; +export type PrefixListReferenceMap = Record; const isPrefixList = (ip: string) => ip.startsWith('pl:'); @@ -309,7 +312,7 @@ export const buildPrefixListReferenceMap = (addresses: { /** * Represents the Firewall Rule IP families to which a Prefix List (PL) is attached or referenced. * - * Used for display and logic purposes, e.g., appending to a PL label in the UI as: + * Used to display a suffix next to the Prefix List label in the UI, e.g.,: * "pl:system:example (IPv4)", "pl:system:example (IPv6)", or "pl:system:example (IPv4, IPv6)". * * The value indicates which firewall IPs the PL applies to: @@ -331,11 +334,11 @@ interface GenerateAddressesLabelV2Options { * Optional callback invoked when a prefix list label is clicked. * * @param prefixListLabel - The label of the clicked prefix list (e.g., "pl:system:test") - * @param plRuleRefTag - Indicates which firewall rule IP family(s) this PL belongs to: `(IPv4)`, `(IPv6)`, or `(IPv4, IPv6)` + * @param plRuleRef - Indicates whether the PL is referenced in the IPv4 and/or IPv6 firewall rule */ onPrefixListClick?: ( prefixListLabel: string, - plRuleRefTag: FirewallRulePrefixListReferenceTag + plRuleRef: PrefixListRuleReference ) => void; /** * Whether to show the truncation "+N" chip with a scrollable tooltip @@ -416,7 +419,7 @@ export const generateAddressesLabelV2 = ( key={pl} onClick={(e) => { e.preventDefault(); - onPrefixListClick?.(pl, plRuleRefTag); + onPrefixListClick?.(pl, reference); }} > {`${pl} ${plRuleRefTag}`} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6e185699fc8..48a10019344 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1323,8 +1323,8 @@ export const handlers = [ label: 'firewall with rule and ruleset reference', rules: firewallRulesFactory.build({ inbound: [ - firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall (ID 123) - firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) + { ruleset: 123 }, // Referenced Ruleset to the Firewall (ID 123) + { ruleset: 123456789 }, // Referenced Ruleset to the Firewall (ID 123456789) ...firewallRuleFactory.buildList(1), ], }), @@ -1361,8 +1361,46 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(rulesets)); }), - http.get('*/v4beta/networking/prefixlists', () => { - const prefixlists = firewallPrefixListFactory.buildList(10); + http.get('*/v4beta/networking/prefixlists', ({ request }) => { + const prefixlists = [ + ...firewallPrefixListFactory.buildList(10), + ...Array.from({ length: 2 }, (_, i) => + firewallPrefixListFactory.build({ + name: `pl::vpcs:supports-both-${i + 1}`, + description: `pl::vpcs:supports-both-${i + 1} description`, + }) + ), + // Prefix List variants / cases + ...[ + { name: 'pl::supports-both' }, + { name: 'pl:system:supports-only-ipv4', ipv6: null }, + { name: 'pl::supports-only-ipv6', ipv4: null }, + { name: 'pl::supports-both-but-ipv6-empty', ipv6: [] }, + { name: 'pl::supports-both-but-empty-both', ipv4: [], ipv6: [] }, + { name: 'pl::marked-for-deletion', deleted: '2025-11-18T18:51:11' }, + { name: 'pl::not-supported', ipv4: null, ipv6: null }, + ].map((variant) => + firewallPrefixListFactory.build({ + ...variant, + description: `${variant.name} description`, + }) + ), + ]; + + if (request.headers.get('x-filter')) { + const filter = JSON.parse(request.headers.get('x-filter') || '{}'); + + if (filter['name']) { + const match = + prefixlists.find((pl) => pl.name === filter.name) ?? + firewallPrefixListFactory.build({ + name: filter['name'], + description: `${filter['name']} description`, + }); // fallback if not found + + return HttpResponse.json(makeResourcePage([match])); + } + } return HttpResponse.json(makeResourcePage(prefixlists)); }), http.get( @@ -1416,28 +1454,30 @@ export const handlers = [ label: 'firewall with rule and ruleset reference', rules: firewallRulesFactory.build({ inbound: [ - firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall (ID 123) - firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) + { ruleset: 123 }, // Referenced Ruleset to the Firewall (ID 123) + { ruleset: 123456789 }, // Referenced Ruleset to the Firewall (ID 123456789) ...firewallRuleFactory.buildList(1, { addresses: { ipv4: [ - 'pl:system:test-1', - 'pl::vpcs:test-1', + 'pl::supports-both', + 'pl:system:supports-only-ipv4', '192.168.1.213', '192.168.1.214', - '192.168.1.215', - '192.168.1.216', - 'pl::vpcs:test-2', + 'pl::vpcs:supports-both-1', + 'pl::supports-both-but-empty-both', '172.31.255.255', + 'pl::marked-for-deletion', ], ipv6: [ - 'pl:system:test-1', - 'pl::vpcs:test-3', + 'pl::supports-both', + 'pl::supports-only-ipv6', + 'pl::supports-both-but-ipv6-empty', + 'pl::vpcs:supports-both-2', '2001:db8:85a3::8a2e:370:7334/128', '2001:db8:85a3::8a2e:371:7335/128', - 'pl::vpcs:test-3', - 'pl::vpcs:test-4', - 'pl::vpcs:test-5', + // Duplicate PrefixList entries like the below one, may not appear, but if they do, + // our logic will treat them as a single entity within the ipv4 or ipv6 array. + 'pl::vpcs:supports-both-2', '2001:db8:85a3::8a2e:372:7336/128', ], }, From debff9e79bebc888e1b9db43fc1beec278ef71b0 Mon Sep 17 00:00:00 2001 From: kagora-akamai Date: Tue, 2 Dec 2025 15:13:13 +0100 Subject: [PATCH 61/91] change: [DPS-35759] - Fix UI/Copy issues after UX review (#13140) * change: [DPS-35759] - Fix UI/Copy issues after UX review * revert spacing change - point 2 --- .../pr-13140-changed-1764317364992.md | 5 + .../Destinations/DestinationTableRow.tsx | 3 + .../Destinations/DestinationsLanding.tsx | 18 ++- .../features/Delivery/Shared/LabelValue.tsx | 23 +++- .../Clusters/StreamFormClusters.tsx | 123 +++++++++++++----- ...AkamaiObjectStorageDetailsSummary.test.tsx | 4 +- ...ationAkamaiObjectStorageDetailsSummary.tsx | 26 +--- .../StreamForm/StreamFormGeneralInfo.tsx | 5 +- .../Delivery/Streams/StreamTableRow.tsx | 5 +- .../Delivery/Streams/StreamsLanding.test.tsx | 3 +- .../Delivery/Streams/StreamsLanding.tsx | 24 +++- .../features/Delivery/Streams/constants.ts | 2 +- 12 files changed, 167 insertions(+), 74 deletions(-) create mode 100644 packages/manager/.changeset/pr-13140-changed-1764317364992.md diff --git a/packages/manager/.changeset/pr-13140-changed-1764317364992.md b/packages/manager/.changeset/pr-13140-changed-1764317364992.md new file mode 100644 index 00000000000..c983c60cbca --- /dev/null +++ b/packages/manager/.changeset/pr-13140-changed-1764317364992.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Logs Delivery UI changes after review ([#13140](https://github.com/linode/manager/pull/13140)) diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx index 8ef54400dda..4c6a850a217 100644 --- a/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx +++ b/packages/manager/src/features/Delivery/Destinations/DestinationTableRow.tsx @@ -42,6 +42,9 @@ export const DestinationTableRow = React.memo( + + {destination.updated_by} +
    - setSearchText(value)} - placeholder="Search" - value={searchText} - /> + > + setSearchText(value)} + placeholder="Search" + value={searchText} + /> + { + setRegionFilter(region?.id ?? ''); + }} + regionFilter="core" + regions={regions ?? []} + sx={{ + width: '280px !important', + }} + value={regionFilter} + /> + {!isAutoAddAllClustersEnabled && formState.errors.stream?.details?.cluster_ids?.message && ( @@ -258,3 +306,18 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { ); }; + +const StyledGrid = styled(Grid)(({ theme }) => ({ + '& .MuiAutocomplete-root > .MuiBox-root': { + display: 'flex', + + '& > .MuiBox-root': { + margin: '0', + + '& > .MuiInputLabel-root': { + margin: 0, + marginRight: theme.spacingFunction(12), + }, + }, + }, +})); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx index 930cc8aae30..968e97568f3 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.test.tsx @@ -37,7 +37,7 @@ describe('DestinationAkamaiObjectStorageDetailsSummary', () => { ); }); - it('renders info icon next to path when it is empty', async () => { + it('does not render log path when it is empty', async () => { const details = { bucket_name: 'test bucket', host: 'test host', @@ -49,6 +49,6 @@ describe('DestinationAkamaiObjectStorageDetailsSummary', () => { ); // Log Path info icon: - expect(screen.getByTestId('tooltip-info-icon')).toBeVisible(); + expect(screen.queryByText('tooltip-info-icon')).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx index d9b1eba72ae..cd016649a83 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationAkamaiObjectStorageDetailsSummary.tsx @@ -1,17 +1,9 @@ -import { streamType } from '@linode/api-v4'; -import { Stack, TooltipIcon, Typography } from '@linode/ui'; import React from 'react'; -import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils'; import { LabelValue } from 'src/features/Delivery/Shared/LabelValue'; import type { AkamaiObjectStorageDetails } from '@linode/api-v4'; -const sxTooltipIcon = { - marginLeft: '4px', - padding: '0px', -}; - export const DestinationAkamaiObjectStorageDetailsSummary = ( props: AkamaiObjectStorageDetails ) => { @@ -24,28 +16,16 @@ export const DestinationAkamaiObjectStorageDetailsSummary = ( - - {!path && ( - - Default paths: - {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`} - {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`} - - } - /> - )} - + {!!path && } ); }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index bc586e7d8d4..f2e511450f3 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -39,9 +39,9 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const capitalizedMode = capitalize(mode); const description = { audit_logs: - 'Configuration and authentication audit logs that capture state-changing operations (mutations) on Linode cloud infrastructure resources and IAM authentication events. Delivered in cloudevents.io JSON format.', + 'Audit logs record state-changing operations on cloud resources and authentication events, delivered in CloudEvents JSON format.', lke_audit_logs: - 'Kubernetes API server audit logs that capture state-changing operations (mutations) on LKE-E cluster resources.', + 'Kubernetes API server audit logs capture state-changing operations on LKE-E cluster resources.', }; const pendoIds = { audit_logs: `Logs Delivery Streams ${capitalizedMode}-Audit Logs`, @@ -136,6 +136,7 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { sx={{ mt: theme.spacingFunction(16), maxWidth: 480, + whiteSpace: 'preserve-spaces', }} > {description[selectedStreamType]} diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index 891c62cd830..91ebdd2e986 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -46,9 +46,12 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { - + + + {stream.updated_by} + { within(screen.getByRole('table')).getByText('Status'); screen.getByText('ID'); screen.getByText('Destination Type'); - screen.getByText('Creation Time'); + screen.getByText('Last Modified'); + screen.getByText('Last Modified By'); // PaginationFooter const paginationFooterSelectPageSizeInput = screen.getAllByTestId( diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index ce0ade7b71c..8316af51819 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -1,6 +1,6 @@ import { streamStatus } from '@linode/api-v4'; import { useStreamsQuery, useUpdateStreamMutation } from '@linode/queries'; -import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; +import { CircleProgress, ErrorState, Hidden, Paper } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; @@ -177,7 +177,7 @@ export const StreamsLanding = () => { }; return ( - <> + { - Creation Time + Last Modified + + + + + Last Modified By @@ -249,7 +259,7 @@ export const StreamsLanding = () => { {streams?.data.map((stream) => ( ))} - {streams?.results === 0 && } + {streams?.results === 0 && } { /> )} - + ); }; diff --git a/packages/manager/src/features/Delivery/Streams/constants.ts b/packages/manager/src/features/Delivery/Streams/constants.ts index e5035a18049..28d6f37de2c 100644 --- a/packages/manager/src/features/Delivery/Streams/constants.ts +++ b/packages/manager/src/features/Delivery/Streams/constants.ts @@ -1,3 +1,3 @@ export const STREAMS_TABLE_DEFAULT_ORDER = 'desc'; -export const STREAMS_TABLE_DEFAULT_ORDER_BY = 'created'; +export const STREAMS_TABLE_DEFAULT_ORDER_BY = 'updated'; export const STREAMS_TABLE_PREFERENCE_KEY = 'streams'; From 23aa45d77dbf6e389af0bb0b2f5d274e9291b721 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Tue, 2 Dec 2025 22:07:24 +0100 Subject: [PATCH 62/91] new: STORIF-172 - Create bucket button removed from OBJ summary page. (#13144) --- .../src/features/ObjectStorage/ObjectStorageLanding.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 44abde6e8b1..e54bd60784a 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -120,6 +120,7 @@ export const ObjectStorageLanding = () => { } }; + const isSummaryOpened = match.routeId === '/object-storage/summary'; const isCreateBucketOpen = match.routeId === '/object-storage/buckets/create'; const isCreateAccessKeyOpen = match.routeId === '/object-storage/access-keys/create'; @@ -151,7 +152,7 @@ export const ObjectStorageLanding = () => { disabledCreateButton={isRestrictedUser} docsLink="https://www.linode.com/docs/platform/object-storage/" entity="Object Storage" - onButtonClick={createButtonAction} + onButtonClick={isSummaryOpened ? undefined : createButtonAction} removeCrumbX={1} shouldHideDocsAndCreateButtons={shouldHideDocsAndCreateButtons} spacingBottom={4} From b89d5f34bdcb5fcd35ada85d5757cf04eff50e79 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:56:54 -0500 Subject: [PATCH 63/91] feat: [UIE-9384] - Add Connection Pool types/endpoints/queries (#13148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Add Connection Pool types, endpoints, queries, validation for the upcoming PG Bouncer work ## How to test 🧪 ### Verification steps (How to verify changes) - [ ] Nothing to test in the UI yet, so we just need to make sure the types match the API spec and that the logic looks good --- ...r-13148-upcoming-features-1764600387228.md | 5 ++ packages/api-v4/src/databases/databases.ts | 73 +++++++++++++++++ packages/api-v4/src/databases/types.ts | 11 +++ packages/manager/src/factories/databases.ts | 12 +++ ...r-13148-upcoming-features-1764600420579.md | 5 ++ packages/queries/src/databases/databases.ts | 78 +++++++++++++++++++ packages/queries/src/databases/keys.ts | 15 ++++ ...r-13148-upcoming-features-1764600446107.md | 5 ++ packages/validation/src/databases.schema.ts | 19 +++++ 9 files changed, 223 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md create mode 100644 packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md create mode 100644 packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md diff --git a/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md b/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md new file mode 100644 index 00000000000..545cf23d859 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Added Database Connection Pool types and endpoints ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 181fbb4a0a0..e23f1ac3536 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -1,5 +1,7 @@ import { + createDatabaseConnectionPoolSchema, createDatabaseSchema, + updateDatabaseConnectionPoolSchema, updateDatabaseSchema, } from '@linode/validation/lib/databases.schema'; @@ -14,6 +16,7 @@ import Request, { import type { Filter, ResourcePage as Page, Params } from '../types'; import type { + ConnectionPool, CreateDatabasePayload, Database, DatabaseBackup, @@ -364,3 +367,73 @@ export const getDatabaseEngineConfig = (engine: Engine) => setURL(`${API_ROOT}/databases/${encodeURIComponent(engine)}/config`), setMethod('GET'), ); + +/** + * Get a paginated list of connection pools for a database + */ +export const getDatabaseConnectionPools = (databaseID: number) => + Request>( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, + ), + setMethod('GET'), + ); + +/** + * Get a connection pool for a database + */ +export const getDatabaseConnectionPool = ( + databaseID: number, + poolName: string, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('GET'), + ); + +/** + * Create a new connection pool for a database. Connection pools can only be created on active clusters + */ +export const createDatabaseConnectionPool = ( + databaseID: number, + data: ConnectionPool, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools`, + ), + setMethod('POST'), + setData(data, createDatabaseConnectionPoolSchema), + ); + +/** + * Update an existing connection pool. This may cause sudden closure of an in-use connection pool + */ +export const updateDatabaseConnectionPool = ( + databaseID: number, + poolName: string, + data: Omit, +) => + Request( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('PUT'), + setData(data, updateDatabaseConnectionPoolSchema), + ); + +/** + * Delete an existing connection pool. This may cause sudden closure of an in-use connection pool + */ +export const deleteDatabaseConnectionPool = ( + databaseID: number, + poolName: string, +) => + Request<{}>( + setURL( + `${API_ROOT}/databases/postgresql/instances/${encodeURIComponent(databaseID)}/connection-pools/${encodeURIComponent(poolName)}`, + ), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 3a07d9aadf6..984cb63e279 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -105,6 +105,7 @@ type MemberType = 'failover' | 'primary'; export interface DatabaseInstance { allow_list: string[]; cluster_size: ClusterSize; + connection_pool_port: null | number; connection_strings: ConnectionStrings[]; created: string; /** @Deprecated used by rdbms-legacy only, rdbms-default always encrypts */ @@ -249,3 +250,13 @@ export interface UpdateDatabasePayload { updates?: UpdatesSchedule; version?: string; } + +export type PoolMode = 'session' | 'statement' | 'transaction'; + +export interface ConnectionPool { + database: string; + label: string; + mode: PoolMode; + size: number; + username: null | string; +} diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index ff326fd2722..ac10c20c467 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -1,5 +1,6 @@ import { type ClusterSize, + type ConnectionPool, type Database, type DatabaseBackup, type DatabaseEngine, @@ -160,6 +161,7 @@ export const databaseInstanceFactory = ? ([1, 3][i % 2] as ClusterSize) : ([1, 2, 3][i % 3] as ClusterSize) ), + connection_pool_port: null, connection_strings: [], created: '2021-12-09T17:15:12', encrypted: false, @@ -211,6 +213,7 @@ export const databaseInstanceFactory = export const databaseFactory = Factory.Sync.makeFactory({ allow_list: [...IPv4List], cluster_size: Factory.each(() => pickRandom([1, 3])), + connection_pool_port: null, connection_strings: [ { driver: 'python', @@ -285,6 +288,15 @@ export const databaseEngineFactory = Factory.Sync.makeFactory({ version: Factory.each((i) => `${i}`), }); +export const databaseConnectionPoolFactory = + Factory.Sync.makeFactory({ + database: 'defaultdb', + mode: 'transaction', + label: Factory.each((i) => `pool/${i}`), + size: 10, + username: null, + }); + export const mysqlConfigResponse = { binlog_retention_period: { description: diff --git a/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md b/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md new file mode 100644 index 00000000000..782b19cc762 --- /dev/null +++ b/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Added Database Connection Pool queries ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 867ab791ba9..505955d11fd 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -1,6 +1,8 @@ import { createDatabase, + createDatabaseConnectionPool, deleteDatabase, + deleteDatabaseConnectionPool, legacyRestoreWithBackup, patchDatabase, resetDatabaseCredentials, @@ -8,6 +10,7 @@ import { resumeDatabase, suspendDatabase, updateDatabase, + updateDatabaseConnectionPool, } from '@linode/api-v4/lib/databases'; import { profileQueries, queryPresets } from '@linode/queries'; import { @@ -22,6 +25,7 @@ import { databaseQueries } from './keys'; import type { APIError, + ConnectionPool, CreateDatabasePayload, Database, DatabaseBackup, @@ -194,6 +198,80 @@ export const useDatabaseBackupsQuery = ( enabled, }); +export const useDatabaseConnectionPool = ( + databaseId: number, + poolName: string, + enabled: boolean = false, +) => + useQuery({ + ...databaseQueries + .database('postgresql', databaseId) + ._ctx.connectionPools._ctx.pool(poolName), + enabled, + }); + +export const useDatabaseConnectionPools = ( + databaseId: number, + enabled: boolean = false, +) => + useQuery, APIError[]>({ + ...databaseQueries.database('postgresql', databaseId)._ctx.connectionPools + ._ctx.pools, + enabled, + }); + +export const useCreateDatabaseConnectionPoolMutation = (databaseId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => createDatabaseConnectionPool(databaseId, data), + onSuccess() { + queryClient.invalidateQueries( + databaseQueries.database('postgresql', databaseId)._ctx.connectionPools, + ); + }, + }); +}; + +export const useUpdateDatabaseConnectionPoolMutation = ( + databaseId: number, + poolName: string, +) => { + const queryClient = useQueryClient(); + return useMutation>( + { + mutationFn: (data) => + updateDatabaseConnectionPool(databaseId, poolName, data), + onSuccess(connectionPool) { + queryClient.setQueryData( + databaseQueries + .database('postgresql', databaseId) + ._ctx.connectionPools._ctx.pool(connectionPool.label).queryKey, + connectionPool, + ); + }, + }, + ); +}; + +export const useDeleteDatabaseConnectionPoolMutation = ( + databaseId: number, + poolName: string, +) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteDatabaseConnectionPool(databaseId, poolName), + onSuccess() { + queryClient.invalidateQueries( + databaseQueries.database('postgresql', databaseId)._ctx.connectionPools, + ); + queryClient.removeQueries({ + queryKey: databaseQueries.database('postgresql', databaseId)._ctx + .connectionPools.queryKey, + }); + }, + }); +}; + export const useDatabaseEnginesQuery = (enabled: boolean = false) => useQuery({ ...databaseQueries.engines, diff --git a/packages/queries/src/databases/keys.ts b/packages/queries/src/databases/keys.ts index 62a3db02864..a4dc7de6168 100644 --- a/packages/queries/src/databases/keys.ts +++ b/packages/queries/src/databases/keys.ts @@ -1,5 +1,7 @@ import { getDatabaseBackups, + getDatabaseConnectionPool, + getDatabaseConnectionPools, getDatabaseCredentials, getDatabaseEngineConfig, getDatabases, @@ -30,6 +32,19 @@ export const databaseQueries = createQueryKeys('databases', { queryFn: () => getDatabaseCredentials(engine, id), queryKey: null, }, + connectionPools: { + contextQueries: { + pool: (poolName: string) => ({ + queryFn: () => getDatabaseConnectionPool(id, poolName), + queryKey: [poolName], + }), + pools: { + queryFn: () => getDatabaseConnectionPools(id), + queryKey: null, + }, + }, + queryKey: null, + }, }, queryFn: () => getEngineDatabase(engine, id), queryKey: [engine, id], diff --git a/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md b/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md new file mode 100644 index 00000000000..0e428fea296 --- /dev/null +++ b/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Upcoming Features +--- + +Added Database Connection Pool schemas ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 5cbead4508d..4c764f68f72 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -217,3 +217,22 @@ export const createDynamicAdvancedConfigSchema = (allConfigurations: any[]) => { ), }); }; + +export const createDatabaseConnectionPoolSchema = object({ + database: string().required('Database is required'), + mode: string() + .oneOf(['transaction', 'session', 'statement'], 'Pool mode is required') + .required('Pool mode is required'), + label: string() + .required('Name is required') + .max(63, 'Name must not exceed 63 characters'), + size: number().required('Size is required'), + username: string().nullable().required('Username is required'), +}); + +export const updateDatabaseConnectionPoolSchema = object({ + database: string().optional(), + mode: string().oneOf(['transaction', 'session', 'statement']).optional(), + size: number().optional(), + username: string().nullable().optional(), +}); From 533c629daec1524917cee66f08e5f9ec80911bcf Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Wed, 3 Dec 2025 11:22:05 +0530 Subject: [PATCH 64/91] upcoming: [UIE-9563] - Implement Listener detail paper (#13130) * upcoming: [UIE-9563] - Implement Listener detail paper. * Added changeset: Add a Network Load Balancer Listener detail page (EntityDetail paper) with breadcrumbs * Addressed review comments. * Address review comments from Harsh. * Address review comments. * Address review comments. --- ...r-13130-upcoming-features-1763993150579.md | 5 + .../EditAlert/EditAlertDefinition.test.tsx | 16 +- .../NetworkLoadBalancerListenersLazyRoute.ts | 9 ++ ...etworkLoadBalancersListenerDetail.test.tsx | 138 ++++++++++++++++++ .../NetworkLoadBalancersListenerDetail.tsx | 71 +++++---- ...NetworkLoadBalancersListenerDetailBody.tsx | 73 +++++++++ ...tworkLoadBalancersListenerDetailHeader.tsx | 25 ++++ ...rkLoadBalancersListenerDetailLazyRoutes.ts | 6 +- packages/manager/src/mocks/serverHandlers.ts | 14 +- .../src/routes/networkLoadBalancer/index.ts | 2 +- 10 files changed, 313 insertions(+), 46 deletions(-) create mode 100644 packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerListenersLazyRoute.ts create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.test.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailBody.tsx create mode 100644 packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailHeader.tsx diff --git a/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md b/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md new file mode 100644 index 00000000000..6a8e6659ad0 --- /dev/null +++ b/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add a Network Load Balancer Listener detail page (EntityDetail paper) with breadcrumbs ([#13130](https://github.com/linode/manager/pull/13130)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx index a7f93ac3bb7..45132bfa387 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx @@ -104,15 +104,15 @@ describe('EditAlertDefinition component', () => { await waitFor(() => expect(mutateAsyncSpy).toHaveBeenCalledTimes(1)); - expect(navigate).toHaveBeenLastCalledWith({ - to: '/alerts/definitions', - }); - await waitFor(() => { - expect( - getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly - ).toBeInTheDocument(); - }); + expect(navigate).toHaveBeenLastCalledWith({ + to: '/alerts/definitions', }); + await waitFor(() => { + expect( + getByText(UPDATE_ALERT_SUCCESS_MESSAGE) // validate whether snackbar is displayed properly + ).toBeInTheDocument(); + }); + }); it('should render EntityTypeSelect for firewall with Linode entity type', () => { const linodeFirewallAlertDetails = alertFactory.build({ diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerListenersLazyRoute.ts b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerListenersLazyRoute.ts new file mode 100644 index 00000000000..7b5b623f346 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancerListenersLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import NetworkLoadBalancersDetail from './NetworkLoadBalancersDetail'; + +export const networkLoadBalancerListenersLazyRoute = createLazyRoute( + '/netloadbalancers/$id/listeners' +)({ + component: NetworkLoadBalancersDetail, +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.test.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.test.tsx new file mode 100644 index 00000000000..80d5882bc0d --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.test.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; + +import { + networkLoadBalancerFactory, + networkLoadBalancerListenerFactory, +} from 'src/factories/networkLoadBalancer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import NetworkLoadBalancersListenerDetail from './NetworkLoadBalancersListenerDetail'; + +const queryMocks = vi.hoisted(() => ({ + useNetworkLoadBalancerQuery: vi.fn().mockReturnValue({}), + useNetworkLoadBalancerNodesQuery: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({ id: 1, listenerId: 1 }), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useNetworkLoadBalancerQuery: queryMocks.useNetworkLoadBalancerQuery, + useNetworkLoadBalancerNodesQuery: + queryMocks.useNetworkLoadBalancerNodesQuery, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +beforeAll(() => mockMatchMedia()); + +describe('NetworkLoadBalancersListenerDetail', () => { + beforeEach(() => { + vi.clearAllMocks(); + queryMocks.useParams.mockReturnValue({ id: 1, listenerId: 1 }); + }); + + it('renders a loading state', () => { + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('circle-progress')).toBeVisible(); + }); + + it('renders an error state when NLB is not found', () => { + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + data: null, + }); + + const { getByText } = renderWithTheme( + + ); + + expect( + getByText( + 'There was a problem retrieving your listener. Please try again.' + ) + ).toBeVisible(); + }); + + it('renders an error state when there is an error', () => { + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + error: new Error('Test error'), + }); + + const { getByText } = renderWithTheme( + + ); + + expect( + getByText( + 'There was a problem retrieving your listener. Please try again.' + ) + ).toBeVisible(); + }); + + it('renders an error state when listener is not found', () => { + const nlbFactory = networkLoadBalancerFactory.build({ + listeners: [], + }); + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + data: nlbFactory, + }); + + const { getByText } = renderWithTheme( + + ); + expect( + getByText( + 'There was a problem retrieving your listener. Please try again.' + ) + ).toBeVisible(); + }); + + it('renders the listener details', () => { + // Build listener with ID '1' as a string to match the route param + const listener = networkLoadBalancerListenerFactory.build({ id: 1 }); + const nlbFactory = networkLoadBalancerFactory.build({ + listeners: [listener], + }); + + queryMocks.useNetworkLoadBalancerQuery.mockReturnValue({ + isLoading: false, + data: nlbFactory, + }); + queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({ + data: { results: 5 }, + isLoading: false, + }); + + const { getByText } = renderWithTheme( + , + { + initialRoute: '/netloadbalancers/$id/listeners/$listenerId', + initialEntries: ['/netloadbalancers/2/listeners/1'], + } + ); + + expect(getByText('Port')).toBeVisible(); + expect(getByText(listener.port.toString())).toBeVisible(); + expect(getByText('Protocol')).toBeVisible(); + expect(getByText(new RegExp(listener.protocol, 'i'))).toBeVisible(); + expect(getByText('Nodes')).toBeVisible(); + expect(getByText('5')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.tsx index 3f14c9067a4..8434b1afd94 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.tsx +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.tsx @@ -1,45 +1,44 @@ import { - useNetworkLoadBalancerListenerQuery, + useNetworkLoadBalancerNodesQuery, useNetworkLoadBalancerQuery, } from '@linode/queries'; -import { CircleProgress, ErrorState, Notice } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { LandingHeader } from 'src/components/LandingHeader'; -export const NetworkLoadBalancersListenerDetail = () => { - const params = useParams({ strict: false }); - const { id: nlbId, listenerId } = params; +import { NetworkLoadBalancersListenerDetailBody } from './NetworkLoadBalancersListenerDetailBody'; +import { NetworkLoadBalancersListenerDetailHeader } from './NetworkLoadBalancersListenerDetailHeader'; - const { - data: nlb, - error, - isLoading, - } = useNetworkLoadBalancerQuery(Number(nlbId) || -1, true); +const NetworkLoadBalancersListenerDetail = () => { + const { id, listenerId } = useParams({ + from: '/netloadbalancers/$id/listeners/$listenerId/nodes', + }); - const { - data: listener, - error: listenerError, - isLoading: listenerLoading, - } = useNetworkLoadBalancerListenerQuery( - Number(nlbId) || -1, - Number(listenerId) || -1, - true - ); + const { data: nlb, error, isLoading } = useNetworkLoadBalancerQuery(id); + + // Fetch nodes for this listener + const { data: nodesData, isLoading: nodesLoading } = + useNetworkLoadBalancerNodesQuery(id, listenerId); + + const listener = nlb?.listeners?.find((l) => l.id === listenerId); - if (isLoading || listenerLoading) { + if (isLoading) { return ; } - if (!nlb || error || !listener || listenerError) { + if (!nlb || error || !listener) { return ( - + ); } return ( <> + { { label: nlb.label, position: 2, - linkTo: `/netloadbalancers/$id/listeners`, - noCap: true, }, ], - pathname: `/netloadbalancers/${nlbId}/${listenerId}`, + pathname: `/netloadbalancers/${id}/listeners/${listenerId}`, }} docsLabel="Docs" - docsLink={ - 'https://techdocs.akamai.com/linode-api/changelog/network-load-balancers' + docsLink="https://techdocs.akamai.com/linode-api/changelog/network-load-balancers" + removeCrumbX={2} + title={`${listener.label}`} + /> + + } + header={ + } - title={listener.label} + noBodyBottomBorder={true} /> - Listener Detail is coming soon... ); }; + +export default NetworkLoadBalancersListenerDetail; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailBody.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailBody.tsx new file mode 100644 index 00000000000..f7cdc400ff6 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailBody.tsx @@ -0,0 +1,73 @@ +import { Box, Paper, Stack, Typography } from '@linode/ui'; +import { Grid, styled } from '@mui/material'; +import * as React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { Skeleton } from 'src/components/Skeleton'; + +import type { NetworkLoadBalancerListener } from '@linode/api-v4'; + +interface Props + extends Pick< + NetworkLoadBalancerListener, + 'created' | 'port' | 'protocol' | 'updated' + > { + nodes: number; + nodesLoading?: boolean; +} + +export const NetworkLoadBalancersListenerDetailBody = ({ + created, + nodes, + nodesLoading, + port, + protocol, + updated, +}: Props) => { + return ( + + + + + + + Port + {port} + + + + + Created + + + + + + Protocol + + {protocol} + + + + + + Updated + + + + + + Nodes + {nodesLoading ? : nodes} + + + + + + + ); +}; + +const LabelTypography = styled(Typography)(({ theme }) => ({ + font: theme.font.bold, +})); diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailHeader.tsx b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailHeader.tsx new file mode 100644 index 00000000000..e19e1a787a8 --- /dev/null +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailHeader.tsx @@ -0,0 +1,25 @@ +import { Box, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; + +interface Props { + label: string; +} + +export const NetworkLoadBalancersListenerDetailHeader = ({ label }: Props) => { + return ( + + + ({ + font: theme.font.bold, + padding: '13px 16px', + })} + > + Listener: {label} + + + + ); +}; diff --git a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailLazyRoutes.ts b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailLazyRoutes.ts index 76b0835e345..502f1de0841 100644 --- a/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailLazyRoutes.ts +++ b/packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailLazyRoutes.ts @@ -1,9 +1,9 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { NetworkLoadBalancersListenerDetail } from './NetworkLoadBalancersListenerDetail'; +import NetworkLoadBalancersListenerDetail from './NetworkLoadBalancersListenerDetail'; -export const NetworkLoadBalancersListenerDetailLazyRoute = createLazyRoute( - '/netloadbalancers/$id/listeners/$listenerId' +export const networkLoadBalancersListenerDetailLazyRoute = createLazyRoute( + '/netloadbalancers/$id/listeners/$listenerId/nodes' )({ component: NetworkLoadBalancersListenerDetail, }); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 48a10019344..86110139cd0 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -595,11 +595,15 @@ const netLoadBalancers = [ http.get('*/v4beta/netloadbalancers/:id/listeners/:listenerId', () => { return HttpResponse.json(networkLoadBalancerListenerFactory.build()); }), - http.get('*/v4beta/netloadbalancers/:id/listeners/:listenerId/nodes', () => { - return HttpResponse.json( - makeResourcePage(networkLoadBalancerNodeFactory.buildList(30)) - ); - }), + http.get( + '*/v4beta/netloadbalancers/:id/listeners/:listenerId/nodes', + async () => { + await sleep(1000); + return HttpResponse.json( + makeResourcePage(networkLoadBalancerNodeFactory.buildList(30)) + ); + } + ), ]; const nanodeType = linodeTypeFactory.build({ id: 'g6-nanode-1' }); diff --git a/packages/manager/src/routes/networkLoadBalancer/index.ts b/packages/manager/src/routes/networkLoadBalancer/index.ts index b2a21d0b55e..5f5245e0207 100644 --- a/packages/manager/src/routes/networkLoadBalancer/index.ts +++ b/packages/manager/src/routes/networkLoadBalancer/index.ts @@ -71,7 +71,7 @@ const networkLoadBalancerNodesRoute = createRoute({ }).lazy(() => import( 'src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetailLazyRoutes' - ).then((m) => m.NetworkLoadBalancersListenerDetailLazyRoute) + ).then((m) => m.networkLoadBalancersListenerDetailLazyRoute) ); export const networkLoadBalancersRouteTree = From 40958c2e965cb89dd0fbb52cb6dbcec3867d7894 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 3 Dec 2025 12:31:33 +0530 Subject: [PATCH 65/91] upcoming: [UIE-9515] - Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection (#13138) * Update Rules Edit/Add drawer to support PrefixLists * Update comment * Add some mocks for prefixlists * Update mock data order * Reset pls state on address change * Feature-flagged add/edit prefixlists * Some clean up * Some changes * More clean up * More clean up * Add code comments and some clean up * Add key and test ids to the pl rows * A small fix * Add unit tests * More clean up * Rename component * Fix one test case * Few fixes * Fix type import * Update mocks for PLs with more possible cases * Some more changes related to sorting * Update mocks * Added changeset: Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection * Add/update comments --- ...r-13138-upcoming-features-1764681439890.md | 5 + .../MultipleIPInput/MultipleIPInput.tsx | 18 +- .../Rules/FirewallRuleDrawer.test.tsx | 209 +++++++--- .../Rules/FirewallRuleDrawer.tsx | 56 ++- .../Rules/FirewallRuleDrawer.types.ts | 4 +- .../Rules/FirewallRuleDrawer.utils.ts | 155 ++++++- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 82 +++- .../Rules/FirewallRuleSetForm.tsx | 1 + .../Rules/MutiplePrefixListSelect.test.tsx | 389 ++++++++++++++++++ .../Rules/MutiplePrefixListSelect.tsx | 328 +++++++++++++++ .../Firewalls/FirewallDetail/Rules/shared.ts | 6 + .../manager/src/features/Firewalls/shared.tsx | 24 +- packages/manager/src/utilities/ipUtils.ts | 4 + 13 files changed, 1163 insertions(+), 118 deletions(-) create mode 100644 packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx diff --git a/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md b/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md new file mode 100644 index 00000000000..35a420a4964 --- /dev/null +++ b/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection ([#13138](https://github.com/linode/manager/pull/13138)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 6515ff3ee81..f8ca08ba0e9 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -26,7 +26,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'flex-start', }, paddingLeft: 0, - paddingTop: theme.spacing(1.5), + paddingTop: theme.spacingFunction(12), }, button: { '& > span': { @@ -70,6 +70,12 @@ export interface MultipeIPInputProps { */ buttonText?: string; + /** + * Whether the first input field can be removed. + * @default false + */ + canRemoveFirstInput?: boolean; + /** * Custom CSS class for additional styling. */ @@ -155,6 +161,7 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { const { adjustSpacingForVPCDualStack, buttonText, + canRemoveFirstInput, className, disabled, error, @@ -244,8 +251,8 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { { * used in DBaaS or for Linode VPC interfaces */} - {(idx > 0 || forDatabaseAccessControls || forVPCIPRanges) && ( + {(idx > 0 || + forDatabaseAccessControls || + forVPCIPRanges || + canRemoveFirstInput) && ( { describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { - expect(formValueToIPs('all', [''].map(stringToExtendedIP))).toEqual( + expect(formValueToIPs('all', [''].map(stringToExtendedIP), [])).toEqual( allIPs ); - expect(formValueToIPs('allIPv4', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv4', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv4: ['0.0.0.0/0'], }); - expect(formValueToIPs('allIPv6', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv6', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv6: ['::/0'], }); expect( - formValueToIPs('ip/netmask', ['1.1.1.1'].map(stringToExtendedIP)) + formValueToIPs( + 'ip/netmask/prefixlist', + ['1.1.1.1'].map(stringToExtendedIP), + [] + ) ).toEqual({ ipv4: ['1.1.1.1'], }); @@ -304,22 +312,27 @@ describe('utilities', () => { }); describe('validateForm', () => { + const baseOptions = { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + }; + it('validates protocol', () => { - expect(validateForm({})).toHaveProperty( + expect(validateForm({}, baseOptions)).toHaveProperty( 'protocol', 'Protocol is required.' ); }); it('validates ports', () => { - expect(validateForm({ ports: '80', protocol: 'ICMP' })).toHaveProperty( - 'ports', - 'Ports are not allowed for ICMP protocols.' - ); expect( - validateForm({ ports: '443', protocol: 'IPENCAP' }) + validateForm({ ports: '80', protocol: 'ICMP' }, baseOptions) + ).toHaveProperty('ports', 'Ports are not allowed for ICMP protocols.'); + expect( + validateForm({ ports: '443', protocol: 'IPENCAP' }, baseOptions) ).toHaveProperty('ports', 'Ports are not allowed for IPENCAP protocols.'); expect( - validateForm({ ports: 'invalid-port', protocol: 'TCP' }) + validateForm({ ports: 'invalid-port', protocol: 'TCP' }, baseOptions) ).toHaveProperty('ports'); }); it('validates custom ports', () => { @@ -328,56 +341,77 @@ describe('utilities', () => { label: 'Firewalllabel', }; // SUCCESS CASES - expect(validateForm({ ports: '1234', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ ports: '1,2,3,4,5', protocol: 'TCP', ...rest }) + validateForm({ ports: '1234', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); expect( - validateForm({ ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, + baseOptions + ) ).toEqual({}); - expect(validateForm({ ports: '1-20', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', - protocol: 'TCP', - ...rest, - }) + validateForm( + { ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }, + baseOptions + ) + ).toEqual({}); + expect( + validateForm({ ports: '1-20', protocol: 'TCP', ...rest }, baseOptions) + ).toEqual({}); + expect( + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', + protocol: 'TCP', + ...rest, + }, + baseOptions + ) ).toEqual({}); expect( - validateForm({ ports: '1-2,3-4', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1-2,3-4', protocol: 'TCP', ...rest }, + baseOptions + ) ).toEqual({}); expect( - validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }) + validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); // FAILURE CASES expect( - validateForm({ ports: '1,21-12', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1,21-12', protocol: 'TCP', ...rest }, + baseOptions + ) ).toHaveProperty( 'ports', 'Range must start with a smaller number and end with a larger number' ); expect( - validateForm({ ports: '1-21-45', protocol: 'TCP', ...rest }) + validateForm( + { ports: '1-21-45', protocol: 'TCP', ...rest }, + baseOptions + ) ).toHaveProperty('ports', 'Ranges must have 2 values'); expect( - validateForm({ ports: 'abc', protocol: 'TCP', ...rest }) + validateForm({ ports: 'abc', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '1--20', protocol: 'TCP', ...rest }) + validateForm({ ports: '1--20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '-20', protocol: 'TCP', ...rest }) + validateForm({ ports: '-20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', - protocol: 'TCP', - ...rest, - }) + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', + protocol: 'TCP', + ...rest, + }, + baseOptions + ) ).toHaveProperty( 'ports', 'Number of ports or port range endpoints exceeded. Max allowed is 15' @@ -432,11 +466,79 @@ describe('utilities', () => { ports: '80', protocol: 'TCP', }; - expect(validateForm({ label: value, ...rest })).toEqual(result); + expect(validateForm({ label: value, ...rest }, baseOptions)).toEqual( + result + ); }); }); + + it('handles addresses field when isFirewallRulesetsPrefixlistsFeatureEnabled is true', () => { + // Invalid cases + expect( + validateForm( + {}, + { + ...baseOptions, + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).toHaveProperty('addresses', 'Sources is a required field.'); + + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + ...baseOptions, + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).toHaveProperty( + 'addresses', + 'Add an IP address in IP/mask format, or reference a Prefix List name.' + ); + + // Valid cases + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [ + { address: '192.268.0.0' }, + { address: '192.268.0.1' }, + ], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [{ address: '192.268.0.0' }], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).not.toHaveProperty('addresses'); + }); + it('handles required fields', () => { - expect(validateForm({})).toEqual({ + expect(validateForm({}, baseOptions)).toEqual({ addresses: 'Sources is a required field.', label: 'Label is required.', ports: 'Ports is a required field.', @@ -445,11 +547,11 @@ describe('utilities', () => { }); }); - describe('getInitialIPs', () => { + describe('getInitialIPsOrPLs', () => { const ruleToModify: ExtendedFirewallRule = { action: 'ACCEPT', addresses: { - ipv4: ['1.2.3.4'], + ipv4: ['1.2.3.4', 'pl:system:test'], ipv6: ['::0'], }, originalIndex: 0, @@ -458,10 +560,8 @@ describe('utilities', () => { status: 'NEW', }; it('parses the IPs when no errors', () => { - expect(getInitialIPs(ruleToModify)).toEqual([ - { address: '1.2.3.4' }, - { address: '::0' }, - ]); + const { ips: initalIPs } = getInitialIPsOrPLs(ruleToModify); + expect(initalIPs).toEqual([{ address: '1.2.3.4' }, { address: '::0' }]); }); it('parses the IPs with no errors', () => { const errors: FirewallRuleError[] = [ @@ -473,13 +573,17 @@ describe('utilities', () => { reason: 'Invalid IP', }, ]; - expect(getInitialIPs({ ...ruleToModify, errors })).toEqual([ + const { ips: initalIPs } = getInitialIPsOrPLs({ + ...ruleToModify, + errors, + }); + expect(initalIPs).toEqual([ { address: '1.2.3.4', error: IP_ERROR_MESSAGE }, { address: '::0' }, ]); }); it('offsets error indices correctly', () => { - const result = getInitialIPs({ + const { ips: initialIPs } = getInitialIPsOrPLs({ ...ruleToModify, addresses: { ipv4: ['1.2.3.4'], @@ -495,11 +599,17 @@ describe('utilities', () => { }, ], }); - expect(result).toEqual([ + expect(initialIPs).toEqual([ { address: '1.2.3.4' }, { address: 'INVALID_IP', error: IP_ERROR_MESSAGE }, ]); }); + it('parses the PLs when no errors', () => { + const { pls: initalPLs } = getInitialIPsOrPLs(ruleToModify); + expect(initalPLs).toEqual([ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: false }, + ]); + }); }); describe('classifyIPs', () => { @@ -528,12 +638,13 @@ describe('utilities', () => { }; it('correctly matches values to their representative type', () => { - const result = deriveTypeFromValuesAndIPs(formValues, []); + const result = deriveTypeFromValuesAndIPs(formValues, [], []); expect(result).toBe('https'); }); it('returns "custom" if there is no match', () => { const result = deriveTypeFromValuesAndIPs( { ...formValues, ports: '22-23' }, + [], [] ); expect(result).toBe('custom'); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index cdd97596b06..61edb9528ca 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -10,11 +10,12 @@ import { useIsFirewallRulesetsPrefixlistsEnabled } from '../../shared'; import { formValueToIPs, getInitialFormValues, - getInitialIPs, + getInitialIPsOrPLs, itemsToPortString, portStringToItems, validateForm, validateIPs, + validatePrefixLists, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; import { FirewallRuleSetDetailsView } from './FirewallRuleSetDetailsView'; @@ -28,11 +29,12 @@ import type { FormRuleSetState, FormState, } from './FirewallRuleDrawer.types'; +import type { ValidateFormOptions } from './FirewallRuleDrawer.utils'; import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; // ============================================================================= // @@ -59,12 +61,16 @@ export const FirewallRuleDrawer = React.memo( const [createEntityType, setCreateEntityType] = React.useState('rule'); - // Custom IPs are tracked separately from the form. The + // Custom IPs or PLs are tracked separately from the form. The or // component consumes this state. We use this on form submission if the - // `addresses` form value is "ip/netmask", which indicates the user has - // intended to specify custom IPs. + // `addresses` form value is "ip/netmask/prefixlist", which indicates the user has + // intended to specify custom IPs or PLs. const [ips, setIPs] = React.useState([{ address: '' }]); + const [pls, setPLs] = React.useState([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + // Firewall Ports, like IPs, are tracked separately. The form.values state value // tracks the custom user input; the FirewallOptionItem[] array of port presets in the multi-select // is stored here. @@ -76,12 +82,15 @@ export const FirewallRuleDrawer = React.memo( // 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' && ruleToModifyOrView) { - setIPs(getInitialIPs(ruleToModifyOrView)); + const { ips, pls } = getInitialIPsOrPLs(ruleToModifyOrView); + setIPs(ips); + setPLs(pls); setPresetPorts(portStringToItems(ruleToModifyOrView.ports)[0]); } else if (isOpen) { setPresetPorts([]); } else { setIPs([{ address: '' }]); + setPLs([]); } // Reset the Create entity selection to 'rule' in two cases: @@ -116,31 +125,46 @@ export const FirewallRuleDrawer = React.memo( // The validated IPs may have errors, so set them to state so we see the errors. const validatedIPs = validateIPs(ips, { - allowEmptyAddress: addresses !== 'ip/netmask', + allowEmptyAddress: addresses !== 'ip/netmask/prefixlist', }); setIPs(validatedIPs); + // The validated PLs may have errors, so set them to state so we see the errors. + const validatedPLs = validatePrefixLists(pls); + setPLs(validatedPLs); + const _ports = itemsToPortString(presetPorts, ports!); + const validateFormOptions: ValidateFormOptions = { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }; + return { - ...validateForm({ - addresses, - description, - label, - ports: _ports, - protocol, - }), + ...validateForm( + { + addresses, + description, + label, + ports: _ports, + protocol, + }, + validateFormOptions + ), // This is a bit of a trick. If this function DOES NOT return an empty object, Formik will call // `onSubmit()`. If there are IP errors, we add them to the return object so Formik knows there // is an issue with the form. ...validatedIPs.filter((thisIP) => Boolean(thisIP.error)), + // For PrefixLists + ...validatedPLs.filter((thisPL) => Boolean(thisPL.error)), }; }; const onSubmitRule = (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!, ips, pls); const payload: FirewallRuleType = { action: values.action, @@ -223,9 +247,11 @@ export const FirewallRuleDrawer = React.memo( closeDrawer={onClose} ips={ips} mode={mode} + pls={pls} presetPorts={presetPorts} ruleErrors={ruleToModifyOrView?.errors} setIPs={setIPs} + setPLs={setPLs} setPresetPorts={setPresetPorts} {...formikProps} /> 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 e0cfdfd8067..f942b9af1cb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -7,7 +7,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FormikProps } from 'formik'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; export type FirewallRuleDrawerMode = 'create' | 'edit' | 'view'; @@ -47,9 +47,11 @@ export interface FirewallRuleFormProps extends FormikProps { closeDrawer: () => void; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; + pls: ExtendedPL[]; presetPorts: FirewallOptionItem[]; ruleErrors?: FirewallRuleError[]; setIPs: (ips: ExtendedIP[]) => void; + setPLs: (pls: ExtendedPL[]) => void; setPresetPorts: (selected: FirewallOptionItem[]) => void; } 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 63e046e57a5..0f8aa88f3dd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -13,6 +13,7 @@ import { allowNoneIPv4, allowNoneIPv6, allowsAllIPs, + buildPrefixListReferenceMap, predefinedFirewallFromRule, } from 'src/features/Firewalls/shared'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; @@ -26,7 +27,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; @@ -42,7 +43,8 @@ export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; */ export const deriveTypeFromValuesAndIPs = ( values: FormState, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ) => { if (values.type === 'custom') { return 'custom'; @@ -52,7 +54,7 @@ export const deriveTypeFromValuesAndIPs = ( const predefinedFirewall = predefinedFirewallFromRule({ action: 'ACCEPT', - addresses: formValueToIPs(values.addresses!, ips), + addresses: formValueToIPs(values.addresses!, ips, pls), ports: values.ports, protocol, }); @@ -74,7 +76,8 @@ export const deriveTypeFromValuesAndIPs = ( */ export const formValueToIPs = ( formValue: string, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ): FirewallRuleType['addresses'] => { switch (formValue) { case 'all': @@ -83,10 +86,34 @@ export const formValueToIPs = ( return { ipv4: [allIPv4] }; case 'allIPv6': return { ipv6: [allIPv6] }; - default: - // The user has selected "IP / Netmask" and entered custom IPs, so we need + default: { + // The user has selected "IP / Netmask / Prefix List" and entered custom IPs or selected PLs, so we need // to separate those into v4 and v6 addresses. - return classifyIPs(ips); + const classifiedIPs = classifyIPs(ips); + const classifiedPLs = classifyPLs(pls); + + const ruleIPv4 = [ + ...(classifiedIPs.ipv4 ?? []), + ...(classifiedPLs.ipv4 ?? []), + ]; + + const ruleIPv6 = [ + ...(classifiedIPs.ipv6 ?? []), + ...(classifiedPLs.ipv6 ?? []), + ]; + + const result: FirewallRuleType['addresses'] = {}; + + if (ruleIPv4.length > 0) { + result.ipv4 = ruleIPv4; + } + + if (ruleIPv6.length > 0) { + result.ipv6 = ruleIPv6; + } + + return result; + } } }; @@ -112,6 +139,34 @@ export const validateIPs = ( }); }; +export const validatePrefixLists = (pls: ExtendedPL[]): ExtendedPL[] => { + const seen = new Set(); + return pls.map((pl) => { + const { address, inIPv4Rule, inIPv6Rule } = pl; + + if (!pl.address) { + return { ...pl, error: 'Please select the Prefix List.' }; + } + + if (pl.inIPv4Rule === false && pl.inIPv6Rule === false) { + return { + ...pl, + error: 'At least one IPv4 or IPv6 option must be selected.', + }; + } + + if (seen.has(pl.address)) { + return { + ...pl, + error: 'This Prefix List is already selected.', + }; + } + + seen.add(pl.address); + return { address, inIPv4Rule, inIPv6Rule }; + }); +}; + /** * Given an array of IP addresses, filter out invalid addresses and categorize * them by "ipv4" and "ipv6." @@ -138,6 +193,28 @@ export const classifyIPs = (ips: ExtendedIP[]) => { ); }; +/** + * Given an array of Firewall Rule IP addresses, categorize + * Prefix List by "ipv4" and "ipv6." + */ +export const classifyPLs = (pls: ExtendedPL[]) => { + return pls.reduce<{ ipv4?: string[]; ipv6?: string[] }>((acc, pl) => { + if (pl.inIPv4Rule) { + if (!acc.ipv4) { + acc.ipv4 = []; + } + acc.ipv4.push(pl.address); + } + if (pl.inIPv6Rule) { + if (!acc.ipv6) { + acc.ipv6 = []; + } + acc.ipv6.push(pl.address); + } + return acc; + }, {}); +}; + const initialValues: FormState = { action: 'ACCEPT', addresses: '', @@ -181,21 +258,42 @@ export const getInitialAddressFormValue = ( return 'allIPv6'; } - return 'ip/netmask'; + return 'ip/netmask/prefixlist'; }; -// Get a list of Extended IP from an existing Firewall rule. This is necessary when opening the +// Get a list of Extended IP or Extended PL from an existing Firewall rule. This is necessary when opening the // drawer/form to modify an existing rule. -export const getInitialIPs = ( +export const getInitialIPsOrPLs = ( ruleToModify: ExtendedFirewallRule -): ExtendedIP[] => { +): { + ips: ExtendedIP[]; + pls: ExtendedPL[]; +} => { const { addresses } = ruleToModify; - const extendedIPv4 = (addresses?.ipv4 ?? []).map(stringToExtendedIP); - const extendedIPv6 = (addresses?.ipv6 ?? []).map(stringToExtendedIP); + // Exclude all prefix list entries (pl:*) from the FW Rule addresses when building extendedIPv4/extendedIPv6 + const extendedIPv4 = (addresses?.ipv4 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); + const extendedIPv6 = (addresses?.ipv6 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); const ips: ExtendedIP[] = [...extendedIPv4, ...extendedIPv6]; + // Build ExtendedPL from the FW Rule addresses + const prefixListMap = buildPrefixListReferenceMap({ + ipv4: addresses?.ipv4 ?? [], + ipv6: addresses?.ipv6 ?? [], + }); + const extendedPL = Object.entries(prefixListMap).map(([pl, reference]) => ({ + address: pl, + inIPv4Rule: reference.inIPv4Rule, + inIPv6Rule: reference.inIPv6Rule, + })); + const pls: ExtendedPL[] = extendedPL; + + // Errors ruleToModify.errors?.forEach((thisError) => { const { formField, ip } = thisError; @@ -223,7 +321,7 @@ export const getInitialIPs = ( ips[index].error = IP_ERROR_MESSAGE; }); - return ips; + return { ips, pls }; }; /** @@ -305,13 +403,20 @@ export const portStringToItems = ( return [items, customInput.join(', ')]; }; -export const validateForm = ({ - addresses, - description, - label, - ports, - protocol, -}: Partial) => { +export interface ValidateFormOptions { + isFirewallRulesetsPrefixlistsFeatureEnabled: boolean; + validatedIPs: ExtendedIP[]; + validatedPLs: ExtendedPL[]; +} + +export const validateForm = ( + { addresses, description, label, ports, protocol }: Partial, + { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }: ValidateFormOptions +) => { const errors: Partial = {}; if (label) { @@ -337,6 +442,14 @@ export const validateForm = ({ if (!addresses) { errors.addresses = 'Sources is a required field.'; + } else if ( + isFirewallRulesetsPrefixlistsFeatureEnabled && + addresses === 'ip/netmask/prefixlist' && + validatedIPs.length === 0 && + validatedPLs.length === 0 + ) { + errors.addresses = + 'Add an IP address in IP/mask format, or reference a Prefix List name.'; } if (!ports && protocol !== 'ICMP' && protocol !== 'IPENCAP') { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index c94082dd44b..6de74c88677 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -1,6 +1,7 @@ import { ActionsPanel, Autocomplete, + Box, FormControlLabel, Radio, RadioGroup, @@ -14,14 +15,16 @@ import * as React from 'react'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { - addressOptions, firewallOptionItemsShort, portPresets, protocolOptions, + useAddressOptions, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; +import { MultiplePrefixListSelect } from './MutiplePrefixListSelect'; import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; @@ -29,7 +32,7 @@ import type { FirewallOptionItem, FirewallPreset, } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; const ipNetmaskTooltipText = 'If you do not specify a mask, /32 will be assumed for IPv4 addresses and /128 will be assumed for IPv6 addresses.'; @@ -44,17 +47,24 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { handleChange, handleSubmit, ips, + pls, mode, presetPorts, ruleErrors, setFieldError, setFieldValue, setIPs, + setPLs, setPresetPorts, touched, values, } = props; + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const addressOptions = useAddressOptions(); + const hasCustomInput = presetPorts.some( (thisPort) => thisPort.value === PORT_PRESETS['CUSTOM'].value ); @@ -146,10 +156,17 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const handleAddressesChange = React.useCallback( (item: string) => { setFieldValue('addresses', item); - // Reset custom IPs - setIPs([{ address: '' }]); + // Reset custom IPs & PLs + if (isFirewallRulesetsPrefixlistsFeatureEnabled) { + // For "IP / Netmask / Prefix List": reset both custom IPs and PLs + setIPs([]); + setPLs([]); + } else { + // For "IP / Netmask": reset IPs to at least one empty input + setIPs([{ address: '' }]); + } }, - [setFieldValue, setIPs] + [setFieldValue, setIPs, setPLs, isFirewallRulesetsPrefixlistsFeatureEnabled] ); const handleActionChange = React.useCallback( @@ -172,6 +189,13 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { setIPs(_ipsWithMasks); }; + const handlePrefixListChange = React.useCallback( + (_pls: ExtendedPL[]) => { + setPLs(_pls); + }, + [setPLs] + ); + const handlePortPresetChange = React.useCallback( (items: FirewallOptionItem[]) => { // If the user is selecting "ALL", it doesn't make sense @@ -195,7 +219,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { return ( addressOptions.find( (thisOption) => thisOption.value === values.addresses - ) || undefined + ) ?? null ); }, [values]); @@ -294,27 +318,41 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { }} options={addressOptions} placeholder={`Select ${addressesLabel}s...`} + required 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' && ( - + {/* Show this field only if "IP / Netmask / Prefix List has been selected." */} + {values.addresses === 'ip/netmask/prefixlist' && ( + + isFirewallRulesetsPrefixlistsFeatureEnabled + ? theme.spacingFunction(24) + : 0 + } + > + 0 ? 'IP / Netmask' : ''} + tooltip={ipNetmaskTooltipText} + /> + {isFirewallRulesetsPrefixlistsFeatureEnabled && ( + + )} + )} @@ -357,6 +395,6 @@ const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ const StyledMultipleIPInput = styled(MultipleIPInput, { label: 'StyledMultipleIPInput', -})(({ theme }) => ({ - marginTop: theme.spacingFunction(16), +})(({ theme, ips }) => ({ + ...(ips.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), })); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index eda6435e4f0..a07f89d38cc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -58,6 +58,7 @@ export const FirewallRuleSetForm = React.memo( const ruleSetDropdownOptions = React.useMemo( () => ruleSets + // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field .filter((ruleSet) => ruleSet.type === category) // Display only rule sets applicable to the given category .map((ruleSet) => ({ label: ruleSet.label, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx new file mode 100644 index 00000000000..8df198514e4 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx @@ -0,0 +1,389 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import * as shared from '../../shared'; +import { MultiplePrefixListSelect } from './MutiplePrefixListSelect'; + +const queryMocks = vi.hoisted(() => ({ + useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallPrefixListsQuery: queryMocks.useAllFirewallPrefixListsQuery, + }; +}); + +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + +describe('MultiplePrefixListSelect', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + const onChange = vi.fn(); + + const mockPrefixLists = [ + { + name: 'pl::supports-both', + ipv4: ['192.168.0.0/24'], + ipv6: ['2001:db8::/128'], + }, // PL supported (supports both) + { + name: 'pl::supports-only-ipv6', + ipv4: null, + ipv6: ['2001:db8:1::/128'], + }, // supported (supports only ipv6) + { + name: 'pl:system:supports-only-ipv4', + ipv4: ['10.0.0.0/16'], + ipv6: null, + }, // supported (supports only ipv4) + { name: 'pl:system:supports-both', ipv4: [], ipv6: [] }, // supported (supports both) + { name: 'pl:system:not-supported', ipv4: null, ipv6: null }, // unsupported + ]; + + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: mockPrefixLists, + isFetching: false, + error: null, + }); + + it('should render the title only when at least one PL row is added', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Prefix List')).toBeVisible(); + }); + + it('should not render the title when no PL row is added', () => { + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('Prefix List')).not.toBeInTheDocument(); + }); + + it('should add a new PL row (empty state) when clicking "Add a Prefix List"', async () => { + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Add a Prefix List')); + expect(onChange).toHaveBeenCalledWith([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + }); + + it('should remove a PL row when clicking delete (X)', async () => { + const onChange = vi.fn(); + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { getByTestId } = renderWithTheme( + + ); + + await userEvent.click(getByTestId('delete-pl-0')); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('filters out unsupported PLs from dropdown', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { getByRole, queryByText } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + await userEvent.type(input, 'pl:system:not-supported'); + + expect(queryByText('pl:system:not-supported')).not.toBeInTheDocument(); + }); + + it('prevents duplicate selection of PLs', async () => { + const selectedPLs = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]; + const { getAllByRole, findByText } = renderWithTheme( + + ); + + const inputs = getAllByRole('combobox'); + const lastEmptyInput = inputs[inputs.length - 1]; + + // Try to search already selected Prefix List + await userEvent.type(lastEmptyInput, 'pl::supports-only-ipv6'); + + // Display no option available message for already selected Prefix List in dropdown + const noOptionsMessage = await findByText( + 'You have no options to choose from' + ); + expect(noOptionsMessage).toBeInTheDocument(); + }); + + it('should render a PL select field for each string in PLs', () => { + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { + address: 'pl:system:supports-only-ipv4', + inIPv6Rule: false, + inIPv4Rule: true, + }, + ]; + const { getByDisplayValue, queryAllByTestId } = renderWithTheme( + + ); + + expect(queryAllByTestId('prefixlist-select')).toHaveLength(3); + getByDisplayValue('pl::supports-both'); + getByDisplayValue('pl::supports-only-ipv6'); + getByDisplayValue('pl:system:supports-only-ipv4'); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-both'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-both'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports only IPv4', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl:system:supports-only-ipv4'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl:system:supports-only-ipv4'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 unselected and IPv6 selected when choosing a PL that supports only IPv6', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-only-ipv6'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-only-ipv6'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]); + }); + + it('renders IPv4 checked + disabled, and IPv6 unchecked + enabled when a prefix list supports both but is only referenced in IPv4 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPv6 Unchecked and enabled (User can check/select IPv6 since this prefix list supports both IPv4 and IPv6) + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 checked + disabled, and IPv4 unchecked + enabled when a prefix list supports both but is only referenced in IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: false, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Enabled (User can check/select IPv4 since this prefix list supports both IPv4 and IPv6) + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders both IPv4 and IPv6 as checked and enabled when the prefix list supports both and is referenced in both IPv4 & IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Enabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Enabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 unchecked + disabled, and IPv4 checked + disabled when PL only supports IPv4', async () => { + const pls = [ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Unchecked and Disabled + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders IPv4 checkbox unchecked + disabled, and IPv6 checked + disabled when PL only supports IPv6', async () => { + const pls = [ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Disabled + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + // Toggling of Checkbox is allowed only when PL supports both IPv4 & IPv6 + it('calls onChange with updated values when toggling checkboxes', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv6Checkbox = await findByTestId('ipv6-checkbox-0'); + await userEvent.click(ipv6Checkbox); + + expect(onChange).toHaveBeenCalledWith([ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx new file mode 100644 index 00000000000..b956e1c4075 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx @@ -0,0 +1,328 @@ +import { useAllFirewallPrefixListsQuery } from '@linode/queries'; +import { + Autocomplete, + BetaChip, + Box, + Button, + Checkbox, + CloseIcon, + IconButton, + InputLabel, + Stack, +} from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { Link } from 'src/components/Link'; +import { useIsFirewallRulesetsPrefixlistsEnabled } from 'src/features/Firewalls/shared'; + +import { getPrefixListType, groupPriority } from './shared'; + +import type { FirewallPrefixList } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { ExtendedPL } from 'src/utilities/ipUtils'; + +const useStyles = makeStyles()((theme: Theme) => ({ + addPL: { + '& span:first-of-type': { + justifyContent: 'flex-start', + }, + paddingLeft: 0, + paddingTop: theme.spacingFunction(12), + }, + button: { + '& > span': { + padding: 2, + }, + marginLeft: `-${theme.spacingFunction(8)}`, + marginTop: 4, + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, + root: { + marginTop: theme.spacingFunction(8), + }, +})); + +const isPrefixListSupported = (pl: FirewallPrefixList) => + (pl.ipv4 !== null && pl.ipv4 !== undefined) || + (pl.ipv6 !== null && pl.ipv6 !== undefined); + +const getSupportDetails = (pl: FirewallPrefixList) => ({ + isPLIPv4Unsupported: pl.ipv4 === null || pl.ipv4 === undefined, + isPLIPv6Unsupported: pl.ipv6 === null || pl.ipv6 === undefined, +}); + +/** + * Default selection state for a newly chosen Prefix List + */ +const getDefaultPLReferenceState = ( + support: ReturnType +): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { + const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; + + if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (!isPLIPv4Unsupported && isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: false, inIPv6Rule: true }; + + // Should not happen but safe fallback + return { inIPv4Rule: false, inIPv6Rule: false }; +}; + +export interface MultiplePrefixListSelectProps { + /** + * Custom CSS class for additional styling. + */ + className?: string; + + /** + * Disables the component (non-interactive). + * @default false + */ + disabled?: boolean; + + /** + * Callback triggered when PLs change, passing updated `pls`. + */ + onChange: (pls: ExtendedPL[]) => void; + + /** + * Placeholder text for an empty input field. + */ + placeholder?: string; + + /** + * Array of `ExtendedPL` objects representing managed PLs. + */ + pls: ExtendedPL[]; +} + +export const MultiplePrefixListSelect = React.memo( + (props: MultiplePrefixListSelectProps) => { + const { className, disabled, pls, onChange } = props; + const { classes, cx } = useStyles(); + const { + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + } = useIsFirewallRulesetsPrefixlistsEnabled(); + const { data, isLoading } = useAllFirewallPrefixListsQuery( + isFirewallRulesetsPrefixlistsFeatureEnabled + ); + + const prefixLists = data ?? []; + + /** + * Filter prefix lists to include those that support IPv4, IPv6, or both, + * and map them to options with label, value, and PL IP support details. + */ + const supportedOptions = React.useMemo( + () => + prefixLists + .filter(isPrefixListSupported) + .map((pl) => ({ + label: pl.name, + value: pl.id, + support: getSupportDetails(pl), + })) + // The API does not seem to sort prefix lists by "name" to prioritize certain types. + // This sort ensures that Autocomplete's groupBy displays groups correctly without duplicates + // and that the dropdown shows groups in the desired order. + .sort((a, b) => { + const groupA = getPrefixListType(a.label); + const groupB = getPrefixListType(b.label); + + return groupPriority[groupA] - groupPriority[groupB]; + }), + [prefixLists] + ); + + /** + * Returns the list of prefix list options available for a specific row. + * Always includes the currently selected option, and excludes any options + * that are already selected in other rows. This prevents duplicate prefix + * list selection across rows. + */ + const getAvailableOptions = React.useCallback( + (idx: number, address: string) => + supportedOptions.filter( + (o) => + o.label === address || // allow current + !pls.some((p, i) => i !== idx && p.address === o.label) + ), + [supportedOptions, pls] + ); + + const updatePL = (idx: number, updated: Partial) => { + const newPLs = [...pls]; + newPLs[idx] = { ...newPLs[idx], ...updated }; + onChange(newPLs); + }; + + // Handlers + const handleSelectPL = (label: string, idx: number) => { + const match = supportedOptions.find((o) => o.label === label); + if (!match) return; + + updatePL(idx, { + address: label, + ...getDefaultPLReferenceState(match.support), + }); + }; + + const handleToggleIPv4 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv4Rule: checked, + }); + }; + + const handleToggleIPv6 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv6Rule: checked, + }); + }; + + const addNewInput = () => { + onChange([...pls, { address: '', inIPv4Rule: false, inIPv6Rule: false }]); + }; + + const removeInput = (idx: number) => { + const _pls = [...pls]; + _pls.splice(idx, 1); + onChange(_pls); + }; + + if (!pls) { + return null; + } + + const renderRow = (thisPL: ExtendedPL, idx: number) => { + const availableOptions = getAvailableOptions(idx, thisPL.address); + + const selectedOption = availableOptions.find( + (o) => o.label === thisPL.address + ); + + // Disabling a checkbox ensures that at least one option (IPv4 or IPv6) remains checked + const ipv4Unsupported = + selectedOption?.support.isPLIPv4Unsupported === true; + const ipv6Unsupported = + selectedOption?.support.isPLIPv6Unsupported === true; + + const ipv4Forced = + thisPL.inIPv4Rule === true && thisPL.inIPv6Rule === false; + const ipv6Forced = + thisPL.inIPv6Rule === true && thisPL.inIPv4Rule === false; + + const disableIPv4 = ipv4Unsupported || ipv4Forced; + const disableIPv6 = ipv6Unsupported || ipv6Forced; + + return ( + + + 0} + disabled={disabled} + errorText={thisPL.error} + getOptionLabel={(option) => option.label} + groupBy={(option) => getPrefixListType(option.label)} + label="" + loading={isLoading} + noMarginTop + onChange={(_, selectedPrefixList) => { + handleSelectPL(selectedPrefixList?.label ?? '', idx); + }} + options={availableOptions} + placeholder="Type to search or select Prefix List" + value={ + availableOptions.find((o) => o.label === thisPL.address) ?? null + } + /> + {thisPL.address.length !== 0 && ( + + + handleToggleIPv4(!thisPL.inIPv4Rule, idx)} + text="IPv4" + /> + handleToggleIPv6(!thisPL.inIPv6Rule, idx)} + text="IPv6" + /> + + + {}}>View Details + + + )} + + + removeInput(idx)} + sx={(theme) => ({ + height: 20, + width: 20, + marginTop: `${theme.spacingFunction(16)} !important`, + })} + > + + + + + ); + }; + + return ( +
    + {/* Display the title only when pls.length > 0 (i.e., at least one PL row is added) */} + {pls.length > 0 && ( + + Prefix List + {isFirewallRulesetsPrefixListsBetaEnabled && } + + )} + + {pls.map((thisPL, idx) => renderRow(thisPL, idx))} + + +
    + ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index bdf38531fd0..200ffa8c0f1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -135,6 +135,12 @@ export const firewallRuleCreateOptions = [ type PrefixListGroup = 'Account' | 'Other' | 'System'; +export const groupPriority: Record = { + Account: 1, + System: 2, + Other: 3, +}; + export const getPrefixListType = (name: string): PrefixListGroup => { if (name.startsWith('pl::')) { return 'Account'; diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index 675683ad504..f9696d68960 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -72,12 +72,24 @@ export const protocolOptions: FirewallOptionItem[] = [ { label: 'IPENCAP', value: 'IPENCAP' }, ]; -export const addressOptions = [ - { label: 'All IPv4, All IPv6', value: 'all' }, - { label: 'All IPv4', value: 'allIPv4' }, - { label: 'All IPv6', value: 'allIPv6' }, - { label: 'IP / Netmask', value: 'ip/netmask' }, -]; +export const useAddressOptions = () => { + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + return [ + { label: 'All IPv4, All IPv6', value: 'all' }, + { label: 'All IPv4', value: 'allIPv4' }, + { label: 'All IPv6', value: 'allIPv6' }, + { + label: isFirewallRulesetsPrefixlistsFeatureEnabled + ? 'IP / Netmask / Prefix List' + : 'IP / Netmask', + // We can keep this entire value even if the option is feature-flagged. + // Feature-flagging the label (without the "Prefix List" text) is sufficient. + value: 'ip/netmask/prefixlist', + }, + ]; +}; export const portPresets: Record = { dns: '53', diff --git a/packages/manager/src/utilities/ipUtils.ts b/packages/manager/src/utilities/ipUtils.ts index e95a8fc3b9f..3cb6659cd67 100644 --- a/packages/manager/src/utilities/ipUtils.ts +++ b/packages/manager/src/utilities/ipUtils.ts @@ -1,6 +1,8 @@ import { PRIVATE_IPV4_REGEX } from '@linode/validation'; import { parseCIDR, parse as parseIP } from 'ipaddr.js'; +import type { PrefixListRuleReference } from 'src/features/Firewalls/shared'; + /** * Removes the prefix length from the end of an IPv6 address. * @@ -21,6 +23,8 @@ export interface ExtendedIP { error?: string; } +export interface ExtendedPL extends ExtendedIP, PrefixListRuleReference {} + export const stringToExtendedIP = (ip: string): ExtendedIP => ({ address: ip }); export const extendedIPToString = (ip: ExtendedIP): string => ip.address; export const ipFieldPlaceholder = '192.0.2.1/32'; From 915b1704286e0e1e91d251c4fb60f291597cab2a Mon Sep 17 00:00:00 2001 From: Ankita Date: Wed, 3 Dec 2025 16:58:20 +0530 Subject: [PATCH 66/91] upcoming: [DI-28504] - Update tooltip msg in LKE service dashboard (#13157) * upcoming: [DI-28504] - Update tooltip msg * upcoming: [DI-28504] - Fix linting * upcoming: [DI-28504] - Add changeset --------- Co-authored-by: venkatmano-akamai --- .../.changeset/pr-13157-upcoming-features-1764741522593.md | 5 +++++ packages/manager/src/features/CloudPulse/Utils/constants.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md diff --git a/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md b/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md new file mode 100644 index 00000000000..f91b3a5d2f5 --- /dev/null +++ b/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Update tooltip msg for `Clusters` filter in LKE service dashboard ([#13157](https://github.com/linode/manager/pull/13157)) diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index eb7629499a6..2e830288e4d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -91,7 +91,8 @@ export const INTERFACE_IDS_LIMIT_ERROR_MESSAGE = export const INTERFACE_IDS_PLACEHOLDER_TEXT = 'e.g., 1234,5678'; -export const CLUSTERS_TOOLTIP_TEXT = 'It includes enterprise Clusters only.'; +export const CLUSTERS_TOOLTIP_TEXT = + 'This list includes only LKE Enterprise clusters.'; export const NO_REGION_MESSAGE: Record = { 1: 'No database clusters configured in any regions.', From 6d8b9c40bf7512a37e14b5e1ef1ab54aa887db3f Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:42:00 +0100 Subject: [PATCH 67/91] fix: [UIE-9731] - IAM Permissions performance improvements: Create from Backup & Clone (#13143) * utilize new endpoint to avoid parallel queries * add loading * handle cards as well * Added changeset: IAM Permissions performance improvements: Create from Backup & Clone --- .../pr-13143-fixed-1764672257327.md | 5 + .../LinodeCreate/shared/LinodeSelectTable.tsx | 86 +++++++++--- .../shared/LinodeSelectTableRow.test.tsx | 124 +++++++++++------- .../shared/LinodeSelectTableRow.tsx | 32 +++-- .../shared/SelectLinodeCard.test.tsx | 44 +++---- .../LinodeCreate/shared/SelectLinodeCard.tsx | 15 +-- 6 files changed, 189 insertions(+), 117 deletions(-) create mode 100644 packages/manager/.changeset/pr-13143-fixed-1764672257327.md diff --git a/packages/manager/.changeset/pr-13143-fixed-1764672257327.md b/packages/manager/.changeset/pr-13143-fixed-1764672257327.md new file mode 100644 index 00000000000..d5cb5c3d6fd --- /dev/null +++ b/packages/manager/.changeset/pr-13143-fixed-1764672257327.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + + IAM Permissions performance improvements: Create from Backup & Clone ([#13143](https://github.com/linode/manager/pull/13143)) diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx index ce008f726d3..e46f3cebac8 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTable.tsx @@ -19,6 +19,8 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useGetAllUserEntitiesByPermission } from 'src/features/IAM/hooks/useGetAllUserEntitiesByPermission'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { linodesCreateTypesMap, useGetLinodeCreateType, @@ -56,6 +58,26 @@ export const LinodeSelectTable = (props: Props) => { theme.breakpoints.up('md') ); + const { data: accountPermissions, isLoading: isLoadingAccountPermissions } = + usePermissions('account', ['create_linode']); + + const { + data: shutdownableLinodes = [], + isLoading: isLoadingShutdownableLinodes, + error: shutdownableLinodesError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'shutdown_linode', + }); + const { + data: cloneableLinodes = [], + isLoading: isLoadingCloneableLinodes, + error: cloneableLinodesError, + } = useGetAllUserEntitiesByPermission({ + entityType: 'linode', + permission: 'clone_linode', + }); + const { control, formState: { @@ -102,7 +124,12 @@ export const LinodeSelectTable = (props: Props) => { const { filter, filterError } = getLinodeXFilter(query, order, orderBy); - const { data, error, isFetching, isLoading } = useLinodesQuery( + const { + data, + error: linodesError, + isFetching, + isLoading: isLoadingLinodes, + } = useLinodesQuery( { page: pagination.page, page_size: pagination.pageSize, @@ -144,6 +171,14 @@ export const LinodeSelectTable = (props: Props) => { const columns = enablePowerOff ? 6 : 5; + const isLoading = + isLoadingAccountPermissions || + isLoadingShutdownableLinodes || + isLoadingCloneableLinodes || + isLoadingLinodes; + const error = + shutdownableLinodesError || cloneableLinodesError || linodesError; + return ( {fieldState.error?.message && ( @@ -195,27 +230,38 @@ export const LinodeSelectTable = (props: Props) => { - {isLoading && } + {isLoading && ( + + )} {error && ( )} {data?.results === 0 && } - {data?.data.map((linode) => ( - { - setLinodeToPowerOff(linode); - sendLinodePowerOffEvent('Clone Linode'); - } - : undefined - } - onSelect={() => handleSelect(linode)} - selected={linode.id === field.value?.id} - /> - ))} + {!isLoading && + !error && + data?.data.map((linode) => ( + l.id === linode.id + )} + isShutdownable={shutdownableLinodes?.some( + (l) => l.id === linode.id + )} + key={linode.id} + linode={linode} + onPowerOff={ + enablePowerOff + ? () => { + setLinodeToPowerOff(linode); + sendLinodePowerOffEvent('Clone Linode'); + } + : undefined + } + onSelect={() => handleSelect(linode)} + selected={linode.id === field.value?.id} + /> + ))} ) : ( @@ -224,6 +270,10 @@ export const LinodeSelectTable = (props: Props) => { handlePowerOff(linode)} handleSelection={() => handleSelect(linode)} + isCloneable={cloneableLinodes?.some((l) => l.id === linode.id)} + isShutdownable={shutdownableLinodes?.some( + (l) => l.id === linode.id + )} key={linode.id} linode={linode} selected={linode.id === field.value?.id} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTableRow.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTableRow.test.tsx index 0d8231e3617..5649783398b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTableRow.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/LinodeSelectTableRow.test.tsx @@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { imageFactory, typeFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext, wrapWithTableBody, @@ -13,18 +11,20 @@ import { import { LinodeSelectTableRow } from './LinodeSelectTableRow'; const queryMocks = vi.hoisted(() => ({ - userPermissions: vi.fn(() => ({ - data: { - shutdown_linode: false, - clone_linode: false, - create_linode: false, - }, - })), + useImageQuery: vi.fn().mockReturnValue({}), + useRegionsQuery: vi.fn().mockReturnValue({}), + useTypeQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - usePermissions: queryMocks.userPermissions, -})); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useImageQuery: queryMocks.useImageQuery, + useRegionsQuery: queryMocks.useRegionsQuery, + useTypeQuery: queryMocks.useTypeQuery, + }; +}); describe('LinodeSelectTableRow', () => { it('should render a Radio that is labeled by the Linode label', () => { @@ -32,7 +32,14 @@ describe('LinodeSelectTableRow', () => { const { getByLabelText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( - + ), }); @@ -44,7 +51,14 @@ describe('LinodeSelectTableRow', () => { const { getByLabelText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( - + ), }); @@ -57,6 +71,9 @@ describe('LinodeSelectTableRow', () => { const { getByLabelText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( { }); it('should should call onSelect when a radio is selected', async () => { - queryMocks.userPermissions.mockReturnValue({ - data: { - shutdown_linode: false, - clone_linode: true, - create_linode: true, - }, - }); const linode = linodeFactory.build(); const onSelect = vi.fn(); @@ -82,6 +92,9 @@ describe('LinodeSelectTableRow', () => { const { getByLabelText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( { label: 'My Image Nice Label', }); - server.use( - http.get('*/v4/images/my-image', () => { - return HttpResponse.json(image); - }) - ); + queryMocks.useImageQuery.mockReturnValue({ + data: image, + }); const { findByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( - + ), }); @@ -124,19 +142,24 @@ describe('LinodeSelectTableRow', () => { label: 'US Test', }); - server.use( - http.get('*/v4*/regions', () => { - return HttpResponse.json(makeResourcePage([region])); - }) - ); + queryMocks.useRegionsQuery.mockReturnValue({ + data: [region], + }); const { findByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( - + ), }); - await findByText(`US, ${region.label}`); + await findByText(region.label); }); it('should render a Linode plan label', async () => { @@ -146,15 +169,20 @@ describe('LinodeSelectTableRow', () => { label: 'Linode Type 1', }); - server.use( - http.get('*/v4/linode/types/linode-type-1', () => { - return HttpResponse.json(type); - }) - ); + queryMocks.useTypeQuery.mockReturnValue({ + data: type, + }); const { findByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( - + ), }); @@ -167,6 +195,9 @@ describe('LinodeSelectTableRow', () => { const { getByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( { it('should render an enabled power off button if the Linode is powered on, a onPowerOff function is passed, and the row is selected, if user has shutdown_linode permission', async () => { const linode = linodeFactory.build({ status: 'running' }); - queryMocks.userPermissions.mockReturnValue({ - data: { - shutdown_linode: true, - clone_linode: true, - create_linode: true, - }, - }); const { getByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( { const { getByText } = renderWithThemeAndHookFormContext({ component: wrapWithTableBody( void; onSelect: () => void; @@ -20,7 +22,15 @@ interface Props { } export const LinodeSelectTableRow = (props: Props) => { - const { linode, onPowerOff, onSelect, selected } = props; + const { + disabled, + isCloneable, + isShutdownable, + linode, + onPowerOff, + onSelect, + selected, + } = props; const { data: image } = useImageQuery( linode.image ?? '', @@ -33,25 +43,13 @@ export const LinodeSelectTableRow = (props: Props) => { const region = regions?.find((r) => r.id === linode.region); - const { data: accountPermissions } = usePermissions('account', [ - 'create_linode', - ]); - - const { data: permissions } = usePermissions( - 'linode', - ['shutdown_linode', 'clone_linode'], - linode.id - ); - return ( - + } - disabled={ - !permissions.clone_linode || !accountPermissions?.create_linode - } + disabled={!isCloneable || disabled} label={linode.label} onChange={onSelect} sx={{ gap: 2 }} @@ -72,7 +70,7 @@ export const LinodeSelectTableRow = (props: Props) => { )} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/shared/SelectLinodeCard.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/shared/SelectLinodeCard.test.tsx index 9baac9b60ad..6da5338a6da 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/shared/SelectLinodeCard.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/shared/SelectLinodeCard.test.tsx @@ -19,23 +19,15 @@ const defaultProps = { showPowerActions: false, }; -const queryMocks = vi.hoisted(() => ({ - userPermissions: vi.fn(() => ({ - data: { - shutdown_linode: false, - clone_linode: false, - }, - })), -})); - -vi.mock('src/features/IAM/hooks/usePermissions', () => ({ - usePermissions: queryMocks.userPermissions, -})); - describe('SelectLinodeCard', () => { it('displays the status of a linode', () => { const { getByLabelText, getByText, queryByRole } = renderWithTheme( - + ); expect(getByLabelText('Linode status running')).toBeInTheDocument(); expect(getByText('Running')).toBeVisible(); @@ -47,6 +39,8 @@ describe('SelectLinodeCard', () => { const { getByLabelText, getByText, getByRole } = renderWithTheme( @@ -63,6 +57,8 @@ describe('SelectLinodeCard', () => { const { getByTestId, getByText } = renderWithTheme( @@ -74,15 +70,11 @@ describe('SelectLinodeCard', () => { }); it('should enable the Selection Card if user has clone_linode permission', () => { - queryMocks.userPermissions.mockReturnValue({ - data: { - shutdown_linode: true, - clone_linode: true, - }, - }); const { getByTestId, getByText } = renderWithTheme( @@ -94,15 +86,11 @@ describe('SelectLinodeCard', () => { }); it('displays the status and the enabled Power Off button of a linode that is selected and running when power actions should be shown, and user has shutdown_linode permission', () => { - queryMocks.userPermissions.mockReturnValue({ - data: { - shutdown_linode: true, - clone_linode: true, - }, - }); const { getByLabelText, getByRole, getByText } = renderWithTheme( @@ -121,6 +109,8 @@ describe('SelectLinodeCard', () => { const { queryByRole } = renderWithTheme( @@ -132,6 +122,8 @@ describe('SelectLinodeCard', () => { const { getByLabelText, getByText, queryByRole } = renderWithTheme( void; handleSelection: () => void; + isCloneable: boolean; + isShutdownable: boolean; linode: Linode; selected?: boolean; showPowerActions: boolean; @@ -29,6 +30,8 @@ export const SelectLinodeCard = ({ disabled, handlePowerOff, handleSelection, + isCloneable, + isShutdownable, linode, selected, showPowerActions, @@ -45,12 +48,6 @@ export const SelectLinodeCard = ({ Boolean(linode?.image) ); - const { data: permissions } = usePermissions( - 'linode', - ['shutdown_linode', 'clone_linode'], - linode.id - ); - const iconStatus = getLinodeIconStatus(linode?.status); const shouldShowPowerButton = showPowerActions && linode?.status === 'running' && selected; @@ -79,7 +76,7 @@ export const SelectLinodeCard = ({ {shouldShowPowerButton && ( - + + + {formType === 'stream' && ( By using this service, you acknowledge your obligations under the diff --git a/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx b/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx new file mode 100644 index 00000000000..3e7f96dc1c0 --- /dev/null +++ b/packages/manager/src/features/Delivery/Shared/LinkWithTooltipAndEllipsis.tsx @@ -0,0 +1,58 @@ +import { Tooltip } from '@linode/ui'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { Link } from 'src/components/Link'; + +const StyledLink = styled(Link, { label: 'StyledLink' })(({ theme }) => ({ + overflow: 'hidden', + textOverflow: 'ellipsis', + display: 'inline-block', + maxWidth: 350, + [theme.breakpoints.down('lg')]: { + maxWidth: 200, + }, + [theme.breakpoints.down('sm')]: { + maxWidth: 120, + }, + whiteSpace: 'nowrap', +})); + +interface EllipsisLinkWithTooltipProps { + children: string; + className?: string; + pendoId?: string; + style?: React.CSSProperties; + to: string; +} + +export const LinkWithTooltipAndEllipsis = ( + props: EllipsisLinkWithTooltipProps +) => { + const { to, children, pendoId, className, style } = props; + + const linkRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + const linkElement = linkRef.current; + if (linkElement) { + setShowTooltip(linkElement.scrollWidth > linkElement.clientWidth); + } + }, [children]); + + return ( + + + {children} + + + ); +}; diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts index 8c447865f22..205e44573bd 100644 --- a/packages/manager/src/features/Delivery/Shared/types.ts +++ b/packages/manager/src/features/Delivery/Shared/types.ts @@ -33,7 +33,7 @@ export const streamTypeOptions: AutocompleteOption[] = [ }, { value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', + label: 'Kubernetes API Audit Logs', }, ]; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 0d1da0680ce..fcd91ae3c84 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -87,10 +87,10 @@ describe.skip('StreamFormCheckoutBar', () => { // change form type value await userEvent.click(streamTypesAutocomplete); - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' + const kubernetesApiAuditLogs = await screen.findByText( + 'Kubernetes API Audit Logs' ); - await userEvent.click(kubernetesAuditLogs); + await userEvent.click(kubernetesApiAuditLogs); expect(getDeliveryPriceContext()).not.toEqual(initialPrice); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx index 4022d10e819..5fad7cd8d3d 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent.tsx @@ -40,7 +40,7 @@ export const StreamFormClusterTableContent = ({ const selectedIds = field.value || []; const isAllSelected = - selectedIds.length === (idsWithLogsEnabled?.length ?? 0); + selectedIds.length > 0 && selectedIds.length === idsWithLogsEnabled?.length; const isIndeterminate = selectedIds.length > 0 && !isAllSelected; const toggleAllClusters = () => { @@ -66,7 +66,9 @@ export const StreamFormClusterTableContent = ({ { // Type the test value inside the input const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); - expect(bucketInput.getAttribute('value')).toEqual('Test'); + expect(bucketInput.getAttribute('value')).toEqual('test'); }); it('should render Access Key ID input after adding a new destination name and allow to type text', async () => { diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 8dfe9f7c400..8a00438d52d 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -6,6 +6,7 @@ import { CircleProgress, ErrorState, Paper, + Stack, Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; @@ -176,13 +177,39 @@ export const StreamFormDelivery = (props: StreamFormDeliveryProps) => { const { id, ...optionProps } = props; return (
  • - {option.create ? ( - <> - Create  "{option.label}" - - ) : ( - option.label - )} + + + + {option.create ? ( + + Create  "{option.label} + " + + ) : ( + option.label + )} + + {option.id && ( + + ID: {option.id} + + )} + +
  • ); }} diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx index fddc01e4afc..4f329004c26 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx @@ -56,9 +56,9 @@ describe('StreamCreate', () => { await waitFor(() => { expect(hostInput).toBeDefined(); }); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx index 2b13c9a0655..6aa64e6aebd 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx @@ -25,6 +25,7 @@ export const StreamCreate = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: 'Create Stream', }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx index 580b5590109..0b924f69c19 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx @@ -65,7 +65,7 @@ describe('StreamEdit', () => { // Host: expect(screen.getByText('3000')).toBeVisible(); // Bucket: - expect(screen.getByText('Bucket Name')).toBeVisible(); + expect(screen.getByText('destinations-bucket-name')).toBeVisible(); // Access Key ID: expect(screen.getByTestId('access-key-id')).toHaveTextContent( '*****************' @@ -97,9 +97,9 @@ describe('StreamEdit', () => { await waitFor(() => { expect(hostInput).toBeDefined(); }); - await userEvent.type(hostInput, 'Test'); + await userEvent.type(hostInput, 'test'); const bucketInput = screen.getByLabelText('Bucket'); - await userEvent.type(bucketInput, 'Test'); + await userEvent.type(bucketInput, 'test'); const accessKeyIDInput = screen.getByLabelText('Access Key ID'); await userEvent.type(accessKeyIDInput, 'Test'); const secretAccessKeyInput = screen.getByLabelText('Secret Access Key'); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx index 57eeaaf2e8b..ccdb5bf11ce 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx @@ -43,6 +43,7 @@ export const StreamEdit = () => { }, ], }, + docsLink: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', removeCrumbX: [1, 2], title: `Edit Stream ${streamId}`, }; diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx index 62ebd97048c..6ac55bb6ff8 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx @@ -9,6 +9,7 @@ import { useUpdateStreamMutation, } from '@linode/queries'; import { Stack } from '@linode/ui'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import { useNavigate } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; @@ -52,6 +53,7 @@ export const StreamForm = (props: StreamFormProps) => { setDestinationVerified, } = useVerifyDestination(); + const formRef = React.useRef(null); const form = useFormContext(); const { control, handleSubmit, trigger } = form; @@ -170,11 +172,13 @@ export const StreamForm = (props: StreamFormProps) => { if (isValid) { await verifyDestination(destination); + } else { + scrollErrorIntoViewV2(formRef); } }; return ( - + @@ -198,7 +202,9 @@ export const StreamForm = (props: StreamFormProps) => { isSubmitting={isSubmitting} isTesting={isVerifyingDestination} mode={mode} - onSubmit={handleSubmit(onSubmit)} + onSubmit={handleSubmit(onSubmit, () => + scrollErrorIntoViewV2(formRef) + )} onTestConnection={handleTestConnection} /> diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx index c385377d3e4..4ca55775ecf 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -43,14 +43,16 @@ describe('StreamFormGeneralInfo', () => { // Open the dropdown await userEvent.click(streamTypesAutocomplete); - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' + // Select the "Kubernetes API Audit Logs" option + const kubernetesApiAuditLogs = await screen.findByText( + 'Kubernetes API Audit Logs' ); - await userEvent.click(kubernetesAuditLogs); + await userEvent.click(kubernetesApiAuditLogs); await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + expect(streamTypesAutocomplete).toHaveValue( + 'Kubernetes API Audit Logs' + ); }); }); }); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx index f2e511450f3..bf2def9dc75 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -60,7 +60,10 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { - setValue('stream.details.is_auto_add_all_clusters_enabled', false); + setValue('stream.details', { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }); } else { setValue('stream.details', null); } @@ -135,7 +138,7 @@ export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { diff --git a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx index 91ebdd2e986..8184c7f33f9 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamTableRow.tsx @@ -2,7 +2,6 @@ import { Hidden } from '@linode/ui'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; @@ -10,6 +9,7 @@ import { getDestinationTypeOption, getStreamTypeOption, } from 'src/features/Delivery/deliveryUtils'; +import { LinkWithTooltipAndEllipsis } from 'src/features/Delivery/Shared/LinkWithTooltipAndEllipsis'; import { StreamActionMenu } from 'src/features/Delivery/Streams/StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; @@ -26,12 +26,12 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { return ( - {stream.label} - + {getStreamTypeOption(stream.type)?.label} @@ -39,7 +39,7 @@ export const StreamTableRow = React.memo((props: StreamTableRowProps) => { {humanizeStreamStatus(status)} {id} - + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx index 8316af51819..1a2b4729691 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx @@ -73,7 +73,6 @@ export const StreamsLanding = () => { const { data: streams, isLoading, - isFetching, error, } = useStreamsQuery( { @@ -180,7 +179,6 @@ export const StreamsLanding = () => { { selectList={streamStatusOptions} selectValue={search?.status} /> - {isLoading ? ( ) : ( @@ -201,7 +198,10 @@ export const StreamsLanding = () => { direction={order} handleClick={handleOrderChange} label="label" - sx={{ width: '30%' }} + sx={{ + width: '30%', + maxWidth: '30%', + }} > Name @@ -229,7 +229,7 @@ export const StreamsLanding = () => { > ID - + Destination Type diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts index c62f70d5607..bf4d039cea6 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts +++ b/packages/manager/src/features/Delivery/Streams/StreamsLandingEmptyStateData.ts @@ -1,3 +1,8 @@ +import { + docsLink, + guidesMoreLinkText, +} from 'src/utilities/emptyStateLandingUtils'; + import type { ResourcesHeaders, ResourcesLinks, @@ -16,10 +21,15 @@ export const linkAnalyticsEvent: ResourcesLinks['linkAnalyticsEvent'] = { }; export const gettingStartedGuides: ResourcesLinkSection = { - links: [], + links: [ + { + text: 'Getting started guide', + to: 'https://techdocs.akamai.com/cloud-computing/docs/log-delivery', + }, + ], moreInfo: { - text: '', - to: '', + text: guidesMoreLinkText, + to: docsLink, }, - title: '', + title: 'Getting Started Guides', }; diff --git a/packages/validation/.changeset/pr-13166-changed-1764854111433.md b/packages/validation/.changeset/pr-13166-changed-1764854111433.md new file mode 100644 index 00000000000..a4e04eb3b87 --- /dev/null +++ b/packages/validation/.changeset/pr-13166-changed-1764854111433.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Validate Bucket name in Destination Form for forbidden characters ([#13166](https://github.com/linode/manager/pull/13166)) diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts index ab6273b4ba2..b0a2fa52127 100644 --- a/packages/validation/src/delivery.schema.ts +++ b/packages/validation/src/delivery.schema.ts @@ -60,8 +60,18 @@ const customHTTPsDetailsSchema = object({ const akamaiObjectStorageDetailsBaseSchema = object({ host: string().max(maxLength, maxLengthMessage).required('Host is required.'), bucket_name: string() - .max(maxLength, maxLengthMessage) - .required('Bucket name is required.'), + .required('Bucket name is required.') + .min(3, 'Bucket name must be between 3 and 63 characters.') + .matches(/^\S*$/, 'Bucket name must not contain spaces.') + .matches( + /^[a-z0-9].*[a-z0-9]$/, + 'Bucket name must start and end with a lowercase letter or number.', + ) + .matches( + /^(?!.*[.-]{2})[a-z0-9.-]+$/, + 'Bucket name must contain only lowercase letters, numbers, periods (.), and hyphens (-). Adjacent periods and hyphens are not allowed.', + ) + .max(63, 'Bucket name must be between 3 and 63 characters.'), path: string().max(maxLength, maxLengthMessage).defined(), access_key_id: string() .max(maxLength, maxLengthMessage) @@ -135,11 +145,16 @@ export const updateDestinationSchema = createDestinationSchema }); // Logs Delivery Stream +const clusterRequiredMessage = 'At least one cluster must be selected.'; const streamDetailsBase = object({ cluster_ids: array() - .of(number().defined()) - .min(1, 'At least one cluster must be selected.'), + .of(number().defined(clusterRequiredMessage)) + .when('is_auto_add_all_clusters_enabled', { + is: false, + then: (schema) => + schema.min(1, clusterRequiredMessage).required(clusterRequiredMessage), + }), is_auto_add_all_clusters_enabled: boolean(), }); From e893249b979579a4660e2deeb4f87b8d01a859fb Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:19:05 -0500 Subject: [PATCH 81/91] =?UTF-8?q?upcoming:=20[UIE-9640]=20=E2=80=93=20Upda?= =?UTF-8?q?te=20`useIsFirewallRulesetsPrefixlistsEnabled`=20to=20include?= =?UTF-8?q?=20account=20capability=20check=20(#13156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-13156-upcoming-features-1764708047549.md | 5 ++ packages/api-v4/src/account/types.ts | 1 + ...r-13156-upcoming-features-1764708766786.md | 5 ++ .../src/features/Firewalls/shared.test.tsx | 62 ++++++++++++++++++- .../manager/src/features/Firewalls/shared.tsx | 47 +++++++++++--- 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md create mode 100644 packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md diff --git a/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md b/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md new file mode 100644 index 00000000000..991b3820fb3 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add 'Cloud Firewall Rule Set' to AccountCapability type ([#13156](https://github.com/linode/manager/pull/13156)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index e6b4a27c25c..2f88bc7b053 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -66,6 +66,7 @@ export const accountCapabilities = [ 'Block Storage', 'Block Storage Encryption', 'Cloud Firewall', + 'Cloud Firewall Rule Set', 'CloudPulse', 'Disk Encryption', 'Kubernetes', diff --git a/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md b/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md new file mode 100644 index 00000000000..085d0c3dbda --- /dev/null +++ b/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update useIsFirewallRulesetsPrefixlistsEnabled() to factor in account capability ([#13156](https://github.com/linode/manager/pull/13156)) diff --git a/packages/manager/src/features/Firewalls/shared.test.tsx b/packages/manager/src/features/Firewalls/shared.test.tsx index 9a3605af7a0..d73064fb1e8 100644 --- a/packages/manager/src/features/Firewalls/shared.test.tsx +++ b/packages/manager/src/features/Firewalls/shared.test.tsx @@ -3,6 +3,8 @@ import { renderHook, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { accountFactory } from 'src/factories'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { @@ -391,7 +393,7 @@ describe('generateAddressesLabelV2', () => { }); describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { - it('returns true if the feature is enabled', async () => { + it('returns true if the feature is enabled AND the account has the capability', async () => { const options = { flags: { fwRulesetsPrefixLists: { @@ -403,6 +405,16 @@ describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { }, }; + const account = accountFactory.build({ + capabilities: ['Cloud Firewall Rule Set'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + const { result } = renderHook( () => useIsFirewallRulesetsPrefixlistsEnabled(), { @@ -417,7 +429,7 @@ describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { }); }); - it('returns false if the feature is NOT enabled', async () => { + it('returns false if the feature is NOT enabled but the account has the capability', async () => { const options = { flags: { fwRulesetsPrefixLists: { @@ -429,6 +441,52 @@ describe('useIsFirewallRulesetsPrefixlistsEnabled', () => { }, }; + const account = accountFactory.build({ + capabilities: ['Cloud Firewall Rule Set'], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + + const { result } = renderHook( + () => useIsFirewallRulesetsPrefixlistsEnabled(), + { + wrapper: (ui) => wrapWithTheme(ui, options), + } + ); + + await waitFor(() => { + expect(result.current.isFirewallRulesetsPrefixlistsFeatureEnabled).toBe( + false + ); + }); + }); + + it('returns false if the feature is enabled but the account DOES NOT have the capability', async () => { + const options = { + flags: { + fwRulesetsPrefixLists: { + enabled: true, + beta: false, + la: false, + ga: false, + }, + }, + }; + + const account = accountFactory.build({ + capabilities: [], + }); + + server.use( + http.get('*/v4*/account', () => { + return HttpResponse.json(account); + }) + ); + const { result } = renderHook( () => useIsFirewallRulesetsPrefixlistsEnabled(), { diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index 289f0db6e39..77d66940084 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -1,5 +1,10 @@ +import { useAccount } from '@linode/queries'; import { BetaChip, Box, Chip, NewFeatureChip, Tooltip } from '@linode/ui'; -import { capitalize, truncateAndJoinList } from '@linode/utilities'; +import { + capitalize, + isFeatureEnabledV2, + truncateAndJoinList, +} from '@linode/utilities'; import React from 'react'; import { Link } from 'src/components/Link'; @@ -107,6 +112,8 @@ export const allIPs = { ipv6: [allIPv6], }; +export const FW_RULESET_CAPABILITY = 'Cloud Firewall Rule Set'; + export interface PredefinedFirewall { inbound: FirewallRuleType[]; label: string; @@ -566,18 +573,40 @@ export const getFirewallDescription = (firewall: Firewall) => { * but will eventually also look at account capabilities if available. */ export const useIsFirewallRulesetsPrefixlistsEnabled = () => { + const { data: account } = useAccount(); const flags = useFlags(); + if (!flags) { + return { + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }; + } + // @TODO: Firewall Rulesets & Prefix Lists - check for customer tag/account capability when it exists return { - isFirewallRulesetsPrefixlistsFeatureEnabled: - flags.fwRulesetsPrefixLists?.enabled ?? false, - isFirewallRulesetsPrefixListsBetaEnabled: - flags.fwRulesetsPrefixLists?.beta ?? false, - isFirewallRulesetsPrefixListsLAEnabled: - flags.fwRulesetsPrefixLists?.la ?? false, - isFirewallRulesetsPrefixListsGAEnabled: - flags.fwRulesetsPrefixLists?.ga ?? false, + isFirewallRulesetsPrefixlistsFeatureEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.enabled), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsBetaEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.beta), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsLAEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.la), + account?.capabilities ?? [] + ), + isFirewallRulesetsPrefixListsGAEnabled: isFeatureEnabledV2( + FW_RULESET_CAPABILITY, + Boolean(flags.fwRulesetsPrefixLists?.ga), + account?.capabilities ?? [] + ), }; }; From 5db036f6147fd52b64c700eb31906f75a5cb1240 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-akamai@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:35:19 -0500 Subject: [PATCH 82/91] fix: [UIE-9690] - Forking a Database Cluster with VPC into another region (#13174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 When forking a cluster attached to a VPC into another region, the request payload doesn't pass VPC as null. The forked instance is created with the previous region's VPC and gets stuck in a provisioning state. Since VPCs are region based, we need to figure out how to handle forking a cluster with a VPC to a different region (TBD). In the meantime, we will set VPCs to null when forking to a different region. ## Changes 🔄 List any change(s) relevant to the reviewer. - Add `private_network` property to `DatabaseBackupsPayload` - Set `private_network` to null when forking to a different region - Show warning notice in the Database Backups Dialog if the Database cluster has a VPC and we're forking to a different region ## How to test đź§Ş ### Reproduction steps (How to reproduce the issue, if applicable) - [ ] On another branch such as develop, go to the Backups tab of a Database cluster that has a VPC - [ ] Select a different region to restore to - [ ] Open the network tab and then click Restore. Observe the forked instance response is created with the previous region's VPC - [ ] The database cluster is stuck in provisioning ### Verification steps (How to verify changes) - [ ] Checkout this PR or preview link, go to the Backups tab of a Database cluster that has a VPC - [ ] Select a different region to restore to - [ ] Click Restore to open up the dialog. The dialog should have a warning notice - [ ] Open the network tab and then click Restore. - [ ] The payload should have `private_network` set to `null` and the forked instance response should not have the previous region's VPC - [ ] The database cluster should not get stuck in provisioning - [ ] Forking a cluster with VPC in the _same_ region should preserve previous VPC and there should be no warning notice Ensure there are no regressions: - [ ] Go to the Backups tab of a Database cluster that does _not_ have VPC - [ ] Click Restore to open up the dialog. The dialog should _not_ have a warning notice - [ ] Open the network tab and then click Restore - [ ] The database cluster should not get stuck in provisioning --- .../.changeset/pr-13174-changed-1764961622718.md | 5 +++++ packages/api-v4/src/databases/types.ts | 1 + .../.changeset/pr-13174-fixed-1764961648716.md | 5 +++++ .../DatabaseBackups/DatabaseBackupsDialog.tsx | 15 +++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13174-changed-1764961622718.md create mode 100644 packages/manager/.changeset/pr-13174-fixed-1764961648716.md diff --git a/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md b/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md new file mode 100644 index 00000000000..fe886996b9b --- /dev/null +++ b/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Add private_network to `DatabaseBackupsPayload` ([#13174](https://github.com/linode/manager/pull/13174)) diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 984cb63e279..d42c0ef23e3 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -81,6 +81,7 @@ export interface DatabaseFork { export interface DatabaseBackupsPayload { fork: DatabaseFork; + private_network?: null | PrivateNetwork; region?: string; } diff --git a/packages/manager/.changeset/pr-13174-fixed-1764961648716.md b/packages/manager/.changeset/pr-13174-fixed-1764961648716.md new file mode 100644 index 00000000000..0deeb1ac90e --- /dev/null +++ b/packages/manager/.changeset/pr-13174-fixed-1764961648716.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Forking a Database Cluster with VPC into another region ([#13174](https://github.com/linode/manager/pull/13174)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index a1ee61fdb2a..f6112e22157 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -39,6 +39,9 @@ export const DatabaseBackupsDialog = (props: Props) => { { fork: toDatabaseFork(database.id, date, time), region, + // Assign same VPC when forking to the same region, otherwise set VPC to null + private_network: + database.region === region ? database.private_network : null, } ); @@ -59,6 +62,9 @@ export const DatabaseBackupsDialog = (props: Props) => { }); }; + const isClusterWithVPCAndForkingToDifferentRegion = + database.private_network !== null && database.region !== region; + return ( { subtitle={formattedDate && `From ${formattedDate} (UTC)`} title={`Restore ${database.label}`} > + {isClusterWithVPCAndForkingToDifferentRegion && ( // Show warning when forking a cluster with VPC to a different region + + The database cluster is currently assigned to a VPC. When you restore + the cluster into a different region, it will not be assigned to a VPC + by default. If your workflow requires a VPC, go to the cluster’s + Networking tab after the restore is complete and assign the cluster to + a VPC. + + )} ({ marginBottom: theme.spacingFunction(32) })}> Restoring a backup creates a fork from this backup. If you proceed and the fork is created successfully, you should remove the original From b112706827251ab49105637da376ab463e656e45 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 8 Dec 2025 14:09:43 +0530 Subject: [PATCH 83/91] upcoming: [UIE-9762] - UX/UI enhancements for RuleSets and Prefix Lists (#13165) * Fix tooltip scroll styling * Improve stylings for MutiplePLSelect and fw rule table row * Update order of addresses * Fix spacing between papers in Prefixlist details * Revert design color token for tooltip arrow * add PL checkbox tooltip text if disabled * fix spacing for checkbox and tooltip * Add hoverable ruleset id copy on table row * reduce gap between label and copyable ruleset id * Remove tooltip arrow for the RS & PL feature * Minor change * Added changeset: UX/UI enhancements for RuleSets and Prefix Lists * Update PrefixList to Prefix List in tooltip message * Fix some styles * Some padding adjustments in MutiplePrefixListSelect * Minor fix --------- Co-authored-by: hrao --- ...r-13165-upcoming-features-1764951969980.md | 5 + .../Rules/FirewallPrefixListDrawer.tsx | 246 +++++++++--------- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 8 +- .../Rules/FirewallRuleTable.tsx | 83 +++--- .../Rules/MultiplePrefixListSelect.tsx | 90 ++++--- .../src/features/Firewalls/shared.test.tsx | 26 +- .../manager/src/features/Firewalls/shared.tsx | 56 ++-- 7 files changed, 288 insertions(+), 226 deletions(-) create mode 100644 packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md diff --git a/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md b/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md new file mode 100644 index 00000000000..ae7b46a0c05 --- /dev/null +++ b/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +UX/UI enhancements for RuleSets and Prefix Lists ([#13165](https://github.com/linode/manager/pull/13165)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx index 083634c3063..845b5486e63 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListDrawer.tsx @@ -1,5 +1,13 @@ import { useAllFirewallPrefixListsQuery } from '@linode/queries'; -import { Box, Button, Chip, Drawer, Paper, TooltipIcon } from '@linode/ui'; +import { + Box, + Button, + Chip, + Drawer, + Paper, + Stack, + TooltipIcon, +} from '@linode/ui'; import { capitalize } from '@linode/utilities'; import * as React from 'react'; @@ -225,140 +233,144 @@ export const FirewallPrefixListDrawer = React.memo( )} - {isIPv4Supported && ( - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - padding: theme.spacingFunction(12), - marginTop: theme.spacingFunction(8), - ...(isIPv4InUse - ? { - border: `1px solid ${theme.tokens.alias.Border.Positive}`, - } - : {}), - })} - > - + {isIPv4Supported && ( + ({ - display: 'flex', - justifyContent: 'space-between', - marginBottom: theme.spacingFunction(4), - ...(!isIPv4InUse + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + ...(isIPv4InUse ? { - color: - theme.tokens.alias.Content.Text.Primary.Disabled, + border: `1px solid ${theme.tokens.alias.Border.Positive}`, } : {}), })} > - IPv4 - ({ - background: isIPv4InUse - ? theme.tokens.component.Badge.Positive.Subtle - .Background - : theme.tokens.component.Badge.Neutral.Subtle - .Background, - color: isIPv4InUse - ? theme.tokens.component.Badge.Positive.Subtle.Text - : theme.tokens.component.Badge.Neutral.Subtle.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!isIPv4InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary + .Disabled, + } + : {}), })} - /> - + > + IPv4 + ({ + background: isIPv4InUse + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Neutral.Subtle + .Background, + color: isIPv4InUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + - ({ - ...(!isIPv4InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary.Disabled, - } - : {}), - })} - > - {prefixListDetails.ipv4!.length > 0 ? ( - prefixListDetails.ipv4!.join(', ') - ) : ( - no IP addresses - )} - - - )} + ({ + ...(!isIPv4InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary + .Disabled, + } + : {}), + })} + > + {prefixListDetails.ipv4!.length > 0 ? ( + prefixListDetails.ipv4!.join(', ') + ) : ( + no IP addresses + )} + + + )} - {isIPv6Supported && ( - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - padding: theme.spacingFunction(12), - marginTop: theme.spacingFunction(8), - ...(isIPv6InUse - ? { - border: `1px solid ${theme.tokens.alias.Border.Positive}`, - } - : {}), - })} - > - ({ - display: 'flex', - justifyContent: 'space-between', - marginBottom: theme.spacingFunction(4), - ...(!isIPv6InUse + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + ...(isIPv6InUse ? { - color: - theme.tokens.alias.Content.Text.Primary.Disabled, + border: `1px solid ${theme.tokens.alias.Border.Positive}`, } : {}), })} > - IPv6 - ({ - background: isIPv6InUse - ? theme.tokens.component.Badge.Positive.Subtle - .Background - : theme.tokens.component.Badge.Neutral.Subtle - .Background, - color: isIPv6InUse - ? theme.tokens.component.Badge.Positive.Subtle.Text - : theme.tokens.component.Badge.Neutral.Subtle.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!isIPv6InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary + .Disabled, + } + : {}), })} - /> - - ({ - ...(!isIPv6InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary.Disabled, - } - : {}), - })} - > - {prefixListDetails.ipv6!.length > 0 ? ( - prefixListDetails.ipv6!.join(', ') - ) : ( - no IP addresses - )} - - - )} + > + IPv6 + ({ + background: isIPv6InUse + ? theme.tokens.component.Badge.Positive.Subtle + .Background + : theme.tokens.component.Badge.Neutral.Subtle + .Background, + color: isIPv6InUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + + ({ + ...(!isIPv6InUse + ? { + color: + theme.tokens.alias.Content.Text.Primary + .Disabled, + } + : {}), + })} + > + {prefixListDetails.ipv6!.length > 0 ? ( + prefixListDetails.ipv6!.join(', ') + ) : ( + no IP addresses + )} + + + )} + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index ef26ae33514..401e0b63459 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -347,7 +347,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { tooltip={ipNetmaskTooltipText} /> {isFirewallRulesetsPrefixlistsFeatureEnabled && ( - ({ ...(ips.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), })); + +const StyledMultiplePrefixListSelect = styled(MultiplePrefixListSelect, { + label: 'StyledMultipleIPInput', +})(({ theme, pls }) => ({ + ...(pls.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), +})); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index ebeff8eec5b..69a4a59f3e6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -25,7 +25,6 @@ import { prop, uniqBy } from 'ramda'; import * as React from 'react'; 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 { Table } from 'src/components/Table'; @@ -42,6 +41,7 @@ import { predefinedFirewallFromRule as ruleToPredefinedFirewall, useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; +import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; @@ -55,7 +55,6 @@ 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'; @@ -384,17 +383,29 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { zIndex: isDragging ? 9999 : 0, } as const; - const { classes } = useStyles(); + const [isHovered, setIsHovered] = React.useState(false); + + const handleMouseEnter = React.useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = React.useCallback(() => { + setIsHovered(false); + }, []); if (isRuleSetLoading) { return ; } + const ruleSetCopyableId = `${rulesetDetails ? 'ID:' : 'Ruleset ID:'} ${ruleset}`; + return ( { )} {isRuleSetRowEnabled && ( - <> - - - - - {rulesetDetails && ( - - handleOpenRuleSetDrawerForViewing?.(rulesetDetails.id) - } - > - {rulesetDetails?.label} - - )} - - - + + + + {rulesetDetails && ( + + handleOpenRuleSetDrawerForViewing?.(rulesetDetails.id) + } > - {rulesetDetails ? 'ID:' : 'Rule Set ID:'}  - {ruleset} - - - + {rulesetDetails?.label} + + )} - - - + + + +
    + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx index 67e3312427f..cb13e874b4a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx @@ -32,21 +32,26 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'flex-start', }, paddingLeft: 0, - paddingTop: theme.spacingFunction(12), + paddingTop: theme.spacingFunction(12), // default when empty + }, + addPLReducedPadding: { + paddingTop: theme.spacingFunction(4), // when last row is selected + }, + autocomplete: { + "& [data-testid='inputLabelWrapper']": { + display: 'none', + }, }, button: { '& > span': { padding: 2, }, + marginTop: theme.spacingFunction(8), marginLeft: `-${theme.spacingFunction(8)}`, - marginTop: 4, - minHeight: 'auto', - minWidth: 'auto', + height: 20, + width: 20, padding: 0, }, - root: { - marginTop: theme.spacingFunction(8), - }, })); const isPrefixListSupported = (pl: FirewallPrefixList) => @@ -216,6 +221,9 @@ export const MultiplePrefixListSelect = React.memo( return null; } + const lastRowSelected = + pls.length > 0 && pls[pls.length - 1].address !== ''; + const renderRow = (thisPL: ExtendedPL, idx: number) => { const availableOptions = getAvailableOptions(idx, thisPL.address); @@ -237,6 +245,19 @@ export const MultiplePrefixListSelect = React.memo( const disableIPv4 = ipv4Unsupported || ipv4Forced; const disableIPv6 = ipv6Unsupported || ipv6Forced; + const getCheckboxTooltipText = ( + ipUnsupported?: boolean, + ipForced?: boolean + ) => { + if (ipUnsupported) { + return 'Not supported by this Prefix List'; + } + if (ipForced) { + return 'At least one array must be selected'; + } + return undefined; + }; + return ( 0} disabled={disabled} errorText={thisPL.error} @@ -274,20 +296,32 @@ export const MultiplePrefixListSelect = React.memo( sx={{ ml: 0.4 }} > - handleToggleIPv4(!thisPL.inIPv4Rule, idx)} - text="IPv4" - /> - handleToggleIPv6(!thisPL.inIPv6Rule, idx)} - text="IPv6" - /> + + handleToggleIPv4(!thisPL.inIPv4Rule, idx)} + text="IPv4" + toolTipText={getCheckboxTooltipText( + ipv4Unsupported, + ipv4Forced + )} + /> + + + handleToggleIPv6(!thisPL.inIPv6Rule, idx)} + text="IPv6" + toolTipText={getCheckboxTooltipText( + ipv6Unsupported, + ipv6Forced + )} + /> + removeInput(idx)} - sx={(theme) => ({ - height: 20, - width: 20, - marginTop: `${theme.spacingFunction(16)} !important`, - })} > @@ -325,11 +354,11 @@ export const MultiplePrefixListSelect = React.memo( }; return ( -
    +
    {/* Display the title only when pls.length > 0 (i.e., at least one PL row is added) */} {pls.length > 0 && ( - Prefix List + Prefix List {getFeatureChip({ isFirewallRulesetsPrefixlistsFeatureEnabled, isFirewallRulesetsPrefixListsBetaEnabled, @@ -342,7 +371,10 @@ export const MultiplePrefixListSelect = React.memo( + + ); + + // For normal Prefix Lists: display all fields. + // For special Prefix Lists: display only 'Name' or 'Prefix List Name' and 'Description'. + const fields = [ + { + label: plFieldLabel, + value: prefixListDetails?.name ?? selectedPrefixListLabel, + }, + !isPrefixListSpecial && { + label: 'ID', + value: prefixListDetails?.id, + copy: true, + }, + { + label: 'Description', + value: prefixListDetails?.description, + column: true, + }, + !isPrefixListSpecial && + prefixListDetails?.name && { + label: 'Type', + value: getPrefixListType(prefixListDetails.name), + }, + !isPrefixListSpecial && + prefixListDetails?.visibility && { + label: 'Visibility', + value: capitalize(prefixListDetails.visibility), + }, + !isPrefixListSpecial && { + label: 'Version', + value: prefixListDetails?.version, + }, + !isPrefixListSpecial && + prefixListDetails?.created && { + label: 'Created', + value: , + }, + !isPrefixListSpecial && + prefixListDetails?.updated && { + label: 'Updated', + value: , + }, + ].filter(Boolean) as { + column?: boolean; + copy?: boolean; + label: string; + value: React.ReactNode | string; + }[]; + return ( {prefixListDetails && ( <> - {[ - { - label: plFieldLabel, - value: prefixListDetails.name, - }, - { - label: 'ID', - value: prefixListDetails.id, - copy: true, - }, - { - label: 'Description', - value: prefixListDetails.description, - column: true, - }, - { - label: 'Type', - value: getPrefixListType(prefixListDetails.name), - }, - { - label: 'Visibility', - value: capitalize(prefixListDetails.visibility), - }, - { - label: 'Version', - value: prefixListDetails.version, - }, - { - label: 'Created', - value: , - }, - { - label: 'Updated', - value: , - }, - ].map((item, idx) => ( + {fields.map((item, idx) => ( {item.label && ( - {item.label}: + {item.label}: )} {item.value} @@ -203,7 +250,7 @@ export const FirewallPrefixListDrawer = React.memo( ))} - {prefixListDetails.deleted && ( + {!isPrefixListSpecial && prefixListDetails.deleted && ( )} - - - {isIPv4Supported && ( - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - padding: theme.spacingFunction(12), - ...(isIPv4InUse - ? { - border: `1px solid ${theme.tokens.alias.Border.Positive}`, - } - : {}), - })} - > - ({ - display: 'flex', - justifyContent: 'space-between', - marginBottom: theme.spacingFunction(4), - ...(!isIPv4InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary - .Disabled, - } - : {}), - })} - > - IPv4 - ({ - background: isIPv4InUse - ? theme.tokens.component.Badge.Positive.Subtle - .Background - : theme.tokens.component.Badge.Neutral.Subtle - .Background, - color: isIPv4InUse - ? theme.tokens.component.Badge.Positive.Subtle.Text - : theme.tokens.component.Badge.Neutral.Subtle.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, - })} - /> - - - ({ - ...(!isIPv4InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary - .Disabled, - } - : {}), - })} - > - {prefixListDetails.ipv4!.length > 0 ? ( - prefixListDetails.ipv4!.join(', ') - ) : ( - no IP addresses - )} - - - )} - - {isIPv6Supported && ( - ({ - backgroundColor: theme.tokens.alias.Background.Neutral, - padding: theme.spacingFunction(12), - ...(isIPv6InUse - ? { - border: `1px solid ${theme.tokens.alias.Border.Positive}`, - } - : {}), - })} - > - ({ - display: 'flex', - justifyContent: 'space-between', - marginBottom: theme.spacingFunction(4), - ...(!isIPv6InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary - .Disabled, - } - : {}), - })} - > - IPv6 - ({ - background: isIPv6InUse - ? theme.tokens.component.Badge.Positive.Subtle - .Background - : theme.tokens.component.Badge.Neutral.Subtle - .Background, - color: isIPv6InUse - ? theme.tokens.component.Badge.Positive.Subtle.Text - : theme.tokens.component.Badge.Neutral.Subtle.Text, - font: theme.font.bold, - fontSize: theme.tokens.font.FontSize.Xxxs, - marginRight: theme.spacingFunction(6), - flexShrink: 0, - })} - /> - - ({ - ...(!isIPv6InUse - ? { - color: - theme.tokens.alias.Content.Text.Primary - .Disabled, - } - : {}), - })} - > - {prefixListDetails.ipv6!.length > 0 ? ( - prefixListDetails.ipv6!.join(', ') - ) : ( - no IP addresses - )} - - - )} - + {!isPrefixListSpecial && ( + + {isIPv4Supported && ( + + )} + {isIPv6Supported && ( + + )} + + )} )} - ({ - marginTop: theme.spacingFunction(16), - display: 'flex', - justifyContent: backButtonText ? 'flex-start' : 'flex-end', - })} - > - - + {drawerFooter} ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx new file mode 100644 index 00000000000..de9163f88c5 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallPrefixListIPSection.tsx @@ -0,0 +1,72 @@ +import { Chip, Paper } from '@linode/ui'; +import React from 'react'; + +import { StyledLabel, StyledListItem } from './shared.styles'; + +interface PrefixListIPSectionProps { + addresses: string[]; + inUse: boolean; + type: 'IPv4' | 'IPv6'; +} + +/** + * Displays a Prefix List IP section (IPv4 or IPv6) with usage indicator. + */ +export const PrefixListIPSection = ({ + type, + inUse, + addresses, +}: PrefixListIPSectionProps) => { + return ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + padding: theme.spacingFunction(12), + ...(inUse && { + border: `1px solid ${theme.tokens.alias.Border.Positive}`, + }), + })} + > + ({ + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacingFunction(4), + ...(!inUse && { + color: theme.tokens.alias.Content.Text.Primary.Disabled, + }), + })} + > + {type} + ({ + background: inUse + ? theme.tokens.component.Badge.Positive.Subtle.Background + : theme.tokens.component.Badge.Neutral.Subtle.Background, + color: inUse + ? theme.tokens.component.Badge.Positive.Subtle.Text + : theme.tokens.component.Badge.Neutral.Subtle.Text, + font: theme.font.bold, + fontSize: theme.tokens.font.FontSize.Xxxs, + marginRight: theme.spacingFunction(6), + flexShrink: 0, + })} + /> + + + ({ + ...(!inUse && { + color: theme.tokens.alias.Content.Text.Primary.Disabled, + }), + })} + > + {addresses.length > 0 ? addresses.join(', ') : no IP addresses} + + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx index cb13e874b4a..52609f4d052 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MultiplePrefixListSelect.tsx @@ -19,7 +19,12 @@ import { useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; -import { getPrefixListType, groupPriority } from './shared'; +import { + combinePrefixLists, + getPrefixListType, + groupPriority, + isSpecialPrefixList, +} from './shared'; import type { FirewallPrefixList } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -54,9 +59,17 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -const isPrefixListSupported = (pl: FirewallPrefixList) => - (pl.ipv4 !== null && pl.ipv4 !== undefined) || - (pl.ipv6 !== null && pl.ipv6 !== undefined); +const isPrefixListSupported = (pl: FirewallPrefixList) => { + // Whitelisting all the Special PrefixLists as supported ones. + if (isSpecialPrefixList(pl.name)) { + return true; + } + + return ( + (pl.ipv4 !== null && pl.ipv4 !== undefined) || + (pl.ipv6 !== null && pl.ipv6 !== undefined) + ); +}; const getSupportDetails = (pl: FirewallPrefixList) => ({ isPLIPv4Unsupported: pl.ipv4 === null || pl.ipv4 === undefined, @@ -67,8 +80,13 @@ const getSupportDetails = (pl: FirewallPrefixList) => ({ * Default selection state for a newly chosen Prefix List */ const getDefaultPLReferenceState = ( - support: ReturnType + support: null | ReturnType ): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { + if (support === null) { + // Special Prefix List case + return { inIPv4Rule: true, inIPv6Rule: false }; + } + const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) @@ -135,7 +153,7 @@ export const MultiplePrefixListSelect = React.memo( isFirewallRulesetsPrefixlistsFeatureEnabled ); - const prefixLists = data ?? []; + const prefixLists = React.useMemo(() => combinePrefixLists(data), [data]); /** * Filter prefix lists to include those that support IPv4, IPv6, or both, @@ -146,16 +164,18 @@ export const MultiplePrefixListSelect = React.memo( prefixLists .filter(isPrefixListSupported) .map((pl) => ({ - label: pl.name, - value: pl.id, - support: getSupportDetails(pl), + label: pl.name!, + value: pl.id ?? pl.name, + support: !isSpecialPrefixList(pl.name) + ? getSupportDetails(pl as FirewallPrefixList) + : null, })) // The API does not seem to sort prefix lists by "name" to prioritize certain types. // This sort ensures that Autocomplete's groupBy displays groups correctly without duplicates // and that the dropdown shows groups in the desired order. .sort((a, b) => { - const groupA = getPrefixListType(a.label); - const groupB = getPrefixListType(b.label); + const groupA = getPrefixListType(a.label!); + const groupB = getPrefixListType(b.label!); return groupPriority[groupA] - groupPriority[groupB]; }), @@ -233,9 +253,9 @@ export const MultiplePrefixListSelect = React.memo( // Disabling a checkbox ensures that at least one option (IPv4 or IPv6) remains checked const ipv4Unsupported = - selectedOption?.support.isPLIPv4Unsupported === true; + selectedOption?.support?.isPLIPv4Unsupported === true; const ipv6Unsupported = - selectedOption?.support.isPLIPv6Unsupported === true; + selectedOption?.support?.isPLIPv6Unsupported === true; const ipv4Forced = thisPL.inIPv4Rule === true && thisPL.inIPv6Rule === false; @@ -301,6 +321,7 @@ export const MultiplePrefixListSelect = React.memo( checked={thisPL.inIPv4Rule === true} data-testid={`ipv4-checkbox-${idx}`} disabled={disableIPv4 || disabled} + id={`ipv4-checkbox-${idx}`} onChange={() => handleToggleIPv4(!thisPL.inIPv4Rule, idx)} text="IPv4" toolTipText={getCheckboxTooltipText( @@ -314,6 +335,7 @@ export const MultiplePrefixListSelect = React.memo( checked={thisPL.inIPv6Rule === true} data-testid={`ipv6-checkbox-${idx}`} disabled={disableIPv6 || disabled} + id={`ipv6-checkbox-${idx}`} onChange={() => handleToggleIPv6(!thisPL.inIPv6Rule, idx)} text="IPv6" toolTipText={getCheckboxTooltipText( diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 200ffa8c0f1..e3fb2754362 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -1,6 +1,6 @@ import { prop, sortBy } from 'ramda'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { APIError, FirewallPrefixList } from '@linode/api-v4/lib/types'; export type Category = 'inbound' | 'outbound'; export interface FirewallRuleError { @@ -150,3 +150,53 @@ export const getPrefixListType = (name: string): PrefixListGroup => { } return 'Other'; // Safe fallback }; + +export type SpecialPrefixList = Partial; + +const SPECIAL_PREFIX_LISTS_DESCRIPTION = + 'System-defined PrefixLists, such as pl::vpcs: and pl::subnets:, for VPC interface firewalls are dynamic and update automatically. They manage access to and from the interface for addresses within the interface’s VPC or VPC subnet.'; + +export const SPECIAL_PREFIX_LISTS: SpecialPrefixList[] = [ + { name: 'pl::vpcs:', description: SPECIAL_PREFIX_LISTS_DESCRIPTION }, + { + name: 'pl::subnets:', + description: SPECIAL_PREFIX_LISTS_DESCRIPTION, + }, +]; + +export const SPECIAL_PREFIX_LIST_NAMES = SPECIAL_PREFIX_LISTS.map( + (pl) => pl.name +); + +export const isSpecialPrefixList = (name: string | undefined) => { + if (!name) return false; + return SPECIAL_PREFIX_LIST_NAMES.includes(name); +}; + +/** + * Combine API prefix lists with hardcoded special prefix lists. + * API results override special PLs if names collide. + * Ensures no duplicate prefix lists when combining hardcoded and API values. + * @TODO: Remove hardcoded special PLs once API supports them. + */ +export const combinePrefixLists = ( + apiPLs: (FirewallPrefixList | SpecialPrefixList)[] | undefined +): (FirewallPrefixList | SpecialPrefixList)[] => { + const map = new Map(); + + // Add hardcoded special PLs first + SPECIAL_PREFIX_LISTS.forEach((pl) => { + if (pl.name) { + map.set(pl.name, pl); + } + }); + + // Add API results (override if name matches with hardcoded special PLs) + (apiPLs ?? []).forEach((pl) => { + if (pl.name) { + map.set(pl.name, pl); + } + }); + + return Array.from(map.values()); +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 03479d934b8..53b7dd66f8d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -150,6 +150,7 @@ import { maintenancePolicyFactory } from 'src/factories/maintenancePolicy'; import { userAccountPermissionsFactory } from 'src/factories/userAccountPermissions'; import { userEntityPermissionsFactory } from 'src/factories/userEntityPermissions'; import { userRolesFactory } from 'src/factories/userRoles'; +import { SPECIAL_PREFIX_LIST_NAMES } from 'src/features/Firewalls/FirewallDetail/Rules/shared'; import type { AccountMaintenance, @@ -1395,14 +1396,25 @@ export const handlers = [ const filter = JSON.parse(request.headers.get('x-filter') || '{}'); if (filter['name']) { - const match = - prefixlists.find((pl) => pl.name === filter.name) ?? - firewallPrefixListFactory.build({ - name: filter['name'], - description: `${filter['name']} description`, - }); // fallback if not found - - return HttpResponse.json(makeResourcePage([match])); + const existingPrefixList = prefixlists.find( + (pl) => pl.name === filter.name + ); + + // SPECIAL_PREFIX_LIST_NAMES may expand in the future if returned by the API + const isPrefixListSpecial = SPECIAL_PREFIX_LIST_NAMES.includes( + filter.name + ); + + const match = isPrefixListSpecial + ? [] // Special PLs: API currently returns empty; @TODO: update with actual response once API supports them + : [ + existingPrefixList ?? + firewallPrefixListFactory.build({ + name: filter.name, + description: `${filter.name} description`, + }), + ]; + return HttpResponse.json(makeResourcePage(match)); } } return HttpResponse.json(makeResourcePage(prefixlists)); @@ -1431,6 +1443,7 @@ export const handlers = [ ipv4: [ 'pl:system:resolvers:test', 'pl:system:test', + 'pl::vpcs:', // special prefixlist '192.168.1.200', '192.168.1.201', ], @@ -1471,6 +1484,7 @@ export const handlers = [ 'pl::supports-both-but-empty-both', '172.31.255.255', 'pl::marked-for-deletion', + 'pl::vpcs:', // special prefixlist ], ipv6: [ 'pl::supports-both', @@ -1483,6 +1497,7 @@ export const handlers = [ // our logic will treat them as a single entity within the ipv4 or ipv6 array. 'pl::vpcs:supports-both-2', '2001:db8:85a3::8a2e:372:7336/128', + 'pl::subnets:', // special prefixlist ], }, ports: '22, 53, 80, 100, 443, 3306', From 5faffaa2797eeb4178b21697f1e3ff2a8e49ea99 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:58:51 +0100 Subject: [PATCH 85/91] fix: [UIE-9733] - Optimize rendering of entities in `AssignedRolesTable` (#13173) * Optimize rendering of entities in AssignRolesTable * Added changeset: Optimize rendering of entities in AssignedRolesTable --- .../pr-13173-fixed-1764944067009.md | 5 ++ .../IAM/Users/UserRoles/AssignedEntities.tsx | 75 +++++++++++-------- 2 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 packages/manager/.changeset/pr-13173-fixed-1764944067009.md diff --git a/packages/manager/.changeset/pr-13173-fixed-1764944067009.md b/packages/manager/.changeset/pr-13173-fixed-1764944067009.md new file mode 100644 index 00000000000..771b1923966 --- /dev/null +++ b/packages/manager/.changeset/pr-13173-fixed-1764944067009.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Optimize rendering of entities in AssignedRolesTable ([#13173](https://github.com/linode/manager/pull/13173)) diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index f1c53a8b555..01f08dfcf51 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -34,7 +34,13 @@ export const AssignedEntities = ({ return sortByString(a.name, b.name, 'asc'); }); - const items = sortedEntities?.map((entity: CombinedEntity) => ( + // We don't need to send all items to the TruncatedList component for performance reasons, + // since past a certain count they will be hidden within the row. + const MAX_ITEMS_TO_RENDER = 15; + const entitiesToRender = sortedEntities.slice(0, MAX_ITEMS_TO_RENDER); + const totalCount = sortedEntities.length; + + const items = entitiesToRender?.map((entity: CombinedEntity) => ( ( - - - - - - )} + customOverflowButton={(numHiddenByTruncate) => { + const numHiddenItems = + totalCount - MAX_ITEMS_TO_RENDER + numHiddenByTruncate; + + return ( + + + + + + ); + }} justifyOverflowButtonRight listContainerSx={{ width: '100%', From 4905ac3e5c7805c960ceaf8cad3c710c1a5149a2 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:11:36 +0100 Subject: [PATCH 86/91] feat: [UIE-9637] - IAM "New" and "Limited Availability" badges (#13175) * flag + badges * changeset * feedback * feedback * deps --- .../pr-13175-added-1765199020354.md | 5 +++ .../src/components/PrimaryNav/PrimaryLink.tsx | 5 ++- .../src/components/PrimaryNav/PrimaryNav.tsx | 4 ++ .../manager/src/dev-tools/FeatureFlagTool.tsx | 6 ++- packages/manager/src/featureFlags.ts | 1 + .../manager/src/features/IAM/IAMLanding.tsx | 44 ++++++++++++++++++- .../features/IAM/Users/UserDetailsLanding.tsx | 21 +++++++-- .../TopMenu/UserMenu/UserMenuPopover.tsx | 26 +++++++++-- 8 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-13175-added-1765199020354.md diff --git a/packages/manager/.changeset/pr-13175-added-1765199020354.md b/packages/manager/.changeset/pr-13175-added-1765199020354.md new file mode 100644 index 00000000000..0dfe282277d --- /dev/null +++ b/packages/manager/.changeset/pr-13175-added-1765199020354.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM "New" and "Limited Availability" badges ([#13175](https://github.com/linode/manager/pull/13175)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx index 3d7afbb9783..60175c32da1 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryLink.tsx @@ -1,4 +1,4 @@ -import { BetaChip } from '@linode/ui'; +import { BetaChip, NewFeatureChip } from '@linode/ui'; import * as React from 'react'; import { StyledActiveLink, StyledPrimaryLinkBox } from './PrimaryNav.styles'; @@ -17,6 +17,7 @@ export interface BaseNavLink { export interface PrimaryLink extends BaseNavLink { betaChipClassName?: string; isBeta?: boolean; + isNew?: boolean; onClick?: (e: React.MouseEvent) => void; } @@ -35,6 +36,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { to, isActiveLink, isBeta, + isNew, isCollapsed, onClick, } = props; @@ -63,6 +65,7 @@ const PrimaryLink = React.memo((props: PrimaryLinkProps) => { {isBeta ? ( ) : null} + {isNew ? : null} ); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index b8eeba617d8..d42cfe40962 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -117,6 +117,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = flags.iamLimitedAvailabilityBadges; + const { isNetworkLoadBalancerEnabled } = useIsNetworkLoadBalancerEnabled(); const { @@ -274,6 +276,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isIAMEnabled || iamRbacPrimaryNavChanges, to: '/iam', isBeta: isIAMBeta, + isNew: !isIAMBeta && showLimitedAvailabilityBadges, }, { display: 'Account', @@ -307,6 +310,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isIAMEnabled, to: '/iam', isBeta: isIAMBeta, + isNew: !isIAMBeta && showLimitedAvailabilityBadges, }, { display: 'Quotas', diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index ec87b33c2d8..e017a81f19c 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -61,7 +61,11 @@ const options: { flag: keyof Flags; label: string }[] = [ label: 'Database Restrict Premium Plan Resize', }, { flag: 'apicliButtonCopy', label: 'APICLI Button Copy' }, - { flag: 'iam', label: 'Identity and Access Beta' }, + { flag: 'iam', label: 'IAM enabled & Beta' }, + { + flag: 'iamLimitedAvailabilityBadges', + label: 'IAM Limited Availability Badges', + }, { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' }, { flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' }, { diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0ab07445dee..3576eb95e94 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -213,6 +213,7 @@ export interface Flags { gpuv2: GpuV2; iam: BetaFeatureFlag; iamDelegation: BaseFeatureFlag; + iamLimitedAvailabilityBadges: boolean; iamRbacPrimaryNavChanges: boolean; ipv6Sharing: boolean; kubernetesBlackwellPlans: boolean; diff --git a/packages/manager/src/features/IAM/IAMLanding.tsx b/packages/manager/src/features/IAM/IAMLanding.tsx index 822ee434d84..588ef58e7f9 100644 --- a/packages/manager/src/features/IAM/IAMLanding.tsx +++ b/packages/manager/src/features/IAM/IAMLanding.tsx @@ -1,3 +1,4 @@ +import { Chip, NewFeatureChip, styled } from '@linode/ui'; import { Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -6,13 +7,21 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useDelegationRole } from './hooks/useDelegationRole'; -import { useIsIAMDelegationEnabled } from './hooks/useIsIAMEnabled'; +import { + useIsIAMDelegationEnabled, + useIsIAMEnabled, +} from './hooks/useIsIAMEnabled'; import { IAM_DOCS_LINK, ROLES_LEARN_MORE_LINK } from './Shared/constants'; export const IdentityAccessLanding = React.memo(() => { + const flags = useFlags(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = + flags.iamLimitedAvailabilityBadges && isIAMEnabled && !isIAMBeta; const location = useLocation(); const navigate = useNavigate(); const { isParentAccount } = useDelegationRole(); @@ -49,7 +58,21 @@ export const IdentityAccessLanding = React.memo(() => { return ( <> - + + + + + ) : null, + }, + removeCrumbX: 1, + }} + spacingBottom={4} + /> }> @@ -61,3 +84,20 @@ export const IdentityAccessLanding = React.memo(() => { ); }); + +const StyledLimitedAvailabilityChip = styled(Chip, { + label: 'StyledLimitedAvailabilityChip', + shouldForwardProp: (prop) => prop !== 'color', +})(({ theme }) => ({ + '& .MuiChip-label': { + padding: 0, + }, + background: theme.tokens.component.Badge.Informative.Subtle.Background, + color: theme.tokens.component.Badge.Informative.Subtle.Text, + font: theme.font.bold, + fontSize: '12px', + lineHeight: '12px', + height: 16, + letterSpacing: '.22px', + padding: theme.spacingFunction(4), +})); diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index 3fcd1a070bb..0d4d6ece9c0 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -1,4 +1,4 @@ -import { Chip, styled } from '@linode/ui'; +import { Chip, NewFeatureChip, styled } from '@linode/ui'; import { Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import React from 'react'; @@ -6,7 +6,11 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; -import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { + useIsIAMDelegationEnabled, + useIsIAMEnabled, +} from 'src/features/IAM/hooks/useIsIAMEnabled'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { useDelegationRole } from '../hooks/useDelegationRole'; @@ -18,6 +22,10 @@ import { } from '../Shared/constants'; export const UserDetailsLanding = () => { + const flags = useFlags(); + const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled(); + const showLimitedAvailabilityBadges = + flags.iamLimitedAvailabilityBadges && isIAMEnabled && !isIAMBeta; const { username } = useParams({ from: '/iam/users/$username' }); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); const { isParentAccount } = useDelegationRole(); @@ -55,7 +63,14 @@ export const UserDetailsLanding = () => { breadcrumbProps={{ crumbOverrides: [ { - label: IAM_LABEL, + label: ( + <> + {IAM_LABEL} + {showLimitedAvailabilityBadges ? ( + + ) : null} + + ), position: 1, }, ], diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx index 5a6544153d7..0ee7f5b8ef1 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx @@ -1,5 +1,12 @@ import { useAccount, useProfile } from '@linode/queries'; -import { BetaChip, Box, Divider, Stack, Typography } from '@linode/ui'; +import { + BetaChip, + Box, + Divider, + NewFeatureChip, + Stack, + Typography, +} from '@linode/ui'; import { styled } from '@mui/material'; import Grid from '@mui/material/Grid'; import Popover from '@mui/material/Popover'; @@ -32,13 +39,18 @@ interface MenuLink { display: string; hide?: boolean; isBeta?: boolean; + isNew?: boolean; to: string; } export const UserMenuPopover = (props: UserMenuPopoverProps) => { const { anchorEl, isDrawerOpen, onClose, onDrawerOpen } = props; const sessionContext = React.useContext(switchAccountSessionContext); - const { iamRbacPrimaryNavChanges, limitsEvolution } = useFlags(); + const { + iamRbacPrimaryNavChanges, + limitsEvolution, + iamLimitedAvailabilityBadges, + } = useFlags(); const theme = useTheme(); const { data: account } = useAccount(); @@ -121,6 +133,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { ? '/users' : '/account/users', isBeta: iamRbacPrimaryNavChanges && isIAMEnabled && isIAMBeta, + isNew: isIAMEnabled && !isIAMBeta && iamLimitedAvailabilityBadges, }, { display: 'Quotas', @@ -150,7 +163,13 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { : '/account/settings', }, ], - [isIAMEnabled, iamRbacPrimaryNavChanges, limitsEvolution] + [ + isIAMEnabled, + iamRbacPrimaryNavChanges, + limitsEvolution, + iamLimitedAvailabilityBadges, + isIAMBeta, + ] ); const renderLink = (link: MenuLink) => { @@ -281,6 +300,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => { > {menuLink.display} {menuLink?.isBeta ? : null} + {menuLink?.isNew ? : null} ) )} From 9921e9e46f7987fd52cd78ed448d5321a9602e6d Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:19:41 +0100 Subject: [PATCH 87/91] fix: [UIE-9245] - EntitiesSelect performance on large accounts (#13168) * save progress * Cleanup * handle search * handle search * handle search * Added changeset: EntitiesSelect performance on large accounts * fix select all * deselect all --- .../pr-13168-fixed-1765192706974.md | 5 ++ .../AssignedPermissionsPanel.tsx | 4 +- ...ities.test.tsx => EntitiesSelect.test.tsx} | 18 ++--- .../{Entities.tsx => EntitiesSelect.tsx} | 68 +++++++++++++++++-- 4 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 packages/manager/.changeset/pr-13168-fixed-1765192706974.md rename packages/manager/src/features/IAM/Shared/Entities/{Entities.test.tsx => EntitiesSelect.test.tsx} (96%) rename packages/manager/src/features/IAM/Shared/Entities/{Entities.tsx => EntitiesSelect.tsx} (63%) diff --git a/packages/manager/.changeset/pr-13168-fixed-1765192706974.md b/packages/manager/.changeset/pr-13168-fixed-1765192706974.md new file mode 100644 index 00000000000..ddd0821e97b --- /dev/null +++ b/packages/manager/.changeset/pr-13168-fixed-1765192706974.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +EntitiesSelect performance on large accounts ([#13168](https://github.com/linode/manager/pull/13168)) diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx index 2f6b38c0dff..c9b33e75b56 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { ROLES_LEARN_MORE_LINK } from '../constants'; -import { Entities } from '../Entities/Entities'; +import { EntitiesSelect } from '../Entities/EntitiesSelect'; import { Permissions } from '../Permissions/Permissions'; import { type ExtendedRole, getFacadeRoleDescription } from '../utilities'; import { @@ -62,7 +62,7 @@ export const AssignedPermissionsPanel = ({ )} {mode !== 'change-role-for-entity' && ( - { it('renders correct data when it is an account access and type is an account', () => { renderWithTheme( - { it('renders correct data when it is an account access and type is not an account', () => { renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { }); renderWithTheme( - { it('renders Autocomplete as readonly when mode is "change-role"', () => { renderWithTheme( - { const errorMessage = 'Entities are required.'; renderWithTheme( - { - const { data: entities } = useAllAccountEntities({}); + const { data: entities, isLoading } = useAllAccountEntities({}); const theme = useTheme(); + const [displayCount, setDisplayCount] = React.useState(INITIAL_DISPLAY_COUNT); + const [inputValue, setInputValue] = React.useState(''); + const memoizedEntities = React.useMemo(() => { if (access !== 'entity_access' || !entities) { return []; @@ -46,6 +52,30 @@ export const Entities = ({ return typeEntities ? mapEntitiesToOptions(typeEntities) : []; }, [entities, access, type]); + const filteredEntities = React.useMemo(() => { + if (!inputValue) { + return memoizedEntities; + } + + return memoizedEntities.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + }, [memoizedEntities, inputValue]); + + const visibleOptions = React.useMemo(() => { + const slice = filteredEntities.slice(0, displayCount); + + const selectedNotVisible = value.filter( + (selected) => !slice.some((opt) => opt.value === selected.value) + ); + + return [...slice, ...selectedNotVisible]; + }, [filteredEntities, displayCount, value]); + + React.useEffect(() => { + setDisplayCount(INITIAL_DISPLAY_COUNT); + }, [filteredEntities]); + if (access === 'account_access') { return ( <> @@ -75,16 +105,28 @@ export const Entities = ({ getOptionLabel={(option) => option.label} isOptionEqualToValue={(option, value) => option.value === value.value} label="Entities" + loading={isLoading} multiple noMarginTop - onChange={(_, newValue) => { - onChange(newValue || []); + onChange={(_, newValue, reason) => { + if ( + reason === 'selectOption' && + newValue.length === displayCount && + filteredEntities.length > displayCount + ) { + onChange(filteredEntities); + } else { + onChange(newValue || []); + } }} - options={memoizedEntities} + onInputChange={(_, value) => { + setInputValue(value); + }} + options={visibleOptions} placeholder={getPlaceholder( type, value.length, - memoizedEntities.length + filteredEntities.length )} readOnly={mode === 'change-role'} renderInput={(params) => ( @@ -97,10 +139,22 @@ export const Entities = ({ placeholder={getPlaceholder( type, value.length, - memoizedEntities.length + filteredEntities.length )} /> )} + slotProps={{ + listbox: { + onScroll: (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollHeight - scrollTop <= clientHeight * 1.5) { + setDisplayCount((prev) => + Math.min(prev + 200, filteredEntities.length) + ); + } + }, + }, + }} sx={{ marginTop: 0, '& .MuiChip-root': { From 17cafe764e5f4d7b90ee8bca0481347ececc33c2 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:58:25 +0100 Subject: [PATCH 88/91] small fix to hidden count (#13178) --- .../src/features/IAM/Users/UserRoles/AssignedEntities.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx index 01f08dfcf51..ee40b3929c0 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -83,7 +83,9 @@ export const AssignedEntities = ({ addEllipsis customOverflowButton={(numHiddenByTruncate) => { const numHiddenItems = - totalCount - MAX_ITEMS_TO_RENDER + numHiddenByTruncate; + totalCount <= MAX_ITEMS_TO_RENDER + ? numHiddenByTruncate + : totalCount - MAX_ITEMS_TO_RENDER + numHiddenByTruncate; return ( Date: Mon, 8 Dec 2025 13:11:20 -0500 Subject: [PATCH 89/91] =?UTF-8?q?Revert=20"upcoming:=20[M3-10708]=20-=20Di?= =?UTF-8?q?splay=20maintenance=20type=20and=20config=20in=20linode=5F?= =?UTF-8?q?=E2=80=A6"=20(#13179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 814c11ec349b119472dffccb0e947a1af98595bc. --- .../pr-13084-changed-1762970902694.md | 5 - packages/api-v4/src/account/events.ts | 6 +- ...r-13084-upcoming-features-1762970850861.md | 5 - .../components/ExtraPresetEvents.tsx | 59 -------- .../components/ExtraPresetMaintenance.tsx | 37 +---- .../src/dev-tools/components/JsonTextArea.tsx | 19 +-- .../Maintenance/MaintenanceTableRow.tsx | 8 +- .../features/Account/Maintenance/utilities.ts | 2 +- .../src/features/Events/factories/linode.tsx | 141 +++--------------- 9 files changed, 32 insertions(+), 250 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-13084-changed-1762970902694.md delete mode 100644 packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md diff --git a/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md b/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md deleted file mode 100644 index b3e1bd0b7c7..00000000000 --- a/packages/api-v4/.changeset/pr-13084-changed-1762970902694.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Use v4beta endpoints for /events and /events/ ([#13084](https://github.com/linode/manager/pull/13084)) diff --git a/packages/api-v4/src/account/events.ts b/packages/api-v4/src/account/events.ts index 9f2359d42c3..4f8c8d21e4b 100644 --- a/packages/api-v4/src/account/events.ts +++ b/packages/api-v4/src/account/events.ts @@ -1,4 +1,4 @@ -import { API_ROOT, BETA_API_ROOT } from '../constants'; +import { API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import type { Filter, Params, ResourcePage } from '../types'; @@ -12,7 +12,7 @@ import type { Event, Notification } from './types'; */ export const getEvents = (params: Params = {}, filter: Filter = {}) => Request>( - setURL(`${BETA_API_ROOT}/account/events`), + setURL(`${API_ROOT}/account/events`), setMethod('GET'), setXFilter(filter), setParams(params), @@ -26,7 +26,7 @@ export const getEvents = (params: Params = {}, filter: Filter = {}) => */ export const getEvent = (eventId: number) => Request( - setURL(`${BETA_API_ROOT}/account/events/${encodeURIComponent(eventId)}`), + setURL(`${API_ROOT}/account/events/${encodeURIComponent(eventId)}`), setMethod('GET'), ); diff --git a/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md b/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md deleted file mode 100644 index 894835a1327..00000000000 --- a/packages/manager/.changeset/pr-13084-upcoming-features-1762970850861.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Display maintenance type (emergency/scheduled) and config information in linode_migrate event messages ([#13084](https://github.com/linode/manager/pull/13084)) diff --git a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx index 8982d6ea2db..6ac26ce24fb 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetEvents.tsx @@ -287,65 +287,6 @@ const eventTemplates = { status: 'finished', }), - 'Linode Migration In Progress': () => - eventFactory.build({ - action: 'linode_migrate', - entity: { - id: 1, - label: 'linode-1', - type: 'linode', - url: '/v4/linode/instances/1', - }, - message: 'Linode migration in progress.', - percent_complete: 45, - status: 'started', - }), - - 'Linode Migration - Emergency': () => - eventFactory.build({ - action: 'linode_migrate', - description: 'emergency', - entity: { - id: 1, - label: 'linode-1', - type: 'linode', - url: '/v4/linode/instances/1', - }, - message: 'Emergency linode migration in progress.', - percent_complete: 30, - status: 'started', - }), - - 'Linode Migration - Scheduled Started': () => - eventFactory.build({ - action: 'linode_migrate', - description: 'scheduled', - entity: { - id: 1, - label: 'linode-1', - type: 'linode', - url: '/v4/linode/instances/1', - }, - message: 'Scheduled linode migration in progress.', - percent_complete: 10, - status: 'started', - }), - - 'Linode Migration - Scheduled': () => - eventFactory.build({ - action: 'linode_migrate', - description: 'scheduled', - entity: { - id: 1, - label: 'linode-1', - type: 'linode', - url: '/v4/linode/instances/1', - }, - message: 'Scheduled linode migration in progress.', - percent_complete: 0, - status: 'scheduled', - }), - 'Completed Event': () => eventFactory.build({ action: 'account_update', diff --git a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx index d1a4eae094b..c257d22db49 100644 --- a/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx +++ b/packages/manager/src/dev-tools/components/ExtraPresetMaintenance.tsx @@ -210,42 +210,7 @@ const maintenanceTemplates = { Canceled: () => accountMaintenanceFactory.build({ status: 'canceled' }), Completed: () => accountMaintenanceFactory.build({ status: 'completed' }), 'In Progress': () => - accountMaintenanceFactory.build({ - status: 'in_progress', - entity: { - type: 'linode', - id: 1, - label: 'linode-1', - url: '/v4/linode/instances/1', - }, - type: 'migrate', - }), - 'In Progress - Emergency Migration': () => - accountMaintenanceFactory.build({ - status: 'in_progress', - entity: { - type: 'linode', - id: 1, - label: 'linode-1', - url: '/v4/linode/instances/1', - }, - type: 'migrate', - description: 'emergency', - reason: 'Emergency maintenance migration', - }), - 'In Progress - Scheduled Migration': () => - accountMaintenanceFactory.build({ - status: 'in_progress', - entity: { - type: 'linode', - id: 1, - label: 'linode-1', - url: '/v4/linode/instances/1', - }, - type: 'migrate', - description: 'scheduled', - reason: 'Scheduled maintenance migration', - }), + accountMaintenanceFactory.build({ status: 'in_progress' }), Pending: () => accountMaintenanceFactory.build({ status: 'pending' }), Scheduled: () => accountMaintenanceFactory.build({ status: 'scheduled' }), Started: () => accountMaintenanceFactory.build({ status: 'started' }), diff --git a/packages/manager/src/dev-tools/components/JsonTextArea.tsx b/packages/manager/src/dev-tools/components/JsonTextArea.tsx index 1bc713bb65e..6eb65224f9f 100644 --- a/packages/manager/src/dev-tools/components/JsonTextArea.tsx +++ b/packages/manager/src/dev-tools/components/JsonTextArea.tsx @@ -24,23 +24,6 @@ export const JsonTextArea = ({ const debouncedUpdate = React.useMemo( () => debounce((text: string) => { - // Handle empty/whitespace text as null - if (!text.trim()) { - const event = { - currentTarget: { - name, - value: null, - }, - target: { - name, - value: null, - }, - } as unknown as React.ChangeEvent; - - onChange(event); - return; - } - try { const parsedJson = JSON.parse(text); const event = { @@ -52,7 +35,7 @@ export const JsonTextArea = ({ name, value: parsedJson, }, - } as unknown as React.ChangeEvent; + } as React.ChangeEvent; onChange(event); } catch (err) { diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 3fc62a6fb2c..9645722c188 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -47,8 +47,6 @@ const statusIconMap: Record = { scheduled: 'active', }; -const MAX_REASON_DISPLAY_LENGTH = 93; - interface MaintenanceTableRowProps { maintenance: AccountMaintenance; tableType: MaintenanceTableType; @@ -76,11 +74,9 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => { const eventProgress = recentEvent && formatProgressEvent(recentEvent); - const truncatedReason = reason - ? truncate(reason, MAX_REASON_DISPLAY_LENGTH) - : ''; + const truncatedReason = truncate(reason, 93); - const isTruncated = reason ? reason !== truncatedReason : false; + const isTruncated = reason !== truncatedReason; const dateField = getMaintenanceDateField(tableType); const dateValue = props.maintenance[dateField]; diff --git a/packages/manager/src/features/Account/Maintenance/utilities.ts b/packages/manager/src/features/Account/Maintenance/utilities.ts index f202f33691d..bb1c7cbe883 100644 --- a/packages/manager/src/features/Account/Maintenance/utilities.ts +++ b/packages/manager/src/features/Account/Maintenance/utilities.ts @@ -40,7 +40,7 @@ export const maintenanceDateColumnMap: Record< > = { completed: ['complete_time', 'End Date'], 'in progress': ['start_time', 'Start Date'], - upcoming: ['when', 'Start Date'], + upcoming: ['start_time', 'Start Date'], pending: ['when', 'Date'], }; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index f65948586e0..2d80ed84d26 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -5,23 +5,6 @@ import { Link } from 'src/components/Link'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; -import type { AccountMaintenance } from '@linode/api-v4'; - -/** - * Normalizes the event description to a valid maintenance type. - * Only accepts 'emergency' or 'scheduled' from AccountMaintenance.description, - * defaults to 'maintenance' for any other value or null/undefined. - */ -type MaintenanceDescription = 'maintenance' | AccountMaintenance['description']; - -const getMaintenanceDescription = ( - description: null | string | undefined -): MaintenanceDescription => { - if (description === 'emergency' || description === 'scheduled') { - return description; - } - return 'maintenance'; -}; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -258,106 +241,30 @@ export const linode: PartialEventMap<'linode'> = { ), }, linode_migrate: { - failed: (e) => { - const maintenanceType = getMaintenanceDescription(e.description); - return ( - <> - Migration failed for Linode{' '} - for{' '} - {maintenanceType === 'maintenance' ? ( - maintenance - ) : ( - <> - {maintenanceType} maintenance - - )} - {e.secondary_entity ? ( - <> - {' '} - with config - - ) : ( - '' - )} - . - - ); - }, - finished: (e) => { - const maintenanceType = getMaintenanceDescription(e.description); - return ( - <> - Linode has been{' '} - migrated for{' '} - {maintenanceType === 'maintenance' ? ( - maintenance - ) : ( - <> - {maintenanceType} maintenance - - )} - {e.secondary_entity ? ( - <> - {' '} - with config - - ) : ( - '' - )} - . - - ); - }, - scheduled: (e) => { - const maintenanceType = getMaintenanceDescription(e.description); - return ( - <> - Linode is scheduled to be{' '} - migrated for{' '} - {maintenanceType === 'maintenance' ? ( - maintenance - ) : ( - <> - {maintenanceType} maintenance - - )} - {e.secondary_entity ? ( - <> - {' '} - with config - - ) : ( - '' - )} - . - - ); - }, - started: (e) => { - const maintenanceType = getMaintenanceDescription(e.description); - return ( - <> - Linode is being{' '} - migrated for{' '} - {maintenanceType === 'maintenance' ? ( - maintenance - ) : ( - <> - {maintenanceType} maintenance - - )} - {e.secondary_entity ? ( - <> - {' '} - with config - - ) : ( - '' - )} - . - - ); - }, + failed: (e) => ( + <> + Migration failed for Linode{' '} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + migrated. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + migrated. + + ), + started: (e) => ( + <> + Linode is being{' '} + migrated. + + ), }, linode_migrate_datacenter: { failed: (e) => ( From 2fd161c0a2b37592db0471891ec06714c87431f5 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 8 Dec 2025 19:31:53 +0100 Subject: [PATCH 90/91] Cloud version 1.156.0, API v4 version 0.154.0, Validation version 0.79.0, UI version 0.23.0, Utilities version 0.13.0, Queries version 0.18.0 --- ...r-13078-upcoming-features-1762869732236.md | 5 - ...r-13079-upcoming-features-1762861826541.md | 5 - .../pr-13097-changed-1763149227691.md | 5 - .../pr-13119-tech-stories-1764007445902.md | 5 - ...r-13127-upcoming-features-1763979506001.md | 5 - ...r-13133-upcoming-features-1764084018326.md | 5 - ...r-13146-upcoming-features-1764600095421.md | 5 - ...r-13148-upcoming-features-1764600387228.md | 5 - ...r-13156-upcoming-features-1764708047549.md | 5 - .../pr-13174-changed-1764961622718.md | 5 - packages/api-v4/CHANGELOG.md | 22 +++++ packages/api-v4/package.json | 2 +- .../pr-13032-changed-1761686860911.md | 5 - .../pr-13057-changed-1762343659819.md | 5 - .../pr-13059-fixed-1762972220664.md | 5 - .../pr-13060-tech-stories-1762357716157.md | 5 - ...r-13068-upcoming-features-1762930872294.md | 5 - .../pr-13075-added-1762776269124.md | 5 - ...r-13079-upcoming-features-1762861957439.md | 5 - ...r-13081-upcoming-features-1762935895154.md | 5 - .../pr-13082-fixed-1762957507469.md | 5 - .../pr-13083-tests-1762969859293.md | 5 - ...r-13087-upcoming-features-1763109384091.md | 5 - ...r-13088-upcoming-features-1763032920807.md | 5 - ...r-13089-upcoming-features-1763100678421.md | 5 - ...r-13090-upcoming-features-1763045734612.md | 5 - ...r-13092-upcoming-features-1763093527692.md | 5 - .../pr-13093-added-1766788494029.md | 5 - ...r-13094-upcoming-features-1763558731421.md | 5 - ...r-13095-upcoming-features-1763130670048.md | 5 - .../pr-13097-added-1763149132736.md | 5 - .../pr-13098-fixed-1763371721536.md | 5 - .../pr-13099-changed-1763377007914.md | 5 - .../pr-13100-fixed-1764265664121.md | 5 - ...r-13104-upcoming-features-1763472761959.md | 5 - .../pr-13107-tests-1763492424120.md | 5 - ...r-13108-upcoming-features-1763746838424.md | 5 - .../pr-13109-fixed-1763535418420.md | 5 - ...r-13110-upcoming-features-1763617588411.md | 5 - ...r-13111-upcoming-features-1763615562636.md | 5 - ...r-13112-upcoming-features-1763627065100.md | 5 - .../pr-13113-fixed-1763631318653.md | 5 - ...r-13115-upcoming-features-1763630712827.md | 5 - ...r-13116-upcoming-features-1763642170773.md | 5 - ...r-13117-upcoming-features-1763642787362.md | 5 - .../pr-13118-fixed-1763722681223.md | 5 - .../pr-13119-tech-stories-1764007553451.md | 5 - .../pr-13119-tech-stories-1764007583363.md | 5 - .../pr-13119-tech-stories-1764007617066.md | 5 - .../pr-13121-fixed-1763734247008.md | 5 - .../pr-13122-changed-1763759617340.md | 5 - ...r-13122-upcoming-features-1763759704504.md | 5 - ...r-13123-upcoming-features-1763724960594.md | 5 - .../pr-13124-changed-1763726824931.md | 5 - ...r-13125-upcoming-features-1763760083439.md | 5 - ...r-13127-upcoming-features-1763979546789.md | 5 - .../pr-13129-fixed-1763987727502.md | 5 - ...r-13130-upcoming-features-1763993150579.md | 5 - .../pr-13131-changed-1763990723303.md | 5 - ...r-13132-upcoming-features-1764056438332.md | 5 - ...r-13133-upcoming-features-1764084041956.md | 5 - ...r-13134-upcoming-features-1764110309888.md | 5 - .../pr-13135-changed-1764659811788.md | 5 - ...r-13137-upcoming-features-1764156818321.md | 5 - ...r-13138-upcoming-features-1764681439890.md | 5 - .../pr-13139-fixed-1764247173880.md | 5 - .../pr-13140-changed-1764317364992.md | 5 - ...r-13141-upcoming-features-1764311100780.md | 5 - .../pr-13142-fixed-1764342267912.md | 5 - .../pr-13143-fixed-1764672257327.md | 5 - ...r-13146-upcoming-features-1764599840190.md | 5 - ...r-13147-upcoming-features-1764592604792.md | 5 - .../pr-13153-fixed-1764672225221.md | 5 - ...r-13156-upcoming-features-1764708766786.md | 5 - ...r-13157-upcoming-features-1764741522593.md | 5 - ...r-13158-upcoming-features-1764752133530.md | 5 - ...r-13164-upcoming-features-1764838771826.md | 5 - ...r-13165-upcoming-features-1764951969980.md | 5 - .../pr-13166-changed-1764854054943.md | 5 - .../pr-13168-fixed-1765192706974.md | 5 - ...r-13169-upcoming-features-1764884932241.md | 5 - ...r-13172-upcoming-features-1764950932547.md | 5 - .../pr-13173-fixed-1764944067009.md | 5 - .../pr-13174-fixed-1764961648716.md | 5 - .../pr-13175-added-1765199020354.md | 5 - packages/manager/CHANGELOG.md | 94 +++++++++++++++++++ packages/manager/package.json | 2 +- ...r-13078-upcoming-features-1762869790174.md | 5 - .../pr-13097-changed-1763149256983.md | 5 - ...r-13148-upcoming-features-1764600420579.md | 5 - packages/queries/CHANGELOG.md | 12 +++ packages/queries/package.json | 2 +- .../pr-13119-tech-stories-1764007518381.md | 5 - packages/utilities/CHANGELOG.md | 7 ++ packages/utilities/package.json | 2 +- ...r-13079-upcoming-features-1762861891552.md | 5 - ...r-13148-upcoming-features-1764600446107.md | 5 - .../pr-13166-changed-1764854111433.md | 5 - packages/validation/CHANGELOG.md | 12 +++ packages/validation/package.json | 2 +- 100 files changed, 152 insertions(+), 455 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md delete mode 100644 packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md delete mode 100644 packages/api-v4/.changeset/pr-13097-changed-1763149227691.md delete mode 100644 packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md delete mode 100644 packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md delete mode 100644 packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md delete mode 100644 packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md delete mode 100644 packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md delete mode 100644 packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md delete mode 100644 packages/api-v4/.changeset/pr-13174-changed-1764961622718.md delete mode 100644 packages/manager/.changeset/pr-13032-changed-1761686860911.md delete mode 100644 packages/manager/.changeset/pr-13057-changed-1762343659819.md delete mode 100644 packages/manager/.changeset/pr-13059-fixed-1762972220664.md delete mode 100644 packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md delete mode 100644 packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md delete mode 100644 packages/manager/.changeset/pr-13075-added-1762776269124.md delete mode 100644 packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md delete mode 100644 packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md delete mode 100644 packages/manager/.changeset/pr-13082-fixed-1762957507469.md delete mode 100644 packages/manager/.changeset/pr-13083-tests-1762969859293.md delete mode 100644 packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md delete mode 100644 packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md delete mode 100644 packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md delete mode 100644 packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md delete mode 100644 packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md delete mode 100644 packages/manager/.changeset/pr-13093-added-1766788494029.md delete mode 100644 packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md delete mode 100644 packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md delete mode 100644 packages/manager/.changeset/pr-13097-added-1763149132736.md delete mode 100644 packages/manager/.changeset/pr-13098-fixed-1763371721536.md delete mode 100644 packages/manager/.changeset/pr-13099-changed-1763377007914.md delete mode 100644 packages/manager/.changeset/pr-13100-fixed-1764265664121.md delete mode 100644 packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md delete mode 100644 packages/manager/.changeset/pr-13107-tests-1763492424120.md delete mode 100644 packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md delete mode 100644 packages/manager/.changeset/pr-13109-fixed-1763535418420.md delete mode 100644 packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md delete mode 100644 packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md delete mode 100644 packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md delete mode 100644 packages/manager/.changeset/pr-13113-fixed-1763631318653.md delete mode 100644 packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md delete mode 100644 packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md delete mode 100644 packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md delete mode 100644 packages/manager/.changeset/pr-13118-fixed-1763722681223.md delete mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md delete mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md delete mode 100644 packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md delete mode 100644 packages/manager/.changeset/pr-13121-fixed-1763734247008.md delete mode 100644 packages/manager/.changeset/pr-13122-changed-1763759617340.md delete mode 100644 packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md delete mode 100644 packages/manager/.changeset/pr-13123-upcoming-features-1763724960594.md delete mode 100644 packages/manager/.changeset/pr-13124-changed-1763726824931.md delete mode 100644 packages/manager/.changeset/pr-13125-upcoming-features-1763760083439.md delete mode 100644 packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md delete mode 100644 packages/manager/.changeset/pr-13129-fixed-1763987727502.md delete mode 100644 packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md delete mode 100644 packages/manager/.changeset/pr-13131-changed-1763990723303.md delete mode 100644 packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md delete mode 100644 packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md delete mode 100644 packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md delete mode 100644 packages/manager/.changeset/pr-13135-changed-1764659811788.md delete mode 100644 packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md delete mode 100644 packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md delete mode 100644 packages/manager/.changeset/pr-13139-fixed-1764247173880.md delete mode 100644 packages/manager/.changeset/pr-13140-changed-1764317364992.md delete mode 100644 packages/manager/.changeset/pr-13141-upcoming-features-1764311100780.md delete mode 100644 packages/manager/.changeset/pr-13142-fixed-1764342267912.md delete mode 100644 packages/manager/.changeset/pr-13143-fixed-1764672257327.md delete mode 100644 packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md delete mode 100644 packages/manager/.changeset/pr-13147-upcoming-features-1764592604792.md delete mode 100644 packages/manager/.changeset/pr-13153-fixed-1764672225221.md delete mode 100644 packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md delete mode 100644 packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md delete mode 100644 packages/manager/.changeset/pr-13158-upcoming-features-1764752133530.md delete mode 100644 packages/manager/.changeset/pr-13164-upcoming-features-1764838771826.md delete mode 100644 packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md delete mode 100644 packages/manager/.changeset/pr-13166-changed-1764854054943.md delete mode 100644 packages/manager/.changeset/pr-13168-fixed-1765192706974.md delete mode 100644 packages/manager/.changeset/pr-13169-upcoming-features-1764884932241.md delete mode 100644 packages/manager/.changeset/pr-13172-upcoming-features-1764950932547.md delete mode 100644 packages/manager/.changeset/pr-13173-fixed-1764944067009.md delete mode 100644 packages/manager/.changeset/pr-13174-fixed-1764961648716.md delete mode 100644 packages/manager/.changeset/pr-13175-added-1765199020354.md delete mode 100644 packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md delete mode 100644 packages/queries/.changeset/pr-13097-changed-1763149256983.md delete mode 100644 packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md delete mode 100644 packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md delete mode 100644 packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md delete mode 100644 packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md delete mode 100644 packages/validation/.changeset/pr-13166-changed-1764854111433.md diff --git a/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md b/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md deleted file mode 100644 index f671a53b5a9..00000000000 --- a/packages/api-v4/.changeset/pr-13078-upcoming-features-1762869732236.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add new API endpoints and types for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) diff --git a/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md b/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md deleted file mode 100644 index a9fa484079f..00000000000 --- a/packages/api-v4/.changeset/pr-13079-upcoming-features-1762861826541.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Update FirewallRuleType to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) diff --git a/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md b/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md deleted file mode 100644 index a64d20f3418..00000000000 --- a/packages/api-v4/.changeset/pr-13097-changed-1763149227691.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Update database restoreWithBackup data to include region ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md b/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md deleted file mode 100644 index db0f148184b..00000000000 --- a/packages/api-v4/.changeset/pr-13119-tech-stories-1764007445902.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Tech Stories ---- - -Add `@types/node` as a devDependency ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md b/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md deleted file mode 100644 index 82561a7e436..00000000000 --- a/packages/api-v4/.changeset/pr-13127-upcoming-features-1763979506001.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add additional status types `enabling`, `disabling`, `provisioning` in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) diff --git a/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md b/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md deleted file mode 100644 index 3e22f0af003..00000000000 --- a/packages/api-v4/.changeset/pr-13133-upcoming-features-1764084018326.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -CloudPulse-Metrics: Update `entity_ids` type in `CloudPulseMetricsRequest` for metrics api in endpoints dahsboard ([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md b/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md deleted file mode 100644 index e0cb2c1aa43..00000000000 --- a/packages/api-v4/.changeset/pr-13146-upcoming-features-1764600095421.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add `deleted` property to `FirewallPrefixList` type after API update ([#13146](https://github.com/linode/manager/pull/13146)) diff --git a/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md b/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md deleted file mode 100644 index 545cf23d859..00000000000 --- a/packages/api-v4/.changeset/pr-13148-upcoming-features-1764600387228.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Added Database Connection Pool types and endpoints ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md b/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md deleted file mode 100644 index 991b3820fb3..00000000000 --- a/packages/api-v4/.changeset/pr-13156-upcoming-features-1764708047549.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Add 'Cloud Firewall Rule Set' to AccountCapability type ([#13156](https://github.com/linode/manager/pull/13156)) diff --git a/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md b/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md deleted file mode 100644 index fe886996b9b..00000000000 --- a/packages/api-v4/.changeset/pr-13174-changed-1764961622718.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Add private_network to `DatabaseBackupsPayload` ([#13174](https://github.com/linode/manager/pull/13174)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index f290607bda5..8fd23ca90a8 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,25 @@ +## [2025-12-10] - v0.154.0 + + +### Changed: + +- Update database restoreWithBackup data to include region ([#13097](https://github.com/linode/manager/pull/13097)) +- Add private_network to `DatabaseBackupsPayload` ([#13174](https://github.com/linode/manager/pull/13174)) + +### Tech Stories: + +- Add `@types/node` as a devDependency ([#13119](https://github.com/linode/manager/pull/13119)) + +### Upcoming Features: + +- Add new API endpoints and types for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) +- Update FirewallRuleType to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) +- Add additional status types `enabling`, `disabling`, `provisioning` in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) +- CloudPulse-Metrics: Update `entity_ids` type in `CloudPulseMetricsRequest` for metrics api in endpoints dahsboard ([#13133](https://github.com/linode/manager/pull/13133)) +- Add `deleted` property to `FirewallPrefixList` type after API update ([#13146](https://github.com/linode/manager/pull/13146)) +- Added Database Connection Pool types and endpoints ([#13148](https://github.com/linode/manager/pull/13148)) +- Add 'Cloud Firewall Rule Set' to AccountCapability type ([#13156](https://github.com/linode/manager/pull/13156)) + ## [2025-11-18] - v0.153.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 033074da041..bc12f54b67b 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.153.0", + "version": "0.154.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-13032-changed-1761686860911.md b/packages/manager/.changeset/pr-13032-changed-1761686860911.md deleted file mode 100644 index dcdc79dea36..00000000000 --- a/packages/manager/.changeset/pr-13032-changed-1761686860911.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update icon svg files to match with Akamai design system ([#13032](https://github.com/linode/manager/pull/13032)) diff --git a/packages/manager/.changeset/pr-13057-changed-1762343659819.md b/packages/manager/.changeset/pr-13057-changed-1762343659819.md deleted file mode 100644 index e50a5c0e329..00000000000 --- a/packages/manager/.changeset/pr-13057-changed-1762343659819.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -DBaaS: Replace the dropdowns in Database cluster settings page with CDS select web component ([#13057](https://github.com/linode/manager/pull/13057)) diff --git a/packages/manager/.changeset/pr-13059-fixed-1762972220664.md b/packages/manager/.changeset/pr-13059-fixed-1762972220664.md deleted file mode 100644 index 8279d18f00f..00000000000 --- a/packages/manager/.changeset/pr-13059-fixed-1762972220664.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Fix incorrect maintenance time display in the Upcoming maintenance table ([#13059](https://github.com/linode/manager/pull/13059)) diff --git a/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md b/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md deleted file mode 100644 index 04211c454c6..00000000000 --- a/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Replace Formik with React Hook Form in MaintenanceWindow ([#13060](https://github.com/linode/manager/pull/13060)) diff --git a/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md b/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md deleted file mode 100644 index 47909ba6d28..00000000000 --- a/packages/manager/.changeset/pr-13068-upcoming-features-1762930872294.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Implement feature flag and routing for NLB ([#13068](https://github.com/linode/manager/pull/13068)) diff --git a/packages/manager/.changeset/pr-13075-added-1762776269124.md b/packages/manager/.changeset/pr-13075-added-1762776269124.md deleted file mode 100644 index ed452d6cbe3..00000000000 --- a/packages/manager/.changeset/pr-13075-added-1762776269124.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -IAM Parent/Child: permissions switch account ([#13075](https://github.com/linode/manager/pull/13075)) diff --git a/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md b/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md deleted file mode 100644 index 63b2dd22823..00000000000 --- a/packages/manager/.changeset/pr-13079-upcoming-features-1762861957439.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add new Firewall RuleSet row layout ([#13079](https://github.com/linode/manager/pull/13079)) diff --git a/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md b/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md deleted file mode 100644 index b65763c0fc7..00000000000 --- a/packages/manager/.changeset/pr-13081-upcoming-features-1762935895154.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Disable premium plan tab if corresponding g7 dedicated plans are available ([#13081](https://github.com/linode/manager/pull/13081)) diff --git a/packages/manager/.changeset/pr-13082-fixed-1762957507469.md b/packages/manager/.changeset/pr-13082-fixed-1762957507469.md deleted file mode 100644 index 004e1cb6473..00000000000 --- a/packages/manager/.changeset/pr-13082-fixed-1762957507469.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM: the aria-label for the Users table action menu displays an incorrect username ([#13082](https://github.com/linode/manager/pull/13082)) diff --git a/packages/manager/.changeset/pr-13083-tests-1762969859293.md b/packages/manager/.changeset/pr-13083-tests-1762969859293.md deleted file mode 100644 index 9b91e05e390..00000000000 --- a/packages/manager/.changeset/pr-13083-tests-1762969859293.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix flakey vm-host test ([#13083](https://github.com/linode/manager/pull/13083)) diff --git a/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md b/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md deleted file mode 100644 index 96635443a16..00000000000 --- a/packages/manager/.changeset/pr-13087-upcoming-features-1763109384091.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Object storage summary page migrated to use table view ([#13087](https://github.com/linode/manager/pull/13087)) diff --git a/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md b/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md deleted file mode 100644 index 7e8f72e9126..00000000000 --- a/packages/manager/.changeset/pr-13088-upcoming-features-1763032920807.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Scaffolding setup for widget level dimension filters in cloudpulse metrics and group by issue fix in cloudpulse metrics ([#13088](https://github.com/linode/manager/pull/13088)) diff --git a/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md b/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md deleted file mode 100644 index 0ee6419cf61..00000000000 --- a/packages/manager/.changeset/pr-13089-upcoming-features-1763100678421.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Integrate Firewall-nodebalancer support for ACLP-Alerting ([#13089](https://github.com/linode/manager/pull/13089)) diff --git a/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md b/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md deleted file mode 100644 index 85af714871c..00000000000 --- a/packages/manager/.changeset/pr-13090-upcoming-features-1763045734612.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add tooltip for Rules column header in Firewall Rules table ([#13090](https://github.com/linode/manager/pull/13090)) diff --git a/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md b/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md deleted file mode 100644 index 3d6050b3391..00000000000 --- a/packages/manager/.changeset/pr-13092-upcoming-features-1763093527692.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Enhance `CloudPulseWidgetUtils.ts` to handle id to label conversion of linode associated with volume in volumes service ([#13092](https://github.com/linode/manager/pull/13092)) diff --git a/packages/manager/.changeset/pr-13093-added-1766788494029.md b/packages/manager/.changeset/pr-13093-added-1766788494029.md deleted file mode 100644 index 401b517416d..00000000000 --- a/packages/manager/.changeset/pr-13093-added-1766788494029.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Implement Filtering for Plans table ([#13093](https://github.com/linode/manager/pull/13093)) diff --git a/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md b/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md deleted file mode 100644 index 825c65ad24d..00000000000 --- a/packages/manager/.changeset/pr-13094-upcoming-features-1763558731421.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update Firewall Rule Drawer to support referencing Rule Set ([#13094](https://github.com/linode/manager/pull/13094)) diff --git a/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md b/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md deleted file mode 100644 index 6236a2c67b8..00000000000 --- a/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Edit Stream form: remove cluster IDs from the edited stream that no longer exist or have log generation disabled ([#13095](https://github.com/linode/manager/pull/13095)) diff --git a/packages/manager/.changeset/pr-13097-added-1763149132736.md b/packages/manager/.changeset/pr-13097-added-1763149132736.md deleted file mode 100644 index 8759fd74af1..00000000000 --- a/packages/manager/.changeset/pr-13097-added-1763149132736.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Region select to Database Backups tab ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/manager/.changeset/pr-13098-fixed-1763371721536.md b/packages/manager/.changeset/pr-13098-fixed-1763371721536.md deleted file mode 100644 index c7e5164cde8..00000000000 --- a/packages/manager/.changeset/pr-13098-fixed-1763371721536.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Alignment with Linode row backup cell icon ([#13098](https://github.com/linode/manager/pull/13098)) diff --git a/packages/manager/.changeset/pr-13099-changed-1763377007914.md b/packages/manager/.changeset/pr-13099-changed-1763377007914.md deleted file mode 100644 index 7a0e0ee8287..00000000000 --- a/packages/manager/.changeset/pr-13099-changed-1763377007914.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -IAM: fix permission check for detaching volumes ([#13099](https://github.com/linode/manager/pull/13099)) diff --git a/packages/manager/.changeset/pr-13100-fixed-1764265664121.md b/packages/manager/.changeset/pr-13100-fixed-1764265664121.md deleted file mode 100644 index 4371d0b1da5..00000000000 --- a/packages/manager/.changeset/pr-13100-fixed-1764265664121.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Plans panel pagination bug fix ([#13100](https://github.com/linode/manager/pull/13100)) diff --git a/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md b/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md deleted file mode 100644 index ff43e8785fe..00000000000 --- a/packages/manager/.changeset/pr-13104-upcoming-features-1763472761959.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - - Implement mocks and factories for Network LoadBalancer ([#13104](https://github.com/linode/manager/pull/13104)) diff --git a/packages/manager/.changeset/pr-13107-tests-1763492424120.md b/packages/manager/.changeset/pr-13107-tests-1763492424120.md deleted file mode 100644 index 5f1b879f190..00000000000 --- a/packages/manager/.changeset/pr-13107-tests-1763492424120.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fixed various test failures when running tests against Prod environment ([#13107](https://github.com/linode/manager/pull/13107)) diff --git a/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md b/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md deleted file mode 100644 index cd7c25bc363..00000000000 --- a/packages/manager/.changeset/pr-13108-upcoming-features-1763746838424.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -New Rule Set Details drawer with Marked for Deletion status ([#13108](https://github.com/linode/manager/pull/13108)) diff --git a/packages/manager/.changeset/pr-13109-fixed-1763535418420.md b/packages/manager/.changeset/pr-13109-fixed-1763535418420.md deleted file mode 100644 index 5ed542147bf..00000000000 --- a/packages/manager/.changeset/pr-13109-fixed-1763535418420.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -The `firewall_id` error on LKE pool update ([#13109](https://github.com/linode/manager/pull/13109)) diff --git a/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md b/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md deleted file mode 100644 index 17bae8d23d4..00000000000 --- a/packages/manager/.changeset/pr-13110-upcoming-features-1763617588411.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -ACLP-Alerting: Filtering entities for firewall system alerts, add tooltip text to Entity Type component ([#13110](https://github.com/linode/manager/pull/13110)) diff --git a/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md b/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md deleted file mode 100644 index 7fc2ab5ef65..00000000000 --- a/packages/manager/.changeset/pr-13111-upcoming-features-1763615562636.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Remove filtering of firewalls and region filter dependency on firewall-select in Firewalls ([#13111](https://github.com/linode/manager/pull/13111)) diff --git a/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md b/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md deleted file mode 100644 index bc0da4f2abb..00000000000 --- a/packages/manager/.changeset/pr-13112-upcoming-features-1763627065100.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns ([#13112](https://github.com/linode/manager/pull/13112)) diff --git a/packages/manager/.changeset/pr-13113-fixed-1763631318653.md b/packages/manager/.changeset/pr-13113-fixed-1763631318653.md deleted file mode 100644 index 5789752b6ca..00000000000 --- a/packages/manager/.changeset/pr-13113-fixed-1763631318653.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Disabled Tab + Tooltip styles & accessibility ([#13113](https://github.com/linode/manager/pull/13113)) diff --git a/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md b/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md deleted file mode 100644 index 881f5d29a08..00000000000 --- a/packages/manager/.changeset/pr-13115-upcoming-features-1763630712827.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Implement filter for GPU plans in plans panel ([#13115](https://github.com/linode/manager/pull/13115)) diff --git a/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md b/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md deleted file mode 100644 index a3617688321..00000000000 --- a/packages/manager/.changeset/pr-13116-upcoming-features-1763642170773.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add integration changes with `CloudPulseWidget` for widget level dimension support in CloudPulse metrics ([#13116](https://github.com/linode/manager/pull/13116)) diff --git a/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md b/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md deleted file mode 100644 index 9467c4a06c1..00000000000 --- a/packages/manager/.changeset/pr-13117-upcoming-features-1763642787362.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Destination Form: fixes and improvements for Sample Destination Object Name ([#13117](https://github.com/linode/manager/pull/13117)) diff --git a/packages/manager/.changeset/pr-13118-fixed-1763722681223.md b/packages/manager/.changeset/pr-13118-fixed-1763722681223.md deleted file mode 100644 index 491ee99f5d2..00000000000 --- a/packages/manager/.changeset/pr-13118-fixed-1763722681223.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM: The StackScript/Linode selector is enabled in the Create Linode flow when the user doesn’t have the create_linode permission ([#13118](https://github.com/linode/manager/pull/13118)) diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md deleted file mode 100644 index 9141261a386..00000000000 --- a/packages/manager/.changeset/pr-13119-tech-stories-1764007553451.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update Vite from `7.1.11` to `7.2.2` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md deleted file mode 100644 index 65133e69b95..00000000000 --- a/packages/manager/.changeset/pr-13119-tech-stories-1764007583363.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Fix circular imports in CloudPulse ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md b/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md deleted file mode 100644 index d79f961c70c..00000000000 --- a/packages/manager/.changeset/pr-13119-tech-stories-1764007617066.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update vitest from `v3` to `v4` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/manager/.changeset/pr-13121-fixed-1763734247008.md b/packages/manager/.changeset/pr-13121-fixed-1763734247008.md deleted file mode 100644 index 30842b6ced6..00000000000 --- a/packages/manager/.changeset/pr-13121-fixed-1763734247008.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -DBaaS - Manage Networking VPC fields not handling error response ([#13121](https://github.com/linode/manager/pull/13121)) diff --git a/packages/manager/.changeset/pr-13122-changed-1763759617340.md b/packages/manager/.changeset/pr-13122-changed-1763759617340.md deleted file mode 100644 index 06175a04a56..00000000000 --- a/packages/manager/.changeset/pr-13122-changed-1763759617340.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Move Action column to the 2nd position in the Firewall Rules Table ([#13122](https://github.com/linode/manager/pull/13122)) diff --git a/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md b/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md deleted file mode 100644 index e05470547d8..00000000000 --- a/packages/manager/.changeset/pr-13122-upcoming-features-1763759704504.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add `generateAddressesLabelV2` utility to support PrefixLists ([#13122](https://github.com/linode/manager/pull/13122)) diff --git a/packages/manager/.changeset/pr-13123-upcoming-features-1763724960594.md b/packages/manager/.changeset/pr-13123-upcoming-features-1763724960594.md deleted file mode 100644 index 718a57bd746..00000000000 --- a/packages/manager/.changeset/pr-13123-upcoming-features-1763724960594.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Implement Listeners Table in Network LoadBalancer Detail page ([#13123](https://github.com/linode/manager/pull/13123)) diff --git a/packages/manager/.changeset/pr-13124-changed-1763726824931.md b/packages/manager/.changeset/pr-13124-changed-1763726824931.md deleted file mode 100644 index 6e3faa3a17b..00000000000 --- a/packages/manager/.changeset/pr-13124-changed-1763726824931.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Await permissions before rendering Linode Detail Header ([#13124](https://github.com/linode/manager/pull/13124)) diff --git a/packages/manager/.changeset/pr-13125-upcoming-features-1763760083439.md b/packages/manager/.changeset/pr-13125-upcoming-features-1763760083439.md deleted file mode 100644 index aab9ee71357..00000000000 --- a/packages/manager/.changeset/pr-13125-upcoming-features-1763760083439.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Use new JSON-based fwRulesetsPrefixLists feature flag for Firewall RuleSets and Prefix Lists feature ([#13125](https://github.com/linode/manager/pull/13125)) diff --git a/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md b/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md deleted file mode 100644 index 8ca707c6855..00000000000 --- a/packages/manager/.changeset/pr-13127-upcoming-features-1763979546789.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add support for additional status types and handle action menu accordingly in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) diff --git a/packages/manager/.changeset/pr-13129-fixed-1763987727502.md b/packages/manager/.changeset/pr-13129-fixed-1763987727502.md deleted file mode 100644 index 91597d4e0b1..00000000000 --- a/packages/manager/.changeset/pr-13129-fixed-1763987727502.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM: filtering by entity type at the Roles table ([#13129](https://github.com/linode/manager/pull/13129)) diff --git a/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md b/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md deleted file mode 100644 index 6a8e6659ad0..00000000000 --- a/packages/manager/.changeset/pr-13130-upcoming-features-1763993150579.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add a Network Load Balancer Listener detail page (EntityDetail paper) with breadcrumbs ([#13130](https://github.com/linode/manager/pull/13130)) diff --git a/packages/manager/.changeset/pr-13131-changed-1763990723303.md b/packages/manager/.changeset/pr-13131-changed-1763990723303.md deleted file mode 100644 index 50ac5d0e771..00000000000 --- a/packages/manager/.changeset/pr-13131-changed-1763990723303.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Legal sign off in Logs Streams Create Checkout bar ([#13131](https://github.com/linode/manager/pull/13131)) diff --git a/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md b/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md deleted file mode 100644 index 5f5fea86ba1..00000000000 --- a/packages/manager/.changeset/pr-13132-upcoming-features-1764056438332.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - - Implement Empty Landing State for Network Load Balancers ([#13132](https://github.com/linode/manager/pull/13132)) diff --git a/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md b/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md deleted file mode 100644 index 3f2f8c5bc68..00000000000 --- a/packages/manager/.changeset/pr-13133-upcoming-features-1764084041956.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Update `FilterConfig.ts` to handle integration of endpoints dashboard for object-storage service in metrics page([#13133](https://github.com/linode/manager/pull/13133)) diff --git a/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md b/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md deleted file mode 100644 index 7f7b3ff0afc..00000000000 --- a/packages/manager/.changeset/pr-13134-upcoming-features-1764110309888.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add feature flag support for PgBouncer in DBaaS ([#13134](https://github.com/linode/manager/pull/13134)) diff --git a/packages/manager/.changeset/pr-13135-changed-1764659811788.md b/packages/manager/.changeset/pr-13135-changed-1764659811788.md deleted file mode 100644 index 4055f36aa11..00000000000 --- a/packages/manager/.changeset/pr-13135-changed-1764659811788.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - - Add Chip Support to Drawer Component Title ([#13135](https://github.com/linode/manager/pull/13135)) diff --git a/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md b/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md deleted file mode 100644 index b21f0da7b01..00000000000 --- a/packages/manager/.changeset/pr-13137-upcoming-features-1764156818321.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -ACLP-Alerting: Update aclpAlerting flag to have beta marker control ([#13137](https://github.com/linode/manager/pull/13137)) diff --git a/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md b/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md deleted file mode 100644 index 35a420a4964..00000000000 --- a/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection ([#13138](https://github.com/linode/manager/pull/13138)) diff --git a/packages/manager/.changeset/pr-13139-fixed-1764247173880.md b/packages/manager/.changeset/pr-13139-fixed-1764247173880.md deleted file mode 100644 index fb971eb3f3e..00000000000 --- a/packages/manager/.changeset/pr-13139-fixed-1764247173880.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -CloudPulse metrics volumes contextual view `not showing dimension values` and CloudPulse metrics `group by default selection retention` ([#13139](https://github.com/linode/manager/pull/13139)) diff --git a/packages/manager/.changeset/pr-13140-changed-1764317364992.md b/packages/manager/.changeset/pr-13140-changed-1764317364992.md deleted file mode 100644 index c983c60cbca..00000000000 --- a/packages/manager/.changeset/pr-13140-changed-1764317364992.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Logs Delivery UI changes after review ([#13140](https://github.com/linode/manager/pull/13140)) diff --git a/packages/manager/.changeset/pr-13141-upcoming-features-1764311100780.md b/packages/manager/.changeset/pr-13141-upcoming-features-1764311100780.md deleted file mode 100644 index c216c66363d..00000000000 --- a/packages/manager/.changeset/pr-13141-upcoming-features-1764311100780.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Add tooltip for clusters filter in lke and fix preferences bug for nodebalancers filter in firewall-nodebalancer dashboard ([#13141](https://github.com/linode/manager/pull/13141)) diff --git a/packages/manager/.changeset/pr-13142-fixed-1764342267912.md b/packages/manager/.changeset/pr-13142-fixed-1764342267912.md deleted file mode 100644 index 5cff2a9d457..00000000000 --- a/packages/manager/.changeset/pr-13142-fixed-1764342267912.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM: disable/enable fields based on create_linode permission ([#13142](https://github.com/linode/manager/pull/13142)) diff --git a/packages/manager/.changeset/pr-13143-fixed-1764672257327.md b/packages/manager/.changeset/pr-13143-fixed-1764672257327.md deleted file mode 100644 index d5cb5c3d6fd..00000000000 --- a/packages/manager/.changeset/pr-13143-fixed-1764672257327.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - - IAM Permissions performance improvements: Create from Backup & Clone ([#13143](https://github.com/linode/manager/pull/13143)) diff --git a/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md b/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md deleted file mode 100644 index ebf9b5fe305..00000000000 --- a/packages/manager/.changeset/pr-13146-upcoming-features-1764599840190.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add and Integrate Prefix List Details Drawer ([#13146](https://github.com/linode/manager/pull/13146)) diff --git a/packages/manager/.changeset/pr-13147-upcoming-features-1764592604792.md b/packages/manager/.changeset/pr-13147-upcoming-features-1764592604792.md deleted file mode 100644 index 460d9a7e704..00000000000 --- a/packages/manager/.changeset/pr-13147-upcoming-features-1764592604792.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Implement Nodes table in Network LoadBalancer Listener detail page ([#13147](https://github.com/linode/manager/pull/13147)) diff --git a/packages/manager/.changeset/pr-13153-fixed-1764672225221.md b/packages/manager/.changeset/pr-13153-fixed-1764672225221.md deleted file mode 100644 index a3fcbd7ffb7..00000000000 --- a/packages/manager/.changeset/pr-13153-fixed-1764672225221.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -IAM Permissions performance improvements: Firewall entity assignment ([#13153](https://github.com/linode/manager/pull/13153)) diff --git a/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md b/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md deleted file mode 100644 index 085d0c3dbda..00000000000 --- a/packages/manager/.changeset/pr-13156-upcoming-features-1764708766786.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update useIsFirewallRulesetsPrefixlistsEnabled() to factor in account capability ([#13156](https://github.com/linode/manager/pull/13156)) diff --git a/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md b/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md deleted file mode 100644 index f91b3a5d2f5..00000000000 --- a/packages/manager/.changeset/pr-13157-upcoming-features-1764741522593.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -CloudPulse-Metrics: Update tooltip msg for `Clusters` filter in LKE service dashboard ([#13157](https://github.com/linode/manager/pull/13157)) diff --git a/packages/manager/.changeset/pr-13158-upcoming-features-1764752133530.md b/packages/manager/.changeset/pr-13158-upcoming-features-1764752133530.md deleted file mode 100644 index 5775b0813cf..00000000000 --- a/packages/manager/.changeset/pr-13158-upcoming-features-1764752133530.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Integrate Prefix List details drawer with Edit and Add Rule drawer ([#13158](https://github.com/linode/manager/pull/13158)) diff --git a/packages/manager/.changeset/pr-13164-upcoming-features-1764838771826.md b/packages/manager/.changeset/pr-13164-upcoming-features-1764838771826.md deleted file mode 100644 index 8276febb6be..00000000000 --- a/packages/manager/.changeset/pr-13164-upcoming-features-1764838771826.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Beta/New feature Chip support for RuleSets and Prefix Lists ([#13164](https://github.com/linode/manager/pull/13164)) diff --git a/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md b/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md deleted file mode 100644 index ae7b46a0c05..00000000000 --- a/packages/manager/.changeset/pr-13165-upcoming-features-1764951969980.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -UX/UI enhancements for RuleSets and Prefix Lists ([#13165](https://github.com/linode/manager/pull/13165)) diff --git a/packages/manager/.changeset/pr-13166-changed-1764854054943.md b/packages/manager/.changeset/pr-13166-changed-1764854054943.md deleted file mode 100644 index b292049b2a3..00000000000 --- a/packages/manager/.changeset/pr-13166-changed-1764854054943.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Logs: Many minor UI fixes and improvements ([#13166](https://github.com/linode/manager/pull/13166)) diff --git a/packages/manager/.changeset/pr-13168-fixed-1765192706974.md b/packages/manager/.changeset/pr-13168-fixed-1765192706974.md deleted file mode 100644 index ddd0821e97b..00000000000 --- a/packages/manager/.changeset/pr-13168-fixed-1765192706974.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -EntitiesSelect performance on large accounts ([#13168](https://github.com/linode/manager/pull/13168)) diff --git a/packages/manager/.changeset/pr-13169-upcoming-features-1764884932241.md b/packages/manager/.changeset/pr-13169-upcoming-features-1764884932241.md deleted file mode 100644 index c7d3a1b5e6f..00000000000 --- a/packages/manager/.changeset/pr-13169-upcoming-features-1764884932241.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Ensure a firewall can only reference a RuleSet once ([#13169](https://github.com/linode/manager/pull/13169)) diff --git a/packages/manager/.changeset/pr-13172-upcoming-features-1764950932547.md b/packages/manager/.changeset/pr-13172-upcoming-features-1764950932547.md deleted file mode 100644 index 38a2c3a6fc3..00000000000 --- a/packages/manager/.changeset/pr-13172-upcoming-features-1764950932547.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Handle special PLs in PrefixList drawer ([#13172](https://github.com/linode/manager/pull/13172)) diff --git a/packages/manager/.changeset/pr-13173-fixed-1764944067009.md b/packages/manager/.changeset/pr-13173-fixed-1764944067009.md deleted file mode 100644 index 771b1923966..00000000000 --- a/packages/manager/.changeset/pr-13173-fixed-1764944067009.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Optimize rendering of entities in AssignedRolesTable ([#13173](https://github.com/linode/manager/pull/13173)) diff --git a/packages/manager/.changeset/pr-13174-fixed-1764961648716.md b/packages/manager/.changeset/pr-13174-fixed-1764961648716.md deleted file mode 100644 index 0deeb1ac90e..00000000000 --- a/packages/manager/.changeset/pr-13174-fixed-1764961648716.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Forking a Database Cluster with VPC into another region ([#13174](https://github.com/linode/manager/pull/13174)) diff --git a/packages/manager/.changeset/pr-13175-added-1765199020354.md b/packages/manager/.changeset/pr-13175-added-1765199020354.md deleted file mode 100644 index 0dfe282277d..00000000000 --- a/packages/manager/.changeset/pr-13175-added-1765199020354.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -IAM "New" and "Limited Availability" badges ([#13175](https://github.com/linode/manager/pull/13175)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index dd5ff6f1a90..e8fabe978eb 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,100 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-12-10] - v1.156.0 + + +### Added: + +- IAM Parent/Child: permissions switch account ([#13075](https://github.com/linode/manager/pull/13075)) +- Region select to Database Backups tab ([#13097](https://github.com/linode/manager/pull/13097)) +- IAM "New" and "Limited Availability" badges ([#13175](https://github.com/linode/manager/pull/13175)) + +### Changed: + +- Update icon svg files to match with Akamai design system ([#13032](https://github.com/linode/manager/pull/13032)) +- IAM: fix permission check for detaching volumes ([#13099](https://github.com/linode/manager/pull/13099)) +- Move Action column to the 2nd position in the Firewall Rules Table ([#13122](https://github.com/linode/manager/pull/13122)) +- Await permissions before rendering Linode Detail Header ([#13124](https://github.com/linode/manager/pull/13124)) +- Legal sign off in Logs Streams Create Checkout bar ([#13131](https://github.com/linode/manager/pull/13131)) +- Add Chip Support to Drawer Component Title ([#13135](https://github.com/linode/manager/pull/13135)) +- Logs Delivery UI changes after review ([#13140](https://github.com/linode/manager/pull/13140)) +- Logs: Many minor UI fixes and improvements ([#13166](https://github.com/linode/manager/pull/13166)) + +### Fixed: + +- Fix incorrect maintenance time display in the Upcoming maintenance table ([#13059](https://github.com/linode/manager/pull/13059)) +- IAM: the aria-label for the Users table action menu displays an incorrect username ([#13082](https://github.com/linode/manager/pull/13082)) +- Alignment with Linode row backup cell icon ([#13098](https://github.com/linode/manager/pull/13098)) +- Plans panel pagination bug fix ([#13100](https://github.com/linode/manager/pull/13100)) +- The `firewall_id` error on LKE pool update ([#13109](https://github.com/linode/manager/pull/13109)) +- Disabled Tab + Tooltip styles & accessibility ([#13113](https://github.com/linode/manager/pull/13113)) +- IAM: The StackScript/Linode selector is enabled in the Create Linode flow when the user doesn’t have the create_linode permission ([#13118](https://github.com/linode/manager/pull/13118)) +- DBaaS - Manage Networking VPC fields not handling error response ([#13121](https://github.com/linode/manager/pull/13121)) +- IAM: filtering by entity type at the Roles table ([#13129](https://github.com/linode/manager/pull/13129)) +- CloudPulse metrics volumes contextual view `not showing dimension values` and CloudPulse metrics `group by default selection retention` ([#13139](https://github.com/linode/manager/pull/13139)) +- IAM: disable/enable fields based on create_linode permission ([#13142](https://github.com/linode/manager/pull/13142)) +- IAM Permissions performance improvements: Create from Backup & Clone ([#13143](https://github.com/linode/manager/pull/13143)) +- IAM Permissions performance improvements: Firewall entity assignment ([#13153](https://github.com/linode/manager/pull/13153)) +- EntitiesSelect performance on large accounts ([#13168](https://github.com/linode/manager/pull/13168)) +- Optimize rendering of entities in AssignedRolesTable ([#13173](https://github.com/linode/manager/pull/13173)) +- Forking a Database Cluster with VPC into another region ([#13174](https://github.com/linode/manager/pull/13174)) + +### Tech Stories: + +- DBaaS: Replace the dropdowns in Database cluster settings page with CDS select web component ([#13057](https://github.com/linode/manager/pull/13057)) +- Replace Formik with React Hook Form in MaintenanceWindow ([#13060](https://github.com/linode/manager/pull/13060)) +- Update Vite from `7.1.11` to `7.2.2` ([#13119](https://github.com/linode/manager/pull/13119)) +- Fix circular imports in CloudPulse ([#13119](https://github.com/linode/manager/pull/13119)) +- Update vitest from `v3` to `v4` ([#13119](https://github.com/linode/manager/pull/13119)) + +### Tests: + +- Fix flakey vm-host test ([#13083](https://github.com/linode/manager/pull/13083)) +- Fixed various test failures when running tests against Prod environment ([#13107](https://github.com/linode/manager/pull/13107)) + +### Upcoming Features: + +- Implement feature flag and routing for NLB ([#13068](https://github.com/linode/manager/pull/13068)) +- Add new Firewall RuleSet row layout ([#13079](https://github.com/linode/manager/pull/13079)) +- Disable premium plan tab if corresponding g7 dedicated plans are available ([#13081](https://github.com/linode/manager/pull/13081)) +- Object storage summary page migrated to use table view ([#13087](https://github.com/linode/manager/pull/13087)) +- Scaffolding setup for widget level dimension filters in cloudpulse metrics and group by issue fix in cloudpulse metrics ([#13088](https://github.com/linode/manager/pull/13088)) +- Integrate Firewall-nodebalancer support for ACLP-Alerting ([#13089](https://github.com/linode/manager/pull/13089)) +- Add tooltip for Rules column header in Firewall Rules table ([#13090](https://github.com/linode/manager/pull/13090)) +- CloudPulse-Metrics: Enhance `CloudPulseWidgetUtils.ts` to handle id to label conversion of linode associated with volume in volumes service ([#13092](https://github.com/linode/manager/pull/13092)) +- Implement Filtering for Plans table ([#13093](https://github.com/linode/manager/pull/13093)) +- Update Firewall Rule Drawer to support referencing Rule Set ([#13094](https://github.com/linode/manager/pull/13094)) +- Edit Stream form: remove cluster IDs from the edited stream that no longer exist or have log generation disabled ([#13095](https://github.com/linode/manager/pull/13095)) +- Implement mocks and factories for Network LoadBalancer ([#13104](https://github.com/linode/manager/pull/13104)) +- New Rule Set Details drawer with Marked for Deletion status ([#13108](https://github.com/linode/manager/pull/13108)) +- ACLP-Alerting: Filtering entities for firewall system alerts, add tooltip text to Entity Type component ([#13110](https://github.com/linode/manager/pull/13110)) +- CloudPulse-Metrics: Remove filtering of firewalls and region filter dependency on firewall-select in Firewalls ([#13111](https://github.com/linode/manager/pull/13111)) +- Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns ([#13112](https://github.com/linode/manager/pull/13112)) +- Implement filter for GPU plans in plans panel ([#13115](https://github.com/linode/manager/pull/13115)) +- Add integration changes with `CloudPulseWidget` for widget level dimension support in CloudPulse metrics ([#13116](https://github.com/linode/manager/pull/13116)) +- Destination Form: fixes and improvements for Sample Destination Object Name ([#13117](https://github.com/linode/manager/pull/13117)) +- Add `generateAddressesLabelV2` utility to support PrefixLists ([#13122](https://github.com/linode/manager/pull/13122)) +- Implement Listeners Table in Network LoadBalancer Detail page ([#13123](https://github.com/linode/manager/pull/13123)) +- Use new JSON-based fwRulesetsPrefixLists feature flag for Firewall RuleSets and Prefix Lists feature ([#13125](https://github.com/linode/manager/pull/13125)) +- Add support for additional status types and handle action menu accordingly in CloudPulse alerts ([#13127](https://github.com/linode/manager/pull/13127)) +- Add a Network Load Balancer Listener detail page (EntityDetail paper) with breadcrumbs ([#13130](https://github.com/linode/manager/pull/13130)) +- Implement Empty Landing State for Network Load Balancers ([#13132](https://github.com/linode/manager/pull/13132)) +- CloudPulse-Metrics: Update `FilterConfig.ts` to handle integration of endpoints dashboard for object-storage service in metrics page([#13133](https://github.com/linode/manager/pull/13133)) +- Add feature flag support for PgBouncer in DBaaS ([#13134](https://github.com/linode/manager/pull/13134)) +- ACLP-Alerting: Update aclpAlerting flag to have beta marker control ([#13137](https://github.com/linode/manager/pull/13137)) +- Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection ([#13138](https://github.com/linode/manager/pull/13138)) +- CloudPulse-Metrics: Add tooltip for clusters filter in lke and fix preferences bug for nodebalancers filter in firewall-nodebalancer dashboard ([#13141](https://github.com/linode/manager/pull/13141)) +- Add and Integrate Prefix List Details Drawer ([#13146](https://github.com/linode/manager/pull/13146)) +- Implement Nodes table in Network LoadBalancer Listener detail page ([#13147](https://github.com/linode/manager/pull/13147)) +- Update useIsFirewallRulesetsPrefixlistsEnabled() to factor in account capability ([#13156](https://github.com/linode/manager/pull/13156)) +- CloudPulse-Metrics: Update tooltip msg for `Clusters` filter in LKE service dashboard ([#13157](https://github.com/linode/manager/pull/13157)) +- Integrate Prefix List details drawer with Edit and Add Rule drawer ([#13158](https://github.com/linode/manager/pull/13158)) +- Add Beta/New feature Chip support for RuleSets and Prefix Lists ([#13164](https://github.com/linode/manager/pull/13164)) +- UX/UI enhancements for RuleSets and Prefix Lists ([#13165](https://github.com/linode/manager/pull/13165)) +- Ensure a firewall can only reference a RuleSet once ([#13169](https://github.com/linode/manager/pull/13169)) +- Handle special PLs in PrefixList drawer ([#13172](https://github.com/linode/manager/pull/13172)) + ## [2025-11-18] - v1.155.0 diff --git a/packages/manager/package.json b/packages/manager/package.json index 7a78fd09062..90cce025425 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.155.0", + "version": "1.156.0", "private": true, "type": "module", "bugs": { diff --git a/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md b/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md deleted file mode 100644 index 6ad1e3662f2..00000000000 --- a/packages/queries/.changeset/pr-13078-upcoming-features-1762869790174.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/queries": Upcoming Features ---- - -Add new queries for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) diff --git a/packages/queries/.changeset/pr-13097-changed-1763149256983.md b/packages/queries/.changeset/pr-13097-changed-1763149256983.md deleted file mode 100644 index 4797066c966..00000000000 --- a/packages/queries/.changeset/pr-13097-changed-1763149256983.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/queries": Changed ---- - -Update database useRestoreFromBackupMutation data to include region ([#13097](https://github.com/linode/manager/pull/13097)) diff --git a/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md b/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md deleted file mode 100644 index 782b19cc762..00000000000 --- a/packages/queries/.changeset/pr-13148-upcoming-features-1764600420579.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/queries": Upcoming Features ---- - -Added Database Connection Pool queries ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index 7708246bd6e..93782eced0b 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2025-12-10] - v0.18.0 + + +### Changed: + +- Update database useRestoreFromBackupMutation data to include region ([#13097](https://github.com/linode/manager/pull/13097)) + +### Upcoming Features: + +- Add new queries for Network Load Balancers ([#13078](https://github.com/linode/manager/pull/13078)) +- Added Database Connection Pool queries ([#13148](https://github.com/linode/manager/pull/13148)) + ## [2025-11-18] - v0.17.0 diff --git a/packages/queries/package.json b/packages/queries/package.json index 930facd44bc..495e18c688f 100644 --- a/packages/queries/package.json +++ b/packages/queries/package.json @@ -1,6 +1,6 @@ { "name": "@linode/queries", - "version": "0.17.0", + "version": "0.18.0", "description": "Linode Utility functions library", "main": "src/index.js", "module": "src/index.ts", diff --git a/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md b/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md deleted file mode 100644 index 00ff19841a8..00000000000 --- a/packages/utilities/.changeset/pr-13119-tech-stories-1764007518381.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/utilities": Tech Stories ---- - -Update `scrollErrorIntoViewV2.test.tsx‎` to not mock MutationObserver` ([#13119](https://github.com/linode/manager/pull/13119)) diff --git a/packages/utilities/CHANGELOG.md b/packages/utilities/CHANGELOG.md index 1aeae7b42c4..f6af6d75885 100644 --- a/packages/utilities/CHANGELOG.md +++ b/packages/utilities/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2025-12-10] - v0.13.0 + + +### Tech Stories: + +- Update `scrollErrorIntoViewV2.test.tsx‎` to not mock MutationObserver` ([#13119](https://github.com/linode/manager/pull/13119)) + ## [2025-11-18] - v0.12.0 diff --git a/packages/utilities/package.json b/packages/utilities/package.json index c4539523877..f69d7df796a 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -1,6 +1,6 @@ { "name": "@linode/utilities", - "version": "0.12.0", + "version": "0.13.0", "description": "Linode Utility functions library", "main": "src/index.ts", "module": "src/index.ts", diff --git a/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md b/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md deleted file mode 100644 index 419f08d8ecf..00000000000 --- a/packages/validation/.changeset/pr-13079-upcoming-features-1762861891552.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Upcoming Features ---- - -Update FirewallRuleTypeSchema to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) diff --git a/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md b/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md deleted file mode 100644 index 0e428fea296..00000000000 --- a/packages/validation/.changeset/pr-13148-upcoming-features-1764600446107.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Upcoming Features ---- - -Added Database Connection Pool schemas ([#13148](https://github.com/linode/manager/pull/13148)) diff --git a/packages/validation/.changeset/pr-13166-changed-1764854111433.md b/packages/validation/.changeset/pr-13166-changed-1764854111433.md deleted file mode 100644 index a4e04eb3b87..00000000000 --- a/packages/validation/.changeset/pr-13166-changed-1764854111433.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -Validate Bucket name in Destination Form for forbidden characters ([#13166](https://github.com/linode/manager/pull/13166)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 176d6748a14..7fa6a7853a5 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2025-12-10] - v0.79.0 + + +### Changed: + +- Validate Bucket name in Destination Form for forbidden characters ([#13166](https://github.com/linode/manager/pull/13166)) + +### Upcoming Features: + +- Update FirewallRuleTypeSchema to support ruleset ([#13079](https://github.com/linode/manager/pull/13079)) +- Added Database Connection Pool schemas ([#13148](https://github.com/linode/manager/pull/13148)) + ## [2025-11-04] - v0.78.0 ### Upcoming Features: diff --git a/packages/validation/package.json b/packages/validation/package.json index 07ae8252d08..6b44f758c62 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.78.0", + "version": "0.79.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From f518c22825138d996c7726f5f6c5675d07d1fb77 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 8 Dec 2025 20:14:28 +0100 Subject: [PATCH 91/91] update changelog dates --- packages/api-v4/CHANGELOG.md | 2 +- packages/manager/CHANGELOG.md | 2 +- packages/queries/CHANGELOG.md | 2 +- packages/utilities/CHANGELOG.md | 2 +- packages/validation/CHANGELOG.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 8fd23ca90a8..ba68e404cca 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2025-12-10] - v0.154.0 +## [2025-12-09] - v0.154.0 ### Changed: diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index e8fabe978eb..efe48704bd1 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [2025-12-10] - v1.156.0 +## [2025-12-09] - v1.156.0 ### Added: diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md index 93782eced0b..ac10cd07909 100644 --- a/packages/queries/CHANGELOG.md +++ b/packages/queries/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2025-12-10] - v0.18.0 +## [2025-12-09] - v0.18.0 ### Changed: diff --git a/packages/utilities/CHANGELOG.md b/packages/utilities/CHANGELOG.md index f6af6d75885..3b586437871 100644 --- a/packages/utilities/CHANGELOG.md +++ b/packages/utilities/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2025-12-10] - v0.13.0 +## [2025-12-09] - v0.13.0 ### Tech Stories: diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index 7fa6a7853a5..5c9b653355e 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2025-12-10] - v0.79.0 +## [2025-12-09] - v0.79.0 ### Changed: