(
setURL(
- `${API_ROOT}/${serviceType}/instances/${encodeURIComponent(entityId)}`,
+ `${API_ROOT}/monitor/services/${encodeURIComponent(serviceType)}/alert-definitions/${encodeURIComponent(entityId)}`,
),
setMethod('PUT'),
setData(payload),
diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts
index 26e09e6743b..88727de8737 100644
--- a/packages/api-v4/src/cloudpulse/types.ts
+++ b/packages/api-v4/src/cloudpulse/types.ts
@@ -389,10 +389,10 @@ export interface CloudPulseAlertsPayload {
* Array of enabled system alert IDs in ACLP (Beta) mode.
* Only included in Beta mode.
*/
- system?: number[];
+ system_alerts?: number[];
/**
* Array of enabled user alert IDs in ACLP (Beta) mode.
* Only included in Beta mode.
*/
- user?: number[];
+ user_alerts?: number[];
}
diff --git a/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md b/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md
new file mode 100644
index 00000000000..00c98269c59
--- /dev/null
+++ b/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+CloudPulse-Alerts: Add `useAlertsMutation.ts`, update `AlertInformationActionTable.tsx` to handle api integration for mutliple services ([#12870](https://github.com/linode/manager/pull/12870))
diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts
index 87cbfc09cf0..ab7c8e4646e 100644
--- a/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/alerts-create.spec.ts
@@ -299,24 +299,24 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
cy.get('pre code').should('be.visible');
/** alert in code snippet
* "alerts": {
- * "system": [
+ * "system_alerts": [
* 1,
* 2,
* ],
- * "user": [
+ * "user_alerts": [
* 2
* ]
* }
*/
- const strAlertSnippet = `alerts '{"system": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user":[${alertDefinitions[2].id}]}`;
+ const strAlertSnippet = `alerts '{"system_alerts": [${alertDefinitions[0].id},${alertDefinitions[1].id}],"user_alerts":[${alertDefinitions[2].id}]}`;
cy.contains(strAlertSnippet).should('be.visible');
// cURL tab
ui.tabList.findTabByTitle('cURL').should('be.visible').click();
// hard to consolidate text within multiple spans in
cy.get('pre code').within(() => {
cy.contains('alerts');
- cy.contains('system');
- cy.contains('user');
+ cy.contains('system_alerts');
+ cy.contains('user_alerts');
});
ui.button
.findByTitle('Close')
@@ -341,11 +341,11 @@ describe('Create flow when beta alerts enabled by region and feature flag', func
.click();
cy.wait('@createLinode').then((intercept) => {
const alerts = intercept.request.body['alerts'];
- expect(alerts.system.length).to.equal(2);
- expect(alerts.system[0]).to.eq(alertDefinitions[0].id);
- expect(alerts.system[1]).to.eq(alertDefinitions[1].id);
- expect(alerts.user.length).to.equal(1);
- expect(alerts.user[0]).to.eq(alertDefinitions[2].id);
+ expect(alerts.system_alerts.length).to.equal(2);
+ expect(alerts.system_alerts[0]).to.eq(alertDefinitions[0].id);
+ expect(alerts.system_alerts[1]).to.eq(alertDefinitions[1].id);
+ expect(alerts.user_alerts.length).to.equal(1);
+ expect(alerts.user_alerts[0]).to.eq(alertDefinitions[2].id);
});
});
diff --git a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
index d2b7e3267a4..7513f2b8fe2 100644
--- a/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/alerts-edit.spec.ts
@@ -37,21 +37,21 @@ const mockEnabledLegacyAlerts = {
};
const mockDisabledBetaAlerts = {
- system: [],
- user: [],
+ system_alerts: [],
+ user_alerts: [],
};
const mockEnabledBetaAlerts = {
- system: [1, 2],
- user: [3],
+ system_alerts: [1, 2],
+ user_alerts: [3],
};
/*
* UI of Linode alerts tab based on beta and legacy alert values in linode.alerts. Dependent on region support for alerts
* Legacy alerts = 0, Beta alerts = [] (empty arrays or no values at all) => legacy disabled for `beta` stage OR beta disabled for `ga` stage
* Legacy alerts > 0, Beta alerts = [] (empty arrays or no values at all) => legacy enabled
- * Legacy alerts = 0, Beta alerts has values (either system, user, or both) => beta enabled
- * Legacy alerts > 0, Beta alerts has values (either system, user, or both) => beta enabled
+ * Legacy alerts = 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
+ * Legacy alerts > 0, Beta alerts has values (either system_alerts, user_alerts, or both) => beta enabled
*
* Note: Here, "disabled" means that all toggles are in the OFF state, but it's still editable (not read-only)
*/
diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
index 6bf2a79e408..a457d7d79ab 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
@@ -1,6 +1,7 @@
import { type Alert, type APIError } from '@linode/api-v4';
import { Box, Button, TooltipIcon } from '@linode/ui';
import { Grid, TableBody, TableHead } from '@mui/material';
+import { useQueryClient } from '@tanstack/react-query';
import { useSnackbar } from 'notistack';
import React from 'react';
@@ -13,14 +14,18 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon
import { TableRow } from 'src/components/TableRow';
import { TableSortCell } from 'src/components/TableSortCell';
import { ALERTS_BETA_PROMPT } from 'src/features/Linodes/constants';
-import { useServiceAlertsMutation } from 'src/queries/cloudpulse/alerts';
+import {
+ invalidateAclpAlerts,
+ servicePayloadTransformerMap,
+ useAlertsMutation,
+} from 'src/queries/cloudpulse/useAlertsMutation';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
-import { compareArrays } from '../../Utils/FilterBuilder';
import { useContextualAlertsState } from '../../Utils/utils';
import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog';
import { ALERT_SCOPE_TOOLTIP_CONTEXTUAL } from '../constants';
import { scrollToElement } from '../Utils/AlertResourceUtils';
+import { arraysEqual } from '../Utils/utils';
import { AlertInformationActionRow } from './AlertInformationActionRow';
import type {
@@ -142,6 +147,8 @@ export const AlertInformationActionTable = (
const isEditMode = !!entityId;
const isCreateMode = !isEditMode;
+ const payloadAlertType = (alert: Alert) =>
+ alert.type === 'system' ? 'system_alerts' : 'user_alerts';
const {
enabledAlerts,
@@ -151,10 +158,8 @@ export const AlertInformationActionTable = (
resetToInitialState,
} = useContextualAlertsState(alerts, entityId);
- const { mutateAsync: updateAlerts } = useServiceAlertsMutation(
- serviceType,
- entityId ?? ''
- );
+ // Mutation to update alerts as per service type
+ const updateAlerts = useAlertsMutation(serviceType, entityId ?? '');
React.useEffect(() => {
// To send initial state of alerts through toggle handler function (For Create Flow)
@@ -174,19 +179,26 @@ export const AlertInformationActionTable = (
setIsDialogOpen(false);
};
+ const queryClient = useQueryClient();
+
const handleConfirm = React.useCallback(
(alertIds: CloudPulseAlertsPayload) => {
setIsLoading(true);
- updateAlerts({
- user: alertIds.user,
- system: alertIds.system,
- })
+ const payload: CloudPulseAlertsPayload = {
+ user_alerts: alertIds.user_alerts,
+ system_alerts: alertIds.system_alerts,
+ };
+ // call updateAlerts mutation with the transformed payload based on the service type
+ updateAlerts(
+ servicePayloadTransformerMap[serviceType]?.(payload) ?? payload
+ )
.then(() => {
enqueueSnackbar('Your settings for alerts have been saved.', {
variant: 'success',
});
// Reset the state to sync with the updated alerts from API
resetToInitialState();
+ invalidateAclpAlerts(queryClient, serviceType, entityId, payload);
})
.catch(() => {
enqueueSnackbar('Alerts changes were not saved, please try again.', {
@@ -205,11 +217,11 @@ export const AlertInformationActionTable = (
(alert: Alert) => {
setEnabledAlerts((prev: CloudPulseAlertsPayload) => {
const newPayload = {
- system: [...(prev.system ?? [])],
- user: [...(prev.user ?? [])],
+ system_alerts: [...(prev.system_alerts ?? [])],
+ user_alerts: [...(prev.user_alerts ?? [])],
};
- const alertIds = newPayload[alert.type];
+ const alertIds = newPayload[payloadAlertType(alert)];
const isCurrentlyEnabled = alertIds.includes(alert.id);
if (isCurrentlyEnabled) {
@@ -222,8 +234,8 @@ export const AlertInformationActionTable = (
}
const hasNewUnsavedChanges =
- !compareArrays(newPayload.system ?? [], initialState.system ?? []) ||
- !compareArrays(newPayload.user ?? [], initialState.user ?? []);
+ !arraysEqual(newPayload.system_alerts, initialState.system_alerts) ||
+ !arraysEqual(newPayload.user_alerts, initialState.user_alerts);
// Call onToggleAlert in both create and edit flow
if (onToggleAlert) {
@@ -314,10 +326,9 @@ export const AlertInformationActionTable = (
if (!(isEditMode || isCreateMode)) {
return null;
}
-
- const status = enabledAlerts[alert.type]?.includes(
- alert.id
- );
+ const status = enabledAlerts[
+ payloadAlertType(alert)
+ ]?.includes(alert.id);
return (
{
it('should return empty initial state when no entityId provided', () => {
const alerts = alertFactory.buildList(3);
const { result } = renderHook(() => useContextualAlertsState(alerts));
- expect(result.current.initialState).toEqual({ system: [], user: [] });
+ expect(result.current.initialState).toEqual({
+ system_alerts: [],
+ user_alerts: [],
+ });
});
it('should include alerts that match entityId or account/region level alerts in initial states', () => {
@@ -284,9 +288,9 @@ describe('useContextualAlertsState', () => {
useContextualAlertsState(alerts, entityId)
);
- expect(result.current.initialState.system).toContain(1);
- expect(result.current.initialState.system).toContain(3);
- expect(result.current.initialState.user).toContain(2);
+ expect(result.current.initialState.system_alerts).toContain(1);
+ expect(result.current.initialState.system_alerts).toContain(3);
+ expect(result.current.initialState.user_alerts).toContain(2);
});
it('should detect unsaved changes when alerts are modified', () => {
@@ -309,7 +313,7 @@ describe('useContextualAlertsState', () => {
act(() => {
result.current.setEnabledAlerts((prev) => ({
...prev,
- system: [...(prev.system ?? []), 999],
+ system_alerts: [...(prev.system_alerts ?? []), 999],
}));
});
@@ -494,3 +498,27 @@ describe('transformDimensionValue', () => {
).toBe('Test_value');
});
});
+
+describe('arraysEqual', () => {
+ it('should return true when both arrays are empty', () => {
+ expect(arraysEqual([], [])).toBe(true);
+ });
+ it('should return false when one array is empty and the other is not', () => {
+ expect(arraysEqual([], [1, 2, 3])).toBe(false);
+ });
+ it('should return true when arrays are undefined', () => {
+ expect(arraysEqual(undefined, undefined)).toBe(true);
+ });
+ it('should return false when one of the arrays is undefined', () => {
+ expect(arraysEqual(undefined, [1, 2, 3])).toBe(false);
+ });
+ it('should return true when arrays are equal', () => {
+ expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true);
+ });
+ it('should return false when arrays are not equal', () => {
+ expect(arraysEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false);
+ });
+ it('should return true when arrays have same elements but in different order', () => {
+ expect(arraysEqual([1, 2, 3], [3, 2, 1])).toBe(true);
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
index f8b9e9b993e..e756d6831e5 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts
@@ -17,6 +17,7 @@ import {
DIMENSION_TRANSFORM_CONFIG,
TRANSFORMS,
} from '../../shared/DimensionTransform';
+import { compareArrays } from '../../Utils/FilterBuilder';
import { aggregationTypeMap, metricOperatorTypeMap } from '../constants';
import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect';
@@ -618,3 +619,23 @@ export const transformDimensionValue = (
)?.(value) ?? TRANSFORMS.capitalize(value)
);
};
+
+/**
+ * Checks if two arrays are equal, ignores the order of the elements
+ * @param a The first array
+ * @param b The second array
+ * @returns True if the arrays are equal, false otherwise
+ */
+export const arraysEqual = (
+ a: number[] | undefined,
+ b: number[] | undefined
+) => {
+ if (a === undefined && b === undefined) return true;
+ if (a === undefined || b === undefined) return false;
+ if (a.length !== b.length) return false;
+
+ return compareArrays(
+ [...a].sort((x, y) => x - y),
+ [...b].sort((x, y) => x - y)
+ );
+};
diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts
index 8916d7fedce..7829f8de042 100644
--- a/packages/manager/src/features/CloudPulse/Utils/utils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts
@@ -5,6 +5,7 @@ import React from 'react';
import { convertData } from 'src/features/Longview/shared/formatters';
import { useFlags } from 'src/hooks/useFlags';
+import { arraysEqual } from '../Alerts/Utils/utils';
import {
INTERFACE_ID,
INTERFACE_IDS_CONSECUTIVE_COMMAS_ERROR_MESSAGE,
@@ -19,7 +20,6 @@ import {
PORTS_LIMIT_ERROR_MESSAGE,
PORTS_RANGE_ERROR_MESSAGE,
} from './constants';
-import { compareArrays } from './FilterBuilder';
import type {
Alert,
@@ -93,8 +93,8 @@ export const useContextualAlertsState = (
const calculateInitialState = React.useCallback(
(alerts: Alert[], entityId?: string): CloudPulseAlertsPayload => {
const initialStates: CloudPulseAlertsPayload = {
- system: [],
- user: [],
+ system_alerts: [],
+ user_alerts: [],
};
alerts.forEach((alert) => {
@@ -107,7 +107,9 @@ export const useContextualAlertsState = (
: isAccountOrRegion;
if (shouldInclude) {
- initialStates[alert.type]?.push(alert.id);
+ const payloadAlertType =
+ alert.type === 'system' ? 'system_alerts' : 'user_alerts';
+ initialStates[payloadAlertType]?.push(alert.id);
}
});
@@ -131,8 +133,8 @@ export const useContextualAlertsState = (
// Check if the enabled alerts have changed from the initial state
const hasUnsavedChanges = React.useMemo(() => {
return (
- !compareArrays(enabledAlerts.system ?? [], initialState.system ?? []) ||
- !compareArrays(enabledAlerts.user ?? [], initialState.user ?? [])
+ !arraysEqual(enabledAlerts.system_alerts, initialState.system_alerts) ||
+ !arraysEqual(enabledAlerts.user_alerts, initialState.user_alerts)
);
}, [enabledAlerts, initialState]);
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
index 5161ad1b6c4..e2af0ce0371 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts.tsx
@@ -27,7 +27,7 @@ export const Alerts = ({
const { field } = useController({
control,
name: 'alerts',
- defaultValue: { system: [], user: [] },
+ defaultValue: { system_alerts: [], user_alerts: [] },
});
const handleToggleAlert = (updatedAlerts: CloudPulseAlertsPayload) => {
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
index e6659c55867..7cb67eca885 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx
@@ -104,11 +104,11 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
isAlertsBetaMode;
const totalBetaAclpAlertsAssignedCount =
- (alerts?.system?.length ?? 0) + (alerts?.user?.length ?? 0);
+ (alerts?.system_alerts?.length ?? 0) + (alerts?.user_alerts?.length ?? 0);
const betaAclpAlertsAssignedList = [
- ...(alerts?.system ?? []),
- ...(alerts?.user ?? []),
+ ...(alerts?.system_alerts ?? []),
+ ...(alerts?.user_alerts ?? []),
].join(', ');
const betaAclpAlertsAssignedDetails =
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
index 271b9df67a9..45473a8904b 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/AlertsPanel.tsx
@@ -5,7 +5,7 @@ import {
} from '@linode/queries';
import { useIsLinodeAclpSubscribed } from '@linode/shared';
import { ActionsPanel, Divider, Notice, Paper, Typography } from '@linode/ui';
-import { alertsSchema } from '@linode/validation';
+import { UpdateLinodeAlertsSchema } from '@linode/validation';
import { styled } from '@mui/material/styles';
import { useFormik } from 'formik';
import { useSnackbar } from 'notistack';
@@ -81,7 +81,7 @@ export const AlertsPanel = (props: Props) => {
enableReinitialize: true,
initialValues,
validateOnChange: true,
- validationSchema: alertsSchema,
+ validationSchema: UpdateLinodeAlertsSchema,
async onSubmit({ cpu, io, network_in, network_out, transfer_quota }) {
await updateLinode({
alerts: {
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index 35dab25ceb8..0f3a5be3bd8 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -1008,8 +1008,8 @@ export const handlers = [
label: 'aclp-supported-region-linode-1',
region: 'us-iad',
alerts: {
- user: [21, 22, 23, 24, 25],
- system: [19, 20],
+ user_alerts: [21, 22, 23, 24, 25],
+ system_alerts: [19, 20],
cpu: 0,
io: 0,
network_in: 0,
@@ -1024,8 +1024,8 @@ export const handlers = [
label: 'aclp-supported-region-linode-2',
region: 'us-east',
alerts: {
- user: [],
- system: [],
+ user_alerts: [],
+ system_alerts: [],
cpu: 10,
io: 10000,
network_in: 0,
@@ -1043,8 +1043,8 @@ export const handlers = [
label: 'aclp-supported-region-linode-3',
region: 'us-iad',
alerts: {
- user: [],
- system: [],
+ user_alerts: [],
+ system_alerts: [],
cpu: 0,
io: 0,
network_in: 0,
@@ -2865,6 +2865,13 @@ export const handlers = [
scope: 'region',
regions: ['us-east'],
}),
+ ...alertFactory.buildList(6, {
+ service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode',
+ type: 'user',
+ scope: 'entity',
+ regions: ['us-east'],
+ entity_ids: ['5', '6'],
+ }),
],
});
}
diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts
index 8b78ef91fa8..c0ea1b86b13 100644
--- a/packages/manager/src/queries/cloudpulse/alerts.ts
+++ b/packages/manager/src/queries/cloudpulse/alerts.ts
@@ -15,6 +15,7 @@ import {
} from '@tanstack/react-query';
import { queryFactory } from './queries';
+import { invalidateAclpAlerts } from './useAlertsMutation';
import type {
Alert,
@@ -248,48 +249,12 @@ export const useServiceAlertsMutation = (
entityId: string
) => {
const queryClient = useQueryClient();
- return useMutation<{}, APIError[], CloudPulseAlertsPayload>({
+ return useMutation({
mutationFn: (payload: CloudPulseAlertsPayload) => {
return updateServiceAlerts(serviceType, entityId, payload);
},
onSuccess(_, payload) {
- const allAlerts = queryClient.getQueryData(
- queryFactory.alerts._ctx.all().queryKey
- );
-
- // Get alerts previously enabled for this entity
- const oldEnabledAlertIds =
- allAlerts
- ?.filter((alert) => alert.entity_ids.includes(entityId))
- .map((alert) => alert.id) || [];
-
- // Combine enabled user and system alert IDs from payload
- const newEnabledAlertIds = [
- ...(payload.user ?? []),
- ...(payload.system ?? []),
- ];
-
- // Get unique list of all enabled alert IDs for cache invalidation
- const alertIdsToInvalidate = Array.from(
- new Set([...oldEnabledAlertIds, ...newEnabledAlertIds])
- );
-
- queryClient.invalidateQueries({
- queryKey: queryFactory.resources(serviceType).queryKey,
- });
-
- queryClient.invalidateQueries({
- queryKey: queryFactory.alerts._ctx.all().queryKey,
- });
-
- alertIdsToInvalidate.forEach((alertId) => {
- queryClient.invalidateQueries({
- queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId(
- serviceType,
- String(alertId)
- ).queryKey,
- });
- });
+ invalidateAclpAlerts(queryClient, serviceType, entityId, payload);
},
});
};
diff --git a/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts
new file mode 100644
index 00000000000..49ff9f3a751
--- /dev/null
+++ b/packages/manager/src/queries/cloudpulse/useAlertsMutation.ts
@@ -0,0 +1,122 @@
+import {
+ type CloudPulseAlertsPayload,
+ type CloudPulseServiceType,
+ type DeepPartial,
+ type Linode,
+} from '@linode/api-v4';
+import { useLinodeUpdateMutation } from '@linode/queries';
+
+import { queryFactory } from './queries';
+
+import type { Alert, LinodeAlerts } from '@linode/api-v4/lib/cloudpulse';
+import type { QueryClient } from '@linode/queries';
+
+/**
+ * The alert type overrides for a given service type.
+ * It contains the payload transformer function type and the response type.
+ * This is used for types only, not to be used anywhere else.
+ */
+interface AlertTypeOverrides {
+ linode: (basePayload: LinodeAlerts) => DeepPartial;
+ // Future overrides go here (e.g. dbaas, ...)
+}
+
+/**
+ * The type of the payload transformer function for a given service type.
+ */
+type AlertPayloadTransformerFn =
+ T extends keyof AlertTypeOverrides
+ ? AlertTypeOverrides[T]
+ : (basePayload: CloudPulseAlertsPayload) => CloudPulseAlertsPayload;
+
+/**
+ * Type of the service payload transformer map
+ */
+export type ServicePayloadTransformerMap = Partial<{
+ [K in CloudPulseServiceType]: AlertPayloadTransformerFn;
+}>;
+
+/**
+ * Service payload transformer map
+ */
+export const servicePayloadTransformerMap: ServicePayloadTransformerMap = {
+ linode: (basePayload: LinodeAlerts) => ({ alerts: basePayload }),
+ // Future transformers go here (e.g. dbaas, ...)
+};
+
+/**
+ *
+ * @param serviceType service type
+ * @param entityId entity id
+ * @returns alerts mutation
+ */
+export const useAlertsMutation = (
+ serviceType: CloudPulseServiceType,
+ entityId: string
+) => {
+ // linode api alerts mutation
+ const { mutateAsync: updateLinode } = useLinodeUpdateMutation(
+ Number(entityId)
+ );
+
+ switch (serviceType) {
+ case 'linode':
+ return updateLinode;
+ default:
+ return (_payload: CloudPulseAlertsPayload) =>
+ Promise.reject(new Error('Error encountered'));
+ }
+};
+
+/**
+ * Invalidates the alerts cache
+ * @param qc The query client
+ * @param serviceType The service type
+ * @param entityId The entity id
+ * @param payload The payload
+ */
+export const invalidateAclpAlerts = (
+ queryClient: QueryClient,
+ serviceType: string,
+ entityId: string | undefined,
+ payload: CloudPulseAlertsPayload
+) => {
+ if (!entityId) return;
+
+ const allAlerts = queryClient.getQueryData(
+ queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey
+ );
+
+ // Get alerts previously enabled for this entity
+ const oldEnabledAlertIds =
+ allAlerts
+ ?.filter((alert) => alert.entity_ids.includes(entityId))
+ .map((alert) => alert.id) || [];
+
+ // Combine enabled user and system alert IDs from payload
+ const newEnabledAlertIds = [
+ ...(payload.user_alerts ?? []),
+ ...(payload.system_alerts ?? []),
+ ];
+
+ // Get unique list of all enabled alert IDs for cache invalidation
+ const alertIdsToInvalidate = [...oldEnabledAlertIds, ...newEnabledAlertIds];
+
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.all().queryKey,
+ });
+
+ queryClient.invalidateQueries({
+ queryKey:
+ queryFactory.alerts._ctx.alertsByServiceType(serviceType).queryKey,
+ });
+
+ alertIdsToInvalidate.forEach((alertId) => {
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.alertByServiceTypeAndId(
+ serviceType,
+ String(alertId)
+ ).queryKey,
+ });
+ });
+};
diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
index 1c9aac39bb5..acb95442383 100644
--- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
+++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.test.ts
@@ -47,8 +47,8 @@ describe('useIsLinodeAclpSubscribed', () => {
network_in: 0,
network_out: 0,
transfer_quota: 0,
- system: [],
- user: [],
+ system_alerts: [],
+ user_alerts: [],
},
},
});
@@ -67,8 +67,8 @@ describe('useIsLinodeAclpSubscribed', () => {
network_in: 0,
network_out: 0,
transfer_quota: 0,
- system: [],
- user: [],
+ system_alerts: [],
+ user_alerts: [],
},
},
});
@@ -87,8 +87,8 @@ describe('useIsLinodeAclpSubscribed', () => {
network_in: 0,
network_out: 0,
transfer_quota: 0,
- system: [],
- user: [],
+ system_alerts: [],
+ user_alerts: [],
},
},
});
@@ -107,8 +107,8 @@ describe('useIsLinodeAclpSubscribed', () => {
network_in: 0,
network_out: 0,
transfer_quota: 0,
- system: [100],
- user: [],
+ system_alerts: [100],
+ user_alerts: [],
},
},
});
@@ -127,8 +127,8 @@ describe('useIsLinodeAclpSubscribed', () => {
network_in: 0,
network_out: 0,
transfer_quota: 0,
- system: [100],
- user: [200],
+ system_alerts: [100],
+ user_alerts: [200],
},
},
});
diff --git a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
index df71aa5b692..a2667c199e2 100644
--- a/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
+++ b/packages/shared/src/hooks/useIsLinodeAclpSubscribed.ts
@@ -39,8 +39,8 @@ export const useIsLinodeAclpSubscribed = (
(linode.alerts.transfer_quota ?? 0) > 0;
const hasAclpAlerts =
- (linode.alerts.system?.length ?? 0) > 0 ||
- (linode.alerts.user?.length ?? 0) > 0;
+ (linode.alerts.system_alerts?.length ?? 0) > 0 ||
+ (linode.alerts.user_alerts?.length ?? 0) > 0;
// Always subscribed if ACLP alerts exist. For GA stage, default to subscribed if no alerts exist.
return (
diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts
index 3550da808b5..bb30e34fc89 100644
--- a/packages/validation/src/linodes.schema.ts
+++ b/packages/validation/src/linodes.schema.ts
@@ -363,15 +363,41 @@ const DiskEncryptionSchema = string()
.oneOf(['enabled', 'disabled'])
.notRequired();
-export const alertsSchema = object({
- cpu: number()
- .required('CPU Usage is required.')
+/**
+ * A number field schema with conditional validation for legacy alert fields.
+ * @param label - The label used in the required error message.
+ * @returns A number schema with conditional validation.
+ */
+const legacyAlertsFieldSchema = (
+ label:
+ | 'CPU Usage'
+ | 'Disk I/O Rate'
+ | 'Incoming Traffic'
+ | 'Outbound Traffic'
+ | 'Transfer Quota',
+) =>
+ // If system_alerts and user_alerts are undefined, then it is legacy alerts context.
+ // If it is legacy alerts context, then the field is required.
+ number().when(['system_alerts', 'user_alerts'], {
+ is: (systemAlerts?: number[], userAlerts?: number[]) => {
+ return systemAlerts === undefined && userAlerts === undefined;
+ },
+ then: (schema) => schema.required(`${label} is required.`),
+ otherwise: (schema) => schema.notRequired(),
+ });
+
+export const UpdateLinodeAlertsSchema = object({
+ // Legacy numeric-threshold alerts. All fields are required to update legacy alerts, but not for ACLP alerts.
+ cpu: legacyAlertsFieldSchema('CPU Usage')
.min(0, 'Must be between 0 and 4800')
.max(4800, 'Must be between 0 and 4800'),
- network_in: number().required('Incoming Traffic is required.'),
- network_out: number().required('Outbound Traffic is required.'),
- transfer_quota: number().required('Transfer Quota is required.'),
- io: number().required('Disk I/O Rate is required.'),
+ network_in: legacyAlertsFieldSchema('Incoming Traffic'),
+ network_out: legacyAlertsFieldSchema('Outbound Traffic'),
+ transfer_quota: legacyAlertsFieldSchema('Transfer Quota'),
+ io: legacyAlertsFieldSchema('Disk I/O Rate'),
+ // ACLP alerts. All fields are required to update ACLP alerts, but not for legacy alerts.
+ system_alerts: array().of(number().defined()).notRequired(),
+ user_alerts: array().of(number().defined()).notRequired(),
});
const schedule = object({
@@ -420,7 +446,7 @@ export const UpdateLinodeSchema = object({
.max(64, LINODE_LABEL_CHAR_REQUIREMENT),
tags: array().of(string()).notRequired(),
watchdog_enabled: boolean().notRequired(),
- alerts: alertsSchema.notRequired().default(undefined),
+ alerts: UpdateLinodeAlertsSchema.notRequired().default(undefined),
backups,
});
@@ -656,9 +682,9 @@ const CreateVlanInterfaceSchema = object({
ipam_address: string().nullable(),
});
-const AclpAlertsPayloadSchema = object({
- system: array().of(number().defined()).required(),
- user: array().of(number().defined()).required(),
+const CreateLinodeAclpAlertsSchema = object({
+ system_alerts: array().of(number().defined()).required(),
+ user_alerts: array().of(number().defined()).required(),
});
export const CreateVPCInterfaceSchema = object({
@@ -833,5 +859,5 @@ export const CreateLinodeSchema = object({
.oneOf(['linode/migrate', 'linode/power_off_on', undefined])
.notRequired()
.nullable(),
- alerts: AclpAlertsPayloadSchema.notRequired().default(undefined),
+ alerts: CreateLinodeAclpAlertsSchema.notRequired().default(undefined),
});
From 41f5418fb87af866431b1cfe64a86cb83bc8c313 Mon Sep 17 00:00:00 2001
From: Dmytro Chyrva
Date: Fri, 19 Sep 2025 18:58:28 +0200
Subject: [PATCH 09/54] fix: [STORIF-101] - Volume deletion from the Volume
Details page (#12894)
---
.../.changeset/pr-12894-fixed-1758298095602.md | 5 +++++
.../features/Volumes/Dialogs/DeleteVolumeDialog.tsx | 11 +++++++++--
.../features/Volumes/VolumeDetails/VolumeDetails.tsx | 12 +++++++++++-
.../features/Volumes/VolumeDrawers/VolumeDrawers.tsx | 7 ++++++-
.../manager/src/features/Volumes/VolumesLanding.tsx | 5 ++++-
5 files changed, 35 insertions(+), 5 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12894-fixed-1758298095602.md
diff --git a/packages/manager/.changeset/pr-12894-fixed-1758298095602.md b/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
new file mode 100644
index 00000000000..b41af7cdf2c
--- /dev/null
+++ b/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Navigation after successful volume deletion ([#12894](https://github.com/linode/manager/pull/12894))
diff --git a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
index 13c4a5b65e4..938da3ddabf 100644
--- a/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
+++ b/packages/manager/src/features/Volumes/Dialogs/DeleteVolumeDialog.tsx
@@ -9,13 +9,15 @@ import type { APIError, Volume } from '@linode/api-v4';
interface Props {
isFetching?: boolean;
onClose: () => void;
+ onDeleteSuccess?: () => void;
open: boolean;
volume: undefined | Volume;
volumeError?: APIError[] | null;
}
export const DeleteVolumeDialog = (props: Props) => {
- const { isFetching, onClose, open, volume, volumeError } = props;
+ const { isFetching, onClose, onDeleteSuccess, open, volume, volumeError } =
+ props;
const {
error,
@@ -27,7 +29,12 @@ export const DeleteVolumeDialog = (props: Props) => {
const onDelete = () => {
deleteVolume({ id: volume?.id ?? -1 }).then(() => {
- onClose();
+ if (onDeleteSuccess) {
+ onDeleteSuccess();
+ } else {
+ onClose();
+ }
+
checkForNewEvents();
});
};
diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
index d29983324a9..9a500492981 100644
--- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
+++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetails.tsx
@@ -36,6 +36,13 @@ export const VolumeDetails = () => {
return ;
}
+ const navigateToVolumes = () => {
+ navigate({
+ search: (prev) => prev,
+ to: '/volumes',
+ });
+ };
+
const navigateToVolumeSummary = () => {
navigate({
search: (prev) => prev,
@@ -62,7 +69,10 @@ export const VolumeDetails = () => {
-
+
>
);
};
diff --git a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
index 409617819e7..6745c377f16 100644
--- a/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
+++ b/packages/manager/src/features/Volumes/VolumeDrawers/VolumeDrawers.tsx
@@ -14,9 +14,13 @@ import { VolumeDetailsDrawer } from './VolumeDetailsDrawer';
interface Props {
onCloseHandler: () => void;
+ onDeleteSuccessHandler: () => void;
}
-export const VolumeDrawers = ({ onCloseHandler }: Props) => {
+export const VolumeDrawers = ({
+ onCloseHandler,
+ onDeleteSuccessHandler,
+}: Props) => {
const params = useParams({ strict: false });
const {
@@ -85,6 +89,7 @@ export const VolumeDrawers = ({ onCloseHandler }: Props) => {
{
pageSize={pagination.pageSize}
/>
-
+
);
};
From 9af32a7fcc3a1ab7fb4801ea667483b184552789 Mon Sep 17 00:00:00 2001
From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
Date: Fri, 19 Sep 2025 14:15:52 -0400
Subject: [PATCH 10/54] test: [M3-10365] - LKE-E "postLa" feature flag smoke
tests (#12886)
* Add LKE-E "Post-LA" feature flag smoke tests for LKE create page
* Add LKE-E "Post-LA" feature flag smoke tests for LKE details page
* Organize and consolidate mock setup
* Added changeset: Add LKE-E Post-LA feature flag smoke tests
---
.../pr-12886-tests-1758051113240.md | 5 +
.../core/cloudpulse/create-user-alert.spec.ts | 2 +-
.../core/cloudpulse/edit-system-alert.spec.ts | 2 +-
.../cloudpulse/timerange-verification.spec.ts | 2 -
.../kubernetes/smoke-lke-enterprise.spec.ts | 980 ++++++++++++------
.../cypress/support/intercepts/cloudpulse.ts | 2 +-
.../SelectFirewallPanel.tsx | 5 +-
.../DimensionFilterValue/utils.test.ts | 8 +-
8 files changed, 657 insertions(+), 349 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12886-tests-1758051113240.md
diff --git a/packages/manager/.changeset/pr-12886-tests-1758051113240.md b/packages/manager/.changeset/pr-12886-tests-1758051113240.md
new file mode 100644
index 00000000000..c6a4d334d42
--- /dev/null
+++ b/packages/manager/.changeset/pr-12886-tests-1758051113240.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Add LKE-E Post-LA feature flag smoke tests ([#12886](https://github.com/linode/manager/pull/12886))
diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
index 69a5c06847d..e2b882a866b 100644
--- a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
+++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts
@@ -499,4 +499,4 @@ describe('Create Firewall Alert Successfully', () => {
});
});
});
-});
\ No newline at end of file
+});
diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
index 315ce8a3135..b7b8f7a7c7b 100644
--- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
+++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
@@ -267,4 +267,4 @@ describe('Integration Tests for Edit Alert', () => {
ui.toast.assertMessage('Alert entities successfully updated.');
});
});
-});
\ No newline at end of file
+});
diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
index a24540c3bb7..d94b7e32ff4 100644
--- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
+++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts
@@ -279,7 +279,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
cy.get(`[aria-label="${startHour} hours"]`).click();
});
-
cy.findByLabelText('Select minutes')
.as('selectMinutes')
.scrollIntoView({ duration: 500, easing: 'linear' });
@@ -288,7 +287,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura
cy.get(`[aria-label="${startMinute} minutes"]`).click();
});
-
cy.findByLabelText('Select meridiem')
.as('startMeridiemSelect')
.scrollIntoView();
diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
index 86f121919f5..1a9f1ec1076 100644
--- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
+++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-enterprise.spec.ts
@@ -1,10 +1,9 @@
/**
* Tests basic functionality for LKE-E feature-flagged work.
- * TODO: M3-10365 - Add `postLa` smoke tests to this file.
* TODO: M3-8838 - Delete this spec file once LKE-E is released to GA.
*/
-import { regionFactory } from '@linode/utilities';
+import { linodeTypeFactory, regionFactory } from '@linode/utilities';
import {
accountFactory,
kubernetesClusterFactory,
@@ -15,23 +14,32 @@ import {
import {
latestEnterpriseTierKubernetesVersion,
minimumNodeNotice,
+ mockTieredEnterpriseVersions,
+ mockTieredStandardVersions,
} from 'support/constants/lke';
import { mockGetAccount } from 'support/intercepts/account';
import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags';
+import {
+ mockGetLinodeType,
+ mockGetLinodeTypes,
+} from 'support/intercepts/linodes';
import {
mockCreateCluster,
mockGetCluster,
mockGetClusterPools,
+ mockGetClusters,
mockGetTieredKubernetesVersions,
} from 'support/intercepts/lke';
-import { mockGetClusters } from 'support/intercepts/lke';
import {} from 'support/intercepts/profile';
import { mockGetRegions } from 'support/intercepts/regions';
import { mockGetVPC } from 'support/intercepts/vpc';
import { ui } from 'support/ui';
+import { lkeClusterCreatePage } from 'support/ui/pages';
import { addNodes } from 'support/util/lke';
import { randomLabel } from 'support/util/random';
+import { extendType } from 'src/utilities/extendType';
+
const mockCluster = kubernetesClusterFactory.build({
id: 1,
vpc_id: 123,
@@ -39,6 +47,12 @@ const mockCluster = kubernetesClusterFactory.build({
tier: 'enterprise',
});
+const mockPlan = extendType(
+ linodeTypeFactory.build({
+ class: 'dedicated',
+ })
+);
+
const mockVPC = vpcFactory.build({
id: 123,
label: 'lke-e-vpc',
@@ -48,18 +62,10 @@ const mockVPC = vpcFactory.build({
const mockNodePools = [nodePoolFactory.build()];
// Mock a valid region for LKE-E to avoid test flake.
-const mockRegions = [
- regionFactory.build({
- capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'],
- id: 'us-iad',
- label: 'Washington, DC',
- }),
-];
+const mockRegion = regionFactory.build({
+ capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise', 'VPCs'],
+});
-/**
- * - Confirms VPC and IP Stack selections are shown with the respective `phase2Mtc` feature flags enabled.
- * - Confirms VPC and IP Stack selections are not shown in create flow with their respective `phase2Mtc` feature flags disabled.
- */
describe('LKE-E Cluster Create', () => {
beforeEach(() => {
mockGetAccount(
@@ -71,290 +77,446 @@ describe('LKE-E Cluster Create', () => {
],
})
).as('getAccount');
- });
-
- it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- postLa: false,
- phase2Mtc: { byoVPC: true, dualStack: false },
- },
- }).as('getFeatureFlags');
-
+ mockGetRegions([mockRegion]);
+ mockGetLinodeTypes([mockPlan]);
+ mockGetLinodeType(mockPlan);
+ mockGetTieredKubernetesVersions('standard', mockTieredStandardVersions);
+ mockGetTieredKubernetesVersions('enterprise', mockTieredEnterpriseVersions);
mockCreateCluster(mockCluster).as('createCluster');
- mockGetTieredKubernetesVersions('enterprise', [
- latestEnterpriseTierKubernetesVersion,
- ]).as('getTieredKubernetesVersions');
- mockGetRegions(mockRegions);
-
- cy.visitWithLogin('/kubernetes/create');
- cy.findByText('Add Node Pools').should('be.visible');
-
- cy.findByLabelText('Cluster Label').click();
- cy.focused().type(mockCluster.label);
-
- cy.findByText('LKE Enterprise').click();
-
- ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
- ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
-
- cy.findByLabelText('Kubernetes Version').should('be.visible').click();
- cy.findByText(latestEnterpriseTierKubernetesVersion.id)
- .should('be.visible')
- .click();
+ });
- // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF.
- cy.findByText('IP Stack').should('not.exist');
- cy.findByText('IPv4', { exact: true }).should('not.exist');
- cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
+ /*
+ * Smoke tests to confirm the state of the LKE Create page when the LKE-E
+ * Post-LA feature flag is enabled and disabled.
+ *
+ * The Post-LA feature flag introduces the "Configure Node Pool" button and
+ * flow when choosing node pools during the create flow. When disabled, it's
+ * expected that users can add node pools from directly within the plan table.
+ * When the flag is enabled, users instead select the plan they want and
+ * configure the pool from within a new drawer. Additional configuration options
+ * are available for LKE-E clusters as well.
+ */
+ describe('Post-LA feature flag', () => {
+ /*
+ * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is enabled.
+ * - Confirms that node pools are configured via new drawer.
+ */
+ it('Simple Page Check - Post LA Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: true,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ },
+ });
- // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON.
- cy.findByText('Automatically generate a VPC for this cluster').should(
- 'be.visible'
- );
- cy.findByText('Use an existing VPC').should('be.visible');
+ cy.visitWithLogin('/kubernetes/create');
- cy.findByText('Shared CPU').should('be.visible').click();
- addNodes('Linode 2 GB');
+ lkeClusterCreatePage.setLabel(randomLabel());
+ lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
+ lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
+ lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel);
- // Bypass ACL validation
- cy.get('input[name="acl-acknowledgement"]').check();
+ // Confirm that the "Configure Node Pool" drawer appears.
+ lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => {
+ ui.button
+ .findByTitle('Add Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- // Confirm change is reflected in checkout bar.
- cy.get('[data-testid="kube-checkout-bar"]').within(() => {
- cy.findByText('Linode 2 GB Plan').should('be.visible');
- cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
+ // Confirm that "Edit Configuration" button is shown for each node pool
+ // in the order summary section.
+ lkeClusterCreatePage.withinOrderSummary(() => {
+ cy.contains(mockPlan.formattedLabel)
+ .closest('[data-testid="node-pool-summary"]')
+ .within(() => {
+ cy.findByText('Edit Configuration').should('be.visible');
+ });
+ });
+ });
- cy.get('[data-qa-notice="true"]').within(() => {
- cy.findByText(minimumNodeNotice).should('be.visible');
+ /*
+ * - Confirms the state of the LKE create page when the LKE-E "postLa" flag is disabled.
+ * - Confirms that node pools are added directly via the plan table.
+ */
+ it('Simple Page Check - Post LA Flag OFF', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: false,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ },
});
- ui.button
- .findByTitle('Create Cluster')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ cy.visitWithLogin('/kubernetes/create');
+
+ lkeClusterCreatePage.setLabel(randomLabel());
+ lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
+ lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
+
+ // Add a node pool with a custom number of nodes, confirm that
+ // it gets added to the summary as expected.
+ lkeClusterCreatePage.addNodePool(mockPlan.formattedLabel, 5);
+
+ lkeClusterCreatePage.withinOrderSummary(() => {
+ cy.contains(mockPlan.formattedLabel)
+ .closest('[data-testid="node-pool-summary"]')
+ .within(() => {
+ // Confirm that fields to edit the node pool size are present and enabled.
+ cy.findByLabelText('Subtract 1')
+ .should('be.visible')
+ .should('be.enabled');
+ cy.findByLabelText('Add 1')
+ .should('be.visible')
+ .should('be.enabled');
+ cy.findByLabelText('Edit Quantity').should('have.value', '5');
+ });
+ });
});
-
- cy.wait('@createCluster');
- cy.url().should(
- 'endWith',
- `/kubernetes/clusters/${mockCluster.id}/summary`
- );
});
- it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- postLa: false,
- phase2Mtc: { byoVPC: false, dualStack: true },
- },
- }).as('getFeatureFlags');
-
- mockCreateCluster(mockCluster).as('createCluster');
- mockGetTieredKubernetesVersions('enterprise', [
- latestEnterpriseTierKubernetesVersion,
- ]).as('getTieredKubernetesVersions');
- mockGetRegions(mockRegions);
+ /**
+ * - Confirms that VPC options are shown when the `phase2Mtc.byoVPC` feature is enabled.
+ * - Confirms that IP stack selections are shown when the `phase2Mtc.dualStack` feature is enabled.
+ * - Confirms that VPC options and IP stack selections are absent when respective `phase2Mtc` options are disabled.
+ */
+ describe('Phase 2 MTC feature flag', () => {
+ it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: false,
+ phase2Mtc: { byoVPC: true, dualStack: false },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin('/kubernetes/create');
+ cy.findByText('Add Node Pools').should('be.visible');
+
+ cy.findByLabelText('Cluster Label').click();
+ cy.focused().type(mockCluster.label);
+
+ cy.findByText('LKE Enterprise').click();
+
+ ui.regionSelect.find().click().type(`${mockRegion.label}`);
+ ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
+
+ cy.findByLabelText('Kubernetes Version').should('be.visible').click();
+ cy.findByText(latestEnterpriseTierKubernetesVersion.id)
+ .should('be.visible')
+ .click();
- cy.visitWithLogin('/kubernetes/create');
- cy.findByText('Add Node Pools').should('be.visible');
+ // Confirms LKE-E Phase 2 VPC options do not display with the Dual Stack flag OFF.
+ cy.findByText('IP Stack').should('not.exist');
+ cy.findByText('IPv4', { exact: true }).should('not.exist');
+ cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
- cy.findByLabelText('Cluster Label').click();
- cy.focused().type(mockCluster.label);
+ // Confirms LKE-E Phase 2 VPC options display with the BYO VPC flag ON.
+ cy.findByText('Automatically generate a VPC for this cluster').should(
+ 'be.visible'
+ );
+ cy.findByText('Use an existing VPC').should('be.visible');
+
+ cy.findByText('Dedicated CPU').should('be.visible').click();
+ addNodes(mockPlan.formattedLabel);
+
+ // Bypass ACL validation
+ cy.get('input[name="acl-acknowledgement"]').check();
+
+ // Confirm change is reflected in checkout bar.
+ cy.get('[data-testid="kube-checkout-bar"]').within(() => {
+ cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
+ cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
+ 'be.visible'
+ );
+
+ cy.get('[data-qa-notice="true"]').within(() => {
+ cy.findByText(minimumNodeNotice).should('be.visible');
+ });
+
+ ui.button
+ .findByTitle('Create Cluster')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- cy.findByText('LKE Enterprise').click();
+ cy.wait('@createCluster');
+ cy.url().should(
+ 'endWith',
+ `/kubernetes/clusters/${mockCluster.id}/summary`
+ );
+ });
- ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
- ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
+ it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: false,
+ phase2Mtc: { byoVPC: false, dualStack: true },
+ },
+ }).as('getFeatureFlags');
- cy.findByLabelText('Kubernetes Version').should('be.visible').click();
- cy.findByText(latestEnterpriseTierKubernetesVersion.id)
- .should('be.visible')
- .click();
+ cy.visitWithLogin('/kubernetes/create');
+ cy.findByText('Add Node Pools').should('be.visible');
- // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON.
- cy.findByText('IP Stack').should('be.visible');
- cy.findByText('IPv4', { exact: true }).should('be.visible');
- cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
+ cy.findByLabelText('Cluster Label').click();
+ cy.focused().type(mockCluster.label);
- // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF.
- cy.findByText('Automatically generate a VPC for this cluster').should(
- 'not.exist'
- );
- cy.findByText('Use an existing VPC').should('not.exist');
+ cy.findByText('LKE Enterprise').click();
- cy.findByText('Shared CPU').should('be.visible').click();
- addNodes('Linode 2 GB');
+ ui.regionSelect.find().click().type(`${mockRegion.label}`);
+ ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
- // Bypass ACL validation
- cy.get('input[name="acl-acknowledgement"]').check();
+ cy.findByLabelText('Kubernetes Version').should('be.visible').click();
+ cy.findByText(latestEnterpriseTierKubernetesVersion.id)
+ .should('be.visible')
+ .click();
- // Confirm change is reflected in checkout bar.
- cy.get('[data-testid="kube-checkout-bar"]').within(() => {
- cy.findByText('Linode 2 GB Plan').should('be.visible');
- cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
+ // Confirms LKE-E Phase 2 IP Stack displays with the Dual Stack flag ON.
+ cy.findByText('IP Stack').should('be.visible');
+ cy.findByText('IPv4', { exact: true }).should('be.visible');
+ cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
- cy.get('[data-qa-notice="true"]').within(() => {
- cy.findByText(minimumNodeNotice).should('be.visible');
+ // Confirms LKE-E Phase 2 VPC options do not display with the BYO VPC flag OFF.
+ cy.findByText('Automatically generate a VPC for this cluster').should(
+ 'not.exist'
+ );
+ cy.findByText('Use an existing VPC').should('not.exist');
+
+ cy.findByText('Dedicated CPU').should('be.visible').click();
+ addNodes(mockPlan.formattedLabel);
+
+ // Bypass ACL validation
+ cy.get('input[name="acl-acknowledgement"]').check();
+
+ // Confirm change is reflected in checkout bar.
+ cy.get('[data-testid="kube-checkout-bar"]').within(() => {
+ cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
+ cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
+ 'be.visible'
+ );
+
+ cy.get('[data-qa-notice="true"]').within(() => {
+ cy.findByText(minimumNodeNotice).should('be.visible');
+ });
+
+ ui.button
+ .findByTitle('Create Cluster')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- ui.button
- .findByTitle('Create Cluster')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ cy.wait('@createCluster');
+ cy.url().should(
+ 'endWith',
+ `/kubernetes/clusters/${mockCluster.id}/summary`
+ );
});
- cy.wait('@createCluster');
- cy.url().should(
- 'endWith',
- `/kubernetes/clusters/${mockCluster.id}/summary`
- );
- });
+ it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: false,
+ phase2Mtc: { byoVPC: true, dualStack: true },
+ },
+ }).as('getFeatureFlags');
- it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- postLa: false,
- phase2Mtc: { byoVPC: true, dualStack: true },
- },
- }).as('getFeatureFlags');
+ cy.visitWithLogin('/kubernetes/create');
+ cy.findByText('Add Node Pools').should('be.visible');
- mockCreateCluster(mockCluster).as('createCluster');
- mockGetTieredKubernetesVersions('enterprise', [
- latestEnterpriseTierKubernetesVersion,
- ]).as('getTieredKubernetesVersions');
- mockGetRegions(mockRegions);
+ cy.findByLabelText('Cluster Label').click();
+ cy.focused().type(mockCluster.label);
- cy.visitWithLogin('/kubernetes/create');
- cy.findByText('Add Node Pools').should('be.visible');
+ cy.findByText('LKE Enterprise').click();
- cy.findByLabelText('Cluster Label').click();
- cy.focused().type(mockCluster.label);
+ ui.regionSelect.find().click().type(`${mockRegion.label}`);
+ ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
- cy.findByText('LKE Enterprise').click();
+ cy.findByLabelText('Kubernetes Version').should('be.visible').click();
+ cy.findByText(latestEnterpriseTierKubernetesVersion.id)
+ .should('be.visible')
+ .click();
- ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
- ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
+ // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON.
+ cy.findByText('IP Stack').should('be.visible');
+ cy.findByText('IPv4', { exact: true }).should('be.visible');
+ cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
+ cy.findByText('Automatically generate a VPC for this cluster').should(
+ 'be.visible'
+ );
+ cy.findByText('Use an existing VPC').should('be.visible');
+
+ cy.findByText('Dedicated CPU').should('be.visible').click();
+ addNodes(mockPlan.formattedLabel);
+
+ // Bypass ACL validation
+ cy.get('input[name="acl-acknowledgement"]').check();
+
+ // Confirm change is reflected in checkout bar.
+ cy.get('[data-testid="kube-checkout-bar"]').within(() => {
+ cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
+ cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
+ 'be.visible'
+ );
+
+ cy.get('[data-qa-notice="true"]').within(() => {
+ cy.findByText(minimumNodeNotice).should('be.visible');
+ });
+
+ ui.button
+ .findByTitle('Create Cluster')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- cy.findByLabelText('Kubernetes Version').should('be.visible').click();
- cy.findByText(latestEnterpriseTierKubernetesVersion.id)
- .should('be.visible')
- .click();
+ cy.wait('@createCluster');
+ cy.url().should(
+ 'endWith',
+ `/kubernetes/clusters/${mockCluster.id}/summary`
+ );
+ });
- // Confirms LKE-E Phase 2 IP Stack and VPC options display with both flags ON.
- cy.findByText('IP Stack').should('be.visible');
- cy.findByText('IPv4', { exact: true }).should('be.visible');
- cy.findByText('IPv4 + IPv6 (dual-stack)').should('be.visible');
- cy.findByText('Automatically generate a VPC for this cluster').should(
- 'be.visible'
- );
- cy.findByText('Use an existing VPC').should('be.visible');
+ it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: false,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ },
+ }).as('getFeatureFlags');
- cy.findByText('Shared CPU').should('be.visible').click();
- addNodes('Linode 2 GB');
+ cy.visitWithLogin('/kubernetes/create');
+ cy.findByText('Add Node Pools').should('be.visible');
- // Bypass ACL validation
- cy.get('input[name="acl-acknowledgement"]').check();
+ cy.findByLabelText('Cluster Label').click();
+ cy.focused().type(mockCluster.label);
- // Confirm change is reflected in checkout bar.
- cy.get('[data-testid="kube-checkout-bar"]').within(() => {
- cy.findByText('Linode 2 GB Plan').should('be.visible');
- cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
+ cy.findByText('LKE Enterprise').click();
- cy.get('[data-qa-notice="true"]').within(() => {
- cy.findByText(minimumNodeNotice).should('be.visible');
- });
+ ui.regionSelect.find().click().type(`${mockRegion.label}`);
+ ui.regionSelect.findItemByRegionId(mockRegion.id, [mockRegion]).click();
- ui.button
- .findByTitle('Create Cluster')
+ cy.findByLabelText('Kubernetes Version').should('be.visible').click();
+ cy.findByText(latestEnterpriseTierKubernetesVersion.id)
.should('be.visible')
- .should('be.enabled')
.click();
- });
-
- cy.wait('@createCluster');
- cy.url().should(
- 'endWith',
- `/kubernetes/clusters/${mockCluster.id}/summary`
- );
- });
-
- it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- postLa: false,
- phase2Mtc: { byoVPC: false, dualStack: false },
- },
- }).as('getFeatureFlags');
- mockCreateCluster(mockCluster).as('createCluster');
- mockGetTieredKubernetesVersions('enterprise', [
- latestEnterpriseTierKubernetesVersion,
- ]).as('getTieredKubernetesVersions');
- mockGetRegions(mockRegions);
-
- cy.visitWithLogin('/kubernetes/create');
- cy.findByText('Add Node Pools').should('be.visible');
-
- cy.findByLabelText('Cluster Label').click();
- cy.focused().type(mockCluster.label);
+ // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF.
+ cy.findByText('IP Stack').should('not.exist');
+ cy.findByText('IPv4', { exact: true }).should('not.exist');
+ cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
+ cy.findByText('Automatically generate a VPC for this cluster').should(
+ 'not.exist'
+ );
+ cy.findByText('Use an existing VPC').should('not.exist');
+
+ cy.findByText('Dedicated CPU').should('be.visible').click();
+ addNodes(mockPlan.formattedLabel);
+
+ // Bypass ACL validation
+ cy.get('input[name="acl-acknowledgement"]').check();
+
+ // Confirm change is reflected in checkout bar.
+ cy.get('[data-testid="kube-checkout-bar"]').within(() => {
+ cy.findByText(`${mockPlan.formattedLabel} Plan`).should('be.visible');
+ cy.findByTitle(`Remove ${mockPlan.label} Node Pool`).should(
+ 'be.visible'
+ );
+
+ cy.get('[data-qa-notice="true"]').within(() => {
+ cy.findByText(minimumNodeNotice).should('be.visible');
+ });
+
+ ui.button
+ .findByTitle('Create Cluster')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
- cy.findByText('LKE Enterprise').click();
+ cy.wait('@createCluster');
+ cy.url().should(
+ 'endWith',
+ `/kubernetes/clusters/${mockCluster.id}/summary`
+ );
+ });
+ });
- ui.regionSelect.find().click().type(`${mockRegions[0].label}`);
- ui.regionSelect.findItemByRegionId(mockRegions[0].id).click();
+ describe('Phase 2 MTC & Post-LA feature flags', () => {
+ it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: true,
+ phase2Mtc: { byoVPC: true, dualStack: true },
+ },
+ });
- cy.findByLabelText('Kubernetes Version').should('be.visible').click();
- cy.findByText(latestEnterpriseTierKubernetesVersion.id)
- .should('be.visible')
- .click();
+ cy.visitWithLogin('/kubernetes/create');
- // Confirms LKE-E Phase 2 IP Stack and VPC options do not display with both flags OFF.
- cy.findByText('IP Stack').should('not.exist');
- cy.findByText('IPv4', { exact: true }).should('not.exist');
- cy.findByText('IPv4 + IPv6 (dual-stack)').should('not.exist');
- cy.findByText('Automatically generate a VPC for this cluster').should(
- 'not.exist'
- );
- cy.findByText('Use an existing VPC').should('not.exist');
+ lkeClusterCreatePage.setLabel(randomLabel());
+ lkeClusterCreatePage.selectClusterTier('enterprise');
+ lkeClusterCreatePage.selectRegionById(mockRegion.id, [mockRegion]);
+ lkeClusterCreatePage.selectPlanTab('Dedicated CPU');
- cy.findByText('Shared CPU').should('be.visible').click();
- addNodes('Linode 2 GB');
+ // Confirm that IP stack selection and VPC options are present.
+ cy.findByText('IPv4')
+ .should('be.visible')
+ .closest('input')
+ .should('be.enabled');
- // Bypass ACL validation
- cy.get('input[name="acl-acknowledgement"]').check();
+ cy.findByText('IPv4 + IPv6 (dual-stack)')
+ .should('be.visible')
+ .closest('input')
+ .should('be.enabled');
- // Confirm change is reflected in checkout bar.
- cy.get('[data-testid="kube-checkout-bar"]').within(() => {
- cy.findByText('Linode 2 GB Plan').should('be.visible');
- cy.findByTitle('Remove Linode 2GB Node Pool').should('be.visible');
+ cy.findByText('Automatically generate a VPC for this cluster')
+ .should('be.visible')
+ .closest('input')
+ .should('be.enabled');
- cy.get('[data-qa-notice="true"]').within(() => {
- cy.findByText(minimumNodeNotice).should('be.visible');
+ cy.findByText('Use an existing VPC')
+ .should('be.visible')
+ .closest('input')
+ .should('be.enabled');
+
+ // Confirm that node pools are configured via new drawer rather than directly within table.
+ lkeClusterCreatePage.selectNodePoolPlan(mockPlan.formattedLabel);
+ lkeClusterCreatePage.withinNodePoolDrawer(mockPlan.formattedLabel, () => {
+ // Confirm that Enterprise-tier specific options are present.
+ cy.findByText('Update Strategy').should('be.visible');
+ cy.findByText('Use default firewall').should('be.visible');
+ cy.findByText('Select existing firewall').should('be.visible');
+
+ ui.button
+ .findByTitle('Add Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
});
- ui.button
- .findByTitle('Create Cluster')
- .should('be.visible')
- .should('be.enabled')
- .click();
+ lkeClusterCreatePage.withinOrderSummary(() => {
+ cy.contains(mockPlan.formattedLabel)
+ .closest('[data-testid="node-pool-summary"]')
+ .within(() => {
+ cy.findByText('3 Nodes').should('be.visible');
+ cy.findByText('Edit Configuration').should('be.visible');
+ });
+ });
});
-
- cy.wait('@createCluster');
- cy.url().should(
- 'endWith',
- `/kubernetes/clusters/${mockCluster.id}/summary`
- );
});
});
@@ -373,129 +535,265 @@ describe('LKE-E Cluster Read', () => {
],
})
).as('getAccount');
- });
-
- it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- phase2Mtc: { byoVPC: true, dualStack: false },
- },
- }).as('getFeatureFlags');
-
mockGetClusters([mockCluster]).as('getClusters');
mockGetCluster(mockCluster).as('getCluster');
mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
mockGetVPC(mockVPC).as('getVPC');
+ });
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
- cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
-
- // Confirm linked VPC is present
- cy.get('[data-qa-kube-entity-footer]').within(() => {
- cy.contains('VPC:').should('exist');
- cy.findByTestId('assigned-lke-cluster-label').should(
- 'contain.text',
- mockVPC.label
- );
- });
+ /*
+ * Smoke tests to confirm the state of the LKE cluster details page when the
+ * LKE-E "phase2Mtc" feature flag is enabled.
+ */
+ describe('Phase 2 MTC feature flag', () => {
+ /*
+ * - Confirms the state of the LKE cluster details page when the Phase 2 BYO VPC feature is enabled.
+ * - Confirms that attached VPC label is displayed in the cluster summary.
+ * - Confirms that VPC IP columns are not present when Phase 2 dual stack flag is disabled.
+ */
+ it('Simple Page Check - Phase 2 MTC BYO VPC Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: true, dualStack: false },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
+
+ // Confirm linked VPC is present
+ cy.get('[data-qa-kube-entity-footer]').within(() => {
+ cy.contains('VPC:').should('exist');
+ cy.findByTestId('assigned-lke-cluster-label').should(
+ 'contain.text',
+ mockVPC.label
+ );
+ });
- // Confirm VPC IP columns are not present in the node table header
- cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
- cy.contains('th', 'VPC IPv4').should('not.exist');
- cy.contains('th', 'VPC IPv6').should('not.exist');
+ // Confirm VPC IP columns are not present in the node table header
+ cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
+ cy.contains('th', 'VPC IPv4').should('not.exist');
+ cy.contains('th', 'VPC IPv6').should('not.exist');
+ });
});
- });
- it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- phase2Mtc: { byoVPC: false, dualStack: true },
- },
- }).as('getFeatureFlags');
+ /*
+ * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack feature is enabled.
+ * - Confirms that VPC node pool table IP columns are present.
+ * - Confirms that attached VPC label is absent in the cluster summary when the BYO VPC feature is disabled.
+ */
+ it('Simple Page Check - Phase 2 MTC Dual Stack Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: false, dualStack: true },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ cy.wait(['@getCluster', '@getNodePools']);
+
+ // Confirm linked VPC is not present
+ cy.get('[data-qa-kube-entity-footer]').within(() => {
+ cy.contains('VPC:').should('not.exist');
+ cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
+ });
- mockGetClusters([mockCluster]).as('getClusters');
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
+ // Confirm VPC IP columns are present in the node table header
+ cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
+ cy.contains('th', 'VPC IPv4').should('be.visible');
+ cy.contains('th', 'VPC IPv6').should('be.visible');
+ });
+ });
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
- cy.wait(['@getCluster', '@getNodePools']);
+ /*
+ * - Confirms the state of the LKE cluster details page when the Phase 2 dual stack and BYO VPC features are enabled.
+ * - Confirms that VPC node pool table IP columns are present.
+ * - Confirms that attached VPC label is displayed in the cluster summary.
+ */
+ it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: true, dualStack: true },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
+
+ // Confirm linked VPC is present
+ cy.get('[data-qa-kube-entity-footer]').within(() => {
+ cy.contains('VPC:').should('exist');
+ cy.findByTestId('assigned-lke-cluster-label').should(
+ 'contain.text',
+ mockVPC.label
+ );
+ });
- // Confirm linked VPC is not present
- cy.get('[data-qa-kube-entity-footer]').within(() => {
- cy.contains('VPC:').should('not.exist');
- cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
+ // Confirm VPC IP columns are present in the node table header
+ cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
+ cy.contains('th', 'VPC IPv4').should('be.visible');
+ cy.contains('th', 'VPC IPv6').should('be.visible');
+ });
});
- // Confirm VPC IP columns are present in the node table header
- cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
- cy.contains('th', 'VPC IPv4').should('be.visible');
- cy.contains('th', 'VPC IPv6').should('be.visible');
+ /*
+ * - Confirms the state of the LKE cluster details page when the "phase2Mtc" feature is disabled.
+ * - Confirms that no VPC label is shown in the cluster summary.
+ * - Confirms that IPv4 and IPv6 node pool table columns are absent.
+ */
+ it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ cy.wait(['@getCluster', '@getNodePools']);
+
+ // Confirm linked VPC is not present
+ cy.get('[data-qa-kube-entity-footer]').within(() => {
+ cy.contains('VPC:').should('not.exist');
+ cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
+ });
+
+ // Confirm VPC IP columns are not present in the node table header
+ cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
+ cy.contains('th', 'VPC IPv4').should('not.exist');
+ cy.contains('th', 'VPC IPv6').should('not.exist');
+ });
});
});
- it('Simple Page Check - Phase 2 MTC Flags Both ON', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- phase2Mtc: { byoVPC: true, dualStack: true },
- },
- }).as('getFeatureFlags');
-
- mockGetClusters([mockCluster]).as('getClusters');
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
- mockGetVPC(mockVPC).as('getVPC');
-
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
- cy.wait(['@getCluster', '@getVPC', '@getNodePools']);
+ /*
+ * Smoke tests to confirm the state of the LKE cluster details page when the
+ * LKE-E "postLa" feature flag is enabled and disabled.
+ */
+ describe('Post-LA feature flags', () => {
+ /*
+ * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is enabled.
+ * - Confirms that update strategy and firewall options are present in the Add Node Pool drawer.
+ */
+ it('Simple Page Check - Post-LA Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ postLa: true,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
- // Confirm linked VPC is present
- cy.get('[data-qa-kube-entity-footer]').within(() => {
- cy.contains('VPC:').should('exist');
- cy.findByTestId('assigned-lke-cluster-label').should(
- 'contain.text',
- mockVPC.label
- );
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Update Strategy').scrollIntoView();
+ cy.findByText('Update Strategy').should('be.visible');
+ cy.findByText('Use default firewall').should('be.visible');
+ cy.findByText('Select existing firewall').should('be.visible');
+ });
});
- // Confirm VPC IP columns are present in the node table header
- cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
- cy.contains('th', 'VPC IPv4').should('be.visible');
- cy.contains('th', 'VPC IPv6').should('be.visible');
+ /*
+ * - Confirms the state of the LKE cluster details page when the "postLa" feature flag is disabled.
+ * - Confirms that update strategy and firewall options are absent in the Add Node Pool drawer.
+ */
+ it('Simple Page Check - Post-LA Flag OFF', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: false, dualStack: false },
+ postLa: false,
+ },
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Update Strategy').should('not.exist');
+ cy.findByText('Use default firewall').should('not.exist');
+ cy.findByText('Select existing firewall').should('not.exist');
+ });
});
});
- it('Simple Page Check - Phase 2 MTC Flags Both OFF', () => {
- mockAppendFeatureFlags({
- lkeEnterprise2: {
- enabled: true,
- la: true,
- phase2Mtc: { byoVPC: false, dualStack: false },
- },
- }).as('getFeatureFlags');
+ /*
+ * Smoke tests to confirm the state of the LKE cluster details page when the
+ * 'phase2Mtc' and 'postLa' LKE-E feature flags are both enabled.
+ */
+ describe('Phase 2 MTC & Post-LA feature flags', () => {
+ /*
+ * - Confirms the state of LKE details page when "phase2Mtc" and "postLa" are both enabled.
+ * - Confirms that update strategy and Firewall options are present in Add Node Pool drawer.
+ * - Confirms that attached VPC is shown in the summary, and IPv4 and IPv6 node pool table columns are present.
+ */
+ it('Simple Page Check - Phase 2 MTC Flags and Post-LA Flag ON', () => {
+ mockAppendFeatureFlags({
+ lkeEnterprise2: {
+ enabled: true,
+ la: true,
+ phase2Mtc: { byoVPC: true, dualStack: true },
+ postLa: true,
+ },
+ });
- mockGetClusters([mockCluster]).as('getClusters');
- mockGetCluster(mockCluster).as('getCluster');
- mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');
+ cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
- cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}/summary`);
- cy.wait(['@getCluster', '@getNodePools']);
+ // Confirm that VPC label is shown in summary, and that IPv4 and IPv6
+ // node pool table columns are present.
+ cy.get('[data-qa-kube-entity-footer]').within(() => {
+ cy.contains('VPC:').should('exist');
+ cy.findByTestId('assigned-lke-cluster-label').should(
+ 'contain.text',
+ mockVPC.label
+ );
+ });
- // Confirm linked VPC is not present
- cy.get('[data-qa-kube-entity-footer]').within(() => {
- cy.contains('VPC:').should('not.exist');
- cy.findByTestId('assigned-lke-cluster-label').should('not.exist');
- });
+ cy.findByLabelText('List of Your Cluster Nodes').within(() => {
+ cy.contains('th', 'VPC IPv4').should('be.visible');
+ cy.contains('th', 'VPC IPv6').should('be.visible');
+ });
- // Confirm VPC IP columns are not present in the node table header
- cy.get('[aria-label="List of Your Cluster Nodes"] thead').within(() => {
- cy.contains('th', 'VPC IPv4').should('not.exist');
- cy.contains('th', 'VPC IPv6').should('not.exist');
+ ui.button
+ .findByTitle('Add a Node Pool')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.drawer
+ .findByTitle(`Add a Node Pool: ${mockCluster.label}`)
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Update Strategy').scrollIntoView();
+ cy.findByText('Update Strategy').should('be.visible');
+ cy.findByText('Use default firewall').should('be.visible');
+ cy.findByText('Select existing firewall').should('be.visible');
+ });
});
});
});
diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts
index 27a8236571f..da8a091b869 100644
--- a/packages/manager/cypress/support/intercepts/cloudpulse.ts
+++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts
@@ -595,4 +595,4 @@ export const mockGetCloudPulseServiceByType = (
apiMatcher(`monitor/services/${serviceType}`),
makeResponse(service)
);
-};
\ No newline at end of file
+};
diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
index 16c242cf062..c533c062fa5 100644
--- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
+++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx
@@ -114,7 +114,10 @@ export const SelectFirewallPanel = (props: Props) => {
value={selectedFirewall}
/>
-
+
Create Firewall
diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
index b9979009e1a..aa83c7492de 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterValue/utils.test.ts
@@ -122,7 +122,9 @@ describe('Utils', () => {
];
it('should return matched resources by entity IDs', () => {
- expect(getFilteredFirewallParentEntities(resources, ['1'])).toEqual(['a']);
+ expect(getFilteredFirewallParentEntities(resources, ['1'])).toEqual([
+ 'a',
+ ]);
});
it('should return empty array if no match', () => {
@@ -131,7 +133,9 @@ describe('Utils', () => {
it('should handle undefined inputs', () => {
expect(getFilteredFirewallParentEntities(undefined, ['1'])).toEqual([]);
- expect(getFilteredFirewallParentEntities(resources, undefined)).toEqual([]);
+ expect(getFilteredFirewallParentEntities(resources, undefined)).toEqual(
+ []
+ );
});
});
From d3a83c85365f2c79e0722a7e4c1bdea6f09b1295 Mon Sep 17 00:00:00 2001
From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
Date: Mon, 22 Sep 2025 14:38:01 +0200
Subject: [PATCH 11/54] fix: [UIE-9134] - IAM RBAC: fetch all entities
client-side to avoid missing items (#12888)
* fetch all entities
* changeset
* add CRUD MSW support for entities
---------
Co-authored-by: Alban Bailly
---
packages/manager/src/dev-tools/load.ts | 1 +
.../AssignedPermissionsPanel.test.tsx | 13 +++--
.../AssignedRolesTable.test.tsx | 21 ++++----
.../AssignedRolesTable/AssignedRolesTable.tsx | 10 ++--
.../UpdateEntitiesDrawer.test.tsx | 9 ++--
.../IAM/Shared/Entities/Entities.test.tsx | 21 ++++----
.../features/IAM/Shared/Entities/Entities.tsx | 7 +--
.../AssignedEntitiesTable.test.tsx | 21 ++++----
.../UserEntities/AssignedEntitiesTable.tsx | 6 +--
.../Users/UserEntities/UserEntities.test.tsx | 10 ++--
.../IAM/Users/UserRoles/UserRoles.test.tsx | 21 ++++----
packages/manager/src/mocks/mockState.ts | 1 +
.../src/mocks/presets/baseline/crud.ts | 2 +
.../src/mocks/presets/crud/entities.ts | 10 ++++
.../mocks/presets/crud/handlers/entities.ts | 50 +++++++++++++++++++
.../src/mocks/presets/crud/seeds/entities.ts | 28 +++++++++++
.../src/mocks/presets/crud/seeds/index.ts | 2 +
packages/manager/src/mocks/types.ts | 4 ++
.../manager/src/queries/entities/entities.ts | 25 ++++++++--
.../manager/src/queries/entities/queries.ts | 26 ++++++++--
.../pr-12888-added-1758193857654.md | 5 ++
21 files changed, 211 insertions(+), 82 deletions(-)
create mode 100644 packages/manager/src/mocks/presets/crud/entities.ts
create mode 100644 packages/manager/src/mocks/presets/crud/handlers/entities.ts
create mode 100644 packages/manager/src/mocks/presets/crud/seeds/entities.ts
create mode 100644 packages/queries/.changeset/pr-12888-added-1758193857654.md
diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts
index 489e4989c09..c810a6bcc5f 100644
--- a/packages/manager/src/dev-tools/load.ts
+++ b/packages/manager/src/dev-tools/load.ts
@@ -92,6 +92,7 @@ export async function loadDevTools() {
...initialContext.firewalls,
...(seedContext?.firewalls || []),
],
+ entities: [...initialContext.entities, ...(seedContext?.entities || [])],
kubernetesClusters: [
...initialContext.kubernetesClusters,
...(seedContext?.kubernetesClusters || []),
diff --git a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
index beecaadb47e..3f5a7e79ef8 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedPermissionsPanel/AssignedPermissionsPanel.test.tsx
@@ -2,7 +2,6 @@ import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { AssignedPermissionsPanel } from './AssignedPermissionsPanel';
@@ -10,14 +9,14 @@ import { AssignedPermissionsPanel } from './AssignedPermissionsPanel';
import type { ExtendedRole } from '../utilities';
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
}));
vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -84,8 +83,8 @@ describe('AssignedPermissionsPanel', () => {
});
it('renders with the correct context when the access is an entity', () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
@@ -107,8 +106,8 @@ describe('AssignedPermissionsPanel', () => {
});
it('renders the Autocomplete when the access is an entity', () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
index 4bcdc69e072..ecd6448b229 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx
@@ -5,13 +5,12 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { AssignedRolesTable } from './AssignedRolesTable';
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
@@ -30,7 +29,7 @@ vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -80,8 +79,8 @@ describe('AssignedRolesTable', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -108,8 +107,8 @@ describe('AssignedRolesTable', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -131,8 +130,8 @@ describe('AssignedRolesTable', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -154,8 +153,8 @@ describe('AssignedRolesTable', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
index f4d8e93dff5..cb20cbaac04 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx
@@ -15,7 +15,7 @@ import { TableRow } from 'src/components/TableRow';
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableSortCell } from 'src/components/TableSortCell/TableSortCell';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
-import { useAccountEntities } from 'src/queries/entities/entities';
+import { useAllAccountEntities } from 'src/queries/entities/entities';
import { usePermissions } from '../../hooks/usePermissions';
import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities';
@@ -135,11 +135,13 @@ export const AssignedRolesTable = () => {
const { data: accountRoles, isLoading: accountPermissionsLoading } =
useAccountRoles();
- const { data: entities, isLoading: entitiesLoading } = useAccountEntities();
+ const { data: entities, isLoading: entitiesLoading } = useAllAccountEntities(
+ {}
+ );
+
const { data: assignedRoles, isLoading: assignedRolesLoading } = useUserRoles(
username ?? ''
);
-
const { filterableOptions, roles } = React.useMemo(() => {
if (!assignedRoles || !accountRoles) {
return { filterableOptions: [], roles: [] };
@@ -154,7 +156,7 @@ export const AssignedRolesTable = () => {
];
if (entities) {
- const transformedEntities = groupAccountEntitiesByType(entities.data);
+ const transformedEntities = groupAccountEntitiesByType(entities);
roles = addEntitiesNamesToRoles(roles, transformedEntities);
}
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
index 43657751ec8..ecbcf169880 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/UpdateEntitiesDrawer.test.tsx
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { UpdateEntitiesDrawer } from './UpdateEntitiesDrawer';
@@ -11,7 +10,7 @@ import { UpdateEntitiesDrawer } from './UpdateEntitiesDrawer';
import type { ExtendedRoleView } from '../types';
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
useParams: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
@@ -51,7 +50,7 @@ vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -113,8 +112,8 @@ describe('UpdateEntitiesDrawer', () => {
});
it('should allow updating entities', async () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
queryMocks.useUserRoles.mockReturnValue({
data: {
diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
index c27a4f682cd..abb88d4ea26 100644
--- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { Entities } from './Entities';
@@ -11,14 +10,14 @@ import { Entities } from './Entities';
import type { EntitiesOption } from '../types';
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
}));
vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -82,8 +81,8 @@ describe('Entities', () => {
});
it('renders correct data when it is an entity access', () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
@@ -108,8 +107,8 @@ describe('Entities', () => {
});
it('renders correct data when it is an entity access', () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
@@ -134,8 +133,8 @@ describe('Entities', () => {
});
it('renders correct options in Autocomplete dropdown when it is an entity access', async () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
@@ -156,8 +155,8 @@ describe('Entities', () => {
});
it('updates selected options when Autocomplete value changes when it is an entity access', async () => {
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme(
diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
index 50db9a551e5..bea517a7611 100644
--- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
+++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx
@@ -4,7 +4,7 @@ import React from 'react';
import { FormLabel } from 'src/components/FormLabel';
import { Link } from 'src/components/Link';
-import { useAccountEntities } from 'src/queries/entities/entities';
+import { useAllAccountEntities } from 'src/queries/entities/entities';
import { getFormattedEntityType } from '../utilities';
import {
@@ -34,14 +34,15 @@ export const Entities = ({
type,
value,
}: Props) => {
- const { data: entities } = useAccountEntities();
+ const { data: entities } = useAllAccountEntities({});
const theme = useTheme();
const memoizedEntities = React.useMemo(() => {
if (access !== 'entity_access' || !entities) {
return [];
}
- const typeEntities = getEntitiesByType(type, entities.data);
+ const typeEntities = getEntitiesByType(type, entities);
+
return typeEntities ? mapEntitiesToOptions(typeEntities) : [];
}, [entities, access, type]);
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
index fe06dd6582f..d0c06967d7d 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx
@@ -4,13 +4,12 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { userRolesFactory } from 'src/factories/userRoles';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable';
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
useParams: vi.fn().mockReturnValue({}),
useSearch: vi.fn().mockReturnValue({}),
useUserRoles: vi.fn().mockReturnValue({}),
@@ -28,7 +27,7 @@ vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -74,8 +73,8 @@ describe('AssignedEntitiesTable', () => {
data: userRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -99,8 +98,8 @@ describe('AssignedEntitiesTable', () => {
data: userRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -118,8 +117,8 @@ describe('AssignedEntitiesTable', () => {
data: userRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -137,8 +136,8 @@ describe('AssignedEntitiesTable', () => {
data: userRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
index a1fd2a6ee39..fc922a54522 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx
@@ -18,7 +18,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError';
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
import { TableSortCell } from 'src/components/TableSortCell';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
-import { useAccountEntities } from 'src/queries/entities/entities';
+import { useAllAccountEntities } from 'src/queries/entities/entities';
import { usePermissions } from '../../hooks/usePermissions';
import { ENTITIES_TABLE_PREFERENCE_KEY } from '../../Shared/constants';
@@ -90,7 +90,7 @@ export const AssignedEntitiesTable = () => {
data: entities,
error: entitiesError,
isLoading: entitiesLoading,
- } = useAccountEntities();
+ } = useAllAccountEntities({});
const {
data: assignedRoles,
@@ -102,7 +102,7 @@ export const AssignedEntitiesTable = () => {
if (!assignedRoles || !entities) {
return { filterableOptions: [], roles: [] };
}
- const transformedEntities = groupAccountEntitiesByType(entities.data);
+ const transformedEntities = groupAccountEntitiesByType(entities);
const roles = addEntityNamesToRoles(assignedRoles, transformedEntities);
diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
index 22034c79276..1858996f4ea 100644
--- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.test.tsx
@@ -5,7 +5,6 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import {
@@ -23,7 +22,7 @@ const mockEntities = [
];
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
useParams: vi.fn().mockReturnValue({}),
useSearch: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
@@ -44,7 +43,7 @@ vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -121,14 +120,13 @@ describe('UserEntities', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
expect(screen.queryByText('Assign New Roles')).toBeNull();
-
expect(screen.getByText('firewall_admin')).toBeVisible();
expect(screen.getByText('firewall-1')).toBeVisible();
diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
index a14d7e6cf9d..3565e7d17c2 100644
--- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx
@@ -5,7 +5,6 @@ import React from 'react';
import { accountEntityFactory } from 'src/factories/accountEntities';
import { accountRolesFactory } from 'src/factories/accountRoles';
import { userRolesFactory } from 'src/factories/userRoles';
-import { makeResourcePage } from 'src/mocks/serverHandlers';
import { renderWithTheme } from 'src/utilities/testHelpers';
import {
@@ -22,7 +21,7 @@ const mockEntities = [
];
const queryMocks = vi.hoisted(() => ({
- useAccountEntities: vi.fn().mockReturnValue({}),
+ useAllAccountEntities: vi.fn().mockReturnValue({}),
useParams: vi.fn().mockReturnValue({}),
useSearch: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
@@ -43,7 +42,7 @@ vi.mock('src/queries/entities/entities', async () => {
const actual = await vi.importActual('src/queries/entities/entities');
return {
...actual,
- useAccountEntities: queryMocks.useAccountEntities,
+ useAllAccountEntities: queryMocks.useAllAccountEntities,
};
});
@@ -108,8 +107,8 @@ describe('UserRoles', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -140,8 +139,8 @@ describe('UserRoles', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -167,8 +166,8 @@ describe('UserRoles', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
@@ -185,8 +184,8 @@ describe('UserRoles', () => {
data: accountRolesFactory.build(),
});
- queryMocks.useAccountEntities.mockReturnValue({
- data: makeResourcePage(mockEntities),
+ queryMocks.useAllAccountEntities.mockReturnValue({
+ data: mockEntities,
});
renderWithTheme();
diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts
index 2a6d78f4d1e..56f9b46ede9 100644
--- a/packages/manager/src/mocks/mockState.ts
+++ b/packages/manager/src/mocks/mockState.ts
@@ -27,6 +27,7 @@ export const emptyStore: MockState = {
destinations: [],
domainRecords: [],
domains: [],
+ entities: [],
eventQueue: [],
firewallDevices: [],
firewalls: [],
diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts
index 51b4519e118..37df11b0d68 100644
--- a/packages/manager/src/mocks/presets/baseline/crud.ts
+++ b/packages/manager/src/mocks/presets/baseline/crud.ts
@@ -7,6 +7,7 @@ import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes';
import { cloudNATCrudPreset } from '../crud/cloudnats';
import { domainCrudPreset } from '../crud/domains';
+import { entityCrudPreset } from '../crud/entities';
import { firewallCrudPreset } from '../crud/firewalls';
import { kubernetesCrudPreset } from '../crud/kubernetes';
import { nodeBalancerCrudPreset } from '../crud/nodebalancers';
@@ -24,6 +25,7 @@ export const baselineCrudPreset: MockPresetBaseline = {
...cloudNATCrudPreset.handlers,
...domainCrudPreset.handlers,
...deliveryCrudPreset.handlers,
+ ...entityCrudPreset.handlers,
...firewallCrudPreset.handlers,
...kubernetesCrudPreset.handlers,
...linodeCrudPreset.handlers,
diff --git a/packages/manager/src/mocks/presets/crud/entities.ts b/packages/manager/src/mocks/presets/crud/entities.ts
new file mode 100644
index 00000000000..fa735b49440
--- /dev/null
+++ b/packages/manager/src/mocks/presets/crud/entities.ts
@@ -0,0 +1,10 @@
+import { getEntities } from 'src/mocks/presets/crud/handlers/entities';
+
+import type { MockPresetCrud } from 'src/mocks/types';
+
+export const entityCrudPreset: MockPresetCrud = {
+ group: { id: 'Entities' },
+ handlers: [getEntities],
+ id: 'entities:crud',
+ label: 'Entities CRUD',
+};
diff --git a/packages/manager/src/mocks/presets/crud/handlers/entities.ts b/packages/manager/src/mocks/presets/crud/handlers/entities.ts
new file mode 100644
index 00000000000..f39ae9e6b81
--- /dev/null
+++ b/packages/manager/src/mocks/presets/crud/handlers/entities.ts
@@ -0,0 +1,50 @@
+import { http } from 'msw';
+
+import { mswDB } from 'src/mocks/indexedDB';
+import {
+ makeNotFoundResponse,
+ makePaginatedResponse,
+ makeResponse,
+} from 'src/mocks/utilities/response';
+
+import type { Entity } from '@linode/api-v4';
+import type { StrictResponse } from 'msw';
+import type {
+ APIErrorResponse,
+ APIPaginatedResponse,
+} from 'src/mocks/utilities/response';
+
+export const getEntities = () => [
+ http.get(
+ '*/v4*/entities',
+ async ({
+ request,
+ }): Promise<
+ StrictResponse>
+ > => {
+ const entities = await mswDB.getAll('entities');
+
+ if (!entities) {
+ return makeNotFoundResponse();
+ }
+ return makePaginatedResponse({
+ data: entities,
+ request,
+ });
+ }
+ ),
+
+ http.get(
+ '*/v4*/entities/:id',
+ async ({ params }): Promise> => {
+ const id = Number(params.id);
+ const entity = await mswDB.get('entities', id);
+
+ if (!entity) {
+ return makeNotFoundResponse();
+ }
+
+ return makeResponse(entity);
+ }
+ ),
+];
diff --git a/packages/manager/src/mocks/presets/crud/seeds/entities.ts b/packages/manager/src/mocks/presets/crud/seeds/entities.ts
new file mode 100644
index 00000000000..7bbfd8c9cbc
--- /dev/null
+++ b/packages/manager/src/mocks/presets/crud/seeds/entities.ts
@@ -0,0 +1,28 @@
+import { getSeedsCountMap } from 'src/dev-tools/utils';
+import { entityFactory } from 'src/factories';
+import { mswDB } from 'src/mocks/indexedDB';
+
+import type { MockSeeder, MockState } from 'src/mocks/types';
+
+export const entitiesSeeder: MockSeeder = {
+ canUpdateCount: true,
+ desc: 'Entities Seeds',
+ group: { id: 'Entities' },
+ id: 'entities:crud',
+ label: 'Entities',
+
+ seeder: async (mockState: MockState) => {
+ const seedsCountMap = getSeedsCountMap();
+ const count = seedsCountMap[entitiesSeeder.id] ?? 0;
+ const entities = entityFactory.buildList(count);
+
+ const updatedMockState = {
+ ...mockState,
+ entities: mockState.entities.concat(entities),
+ };
+
+ await mswDB.saveStore(updatedMockState, 'seedState');
+
+ return updatedMockState;
+ },
+};
diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts
index c46a700914a..7a21e2e67f7 100644
--- a/packages/manager/src/mocks/presets/crud/seeds/index.ts
+++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts
@@ -1,5 +1,6 @@
import { cloudNATSeeder } from './cloudnats';
import { domainSeeder } from './domains';
+import { entitiesSeeder } from './entities';
import { firewallSeeder } from './firewalls';
import { kubernetesSeeder } from './kubernetes';
import { linodesSeeder } from './linodes';
@@ -13,6 +14,7 @@ import { vpcSeeder } from './vpcs';
export const dbSeeders = [
cloudNATSeeder,
domainSeeder,
+ entitiesSeeder,
firewallSeeder,
ipAddressSeeder,
kubernetesSeeder,
diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts
index 5b13155d66c..8a1dc9e45f7 100644
--- a/packages/manager/src/mocks/types.ts
+++ b/packages/manager/src/mocks/types.ts
@@ -4,6 +4,7 @@ import type {
Destination,
Domain,
DomainRecord,
+ Entity,
Event,
Firewall,
FirewallDevice,
@@ -123,6 +124,7 @@ export type MockPresetCrudGroup = {
| 'CloudNATs'
| 'Delivery'
| 'Domains'
+ | 'Entities'
| 'Firewalls'
| 'IP Addresses'
| 'Kubernetes'
@@ -138,6 +140,7 @@ export type MockPresetCrudId =
| 'cloudnats:crud'
| 'delivery:crud'
| 'domains:crud'
+ | 'entities:crud'
| 'firewalls:crud'
| 'ip-addresses:crud'
| 'kubernetes:crud'
@@ -165,6 +168,7 @@ export interface MockState {
destinations: Destination[];
domainRecords: DomainRecord[];
domains: Domain[];
+ entities: Entity[];
eventQueue: Event[];
firewallDevices: [number, FirewallDevice][]; // number is Firewall ID
firewalls: Firewall[];
diff --git a/packages/manager/src/queries/entities/entities.ts b/packages/manager/src/queries/entities/entities.ts
index 0f3dc308d1d..50e6fe5525a 100644
--- a/packages/manager/src/queries/entities/entities.ts
+++ b/packages/manager/src/queries/entities/entities.ts
@@ -3,11 +3,26 @@ import { useQuery } from '@tanstack/react-query';
import { entitiesQueries } from './queries';
-import type { AccountEntity, APIError, ResourcePage } from '@linode/api-v4';
+import type {
+ AccountEntity,
+ APIError,
+ Filter,
+ Params,
+ ResourcePage,
+} from '@linode/api-v4';
-export const useAccountEntities = () => {
- return useQuery, APIError[]>({
- ...entitiesQueries.entities,
+export const useAllAccountEntities = ({
+ enabled = true,
+ filter = {},
+ params = {},
+}) =>
+ useQuery({
+ enabled,
+ ...entitiesQueries.all(params, filter),
+ });
+
+export const useAccountEntities = (params: Params, filter: Filter) =>
+ useQuery, APIError[]>({
+ ...entitiesQueries.paginated(params, filter),
...queryPresets.shortLived,
});
-};
diff --git a/packages/manager/src/queries/entities/queries.ts b/packages/manager/src/queries/entities/queries.ts
index e5d237dbe58..00e76fcff89 100644
--- a/packages/manager/src/queries/entities/queries.ts
+++ b/packages/manager/src/queries/entities/queries.ts
@@ -1,10 +1,26 @@
import { getAccountEntities } from '@linode/api-v4';
+import { getAll } from '@linode/utilities';
import { createQueryKeys } from '@lukemorales/query-key-factory';
+import type { AccountEntity, Filter, Params } from '@linode/api-v4';
+
+// TODO: Temporary—use getAll since API can’t filter yet.
+// Switch to paginated + API filtering (X-Filter) when supported.
+const getAllAccountEntitiesRequest = (
+ _params: Params = {},
+ _filter: Filter = {}
+) =>
+ getAll((params) =>
+ getAccountEntities({ ...params, ..._params })
+ )().then((data) => data.data);
+
export const entitiesQueries = createQueryKeys('entities', {
- entities: {
- queryFn: ({ pageParam }) =>
- getAccountEntities({ page: pageParam as number, page_size: 500 }),
- queryKey: null,
- },
+ all: (params: Params = {}, filter: Filter = {}) => ({
+ queryFn: () => getAllAccountEntitiesRequest(params, filter),
+ queryKey: [params, filter],
+ }),
+ paginated: (params: Params, filter: Filter) => ({
+ queryFn: () => getAccountEntities(params),
+ queryKey: [params, filter],
+ }),
});
diff --git a/packages/queries/.changeset/pr-12888-added-1758193857654.md b/packages/queries/.changeset/pr-12888-added-1758193857654.md
new file mode 100644
index 00000000000..e7013aec397
--- /dev/null
+++ b/packages/queries/.changeset/pr-12888-added-1758193857654.md
@@ -0,0 +1,5 @@
+---
+"@linode/queries": Added
+---
+
+IAM RBAC: useAllAccountEntities to fetch all pages client-side via getAll, preventing missing items on large accounts ([#12888](https://github.com/linode/manager/pull/12888))
From f9ecfd312006a03781ff7bfaa34eb2c7fc33abf4 Mon Sep 17 00:00:00 2001
From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com>
Date: Mon, 22 Sep 2025 16:51:24 -0400
Subject: [PATCH 12/54] test: [M3-10608] - Add tests for Linode Interfaces
table in Linode Networking tab (part 1) (#12842)
* WIP Linode network tests
* organize stuff
* save
* add test
* add tests
* add details tests
* update test
* changeset + other test
* remove aria label feedback
---------
Co-authored-by: Joe D'Amore
---
.../pr-12842-tests-1757434078486.md | 5 +
.../e2e/core/linodes/linode-network.spec.ts | 748 +++++++++++++++---
.../cypress/support/intercepts/linodes.ts | 21 +
.../LinodeCreate/Networking/Firewall.tsx | 2 +-
.../Networking/InterfaceFirewall.tsx | 2 +-
.../LinodeFirewalls/LinodeFirewalls.tsx | 2 +-
.../VlanInterfaceDetailsContent.tsx | 6 +-
.../LinodeInterfacesTable.tsx | 2 +-
8 files changed, 663 insertions(+), 125 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12842-tests-1757434078486.md
diff --git a/packages/manager/.changeset/pr-12842-tests-1757434078486.md b/packages/manager/.changeset/pr-12842-tests-1757434078486.md
new file mode 100644
index 00000000000..9d0d850a651
--- /dev/null
+++ b/packages/manager/.changeset/pr-12842-tests-1757434078486.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Add tests for Linode Interface Networking table - details drawer and adding a VLAN interface ([#12842](https://github.com/linode/manager/pull/12842))
diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
index 1a47787c0cd..87840e9ae1a 100644
--- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
+++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts
@@ -1,5 +1,6 @@
import {
linodeInterfaceFactoryPublic,
+ linodeInterfaceFactoryVlan,
linodeInterfaceFactoryVPC,
} from '@linode/utilities';
import { linodeFactory } from '@linode/utilities';
@@ -22,16 +23,18 @@ import {
mockCreateLinodeInterface,
mockGetLinodeDetails,
mockGetLinodeFirewalls,
+ mockGetLinodeInterface,
mockGetLinodeInterfaces,
mockGetLinodeIPAddresses,
} from 'support/intercepts/linodes';
import { mockUpdateIPAddress } from 'support/intercepts/networking';
-import { mockGetVPCs } from 'support/intercepts/vpc';
+import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc';
import { ui } from 'support/ui';
-import type { IPRange } from '@linode/api-v4';
+import type { IPRange, LinodeIPsResponse } from '@linode/api-v4';
describe('IP Addresses', () => {
+ // TODO M3-9775: Set mock linode interface type to legacy once Linode Interfaces is GA.
const mockLinode = linodeFactory.build();
const linodeIPv4 = mockLinode.ipv4[0];
const mockRDNS = `${linodeIPv4}.ip.linodeusercontent.com`;
@@ -253,11 +256,11 @@ describe('Firewalls', () => {
});
});
-describe('Linode Interfaces', () => {
+describe('Linode Interfaces enabled', () => {
beforeEach(() => {
mockGetAccount(
accountFactory.build({
- capabilities: ['Linode Interfaces'],
+ capabilities: ['Linodes', 'Linode Interfaces'],
})
);
mockAppendFeatureFlags({
@@ -265,162 +268,673 @@ describe('Linode Interfaces', () => {
});
});
- it('allows the user to add a public network interface with a firewall', () => {
- const linode = linodeFactory.build({ interface_generation: 'linode' });
- const firewalls = firewallFactory.buildList(3);
- const linodeInterface = linodeInterfaceFactoryPublic.build();
-
- const selectedFirewall = firewalls[1];
+ describe('Linode with legacy config-based interfaces', () => {
+ const mockLinode = linodeFactory.build({
+ interface_generation: 'legacy_config',
+ });
- mockGetLinodeDetails(linode.id, linode).as('getLinode');
- mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
- mockGetFirewalls(firewalls).as('getFirewalls');
- mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
- mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
- selectedFirewall,
- ]).as('getInterfaceFirewalls');
+ const mockLinodeIPv4 = ipAddressFactory.build({
+ linode_id: mockLinode.id,
+ public: true,
+ type: 'ipv4',
+ region: mockLinode.region,
+ interface_id: null,
+ });
- cy.visitWithLogin(`/linodes/${linode.id}/networking`);
+ const mockLinodeIPs: LinodeIPsResponse = {
+ ipv4: {
+ public: [mockLinodeIPv4],
+ private: [],
+ reserved: [],
+ shared: [],
+ vpc: [],
+ },
+ };
- cy.wait(['@getLinode', '@getInterfaces']);
+ beforeEach(() => {
+ mockGetLinodeDetails(mockLinode.id, mockLinode);
+ mockGetLinodeFirewalls(mockLinode.id, []);
+ mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs);
+ });
- ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+ /*
+ * - Confirms network tab Firewall table is present for Linodes with config-based interfaces.
+ * - Confirms that "Add Firewall" button is present and enabled for Linodes with config-based interfaces.
+ */
+ it('shows the Firewall table for Linodes with config-based interfaces', () => {
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ ui.button
+ .findByTitle('Add Firewall')
+ .should('be.visible')
+ .should('be.enabled');
+
+ cy.get('[data-qa-linode-firewalls-table]')
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('No Firewalls are assigned.').should('be.visible');
+ });
+ });
- ui.drawer.findByTitle('Add Network Interface').within(() => {
- // Verify firewalls fetch
- cy.wait('@getFirewalls');
+ /*
+ * - Confirms that network tab IP Addresses table is present for Linodes with config-based interfaces.
+ * - Confirms that IP address add and delete buttons are present for Linodes with config-based interfaces.
+ */
+ it('shows the IP address add and remove buttons for Linodes with config-based interfaces', () => {
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ ui.button
+ .findByTitle('Add an IP Address')
+ .should('be.visible')
+ .should('be.enabled');
+
+ cy.findByLabelText('Linode IP Addresses').should('be.visible');
+ cy.findByText(mockLinodeIPv4.address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ cy.findByText('Public – IPv4').should('be.visible');
+ ui.button.findByTitle('Delete').should('be.visible');
+ });
+ });
- // Try submitting the form
- ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+ /**
+ * - Confirms the Networking Interface table doesn't exist for config-based interfaces
+ */
+ it('does not show the Linode Interface networking table', () => {
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
- // Verify a validation error shows
- cy.findByText('You must selected an Interface type.').should(
- 'be.visible'
- );
+ cy.get('[data-qa-linode-interfaces-table]').should('not.exist');
+ cy.findByText('Add Network Interface').should('not.exist');
+ cy.findByText('Interface Settings').should('not.exist');
+ });
+ });
- // Select the public interface type
- cy.findByLabelText('Public').click();
+ describe('Linode with Linode-based interfaces', () => {
+ const mockLinode = linodeFactory.build({
+ interface_generation: 'linode',
+ });
- // Verify a validation error goes away
- cy.findByText('You must selected an Interface type.').should('not.exist');
+ const mockLinodeIPv4 = ipAddressFactory.build({
+ linode_id: mockLinode.id,
+ public: true,
+ type: 'ipv4',
+ region: mockLinode.region,
+ interface_id: null,
+ });
- // Select a Firewall
- ui.autocomplete.findByLabel('Firewall').click();
- ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
+ const mockLinodeIPs: LinodeIPsResponse = {
+ ipv4: {
+ public: [mockLinodeIPv4],
+ private: [],
+ reserved: [],
+ shared: [],
+ vpc: [],
+ },
+ };
- mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
+ const mockFirewalls = firewallFactory.buildList(3);
- ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+ beforeEach(() => {
+ mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode');
+ mockGetLinodeIPAddresses(mockLinode.id, mockLinodeIPs).as('getLinodeIPs');
+ mockGetLinodeInterfaces(mockLinode.id, { interfaces: [] }).as(
+ 'getInterfaces'
+ );
});
- cy.wait('@createInterface').then((xhr) => {
- const requestPayload = xhr.request.body;
+ /*
+ * - Confirms that network tab Firewall table is absent for Linodes using new Linode-based interfaces.
+ * - Confirms that IP address add and delete buttons are absent for Linodes using new Linode-based interfaces.
+ */
+ it('hides Firewall table and IP address buttons', () => {
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ // Confirm Firewalls section is absent
+ cy.get('[data-qa-linode-firewalls-table]').should('not.exist');
+ cy.findByText('Add Firewall').should('not.exist');
+
+ // Confirm add IP and delete IP buttons are missing from IP address section
+ cy.findByLabelText('Linode IP Addresses').should('be.visible');
+ cy.findByText('Add an IP Address').should('not.exist');
+ cy.findByText(mockLinodeIPv4.address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ cy.findByText('Public – IPv4').should('be.visible');
+ cy.findByText('Delete').should('not.exist');
+ });
+ });
- // Confirm that request payload includes a Public interface only
- expect(requestPayload['public']).to.be.an('object');
- expect(requestPayload['vpc']).to.equal(null);
- expect(requestPayload['vlan']).to.equal(null);
+ it('confirms the Network Interfaces table functions as expected', () => {
+ const publicInterface = linodeInterfaceFactoryPublic.build();
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [publicInterface],
+ }).as('getInterfaces');
+
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ ui.button
+ .findByTitle('Add Network Interface')
+ .should('be.visible')
+ .should('be.enabled');
+ ui.button
+ .findByTitle('Interface Settings')
+ .should('be.visible')
+ .should('be.enabled');
+
+ // Confirm table heading row
+ cy.get('[data-qa-linode-interfaces-table]')
+ .should('be.visible')
+ .within(() => {
+ cy.findByText('Type').should('be.visible');
+ cy.findByText('ID').should('be.visible');
+ cy.findByText('MAC Address').should('be.visible');
+ cy.findByText('IP Addresses').should('be.visible');
+ cy.findByText('Version').should('be.visible');
+ cy.findByText('Firewall').should('be.visible');
+ cy.findByText('Updated').should('be.visible');
+ cy.findByText('Created').should('be.visible');
+ });
+
+ // Confirm interface row's action menu
+ cy.findByText(publicInterface.mac_address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.actionMenu
+ .findByTitle(
+ `Action menu for Public Interface (${publicInterface.id})`
+ )
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.actionMenuItem
+ .findByTitle('Details')
+ .should('be.visible')
+ .should('be.enabled');
+ ui.actionMenuItem
+ .findByTitle('Edit')
+ .should('be.visible')
+ .should('be.enabled');
+ ui.actionMenuItem
+ .findByTitle('Delete')
+ .should('be.visible')
+ .should('be.enabled');
+ });
});
- ui.toast.assertMessage('Successfully added network interface.');
+ describe('Adding a Linode Interface', () => {
+ it('allows the user to add a VLAN interface', () => {
+ const mockLinodeInterface = linodeInterfaceFactoryVlan.build();
- // Verify the interface row shows upon creation
- cy.findByText(linodeInterface.mac_address)
- .closest('tr')
- .within(() => {
- // Verify we fetch the interfaces firewalls and the label shows
- cy.wait('@getInterfaceFirewalls');
- cy.findByText(selectedFirewall.label).should('be.visible');
+ mockGetFirewalls(mockFirewalls).as('getFirewalls');
+ mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
+ 'createInterface'
+ );
+ mockGetLinodeInterfaceFirewalls(
+ mockLinode.id,
+ mockLinodeInterface.id,
+ []
+ ).as('getInterfaceFirewalls');
+
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ cy.wait(['@getLinode', '@getInterfaces']);
+
+ ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+
+ ui.drawer.findByTitle('Add Network Interface').within(() => {
+ // Verify firewalls fetch
+ cy.wait('@getFirewalls');
+
+ // Try submitting the form
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+
+ // Verify a validation error shows
+ cy.findByText('You must selected an Interface type.').should(
+ 'be.visible'
+ );
+
+ // Select the public interface type
+ cy.findByLabelText('VLAN').click();
+
+ // Verify a validation error goes away
+ cy.findByText('You must selected an Interface type.').should(
+ 'not.exist'
+ );
+
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+
+ // Verify an error shows because a VLAN was not selected nor created
+ cy.findByText('VLAN label is required.').should('be.visible');
+
+ // Verify VLAN label and IPAM selects
+ // Type label for VLAN
+ ui.autocomplete
+ .findByLabel('VLAN')
+ .should('be.visible')
+ .click()
+ .type('testVLAN');
+
+ cy.findByText('IPAM Address').should('be.visible').click();
+ cy.findByText(
+ 'IPAM address must use IP/netmask format, e.g. 192.0.2.0/24.'
+ ).should('be.visible');
+
+ // Verify VLAN error disappears
+ cy.findByText('VLAN label is required.').should('not.exist');
+
+ // Verify firewall select doees not appear
+ cy.findByText('Firewall').should('not.exist');
+
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [mockLinodeInterface],
+ });
+
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [mockLinodeInterface],
+ });
+
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+ });
+
+ cy.wait('@createInterface').then((xhr) => {
+ const requestPayload = xhr.request.body;
+
+ // Confirm that request payload includes a VLAN interface only
+ expect(requestPayload['public']).to.equal(null);
+ expect(requestPayload['vpc']).to.equal(null);
+ expect(requestPayload['vlan']).to.be.an('object');
+ });
+
+ ui.toast.assertMessage('Successfully added network interface.');
+
+ // Verify the interface row shows upon creation
+ cy.findByText(mockLinodeInterface.mac_address)
+ .closest('tr')
+ .within(() => {
+ // Verify we fetch the interfaces firewalls and the label shows
+ cy.wait('@getInterfaceFirewalls');
+ cy.findByText('None').should('be.visible');
+
+ // Verify the interface type shows
+ cy.findByText('VLAN').should('be.visible');
+ });
+ });
+
+ it('allows the user to add a public network interface with a firewall', () => {
+ const mockLinodeInterface = linodeInterfaceFactoryPublic.build();
+ const selectedMockFirewall = mockFirewalls[1];
- // Verify the interface type shows
- cy.findByText('Public').should('be.visible');
+ mockGetFirewalls(mockFirewalls).as('getFirewalls');
+ mockCreateLinodeInterface(mockLinode.id, mockLinodeInterface).as(
+ 'createInterface'
+ );
+ mockGetLinodeInterfaceFirewalls(mockLinode.id, mockLinodeInterface.id, [
+ selectedMockFirewall,
+ ]).as('getInterfaceFirewalls');
+
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ cy.wait(['@getLinode', '@getInterfaces']);
+
+ ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+
+ ui.drawer.findByTitle('Add Network Interface').within(() => {
+ // Verify firewalls fetch
+ cy.wait('@getFirewalls');
+
+ // Try submitting the form
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+
+ // Verify a validation error shows
+ cy.findByText('You must selected an Interface type.').should(
+ 'be.visible'
+ );
+
+ // Select the public interface type
+ cy.findByLabelText('Public').click();
+
+ // Verify a validation error goes away
+ cy.findByText('You must selected an Interface type.').should(
+ 'not.exist'
+ );
+
+ // Select a Firewall
+ ui.autocomplete.findByLabel('Firewall').click();
+ ui.autocompletePopper.findByTitle(selectedMockFirewall.label).click();
+
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [mockLinodeInterface],
+ });
+
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+ });
+
+ cy.wait('@createInterface').then((xhr) => {
+ const requestPayload = xhr.request.body;
+
+ // Confirm that request payload includes a Public interface only
+ expect(requestPayload['public']).to.be.an('object');
+ expect(requestPayload['vpc']).to.equal(null);
+ expect(requestPayload['vlan']).to.equal(null);
+ });
+
+ ui.toast.assertMessage('Successfully added network interface.');
+
+ // Verify the interface row shows upon creation
+ cy.findByText(mockLinodeInterface.mac_address)
+ .closest('tr')
+ .within(() => {
+ // Verify we fetch the interfaces firewalls and the label shows
+ cy.wait('@getInterfaceFirewalls');
+ cy.findByText(selectedMockFirewall.label).should('be.visible');
+
+ // Verify the interface type shows
+ cy.findByText('Public').should('be.visible');
+ });
});
- });
- it('allows the user to add a VPC network interface with a firewall', () => {
- const linode = linodeFactory.build({ interface_generation: 'linode' });
- const firewalls = firewallFactory.buildList(3);
- const subnets = subnetFactory.buildList(3);
- const vpcs = vpcFactory.buildList(3, { subnets });
- const linodeInterface = linodeInterfaceFactoryVPC.build();
+ it('allows the user to add a VPC network interface with a firewall', () => {
+ const linode = linodeFactory.build({ interface_generation: 'linode' });
+ const firewalls = firewallFactory.buildList(3);
+ const subnets = subnetFactory.buildList(3);
+ const vpcs = vpcFactory.buildList(3, { subnets });
+ const linodeInterface = linodeInterfaceFactoryVPC.build();
- const selectedFirewall = firewalls[1];
- const selectedVPC = vpcs[1];
- const selectedSubnet = selectedVPC.subnets[0];
+ const selectedFirewall = firewalls[1];
+ const selectedVPC = vpcs[1];
+ const selectedSubnet = selectedVPC.subnets[0];
- mockGetLinodeDetails(linode.id, linode).as('getLinode');
- mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as('getInterfaces');
- mockGetFirewalls(firewalls).as('getFirewalls');
- mockGetVPCs(vpcs).as('getVPCs');
- mockCreateLinodeInterface(linode.id, linodeInterface).as('createInterface');
- mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
- selectedFirewall,
- ]).as('getInterfaceFirewalls');
+ mockGetLinodeDetails(linode.id, linode).as('getLinode');
+ mockGetLinodeInterfaces(linode.id, { interfaces: [] }).as(
+ 'getInterfaces'
+ );
+ mockGetFirewalls(firewalls).as('getFirewalls');
+ mockGetVPCs(vpcs).as('getVPCs');
+ mockCreateLinodeInterface(linode.id, linodeInterface).as(
+ 'createInterface'
+ );
+ mockGetLinodeInterfaceFirewalls(linode.id, linodeInterface.id, [
+ selectedFirewall,
+ ]).as('getInterfaceFirewalls');
- cy.visitWithLogin(`/linodes/${linode.id}/networking`);
+ cy.visitWithLogin(`/linodes/${linode.id}/networking`);
- cy.wait(['@getLinode', '@getInterfaces']);
+ cy.wait(['@getLinode', '@getInterfaces']);
- ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
+ ui.button.findByTitle('Add Network Interface').scrollIntoView().click();
- ui.drawer.findByTitle('Add Network Interface').within(() => {
- // Verify firewalls fetch
- cy.wait('@getFirewalls');
+ ui.drawer.findByTitle('Add Network Interface').within(() => {
+ // Verify firewalls fetch
+ cy.wait('@getFirewalls');
- cy.findByLabelText('VPC').click();
+ cy.findByLabelText('VPC').click();
- // Verify VPCs fetch
- cy.wait('@getVPCs');
+ // Verify VPCs fetch
+ cy.wait('@getVPCs');
- // Select a VPC
- ui.autocomplete.findByLabel('VPC').click();
- ui.autocompletePopper.findByTitle(selectedVPC.label).click();
+ // Select a VPC
+ ui.autocomplete.findByLabel('VPC').click();
+ ui.autocompletePopper.findByTitle(selectedVPC.label).click();
- // Select a Firewall
- ui.autocomplete.findByLabel('Firewall').click();
- ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
+ // Select a Firewall
+ ui.autocomplete.findByLabel('Firewall').click();
+ ui.autocompletePopper.findByTitle(selectedFirewall.label).click();
- // Submit the form
- ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
+ // Submit the form
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
- // Verify an error shows because a subnet is not selected
- cy.findByText('Subnet is required.').should('be.visible');
+ // Verify an error shows because a subnet is not selected
+ cy.findByText('Subnet is required.').should('be.visible');
- // Select a Subnet
- ui.autocomplete.findByLabel('Subnet').click();
- ui.autocompletePopper
- .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
- .click();
+ // Select a Subnet
+ ui.autocomplete.findByLabel('Subnet').click();
+ ui.autocompletePopper
+ .findByTitle(`${selectedSubnet.label} (${selectedSubnet.ipv4})`)
+ .click();
- // Verify the error goes away
- cy.findByText('Subnet is required.').should('not.exist');
+ // Verify the error goes away
+ cy.findByText('Subnet is required.').should('not.exist');
- mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
+ mockGetLinodeInterfaces(linode.id, { interfaces: [linodeInterface] });
- ui.button.findByAttribute('type', 'submit').should('be.enabled').click();
- });
+ ui.button
+ .findByAttribute('type', 'submit')
+ .should('be.enabled')
+ .click();
+ });
- cy.wait('@createInterface').then((xhr) => {
- const requestPayload = xhr.request.body;
+ cy.wait('@createInterface').then((xhr) => {
+ const requestPayload = xhr.request.body;
+
+ // Confirm that request payload includes VPC interface only
+ expect(requestPayload['public']).to.equal(null);
+ expect(requestPayload['vpc']['subnet_id']).to.equal(
+ selectedSubnet.id
+ );
+ expect(requestPayload['vlan']).to.equal(null);
+ });
- // Confirm that request payload includes VPC interface only
- expect(requestPayload['public']).to.equal(null);
- expect(requestPayload['vpc']['subnet_id']).to.equal(selectedSubnet.id);
- expect(requestPayload['vlan']).to.equal(null);
+ ui.toast.assertMessage('Successfully added network interface.');
+
+ // Verify the interface row shows upon creation
+ cy.findByText(linodeInterface.mac_address)
+ .closest('tr')
+ .within(() => {
+ // Verify we fetch the interfaces firewalls and the label shows
+ cy.wait('@getInterfaceFirewalls');
+ cy.findByText(selectedFirewall.label).should('be.visible');
+
+ // Verify the interface type shows
+ cy.findByText('VPC').should('be.visible');
+ });
+ });
});
- ui.toast.assertMessage('Successfully added network interface.');
+ describe('Interface Details drawer', () => {
+ it('confirms the details drawer for a public interface', () => {
+ const linodeInterface = linodeInterfaceFactoryPublic.build({
+ public: {
+ ipv6: {
+ ranges: [
+ {
+ range: '2600:3c06:e001:149::/64',
+ route_target: null,
+ },
+ {
+ range: '2600:3c06:e001:149::/56',
+ route_target: null,
+ },
+ ],
+ shared: [],
+ slaac: [
+ { address: '2600:3c06::2000:13ff:fe6b:31b0', prefix: '64' },
+ ],
+ },
+ },
+ });
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [linodeInterface],
+ }).as('getInterfaces');
+ mockGetLinodeInterface(
+ mockLinode.id,
+ linodeInterface.id,
+ linodeInterface
+ );
- // Verify the interface row shows upon creation
- cy.findByText(linodeInterface.mac_address)
- .closest('tr')
- .within(() => {
- // Verify we fetch the interfaces firewalls and the label shows
- cy.wait('@getInterfaceFirewalls');
- cy.findByText(selectedFirewall.label).should('be.visible');
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ // Open up the detail drawer
+ cy.findByText(linodeInterface.mac_address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.actionMenu
+ .findByTitle(
+ `Action menu for Public Interface (${linodeInterface.id})`
+ )
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.actionMenuItem
+ .findByTitle('Details')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ // Confirm drawer content
+ ui.drawer
+ .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+ .within(() => {
+ cy.findByText('IPv4 Default Route').should('be.visible');
+ cy.findByText('IPv6 Default Route').should('be.visible');
+ cy.findByText('Type').should('be.visible');
+ cy.findByText('Public').should('be.visible');
+ cy.findByText('IPv4 Addresses').should('be.visible');
+ cy.findByText(
+ `${linodeInterface.public?.ipv4.addresses[0].address} (Primary)`
+ ).should('be.visible');
+ cy.findByText('2600:3c06::2000:13ff:fe6b:31b0 (SLAAC)').should(
+ 'be.visible'
+ );
+ cy.findByText('2600:3c06:e001:149::/64 (Range)').should(
+ 'be.visible'
+ );
+ cy.findByText('2600:3c06:e001:149::/56 (Range)').should(
+ 'be.visible'
+ );
+ });
+ });
+
+ it('confirms the details drawer for a VLAN interface', () => {
+ const linodeInterface = linodeInterfaceFactoryVlan.build();
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [linodeInterface],
+ }).as('getInterfaces');
+ mockGetLinodeInterface(
+ mockLinode.id,
+ linodeInterface.id,
+ linodeInterface
+ );
+
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
- // Verify the interface type shows
- cy.findByText('VPC').should('be.visible');
+ // Open up the detail drawer
+ cy.findByText(linodeInterface.mac_address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.actionMenu
+ .findByTitle(
+ `Action menu for VLAN Interface (${linodeInterface.id})`
+ )
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.actionMenuItem
+ .findByTitle('Details')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ // Confirm drawer content
+ ui.drawer
+ .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+ .within(() => {
+ cy.findByText('Type').should('be.visible');
+ cy.findByText('VLAN').should('be.visible');
+ cy.findByText('VLAN Label').should('be.visible');
+ cy.findByText(`${linodeInterface.vlan?.vlan_label}`).should(
+ 'be.visible'
+ );
+ cy.findByText('IPAM Address').should('be.visible');
+ cy.findByText(`${linodeInterface.vlan?.ipam_address}`).should(
+ 'be.visible'
+ );
+ });
+ });
+
+ it('confirms the details drawer for a VPC interface', () => {
+ const linodeInterface = linodeInterfaceFactoryVPC.build();
+ const mockSubnet = subnetFactory.build({
+ id: linodeInterface.vpc?.subnet_id,
+ });
+ const mockVPC = vpcFactory.build({
+ id: linodeInterface.vpc?.vpc_id,
+ subnets: [mockSubnet],
+ });
+
+ mockGetVPC(mockVPC);
+ mockGetLinodeInterfaces(mockLinode.id, {
+ interfaces: [linodeInterface],
+ }).as('getInterfaces');
+ mockGetLinodeInterface(
+ mockLinode.id,
+ linodeInterface.id,
+ linodeInterface
+ );
+
+ cy.visitWithLogin(`/linodes/${mockLinode.id}/networking`);
+
+ // Open up the details drawer
+ cy.findByText(linodeInterface.mac_address)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ ui.actionMenu
+ .findByTitle(
+ `Action menu for VPC Interface (${linodeInterface.id})`
+ )
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+
+ ui.actionMenuItem
+ .findByTitle('Details')
+ .should('be.visible')
+ .should('be.enabled')
+ .click();
+ });
+
+ // Confirm drawer content
+ ui.drawer
+ .findByTitle(`Network Interface Details (ID: ${linodeInterface.id})`)
+ .within(() => {
+ cy.findByText('IPv4 Default Route').should('be.visible');
+ cy.findByText('Type').should('be.visible');
+ cy.findByText('VPC').should('be.visible');
+ cy.findByText('VPC Label').should('be.visible');
+ cy.findByText(`${mockVPC.label}`).should('be.visible');
+ cy.findByText('Subnet Label').should('be.visible');
+ cy.findByText(`${mockSubnet.label}`).should('be.visible');
+ cy.findByText('IPv4 Addresses').should('be.visible');
+ });
});
+ });
});
});
diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts
index 28516154615..26c50864836 100644
--- a/packages/manager/cypress/support/intercepts/linodes.ts
+++ b/packages/manager/cypress/support/intercepts/linodes.ts
@@ -684,6 +684,27 @@ export const mockGetLinodeInterfaces = (
);
};
+/**
+ * Mocks GET request to get a single Linode Interface.
+ *
+ * @param linodeId - ID of Linode to get interface associated with it
+ * @param interfaceId - ID of interface to get
+ * @param interfaces - the mocked Linode interface
+ *
+ * @returns Cypress Chainable.
+ */
+export const mockGetLinodeInterface = (
+ linodeId: number,
+ interfaceId: number,
+ linodeInterface: LinodeInterface
+): Cypress.Chainable => {
+ return cy.intercept(
+ 'GET',
+ apiMatcher(`linode/instances/${linodeId}/interfaces/${interfaceId}`),
+ linodeInterface
+ );
+};
+
/**
* Intercepts POST request to create a Linode Interface.
*
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
index f537896fd81..2787eea9874 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/Firewall.tsx
@@ -52,7 +52,7 @@ export const Firewall = () => {
}
text={
flags.secureVmCopy?.linodeCreate?.text ??
- 'All accounts must apply an compliant firewall to all their Linodes.'
+ 'All accounts must apply a compliant firewall to all their Linodes.'
}
/>
)}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
index cab7c07820d..34d99be08f3 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/InterfaceFirewall.tsx
@@ -62,7 +62,7 @@ export const InterfaceFirewall = ({ index }: Props) => {
}
text={
flags.secureVmCopy?.linodeCreate?.text ??
- 'All accounts must apply an compliant firewall to all their Linodes.'
+ 'All accounts must apply a compliant firewall to all their Linodes.'
}
/>
)}
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
index 6aed322306f..03e67423d15 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx
@@ -110,7 +110,7 @@ export const LinodeFirewalls = (props: LinodeFirewallsProps) => {
Add Firewall
-
+
Firewall
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx
index e7561e27138..35087465117 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/InterfaceDetailsDrawer/VlanInterfaceDetailsContent.tsx
@@ -5,7 +5,7 @@ import { MaskableText } from 'src/components/MaskableText/MaskableText';
export const VlanInterfaceDetailsContent = (props: {
ipam_address: string;
- vlan_label: string;
+ vlan_label: null | string;
}) => {
const { ipam_address, vlan_label } = props;
return (
@@ -20,9 +20,7 @@ export const VlanInterfaceDetailsContent = (props: {
IPAM Address
-
-
-
+
>
);
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx
index 5530bfb6172..80013557fa2 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx
@@ -18,7 +18,7 @@ interface Props {
export const LinodeInterfacesTable = ({ handlers, linodeId }: Props) => {
return (
-
+
Type
From 906b50fe44459d48f226cc24e4589f782fa0d97e Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Tue, 23 Sep 2025 11:10:45 +0200
Subject: [PATCH 13/54] changed: [UIE-9191] -UIE/RBAC LA gating for
useQueryWithPermissions (#12880)
* add LA gating to useQueryWithPermissions
* small cleanup
* coverage
* Added changeset: UIE/RBAC LA gating for useQueryWithPermissions
---
.../pr-12880-changed-1758009806570.md | 5 +
.../src/features/IAM/hooks/usePermissions.ts | 32 ++-
.../IAM/hooks/useQueryWithPermissions.test.ts | 267 ++++++++++++++++++
3 files changed, 300 insertions(+), 4 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12880-changed-1758009806570.md
create mode 100644 packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts
diff --git a/packages/manager/.changeset/pr-12880-changed-1758009806570.md b/packages/manager/.changeset/pr-12880-changed-1758009806570.md
new file mode 100644
index 00000000000..095e49a3929
--- /dev/null
+++ b/packages/manager/.changeset/pr-12880-changed-1758009806570.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+UIE/RBAC LA gating for useQueryWithPermissions ([#12880](https://github.com/linode/manager/pull/12880))
diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts
index d5eba95fcba..117bbf322e9 100644
--- a/packages/manager/src/features/IAM/hooks/usePermissions.ts
+++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts
@@ -169,17 +169,41 @@ export const useQueryWithPermissions = (
...restQueryResult
} = useQueryResult;
const { data: profile } = useProfile();
- const { isIAMEnabled } = useIsIAMEnabled();
+ const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled();
+
+ const accessType = entityType;
+
+ /**
+ * Apply the same Beta/LA permission logic as usePermissions.
+ * - Use Beta Permissions if:
+ * - The feature is beta
+ * - The access type is in the BETA_ACCESS_TYPE_SCOPE
+ * - The account permission is not in the LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE
+ * - Use LA Permissions if:
+ * - The feature is not beta
+ */
+ const useBetaPermissions =
+ isIAMEnabled &&
+ isIAMBeta &&
+ BETA_ACCESS_TYPE_SCOPE.includes(accessType) &&
+ LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some((blacklistedPermission) =>
+ permissionsToCheck.includes(blacklistedPermission as AccountAdmin)
+ ) === false;
+ const useLAPermissions = isIAMEnabled && !isIAMBeta;
+ const shouldUsePermissionMap = useBetaPermissions || useLAPermissions;
+
const { data: entityPermissions, isLoading: areEntityPermissionsLoading } =
useEntitiesPermissions(
allEntities,
entityType,
profile,
- isIAMEnabled && enabled
+ shouldUsePermissionMap && enabled
);
- const { data: grants } = useGrants(!isIAMEnabled);
+ const { data: grants } = useGrants(
+ (!isIAMEnabled || !shouldUsePermissionMap) && enabled
+ );
- const entityPermissionsMap = isIAMEnabled
+ const entityPermissionsMap = shouldUsePermissionMap
? toEntityPermissionMap(
allEntities,
entityPermissions,
diff --git a/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts
new file mode 100644
index 00000000000..1ecd51aab28
--- /dev/null
+++ b/packages/manager/src/features/IAM/hooks/useQueryWithPermissions.test.ts
@@ -0,0 +1,267 @@
+import { renderHook } from '@testing-library/react';
+
+import { wrapWithTheme } from 'src/utilities/testHelpers';
+
+import { useQueryWithPermissions } from './usePermissions';
+
+import type { EntityBase } from './usePermissions';
+import type { APIError, PermissionType } from '@linode/api-v4';
+import type { UseQueryResult } from '@tanstack/react-query';
+
+type Entity = { id: number; label: string };
+
+const queryMocks = vi.hoisted(() => {
+ let entitiesPermsLoading = false;
+
+ return {
+ useIsIAMEnabled: vi
+ .fn()
+ .mockReturnValue({ isIAMEnabled: true, isIAMBeta: true }),
+ useGrants: vi.fn().mockReturnValue({ data: null }),
+ useProfile: vi
+ .fn()
+ .mockReturnValue({ data: { username: 'user-1', restricted: true } }),
+ useQueries: Object.assign(
+ vi.fn().mockImplementation(({ queries }) =>
+ (queries || []).map(() => ({
+ data: null,
+ error: null,
+ isError: false,
+ isLoading: entitiesPermsLoading,
+ }))
+ ),
+ {
+ setEntitiesPermsLoading: (b: boolean) => {
+ entitiesPermsLoading = b;
+ },
+ }
+ ),
+ };
+});
+
+vi.mock(import('@linode/queries'), async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useGrants: queryMocks.useGrants,
+ useProfile: queryMocks.useProfile,
+ useQueries: queryMocks.useQueries,
+ };
+});
+
+vi.mock('src/features/IAM/hooks/useIsIAMEnabled', async () => {
+ const actual = await vi.importActual(
+ 'src/features/IAM/hooks/useIsIAMEnabled'
+ );
+
+ return {
+ ...actual,
+ useIsIAMEnabled: queryMocks.useIsIAMEnabled,
+ };
+});
+
+vi.mock('./adapters/permissionAdapters', () => ({
+ toEntityPermissionMap: vi.fn(
+ (entities: EntityBase[] = [], entitiesPermissions: PermissionType[]) => {
+ const map: Record> = {};
+ entities.forEach((e) => {
+ map[e.id] = entitiesPermissions?.reduce>(
+ (acc, p) => {
+ acc[p] = e.id === 1;
+ return acc;
+ },
+ {}
+ );
+ });
+
+ return map;
+ }
+ ),
+ entityPermissionMapFrom: vi.fn(() => {
+ return {
+ 1: { update_linode: true, resize_volume: true, create_volume: true },
+ 2: { update_linode: true, resize_volume: true, create_volume: true },
+ };
+ }),
+}));
+
+describe('useQueryWithPermissions', () => {
+ const entities: Entity[] = [
+ { id: 1, label: 'one' },
+ { id: 2, label: 'two' },
+ ];
+
+ const baseQueryResult = {
+ data: entities,
+ error: null,
+ isError: false,
+ isLoading: false,
+ } as UseQueryResult;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ queryMocks.useQueries.setEntitiesPermsLoading(false);
+ });
+
+ it('uses Beta permissions when IAM enabled + beta true + in scope; filters restricted entities', () => {
+ const flags = { iam: { beta: true, enabled: true } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: true,
+ isIAMBeta: true,
+ });
+ queryMocks.useProfile.mockReturnValue({
+ data: { username: 'user-1', restricted: true },
+ });
+
+ const { result } = renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'linode',
+ ['update_linode'],
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(queryMocks.useGrants).toHaveBeenCalledWith(false);
+
+ const calls = queryMocks.useQueries.mock.calls;
+ expect(calls.length).toBeGreaterThan(0);
+ const queryArgs = calls[0][0];
+ expect(
+ queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true)
+ ).toBe(true);
+
+ expect(result.current.data.map((e) => e.id)).toEqual([]);
+ expect(result.current.hasFiltered).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('falls back to grants when IAM disabled', () => {
+ const flags = { iam: { beta: false, enabled: false } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: false,
+ isIAMBeta: false,
+ });
+
+ const { result } = renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'linode',
+ ['update_linode'],
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(queryMocks.useGrants).toHaveBeenCalledWith(true);
+
+ const queryArgs = queryMocks.useQueries.mock.calls[0][0];
+ expect(
+ queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false)
+ ).toBe(true);
+
+ expect(result.current.data.map((e) => e.id)).toEqual([1, 2]);
+ expect(result.current.hasFiltered).toBe(false);
+ });
+
+ it('falls back to grants when Beta true but entityType not in scope', () => {
+ const flags = { iam: { beta: true, enabled: true } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: true,
+ isIAMBeta: true,
+ });
+
+ renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'volume',
+ ['resize_volume'],
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(queryMocks.useGrants).toHaveBeenCalledWith(true);
+ const qArg = queryMocks.useQueries.mock.calls[0][0];
+ expect(
+ qArg.queries.every((q: { enabled?: boolean }) => q.enabled === false)
+ ).toBe(true);
+ });
+
+ it('falls back to grants when Beta true but permission is in the LA exclusion list', () => {
+ const flags = { iam: { beta: true, enabled: true } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: true,
+ isIAMBeta: true,
+ });
+
+ renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'volume',
+ ['create_volume'], // blacklisted
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(queryMocks.useGrants).toHaveBeenCalledWith(true);
+ const queryArgs = queryMocks.useQueries.mock.calls[0][0];
+ expect(
+ queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false)
+ ).toBe(true);
+ });
+
+ it('uses LA permissions when IAM enabled + beta false', () => {
+ const flags = { iam: { beta: false, enabled: true } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: true,
+ isIAMBeta: false,
+ });
+
+ renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'linode',
+ ['update_linode'],
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(queryMocks.useGrants).toHaveBeenCalledWith(false);
+ const queryArgs = queryMocks.useQueries.mock.calls[0][0];
+ expect(
+ queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === true)
+ ).toBe(true);
+ });
+
+ it('marks loading when entity permissions queries are loading', () => {
+ const flags = { iam: { beta: true, enabled: true } };
+ queryMocks.useIsIAMEnabled.mockReturnValue({
+ isIAMEnabled: true,
+ isIAMBeta: true,
+ });
+ queryMocks.useQueries.setEntitiesPermsLoading(true);
+
+ const { result } = renderHook(
+ () =>
+ useQueryWithPermissions(
+ baseQueryResult,
+ 'linode',
+ ['update_linode'],
+ true
+ ),
+ { wrapper: (ui) => wrapWithTheme(ui, { flags }) }
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ });
+});
From 6f7b56e969f6a51274f54ed25ced0e0303d94375 Mon Sep 17 00:00:00 2001
From: kagora-akamai
Date: Tue, 23 Sep 2025 13:45:23 +0200
Subject: [PATCH 14/54] upcoming: [DPS-34666] - Log path sample component
(#12851)
* upcoming: [DPS-34666] - Log path sample component
---
...r-12851-upcoming-features-1757490281299.md | 5 +
packages/api-v4/src/delivery/types.ts | 11 ++-
...r-12851-upcoming-features-1757490359796.md | 5 +
.../DestinationCreate.test.tsx | 54 +++++++++-
.../DestinationForm/DestinationCreate.tsx | 13 ++-
.../DestinationForm/DestinationEdit.tsx | 16 ++-
...tinationLinodeObjectStorageDetailsForm.tsx | 20 ++--
.../features/Delivery/Shared/LabelValue.tsx | 10 +-
.../features/Delivery/Shared/PathSample.tsx | 98 ++++++++++++++++++-
.../src/features/Delivery/Shared/types.ts | 11 ++-
.../Clusters/StreamFormClusters.tsx | 6 +-
.../Clusters/StreamFormClustersTable.tsx | 6 +-
...LinodeObjectStorageDetailsSummary.test.tsx | 28 ++++++
...ationLinodeObjectStorageDetailsSummary.tsx | 24 ++++-
.../Streams/StreamForm/StreamCreate.test.tsx | 1 +
.../Streams/StreamForm/StreamCreate.tsx | 1 +
.../Streams/StreamForm/StreamEdit.tsx | 21 +++-
.../Streams/StreamForm/StreamForm.tsx | 20 +++-
.../features/Delivery/deliveryUtils.test.ts | 38 ++++++-
.../src/features/Delivery/deliveryUtils.ts | 17 +++-
.../mocks/presets/crud/handlers/delivery.ts | 22 ++++-
...r-12851-upcoming-features-1757490426873.md | 5 +
packages/validation/src/delivery.schema.ts | 33 +++++--
23 files changed, 416 insertions(+), 49 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
create mode 100644 packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
create mode 100644 packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
diff --git a/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md b/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
new file mode 100644
index 00000000000..ec645e1fbc0
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Upcoming Features
+---
+
+Update Destination's details interface ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts
index 15b3129fdd3..565686bb810 100644
--- a/packages/api-v4/src/delivery/types.ts
+++ b/packages/api-v4/src/delivery/types.ts
@@ -121,8 +121,17 @@ export interface UpdateStreamPayloadWithId extends UpdateStreamPayload {
id: number;
}
+export interface LinodeObjectStorageDetailsPayload
+ extends Omit {
+ path?: string;
+}
+
+export type DestinationDetailsPayload =
+ | CustomHTTPsDetails
+ | LinodeObjectStorageDetailsPayload;
+
export interface CreateDestinationPayload {
- details: CustomHTTPsDetails | LinodeObjectStorageDetails;
+ details: DestinationDetailsPayload;
label: string;
type: DestinationType;
}
diff --git a/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md b/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
new file mode 100644
index 00000000000..83714b72577
--- /dev/null
+++ b/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Generate Destination's sample Path based on Stream Type or custom value ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx
index 38ee59db256..65371c3eccf 100644
--- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx
+++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.test.tsx
@@ -1,5 +1,10 @@
import { destinationType } from '@linode/api-v4';
-import { screen, waitFor } from '@testing-library/react';
+import { profileFactory } from '@linode/utilities';
+import {
+ screen,
+ waitFor,
+ waitForElementToBeRemoved,
+} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect } from 'vitest';
@@ -70,6 +75,47 @@ describe('DestinationCreate', () => {
}
);
+ it('should render Sample Destination Object Name and change its value according to Log Path Prefix input', async () => {
+ const profileUid = 123;
+ const [month, day, year] = new Date().toLocaleDateString().split('/');
+ server.use(
+ http.get('*/profile', () => {
+ return HttpResponse.json(profileFactory.build({ uid: profileUid }));
+ })
+ );
+
+ renderDestinationCreate();
+
+ const loadingElement = screen.queryByTestId('circle-progress');
+ await waitForElementToBeRemoved(loadingElement);
+
+ const samplePath = screen.getByText(
+ `/audit_logs/com.akamai.audit.login/${profileUid}/${year}/${month}/${day}/akamai_log-000166-1756015362-319597.gz`
+ );
+ expect(samplePath).toBeInTheDocument();
+
+ // Type the test value inside the input
+ const logPathPrefixInput = screen.getByLabelText('Log Path Prefix');
+
+ await userEvent.type(logPathPrefixInput, 'test');
+ // sample path should be created based on *log path* value
+ expect(samplePath.textContent).toEqual(
+ '/test/akamai_log-000166-1756015362-319597.gz'
+ );
+
+ await userEvent.clear(logPathPrefixInput);
+ await userEvent.type(logPathPrefixInput, '/test');
+ expect(samplePath.textContent).toEqual(
+ '/test/akamai_log-000166-1756015362-319597.gz'
+ );
+
+ await userEvent.clear(logPathPrefixInput);
+ await userEvent.type(logPathPrefixInput, '/');
+ expect(samplePath.textContent).toEqual(
+ '/akamai_log-000166-1756015362-319597.gz'
+ );
+ });
+
describe('given Test Connection and Create Destination buttons', () => {
const testConnectionButtonText = 'Test Connection';
const createDestinationButtonText = 'Create Destination';
@@ -107,6 +153,9 @@ describe('DestinationCreate', () => {
http.post('*/monitor/streams/destinations', () => {
createDestinationSpy();
return HttpResponse.json({});
+ }),
+ http.get('*/profile', () => {
+ return HttpResponse.json(profileFactory.build());
})
);
@@ -141,6 +190,9 @@ describe('DestinationCreate', () => {
http.post('*/monitor/streams/destinations/verify', () => {
verifyDestinationSpy();
return HttpResponse.error();
+ }),
+ http.get('*/profile', () => {
+ return HttpResponse.json(profileFactory.build());
})
);
diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx
index 15b997bb0c1..dd0eb514589 100644
--- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx
+++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationCreate.tsx
@@ -1,7 +1,7 @@
import { yupResolver } from '@hookform/resolvers/yup';
import { destinationType } from '@linode/api-v4';
import { useCreateDestinationMutation } from '@linode/queries';
-import { destinationSchema } from '@linode/validation';
+import { destinationFormSchema } from '@linode/validation';
import { useNavigate } from '@tanstack/react-router';
import { enqueueSnackbar } from 'notistack';
import * as React from 'react';
@@ -9,8 +9,10 @@ import { FormProvider, useForm } from 'react-hook-form';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { LandingHeader } from 'src/components/LandingHeader';
+import { getDestinationPayloadDetails } from 'src/features/Delivery/deliveryUtils';
import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm';
+import type { CreateDestinationPayload } from '@linode/api-v4';
import type { LandingHeaderProps } from 'src/components/LandingHeader';
import type { DestinationFormType } from 'src/features/Delivery/Shared/types';
@@ -39,14 +41,19 @@ export const DestinationCreate = () => {
type: destinationType.LinodeObjectStorage,
details: {
region: '',
+ path: '',
},
},
mode: 'onBlur',
- resolver: yupResolver(destinationSchema),
+ resolver: yupResolver(destinationFormSchema),
});
const onSubmit = () => {
- const destination = form.getValues();
+ const formValues = form.getValues();
+ const destination: CreateDestinationPayload = {
+ ...formValues,
+ details: getDestinationPayloadDetails(formValues.details),
+ };
createDestination(destination)
.then(() => {
diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
index 17c777e4455..b040d6aebbf 100644
--- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
+++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
@@ -5,7 +5,7 @@ import {
useUpdateDestinationMutation,
} from '@linode/queries';
import { Box, CircleProgress, ErrorState } from '@linode/ui';
-import { destinationSchema } from '@linode/validation';
+import { destinationFormSchema } from '@linode/validation';
import { useNavigate, useParams } from '@tanstack/react-router';
import { enqueueSnackbar } from 'notistack';
import * as React from 'react';
@@ -17,6 +17,7 @@ import { LandingHeader } from 'src/components/LandingHeader';
import { DestinationForm } from 'src/features/Delivery/Destinations/DestinationForm/DestinationForm';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import type { UpdateDestinationPayloadWithId } from '@linode/api-v4';
import type { LandingHeaderProps } from 'src/components/LandingHeader';
import type { DestinationFormType } from 'src/features/Delivery/Shared/types';
@@ -53,22 +54,31 @@ export const DestinationEdit = () => {
type: destinationType.LinodeObjectStorage,
details: {
region: '',
+ path: '',
},
},
mode: 'onBlur',
- resolver: yupResolver(destinationSchema),
+ resolver: yupResolver(destinationFormSchema),
});
useEffect(() => {
if (destination) {
form.reset({
...destination,
+ ...('path' in destination.details
+ ? {
+ details: {
+ ...destination.details,
+ path: destination.details.path || '',
+ },
+ }
+ : {}),
});
}
}, [destination, form]);
const onSubmit = () => {
- const destination = {
+ const destination: UpdateDestinationPayloadWithId = {
id: destinationId,
...form.getValues(),
};
diff --git a/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx
index cf599ef9374..9f99b4f030f 100644
--- a/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx
+++ b/packages/manager/src/features/Delivery/Shared/DestinationLinodeObjectStorageDetailsForm.tsx
@@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries';
import { useIsGeckoEnabled } from '@linode/shared';
import { Box, Divider, TextField, Typography } from '@linode/ui';
import React from 'react';
-import { Controller, useFormContext } from 'react-hook-form';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { HideShowText } from 'src/components/PasswordInput/HideShowText';
import { RegionSelect } from 'src/components/RegionSelect/RegionSelect';
@@ -36,6 +36,10 @@ export const DestinationLinodeObjectStorageDetailsForm = ({
const { isGeckoLAEnabled } = useIsGeckoEnabled(gecko2?.enabled, gecko2?.la);
const { data: regions } = useRegionsQuery();
const { control } = useFormContext();
+ const path = useWatch({
+ control,
+ name: controlPaths?.path,
+ });
return (
<>
@@ -126,7 +130,13 @@ export const DestinationLinodeObjectStorageDetailsForm = ({
/>
Path
-
+ *': { width: '100%' } }}
+ >
field.onChange(value)}
placeholder="Log Path Prefix"
- sx={{ width: 416 }}
+ sx={{ maxWidth: 416 }}
value={field.value}
/>
)}
/>
-
+
>
);
diff --git a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx
index 0104400dcf0..da41143ffc3 100644
--- a/packages/manager/src/features/Delivery/Shared/LabelValue.tsx
+++ b/packages/manager/src/features/Delivery/Shared/LabelValue.tsx
@@ -3,6 +3,7 @@ import { styled, useTheme } from '@mui/material/styles';
import * as React from 'react';
interface LabelValueProps {
+ children?: React.ReactNode;
compact?: boolean;
'data-testid'?: string;
label: string;
@@ -10,7 +11,13 @@ interface LabelValueProps {
}
export const LabelValue = (props: LabelValueProps) => {
- const { compact = false, label, value, 'data-testid': dataTestId } = props;
+ const {
+ compact = false,
+ label,
+ value,
+ 'data-testid': dataTestId,
+ children,
+ } = props;
const theme = useTheme();
return (
@@ -29,6 +36,7 @@ export const LabelValue = (props: LabelValueProps) => {
{label}:
{value}
+ {children}
);
};
diff --git a/packages/manager/src/features/Delivery/Shared/PathSample.tsx b/packages/manager/src/features/Delivery/Shared/PathSample.tsx
index 15920c4a658..77ae2f2cac1 100644
--- a/packages/manager/src/features/Delivery/Shared/PathSample.tsx
+++ b/packages/manager/src/features/Delivery/Shared/PathSample.tsx
@@ -1,6 +1,23 @@
-import { Box, InputLabel } from '@linode/ui';
+import { streamType, type StreamType } from '@linode/api-v4';
+import { useProfile } from '@linode/queries';
+import { Box, InputLabel, Stack, TooltipIcon, Typography } from '@linode/ui';
import { styled } from '@mui/material/styles';
import * as React from 'react';
+import { useMemo } from 'react';
+import { useFormContext, useWatch } from 'react-hook-form';
+
+import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils';
+
+const sxTooltipIcon = {
+ marginLeft: '4px',
+ padding: '0px',
+ marginTop: '-2px',
+};
+
+const logType = {
+ [streamType.LKEAuditLogs]: 'com.akamai.audit.k8s',
+ [streamType.AuditLogs]: 'com.akamai.audit.login',
+};
interface PathSampleProps {
value: string;
@@ -8,18 +25,89 @@ interface PathSampleProps {
export const PathSample = (props: PathSampleProps) => {
const { value } = props;
+ const fileName = 'akamai_log-000166-1756015362-319597.gz';
+ const sampleClusterId = useMemo(
+ // eslint-disable-next-line sonarjs/pseudo-random
+ () => Math.floor(Math.random() * 90000) + 10000,
+ []
+ );
+
+ const { control } = useFormContext();
+ const streamTypeFormValue = useWatch({
+ control,
+ name: 'stream.type',
+ });
+
+ const clusterId = useWatch({
+ control,
+ name: 'stream.details.cluster_ids[0]',
+ });
+
+ const { data: profile } = useProfile();
+ const [month, day, year] = new Date().toLocaleDateString().split('/');
+
+ const setStreamType = (): StreamType => {
+ return streamTypeFormValue ?? streamType.AuditLogs;
+ };
+
+ const streamTypeValue = useMemo(setStreamType, [streamTypeFormValue]);
+
+ const createSamplePath = (): string => {
+ let partition = '';
+
+ if (streamTypeValue === streamType.LKEAuditLogs) {
+ partition = `${clusterId ?? sampleClusterId}/`;
+ }
+
+ return `/${streamTypeValue}/${logType[streamTypeValue]}/${profile?.uid}/${partition}${year}/${month}/${day}`;
+ };
+
+ const defaultPath = useMemo(createSamplePath, [
+ profile,
+ streamTypeValue,
+ clusterId,
+ ]);
+
+ const getPath = () => {
+ if (value === '/') {
+ return `/${fileName}`;
+ }
+
+ const path = `${value || defaultPath}/${fileName}`;
+
+ if (!path.startsWith('/')) {
+ return `/${path}`;
+ }
+
+ return path;
+ };
return (
- Destination object name sample
- {value}
+
+ Sample Destination Object Name
+
+ Default paths:
+ {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`}
+ {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`}
+
+ }
+ />
+
+ {getPath()}
);
};
const StyledValue = styled('span', { label: 'StyledValue' })(({ theme }) => ({
backgroundColor: theme.tokens.alias.Interaction.Background.Disabled,
- height: 34,
- width: theme.inputMaxWidth,
+ width: '100%',
+ maxWidth: 'max-content',
+ minHeight: 34,
padding: theme.spacingFunction(8),
+ overflowWrap: 'anywhere',
}));
diff --git a/packages/manager/src/features/Delivery/Shared/types.ts b/packages/manager/src/features/Delivery/Shared/types.ts
index 0730b676705..ceac897e5ce 100644
--- a/packages/manager/src/features/Delivery/Shared/types.ts
+++ b/packages/manager/src/features/Delivery/Shared/types.ts
@@ -2,7 +2,7 @@ import { destinationType, streamStatus, streamType } from '@linode/api-v4';
import type {
CreateDestinationPayload,
- UpdateDestinationPayload,
+ DestinationDetails,
} from '@linode/api-v4';
export type FormMode = 'create' | 'edit';
@@ -46,6 +46,9 @@ export const streamStatusOptions: LabelValueOption[] = [
},
];
-export type DestinationFormType =
- | CreateDestinationPayload
- | UpdateDestinationPayload;
+export interface DestinationForm
+ extends Omit {
+ details: DestinationDetails;
+}
+
+export type DestinationFormType = DestinationForm;
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx
index 745346f8a88..405d37b32e0 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx
@@ -29,7 +29,7 @@ const controlPaths = {
} as const;
export const StreamFormClusters = () => {
- const { control, setValue, formState } =
+ const { control, setValue, formState, trigger } =
useFormContext();
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
@@ -109,14 +109,14 @@ export const StreamFormClusters = () => {
render={({ field }) => (
{
+ onChange={async (_, checked) => {
field.onChange(checked);
if (checked) {
setValue(controlPaths.clusterIds, idsWithLogsEnabled);
} else {
setValue(controlPaths.clusterIds, []);
}
+ await trigger('stream.details');
}}
sxFormLabel={{ ml: -1 }}
text="Automatically include all existing and recently configured clusters."
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx
index 4c4b5495448..8b3fa16c815 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable.tsx
@@ -43,8 +43,10 @@ export const StreamFormClusterTableContent = ({
selectedIds.length === (idsWithLogsEnabled?.length ?? 0);
const isIndeterminate = selectedIds.length > 0 && !isAllSelected;
- const toggleAllClusters = () =>
+ const toggleAllClusters = () => {
field.onChange(isAllSelected ? [] : idsWithLogsEnabled);
+ field.onBlur();
+ };
const toggleCluster = (toggledId: number) => {
const updatedClusterIds = selectedIds.includes(toggledId)
@@ -52,6 +54,7 @@ export const StreamFormClusterTableContent = ({
: [...selectedIds, toggledId];
field.onChange(updatedClusterIds);
+ field.onBlur();
};
return (
@@ -114,7 +117,6 @@ export const StreamFormClusterTableContent = ({
aria-label={`Toggle ${label} cluster`}
checked={selectedIds.includes(id)}
disabled={isAutoAddAllClustersEnabled || !logsEnabled}
- onBlur={field.onBlur}
onChange={() => toggleCluster(id)}
/>
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx
index b66fe12a369..13a7e819123 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx
@@ -1,6 +1,7 @@
import { regionFactory } from '@linode/utilities';
import { screen, waitFor } from '@testing-library/react';
import React from 'react';
+import { expect } from 'vitest';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { http, HttpResponse, server } from 'src/mocks/testServer';
@@ -39,6 +40,7 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => {
expect(screen.getByText('test bucket')).toBeVisible();
// Log Path:
expect(screen.getByText('test/path')).toBeVisible();
+ expect(screen.queryByTestId('tooltip-info-icon')).not.toBeInTheDocument();
// Region:
await waitFor(() => {
expect(screen.getByText('US, Chicago, IL')).toBeVisible();
@@ -52,4 +54,30 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => {
'*****************'
);
});
+
+ it('renders info icon next to path when it is empty', async () => {
+ server.use(
+ http.get('*/regions', () => {
+ const regions = regionFactory.buildList(1, {
+ id: 'us-ord',
+ label: 'Chicago, IL',
+ });
+ return HttpResponse.json(makeResourcePage(regions));
+ })
+ );
+
+ const details = {
+ bucket_name: 'test bucket',
+ host: 'test host',
+ path: '',
+ region: 'us-ord',
+ } as LinodeObjectStorageDetails;
+
+ renderWithTheme(
+
+ );
+
+ // Log Path info icon:
+ expect(screen.getByTestId('tooltip-info-icon')).toBeVisible();
+ });
});
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx
index fa578cc4da3..5da8052d576 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx
@@ -1,10 +1,18 @@
+import { streamType } from '@linode/api-v4';
import { useRegionsQuery } from '@linode/queries';
+import { Stack, TooltipIcon, Typography } from '@linode/ui';
import React from 'react';
+import { getStreamTypeOption } from 'src/features/Delivery/deliveryUtils';
import { LabelValue } from 'src/features/Delivery/Shared/LabelValue';
import type { LinodeObjectStorageDetails } from '@linode/api-v4';
+const sxTooltipIcon = {
+ marginLeft: '4px',
+ padding: '0px',
+};
+
export const DestinationLinodeObjectStorageDetailsSummary = (
props: LinodeObjectStorageDetails
) => {
@@ -28,7 +36,21 @@ export const DestinationLinodeObjectStorageDetailsSummary = (
label="Secret Access Key"
value="*****************"
/>
-
+
+ {!path && (
+
+ Default paths:
+ {`${getStreamTypeOption(streamType.LKEAuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{partition}/ {%Y/%m/%d/}`}
+ {`${getStreamTypeOption(streamType.AuditLogs)?.label} - {stream_type}/{log_type}/ {account}/{%Y/%m/%d/}`}
+
+ }
+ />
+ )}
+
>
);
};
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx
index 5f9739fdb38..183e1ffb692 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.test.tsx
@@ -28,6 +28,7 @@ describe('StreamCreate', () => {
type: destinationType.LinodeObjectStorage,
details: {
region: '',
+ path: '',
},
},
},
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
index f7728d87a35..688227d3a02 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
@@ -39,6 +39,7 @@ export const StreamCreate = () => {
type: destinationType.LinodeObjectStorage,
details: {
region: '',
+ path: '',
},
},
},
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
index 6ede8ecf012..7e84853915e 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
@@ -57,6 +57,7 @@ export const StreamEdit = () => {
type: destinationType.LinodeObjectStorage,
details: {
region: '',
+ path: '',
},
},
},
@@ -76,15 +77,29 @@ export const StreamEdit = () => {
: {};
const streamsDestinationIds = stream.destinations.map(({ id }) => id);
+ const destination = destinations?.data?.find(
+ ({ id }) => id === streamsDestinationIds[0]
+ );
+
form.reset({
stream: {
...stream,
details,
destinations: streamsDestinationIds,
},
- destination: destinations?.data?.find(
- ({ id }) => id === streamsDestinationIds[0]
- ),
+ destination: destination
+ ? {
+ ...destination,
+ ...('path' in destination.details
+ ? {
+ details: {
+ ...destination.details,
+ path: destination.details.path || '',
+ },
+ }
+ : {}),
+ }
+ : undefined,
});
}
}, [stream, destinations, form]);
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
index 00ae4fbe5ab..5e65f6341ff 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
@@ -1,4 +1,8 @@
-import { type StreamStatus, streamType } from '@linode/api-v4';
+import {
+ type CreateDestinationPayload,
+ type StreamStatus,
+ streamType,
+} from '@linode/api-v4';
import {
useCreateDestinationMutation,
useCreateStreamMutation,
@@ -12,7 +16,10 @@ import * as React from 'react';
import { useEffect } from 'react';
import { type SubmitHandler, useFormContext, useWatch } from 'react-hook-form';
-import { getStreamPayloadDetails } from 'src/features/Delivery/deliveryUtils';
+import {
+ getDestinationPayloadDetails,
+ getStreamPayloadDetails,
+} from 'src/features/Delivery/deliveryUtils';
import { FormSubmitBar } from 'src/features/Delivery/Shared/FormSubmitBar/FormSubmitBar';
import { useVerifyDestination } from 'src/features/Delivery/Shared/useVerifyDestination';
import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery';
@@ -20,6 +27,7 @@ import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Del
import { StreamFormClusters } from './Clusters/StreamFormClusters';
import { StreamFormGeneralInfo } from './StreamFormGeneralInfo';
+import type { UpdateDestinationPayload } from '@linode/api-v4';
import type { FormMode } from 'src/features/Delivery/Shared/types';
import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types';
@@ -79,7 +87,13 @@ export const StreamForm = (props: StreamFormProps) => {
let destinationId = destinations?.[0];
if (!destinationId) {
try {
- const { id } = await createDestination(destination);
+ const destinationPayload:
+ | CreateDestinationPayload
+ | UpdateDestinationPayload = {
+ ...destination,
+ details: getDestinationPayloadDetails(destination.details),
+ };
+ const { id } = await createDestination(destinationPayload);
destinationId = id;
enqueueSnackbar(
`Destination ${destination.label} created successfully`,
diff --git a/packages/manager/src/features/Delivery/deliveryUtils.test.ts b/packages/manager/src/features/Delivery/deliveryUtils.test.ts
index aa255f8ecc2..3b2df03578a 100644
--- a/packages/manager/src/features/Delivery/deliveryUtils.test.ts
+++ b/packages/manager/src/features/Delivery/deliveryUtils.test.ts
@@ -1,9 +1,17 @@
import { destinationType } from '@linode/api-v4';
import { expect } from 'vitest';
-import { getDestinationTypeOption } from 'src/features/Delivery/deliveryUtils';
+import {
+ getDestinationPayloadDetails,
+ getDestinationTypeOption,
+} from 'src/features/Delivery/deliveryUtils';
import { destinationTypeOptions } from 'src/features/Delivery/Shared/types';
+import type {
+ LinodeObjectStorageDetails,
+ LinodeObjectStorageDetailsPayload,
+} from '@linode/api-v4';
+
describe('delivery utils functions', () => {
describe('getDestinationTypeOption ', () => {
it('should return option object matching provided value', () => {
@@ -18,4 +26,32 @@ describe('delivery utils functions', () => {
expect(result).toEqual(undefined);
});
});
+
+ describe('getDestinationPayloadDetails ', () => {
+ const testDetails: LinodeObjectStorageDetails = {
+ path: 'testpath',
+ access_key_id: 'keyId',
+ access_key_secret: 'secret',
+ bucket_name: 'name',
+ host: 'host',
+ region: 'us-ord',
+ };
+
+ it('should return payload details with path', () => {
+ const result = getDestinationPayloadDetails(
+ testDetails
+ ) as LinodeObjectStorageDetailsPayload;
+
+ expect(result.path).toEqual(testDetails.path);
+ });
+
+ it('should return details without path property', () => {
+ const result = getDestinationPayloadDetails({
+ ...testDetails,
+ path: '',
+ }) as LinodeObjectStorageDetailsPayload;
+
+ expect(result.path).toEqual(undefined);
+ });
+ });
});
diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts
index 918cb0a2c96..7f7e3385cec 100644
--- a/packages/manager/src/features/Delivery/deliveryUtils.ts
+++ b/packages/manager/src/features/Delivery/deliveryUtils.ts
@@ -11,7 +11,12 @@ import {
streamTypeOptions,
} from 'src/features/Delivery/Shared/types';
-import type { StreamDetails, StreamType } from '@linode/api-v4';
+import type {
+ DestinationDetails,
+ DestinationDetailsPayload,
+ StreamDetails,
+ StreamType,
+} from '@linode/api-v4';
import type {
FormMode,
LabelValueOption,
@@ -46,6 +51,16 @@ export const getStreamPayloadDetails = (
return payloadDetails;
};
+export const getDestinationPayloadDetails = (
+ details: DestinationDetails
+): DestinationDetailsPayload => {
+ if ('path' in details && details.path === '') {
+ return omitProps(details, ['path']);
+ }
+
+ return details;
+};
+
export const getStreamDescription = (stream: Stream) => {
return `${getStreamTypeOption(stream.type)?.label}`;
};
diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
index c7ae5c8ad5b..2d111289e32 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
@@ -1,3 +1,4 @@
+import { destinationType } from '@linode/api-v4';
import { DateTime } from 'luxon';
import { http } from 'msw';
@@ -11,7 +12,12 @@ import {
makeResponse,
} from 'src/mocks/utilities/response';
-import type { Destination, Stream } from '@linode/api-v4';
+import type {
+ CreateDestinationPayload,
+ Destination,
+ LinodeObjectStorageDetails,
+ Stream,
+} from '@linode/api-v4';
import type { StrictResponse } from 'msw';
import type { MockState } from 'src/mocks/types';
import type {
@@ -214,11 +220,17 @@ export const createDestinations = (mockState: MockState) => [
async ({
request,
}): Promise> => {
- const payload = await request.clone().json();
+ const payload: CreateDestinationPayload = await request.clone().json();
+ const details = payload.details;
const destination = destinationFactory.build({
- label: payload['label'],
- type: payload['type'],
- details: payload['details'],
+ label: payload.label,
+ type: payload.type,
+ details: {
+ ...details,
+ ...(payload.type === destinationType.LinodeObjectStorage
+ ? { path: (details as LinodeObjectStorageDetails).path ?? null }
+ : {}),
+ },
created: DateTime.now().toISO(),
updated: DateTime.now().toISO(),
});
diff --git a/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md b/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
new file mode 100644
index 00000000000..bb3e3056264
--- /dev/null
+++ b/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Upcoming Features
+---
+
+Update validation schema for Destination - Details - Path ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts
index 05dc69d2cd6..f027add7128 100644
--- a/packages/validation/src/delivery.schema.ts
+++ b/packages/validation/src/delivery.schema.ts
@@ -57,7 +57,7 @@ const customHTTPsDetailsSchema = object({
endpoint_url: string().max(maxLength, maxLengthMessage).required(),
});
-const linodeObjectStorageDetailsSchema = object({
+const linodeObjectStorageDetailsBaseSchema = object({
host: string().max(maxLength, maxLengthMessage).required('Host is required.'),
bucket_name: string()
.max(maxLength, maxLengthMessage)
@@ -65,7 +65,7 @@ const linodeObjectStorageDetailsSchema = object({
region: string()
.max(maxLength, maxLengthMessage)
.required('Region is required.'),
- path: string().max(maxLength, maxLengthMessage).required('Path is required.'),
+ path: string().max(maxLength, maxLengthMessage).defined(),
access_key_id: string()
.max(maxLength, maxLengthMessage)
.required('Access Key ID is required.'),
@@ -74,20 +74,41 @@ const linodeObjectStorageDetailsSchema = object({
.required('Access Key Secret is required.'),
});
-export const destinationSchema = object().shape({
+const linodeObjectStorageDetailsPayloadSchema =
+ linodeObjectStorageDetailsBaseSchema.shape({
+ path: string().max(maxLength, maxLengthMessage).optional(),
+ });
+
+const destinationSchemaBase = object().shape({
label: string()
.max(maxLength, maxLengthMessage)
.required('Destination name is required.'),
type: string().oneOf(['linode_object_storage', 'custom_https']).required(),
details: mixed<
| InferType
- | InferType
+ | InferType
+ >()
+ .defined()
+ .required()
+ .when('type', {
+ is: 'linode_object_storage',
+ then: () => linodeObjectStorageDetailsBaseSchema,
+ otherwise: () => customHTTPsDetailsSchema,
+ }),
+});
+
+export const destinationFormSchema = destinationSchemaBase;
+
+export const destinationSchema = destinationSchemaBase.shape({
+ details: mixed<
+ | InferType
+ | InferType
>()
.defined()
.required()
.when('type', {
is: 'linode_object_storage',
- then: () => linodeObjectStorageDetailsSchema,
+ then: () => linodeObjectStorageDetailsPayloadSchema,
otherwise: () => customHTTPsDetailsSchema,
}),
});
@@ -158,7 +179,7 @@ export const streamAndDestinationFormSchema = object({
})
.required(),
}),
- destination: destinationSchema.defined().when('stream.destinations', {
+ destination: destinationFormSchema.defined().when('stream.destinations', {
is: (value: never[]) => !value?.length,
then: (schema) => schema,
otherwise: (schema) =>
From c3488114fdef9cf83c384b9f8e6b6f0b3f4ef165 Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Tue, 23 Sep 2025 14:14:57 +0200
Subject: [PATCH 15/54] tech-story: [UIE-9205] IAM - Improve type safety in
`usePermissions` (#12893)
* strenghten type narrowing in usePermissions
* changeset and cleanup
---
.../pr-12893-tech-stories-1758193861545.md | 5 +
.../src/features/IAM/hooks/usePermissions.ts | 124 +++++++++++++++++-
.../AdditionalOptions/MaintenancePolicy.tsx | 5 +-
3 files changed, 126 insertions(+), 8 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
diff --git a/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
new file mode 100644
index 00000000000..cfdfe96c005
--- /dev/null
+++ b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893))
diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts
index 117bbf322e9..486263c3adc 100644
--- a/packages/manager/src/features/IAM/hooks/usePermissions.ts
+++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts
@@ -21,9 +21,27 @@ import type {
AccountEntity,
APIError,
EntityType,
+ FirewallAdmin,
+ FirewallContributor,
+ FirewallViewer,
GrantType,
+ ImageAdmin,
+ ImageContributor,
+ ImageViewer,
+ LinodeAdmin,
+ LinodeContributor,
+ LinodeViewer,
+ NodeBalancerAdmin,
+ NodeBalancerContributor,
+ NodeBalancerViewer,
PermissionType,
Profile,
+ VolumeAdmin,
+ VolumeContributor,
+ VolumeViewer,
+ VPCAdmin,
+ VPCContributor,
+ VPCViewer,
} from '@linode/api-v4';
import type { UseQueryResult } from '@linode/queries';
@@ -36,16 +54,110 @@ const LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE = [
'create_nodebalancer',
];
+type EntityPermission =
+ | FirewallAdmin
+ | FirewallContributor
+ | FirewallViewer
+ | ImageAdmin
+ | ImageContributor
+ | ImageViewer
+ | LinodeAdmin
+ | LinodeContributor
+ | LinodeViewer
+ | NodeBalancerAdmin
+ | NodeBalancerContributor
+ | NodeBalancerViewer
+ | VolumeAdmin
+ | VolumeContributor
+ | VolumeViewer
+ | VPCAdmin
+ | VPCContributor
+ | VPCViewer;
+
+declare const PermissionByAccessKnown: {
+ account: Exclude;
+ database: never; // TODO: add database permissions
+ domain: never; // TODO: add domain permissions
+ firewall: FirewallAdmin | FirewallContributor | FirewallViewer;
+ image: ImageAdmin | ImageContributor | ImageViewer;
+ linode: LinodeAdmin | LinodeContributor | LinodeViewer;
+ lkecluster: never; // TODO: add lkecluster permissions
+ longview: never; // TODO: add longview permissions
+ nodebalancer:
+ | NodeBalancerAdmin
+ | NodeBalancerContributor
+ | NodeBalancerViewer;
+ placement_group: never; // TODO: add placement_group permissions
+ stackscript: never; // TODO: add stackscript permissions
+ volume: VolumeAdmin | VolumeContributor | VolumeViewer;
+ vpc: VPCAdmin | VPCContributor | VPCViewer;
+};
+
+type AssertNever = T;
+
+/**
+ * Compile‑time assertions only.
+ *
+ * Ensure:
+ * - PermissionByAccessKnown has only allowed AccessTypes.
+ * - All AccessTypes are handled by PermissionByAccessKnown.
+ */
+export type NoExtraKeys = AssertNever<
+ Exclude
+>;
+export type AllHandled = AssertNever<
+ Exclude
+>;
+
+type KnownAccessKeys = keyof typeof PermissionByAccessKnown & AccessType;
+
+type AllowedPermissionsFor = A extends KnownAccessKeys
+ ? (typeof PermissionByAccessKnown)[A]
+ : // exhaustiveness check, no fallback
+ never;
+
export type PermissionsResult = {
data: Record;
} & Omit, 'data'>;
-export const usePermissions = (
- accessType: AccessType,
+/**
+ * Overload 1: account-level
+ */
+export function usePermissions<
+ A extends 'account',
+ T extends readonly AllowedPermissionsFor[],
+>(
+ accessType: A,
+ permissionsToCheck: T,
+ entityId?: undefined,
+ enabled?: boolean
+): PermissionsResult;
+
+/**
+ * Overload 2: entity-level
+ */
+export function usePermissions<
+ A extends Exclude,
+ T extends readonly AllowedPermissionsFor[],
+>(
+ accessType: A,
+ permissionsToCheck: T,
+ entityId: number | string | undefined,
+ enabled?: boolean
+): PermissionsResult;
+
+/**
+ * Implementation
+ */
+export function usePermissions<
+ A extends AccessType,
+ T extends readonly PermissionType[],
+>(
+ accessType: A,
permissionsToCheck: T,
entityId?: number | string,
enabled: boolean = true
-): PermissionsResult => {
+): PermissionsResult {
const { isIAMBeta, isIAMEnabled } = useIsIAMEnabled();
const { data: profile } = useProfile();
@@ -70,7 +182,9 @@ export const usePermissions = (
BETA_ACCESS_TYPE_SCOPE.includes(accessType) &&
LA_ACCOUNT_ADMIN_PERMISSIONS_TO_EXCLUDE.some(
(blacklistedPermission) =>
- permissionsToCheck.includes(blacklistedPermission as AccountAdmin) // some of the account admin in the blacklist have not been added yet
+ permissionsToCheck.includes(
+ blacklistedPermission as AllowedPermissionsFor
+ ) // some of the account admin in the blacklist have not been added yet
) === false;
const useLAPermissions = isIAMEnabled && !isIAMBeta;
const shouldUsePermissionMap = useBetaPermissions || useLAPermissions;
@@ -113,7 +227,7 @@ export const usePermissions = (
...restAccountPermissions,
...restEntityPermissions,
} as const;
-};
+}
export type EntityBase = Pick;
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx
index 4bdd304b90d..61357db3320 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/MaintenancePolicy.tsx
@@ -31,8 +31,7 @@ export const MaintenancePolicy = () => {
const { data: region } = useRegionQuery(selectedRegion);
const { data: type } = useTypeQuery(selectedType, Boolean(selectedType));
- // Check if user has permission to update linodes (needed for maintenance policy)
- const { data: permissions } = usePermissions('linode', ['update_linode']);
+ const { data: permissions } = usePermissions('account', ['create_linode']);
const isGPUPlan = type && type.class === 'gpu';
@@ -42,7 +41,7 @@ export const MaintenancePolicy = () => {
// Determine if disabled due to missing prerequisites vs permission issues
const isDisabledDueToPrerequisites =
!selectedRegion || !regionSupportsMaintenancePolicy;
- const isDisabledDueToPermissions = !permissions?.update_linode;
+ const isDisabledDueToPermissions = !permissions?.create_linode;
const isDisabled = isDisabledDueToPrerequisites || isDisabledDueToPermissions;
return (
From 77d3b024519652dc7fb1f49ef10aada791751224 Mon Sep 17 00:00:00 2001
From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
Date: Tue, 23 Sep 2025 15:54:47 +0200
Subject: [PATCH 16/54] feat: [UIE-9126] - IAM RBAC: VPC Details permissions
check (#12810)
* IAM RBAC: VPC permission check for the Details page
* unit tests
* update sub-entity permissions
* add update_vpc check to the linode action menu
* Added changeset: IAM RBAC: Implements IAM RBAC permissions for VPC Details page
* fix unit tests
* add a delay to the action menu for VPC Landing page
* add enabled option to the useQueryWithPermissions hook, move permissions to the drawer
* add useArrayWithPermissions hook, fix e2e tests, review fix
* unit test fix
* update_vpc for unassign linode drawer, refactor
* remove useArrayWithPermissions, refactoring
* tooltipText fix
* add TODO
* review fix
---
...r-12810-upcoming-features-1757334461818.md | 5 ++
.../e2e/core/vpc/vpc-landing-page.spec.ts | 78 +++++++++++------
.../VPCs/VPCDetail/SubnetActionMenu.test.tsx | 85 +++++++++++++++++++
.../VPCs/VPCDetail/SubnetActionMenu.tsx | 25 +++++-
.../VPCDetail/SubnetAssignLinodesDrawer.tsx | 45 +++++-----
.../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 25 +++---
.../VPCs/VPCDetail/SubnetDeleteDialog.tsx | 15 +++-
.../VPCs/VPCDetail/SubnetEditDrawer.test.tsx | 45 ++++++++++
.../VPCs/VPCDetail/SubnetEditDrawer.tsx | 27 ++----
.../VPCDetail/SubnetLinodeActionMenu.test.tsx | 71 +++++++++++++++-
.../VPCs/VPCDetail/SubnetLinodeActionMenu.tsx | 27 ++++++
.../VPCs/VPCDetail/SubnetLinodeRow.test.tsx | 8 ++
.../VPCs/VPCDetail/SubnetLinodeRow.tsx | 3 +
.../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 36 ++++----
.../VPCs/VPCDetail/VPCDetail.styles.ts | 2 +-
.../VPCs/VPCDetail/VPCDetail.test.tsx | 42 +++++++++
.../src/features/VPCs/VPCDetail/VPCDetail.tsx | 27 +++++-
.../VPCs/VPCDetail/VPCSubnetsTable.test.tsx | 54 +++++++++++-
.../VPCs/VPCDetail/VPCSubnetsTable.tsx | 14 +++
.../features/VPCs/VPCLanding/VPCRow.test.tsx | 30 +++++--
.../src/features/VPCs/VPCLanding/VPCRow.tsx | 26 +++---
21 files changed, 565 insertions(+), 125 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
diff --git a/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md b/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
new file mode 100644
index 00000000000..d3d8e218f4c
--- /dev/null
+++ b/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+IAM RBAC: Implements IAM RBAC permissions for VPC Details page ([#12810](https://github.com/linode/manager/pull/12810))
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
index 50ad31d690a..62f9e387b66 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts
@@ -28,25 +28,28 @@ describe('VPC landing page', () => {
cy.wait('@getVPCs');
// Confirm each VPC is listed with expected data.
- mockVPCs.forEach((mockVPC) => {
- const regionLabel = getRegionById(mockVPC.region).label;
- cy.findByText(mockVPC.label)
- .should('be.visible')
- .closest('tr')
- .within(() => {
- cy.findByText(regionLabel).should('be.visible');
-
- ui.button
- .findByTitle('Edit')
- .should('be.visible')
- .should('be.enabled');
-
- ui.button
- .findByTitle('Delete')
- .should('be.visible')
- .should('be.enabled');
- });
- });
+ const regionLabel = getRegionById(mockVPCs[0].region).label;
+ cy.findByText(mockVPCs[0].label)
+ .should('be.visible')
+ .closest('tr')
+ .within(() => {
+ cy.findByText(regionLabel).should('be.visible');
+
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+ .should('be.visible')
+ .click();
+
+ ui.actionMenuItem
+ .findByTitle('Edit')
+ .should('be.visible')
+ .should('be.enabled');
+
+ ui.actionMenuItem
+ .findByTitle('Delete')
+ .should('be.visible')
+ .should('be.enabled');
+ });
});
/*
@@ -112,7 +115,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button.findByTitle('Edit').should('be.visible').click();
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem.findByTitle('Edit').should('be.visible').click();
});
// Confirm correct information is shown and update label and description.
@@ -149,7 +156,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button.findByTitle('Edit').should('be.visible').click();
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockUpdatedVPC.label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem.findByTitle('Edit').should('be.visible').click();
});
ui.drawer
@@ -179,7 +190,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem
.findByTitle('Delete')
.should('be.visible')
.should('be.enabled')
@@ -192,7 +207,6 @@ describe('VPC landing page', () => {
.within(() => {
cy.findByLabelText('VPC Label').should('be.visible').click();
cy.focused().type(mockVPCs[0].label);
-
ui.button
.findByTitle('Delete')
.should('be.visible')
@@ -211,7 +225,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem
.findByTitle('Delete')
.should('be.visible')
.should('be.enabled')
@@ -269,7 +287,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[0].label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem
.findByTitle('Delete')
.should('be.visible')
.should('be.enabled')
@@ -312,7 +334,11 @@ describe('VPC landing page', () => {
.should('be.visible')
.closest('tr')
.within(() => {
- ui.button
+ ui.actionMenu
+ .findByTitle(`Action menu for VPC ${mockVPCs[1].label}`)
+ .should('be.visible')
+ .click();
+ ui.actionMenuItem
.findByTitle('Delete')
.should('be.visible')
.should('be.enabled')
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx
index 06ec7e74c87..945889997cf 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.test.tsx
@@ -7,6 +7,27 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { SubnetActionMenu } from './SubnetActionMenu';
+const queryMocks = vi.hoisted(() => ({
+ userPermissions: vi.fn(() => ({
+ data: {
+ update_linode: true,
+ delete_linode: true,
+ update_vpc: true,
+ },
+ })),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [
+ { id: 1, label: 'linode-1' },
+ { id: 2, label: 'linode-2' },
+ ],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
+}));
afterEach(() => {
vi.clearAllMocks();
});
@@ -105,4 +126,68 @@ describe('SubnetActionMenu', () => {
await userEvent.click(assignButton);
expect(props.handleAssignLinodes).toHaveBeenCalled();
});
+
+ it('should disable the Assign Linodes button if user does not have update_linode permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_linode: false,
+ delete_linode: false,
+ update_vpc: false,
+ },
+ });
+ const view = renderWithTheme();
+ const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`);
+ await userEvent.click(actionMenu);
+
+ const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' });
+ expect(assignButton).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('should enable the Assign Linodes button if user has update_linode and update_vpc permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_linode: true,
+ delete_linode: false,
+ update_vpc: true,
+ },
+ });
+ const view = renderWithTheme();
+ const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`);
+ await userEvent.click(actionMenu);
+
+ const assignButton = view.getByRole('menuitem', { name: 'Assign Linodes' });
+ expect(assignButton).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('should disable the Edit button if user does not have update_vpc permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_linode: false,
+ delete_linode: false,
+ update_vpc: false,
+ },
+ });
+ const view = renderWithTheme();
+ const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`);
+ await userEvent.click(actionMenu);
+
+ const editButton = view.getByRole('menuitem', { name: 'Edit' });
+ expect(editButton).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('should enable the Edit button if user has update_vpc permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_linode: false,
+ delete_linode: false,
+ update_vpc: true,
+ },
+ });
+ const view = renderWithTheme();
+ const actionMenu = view.getByLabelText(`Action menu for Subnet subnet-1`);
+ await userEvent.click(actionMenu);
+
+ const editButton = view.getByRole('menuitem', { name: 'Edit' });
+ expect(editButton).not.toHaveAttribute('aria-disabled', 'true');
+ });
});
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx
index 0fad2132dc0..347a70b52d2 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetActionMenu.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils';
import type { Subnet } from '@linode/api-v4';
@@ -29,31 +30,49 @@ export const SubnetActionMenu = (props: Props) => {
numLinodes,
numNodebalancers,
subnet,
+ vpcId,
} = props;
const flags = useIsNodebalancerVPCEnabled();
+ const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId);
+ const canUpdateVPC = permissions?.update_vpc;
+
const actions: Action[] = [
{
onClick: () => {
handleAssignLinodes(subnet);
},
title: 'Assign Linodes',
+ disabled: !canUpdateVPC,
+ tooltip: !canUpdateVPC
+ ? 'You do not have permission to assign Linode to this subnet.'
+ : undefined,
},
{
onClick: () => {
handleUnassignLinodes(subnet);
},
title: 'Unassign Linodes',
+ disabled: !canUpdateVPC,
+ tooltip: !canUpdateVPC
+ ? 'You do not have permission to unassign Linode from this subnet.'
+ : undefined,
},
{
onClick: () => {
handleEdit(subnet);
},
title: 'Edit',
+ // TODO: change to 'update_vpc_subnet' once it's available
+ disabled: !canUpdateVPC,
+ tooltip: !canUpdateVPC
+ ? 'You do not have permission to edit this subnet.'
+ : undefined,
},
{
- disabled: numLinodes !== 0 || numNodebalancers !== 0,
+ // TODO: change to 'delete_vpc_subnet' once it's available
+ disabled: numLinodes !== 0 || numNodebalancers !== 0 || !canUpdateVPC,
onClick: () => {
handleDelete(subnet);
},
@@ -61,7 +80,9 @@ export const SubnetActionMenu = (props: Props) => {
tooltip:
numLinodes > 0 || numNodebalancers > 0
? `${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'} assigned to a subnet must be unassigned before the subnet can be deleted.`
- : '',
+ : !canUpdateVPC
+ ? 'You do not have permission to delete this subnet.'
+ : undefined,
},
];
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
index 563a51cccc7..b28deda1f32 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
@@ -4,8 +4,6 @@ import {
getAllLinodeConfigs,
useAllLinodesQuery,
useFirewallSettingsQuery,
- useGrants,
- useProfile,
} from '@linode/queries';
import { LinodeSelect } from '@linode/shared';
import {
@@ -31,6 +29,10 @@ import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV';
import { Link } from 'src/components/Link';
import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable';
import { FirewallSelect } from 'src/features/Firewalls/components/FirewallSelect';
+import {
+ usePermissions,
+ useQueryWithPermissions,
+} from 'src/features/IAM/hooks/usePermissions';
import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/LinodeCreate/Networking/utilities';
import {
REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS,
@@ -146,16 +148,19 @@ export const SubnetAssignLinodesDrawer = (
const [allowPublicIPv6Access, setAllowPublicIPv6Access] =
React.useState(false);
- const { data: profile } = useProfile();
- const { data: grants } = useGrants();
- const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId);
+ const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId);
+ // TODO: change update_linode to create_linode_config_profile_interface once it's available
+ // TODO: change delete_linode to delete_linode_config_profile_interface once it's available
+ // TODO: refactor useQueryWithPermissions once API filter is available
+ const { data: filteredLinodes } = useQueryWithPermissions(
+ useAllLinodesQuery(),
+ 'linode',
+ ['update_linode', 'delete_linode'],
+ open
+ );
- // @TODO VPC: this logic for vpc grants/perms appears a lot - commenting a todo here in case we want to move this logic to a parent component
- // there isn't a 'view VPC/Subnet' grant that does anything, so all VPCs get returned even for restricted users
- // with permissions set to 'None'. Therefore, we're treating those as read_only as well
- const userCannotAssignLinodes =
- Boolean(profile?.restricted) &&
- (vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0);
+ const userCanAssignLinodes =
+ permissions?.update_vpc && filteredLinodes?.length > 0;
const downloadCSV = async () => {
await getCSVData();
@@ -586,7 +591,7 @@ export const SubnetAssignLinodesDrawer = (
subnet?.ipv4 ?? subnet?.ipv6 ?? 'Unknown'
})`}
>
- {userCannotAssignLinodes && (
+ {!userCanAssignLinodes && (
{REGIONAL_LINODE_MESSAGE}
{
setFieldValue('selectedLinode', selected);
@@ -633,7 +638,7 @@ export const SubnetAssignLinodesDrawer = (
/>
}
data-testid="vpc-ipv4-checkbox"
- disabled={userCannotAssignLinodes}
+ disabled={!userCanAssignLinodes}
label={Auto-assign VPC IPv4 address}
sx={{ marginRight: 0 }}
/>
@@ -654,7 +659,7 @@ export const SubnetAssignLinodesDrawer = (
{!autoAssignVPCIPv4Address && (
}
data-testid="vpc-ipv6-checkbox"
- disabled={userCannotAssignLinodes}
+ disabled={!userCanAssignLinodes}
label={
Auto-assign VPC IPv6 address
}
@@ -711,7 +716,7 @@ export const SubnetAssignLinodesDrawer = (
{!autoAssignVPCIPv6Address && (
{
setFieldValue('selectedConfig', value);
@@ -760,7 +765,7 @@ export const SubnetAssignLinodesDrawer = (
}
showIPv6Content={showIPv6Content}
sx={{ margin: `${theme.spacingFunction(16)} 0` }}
- userCannotAssignLinodes={userCannotAssignLinodes}
+ userCannotAssignLinodes={!userCanAssignLinodes}
/>
{/* Display the 'Assign additional [IPv4] ranges' section if
the Configuration Profile section has been populated, or
@@ -801,7 +806,7 @@ export const SubnetAssignLinodesDrawer = (
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
index 65ce867cf54..419b13481e1 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx
@@ -1,9 +1,4 @@
-import {
- linodeQueries,
- useAllLinodesQuery,
- useGrants,
- useProfile,
-} from '@linode/queries';
+import { linodeQueries, useAllLinodesQuery } from '@linode/queries';
import {
ActionsPanel,
Autocomplete,
@@ -20,6 +15,10 @@ import * as React from 'react';
import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV';
import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable';
+import {
+ usePermissions,
+ useQueryWithPermissions,
+} from 'src/features/IAM/hooks/usePermissions';
import { REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS } from 'src/features/VPCs/constants';
import { useUnassignLinode } from 'src/hooks/useUnassignLinode';
import { useVPCDualStack } from 'src/hooks/useVPCDualStack';
@@ -74,16 +73,12 @@ export const SubnetUnassignLinodesDrawer = React.memo(
subnetError,
vpcId,
}: Props) => {
- const { data: profile } = useProfile();
- const { data: grants } = useGrants();
-
const { isDualStackEnabled } = useVPCDualStack(subnet?.ipv6 ?? []);
const showIPv6Content =
isDualStackEnabled &&
Boolean(subnet?.ipv6?.length && subnet?.ipv6?.length > 0);
const subnetId = subnet?.id;
- const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId);
const queryClient = useQueryClient();
const { setUnassignLinodesErrors, unassignLinode, unassignLinodesErrors } =
@@ -110,10 +105,6 @@ export const SubnetUnassignLinodesDrawer = React.memo(
const { linodes: subnetLinodeIds } = subnet || {};
- const userCannotUnassignLinodes =
- Boolean(profile?.restricted) &&
- (vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0);
-
// 1. We need to get all the linodes.
const {
data: linodes,
@@ -130,6 +121,16 @@ export const SubnetUnassignLinodesDrawer = React.memo(
});
}, [linodes, subnetLinodeIds]);
+ const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId);
+ // TODO: change to 'delete_linode_config_profile_interface' once it's available
+ const { data: filteredLinodes } = useQueryWithPermissions(
+ useAllLinodesQuery(),
+ 'linode',
+ ['delete_linode']
+ );
+ const userCanUnassignLinodes =
+ permissions.update_vpc && filteredLinodes?.length > 0;
+
React.useEffect(() => {
if (linodes) {
setLinodeOptionsToUnassign(findAssignedLinodes() ?? []);
@@ -337,7 +338,7 @@ export const SubnetUnassignLinodesDrawer = React.memo(
subnet?.ipv4 ?? subnet?.ipv6 ?? 'Unknown'
})`}
>
- {userCannotUnassignLinodes && (
+ {!userCanUnassignLinodes && linodeOptionsToUnassign.length > 0 && (
{!singleLinodeToBeUnassigned && (
({
- '&:hover': {
+ '&:not([aria-disabled="true"]):hover': {
backgroundColor: theme.color.blue,
color: theme.tokens.color.Neutrals.White,
},
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx
index dcfdb252c5d..1a708ced2b6 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx
@@ -16,6 +16,17 @@ const queryMocks = vi.hoisted(() => ({
useVPCQuery: vi.fn().mockReturnValue({}),
useFirewallSettingsQuery: vi.fn().mockReturnValue({}),
useRegionsQuery: vi.fn().mockReturnValue({}),
+ userPermissions: vi.fn(() => ({
+ data: {
+ update_vpc: true,
+ delete_vpc: true,
+ },
+ })),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
}));
vi.mock('@linode/queries', async () => {
@@ -39,6 +50,10 @@ vi.mock('@tanstack/react-router', async () => {
};
});
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
+}));
beforeAll(() => mockMatchMedia());
describe('VPC Detail Summary section', () => {
@@ -192,4 +207,31 @@ describe('VPC Detail Summary section', () => {
)
).toBeVisible();
});
+ it('should disable actions if user does not have "update_vpc" or "delete_vpc" permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_vpc: false,
+ delete_vpc: false,
+ },
+ });
+
+ const { getByText } = renderWithTheme();
+
+ expect(getByText('Edit')).toBeDisabled();
+ expect(getByText('Delete')).toBeDisabled();
+ });
+
+ it('should enable actions if user has "update_vpc" or "delete_vpc" permissions', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ update_vpc: true,
+ delete_vpc: true,
+ },
+ });
+
+ const { getByText } = renderWithTheme();
+
+ expect(getByText('Edit')).toBeEnabled();
+ expect(getByText('Delete')).toBeEnabled();
+ });
});
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx
index 295b7bec2f1..db6c3f55310 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx
@@ -15,6 +15,7 @@ import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleB
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { EntityHeader } from 'src/components/EntityHeader/EntityHeader';
import { LandingHeader } from 'src/components/LandingHeader';
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { LKE_ENTERPRISE_AUTOGEN_VPC_WARNING } from 'src/features/Kubernetes/constants';
import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils';
import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants';
@@ -54,6 +55,12 @@ const VPCDetail = () => {
const { data: regions } = useRegionsQuery();
+ const { data: permissions } = usePermissions(
+ 'vpc',
+ ['update_vpc', 'delete_vpc'],
+ vpcId
+ );
+
const handleEditVPC = (vpc: VPC) => {
navigate({
params: { action: 'edit', vpcId: vpc.id },
@@ -166,10 +173,26 @@ const VPCDetail = () => {
- handleEditVPC(vpc)}>
+ handleEditVPC(vpc)}
+ tooltipText={
+ !permissions.update_vpc
+ ? 'You do not have permission to edit this VPC.'
+ : undefined
+ }
+ >
Edit
- handleDeleteVPC(vpc)}>
+ handleDeleteVPC(vpc)}
+ tooltipText={
+ !permissions.delete_vpc
+ ? 'You do not have permission to delete this VPC.'
+ : undefined
+ }
+ >
Delete
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
index aeeca4f8231..02c61cf48a1 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx
@@ -17,6 +17,16 @@ const queryMocks = vi.hoisted(() => ({
useSearch: vi.fn().mockReturnValue({ query: undefined }),
useSubnetsQuery: vi.fn().mockReturnValue({}),
useFirewallSettingsQuery: vi.fn().mockReturnValue({}),
+ userPermissions: vi.fn(() => ({
+ data: {
+ create_vpc_subnet: true,
+ },
+ })),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
}));
vi.mock('@tanstack/react-router', async () => {
@@ -35,7 +45,10 @@ vi.mock('@linode/queries', async () => {
useFirewallSettingsQuery: queryMocks.useFirewallSettingsQuery,
};
});
-
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
+}));
const loadingTestId = 'circle-progress';
describe('VPC Subnets table', () => {
@@ -272,4 +285,43 @@ describe('VPC Subnets table', () => {
},
{ timeout: 15_000 }
);
+
+ it('should disable "Create Subnet" button when user does not have create_vpc_subnet permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ create_vpc_subnet: false,
+ },
+ });
+
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Create Subnet')).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('should enable "Create Subnet" button when user has create_vpc_subnet permission', async () => {
+ queryMocks.userPermissions.mockReturnValue({
+ data: {
+ create_vpc_subnet: true,
+ },
+ });
+
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Create Subnet')).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ });
});
diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
index d59c5a7266d..a8c9ebb9fc5 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx
@@ -25,6 +25,7 @@ import { TableHead } from 'src/components/TableHead';
import { TableRow } from 'src/components/TableRow';
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableSortCell } from 'src/components/TableSortCell';
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer';
import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils';
import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu';
@@ -91,6 +92,12 @@ export const VPCSubnetsTable = (props: Props) => {
const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled();
+ const { data: permissions } = usePermissions(
+ 'vpc',
+ ['create_vpc_subnet'],
+ vpcId
+ );
+
const pagination = usePaginationV2({
currentRoute: VPC_DETAILS_ROUTE,
preferenceKey,
@@ -360,6 +367,7 @@ export const VPCSubnetsTable = (props: Props) => {
subnet={subnet}
subnetId={subnet.id}
subnetInterfaces={linodeInfo.interfaces}
+ vpcId={vpcId}
/>
))
) : (
@@ -423,10 +431,16 @@ export const VPCSubnetsTable = (props: Props) => {
/>
diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx
index 3da8b5343c7..32f2f31e0cd 100644
--- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx
@@ -25,11 +25,11 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({
}));
describe('VPC Table Row', () => {
- it('should render a VPC row', () => {
+ it('should render a VPC row', async () => {
const vpc = vpcFactory.build({ id: 24, subnets: [subnetFactory.build()] });
resizeScreenSize(1600);
- const { getByText } = renderWithTheme(
+ const { getByText, getByLabelText } = renderWithTheme(
wrapWithTableBody(
{
)
);
+ const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`);
+ await userEvent.click(actionMenu);
// Check to see if the row rendered some data
expect(getByText(vpc.label)).toBeVisible();
expect(getByText(vpc.id)).toBeVisible();
@@ -52,7 +54,7 @@ describe('VPC Table Row', () => {
it('should have a delete button that calls the provided callback when clicked', async () => {
const vpc = vpcFactory.build();
const handleDelete = vi.fn();
- const { getByTestId } = renderWithTheme(
+ const { getByTestId, getByLabelText } = renderWithTheme(
wrapWithTableBody(
{
/>
)
);
+ const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`);
+ await userEvent.click(actionMenu);
+
const deleteBtn = getByTestId('Delete');
await userEvent.click(deleteBtn);
expect(handleDelete).toHaveBeenCalled();
@@ -70,7 +75,7 @@ describe('VPC Table Row', () => {
it('should have an edit button that calls the provided callback when clicked', async () => {
const vpc = vpcFactory.build();
const handleEdit = vi.fn();
- const { getByTestId } = renderWithTheme(
+ const { getByTestId, getByLabelText } = renderWithTheme(
wrapWithTableBody(
{
/>
)
);
+ const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`);
+ await userEvent.click(actionMenu);
+
const editButton = getByTestId('Edit');
await userEvent.click(editButton);
expect(handleEdit).toHaveBeenCalled();
@@ -94,7 +102,7 @@ describe('VPC Table Row', () => {
});
const vpc = vpcFactory.build();
const handleEdit = vi.fn();
- const { getByTestId } = renderWithTheme(
+ const { getByTestId, getByLabelText } = renderWithTheme(
wrapWithTableBody(
{
/>
)
);
+ const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`);
+ await userEvent.click(actionMenu);
+
const editButton = getByTestId('Edit');
- expect(editButton).toBeDisabled();
+ expect(editButton).toHaveAttribute('aria-disabled', 'true');
const deleteButton = getByTestId('Delete');
- expect(deleteButton).toBeDisabled();
+ expect(deleteButton).toHaveAttribute('aria-disabled', 'true');
});
it('should enable "Edit" and "Delete" button if user has "update_vpc" and "delete_vpc" permissions', async () => {
queryMocks.userPermissions.mockReturnValue({
@@ -118,7 +129,7 @@ describe('VPC Table Row', () => {
});
const vpc = vpcFactory.build();
const handleEdit = vi.fn();
- const { getByTestId } = renderWithTheme(
+ const { getByTestId, getByLabelText } = renderWithTheme(
wrapWithTableBody(
{
/>
)
);
+ const actionMenu = getByLabelText(`Action menu for VPC ${vpc.label}`);
+ await userEvent.click(actionMenu);
+
const editButton = getByTestId('Edit');
expect(editButton).toBeEnabled();
const deleteButton = getByTestId('Delete');
diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx
index ad7ac6e70ca..eae57e2347a 100644
--- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx
+++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx
@@ -2,7 +2,7 @@ import { useRegionsQuery } from '@linode/queries';
import { Hidden } from '@linode/ui';
import * as React from 'react';
-import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
+import { type Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { Link } from 'src/components/Link';
import { TableCell } from 'src/components/TableCell';
import { TableRow } from 'src/components/TableRow';
@@ -15,7 +15,6 @@ import {
} from '../utils';
import type { VPC } from '@linode/api-v4/lib/vpcs/types';
-import type { Action } from 'src/components/ActionMenu/ActionMenu';
interface Props {
handleDeleteVPC: () => void;
@@ -33,15 +32,18 @@ export const VPCRow = ({
const { id, label, subnets } = vpc;
const { data: regions } = useRegionsQuery();
+ const [isOpen, setIsOpen] = React.useState(false);
+
const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? '';
const numResources = isNodebalancerVPCEnabled
? getUniqueResourcesFromSubnets(vpc.subnets)
: getUniqueLinodesFromSubnets(vpc.subnets);
- const { data: permissions } = usePermissions(
+ const { data: permissions, isLoading } = usePermissions(
'vpc',
['update_vpc', 'delete_vpc'],
- vpc.id
+ vpc.id,
+ isOpen
);
const actions: Action[] = [
@@ -87,16 +89,12 @@ export const VPCRow = ({
{numResources}
- {actions.map((action) => (
-
- ))}
+ setIsOpen(true)}
+ />
);
From 755ebeb03fc2812f451010b8ede66cadf758a48d Mon Sep 17 00:00:00 2001
From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
Date: Tue, 23 Sep 2025 15:55:10 +0200
Subject: [PATCH 17/54] change: [UIE-9202] - IAM RBAC: Improve Change Role
Autocomplete UX in Change Role Drawer (#12901)
* change: [UIE-9202] - IAM RBAC: filter for Change Role Drawer
* Added changeset: Improve role selection UX in change role drawer
---
.../pr-12901-changed-1758617282890.md | 5 ++++
.../ChangeRoleDrawer.test.tsx | 28 ++++++++++++++++++
.../AssignedRolesTable/ChangeRoleDrawer.tsx | 29 +++++++++++++++----
3 files changed, 56 insertions(+), 6 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12901-changed-1758617282890.md
diff --git a/packages/manager/.changeset/pr-12901-changed-1758617282890.md b/packages/manager/.changeset/pr-12901-changed-1758617282890.md
new file mode 100644
index 00000000000..793be65208e
--- /dev/null
+++ b/packages/manager/.changeset/pr-12901-changed-1758617282890.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Improve role selection UX in change role drawer ([#12901](https://github.com/linode/manager/pull/12901))
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
index f78689acdd7..548c4a0d63a 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.test.tsx
@@ -196,4 +196,32 @@ describe('ChangeRoleDrawer', () => {
mockAccountAccessRole.name
);
});
+
+ it('should not list roles that the user already has', async () => {
+ queryMocks.useUserRoles.mockReturnValue({
+ data: {
+ account_access: ['account_linode_admin', 'account_viewer'],
+ entity_access: [],
+ },
+ });
+
+ queryMocks.useAccountRoles.mockReturnValue({
+ data: accountRolesFactory.build(),
+ });
+
+ renderWithTheme();
+
+ const autocomplete = screen.getByRole('combobox');
+
+ await userEvent.click(autocomplete);
+
+ // expect select not to have the current role as one of the options
+ const options = screen.getAllByRole('option');
+ expect(options.map((option) => option.textContent)).not.toContain(
+ 'account_linode_admin'
+ );
+ expect(options.map((option) => option.textContent)).not.toContain(
+ 'account_viewer'
+ );
+ });
});
diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
index acf271a1fb0..41bd9b69874 100644
--- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
+++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/ChangeRoleDrawer.tsx
@@ -24,6 +24,8 @@ import {
getAllRoles,
getErrorMessage,
getRoleByName,
+ isAccountRole,
+ isEntityRole,
} from '../utilities';
import type { DrawerModes, EntitiesOption, ExtendedRoleView } from '../types';
@@ -63,14 +65,29 @@ export const ChangeRoleDrawer = ({ mode, onClose, open, role }: Props) => {
if (!accountRoles) {
return [];
}
-
- return getAllRoles(accountRoles).filter(
- (el) =>
+ return getAllRoles(accountRoles).filter((el) => {
+ const matchesRoleContext =
el.entity_type === role?.entity_type &&
el.access === role?.access &&
- el.value !== role?.name
- );
- }, [accountRoles, role]);
+ el.value !== role?.name;
+ // Exclude account roles already assigned to the user
+ if (isAccountRole(el)) {
+ return (
+ !assignedRoles?.account_access.includes(el.value) &&
+ matchesRoleContext
+ );
+ }
+ // Exclude entity roles already assigned to the user
+ if (isEntityRole(el)) {
+ return (
+ !assignedRoles?.entity_access.some((entity) =>
+ entity.roles.includes(el.value)
+ ) && matchesRoleContext
+ );
+ }
+ return true;
+ });
+ }, [accountRoles, assignedRoles, role]);
const {
control,
From 23a8dc09da7a66a470612cf9619c33775712ba70 Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Wed, 24 Sep 2025 10:12:56 +0200
Subject: [PATCH 18/54] feat: [UIE-9242] - Add IAM delegation (parent/child)
feature flag (#12906)
* addd feature flag and dev tool support
* Added changeset: IAM delegation feature flag
---
packages/manager/.changeset/pr-12906-added-1758634850982.md | 5 +++++
packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 +
packages/manager/src/featureFlags.ts | 1 +
3 files changed, 7 insertions(+)
create mode 100644 packages/manager/.changeset/pr-12906-added-1758634850982.md
diff --git a/packages/manager/.changeset/pr-12906-added-1758634850982.md b/packages/manager/.changeset/pr-12906-added-1758634850982.md
new file mode 100644
index 00000000000..27db11bdf09
--- /dev/null
+++ b/packages/manager/.changeset/pr-12906-added-1758634850982.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+IAM delegation feature flag ([#12906](https://github.com/linode/manager/pull/12906))
diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
index 2dea41f2d3c..f6ad923d33e 100644
--- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx
+++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx
@@ -53,6 +53,7 @@ const options: { flag: keyof Flags; label: string }[] = [
},
{ flag: 'apicliButtonCopy', label: 'APICLI Button Copy' },
{ flag: 'iam', label: 'Identity and Access Beta' },
+ { flag: 'iamDelegation', label: 'IAM Delegation (Parent/Child)' },
{ flag: 'iamRbacPrimaryNavChanges', label: 'IAM Primary Nav Changes' },
{
flag: 'linodeCloneFirewall',
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index bb9f6c93cb5..ecbabf65481 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -174,6 +174,7 @@ export interface Flags {
gecko2: GeckoFeatureFlag;
gpuv2: GpuV2;
iam: BetaFeatureFlag;
+ iamDelegation: BaseFeatureFlag;
iamRbacPrimaryNavChanges: boolean;
ipv6Sharing: boolean;
kubernetesBlackwellPlans: boolean;
From 675174304f13ee9094b34c2227bee100690b485d Mon Sep 17 00:00:00 2001
From: Dmytro Chyrva
Date: Wed, 24 Sep 2025 10:38:19 +0200
Subject: [PATCH 19/54] change: [STORIF-84] Updated "Getting Started" link on
the Volume Details page. (#12904)
* change: [STORIF-84] Updated "Getting Started" link on the Volume Details page.
* Added changeset: Getting started link on the volume details page
---
.../manager/.changeset/pr-12904-changed-1758623601366.md | 5 +++++
.../features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
create mode 100644 packages/manager/.changeset/pr-12904-changed-1758623601366.md
diff --git a/packages/manager/.changeset/pr-12904-changed-1758623601366.md b/packages/manager/.changeset/pr-12904-changed-1758623601366.md
new file mode 100644
index 00000000000..69f7ebf8a8a
--- /dev/null
+++ b/packages/manager/.changeset/pr-12904-changed-1758623601366.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Getting started link on the volume details page ([#12904](https://github.com/linode/manager/pull/12904))
diff --git a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx
index a717462fd9f..8420243d2cb 100644
--- a/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx
+++ b/packages/manager/src/features/Volumes/VolumeDetails/VolumeDetailsHeader.tsx
@@ -28,7 +28,7 @@ export const VolumeDetailsHeader = ({ volume }: Props) => {
pathname: `/volumes/${volume.label}`,
}}
docsLabel="Getting Started"
- docsLink="https://techdocs.akamai.com/cloud-computing/docs/faqs-for-compute-instances"
+ docsLink="https://techdocs.akamai.com/cloud-computing/docs/block-storage"
entity="Volume"
spacingBottom={16}
/>
From d95ad8a7df9f735c4a58cb4ef3b9212589f372ca Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Wed, 24 Sep 2025 11:06:43 +0200
Subject: [PATCH 20/54] feat: [UIE-9203] - IAM Parent/Child - Implement new
delegation types, endpoints & hooks (#12895)
* types and queries
* hooks + cleanup
* moar cleanup
* changesets
* feedback @bnussman-akamai
* cleanup
* moar cleanup
* feedback @aaleksee-akamai
* cleanup from feedback
---
.../pr-12895-added-1758540546949.md | 5 +
packages/api-v4/src/iam/delegation.ts | 107 ++++++++++
packages/api-v4/src/iam/delegation.types.ts | 34 +++
packages/api-v4/src/iam/index.ts | 4 +-
.../pr-12895-added-1758540593030.md | 5 +
packages/queries/src/iam/delegation.ts | 200 ++++++++++++++++++
6 files changed, 354 insertions(+), 1 deletion(-)
create mode 100644 packages/api-v4/.changeset/pr-12895-added-1758540546949.md
create mode 100644 packages/api-v4/src/iam/delegation.ts
create mode 100644 packages/api-v4/src/iam/delegation.types.ts
create mode 100644 packages/queries/.changeset/pr-12895-added-1758540593030.md
create mode 100644 packages/queries/src/iam/delegation.ts
diff --git a/packages/api-v4/.changeset/pr-12895-added-1758540546949.md b/packages/api-v4/.changeset/pr-12895-added-1758540546949.md
new file mode 100644
index 00000000000..6da438424cc
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12895-added-1758540546949.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Added
+---
+
+IAM Parent/Child - Implement new delegation types and endpoints definitions ([#12895](https://github.com/linode/manager/pull/12895))
diff --git a/packages/api-v4/src/iam/delegation.ts b/packages/api-v4/src/iam/delegation.ts
new file mode 100644
index 00000000000..2632fe85f19
--- /dev/null
+++ b/packages/api-v4/src/iam/delegation.ts
@@ -0,0 +1,107 @@
+import { BETA_API_ROOT } from '../constants';
+import Request, { setData, setMethod, setParams, setURL } from '../request';
+
+import type { Account } from '../account';
+import type { Token } from '../profile';
+import type { ResourcePage as Page } from '../types';
+import type {
+ ChildAccount,
+ ChildAccountWithDelegates,
+ GetChildAccountDelegatesParams,
+ GetChildAccountsIamParams,
+ GetDelegatedChildAccountsForUserParams,
+ GetMyDelegatedChildAccountsParams,
+ UpdateChildAccountDelegatesParams,
+} from './delegation.types';
+import type { IamUserRoles } from './types';
+
+export const getChildAccountsIam = ({
+ params,
+ users,
+}: GetChildAccountsIamParams) =>
+ users
+ ? Request>(
+ setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts?users=true`),
+ setMethod('GET'),
+ setParams({ ...params }),
+ )
+ : Request>(
+ setURL(`${BETA_API_ROOT}/iam/delegation/child-accounts`),
+ setMethod('GET'),
+ setParams({ ...params }),
+ );
+
+export const getDelegatedChildAccountsForUser = ({
+ username,
+ params,
+}: GetDelegatedChildAccountsForUserParams) =>
+ Request>(
+ setURL(
+ `${BETA_API_ROOT}/iam/delegation/users/${encodeURIComponent(username)}/child-accounts`,
+ ),
+ setMethod('GET'),
+ setParams(params),
+ );
+
+export const getChildAccountDelegates = ({
+ euuid,
+ params,
+}: GetChildAccountDelegatesParams) =>
+ Request>(
+ setURL(
+ `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`,
+ ),
+ setMethod('GET'),
+ setParams(params),
+ );
+
+export const updateChildAccountDelegates = ({
+ euuid,
+ data,
+}: UpdateChildAccountDelegatesParams) =>
+ Request>(
+ setURL(
+ `${BETA_API_ROOT}/iam/delegation/child-accounts/${encodeURIComponent(euuid)}/users`,
+ ),
+ setMethod('PUT'),
+ setData(data),
+ );
+
+export const getMyDelegatedChildAccounts = ({
+ params,
+}: GetMyDelegatedChildAccountsParams) =>
+ Request>(
+ setURL(`${BETA_API_ROOT}/iam/delegation/profile/child-accounts`),
+ setMethod('GET'),
+ setParams(params),
+ );
+
+export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) =>
+ Request(
+ setURL(
+ `${BETA_API_ROOT}/iam/delegation/profile/child-accounts/${encodeURIComponent(euuid)}`,
+ ),
+ setMethod('GET'),
+ );
+
+export const generateChildAccountToken = ({ euuid }: { euuid: string }) =>
+ Request(
+ setURL(
+ `${BETA_API_ROOT}/iam/delegation/child-accounts/child-accounts/${encodeURIComponent(euuid)}/token`,
+ ),
+ setMethod('POST'),
+ setData(euuid),
+ );
+
+export const getDefaultDelegationAccess = () =>
+ Request(
+ setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`),
+ setMethod('GET'),
+ );
+
+export const updateDefaultDelegationAccess = (data: IamUserRoles) =>
+ Request(
+ setURL(`${BETA_API_ROOT}/iam/delegation/default-role-permissions`),
+ setMethod('PUT'),
+ setData(data),
+ );
diff --git a/packages/api-v4/src/iam/delegation.types.ts b/packages/api-v4/src/iam/delegation.types.ts
new file mode 100644
index 00000000000..2eafc480b7a
--- /dev/null
+++ b/packages/api-v4/src/iam/delegation.types.ts
@@ -0,0 +1,34 @@
+import type { Params } from 'src/types';
+
+export interface ChildAccount {
+ company: string;
+ euuid: string;
+}
+
+export interface GetChildAccountsIamParams {
+ params?: Params;
+ users?: boolean;
+}
+
+export interface ChildAccountWithDelegates extends ChildAccount {
+ users: string[];
+}
+
+export interface GetMyDelegatedChildAccountsParams {
+ params?: Params;
+}
+
+export interface GetDelegatedChildAccountsForUserParams {
+ params?: Params;
+ username: string;
+}
+
+export interface GetChildAccountDelegatesParams {
+ euuid: string;
+ params?: Params;
+}
+
+export interface UpdateChildAccountDelegatesParams {
+ data: string[];
+ euuid: string;
+}
diff --git a/packages/api-v4/src/iam/index.ts b/packages/api-v4/src/iam/index.ts
index 8442040a86a..9bc43a8eb93 100644
--- a/packages/api-v4/src/iam/index.ts
+++ b/packages/api-v4/src/iam/index.ts
@@ -1,3 +1,5 @@
-export * from './iam';
+export * from './delegation';
+export * from './delegation.types';
+export * from './iam';
export * from './types';
diff --git a/packages/queries/.changeset/pr-12895-added-1758540593030.md b/packages/queries/.changeset/pr-12895-added-1758540593030.md
new file mode 100644
index 00000000000..a3664d63328
--- /dev/null
+++ b/packages/queries/.changeset/pr-12895-added-1758540593030.md
@@ -0,0 +1,5 @@
+---
+"@linode/queries": Added
+---
+
+IAM Parent/Child - Implement new delegation query hooks ([#12895](https://github.com/linode/manager/pull/12895))
diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts
new file mode 100644
index 00000000000..58400a5f810
--- /dev/null
+++ b/packages/queries/src/iam/delegation.ts
@@ -0,0 +1,200 @@
+import {
+ generateChildAccountToken,
+ getChildAccountDelegates,
+ getChildAccountsIam,
+ getDefaultDelegationAccess,
+ getDelegatedChildAccount,
+ getDelegatedChildAccountsForUser,
+ getMyDelegatedChildAccounts,
+ updateChildAccountDelegates,
+ updateDefaultDelegationAccess,
+} from '@linode/api-v4';
+import { createQueryKeys } from '@lukemorales/query-key-factory';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import type {
+ APIError,
+ GetChildAccountDelegatesParams,
+ GetChildAccountsIamParams,
+ GetDelegatedChildAccountsForUserParams,
+ IamUserRoles,
+ Params,
+ ResourcePage,
+ Token,
+} from '@linode/api-v4';
+
+export const delegationQueries = createQueryKeys('delegation', {
+ childAccounts: ({ params, users }) => ({
+ queryFn: () => getChildAccountsIam({ params, users }),
+ queryKey: [params],
+ }),
+ delegatedChildAccountsForUser: ({
+ username,
+ params,
+ }: GetDelegatedChildAccountsForUserParams) => ({
+ queryFn: getDelegatedChildAccountsForUser,
+ queryKey: [username, params],
+ }),
+ childAccountDelegates: ({
+ euuid,
+ params,
+ }: GetChildAccountDelegatesParams) => ({
+ queryFn: getChildAccountDelegates,
+ queryKey: [euuid, params],
+ }),
+ myDelegatedChildAccounts: (params: Params) => ({
+ queryFn: getMyDelegatedChildAccounts,
+ queryKey: [params],
+ }),
+ delegatedChildAccount: (euuid: string) => ({
+ queryFn: getDelegatedChildAccount,
+ queryKey: [euuid],
+ }),
+ defaultAccess: {
+ queryFn: getDefaultDelegationAccess,
+ queryKey: null,
+ },
+});
+
+/**
+ * List all child accounts (gets all child accounts from customerParentChild table for the parent account)
+ * - Purpose: Inventory child accounts under the caller’s parent account.
+ * - Scope: All child accounts for the parent; not filtered by any user’s delegation.
+ * - Audience: Parent account administrators managing delegation.
+ * - Data: Page; optionally Page when `users=true` (use `params.includeDelegates` to set).
+ */
+export const useListChildAccountsQuery = (
+ params: GetChildAccountsIamParams,
+) => {
+ return useQuery({
+ ...delegationQueries.childAccounts(params),
+ });
+};
+
+/**
+ * List delegated child accounts for a user
+ * - Purpose: Which child accounts the specified parent user is delegated to manage.
+ * - Scope: Subset filtered by `username`; only where that user has an active delegate and required view permission.
+ * - Audience: Parent account administrators auditing a user’s delegated access.
+ * - Data: Page for `GET /iam/delegation/users/:username/child-accounts`.
+ */
+export const useListDelegatedChildAccountsForUserQuery = ({
+ username,
+ params,
+}: GetDelegatedChildAccountsForUserParams) => {
+ return useQuery({
+ ...delegationQueries.delegatedChildAccountsForUser({ username, params }),
+ });
+};
+
+/**
+ * List delegates for a child account
+ * - Purpose: Which parent users are currently delegated to manage this child account.
+ * - Scope: Delegates tied to `euuid`; only active delegate users and active parent user records included.
+ * - Audience: Parent account administrators managing delegates for a specific child account.
+ * - Data: Page (usernames) for `GET /iam/delegation/child-accounts/:euuid/users`.
+ */
+export const useListChildAccountDelegatesQuery = ({
+ euuid,
+ params,
+}: GetChildAccountDelegatesParams) => {
+ return useQuery({
+ ...delegationQueries.childAccountDelegates({
+ euuid,
+ params,
+ }),
+ });
+};
+
+/**
+ * Update delegates for a child account
+ * - Purpose: Replace the full set of parent users delegated to a child account.
+ * - Scope: Requires parent-account context, valid parent→child relationship, and authorization; payload must be non-empty.
+ * - Audience: Parent account administrators assigning/removing delegates for a child account.
+ * - Data: Request usernames (**full replacement**); Response Page of resulting delegate usernames for `PUT /.../:euuid/users`.
+ */
+export const useUpdateChildAccountDelegatesQuery = () => {
+ const queryClient = useQueryClient();
+ return useMutation<
+ ResourcePage,
+ APIError[],
+ { data: string[]; euuid: string }
+ >({
+ mutationFn: updateChildAccountDelegates,
+ onSuccess(_data, { euuid }) {
+ // Invalidate all child account delegates
+ queryClient.invalidateQueries({
+ queryKey: delegationQueries.childAccountDelegates({ euuid }).queryKey,
+ });
+ },
+ });
+};
+
+/**
+ * List my delegated child accounts (gets child accounts where user has view_child_account permission)
+ * - Purpose: Which child accounts the current caller can manage via delegation.
+ * - Scope: Only child accounts where the caller has an active delegate and required view permission.
+ * - Audience: Needing to return accounts the caller can actually access
+ * - Data: Page (limited profile fields) for `GET /iam/delegation/profile/child-accounts`.
+ */
+export const useListMyDelegatedChildAccountsQuery = (params: Params) => {
+ return useQuery({
+ ...delegationQueries.myDelegatedChildAccounts(params),
+ });
+};
+
+/**
+ * Get child account
+ * - Purpose: Retrieve profile information for a specific child account by EUUID.
+ * - Scope: Single child account identified by `euuid`; subject to required grants.
+ * - Audience: Callers needing basic child account info in the delegation context.
+ * - Data: Account (limited account fields) for `GET /iam/delegation/profile/child-accounts/:euuid`.
+ */
+export const useGetChildAccountQuery = (euuid: string) => {
+ return useQuery({
+ ...delegationQueries.delegatedChildAccount(euuid),
+ });
+};
+
+/**
+ * Create child account token
+ * - Purpose: Create a short‑lived bearer token to act on a child account as a proxy/delegate.
+ * - Scope: For a parent user delegated on the target child account identified by `euuid`.
+ * - Audience: Clients that need temporary auth to perform actions in the child account.
+ * - Data: Token for `POST /iam/delegation/child-accounts/:euuid/token`.
+ */
+export const useGenerateChildAccountTokenQuery = () => {
+ return useMutation({
+ mutationFn: generateChildAccountToken,
+ });
+};
+
+/**
+ * Get default delegation access
+ * - Purpose: View the default access (roles/permissions) applied to new delegates on this child account.
+ * - Scope: Child-account context; restricted to authorized, non-delegate callers.
+ * - Audience: Child account administrators reviewing default delegate access.
+ * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`.
+ */
+export const useGetDefaultDelegationAccessQuery = () => {
+ return useQuery({
+ ...delegationQueries.defaultAccess,
+ });
+};
+
+/**
+ * Update default delegation access
+ * - Purpose: Update the default access (roles/permissions) applied to new delegates on this child account.
+ * - Scope: Child-account context; restricted to authorized, non-delegate callers; validates entity IDs.
+ * - Audience: Child account administrators configuring default delegate access.
+ * - Data: Request/Response IamUserRoles for `PUT /iam/delegation/default-role-permissions`.
+ */
+export const useUpdateDefaultDelegationAccessQuery = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: updateDefaultDelegationAccess,
+ onSuccess(data) {
+ queryClient.setQueryData(delegationQueries.defaultAccess.queryKey, data);
+ },
+ });
+};
From b87b7393fd94a85978f11118f1a071a602e460a8 Mon Sep 17 00:00:00 2001
From: Ankita
Date: Wed, 24 Sep 2025 22:51:27 +0530
Subject: [PATCH 21/54] [DI-26882] - Add new component for endpoints filter in
Object Storage (#12905)
* [DI-26882] - Add new component - cloudpulseendpointsselect
* [DI-26882] - Add tests for endpoints props, update comments
* [DI-26882] - Remove prop not in use
* [DI-26882] - Simplify props, add new func
* [DI-26882] - Update util
* [DI-26882] - Fix failing tests
* [DI-26882] - Use resources query and update new component
* [DI-26882] - Update service type in types.ts
* [DI-26882] - Add changesets
---------
Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com>
---
.../pr-12905-added-1758635135139.md | 5 +
packages/api-v4/src/cloudpulse/types.ts | 4 +-
...r-12905-upcoming-features-1758635267264.md | 5 +
.../CloudPulse/Utils/FilterBuilder.test.ts | 34 ++-
.../CloudPulse/Utils/FilterBuilder.ts | 24 ++
.../shared/CloudPulseEndpointsSelect.test.tsx | 228 ++++++++++++++++
.../shared/CloudPulseEndpointsSelect.tsx | 249 ++++++++++++++++++
.../shared/CloudPulseResourcesSelect.tsx | 1 +
packages/manager/src/mocks/serverHandlers.ts | 1 +
9 files changed, 549 insertions(+), 2 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12905-added-1758635135139.md
create mode 100644 packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx
create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx
diff --git a/packages/api-v4/.changeset/pr-12905-added-1758635135139.md b/packages/api-v4/.changeset/pr-12905-added-1758635135139.md
new file mode 100644
index 00000000000..e96d51ceb49
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12905-added-1758635135139.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Added
+---
+
+CloudPulse-Metrics: Update `CloudPulseServiceType` and constant `capabilityServiceTypeMapping` at `types.ts` ([#12905](https://github.com/linode/manager/pull/12905))
diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts
index 88727de8737..494e4e2ee21 100644
--- a/packages/api-v4/src/cloudpulse/types.ts
+++ b/packages/api-v4/src/cloudpulse/types.ts
@@ -7,7 +7,8 @@ export type CloudPulseServiceType =
| 'dbaas'
| 'firewall'
| 'linode'
- | 'nodebalancer';
+ | 'nodebalancer'
+ | 'objectstorage';
export type AlertClass = 'dedicated' | 'shared';
export type DimensionFilterOperatorType =
@@ -375,6 +376,7 @@ export const capabilityServiceTypeMapping: Record<
dbaas: 'Managed Databases',
nodebalancer: 'NodeBalancers',
firewall: 'Cloud Firewall',
+ objectstorage: 'Object Storage',
};
/**
diff --git a/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md b/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
new file mode 100644
index 00000000000..9fc7c721a9a
--- /dev/null
+++ b/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+CloudPulse-Metrics: Add new component at `CloudPulseEndpointsSelect.tsx` ([#12905](https://github.com/linode/manager/pull/12905))
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
index 217a73b1222..3d9b0bf6633 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
@@ -1,12 +1,17 @@
import { databaseQueries } from '@linode/queries';
import { DateTime } from 'luxon';
-import { dashboardFactory, databaseInstanceFactory } from 'src/factories';
+import {
+ dashboardFactory,
+ databaseInstanceFactory,
+ objectStorageEndpointsFactory,
+} from 'src/factories';
import { RESOURCE_ID, RESOURCES } from './constants';
import {
deepEqual,
filterBasedOnConfig,
+ filterEndpointsUsingRegion,
filterUsingDependentFilters,
getFilters,
getTextFilterProperties,
@@ -25,6 +30,7 @@ import {
import { FILTER_CONFIG } from './FilterConfig';
import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models';
+import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect';
import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect';
import type { CloudPulseServiceTypeFilters } from './models';
@@ -556,6 +562,32 @@ describe('filterUsingDependentFilters', () => {
});
});
+describe('filterEndpointsUsingRegion', () => {
+ const mockData: CloudPulseEndpoints[] = [
+ {
+ ...objectStorageEndpointsFactory.build({ region: 'us-east' }),
+ label: 'us-east-1.linodeobjects.com',
+ },
+ {
+ ...objectStorageEndpointsFactory.build({ region: 'us-west' }),
+ label: 'us-west-1.linodeobjects.com',
+ },
+ ];
+ it('should return data as is if data is undefined', () => {
+ expect(
+ filterEndpointsUsingRegion(undefined, { region: 'us-east' })
+ ).toEqual(undefined);
+ });
+ it('should return undefined if region filter is undefined', () => {
+ expect(filterEndpointsUsingRegion(mockData, undefined)).toEqual(undefined);
+ });
+ it('should return endpoints based on region if region filter is provided', () => {
+ expect(filterEndpointsUsingRegion(mockData, { region: 'us-east' })).toEqual(
+ [mockData[0]]
+ );
+ });
+});
+
describe('filterBasedOnConfig', () => {
const config: CloudPulseServiceTypeFilters = {
configuration: {
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
index 0998166afc6..34b07701b8b 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
@@ -14,6 +14,7 @@ import type {
FilterValueType,
} from '../Dashboard/CloudPulseDashboardLanding';
import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect';
+import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect';
import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter';
import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect';
import type {
@@ -672,4 +673,27 @@ export const filterUsingDependentFilters = (
}
});
});
+}
+
+/**
+ * @param data The endpoints for which the filter needs to be applied
+ * @param regionFilter The selected region filter that will be used to filter the endpoints
+ * @returns The filtered endpoints
+ */
+export const filterEndpointsUsingRegion = (
+ data?: CloudPulseEndpoints[],
+ regionFilter?: CloudPulseMetricsFilter
+): CloudPulseEndpoints[] | undefined => {
+ if (!data) {
+ return data;
+ }
+
+ const regionFromFilter = regionFilter?.region;
+
+ // If no region filter is provided, return undefined as region is mandatory filter
+ if (!regionFromFilter) {
+ return undefined;
+ }
+
+ return data.filter(({ region }) => region === regionFromFilter);
};
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx
new file mode 100644
index 00000000000..e38acc948bd
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.test.tsx
@@ -0,0 +1,228 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { objectStorageBucketFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect';
+
+import type { CloudPulseResources } from './CloudPulseResourcesSelect';
+
+const queryMocks = vi.hoisted(() => ({
+ useResourcesQuery: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/cloudpulse/resources', async () => {
+ const actual = await vi.importActual('src/queries/cloudpulse/resources');
+ return {
+ ...actual,
+ useResourcesQuery: queryMocks.useResourcesQuery,
+ };
+});
+
+const mockEndpointHandler = vi.fn();
+const SELECT_ALL = 'Select All';
+const ARIA_SELECTED = 'aria-selected';
+
+const mockBuckets: CloudPulseResources[] = [
+ {
+ id: 'obj-bucket-1.us-east-1.linodeobjects.com',
+ label: 'obj-bucket-1.us-east-1.linodeobjects.com',
+ region: 'us-east',
+ endpoint: 'us-east-1.linodeobjects.com',
+ },
+ {
+ id: 'obj-bucket-2.us-east-2.linodeobjects.com',
+ label: 'obj-bucket-2.us-east-2.linodeobjects.com',
+ region: 'us-east',
+ endpoint: 'us-east-2.linodeobjects.com',
+ },
+ {
+ id: 'obj-bucket-1.br-gru-1.linodeobjects.com',
+ label: 'obj-bucket-1.br-gru-1.linodeobjects.com',
+ region: 'us-east',
+ endpoint: 'br-gru-1.linodeobjects.com',
+ },
+];
+
+describe('CloudPulseEndpointsSelect component tests', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ objectStorageBucketFactory.resetSequenceNumber();
+ });
+
+ it('renders with the correct label and placeholder', () => {
+ renderWithTheme(
+
+ );
+
+ expect(screen.getByLabelText('Endpoints')).toBeVisible();
+ expect(screen.getByPlaceholderText('Select Endpoints')).toBeVisible();
+ });
+
+ it('should render disabled component if the props are undefined', () => {
+ renderWithTheme(
+
+ );
+
+ expect(screen.getByTestId('textfield-input')).toBeDisabled();
+ });
+
+ it('should render endpoints', async () => {
+ queryMocks.useResourcesQuery.mockReturnValue({
+ data: mockBuckets,
+ isError: false,
+ isLoading: false,
+ status: 'success',
+ });
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Open' }));
+
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[0].endpoint,
+ })
+ ).toBeVisible();
+
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[1].endpoint,
+ })
+ ).toBeVisible();
+ });
+
+ it('should be able to deselect the selected endpoints', async () => {
+ queryMocks.useResourcesQuery.mockReturnValue({
+ data: mockBuckets,
+ isError: false,
+ isLoading: false,
+ status: 'success',
+ });
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Open' }));
+ await userEvent.click(
+ await screen.findByRole('option', { name: SELECT_ALL })
+ );
+ await userEvent.click(
+ await screen.findByRole('option', { name: 'Deselect All' })
+ );
+
+ // Check that both endpoints are deselected
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[0].endpoint,
+ })
+ ).toHaveAttribute(ARIA_SELECTED, 'false');
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[1].endpoint,
+ })
+ ).toHaveAttribute(ARIA_SELECTED, 'false');
+ });
+
+ it('should select multiple endpoints', async () => {
+ queryMocks.useResourcesQuery.mockReturnValue({
+ data: mockBuckets,
+ isError: false,
+ isLoading: false,
+ status: 'success',
+ });
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Open' }));
+ await userEvent.click(
+ await screen.findByRole('option', {
+ name: mockBuckets[0].endpoint,
+ })
+ );
+ await userEvent.click(
+ await screen.findByRole('option', {
+ name: mockBuckets[1].endpoint,
+ })
+ );
+
+ // Check that the correct endpoints are selected/not selected
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[0].endpoint,
+ })
+ ).toHaveAttribute(ARIA_SELECTED, 'true');
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[1].endpoint,
+ })
+ ).toHaveAttribute(ARIA_SELECTED, 'true');
+ expect(
+ await screen.findByRole('option', {
+ name: mockBuckets[2].endpoint,
+ })
+ ).toHaveAttribute(ARIA_SELECTED, 'false');
+ expect(
+ await screen.findByRole('option', { name: SELECT_ALL })
+ ).toHaveAttribute(ARIA_SELECTED, 'false');
+ });
+
+ it('should show appropriate error message on endpoints call failure', async () => {
+ queryMocks.useResourcesQuery.mockReturnValue({
+ data: undefined,
+ isError: true,
+ isLoading: false,
+ status: 'error',
+ });
+
+ renderWithTheme(
+
+ );
+ expect(
+ await waitFor(() => {
+ return screen.findByText('Failed to fetch Endpoints.');
+ })
+ ).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx
new file mode 100644
index 00000000000..42cf343176e
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseEndpointsSelect.tsx
@@ -0,0 +1,249 @@
+import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui';
+import { Box } from '@mui/material';
+import React, { useMemo } from 'react';
+
+import { useResourcesQuery } from 'src/queries/cloudpulse/resources';
+
+import { RESOURCE_FILTER_MAP } from '../Utils/constants';
+import { deepEqual, filterEndpointsUsingRegion } from '../Utils/FilterBuilder';
+
+import type {
+ CloudPulseMetricsFilter,
+ FilterValueType,
+} from '../Dashboard/CloudPulseDashboardLanding';
+import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4';
+
+export interface CloudPulseEndpoints {
+ /**
+ * The label of the endpoint which is 's3_endpoint' in the response from the API
+ */
+ label: string;
+ /**
+ * The region of the endpoint
+ */
+ region: string;
+}
+
+export interface CloudPulseEndpointsSelectProps {
+ /**
+ * The default value of the endpoints filter
+ */
+ defaultValue?: Partial;
+ /**
+ * Whether the endpoints filter is disabled
+ */
+ disabled?: boolean;
+ /**
+ * The function to handle the endpoints selection
+ */
+ handleEndpointsSelection: (endpoints: string[], savePref?: boolean) => void;
+ /**
+ * The label of the endpoints filter
+ */
+ label: string;
+ /**
+ * The placeholder of the endpoints filter
+ */
+ placeholder?: string;
+ /**
+ * The region of the endpoints
+ */
+ region?: FilterValueType;
+ /**
+ * Whether to save the preferences
+ */
+ savePreferences?: boolean;
+ /**
+ * The service type
+ */
+ serviceType: CloudPulseServiceType | undefined;
+ /**
+ * The dependent filters of the endpoints
+ */
+ xFilter?: CloudPulseMetricsFilter;
+}
+
+export const CloudPulseEndpointsSelect = React.memo(
+ (props: CloudPulseEndpointsSelectProps) => {
+ const {
+ defaultValue,
+ disabled,
+ handleEndpointsSelection,
+ label,
+ placeholder,
+ region,
+ serviceType,
+ savePreferences,
+ xFilter,
+ } = props;
+
+ const {
+ data: buckets,
+ isError,
+ isLoading,
+ } = useResourcesQuery(
+ disabled !== undefined ? !disabled : Boolean(region && serviceType),
+ serviceType,
+ {},
+
+ RESOURCE_FILTER_MAP[serviceType ?? ''] ?? {}
+ );
+
+ const validSortedEndpoints = useMemo(() => {
+ if (!buckets) return [];
+
+ const visitedEndpoints = new Set();
+ const uniqueEndpoints: CloudPulseEndpoints[] = [];
+
+ buckets.forEach(({ endpoint, region }) => {
+ if (endpoint && region && !visitedEndpoints.has(endpoint)) {
+ visitedEndpoints.add(endpoint);
+ uniqueEndpoints.push({ label: endpoint, region });
+ }
+ });
+
+ uniqueEndpoints.sort((a, b) => a.label.localeCompare(b.label));
+ return uniqueEndpoints;
+ }, [buckets]);
+
+ const [selectedEndpoints, setSelectedEndpoints] =
+ React.useState();
+
+ /**
+ * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below
+ * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time
+ * When the autocomplete is open, we should publish any resources on clear action until the autocomplete is close
+ */
+ const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete
+
+ const getEndpointsList = React.useMemo(() => {
+ return filterEndpointsUsingRegion(validSortedEndpoints, xFilter) ?? [];
+ }, [validSortedEndpoints, xFilter]);
+
+ // Once the data is loaded, set the state variable with value stored in preferences
+ React.useEffect(() => {
+ if (disabled && !selectedEndpoints) {
+ return;
+ }
+ // To save default values, go through side effects if disabled is false
+ if (!buckets || !savePreferences || selectedEndpoints) {
+ if (selectedEndpoints) {
+ setSelectedEndpoints([]);
+ handleEndpointsSelection([]);
+ }
+ } else {
+ const defaultEndpoints =
+ defaultValue && Array.isArray(defaultValue)
+ ? defaultValue.map((endpoint) => String(endpoint))
+ : [];
+ const endpoints = getEndpointsList.filter((endpointObj) =>
+ defaultEndpoints.includes(endpointObj.label)
+ );
+
+ handleEndpointsSelection(endpoints.map((endpoint) => endpoint.label));
+ setSelectedEndpoints(endpoints);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [buckets, region, xFilter, serviceType]);
+
+ return (
+ option.label === value.label}
+ label={label || 'Endpoints'}
+ limitTags={1}
+ loading={isLoading}
+ multiple
+ noMarginTop
+ onChange={(_e, endpointSelections) => {
+ setSelectedEndpoints(endpointSelections);
+
+ if (!isAutocompleteOpen.current) {
+ handleEndpointsSelection(
+ endpointSelections.map((endpoint) => endpoint.label),
+ savePreferences
+ );
+ }
+ }}
+ onClose={() => {
+ isAutocompleteOpen.current = false;
+ handleEndpointsSelection(
+ selectedEndpoints?.map((endpoint) => endpoint.label) ?? [],
+ savePreferences
+ );
+ }}
+ onOpen={() => {
+ isAutocompleteOpen.current = true;
+ }}
+ options={getEndpointsList}
+ placeholder={
+ selectedEndpoints?.length ? '' : placeholder || 'Select Endpoints'
+ }
+ renderOption={(props, option) => {
+ const { key, ...rest } = props;
+ const isEndpointSelected = selectedEndpoints?.some(
+ (item) => item.label === option.label
+ );
+
+ const isSelectAllORDeslectAllOption =
+ option.label === 'Select All ' || option.label === 'Deselect All ';
+
+ const ListItem = isSelectAllORDeslectAllOption
+ ? StyledListItem
+ : 'li';
+
+ return (
+
+ <>
+ {option.label}
+
+ >
+
+ );
+ }}
+ textFieldProps={{
+ InputProps: {
+ sx: {
+ '::-webkit-scrollbar': {
+ display: 'none',
+ },
+ maxHeight: '55px',
+ msOverflowStyle: 'none',
+ overflow: 'auto',
+ scrollbarWidth: 'none',
+ },
+ },
+ }}
+ value={selectedEndpoints ?? []}
+ />
+ );
+ },
+ compareProps
+);
+
+function compareProps(
+ prevProps: CloudPulseEndpointsSelectProps,
+ nextProps: CloudPulseEndpointsSelectProps
+): boolean {
+ // these properties can be extended going forward
+ const keysToCompare: (keyof CloudPulseEndpointsSelectProps)[] = [
+ 'region',
+ 'serviceType',
+ ];
+
+ for (const key of keysToCompare) {
+ if (prevProps[key] !== nextProps[key]) {
+ return false;
+ }
+ }
+ if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) {
+ return false;
+ }
+
+ // Ignore function props in comparison
+ return true;
+}
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx
index c95f20d2236..cbf097c063b 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx
@@ -13,6 +13,7 @@ import type { CloudPulseServiceType, FilterValue } from '@linode/api-v4';
export interface CloudPulseResources {
clusterSize?: number;
+ endpoint?: string;
engineType?: string;
entities?: Record;
id: string;
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index 0f3a5be3bd8..b051a64238c 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -3060,6 +3060,7 @@ export const handlers = [
dbaas: 'Databases',
nodebalancer: 'NodeBalancers',
firewall: 'Firewalls',
+ objectstorage: 'Object Storage',
};
const response = serviceTypesFactory.build({
service_type: `${serviceType}`,
From 92a19efbd5bae8b6eca7cb52884368d71082dd1d Mon Sep 17 00:00:00 2001
From: venkatmano-akamai
Date: Thu, 25 Sep 2025 10:31:13 +0530
Subject: [PATCH 22/54] fix: [DI-27257] - Disable metric and dimension filter
on no serviceType selected, Also disable resources hook on no supported
regions in Create Alerts flow (#12891)
* DI-27257: Fix for bugs, disable metric and dimension filter button if service type is not selected and disable useResources query in alerts section if no supported regions
* DI-27257: Add changeset
---
.../pr-12891-fixed-1758180544654.md | 5 +++++
.../core/cloudpulse/edit-system-alert.spec.ts | 2 ++
.../AlertsResources/AlertsResources.tsx | 20 +++++++++----------
.../CreateAlertDefinition.test.tsx | 6 +-----
.../CreateAlert/Criteria/DimensionFilter.tsx | 6 +++++-
.../CreateAlert/Criteria/MetricCriteria.tsx | 4 +++-
.../src/queries/cloudpulse/resources.ts | 3 +++
7 files changed, 29 insertions(+), 17 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12891-fixed-1758180544654.md
diff --git a/packages/manager/.changeset/pr-12891-fixed-1758180544654.md b/packages/manager/.changeset/pr-12891-fixed-1758180544654.md
new file mode 100644
index 00000000000..7e257eb3deb
--- /dev/null
+++ b/packages/manager/.changeset/pr-12891-fixed-1758180544654.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Disable `Add Metric and Add Dimension Filter` without serviceType; skip `useResources` if no supported regions in CloudPulse Alerting ([#12891](https://github.com/linode/manager/pull/12891))
diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
index b7b8f7a7c7b..335b2ae55eb 100644
--- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
+++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts
@@ -42,11 +42,13 @@ const regions = [
capabilities: ['Managed Databases'],
id: 'us-ord',
label: 'Chicago, IL',
+ monitors: { alerts: ['Managed Databases'] },
}),
regionFactory.build({
capabilities: ['Managed Databases'],
id: 'us-east',
label: 'Newark',
+ monitors: { alerts: ['Managed Databases'] },
}),
];
const databases: Database[] = databaseFactory
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
index 5490bef4be5..5de8c1725f8 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
@@ -131,17 +131,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
const supportedRegionIds = getSupportedRegionIds(regions, serviceType);
const xFilterToBeApplied: Filter | undefined = React.useMemo(() => {
- if (serviceType === 'firewall') {
+ if (serviceType === 'firewall' || !supportedRegionIds?.length) {
return undefined;
}
- const regionFilter: Filter = supportedRegionIds
- ? {
- '+or': supportedRegionIds.map((regionId) => ({
- region: regionId,
- })),
- }
- : {};
+ const regionFilter: Filter = {
+ '+or': supportedRegionIds?.map((regionId) => ({
+ region: regionId,
+ })),
+ };
// if service type is other than dbaas, return only region filter
if (serviceType !== 'dbaas') {
@@ -153,7 +151,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
// If alertType is not 'system' or alertClass is not defined, return only platform filter
if (alertType !== 'system' || !alertClass) {
- return platformFilter;
+ return { ...platformFilter, '+and': [regionFilter] };
}
// Dynamically exclude 'dedicated' if alertClass is 'shared'
@@ -182,7 +180,9 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
isError: isResourcesError,
isLoading: isResourcesLoading,
} = useResourcesQuery(
- Boolean(serviceType),
+ Boolean(
+ serviceType && (serviceType === 'firewall' || supportedRegionIds?.length)
+ ), // Enable query only if serviceType and supportedRegionIds are available, in case of firewall only serviceType is needed
serviceType,
{},
xFilterToBeApplied
diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx
index da1b0d8249b..71dc62de48f 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx
@@ -184,12 +184,8 @@ describe('AlertDefinition Create', () => {
const submitButton = container.getByText('Submit');
- await user.click(
- container.getByRole('button', { name: 'Add dimension filter' })
- );
-
await user.click(submitButton!);
- expect(container.getAllByText(errorMessage).length).toBe(12);
+ expect(container.getAllByText(errorMessage).length).toBe(9);
container.getAllByText(errorMessage).forEach((element) => {
expect(element).toBeVisible();
});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx
index 469de3cea93..64668cc2900 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx
@@ -32,6 +32,7 @@ export const DimensionFilters = (props: DimensionFilterProps) => {
});
const dimensionFilterWatcher = useWatch({ control, name });
+ const serviceTypeWatcher = useWatch({ control, name: 'serviceType' });
return (
@@ -54,7 +55,10 @@ export const DimensionFilters = (props: DimensionFilterProps) => {
{
({
- backgroundColor: theme.palette.background.paper,
+ backgroundColor: !permissions.replicate_image
+ ? theme.tokens.alias.Interaction.Background.Disabled
+ : theme.palette.background.paper,
p: 2,
py: 1,
})}
@@ -173,6 +192,7 @@ export const ManageImageReplicasForm = (props: Props) => {
return (
@@ -191,7 +211,7 @@ export const ManageImageReplicasForm = (props: Props) => {
Date: Mon, 29 Sep 2025 12:31:01 +0200
Subject: [PATCH 32/54] feat: [UIE-9204] - IAM RBAC: replace grants in
Firewalls (#12902)
* feat: [UIE-9204] - IAM RBAC: replace grants for nodebalancer
* replace grants for linodes
* fix menu button
* fix perm check for menu
* Added changeset: IAM RBAC: replace grants with usePermission hook for Firewalls
* clean up
* minor changes
* add loading state to usePermissions hook
* fix loading state
---
.../pr-12902-changed-1758618381547.md | 5 ++
.../Devices/AddLinodeDrawer.test.tsx | 55 ++++++-----------
.../Devices/AddLinodeDrawer.tsx | 59 ++++++++-----------
.../Devices/AddNodebalancerDrawer.test.tsx | 15 +++++
.../Devices/AddNodebalancerDrawer.tsx | 16 +----
.../Devices/FirewallDeviceActionMenu.tsx | 40 ++++++++++++-
.../Devices/FirewallDeviceLanding.test.tsx | 6 ++
.../Devices/FirewallDeviceLanding.tsx | 18 ++++--
.../Devices/FirewallDeviceRow.test.tsx | 12 ++++
.../FirewallLanding/CreateFirewallDrawer.tsx | 10 +++-
.../FirewallLanding/CustomFirewallFields.tsx | 45 ++++++--------
.../manager/src/features/Firewalls/shared.ts | 13 ----
.../src/features/IAM/hooks/usePermissions.ts | 31 ++++++----
.../NodeBalancers/NodeBalancerSelect.tsx | 30 ++++++++--
packages/utilities/src/helpers/grants.test.ts | 29 ---------
packages/utilities/src/helpers/grants.ts | 26 --------
packages/utilities/src/helpers/index.ts | 1 -
17 files changed, 206 insertions(+), 205 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12902-changed-1758618381547.md
delete mode 100644 packages/utilities/src/helpers/grants.test.ts
delete mode 100644 packages/utilities/src/helpers/grants.ts
diff --git a/packages/manager/.changeset/pr-12902-changed-1758618381547.md b/packages/manager/.changeset/pr-12902-changed-1758618381547.md
new file mode 100644
index 00000000000..8e7b62d4c1b
--- /dev/null
+++ b/packages/manager/.changeset/pr-12902-changed-1758618381547.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+IAM RBAC: replace grants with usePermission hook for Firewalls ([#12902](https://github.com/linode/manager/pull/12902))
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
index 65c58a3cd2f..c6143a282b6 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx
@@ -1,5 +1,4 @@
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
+import { waitFor } from '@testing-library/react';
import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -13,19 +12,20 @@ const props = {
helperText,
onClose,
open: true,
+ disabled: true,
};
const queryMocks = vi.hoisted(() => ({
useParams: vi.fn().mockReturnValue({}),
- userPermissions: vi.fn(() => ({
- data: {
- create_firewall_device: false,
- },
- })),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
}));
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
- usePermissions: queryMocks.userPermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
}));
vi.mock('@tanstack/react-router', async () => {
@@ -62,20 +62,11 @@ describe('AddLinodeDrawer', () => {
});
it('should disable "Add" button if the user does not have create_firewall_device permission', async () => {
- queryMocks.userPermissions.mockReturnValue({
- data: {
- create_firewall_device: false,
- },
- });
-
const { getByRole } = renderWithTheme();
- const autocomplete = screen.getByRole('combobox');
- await userEvent.click(autocomplete);
- await userEvent.type(autocomplete, 'linode-5');
-
- const option = await screen.findByText('linode-5');
- await userEvent.click(option);
+ const select = getByRole('combobox');
+ expect(select).toBeInTheDocument();
+ expect(select).toBeDisabled();
const addButton = getByRole('button', {
name: 'Add',
@@ -85,25 +76,15 @@ describe('AddLinodeDrawer', () => {
});
it('should enable "Add" button if the user has create_firewall_device permission', async () => {
- queryMocks.userPermissions.mockReturnValue({
- data: {
- create_firewall_device: true,
- },
- });
-
- const { getByRole } = renderWithTheme();
-
- const autocomplete = screen.getByRole('combobox');
- await userEvent.click(autocomplete);
- await userEvent.type(autocomplete, 'linode-5');
+ const { getByRole } = renderWithTheme(
+
+ );
- const option = await screen.findByText('linode-5');
- await userEvent.click(option);
+ const select = getByRole('combobox');
+ expect(select).toBeInTheDocument();
- const addButton = getByRole('button', {
- name: 'Add',
+ await waitFor(() => {
+ expect(select).toBeEnabled();
});
- expect(addButton).toBeInTheDocument();
- expect(addButton).toBeEnabled();
});
});
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
index ff32e4c52c8..9f3be27bf35 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx
@@ -3,8 +3,6 @@ import {
useAddFirewallDeviceMutation,
useAllFirewallsQuery,
useAllLinodesQuery,
- useGrants,
- useProfile,
} from '@linode/queries';
import { LinodeSelect } from '@linode/shared';
import {
@@ -14,7 +12,6 @@ import {
Notice,
Typography,
} from '@linode/ui';
-import { getEntityIdsByPermission } from '@linode/utilities';
import { useTheme } from '@mui/material';
import { useQueries } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
@@ -23,7 +20,8 @@ import * as React from 'react';
import { Link } from 'src/components/Link';
import { SupportLink } from 'src/components/SupportLink';
-import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
+import { getRestrictedResourceText } from 'src/features/Account/utils';
+import { useQueryWithPermissions } from 'src/features/IAM/hooks/usePermissions';
import { getLinodeInterfaceType } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
@@ -32,6 +30,7 @@ import { sanitizeHTML } from 'src/utilities/sanitizeHTML';
import type { Linode, LinodeInterfaces } from '@linode/api-v4';
interface Props {
+ disabled: boolean;
helperText: string;
onClose: () => void;
open: boolean;
@@ -44,31 +43,28 @@ interface InterfaceDeviceInfo {
}
export const AddLinodeDrawer = (props: Props) => {
- const { helperText, onClose, open } = props;
+ const { helperText, onClose, open, disabled } = props;
const { id } = useParams({ strict: false });
const { enqueueSnackbar } = useSnackbar();
- const { data: grants } = useGrants();
- const { data: profile } = useProfile();
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();
- const isRestrictedUser = Boolean(profile?.restricted);
const { data, error, isLoading } = useAllFirewallsQuery();
const firewall = data?.find((firewall) => firewall.id === Number(id));
- const { data: permissions } = usePermissions(
- 'firewall',
- ['create_firewall_device'],
- firewall?.id
- );
-
- const { data: allLinodes } = useAllLinodesQuery({}, {});
+ const { data: availableLinodes, isLoading: availableLinodesLoading } =
+ useQueryWithPermissions(
+ useAllLinodesQuery({}, {}),
+ 'linode',
+ ['update_linode'],
+ open
+ );
const linodesUsingLinodeInterfaces =
- allLinodes?.filter((l) => l.interface_generation === 'linode') ?? [];
+ availableLinodes?.filter((l) => l.interface_generation === 'linode') ?? [];
const allFirewallEntities = React.useMemo(
() => data?.map((firewall) => firewall.entities).flat() ?? [],
@@ -90,15 +86,6 @@ export const AddLinodeDrawer = (props: Props) => {
[allFirewallEntities]
);
- // If a user is restricted, they can not add a read-only Linode to a firewall.
- const readOnlyLinodeIds = React.useMemo(
- () =>
- isRestrictedUser
- ? getEntityIdsByPermission(grants, 'linode', 'read_only')
- : [],
- [grants, isRestrictedUser]
- );
-
// Keeps track of Linode and its eligible Linode Interfaces if they exist (eligible = a non-vlan interface that isn't already assigned to a firewall)
// Key is Linode ID. Value is an object containing the Linode object and the Linode's interfaces
const linodesAndEligibleInterfaces = useQueries({
@@ -130,12 +117,7 @@ export const AddLinodeDrawer = (props: Props) => {
},
});
- const linodeOptions = allLinodes?.filter((linode) => {
- // Exclude read only Linodes
- if (readOnlyLinodeIds.includes(linode.id)) {
- return false;
- }
-
+ const linodeOptions = availableLinodes?.filter((linode) => {
// Exclude a Linode if it uses Linode Interfaces but has no eligible interfaces
if (linode.interface_generation === 'linode') {
return Boolean(linodesAndEligibleInterfaces[linode.id]);
@@ -366,10 +348,19 @@ export const AddLinodeDrawer = (props: Props) => {
handleSubmit();
}}
>
+ {disabled && (
+
+ )}
{localError ? errorNotice() : null}
onSelectionChange(linodes)}
options={linodeOptions}
@@ -416,9 +407,7 @@ export const AddLinodeDrawer = (props: Props) => {
})}
({
useParams: vi.fn().mockReturnValue({}),
+ userPermissions: vi.fn(() => ({
+ data: {
+ create_firewall_device: true,
+ },
+ })),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
+}));
+
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
}));
vi.mock('@tanstack/react-router', async () => {
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
index 879c1994803..bc22e48eba8 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx
@@ -1,11 +1,8 @@
import {
useAddFirewallDeviceMutation,
useAllFirewallsQuery,
- useGrants,
- useProfile,
} from '@linode/queries';
import { ActionsPanel, Drawer, Notice } from '@linode/ui';
-import { getEntityIdsByPermission } from '@linode/utilities';
import { useTheme } from '@mui/material';
import { useParams } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
@@ -32,9 +29,6 @@ export const AddNodebalancerDrawer = (props: Props) => {
const { helperText, onClose, open, disabled } = props;
const { enqueueSnackbar } = useSnackbar();
const { id } = useParams({ strict: false });
- const { data: grants } = useGrants();
- const { data: profile } = useProfile();
- const isRestrictedUser = Boolean(profile?.restricted);
const { data, error, isLoading } = useAllFirewallsQuery(open);
@@ -149,20 +143,14 @@ export const AddNodebalancerDrawer = (props: Props) => {
}
};
- // If a user is restricted, they can not add a read-only Nodebalancer to a firewall.
- const readOnlyNodebalancerIds = isRestrictedUser
- ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only')
- : [];
-
const assignedNodeBalancers = data
?.map((firewall) => firewall.entities)
.flat()
?.filter((service) => service.type === 'nodebalancer');
const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => {
- return (
- !readOnlyNodebalancerIds.includes(nodebalancer.id) &&
- !assignedNodeBalancers?.some((service) => service.id === nodebalancer.id)
+ return !assignedNodeBalancers?.some(
+ (service) => service.id === nodebalancer.id
);
};
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
index bd80e4a2859..5cccbf61343 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx
@@ -6,6 +6,8 @@ export interface ActionHandlers {
handleRemoveDevice: (device: FirewallDevice) => void;
}
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
+
import type { FirewallDevice } from '@linode/api-v4';
export interface FirewallDeviceActionMenuProps extends ActionHandlers {
@@ -17,12 +19,48 @@ export const FirewallDeviceActionMenu = React.memo(
(props: FirewallDeviceActionMenuProps) => {
const { device, disabled, handleRemoveDevice } = props;
+ const { type } = device.entity;
+
+ const { data: linodePermissions, isLoading: isLinodePermissionsLoading } =
+ usePermissions(
+ 'linode',
+ ['update_linode'],
+ device?.entity.id,
+ type !== 'nodebalancer'
+ );
+
+ const {
+ data: nodebalancerPermissions,
+ isLoading: isNodebalancerPermissionsLoading,
+ } = usePermissions(
+ 'nodebalancer',
+ ['update_nodebalancer'],
+ device?.entity.id,
+ type === 'nodebalancer'
+ );
+
+ const disabledDueToPermissions =
+ type === 'nodebalancer'
+ ? !nodebalancerPermissions?.update_nodebalancer
+ : !linodePermissions?.update_linode;
+
+ const isPermissionsLoading =
+ type === 'nodebalancer'
+ ? isNodebalancerPermissionsLoading
+ : isLinodePermissionsLoading;
+
return (
handleRemoveDevice(device)}
+ tooltip={
+ disabledDueToPermissions
+ ? `You do not have permission to modify this ${type === 'nodebalancer' ? 'NodeBalancer' : 'Linode'}.`
+ : undefined
+ }
/>
);
}
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
index 7ef14493bdd..0e8467c28da 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx
@@ -23,6 +23,11 @@ const queryMocks = vi.hoisted(() => ({
create_firewall_device: false,
},
})),
+ useQueryWithPermissions: vi.fn().mockReturnValue({
+ data: [],
+ isLoading: false,
+ isError: false,
+ }),
}));
vi.mock('@tanstack/react-router', async () => {
@@ -46,6 +51,7 @@ vi.mock('src/hooks/useOrderV2', async () => {
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: queryMocks.usePermissions,
+ useQueryWithPermissions: queryMocks.useQueryWithPermissions,
}));
const baseProps = (
diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
index 84b13774090..f42ca63ee13 100644
--- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx
@@ -1,4 +1,4 @@
-import { Button, Notice, Typography } from '@linode/ui';
+import { Button, CircleProgress, Notice, Typography } from '@linode/ui';
import Grid from '@mui/material/Grid';
import { styled, useTheme } from '@mui/material/styles';
import { useLocation, useNavigate } from '@tanstack/react-router';
@@ -27,11 +27,12 @@ export const FirewallDeviceLanding = React.memo(
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
- const { data: permissions } = usePermissions(
- 'firewall',
- ['create_firewall_device', 'delete_firewall_device'],
- firewallId
- );
+ const { data: permissions, isLoading: isPermissionsLoading } =
+ usePermissions(
+ 'firewall',
+ ['create_firewall_device', 'delete_firewall_device'],
+ firewallId
+ );
const helperText =
'Assign one or more services to this firewall. You can add services later if you want to customize your rules first.';
@@ -82,6 +83,10 @@ export const FirewallDeviceLanding = React.memo(
}
}, [device, location.pathname, firewallId, type, navigate]);
+ if (isPermissionsLoading) {
+ return ;
+ }
+
return (
// TODO: Matching old behavior. Do we want separate messages for when the user can't create or remove devices?
<>
@@ -154,6 +159,7 @@ export const FirewallDeviceLanding = React.memo(
/>
{type === 'linode' ? (
({
+ userPermissions: vi.fn(() => ({
+ data: {
+ update_linode: true,
+ },
+ })),
+}));
+
+vi.mock('src/features/IAM/hooks/usePermissions', () => ({
+ usePermissions: queryMocks.userPermissions,
+}));
+
const props = {
device: firewallDeviceFactory.build(),
disabled: false,
diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx
index ecced57860c..ca8937cd963 100644
--- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx
+++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx
@@ -1,6 +1,7 @@
import { useCreateFirewall } from '@linode/queries';
import {
ActionsPanel,
+ CircleProgress,
Drawer,
FormControlLabel,
Notice,
@@ -62,7 +63,10 @@ export const CreateFirewallDrawer = (props: CreateFirewallDrawerProps) => {
const { mutateAsync: createFirewall } = useCreateFirewall();
- const { data: permissions } = usePermissions('account', ['create_firewall']);
+ const { data: permissions, isLoading: isPermissionsLoading } = usePermissions(
+ 'account',
+ ['create_firewall']
+ );
const { enqueueSnackbar } = useSnackbar();
@@ -134,6 +138,10 @@ export const CreateFirewallDrawer = (props: CreateFirewallDrawerProps) => {
}
};
+ if (isPermissionsLoading) {
+ return ;
+ }
+
return (
{
const { control } = useFormContext();
- const { data: grants } = useGrants();
const { data: firewalls } = useAllFirewallsQuery(open);
- const { data: profile } = useProfile();
-
- const { data: permissableLinodes, hasFiltered: hasFilteredLinodes } =
- useQueryWithPermissions(useAllLinodesQuery(), 'linode', [
- 'apply_linode_firewalls',
- ]);
-
- const isRestrictedUser = profile?.restricted;
- // If a user is restricted, they can not add a read-only NodeBalancer to a firewall.
- const readOnlyNodebalancerIds = isRestrictedUser
- ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only')
- : [];
+ const {
+ data: permissableLinodes,
+ hasFiltered: hasFilteredLinodes,
+ isLoading: isLoadingLinodes,
+ } = useQueryWithPermissions(useAllLinodesQuery(), 'linode', [
+ 'apply_linode_firewalls',
+ ]);
- const deviceSelectGuidance =
- hasFilteredLinodes || readOnlyNodebalancerIds.length > 0
- ? READ_ONLY_DEVICES_HIDDEN_MESSAGE
- : undefined;
+ const deviceSelectGuidance = hasFilteredLinodes
+ ? READ_ONLY_DEVICES_HIDDEN_MESSAGE
+ : undefined;
const assignedServices = firewalls
?.map((firewall) => firewall.entities)
@@ -101,9 +89,8 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => {
};
const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => {
- return (
- !readOnlyNodebalancerIds.includes(nodebalancer.id) &&
- !assignedNodeBalancers?.some((service) => service.id === nodebalancer.id)
+ return !assignedNodeBalancers?.some(
+ (service) => service.id === nodebalancer.id
);
};
@@ -123,6 +110,10 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => {
);
+ if (isLoadingLinodes) {
+ return ;
+ }
+
return (
<>
diff --git a/packages/manager/src/features/Firewalls/shared.ts b/packages/manager/src/features/Firewalls/shared.ts
index beeaa834c50..754451074bd 100644
--- a/packages/manager/src/features/Firewalls/shared.ts
+++ b/packages/manager/src/features/Firewalls/shared.ts
@@ -2,7 +2,6 @@ import { truncateAndJoinList } from '@linode/utilities';
import { capitalize } from '@linode/utilities';
import type { PORT_PRESETS } from './FirewallDetail/Rules/shared';
-import type { Grants, Profile } from '@linode/api-v4';
import type {
Firewall,
FirewallRuleProtocol,
@@ -249,18 +248,6 @@ export const generateAddressesLabel = (
return 'None';
};
-export const checkIfUserCanModifyFirewall = (
- firewallId: number,
- profile?: Profile,
- grants?: Grants
-) => {
- return (
- !profile?.restricted ||
- grants?.firewall?.find((firewall) => firewall.id === firewallId)
- ?.permissions === 'read_write'
- );
-};
-
export const getFirewallDescription = (firewall: Firewall) => {
const description = [
`Status: ${capitalize(firewall.status)}`,
diff --git a/packages/manager/src/features/IAM/hooks/usePermissions.ts b/packages/manager/src/features/IAM/hooks/usePermissions.ts
index 486263c3adc..b266e03ff90 100644
--- a/packages/manager/src/features/IAM/hooks/usePermissions.ts
+++ b/packages/manager/src/features/IAM/hooks/usePermissions.ts
@@ -189,21 +189,27 @@ export function usePermissions<
const useLAPermissions = isIAMEnabled && !isIAMBeta;
const shouldUsePermissionMap = useBetaPermissions || useLAPermissions;
- const { data: grants } = useGrants(
+ const { data: grants, isLoading: isGrantsLoading } = useGrants(
(!isIAMEnabled || !shouldUsePermissionMap) && enabled
);
- const { data: userAccountPermissions, ...restAccountPermissions } =
- useUserAccountPermissions(
- shouldUsePermissionMap && accessType === 'account' && enabled
- );
+ const {
+ data: userAccountPermissions,
+ isLoading: isUserAccountPermissionsLoading,
+ ...restAccountPermissions
+ } = useUserAccountPermissions(
+ shouldUsePermissionMap && accessType === 'account' && enabled
+ );
- const { data: userEntityPermissions, ...restEntityPermissions } =
- useUserEntityPermissions(
- accessType,
- _entityId!,
- shouldUsePermissionMap && enabled
- );
+ const {
+ data: userEntityPermissions,
+ isLoading: isUserEntityPermissionsLoading,
+ ...restEntityPermissions
+ } = useUserEntityPermissions(
+ accessType,
+ _entityId!,
+ shouldUsePermissionMap && enabled
+ );
const usersPermissions =
accessType === 'account' ? userAccountPermissions : userEntityPermissions;
@@ -224,6 +230,9 @@ export function usePermissions<
return {
data: permissionMap,
+ isLoading: shouldUsePermissionMap
+ ? isUserAccountPermissionsLoading || isUserEntityPermissionsLoading
+ : isGrantsLoading,
...restAccountPermissions,
...restEntityPermissions,
} as const;
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx
index 2d1b624b9c3..a91724ebde0 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx
@@ -5,6 +5,8 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import * as React from 'react';
import type { JSX } from 'react';
+import { useQueryWithPermissions } from '../IAM/hooks/usePermissions';
+
import type { APIError, NodeBalancer } from '@linode/api-v4';
import type { SxProps, Theme } from '@mui/material/styles';
@@ -95,7 +97,20 @@ export const NodeBalancerSelect = (
const { data, error, isLoading } = useAllNodeBalancersQuery();
- const nodebalancers = optionsFilter ? data?.filter(optionsFilter) : data;
+ const {
+ data: availableNodebalancers,
+ error: availableNodebalancersError,
+ isLoading: availableNodebalancersLoading,
+ } = useQueryWithPermissions(
+ useAllNodeBalancersQuery(),
+ 'nodebalancer',
+ ['update_nodebalancer'],
+ Boolean(optionsFilter)
+ );
+
+ const nodebalancers = optionsFilter
+ ? availableNodebalancers.filter(optionsFilter)
+ : data;
React.useEffect(() => {
/** We want to clear the input value when the value prop changes to null.
@@ -116,7 +131,10 @@ export const NodeBalancerSelect = (
disableCloseOnSelect={multiple}
disabled={disabled}
disablePortal={true}
- errorText={error?.[0].reason ?? errorText}
+ errorText={
+ (error?.[0].reason || availableNodebalancersError?.[0].reason) ??
+ errorText
+ }
getOptionLabel={(nodebalancer: NodeBalancer) =>
renderOptionLabel ? renderOptionLabel(nodebalancer) : nodebalancer.label
}
@@ -124,11 +142,15 @@ export const NodeBalancerSelect = (
id={id}
inputValue={inputValue}
label={label ? label : multiple ? 'NodeBalancers' : 'NodeBalancer'}
- loading={isLoading || loading}
+ loading={isLoading || availableNodebalancersLoading || loading}
multiple={multiple}
noMarginTop={noMarginTop}
noOptionsText={
- noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading)
+ noOptionsMessage ??
+ getDefaultNoOptionsMessage(
+ error || availableNodebalancersError,
+ isLoading || availableNodebalancersLoading
+ )
}
onBlur={onBlur}
onChange={(_, value) =>
diff --git a/packages/utilities/src/helpers/grants.test.ts b/packages/utilities/src/helpers/grants.test.ts
deleted file mode 100644
index ac5ad7f5fbf..00000000000
--- a/packages/utilities/src/helpers/grants.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { grantsFactory } from '../factories';
-import { getEntityIdsByPermission } from './grants';
-
-const grants = grantsFactory.build({
- linode: [
- { id: 0, permissions: 'read_only' },
- { id: 1, permissions: 'read_write' },
- { id: 2, permissions: 'read_only' },
- { id: 3, permissions: null },
- ],
-});
-
-describe('getEntityIdsByPermission', () => {
- it('should return an empty array when there is no grant data', () => {
- expect(getEntityIdsByPermission(undefined, 'linode', 'read_write')).toEqual(
- [],
- );
- });
- it('should return read-only entity ids with read_only permission', () => {
- expect(getEntityIdsByPermission(grants, 'linode', 'read_only')).toEqual([
- 0, 2,
- ]);
- });
- it('should return all entity ids if a permission level is omitted', () => {
- expect(getEntityIdsByPermission(grants, 'linode')).toEqual([0, 1, 2, 3]);
- });
-});
diff --git a/packages/utilities/src/helpers/grants.ts b/packages/utilities/src/helpers/grants.ts
deleted file mode 100644
index 7ff57f237f6..00000000000
--- a/packages/utilities/src/helpers/grants.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { GrantLevel, Grants, GrantType } from '@linode/api-v4';
-
-/**
- * Gets entity ids for a specified permission level given a user's grants
- * @param grants user grants (probably from React Query)
- * @param entity the entity type you want grants for
- * @param permission the level of permission you want ids for. Omit this for all entity ids.
- * @returns a list of entity ids that match given paramaters
- */
-export const getEntityIdsByPermission = (
- grants: Grants | undefined,
- entity: GrantType,
- permission?: GrantLevel,
-) => {
- if (!grants) {
- return [];
- }
-
- if (permission === undefined) {
- return grants[entity].map((grant) => grant.id);
- }
-
- return grants[entity]
- .filter((grant) => grant.permissions === permission)
- .map((grant) => grant.id);
-};
diff --git a/packages/utilities/src/helpers/index.ts b/packages/utilities/src/helpers/index.ts
index 6ccb85f515f..4e9ff3495d1 100644
--- a/packages/utilities/src/helpers/index.ts
+++ b/packages/utilities/src/helpers/index.ts
@@ -24,7 +24,6 @@ export * from './getDisplayName';
export * from './getIsLegacyInterfaceArray';
export * from './getNewRegionLabel';
export * from './getUserTimezone';
-export * from './grants';
export * from './groupByTags';
export * from './initWindows';
export * from './isNilOrEmpty';
From ca716f004efe10acc64b0ea6b320a43e8629be16 Mon Sep 17 00:00:00 2001
From: fabrice-akamai
Date: Mon, 29 Sep 2025 15:13:45 -0400
Subject: [PATCH 33/54] change: [M3-10470] - Lack of padding around Managed
paper (#12923)
* Add missing padding around the Managed dashboard card
* change spacing to spacingFunction
* Add changeset: Add padding inside the ManagedDashboardCard component
* Update packages/manager/.changeset/pr-12923-changed-1758915343839.md
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
* Remove extra padding on the Paper container when the chart renders in the UI
---------
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
---
.../manager/.changeset/pr-12923-changed-1758915343839.md | 5 +++++
.../ManagedDashboardCard/ManagedChartPanel.styles.tsx | 3 +++
.../ManagedDashboardCard/ManagedDashboardCard.styles.tsx | 1 +
3 files changed, 9 insertions(+)
create mode 100644 packages/manager/.changeset/pr-12923-changed-1758915343839.md
diff --git a/packages/manager/.changeset/pr-12923-changed-1758915343839.md b/packages/manager/.changeset/pr-12923-changed-1758915343839.md
new file mode 100644
index 00000000000..05e26385bf7
--- /dev/null
+++ b/packages/manager/.changeset/pr-12923-changed-1758915343839.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Add padding inside the ManagedDashboardCard component ([#12923](https://github.com/linode/manager/pull/12923))
diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx
index 16612857359..a7c2bf37a36 100644
--- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx
+++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.styles.tsx
@@ -29,6 +29,9 @@ export const StyledGraphControlsDiv = styled('div', {
top: 52,
width: 1,
},
+ '& .MuiPaper-root': {
+ padding: `0 0 0 ${theme.spacingFunction(24)}`,
+ },
alignItems: 'center',
display: 'flex',
minHeight: 460,
diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx
index aac50be3668..9d03064cd63 100644
--- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx
+++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx
@@ -18,6 +18,7 @@ export const StyledOuterContainerGrid = styled(Grid, {
background: theme.bg.bgPaper,
flexDirection: 'column',
margin: '-8px',
+ padding: theme.spacingFunction(12),
[theme.breakpoints.up('sm')]: {
flexDirection: 'row',
flexWrap: 'nowrap',
From 6f479491e7c017a3503dc8c1e9a93b10c14348dc Mon Sep 17 00:00:00 2001
From: Ankita
Date: Tue, 30 Sep 2025 11:07:08 +0530
Subject: [PATCH 34/54] fix: [DI-27529] - Update linode region pref on firewall
change (#12926)
* fix: [DI-27529] - Update linode region pref on firewall change
* fix: [DI-27529] - Simplify region select useffect
* fix: [DI-27529] - Add changeset
---------
Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com>
---
.../.changeset/pr-12926-fixed-1759149991132.md | 5 +++++
.../shared/CloudPulseDashboardFilterBuilder.tsx | 1 +
.../CloudPulse/shared/CloudPulseRegionSelect.tsx | 14 ++++++--------
3 files changed, 12 insertions(+), 8 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12926-fixed-1759149991132.md
diff --git a/packages/manager/.changeset/pr-12926-fixed-1759149991132.md b/packages/manager/.changeset/pr-12926-fixed-1759149991132.md
new file mode 100644
index 00000000000..d97a5e4c846
--- /dev/null
+++ b/packages/manager/.changeset/pr-12926-fixed-1759149991132.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` and `CloudPulseRegionSelect.tsx` to handle saved preference clearance for linode region filter ([#12926](https://github.com/linode/manager/pull/12926))
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
index 34e25625503..7142c7ea322 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
@@ -214,6 +214,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
savePref,
{
[NODE_TYPE]: undefined,
+ [LINODE_REGION]: undefined,
[RESOURCES]: resourceId.map((resource: { id: string }) =>
String(resource.id)
),
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx
index 5502aeeca54..3ab0b0bb58b 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx
@@ -123,7 +123,7 @@ export const CloudPulseRegionSelect = React.memo(
xFilter,
]);
- const dependencyKey = supportedLinodeRegions
+ const dependencyKey = supportedRegionsFromResources
.map((region) => region.id)
.sort()
.join(',');
@@ -136,13 +136,15 @@ export const CloudPulseRegionSelect = React.memo(
// and there's no selected region — attempt to preselect from defaultValue.
if (
!disabled &&
- regions &&
+ supportedRegionsFromResources &&
savePreferences &&
selectedRegion === undefined
) {
// Try to find the region corresponding to the saved default value
const region = defaultValue
- ? regions.find((regionObj) => regionObj.id === defaultValue)
+ ? supportedRegionsFromResources.find(
+ (regionObj) => regionObj.id === defaultValue
+ )
: undefined;
// Notify parent and set internal state
handleRegionChange(filterKey, region?.id, region ? [region.label] : []);
@@ -159,9 +161,6 @@ export const CloudPulseRegionSelect = React.memo(
handleRegionChange(filterKey, defaultRegionId, [defaultRegionLabel]);
setSelectedRegion(defaultRegionId);
} else {
- if (!disabled && filterKey === LINODE_REGION && selectedRegion) {
- return;
- }
if (selectedRegion !== undefined) {
setSelectedRegion('');
}
@@ -170,8 +169,7 @@ export const CloudPulseRegionSelect = React.memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
xFilter, // Reacts to filter changes (to reset region)
- regions, // Function to call on change
- dependencyKey, // Reacts to linode region changes
+ dependencyKey, // Reacts to region changes
]);
return (
From 6d1e350cf46e9aa83578e2ab99620c4b28ee391f Mon Sep 17 00:00:00 2001
From: Ankita
Date: Tue, 30 Sep 2025 14:25:15 +0530
Subject: [PATCH 35/54] [DI-26882] - Handle integration of Object Storage in
Metrics (#12912)
* [DI-26882] - Add initial changes for object storage integration in metrics
* [DI-26882] - Update type for reusable component, queryFn, tests, and mocks
* [DI-26882] - Integrate new component and temporarily update obj storage metrics tab
* [DI-26882] - Make prop otional
* [DI-26882] - Add tests for endpoints props, update comments
* [DI-26882] - Update test case
* [DI-26882] - Review suggestions
* [DI-26882] - Simplify props, add new func
* [DI-26882] - Update unit tes
* [DI-26882] - Update util
* [DI-26882] - Fix linting
* [DI-26882] - Add changesets
* [DI-26882] - Update queryKeys
* [DI-26882] - Remove type - any
* upcoming: [DI-26882] - Remove temporary changes
---------
Co-authored-by: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com>
---
.../pr-12912-changed-1758782562755.md | 5 ++
packages/api-v4/src/cloudpulse/types.ts | 6 +--
...r-12912-upcoming-features-1758782466180.md | 5 ++
.../Dashboard/CloudPulseDashboard.tsx | 18 ++++++-
.../Dashboard/CloudPulseDashboardRenderer.tsx | 1 +
.../CloudPulseDashboardWithFilters.tsx | 18 +++++--
.../Utils/CloudPulseWidgetUtils.test.ts | 25 +++++++++
.../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 49 ++++++++++++++---
.../CloudPulse/Utils/FilterBuilder.test.ts | 48 +++++++++++++++++
.../CloudPulse/Utils/FilterBuilder.ts | 52 +++++++++++++++++--
.../features/CloudPulse/Utils/FilterConfig.ts | 52 +++++++++++++++++++
.../ReusableDashboardFilterUtils.test.ts | 26 ++++++++++
.../Utils/ReusableDashboardFilterUtils.ts | 17 ++++--
.../features/CloudPulse/Utils/constants.ts | 5 ++
.../CloudPulse/Widget/CloudPulseWidget.tsx | 10 +++-
.../Widget/CloudPulseWidgetRenderer.tsx | 6 +++
.../shared/CloudPulseComponentRenderer.tsx | 5 ++
.../CloudPulseDashboardFilterBuilder.tsx | 26 ++++++++++
packages/manager/src/mocks/serverHandlers.ts | 39 +++++++++++++-
.../manager/src/queries/cloudpulse/queries.ts | 32 +++++++++++-
.../src/queries/cloudpulse/resources.ts | 10 +++-
.../utilities/src/__data__/regionsData.ts | 3 +-
22 files changed, 432 insertions(+), 26 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
create mode 100644 packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
diff --git a/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md b/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
new file mode 100644
index 00000000000..a169768c34a
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Changed
+---
+
+CloudPulse-Metrics: Update `CloudPulseMetricsRequest` and `JWETokenPayLoad` type at `types.ts` ([#12912](https://github.com/linode/manager/pull/12912))
diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts
index 494e4e2ee21..110fea9476a 100644
--- a/packages/api-v4/src/cloudpulse/types.ts
+++ b/packages/api-v4/src/cloudpulse/types.ts
@@ -9,7 +9,6 @@ export type CloudPulseServiceType =
| 'linode'
| 'nodebalancer'
| 'objectstorage';
-
export type AlertClass = 'dedicated' | 'shared';
export type DimensionFilterOperatorType =
| 'endswith'
@@ -134,7 +133,7 @@ export interface Dimension {
}
export interface JWETokenPayLoad {
- entity_ids: number[];
+ entity_ids?: number[];
}
export interface JWEToken {
@@ -149,7 +148,8 @@ export interface Metric {
export interface CloudPulseMetricsRequest {
absolute_time_duration: DateTimeWithPreset | undefined;
associated_entity_region?: string;
- entity_ids: number[];
+ entity_ids: number[] | string[];
+ entity_region?: string;
filters?: Filters[];
group_by?: string[];
metrics: Metric[];
diff --git a/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md b/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
new file mode 100644
index 00000000000..f7705437af0
--- /dev/null
+++ b/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+CloudPulse-Metrics: Handle special conditions for `objectstorage` service addition, add related filters at `FilterConfig.ts`, integrate related component `CloudPulseEndpointsSelect.tsx` ([#12912](https://github.com/linode/manager/pull/12912))
diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx
index f9b3c3240ad..3403f984a8a 100644
--- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx
+++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx
@@ -17,7 +17,11 @@ import {
} from '../Widget/CloudPulseWidgetRenderer';
import type { CloudPulseMetricsAdditionalFilters } from '../Widget/CloudPulseWidget';
-import type { DateTimeWithPreset, JWETokenPayLoad } from '@linode/api-v4';
+import type {
+ CloudPulseServiceType,
+ DateTimeWithPreset,
+ JWETokenPayLoad,
+} from '@linode/api-v4';
export interface DashboardProperties {
/**
@@ -65,6 +69,11 @@ export interface DashboardProperties {
*/
savePref?: boolean;
+ /**
+ * Selected service type for the dashboard
+ */
+ serviceType: CloudPulseServiceType;
+
/**
* Selected tags for the dashboard
*/
@@ -79,12 +88,18 @@ export const CloudPulseDashboard = (props: DashboardProperties) => {
manualRefreshTimeStamp,
resources,
savePref,
+ serviceType,
groupBy,
linodeRegion,
+ region,
} = props;
const { preferences } = useAclpPreference();
+
const getJweTokenPayload = (): JWETokenPayLoad => {
+ if (serviceType === 'objectstorage') {
+ return {};
+ }
return {
entity_ids: resources?.map((resource) => Number(resource)) ?? [],
};
@@ -169,6 +184,7 @@ export const CloudPulseDashboard = (props: DashboardProperties) => {
manualRefreshTimeStamp={manualRefreshTimeStamp}
metricDefinitions={metricDefinitions}
preferences={preferences}
+ region={region}
resourceList={resourceList}
resources={resources}
savePref={savePref}
diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx
index d0868b7b15e..5474c73dcce 100644
--- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx
+++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardRenderer.tsx
@@ -85,6 +85,7 @@ export const CloudPulseDashboardRenderer = React.memo(
: []
}
savePref={true}
+ serviceType={dashboard.service_type}
tags={
filterValue[TAGS] && Array.isArray(filterValue[TAGS])
? (filterValue[TAGS] as string[])
diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx
index 26e39a95ed2..e5dbc76f886 100644
--- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx
+++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx
@@ -28,15 +28,19 @@ export interface CloudPulseDashboardWithFiltersProp {
* The id of the dashboard that needs to be rendered
*/
dashboardId: number;
+ /**
+ * The region for which the metrics will be listed
+ */
+ region?: string;
/**
* The resource id for which the metrics will be listed
*/
- resource: number;
+ resource: number | string;
}
export const CloudPulseDashboardWithFilters = React.memo(
(props: CloudPulseDashboardWithFiltersProp) => {
- const { dashboardId, resource } = props;
+ const { dashboardId, resource, region } = props;
const { data: dashboard, isError } =
useCloudPulseDashboardByIdQuery(dashboardId);
const [filterData, setFilterData] = React.useState({
@@ -122,6 +126,7 @@ export const CloudPulseDashboardWithFilters = React.memo(
dashboardObj: dashboard,
filterValue: filterData.id,
resource,
+ region,
timeDuration,
groupBy,
});
@@ -180,7 +185,13 @@ export const CloudPulseDashboardWithFilters = React.memo(
emitFilterChange={onFilterChange}
handleToggleAppliedFilter={toggleAppliedFilter}
isServiceAnalyticsIntegration
- resource_ids={[resource]}
+ resource_ids={
+ dashboard.service_type !== 'objectstorage'
+ ? typeof resource === 'number'
+ ? [resource]
+ : undefined
+ : undefined
+ }
/>
)}
{
const result = getTimeDurationFromPreset('15min');
expect(result).toBe(undefined);
});
+
+ describe('getEntityIds method', () => {
+ it('should return entity ids for linode service type', () => {
+ const result = getEntityIds(
+ [{ id: '123', label: 'linode-1' }],
+ ['123'],
+ widgetFactory.build(),
+ 'linode'
+ );
+ expect(result).toEqual([123]);
+ });
+
+ it('should return entity ids for objectstorage service type', () => {
+ const result = getEntityIds(
+ [{ id: 'bucket-1', label: 'bucket-name-1' }],
+ ['bucket-1'],
+ widgetFactory.build(),
+ 'objectstorage'
+ );
+ expect(result).toEqual(['bucket-1']);
+ });
+ });
});
diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
index 7ebfc02b5c4..15285321937 100644
--- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
@@ -128,11 +128,21 @@ interface MetricRequestProps {
*/
linodeRegion?: string;
+ /**
+ * selected region for the widget
+ */
+ region?: string;
+
/**
* list of CloudPulse resources available
*/
resources: CloudPulseResources[];
+ /**
+ * service type of the widget
+ */
+ serviceType: CloudPulseServiceType;
+
/**
* widget filters for metrics data
*/
@@ -326,18 +336,23 @@ export const generateMaxUnit = (
export const getCloudPulseMetricRequest = (
props: MetricRequestProps
): CloudPulseMetricsRequest => {
- const { duration, entityIds, resources, widget, groupBy, linodeRegion } =
- props;
+ const {
+ duration,
+ entityIds,
+ resources,
+ widget,
+ groupBy,
+ linodeRegion,
+ region,
+ serviceType,
+ } = props;
const preset = duration.preset;
-
return {
absolute_time_duration:
preset !== 'reset' && preset !== 'this month' && preset !== 'last month'
? undefined
: { end: duration.end, start: duration.start },
- entity_ids: resources
- ? entityIds.map((id) => parseInt(id, 10))
- : widget.entity_ids.map((id) => parseInt(id, 10)),
+ entity_ids: getEntityIds(resources, entityIds, widget, serviceType),
filters: undefined,
group_by: !groupBy?.length ? undefined : groupBy,
relative_time_duration: getTimeDurationFromPreset(preset),
@@ -355,9 +370,31 @@ export const getCloudPulseMetricRequest = (
value: widget.time_granularity.value,
},
associated_entity_region: linodeRegion,
+ entity_region: serviceType === 'objectstorage' ? region : undefined,
};
};
+/**
+ *
+ * @param resources list of CloudPulse resources
+ * @param entityIds list of entity ids
+ * @param widget widget
+ * @returns transformed entity ids
+ */
+export const getEntityIds = (
+ resources: CloudPulseResources[],
+ entityIds: string[],
+ widget: Widgets,
+ serviceType: CloudPulseServiceType
+) => {
+ if (serviceType === 'objectstorage') {
+ return entityIds;
+ }
+ return resources
+ ? entityIds.map((id) => parseInt(id, 10))
+ : widget.entity_ids.map((id) => parseInt(id, 10));
+};
+
/**
*
* @returns generated label name for graph dimension
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
index 3d9b0bf6633..c4fcb62af9a 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts
@@ -13,6 +13,7 @@ import {
filterBasedOnConfig,
filterEndpointsUsingRegion,
filterUsingDependentFilters,
+ getEndpointsProperties,
getFilters,
getTextFilterProperties,
} from './FilterBuilder';
@@ -46,6 +47,13 @@ const firewallConfig = FILTER_CONFIG.get(4);
const dbaasDashboard = dashboardFactory.build({ service_type: 'dbaas', id: 1 });
+const objectStorageBucketDashboard = dashboardFactory.build({
+ service_type: 'objectstorage',
+ id: 6,
+});
+
+const objectStorageBucketConfig = FILTER_CONFIG.get(6);
+
it('test getRegionProperties method', () => {
const regionConfig = linodeConfig?.filters.find(
(filterObj) => filterObj.name === 'Region'
@@ -408,6 +416,46 @@ it('test getTextFilterProperties method for interface_id', () => {
}
});
+it('test getEndpointsProperties method', () => {
+ const endpointsConfig = objectStorageBucketConfig?.filters.find(
+ (filterObj) => filterObj.name === 'Endpoints'
+ );
+
+ expect(endpointsConfig).toBeDefined();
+
+ if (endpointsConfig) {
+ const endpointsProperties = getEndpointsProperties(
+ {
+ config: endpointsConfig,
+ dashboard: objectStorageBucketDashboard,
+ dependentFilters: { region: 'us-east' },
+ isServiceAnalyticsIntegration: false,
+ },
+ vi.fn()
+ );
+ const {
+ label,
+ serviceType,
+ disabled,
+ savePreferences,
+ handleEndpointsSelection,
+ defaultValue,
+ region,
+ xFilter,
+ } = endpointsProperties;
+
+ expect(endpointsProperties).toBeDefined();
+ expect(label).toEqual(endpointsConfig.configuration.name);
+ expect(serviceType).toEqual('objectstorage');
+ expect(savePreferences).toEqual(true);
+ expect(disabled).toEqual(false);
+ expect(handleEndpointsSelection).toBeDefined();
+ expect(defaultValue).toEqual(undefined);
+ expect(region).toEqual('us-east');
+ expect(xFilter).toEqual({ region: 'us-east' });
+ }
+});
+
it('test getFiltersForMetricsCallFromCustomSelect method', () => {
const result = getMetricsCallCustomFilters(
{
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
index 34b07701b8b..a875dec9481 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts
@@ -14,6 +14,7 @@ import type {
FilterValueType,
} from '../Dashboard/CloudPulseDashboardLanding';
import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect';
+import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect';
import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect';
import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter';
import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect';
@@ -360,6 +361,48 @@ export const getTextFilterProperties = (
};
};
+/**
+ * This function helps in building the properties needed for endpoints selection component
+ *
+ * @param config - accepts a CloudPulseServiceTypeFilters of endpoints key
+ * @param handleEndpointsChange - the callback when we select new endpoints
+ * @param dashboard - the actual selected dashboard
+ * @param isServiceAnalyticsIntegration - only if this is false, we need to save preferences , else no need
+ * @returns CloudPulseEndpointsSelectProps
+ */
+export const getEndpointsProperties = (
+ props: CloudPulseFilterProperties,
+ handleEndpointsChange: (endpoints: string[], savePref?: boolean) => void
+): CloudPulseEndpointsSelectProps => {
+ const { filterKey, name: label, placeholder } = props.config.configuration;
+ const {
+ config,
+ dashboard,
+ dependentFilters,
+ isServiceAnalyticsIntegration,
+ preferences,
+ shouldDisable,
+ } = props;
+ return {
+ defaultValue: preferences?.[config.configuration.filterKey],
+ disabled:
+ shouldDisable ||
+ shouldDisableFilterByFilterKey(
+ filterKey,
+ dependentFilters ?? {},
+ dashboard,
+ preferences
+ ),
+ handleEndpointsSelection: handleEndpointsChange,
+ label,
+ placeholder,
+ serviceType: dashboard.service_type,
+ region: dependentFilters?.[REGION],
+ savePreferences: !isServiceAnalyticsIntegration,
+ xFilter: filterBasedOnConfig(config, dependentFilters ?? {}),
+ };
+};
+
/**
* This function helps in builder the xFilter needed to passed in a apiV4 call
*
@@ -666,11 +709,14 @@ export const filterUsingDependentFilters = (
if (Array.isArray(resourceValue) && Array.isArray(filterValue)) {
return filterValue.some((val) => resourceValue.includes(String(val)));
- } else if (Array.isArray(resourceValue)) {
+ }
+ if (Array.isArray(resourceValue)) {
return resourceValue.includes(String(filterValue));
- } else {
- return resourceValue === filterValue;
}
+ if (Array.isArray(filterValue)) {
+ return (filterValue as string[]).includes(String(resourceValue));
+ }
+ return resourceValue === filterValue;
});
});
}
diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
index c93438a7e29..c152b66ab0d 100644
--- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts
@@ -1,8 +1,10 @@
import { capabilityServiceTypeMapping } from '@linode/api-v4';
import {
+ ENDPOINT,
INTERFACE_IDS_PLACEHOLDER_TEXT,
LINODE_REGION,
+ REGION,
RESOURCE_ID,
} from './constants';
import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models';
@@ -315,6 +317,55 @@ export const FIREWALL_CONFIG: Readonly = {
serviceType: 'firewall',
};
+export const OBJECTSTORAGE_CONFIG_BUCKET: Readonly =
+ {
+ capability: capabilityServiceTypeMapping['objectstorage'],
+ filters: [
+ {
+ configuration: {
+ filterKey: REGION,
+ filterType: 'string',
+ isFilterable: true,
+ isMetricsFilter: true,
+ name: 'Region',
+ priority: 1,
+ neededInViews: [CloudPulseAvailableViews.central],
+ },
+ name: 'Region',
+ },
+ {
+ configuration: {
+ dependency: [REGION],
+ filterKey: ENDPOINT,
+ filterType: 'string',
+ isFilterable: false,
+ isMetricsFilter: false,
+ isMultiSelect: true,
+ name: 'Endpoints',
+ priority: 2,
+ neededInViews: [CloudPulseAvailableViews.central],
+ },
+ name: 'Endpoints',
+ },
+ {
+ configuration: {
+ dependency: [REGION, ENDPOINT],
+ filterKey: RESOURCE_ID,
+ filterType: 'string',
+ isFilterable: true,
+ isMetricsFilter: true,
+ isMultiSelect: true,
+ name: 'Buckets',
+ neededInViews: [CloudPulseAvailableViews.central],
+ placeholder: 'Select Buckets',
+ priority: 3,
+ },
+ name: 'Buckets',
+ },
+ ],
+ serviceType: 'objectstorage',
+ };
+
export const FILTER_CONFIG: Readonly<
Map
> = new Map([
@@ -322,4 +373,5 @@ export const FILTER_CONFIG: Readonly<
[2, LINODE_CONFIG],
[3, NODEBALANCER_CONFIG],
[4, FIREWALL_CONFIG],
+ [6, OBJECTSTORAGE_CONFIG_BUCKET],
]);
diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
index d3e0132dbfe..1f68344cf67 100644
--- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.test.ts
@@ -20,11 +20,14 @@ it('test getDashboardProperties method', () => {
filterValue: { region: 'us-east' },
resource: 1,
groupBy: [],
+ region: 'us-east',
});
expect(result).toBeDefined();
expect(result.dashboardId).toEqual(mockDashboard.id);
+ expect(result.serviceType).toEqual(mockDashboard.service_type);
expect(result.resources).toEqual(['1']);
+ expect(result.region).toEqual('us-east');
});
it('test checkMandatoryFiltersSelected method for time duration and resource', () => {
@@ -93,6 +96,29 @@ it('test checkMandatoryFiltersSelected method for role', () => {
expect(result).toBe(true);
});
+it('checkMandatoryFiltersSelected method should return false if no region is selected for objectstorage service type', () => {
+ const result = checkMandatoryFiltersSelected({
+ dashboardObj: { ...mockDashboard, service_type: 'objectstorage', id: 6 },
+ filterValue: {},
+ resource: 1,
+ timeDuration: { end: end.toISO(), preset, start: start.toISO() },
+ groupBy: [],
+ });
+ expect(result).toBe(false);
+});
+
+it('checkMandatoryFiltersSelected method should return true if region is selected for objectstorage service type', () => {
+ const result = checkMandatoryFiltersSelected({
+ dashboardObj: { ...mockDashboard, service_type: 'objectstorage', id: 6 },
+ filterValue: {},
+ resource: 1,
+ timeDuration: { end: end.toISO(), preset, start: start.toISO() },
+ groupBy: [],
+ region: 'ap-west',
+ });
+ expect(result).toBe(true);
+});
+
it('test constructDimensionFilters method', () => {
mockDashboard.service_type = 'dbaas';
const result = constructDimensionFilters({
diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts
index 7337e235b4d..edce614f656 100644
--- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts
@@ -23,10 +23,14 @@ interface ReusableDashboardFilterUtilProps {
* The selected grouping criteria
*/
groupBy: string[];
+ /**
+ * The selected region
+ */
+ region?: string;
/**
* The selected resource id
*/
- resource: number;
+ resource: number | string;
/**
* The selected time duration
*/
@@ -40,7 +44,8 @@ interface ReusableDashboardFilterUtilProps {
export const getDashboardProperties = (
props: ReusableDashboardFilterUtilProps
): DashboardProperties => {
- const { dashboardObj, filterValue, resource, timeDuration, groupBy } = props;
+ const { dashboardObj, filterValue, resource, timeDuration, groupBy, region } =
+ props;
return {
additionalFilters: constructDimensionFilters({
dashboardObj,
@@ -51,8 +56,10 @@ export const getDashboardProperties = (
dashboardId: dashboardObj.id,
duration: timeDuration ?? defaultTimeDuration(),
resources: [String(resource)],
+ serviceType: dashboardObj.service_type,
savePref: false,
groupBy,
+ region,
};
};
@@ -63,7 +70,7 @@ export const getDashboardProperties = (
export const checkMandatoryFiltersSelected = (
props: ReusableDashboardFilterUtilProps
): boolean => {
- const { dashboardObj, filterValue, resource, timeDuration } = props;
+ const { dashboardObj, filterValue, resource, timeDuration, region } = props;
const serviceTypeConfig = FILTER_CONFIG.get(dashboardObj.id);
if (!serviceTypeConfig) {
@@ -74,6 +81,10 @@ export const checkMandatoryFiltersSelected = (
return false;
}
+ if (dashboardObj.service_type === 'objectstorage' && !region) {
+ return false;
+ }
+
return serviceTypeConfig.filters.every(({ configuration }) => {
const { filterKey, neededInViews } = configuration;
diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts
index 786a4795805..64f98ddbbe4 100644
--- a/packages/manager/src/features/CloudPulse/Utils/constants.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts
@@ -8,6 +8,10 @@ export const SECONDARY_NODE = 'secondary';
export const REGION = 'region';
+export const ENTITY_REGION = 'entity_region';
+
+export const ENDPOINT = 'endpoint';
+
export const LINODE_REGION = 'associated_entity_region';
export const RESOURCES = 'resources';
@@ -85,6 +89,7 @@ export const NO_REGION_MESSAGE: Record = {
linode: 'No Linodes configured in any regions.',
nodebalancer: 'No NodeBalancers configured in any regions.',
firewall: 'No firewalls configured in any Linode regions.',
+ objectstorage: 'No Object Storage buckets configured in any region.',
};
export const HELPER_TEXT: Record = {
diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx
index 8ef969b8e97..c717a0b44d9 100644
--- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx
+++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx
@@ -91,6 +91,11 @@ export interface CloudPulseWidgetProperties {
*/
linodeRegion?: string;
+ /**
+ * Selected region for the widget
+ */
+ region?: string;
+
/**
* List of resources available of selected service type
*/
@@ -149,7 +154,6 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => {
const {
additionalFilters,
- dashboardId,
ariaLabel,
authToken,
availableMetrics,
@@ -163,6 +167,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => {
unit,
widget: widgetProp,
linodeRegion,
+ dashboardId,
+ region,
} = props;
const timezone =
@@ -262,6 +268,8 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => {
widget,
groupBy: [...(widgetProp.group_by ?? []), ...groupBy],
linodeRegion,
+ region,
+ serviceType,
}),
filters, // any additional dimension filters will be constructed and passed here
},
diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx
index e09fa2129e2..ee4d5c867b2 100644
--- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx
+++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidgetRenderer.tsx
@@ -37,6 +37,10 @@ interface WidgetProps {
manualRefreshTimeStamp?: number;
metricDefinitions: ResourcePage | undefined;
preferences?: AclpConfig;
+ /**
+ * Selected region for the widget
+ */
+ region?: string;
resourceList: CloudPulseResources[] | undefined;
resources: string[];
savePref?: boolean;
@@ -68,6 +72,7 @@ export const RenderWidgets = React.memo(
savePref,
groupBy,
linodeRegion,
+ region,
} = props;
const getCloudPulseGraphProperties = (
@@ -176,6 +181,7 @@ export const RenderWidgets = React.memo(
availableMetrics={availMetrics}
isJweTokenFetching={isJweTokenFetching}
linodeRegion={linodeRegion}
+ region={region}
resources={resourceList!}
savePref={savePref}
/>
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx
index 220b36f03bb..75ab29be939 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx
@@ -5,6 +5,7 @@ import NullComponent from 'src/components/NullComponent';
import { CloudPulseCustomSelect } from './CloudPulseCustomSelect';
import { CloudPulseDateTimeRangePicker } from './CloudPulseDateTimeRangePicker';
+import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect';
import { CloudPulseNodeTypeFilter } from './CloudPulseNodeTypeFilter';
import { CloudPulseRegionSelect } from './CloudPulseRegionSelect';
import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect';
@@ -13,6 +14,7 @@ import { CloudPulseTextFilter } from './CloudPulseTextFilter';
import type { CloudPulseCustomSelectProps } from './CloudPulseCustomSelect';
import type { CloudPulseDateTimeRangePickerProps } from './CloudPulseDateTimeRangePicker';
+import type { CloudPulseEndpointsSelectProps } from './CloudPulseEndpointsSelect';
import type { CloudPulseNodeTypeFilterProps } from './CloudPulseNodeTypeFilter';
import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect';
import type { CloudPulseResourcesSelectProps } from './CloudPulseResourcesSelect';
@@ -24,6 +26,7 @@ export interface CloudPulseComponentRendererProps {
componentProps:
| CloudPulseCustomSelectProps
| CloudPulseDateTimeRangePickerProps
+ | CloudPulseEndpointsSelectProps
| CloudPulseNodeTypeFilterProps
| CloudPulseRegionSelectProps
| CloudPulseResourcesSelectProps
@@ -37,6 +40,7 @@ const Components: {
React.ComponentType<
| CloudPulseCustomSelectProps
| CloudPulseDateTimeRangePickerProps
+ | CloudPulseEndpointsSelectProps
| CloudPulseNodeTypeFilterProps
| CloudPulseRegionSelectProps
| CloudPulseResourcesSelectProps
@@ -54,6 +58,7 @@ const Components: {
resource_id: CloudPulseResourcesSelect,
tags: CloudPulseTagsSelect,
associated_entity_region: CloudPulseRegionSelect,
+ endpoint: CloudPulseEndpointsSelect,
};
const buildComponent = (props: CloudPulseComponentRendererProps) => {
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
index 7142c7ea322..404af3c14b1 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx
@@ -10,6 +10,7 @@ import NullComponent from 'src/components/NullComponent';
import RenderComponent from '../shared/CloudPulseComponentRenderer';
import {
DASHBOARD_ID,
+ ENDPOINT,
INTERFACE_ID,
LINODE_REGION,
NODE_TYPE,
@@ -21,6 +22,7 @@ import {
} from '../Utils/constants';
import {
getCustomSelectProperties,
+ getEndpointsProperties,
getFilters,
getNodeTypeProperties,
getRegionProperties,
@@ -235,6 +237,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
filterKey === REGION
? {
[filterKey]: region,
+ [ENDPOINT]: undefined,
[RESOURCES]: undefined,
[TAGS]: undefined,
}
@@ -252,6 +255,16 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
[emitFilterChangeByFilterKey]
);
+ const handleEndpointsChange = React.useCallback(
+ (endpoints: string[], savePref: boolean = false) => {
+ emitFilterChangeByFilterKey(ENDPOINT, endpoints, endpoints, savePref, {
+ [ENDPOINT]: endpoints,
+ [RESOURCE_ID]: undefined,
+ });
+ },
+ [emitFilterChangeByFilterKey]
+ );
+
const handleCustomSelectChange = React.useCallback(
(
filterKey: string,
@@ -357,6 +370,18 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
},
handleTextFilterChange
);
+ } else if (config.configuration.filterKey === ENDPOINT) {
+ return getEndpointsProperties(
+ {
+ config,
+ dashboard,
+ dependentFilters: dependentFilterReference.current,
+ isServiceAnalyticsIntegration,
+ preferences,
+ shouldDisable: isError || isLoading,
+ },
+ handleEndpointsChange
+ );
} else {
return getCustomSelectProperties(
{
@@ -380,6 +405,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo(
handleRegionChange,
handleTextFilterChange,
handleResourceChange,
+ handleEndpointsChange,
handleCustomSelectChange,
isServiceAnalyticsIntegration,
preferences,
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index b051a64238c..e5aff744b51 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -1340,6 +1340,11 @@ export const handlers = [
region: 'us-mia',
s3_endpoint: 'us-mia-1.linodeobjects.com',
}),
+ objectStorageBucketFactoryGen2.build({
+ endpoint_type: 'E3',
+ region: 'ap-west',
+ s3_endpoint: 'ap-west-1.linodeobjects.com',
+ }),
];
return HttpResponse.json(makeResourcePage(endpoints));
}),
@@ -1448,6 +1453,18 @@ export const handlers = [
label: `obj-bucket-${randomBucketNumber}`,
region,
});
+ if (region === 'ap-west') {
+ buckets.push(
+ objectStorageBucketFactoryGen2.build({
+ cluster: `${region}-1`,
+ endpoint_type: 'E3',
+ s3_endpoint: 'ap-west-1.linodeobjects.com',
+ hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`,
+ label: `obj-bucket-${randomBucketNumber}`,
+ region,
+ })
+ );
+ }
return HttpResponse.json({
data: buckets.slice(
@@ -3048,6 +3065,12 @@ export const handlers = [
regions: 'us-iad,us-east',
alert: serviceAlertFactory.build({ scope: ['entity'] }),
}),
+ serviceTypesFactory.build({
+ label: 'Object Storage',
+ service_type: 'objectstorage',
+ regions: 'us-iad,us-east',
+ alert: serviceAlertFactory.build({ scope: ['entity'] }),
+ }),
],
};
@@ -3136,6 +3159,16 @@ export const handlers = [
);
}
+ if (params.serviceType === 'objectstorage') {
+ response.data.push(
+ dashboardFactory.build({
+ id: 6,
+ label: 'Object Storage Dashboard',
+ service_type: 'objectstorage',
+ })
+ );
+ }
+
return HttpResponse.json(response);
}),
http.get(
@@ -3422,6 +3455,9 @@ export const handlers = [
} else if (id === '4') {
serviceType = 'firewall';
dashboardLabel = 'Firewall Service I/O Statistics';
+ } else if (id === '6') {
+ serviceType = 'objectstorage';
+ dashboardLabel = 'Object Storage Service I/O Statistics';
} else {
serviceType = 'linode';
dashboardLabel = 'Linode Service I/O Statistics';
@@ -3562,9 +3598,8 @@ export const handlers = [
},
{
metric: {
- entity_id: '789',
+ entity_id: 'obj-bucket-383.ap-west.linodeobjects.com',
metric_name: 'average_cpu_usage',
- node_id: 'primary-3',
},
values: [
[1721854379, '0.3744841110560275'],
diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts
index d53fedbfb23..1ac6118c044 100644
--- a/packages/manager/src/queries/cloudpulse/queries.ts
+++ b/packages/manager/src/queries/cloudpulse/queries.ts
@@ -16,6 +16,11 @@ import {
} from '@linode/queries';
import { createQueryKeys } from '@lukemorales/query-key-factory';
+import { objectStorageQueries } from '../object-storage/queries';
+import {
+ getAllBucketsFromEndpoints,
+ getAllObjectStorageEndpoints,
+} from '../object-storage/requests';
import { fetchCloudPulseMetrics } from './metrics';
import {
getAllAlertsRequest,
@@ -124,6 +129,14 @@ export const queryFactory = createQueryKeys(key, {
case 'nodebalancer':
return nodebalancerQueries.nodebalancers._ctx.all(params, filters);
+ case 'objectstorage':
+ return {
+ queryFn: () => getAllBuckets(),
+ queryKey: [
+ ...objectStorageQueries.endpoints.queryKey,
+ objectStorageQueries.buckets.queryKey[1],
+ ],
+ };
case 'volumes':
return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts
@@ -134,6 +147,23 @@ export const queryFactory = createQueryKeys(key, {
token: (serviceType: string | undefined, request: JWETokenPayLoad) => ({
queryFn: () => getJWEToken(request, serviceType!),
- queryKey: [serviceType, { resource_ids: request.entity_ids.sort() }],
+ queryKey: [serviceType, { resource_ids: request.entity_ids?.sort() }],
}),
});
+
+const getAllBuckets = async () => {
+ const endpoints = await getAllObjectStorageEndpoints();
+
+ // Get all the buckets from the endpoints
+ const allBuckets = await getAllBucketsFromEndpoints(endpoints);
+
+ // Throw the error if we encounter any error for any single call.
+ if (allBuckets.errors.length) {
+ throw new Error('Unable to fetch the data.');
+ }
+
+ // Filter the E0, E1 endpoint_type out and return the buckets
+ return allBuckets.buckets.filter(
+ (bucket) => bucket.endpoint_type !== 'E0' && bucket.endpoint_type !== 'E1'
+ );
+};
diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts
index b1f3dd89bfa..074c6c98eb9 100644
--- a/packages/manager/src/queries/cloudpulse/resources.ts
+++ b/packages/manager/src/queries/cloudpulse/resources.ts
@@ -14,6 +14,7 @@ export const useResourcesQuery = (
useQuery({
...queryFactory.resources(resourceType, params, filters),
enabled,
+ retry: resourceType === 'objectstorage' ? false : 3,
select: (resources) => {
if (!enabled) {
return []; // Return empty array if the query is not enabled
@@ -36,13 +37,18 @@ export const useResourcesQuery = (
}
});
}
+ const id =
+ resourceType === 'objectstorage'
+ ? resource.hostname
+ : String(resource.id);
return {
engineType: resource.engine,
- id: String(resource.id),
- label: resource.label,
+ id,
+ label: resourceType === 'objectstorage' ? id : resource.label,
region: resource.region,
regions: resource.regions ? resource.regions : [],
tags: resource.tags,
+ endpoint: resource.s3_endpoint,
entities,
clusterSize: resource.cluster_size,
};
diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts
index 84587c53e5c..6b1f916e968 100644
--- a/packages/utilities/src/__data__/regionsData.ts
+++ b/packages/utilities/src/__data__/regionsData.ts
@@ -13,6 +13,7 @@ export const regions: Region[] = [
'VPCs',
'Block Storage Migrations',
'Managed Databases',
+ 'Object Storage',
],
country: 'in',
id: 'ap-west',
@@ -27,7 +28,7 @@ export const regions: Region[] = [
},
site_type: 'core',
status: 'ok',
- monitors: { alerts: ['Cloud Firewall'], metrics: [] },
+ monitors: { alerts: ['Cloud Firewall'], metrics: ['Object Storage'] },
},
{
capabilities: [
From ea3390a159da75fa6190a8f91237cd23fc8fa44c Mon Sep 17 00:00:00 2001
From: santoshp210-akamai
<159890961+santoshp210-akamai@users.noreply.github.com>
Date: Tue, 30 Sep 2025 19:02:37 +0530
Subject: [PATCH 36/54] upcoming: [DI-27317] - Onboarding Object Storage
service to Alerts UI (#12910)
* upcoming: [DI-27317] - Onboarding Object Storage service to Alerts UI
* upcoming: [DI-27317] - Add mocks to test show-details and edit flow
* add changeset
* upcoming: [DI-27317] - Add interface for EndpointOption
---
...r-12910-upcoming-features-1758728184885.md | 5 ++
.../src/factories/cloudpulse/services.ts | 1 +
.../AlertsEndpointFilter.test.tsx | 78 +++++++++++++++++++
.../AlertsResources/AlertsEndpointFilter.tsx | 63 +++++++++++++++
.../AlertsResources/AlertsResources.tsx | 67 +++++++++++-----
.../AlertsResourcesFilterRenderer.test.tsx | 26 +++++++
.../AlertsResources/DisplayAlertResources.tsx | 4 +
.../Alerts/AlertsResources/constants.ts | 24 ++++++
.../Alerts/AlertsResources/types.ts | 6 +-
.../Alerts/Utils/AlertResourceUtils.test.ts | 64 +++++++++++++++
.../Alerts/Utils/AlertResourceUtils.ts | 53 +++++++++++++
packages/manager/src/mocks/serverHandlers.ts | 71 ++++++++++++++---
.../manager/src/queries/cloudpulse/queries.ts | 2 -
.../utilities/src/__data__/regionsData.ts | 7 +-
14 files changed, 434 insertions(+), 37 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.test.tsx
create mode 100644 packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.tsx
diff --git a/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md b/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
new file mode 100644
index 00000000000..7479722aa89
--- /dev/null
+++ b/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+ACLP-Alerting: Object Storage service onboarding for Alerts UI ([#12910](https://github.com/linode/manager/pull/12910))
diff --git a/packages/manager/src/factories/cloudpulse/services.ts b/packages/manager/src/factories/cloudpulse/services.ts
index 6e9c00f9eda..b794146457a 100644
--- a/packages/manager/src/factories/cloudpulse/services.ts
+++ b/packages/manager/src/factories/cloudpulse/services.ts
@@ -8,6 +8,7 @@ const serviceTypes: CloudPulseServiceType[] = [
'nodebalancer',
'dbaas',
'firewall',
+ 'objectstorage',
];
export const serviceAlertFactory = Factory.Sync.makeFactory({
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.test.tsx
new file mode 100644
index 00000000000..c8a2f3e7ff0
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.test.tsx
@@ -0,0 +1,78 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { AlertsEndpointFilter } from './AlertsEndpointFilter';
+
+describe('AlertsEndpointFilter', () => {
+ const endpointOptions = ['endpoint-1', 'endpoint-2'];
+ it('calls handleFilterChange with correct arguments when an endpoint is selected', async () => {
+ const handleFilterChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+
+ // Check first option exists
+ expect(
+ screen.getByRole('option', { name: endpointOptions[0] })
+ ).toBeVisible();
+
+ // Select first option
+ await userEvent.click(
+ screen.getByRole('option', { name: endpointOptions[0] })
+ );
+ expect(handleFilterChange).toHaveBeenCalledWith(
+ [endpointOptions[0]],
+ 'endpoint'
+ );
+ });
+ it('renders with empty endpointOptions', async () => {
+ const handleFilterChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open' })); // indicates there is a drop down
+ expect(
+ screen.getByText('You have no options to choose from')
+ ).toBeVisible();
+ });
+
+ it('renders with multiple selection endpoints', async () => {
+ const handleFilterChange = vi.fn();
+
+ renderWithTheme(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }));
+
+ // Select first option
+ await userEvent.click(
+ screen.getByRole('option', { name: endpointOptions[0] })
+ );
+ // Select second option
+ await userEvent.click(
+ screen.getByRole('option', { name: endpointOptions[1] })
+ );
+ expect(handleFilterChange).toHaveBeenCalledWith(
+ [...endpointOptions],
+ 'endpoint'
+ );
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.tsx
new file mode 100644
index 00000000000..abb2d8b48c1
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsEndpointFilter.tsx
@@ -0,0 +1,63 @@
+import { Autocomplete } from '@linode/ui';
+import React from 'react';
+
+import type { AlertFilterKey } from './types';
+
+interface EndpointOption {
+ label: string;
+}
+export interface AlertsEndpointFilterProps {
+ /**
+ * List of object storage endpoints.
+ */
+ endpointOptions: string[];
+ /**
+ * Callback to publish the selected engine type
+ */
+ handleFilterChange: (
+ endpoints: string[] | undefined,
+ type: AlertFilterKey
+ ) => void;
+}
+
+export const AlertsEndpointFilter = React.memo(
+ (props: AlertsEndpointFilterProps) => {
+ const { handleFilterChange: handleSelection, endpointOptions } = props;
+ const [selectedEndpoints, setSelectedEndpoints] = React.useState<
+ EndpointOption[]
+ >([]);
+ const endpointBuiltOptions: EndpointOption[] = endpointOptions.map(
+ (option) => ({
+ label: option,
+ })
+ );
+
+ const handleFilterSelection = React.useCallback(
+ (_: React.SyntheticEvent, endpoints: EndpointOption[]) => {
+ setSelectedEndpoints(endpoints);
+ handleSelection(
+ endpoints.length ? endpoints.map(({ label }) => label) : undefined,
+ 'endpoint'
+ );
+ },
+ [handleSelection]
+ );
+ return (
+ option.label === value.label}
+ label="Endpoints"
+ limitTags={1}
+ multiple
+ onChange={handleFilterSelection}
+ options={endpointBuiltOptions}
+ placeholder="Select Endpoints"
+ textFieldProps={{
+ hideLabel: true,
+ }}
+ value={selectedEndpoints}
+ />
+ );
+ }
+);
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
index 5de8c1725f8..2b2bbc9b6eb 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx
@@ -12,7 +12,9 @@ import { MULTILINE_ERROR_SEPARATOR } from '../constants';
import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages';
import {
getAlertResourceFilterProps,
+ getEndpointOptions,
getFilteredResources,
+ getOfflineRegionFilteredResources,
getRegionOptions,
getRegionsIdRegionMap,
getSupportedRegionIds,
@@ -119,7 +121,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
const [selectedOnly, setSelectedOnly] = React.useState(false);
const [additionalFilters, setAdditionalFilters] = React.useState<
Record
- >({ engineType: undefined, tags: undefined });
+ >({ engineType: undefined, tags: undefined, endpoint: undefined });
const {
data: regions,
@@ -129,9 +131,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
const theme = useTheme();
- const supportedRegionIds = getSupportedRegionIds(regions, serviceType);
+ const supportedRegionIds = React.useMemo(() => {
+ return getSupportedRegionIds(regions, serviceType);
+ }, [regions, serviceType]);
const xFilterToBeApplied: Filter | undefined = React.useMemo(() => {
- if (serviceType === 'firewall' || !supportedRegionIds?.length) {
+ if (
+ serviceType === 'firewall' ||
+ serviceType === 'objectstorage' ||
+ !supportedRegionIds?.length
+ ) {
return undefined;
}
@@ -188,14 +196,21 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
xFilterToBeApplied
);
+ const regionFilteredResources = React.useMemo(() => {
+ if (serviceType === 'objectstorage' && resources && supportedRegionIds) {
+ return getOfflineRegionFilteredResources(resources, supportedRegionIds);
+ }
+ return resources;
+ }, [serviceType, resources, supportedRegionIds]);
+
const computedSelectedResources = React.useMemo(() => {
- if (!isSelectionsNeeded || !resources) {
+ if (!isSelectionsNeeded || !regionFilteredResources) {
return alertResourceIds;
}
- return resources
+ return regionFilteredResources
.filter(({ id }) => alertResourceIds.includes(id))
.map(({ id }) => id);
- }, [resources, isSelectionsNeeded, alertResourceIds]);
+ }, [regionFilteredResources, isSelectionsNeeded, alertResourceIds]);
React.useEffect(() => {
setSelectedResources(computedSelectedResources);
@@ -209,13 +224,25 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
// Derived list of regions associated with the provided resource IDs, filtered based on available data.
const regionOptions: Region[] = React.useMemo(() => {
return getRegionOptions({
- data: resources,
+ data: regionFilteredResources,
isAdditionOrDeletionNeeded: isSelectionsNeeded,
regionsIdToRegionMap,
resourceIds: alertResourceIds,
});
- }, [resources, alertResourceIds, regionsIdToRegionMap, isSelectionsNeeded]);
+ }, [
+ regionFilteredResources,
+ alertResourceIds,
+ regionsIdToRegionMap,
+ isSelectionsNeeded,
+ ]);
+ const endpointOptions: string[] = React.useMemo(() => {
+ return getEndpointOptions(
+ regionFilteredResources,
+ isSelectionsNeeded,
+ alertResourceIds
+ );
+ }, [alertResourceIds, isSelectionsNeeded, regionFilteredResources]);
const isDataLoadingError = isRegionsError || isResourcesError;
const handleSearchTextChange = (searchText: string) => {
@@ -246,7 +273,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
const filteredResources: AlertInstance[] = React.useMemo(() => {
return getFilteredResources({
additionalFilters,
- data: resources,
+ data: regionFilteredResources,
filteredRegions,
isAdditionOrDeletionNeeded: isSelectionsNeeded,
regionsIdToRegionMap,
@@ -256,7 +283,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
selectedResources,
});
}, [
- resources,
+ regionFilteredResources,
filteredRegions,
isSelectionsNeeded,
regionsIdToRegionMap,
@@ -283,7 +310,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
const handleAllSelection = React.useCallback(
(action: SelectDeselectAll) => {
- if (!resources) {
+ if (!regionFilteredResources) {
return;
}
@@ -294,7 +321,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
setSelectedResources([]);
} else {
// Select all
- currentSelections = resources.map(({ id }) => id);
+ currentSelections = regionFilteredResources.map(({ id }) => id);
setSelectedResources(currentSelections);
}
@@ -302,7 +329,7 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
handleResourcesSelection(currentSelections); // publish the resources selected
}
},
- [handleResourcesSelection, resources]
+ [handleResourcesSelection, regionFilteredResources]
);
const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes.
@@ -352,7 +379,6 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
maxSelectionCount && selectedResources
? Math.max(0, maxSelectionCount - selectedResources.length)
: undefined;
-
return (
{!hideLabel && (
@@ -401,11 +427,14 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
filterKey,
handleFilterChange,
handleFilteredRegionsChange,
+ endpointOptions,
regionOptions,
tagOptions: Array.from(
new Set(
- resources
- ? resources.flatMap(({ tags }) => tags ?? [])
+ regionFilteredResources
+ ? regionFilteredResources.flatMap(
+ ({ tags }) => tags ?? []
+ )
: []
)
),
@@ -454,15 +483,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => {
)}
{isSelectionsNeeded &&
!isDataLoadingError &&
- resources &&
- resources.length > 0 && (
+ regionFilteredResources &&
+ regionFilteredResources.length > 0 && (
)}
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx
index da5e25f5252..04aba67464a 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx
@@ -1,3 +1,4 @@
+import { screen } from '@testing-library/react';
import React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -8,12 +9,14 @@ import { serviceToFiltersMap } from './constants';
describe('AlertsResourcesFilterRenderer', () => {
const filters = serviceToFiltersMap['dbaas'] ?? []; // Get filters for dbaas service type
+ const objectStorageFilters = serviceToFiltersMap['objectstorage'] ?? [];
it('renders the correct filter components based on properties passed', () => {
const handleFilterChangeMock = vi.fn();
const engineProps = getAlertResourceFilterProps({
filterKey: 'engineType',
handleFilterChange: handleFilterChangeMock,
handleFilteredRegionsChange: handleFilterChangeMock,
+ endpointOptions: [],
regionOptions: [],
tagOptions: [],
});
@@ -36,6 +39,7 @@ describe('AlertsResourcesFilterRenderer', () => {
filterKey: 'region',
handleFilterChange: handleFilterChangeMock,
handleFilteredRegionsChange: handleFilterChangeMock,
+ endpointOptions: [],
regionOptions: [],
tagOptions: [],
});
@@ -52,5 +56,27 @@ describe('AlertsResourcesFilterRenderer', () => {
);
expect(getByPlaceholderText('Select Regions')).toBeInTheDocument();
+
+ const endpointProps = getAlertResourceFilterProps({
+ filterKey: 'endpoint',
+ handleFilterChange: handleFilterChangeMock,
+ handleFilteredRegionsChange: handleFilterChangeMock,
+ endpointOptions: [],
+ regionOptions: [],
+ tagOptions: [],
+ });
+ const endpointPropKeys = Object.keys(endpointProps);
+ expect(endpointPropKeys.includes('handleFilterChange')).toBeTruthy();
+ expect(endpointPropKeys.includes('handleSelectionChange')).toBeFalsy();
+
+ // Check for region filter
+ renderWithTheme(
+
+ );
+
+ expect(screen.getByPlaceholderText('Select Endpoints')).toBeVisible();
});
});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx
index a08e62cdda0..3bffebb91c4 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx
@@ -24,6 +24,10 @@ export interface AlertInstance {
* Indicates if the instance is selected or not
*/
checked?: boolean;
+ /**
+ * The endpoint associated with the object storage instance
+ */
+ endpoint?: string;
/**
* The region associated with the instance
*/
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts
index f28c28d1475..4c6cabef159 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts
@@ -1,6 +1,7 @@
import React from 'react';
import { engineTypeMap } from '../constants';
+import { AlertsEndpointFilter } from './AlertsEndpointFilter';
import { AlertsEngineTypeFilter } from './AlertsEngineTypeFilter';
import { AlertsRegionFilter } from './AlertsRegionFilter';
import { AlertsTagFilter } from './AlertsTagsFilter';
@@ -86,6 +87,23 @@ export const serviceTypeBasedColumns: ServiceColumns = {
sortingKey: 'tags',
},
],
+ objectstorage: [
+ {
+ accessor: ({ label }) => label,
+ label: 'Entity',
+ sortingKey: 'label',
+ },
+ {
+ accessor: ({ region }) => region,
+ label: 'Region',
+ sortingKey: 'region',
+ },
+ {
+ accessor: ({ endpoint }) => endpoint,
+ label: 'Endpoint',
+ sortingKey: 'endpoint',
+ },
+ ],
};
export const serviceToFiltersMap: Partial<
@@ -103,16 +121,22 @@ export const serviceToFiltersMap: Partial<
{ component: AlertsRegionFilter, filterKey: 'region' },
{ component: AlertsTagFilter, filterKey: 'tags' },
],
+ objectstorage: [
+ { component: AlertsRegionFilter, filterKey: 'region' },
+ { component: AlertsEndpointFilter, filterKey: 'endpoint' },
+ ],
};
export const applicableAdditionalFilterKeys: AlertAdditionalFilterKey[] = [
'engineType', // Extendable in future for filter keys like 'tags', 'plan', etc.
'tags',
+ 'endpoint',
];
export const alertAdditionalFilterKeyMap: Record<
AlertAdditionalFilterKey,
keyof AlertInstance
> = {
+ endpoint: 'endpoint',
engineType: 'engineType', // engineType filter selected here, will map to engineType property on AlertInstance
tags: 'tags',
};
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts
index 5a0764e0766..39dcec3f170 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts
@@ -1,5 +1,6 @@
import type { MemoExoticComponent } from 'react';
+import type { AlertsEndpointFilterProps } from './AlertsEndpointFilter';
import type { AlertsEngineOptionProps } from './AlertsEngineTypeFilter';
import type { AlertsRegionProps } from './AlertsRegionFilter';
import type { AlertsTagFilterProps } from './AlertsTagsFilter';
@@ -46,7 +47,7 @@ export type ServiceColumns = Partial<
* Defines the available filter keys that can be used to filter alerts.
* This type will be extended in the future to include other attributes like tags, plan, etc.
*/
-export type AlertFilterKey = 'engineType' | 'region' | 'tags'; // will be extended to have tags, plan etc.,
+export type AlertFilterKey = 'endpoint' | 'engineType' | 'region' | 'tags'; // will be extended to have tags, plan etc.,
/**
* Represents the possible types for alert filter values.
@@ -58,9 +59,10 @@ export type AlertFilterType = boolean | number | string | string[] | undefined;
* Defines additional filter keys that can be used beyond the primary ones.
* Future Extensions: Additional attributes like 'tags' and 'plan' can be added here.
*/
-export type AlertAdditionalFilterKey = 'engineType' | 'tags'; // will be extended to have tags, plan etc.,
+export type AlertAdditionalFilterKey = 'endpoint' | 'engineType' | 'tags'; // will be extended to have tags, plan etc.,
export type AlertResourceFiltersProps =
+ | AlertsEndpointFilterProps
| AlertsEngineOptionProps
| AlertsRegionProps
| AlertsTagFilterProps;
diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts
index d3064ecbfea..9d542b0a451 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts
@@ -1,7 +1,9 @@
import { regionFactory } from '@linode/utilities';
import {
+ getEndpointOptions,
getFilteredResources,
+ getOfflineRegionFilteredResources,
getRegionOptions,
getRegionsIdRegionMap,
getSupportedRegionIds,
@@ -196,3 +198,65 @@ describe('getSupportedRegionIds', () => {
expect(result).toHaveLength(0);
});
});
+
+describe('getEndpointOptions', () => {
+ const mockResources: CloudPulseResources[] = [
+ { id: '1', endpoint: 'endpoint-a', region: 'us-east', label: 'r1' },
+ { id: '2', endpoint: 'endpoint-b', region: 'us-east', label: 'r2' },
+ { id: '3', endpoint: 'endpoint-a', region: 'us-west', label: 'r3' },
+ { id: '4', endpoint: undefined, region: 'us-west', label: 'r4' },
+ ];
+
+ it('returns all unique endpoints when isAdditionOrDeletionNeeded = true', () => {
+ const result = getEndpointOptions(mockResources, true);
+ expect(result).toEqual(['endpoint-a', 'endpoint-b']);
+ });
+
+ it('returns endpoints only for matching resourceIds when flag = false', () => {
+ const result = getEndpointOptions(mockResources, false, ['2', '4']);
+ expect(result).toEqual(['endpoint-b']);
+ });
+
+ it('returns empty array when no data provided', () => {
+ const result = getEndpointOptions(undefined, true);
+ expect(result).toEqual([]);
+ });
+
+ it('returns empty array when no resourceIds and flag = false', () => {
+ const result = getEndpointOptions(mockResources, false, []);
+ expect(result).toEqual([]);
+ });
+});
+
+describe('getOfflineRegionFilteredResources', () => {
+ const mockResources: CloudPulseResources[] = [
+ { id: '1', region: 'us-east', label: 'r1' },
+ { id: '2', region: 'us-west', label: 'r2' },
+ { id: '3', region: '', label: 'r3' },
+ ];
+
+ it('filters resources based on supportedRegionIds', () => {
+ const result = getOfflineRegionFilteredResources(mockResources, [
+ 'us-east',
+ ]);
+ expect(result).toEqual([{ id: '1', region: 'us-east', label: 'r1' }]);
+ });
+
+ it('returns empty array if no supported regions match', () => {
+ const result = getOfflineRegionFilteredResources(mockResources, [
+ 'eu-central',
+ ]);
+ expect(result).toEqual([]);
+ });
+
+ it('excludes resources with undefined region', () => {
+ const result = getOfflineRegionFilteredResources(mockResources, [
+ 'us-east',
+ 'us-west',
+ ]);
+ expect(result).toEqual([
+ { id: '1', region: 'us-east', label: 'r1' },
+ { id: '2', region: 'us-west', label: 'r2' },
+ ]);
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts
index ddcdae208a3..c0a202a7c07 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts
@@ -62,6 +62,10 @@ interface FilterResourceProps {
}
interface FilterRendererProps {
+ /**
+ * The endpoints to be displayed according the resources associated with alerts
+ */
+ endpointOptions: string[];
/**
* The filter to which the props needs to built for
*/
@@ -136,6 +140,32 @@ export const getRegionOptions = (
});
return Array.from(uniqueRegions);
};
+/**
+ * Builds a list of unique endpoint strings from CloudPulse resources.
+ *
+ * @param data - List of CloudPulse resources to extract endpoints from.
+ * @param isAdditionOrDeletionNeeded - Flag to include all resources regardless of `resourceIds`.
+ * @param resourceIds - Optional list of resource IDs to filter which endpoints are included.
+ * @returns A unique list of endpoint strings. Returns an empty array if no valid data.
+ */
+export const getEndpointOptions = (
+ data?: CloudPulseResources[],
+ isAdditionOrDeletionNeeded?: boolean,
+ resourceIds?: string[]
+): string[] => {
+ const isEmpty =
+ !data || (!isAdditionOrDeletionNeeded && !resourceIds?.length);
+
+ if (isEmpty) return [];
+
+ const uniqueEndpoints = new Set();
+ data.forEach(({ id, endpoint }) => {
+ if (isAdditionOrDeletionNeeded || resourceIds?.includes(String(id))) {
+ if (endpoint) uniqueEndpoints.add(endpoint);
+ }
+ });
+ return Array.from(uniqueEndpoints);
+};
/**
* Filters regions based on service type and returns their IDs.
@@ -242,6 +272,10 @@ const applyAdditionalFilter = (
return value.some((obj) => resourceValue.includes(obj));
}
+ // to cover the endpoint scenario where resourceValue is string and value can be string[]
+ if (Array.isArray(value) && typeof resourceValue === 'string') {
+ return resourceValue && value.includes(resourceValue);
+ }
return resourceValue === value;
});
};
@@ -319,10 +353,13 @@ export const getAlertResourceFilterProps = ({
filterKey,
handleFilterChange,
handleFilteredRegionsChange: handleSelectionChange,
+ endpointOptions,
regionOptions,
tagOptions,
}: FilterRendererProps): AlertResourceFiltersProps => {
switch (filterKey) {
+ case 'endpoint':
+ return { handleFilterChange, endpointOptions };
case 'engineType':
return { handleFilterChange };
case 'region':
@@ -333,3 +370,19 @@ export const getAlertResourceFilterProps = ({
return { handleFilterChange };
}
};
+
+/**
+ * Filters CloudPulse resources to include only those whose region
+ * is present in the given list of ACLP supported region IDs.
+ * @param resources - The unfiltered list of CloudPulse resources.
+ * @param supportedRegionIds - Region IDs that have ACLP support and required capabilities.
+ * @returns A filtered list of CloudPulse resources located in supported regions.
+ */
+export const getOfflineRegionFilteredResources = (
+ resources: CloudPulseResources[],
+ supportedRegionIds: string[]
+): CloudPulseResources[] => {
+ return resources.filter(
+ ({ region }) => region && supportedRegionIds.includes(region)
+ );
+};
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index e5aff744b51..b067c432890 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -1340,11 +1340,16 @@ export const handlers = [
region: 'us-mia',
s3_endpoint: 'us-mia-1.linodeobjects.com',
}),
- objectStorageBucketFactoryGen2.build({
+ objectStorageEndpointsFactory.build({
endpoint_type: 'E3',
region: 'ap-west',
s3_endpoint: 'ap-west-1.linodeobjects.com',
}),
+ objectStorageEndpointsFactory.build({
+ endpoint_type: 'E3',
+ region: 'us-iad',
+ s3_endpoint: 'us-iad-1.linodeobjects.com',
+ }),
];
return HttpResponse.json(makeResourcePage(endpoints));
}),
@@ -1446,26 +1451,49 @@ export const handlers = [
Math.random() * 4
)}` as ObjectStorageEndpointTypes;
- const buckets = objectStorageBucketFactoryGen2.buildList(1, {
- cluster: `${region}-1`,
- endpoint_type: randomEndpointType,
- hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`,
- label: `obj-bucket-${randomBucketNumber}`,
- region,
- });
+ const buckets =
+ region !== 'ap-west' && region !== 'us-iad'
+ ? objectStorageBucketFactoryGen2.buildList(1, {
+ cluster: `${region}-1`,
+ endpoint_type: randomEndpointType,
+ hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`,
+ label: `obj-bucket-${randomBucketNumber}`,
+ region,
+ })
+ : [];
if (region === 'ap-west') {
buckets.push(
objectStorageBucketFactoryGen2.build({
- cluster: `${region}-1`,
+ cluster: `ap-west-1`,
endpoint_type: 'E3',
s3_endpoint: 'ap-west-1.linodeobjects.com',
- hostname: `obj-bucket-${randomBucketNumber}.${region}.linodeobjects.com`,
- label: `obj-bucket-${randomBucketNumber}`,
+ hostname: `obj-bucket-804.ap-west.linodeobjects.com`,
+ label: `obj-bucket-804`,
+ region,
+ })
+ );
+ buckets.push(
+ objectStorageBucketFactoryGen2.build({
+ cluster: `ap-west-1`,
+ endpoint_type: 'E3',
+ s3_endpoint: 'ap-west-1.linodeobjects.com',
+ hostname: `obj-bucket-902.ap-west.linodeobjects.com`,
+ label: `obj-bucket-902`,
region,
})
);
}
-
+ if (region === 'us-iad')
+ buckets.push(
+ objectStorageBucketFactoryGen2.build({
+ cluster: `us-iad-1`,
+ endpoint_type: 'E3',
+ s3_endpoint: 'us-iad-1.linodeobjects.com',
+ hostname: `obj-bucket-230.us-iad.linodeobjects.com`,
+ label: `obj-bucket-230`,
+ region,
+ })
+ );
return HttpResponse.json({
data: buckets.slice(
(page - 1) * pageSize,
@@ -2958,6 +2986,13 @@ export const handlers = [
rules: [firewallMetricRulesFactory.build()],
},
}),
+ alertFactory.build({
+ id: 550,
+ label: 'Object Storage - testing',
+ type: 'user',
+ service_type: 'objectstorage',
+ entity_ids: ['obj-bucket-804.ap-west.linodeobjects.com'],
+ }),
];
return HttpResponse.json(makeResourcePage(alerts));
}),
@@ -2978,6 +3013,17 @@ export const handlers = [
})
);
}
+ if (params.id === '550' && params.serviceType === 'objectstorage') {
+ return HttpResponse.json(
+ alertFactory.build({
+ id: 550,
+ type: 'user',
+ label: 'object-storage -testing',
+ service_type: 'objectstorage',
+ entity_ids: ['obj-bucket-804.ap-west.linodeobjects.com'],
+ })
+ );
+ }
if (params.id !== undefined) {
return HttpResponse.json(
alertFactory.build({
@@ -3091,6 +3137,7 @@ export const handlers = [
alert: serviceAlertFactory.build({
evaluation_period_seconds: [300],
polling_interval_seconds: [300],
+ scope: ['entity'],
}),
});
diff --git a/packages/manager/src/queries/cloudpulse/queries.ts b/packages/manager/src/queries/cloudpulse/queries.ts
index 1ac6118c044..78f5cb06872 100644
--- a/packages/manager/src/queries/cloudpulse/queries.ts
+++ b/packages/manager/src/queries/cloudpulse/queries.ts
@@ -126,7 +126,6 @@ export const queryFactory = createQueryKeys(key, {
queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys
queryKey: ['linodes', params, filters],
};
-
case 'nodebalancer':
return nodebalancerQueries.nodebalancers._ctx.all(params, filters);
case 'objectstorage':
@@ -139,7 +138,6 @@ export const queryFactory = createQueryKeys(key, {
};
case 'volumes':
return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts
-
default:
return volumeQueries.lists._ctx.all(params, filters); // default to volumes
}
diff --git a/packages/utilities/src/__data__/regionsData.ts b/packages/utilities/src/__data__/regionsData.ts
index 6b1f916e968..371d18f1a66 100644
--- a/packages/utilities/src/__data__/regionsData.ts
+++ b/packages/utilities/src/__data__/regionsData.ts
@@ -28,7 +28,10 @@ export const regions: Region[] = [
},
site_type: 'core',
status: 'ok',
- monitors: { alerts: ['Cloud Firewall'], metrics: ['Object Storage'] },
+ monitors: {
+ alerts: ['Cloud Firewall', 'Object Storage'],
+ metrics: ['Object Storage'],
+ },
},
{
capabilities: [
@@ -114,7 +117,7 @@ export const regions: Region[] = [
},
site_type: 'core',
status: 'ok',
- monitors: { alerts: ['Linodes'], metrics: ['Linodes'] },
+ monitors: { alerts: ['Linodes', 'Object Storage'], metrics: ['Linodes'] },
},
{
capabilities: [
From 13cfb90d1a3967e09d90c2c450b16df70e31f16c Mon Sep 17 00:00:00 2001
From: Hana Xu <115299789+hana-akamai@users.noreply.github.com>
Date: Tue, 30 Sep 2025 09:44:40 -0400
Subject: [PATCH 37/54] upcoming: [M3-10614] - Check Region VPC capability for
VPC Create IPv6 prefix lengths (#12919)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description 📝
Display IPv6 prefix lengths in the VPC Create flow by checking Region VPC capability instead of hardcoded prefix lengths
## How to test 🧪
### Prerequisites
(How to setup test environment)
- Ensure the VPC IPv6 feature flag is on and your account has VPC IPv6 customer tags.
### Verification steps
(How to verify changes)
- [ ] Checkout this PR and point to devcloud locally
- [ ] Have the network tab open and go to the VPC Create page
- [ ] You should see a request to the `vpc-availability` endpoint
- [ ] Select a region and then select dual-stack as the IP Stack
- [ ] You should see a `VPC IPv6 Prefix Length` section with radio options for each IPv6 prefix length available
- [ ] The section won't show if there are no available prefixes or there is only one prefix (which we can assume to be /52). You can test this by pointing to prod locally and selecting the NO, Oslo region.
- Note: Since all regions should be supporting dual-stack VPC by end of October and current customers have probably been communicated which regions to use, we will not be disabling the selection card if there are no prefixes available (requests will just fail and an error is displayed)
```
pnpm test VPCTopSectionContent
```
---
.../pr-12919-added-1758910984747.md | 5 +
packages/api-v4/src/regions/regions.ts | 28 +++++-
packages/api-v4/src/regions/types.ts | 6 ++
.../pr-12919-changed-1758911173078.md | 5 +
.../VPCTopSectionContent.test.tsx | 62 +++++++++++--
.../FormComponents/VPCTopSectionContent.tsx | 92 ++++++++++---------
.../pr-12919-added-1758911037397.md | 5 +
packages/queries/src/regions/regions.ts | 42 ++++++++-
packages/queries/src/regions/requests.ts | 17 +++-
.../pr-12919-added-1758911126565.md | 5 +
packages/utilities/src/factories/regions.ts | 8 ++
11 files changed, 219 insertions(+), 56 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12919-added-1758910984747.md
create mode 100644 packages/manager/.changeset/pr-12919-changed-1758911173078.md
create mode 100644 packages/queries/.changeset/pr-12919-added-1758911037397.md
create mode 100644 packages/utilities/.changeset/pr-12919-added-1758911126565.md
diff --git a/packages/api-v4/.changeset/pr-12919-added-1758910984747.md b/packages/api-v4/.changeset/pr-12919-added-1758910984747.md
new file mode 100644
index 00000000000..039d2956a93
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12919-added-1758910984747.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Added
+---
+
+Region VPC availability types and endpoints ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/api-v4/src/regions/regions.ts b/packages/api-v4/src/regions/regions.ts
index 19ac6b8ca49..f217d0555b8 100644
--- a/packages/api-v4/src/regions/regions.ts
+++ b/packages/api-v4/src/regions/regions.ts
@@ -3,7 +3,7 @@ import Request, { setMethod, setParams, setURL, setXFilter } from '../request';
import { Region } from './types';
import type { Filter, ResourcePage as Page, Params } from '../types';
-import type { RegionAvailability } from './types';
+import type { RegionAvailability, RegionVPCAvailability } from './types';
/**
* getRegions
@@ -67,3 +67,29 @@ export const getRegionAvailability = (regionId: string) =>
),
setMethod('GET'),
);
+
+/**
+ * getRegionsVPCAvailabilities
+ *
+ * Returns the availability of VPC IPv6 prefix lengths for all regions.
+ */
+export const getRegionsVPCAvailabilities = (params?: Params, filter?: Filter) =>
+ Request>(
+ setURL(`${BETA_API_ROOT}/regions/vpc-availability`),
+ setMethod('GET'),
+ setParams(params),
+ setXFilter(filter),
+ );
+
+/**
+ * getRegionVPCAvailability
+ *
+ * Returns the availability of VPC IPv6 prefix lengths for a specified region.
+ */
+export const getRegionVPCAvailability = (regionId: string) =>
+ Request(
+ setURL(
+ `${BETA_API_ROOT}/regions/${encodeURIComponent(regionId)}/vpc-availability`,
+ ),
+ setMethod('GET'),
+ );
diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts
index 63f1fe5b4f6..4da65b28c95 100644
--- a/packages/api-v4/src/regions/types.ts
+++ b/packages/api-v4/src/regions/types.ts
@@ -69,6 +69,12 @@ export interface RegionAvailability {
region: string;
}
+export interface RegionVPCAvailability {
+ available: boolean; // True if Region has VPC capabilities
+ available_ipv6_prefix_lengths: number[];
+ region: string;
+}
+
type CountryCode = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE;
export type Country = Lowercase;
diff --git a/packages/manager/.changeset/pr-12919-changed-1758911173078.md b/packages/manager/.changeset/pr-12919-changed-1758911173078.md
new file mode 100644
index 00000000000..c96c13d855a
--- /dev/null
+++ b/packages/manager/.changeset/pr-12919-changed-1758911173078.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Check Region VPC availability for IPv6 prefix lengths instead of hardcoded prefix lengths ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
index 9dfa388b178..97345c9b573 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.test.tsx
@@ -1,9 +1,11 @@
+import { regionFactory, regionVPCAvailabilityFactory } from '@linode/utilities';
import { waitFor } from '@testing-library/react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { accountFactory } from 'src/factories';
+import { makeResourcePage } from 'src/mocks/serverHandlers';
import { http, HttpResponse, server } from 'src/mocks/testServer';
import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
@@ -45,7 +47,7 @@ describe('VPC Top Section form content', () => {
capabilities: ['VPC Dual Stack'],
});
- server.use(http.get('*/v4*/account', () => HttpResponse.json(account)));
+ server.use(http.get('*/account', () => HttpResponse.json(account)));
renderWithThemeAndHookFormContext({
component: ,
@@ -74,15 +76,38 @@ describe('VPC Top Section form content', () => {
expect(NetworkingIPStackRadios[1]).not.toBeChecked(); // Dual Stack
});
- it('renders VPC IPv6 Prefix Length options with /52 selected if Dual Stack is checked and the customer is enterprise', async () => {
+ it('renders VPC IPv6 Prefix Length options with /52 selected if the selected region has multiple prefix lengths available', async () => {
const account = accountFactory.build({
capabilities: ['VPC Dual Stack', 'VPC IPv6 Large Prefixes'],
});
- server.use(http.get('*/v4*/account', () => HttpResponse.json(account)));
+ server.use(http.get('*/account', () => HttpResponse.json(account)));
+ server.use(
+ http.get('*/regions/vpc-availability*', () =>
+ HttpResponse.json(
+ makeResourcePage([
+ regionVPCAvailabilityFactory.build({
+ region: 'us-east',
+ available_ipv6_prefix_lengths: [48, 52],
+ }),
+ ])
+ )
+ )
+ );
renderWithThemeAndHookFormContext({
- component: ,
+ component: (
+
+ ),
// @TODO VPC IPv6: Remove this flag check once VPC IPv6 is in GA
options: {
flags: {
@@ -99,6 +124,15 @@ describe('VPC Top Section form content', () => {
},
});
+ const regionSelect = screen.getByPlaceholderText('Select a Region');
+
+ await userEvent.click(regionSelect);
+ await userEvent.type(regionSelect, 'US, Newark, NJ (us-east)');
+ await waitFor(async () => {
+ const selectedRegionOption = screen.getByText('US, Newark, NJ (us-east)');
+ await userEvent.click(selectedRegionOption);
+ });
+
await waitFor(() => {
expect(screen.getByText('IP Stack')).toBeVisible();
});
@@ -110,16 +144,28 @@ describe('VPC Top Section form content', () => {
expect(screen.getByText('VPC IPv6 Prefix Length')).toBeVisible();
const IPv6CIDRRadios = screen.getAllByRole('radio');
- expect(IPv6CIDRRadios[2]).toBeChecked(); // /52
- expect(IPv6CIDRRadios[3]).not.toBeChecked(); // /48
+ expect(IPv6CIDRRadios[2]).not.toBeChecked(); // /48
+ expect(IPv6CIDRRadios[3]).toBeChecked(); // /52
});
- it('does not render VPC IPv6 Prefix Length options if the customer is not enterprise', async () => {
+ it('does not render VPC IPv6 Prefix Length options if there are none available or only /52 available', async () => {
const account = accountFactory.build({
capabilities: ['VPC Dual Stack'],
});
- server.use(http.get('*/v4*/account', () => HttpResponse.json(account)));
+ server.use(http.get('*/account', () => HttpResponse.json(account)));
+ server.use(
+ http.get('*/regions/vpc-availability*', () =>
+ HttpResponse.json(
+ makeResourcePage([
+ regionVPCAvailabilityFactory.build({
+ region: 'us-east',
+ available_ipv6_prefix_lengths: [52],
+ }),
+ ])
+ )
+ )
+ );
renderWithThemeAndHookFormContext({
component: ,
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
index 5911da09fa6..94440f7ce22 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
@@ -1,3 +1,4 @@
+import { useRegionsVPCAvailabilitiesQuery } from '@linode/queries';
import { useIsGeckoEnabled } from '@linode/shared';
import {
Box,
@@ -65,13 +66,21 @@ export const VPCTopSectionContent = (props: Props) => {
name: 'subnets',
});
- const subnets = useWatch({ control, name: 'subnets' });
- const vpcIPv6 = useWatch({ control, name: 'ipv6' });
+ const [subnets, vpcIPv6, regionId] = useWatch({
+ control,
+ name: ['subnets', 'ipv6', 'region'],
+ });
const { data: permissions } = usePermissions('account', ['create_vpc']);
- const { isDualStackEnabled, isDualStackSelected, isEnterpriseCustomer } =
- useVPCDualStack(vpcIPv6);
+ const { isDualStackEnabled, isDualStackSelected } = useVPCDualStack(vpcIPv6);
+
+ const { data: regionsVPCAvailabilities } =
+ useRegionsVPCAvailabilitiesQuery(isDualStackEnabled);
+
+ const availableRegionIPv6PrefixLengths = regionsVPCAvailabilities?.find(
+ (region) => region.region === regionId
+ )?.available_ipv6_prefix_lengths;
return (
<>
@@ -255,45 +264,42 @@ export const VPCTopSectionContent = (props: Props) => {
/>
)}
- {isDualStackSelected && isEnterpriseCustomer && (
- (
- field.onChange([{ range: value }])}
- value={field.value}
- >
-
- VPC IPv6 Prefix Length
-
- {errors.ipv6 && (
-
- )}
- <>
- }
- disabled={!permissions?.create_vpc}
- label="/52"
- value="/52"
- />
- }
- disabled={!permissions?.create_vpc}
- label="/48"
- value="/48"
- />
- >
-
- )}
- />
- )}
+ {isDualStackSelected &&
+ availableRegionIPv6PrefixLengths &&
+ availableRegionIPv6PrefixLengths.length > 1 && ( // Hide /52 if it's the only prefix length
+ (
+ field.onChange([{ range: value }])}
+ style={{ margin: 0 }}
+ value={field.value}
+ >
+
+ VPC IPv6 Prefix Length
+
+ {errors.ipv6 && (
+
+ )}
+ {availableRegionIPv6PrefixLengths.map((prefixLength) => (
+ }
+ disabled={!permissions?.create_vpc}
+ key={prefixLength}
+ label={`/${prefixLength}`}
+ value={`/${prefixLength}`}
+ />
+ ))}
+
+ )}
+ />
+ )}
>
);
};
diff --git a/packages/queries/.changeset/pr-12919-added-1758911037397.md b/packages/queries/.changeset/pr-12919-added-1758911037397.md
new file mode 100644
index 00000000000..488397793cd
--- /dev/null
+++ b/packages/queries/.changeset/pr-12919-added-1758911037397.md
@@ -0,0 +1,5 @@
+---
+"@linode/queries": Added
+---
+
+Region VPC availability queries ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/queries/src/regions/regions.ts b/packages/queries/src/regions/regions.ts
index 1377c5c4b6f..0a735b36aaf 100644
--- a/packages/queries/src/regions/regions.ts
+++ b/packages/queries/src/regions/regions.ts
@@ -1,4 +1,8 @@
-import { getRegion, getRegionAvailability } from '@linode/api-v4/lib/regions';
+import {
+ getRegion,
+ getRegionAvailability,
+ getRegionVPCAvailability,
+} from '@linode/api-v4/lib/regions';
import { getNewRegionLabel } from '@linode/utilities';
import { createQueryKeys } from '@lukemorales/query-key-factory';
import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -7,9 +11,14 @@ import { queryPresets } from '../base';
import {
getAllRegionAvailabilitiesRequest,
getAllRegionsRequest,
+ getAllRegionVPCAvailabilitiesRequest,
} from './requests';
-import type { Region, RegionAvailability } from '@linode/api-v4/lib/regions';
+import type {
+ Region,
+ RegionAvailability,
+ RegionVPCAvailability,
+} from '@linode/api-v4/lib/regions';
import type { APIError } from '@linode/api-v4/lib/types';
export const regionQueries = createQueryKeys('regions', {
@@ -23,6 +32,19 @@ export const regionQueries = createQueryKeys('regions', {
queryFn: () => getRegionAvailability(regionId),
queryKey: [regionId],
}),
+ vpc: {
+ contextQueries: {
+ all: {
+ queryFn: getAllRegionVPCAvailabilitiesRequest,
+ queryKey: null,
+ },
+ region: (regionId: string) => ({
+ queryFn: () => getRegionVPCAvailability(regionId),
+ queryKey: [regionId],
+ }),
+ },
+ queryKey: null,
+ },
},
queryKey: null,
},
@@ -80,3 +102,19 @@ export const useRegionAvailabilityQuery = (
enabled,
});
};
+
+export const useRegionsVPCAvailabilitiesQuery = (enabled: boolean = false) =>
+ useQuery({
+ ...regionQueries.availability._ctx.vpc._ctx.all,
+ enabled,
+ });
+
+export const useRegionVPCAvailabilityQuery = (
+ regionId: string,
+ enabled: boolean = false,
+) => {
+ return useQuery({
+ ...regionQueries.availability._ctx.vpc._ctx.region(regionId),
+ enabled,
+ });
+};
diff --git a/packages/queries/src/regions/requests.ts b/packages/queries/src/regions/requests.ts
index 490101e19c3..74e0575aaff 100644
--- a/packages/queries/src/regions/requests.ts
+++ b/packages/queries/src/regions/requests.ts
@@ -1,7 +1,15 @@
-import { getRegionAvailabilities, getRegions } from '@linode/api-v4';
+import {
+ getRegionAvailabilities,
+ getRegions,
+ getRegionsVPCAvailabilities,
+} from '@linode/api-v4';
import { getAll } from '@linode/utilities';
-import type { Region, RegionAvailability } from '@linode/api-v4';
+import type {
+ Region,
+ RegionAvailability,
+ RegionVPCAvailability,
+} from '@linode/api-v4';
export const getAllRegionsRequest = () =>
getAll((params) => getRegions(params))().then((data) => data.data);
@@ -10,3 +18,8 @@ export const getAllRegionAvailabilitiesRequest = () =>
getAll((params, filters) =>
getRegionAvailabilities(params, filters),
)().then((data) => data.data);
+
+export const getAllRegionVPCAvailabilitiesRequest = () =>
+ getAll((params, filters) =>
+ getRegionsVPCAvailabilities(params, filters),
+ )().then((data) => data.data);
diff --git a/packages/utilities/.changeset/pr-12919-added-1758911126565.md b/packages/utilities/.changeset/pr-12919-added-1758911126565.md
new file mode 100644
index 00000000000..6c09402824a
--- /dev/null
+++ b/packages/utilities/.changeset/pr-12919-added-1758911126565.md
@@ -0,0 +1,5 @@
+---
+"@linode/utilities": Added
+---
+
+Added `regionVPCAvailabilityFactory` in regions.ts ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/utilities/src/factories/regions.ts b/packages/utilities/src/factories/regions.ts
index 27b3b6ee165..adf4f746459 100644
--- a/packages/utilities/src/factories/regions.ts
+++ b/packages/utilities/src/factories/regions.ts
@@ -5,6 +5,7 @@ import type {
DNSResolvers,
Region,
RegionAvailability,
+ RegionVPCAvailability,
} from '@linode/api-v4/lib/regions/types';
export const resolverFactory = Factory.Sync.makeFactory({
@@ -59,3 +60,10 @@ export const regionAvailabilityFactory =
plan: 'g6-standard-7',
region: 'us-east',
});
+
+export const regionVPCAvailabilityFactory =
+ Factory.Sync.makeFactory({
+ available: true,
+ available_ipv6_prefix_lengths: [52],
+ region: 'us-east',
+ });
From 796c73587c4313c682a92bc05cf776fc68f93444 Mon Sep 17 00:00:00 2001
From: Nikhil Agrawal <165884194+nikhagra-akamai@users.noreply.github.com>
Date: Tue, 30 Sep 2025 21:22:08 +0530
Subject: [PATCH 38/54] change: [DI-27360] - Changed default preset to 1 hour
for cloudpulse date time range picker (#12915)
* change: [DI-27360] - Changed default preset to 1 hour for cloudpulse date time range picker
* change: [DI-26544] - Update cloud pulse metric request to handle invalid preset
* fix: [DI-26544] - Added method to get time based on presets
* fix: [DI-26544] - Updated logic to get latest time for the preset
* fix: [DI-26544] - Updated function documentation
* Added changeset
---
.../pr-12915-changed-1758901979976.md | 5 ++
.../Utils/CloudPulseDateTimePickerUtils.ts | 86 ++++++++++++++++++-
.../CloudPulse/Utils/CloudPulseWidgetUtils.ts | 9 +-
.../shared/CloudPulseDateTimeRangePicker.tsx | 7 +-
4 files changed, 100 insertions(+), 7 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12915-changed-1758901979976.md
diff --git a/packages/manager/.changeset/pr-12915-changed-1758901979976.md b/packages/manager/.changeset/pr-12915-changed-1758901979976.md
new file mode 100644
index 00000000000..3c632ced0c8
--- /dev/null
+++ b/packages/manager/.changeset/pr-12915-changed-1758901979976.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+ACLP: update default `ACLP Time Range Picker Preset` to `1 hour` ([#12915](https://github.com/linode/manager/pull/12915))
diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils.ts
index 4b7033363ec..75fb3dbfff2 100644
--- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils.ts
@@ -1,7 +1,17 @@
+/**
+ * Utility functions for handling date and time operations for CloudPulse.
+ */
+
import { DateTime } from 'luxon';
import type { DateTimeWithPreset } from '@linode/api-v4';
+/**
+ * Returns the default time duration, which is the last 30 minutes from the current time.
+ *
+ * @param timezone Optional timezone to use. If not provided, the local timezone is used.
+ * @returns An object containing start time, end time, preset, and timezone.
+ */
export const defaultTimeDuration = (timezone?: string): DateTimeWithPreset => {
const date = DateTime.now()
.set({ second: 0 })
@@ -9,12 +19,19 @@ export const defaultTimeDuration = (timezone?: string): DateTimeWithPreset => {
return {
end: date.toISO() ?? '',
- preset: 'last 30 minutes',
- start: date.minus({ minutes: 30 }).toISO() ?? '',
+ preset: 'last hour',
+ start: date.minus({ hours: 1 }).toISO() ?? '',
timeZone: timezone,
};
};
+/**
+ * Converts a date string to GMT timezone.
+ *
+ * @param date ISO date string to convert
+ * @param timeZone Optional timezone of the input date. If not provided, the local timezone is used.
+ * @returns ISO date string in GMT timezone (with 'Z' suffix)
+ */
export const convertToGmt = (date: string, timeZone?: string): string => {
const dateObject = DateTime.fromISO(date).setZone(
timeZone ?? DateTime.local().zoneName
@@ -23,3 +40,68 @@ export const convertToGmt = (date: string, timeZone?: string): string => {
return updatedDate.toISO()?.split('.')[0] + 'Z';
};
+
+/**
+ * Calculates the start and end times based on a preset time range.
+ *
+ * @param currentValue The current date time range with preset
+ * @param timeZone The timezone to use for calculations
+ * @returns An object with updated start and end dates based on the preset
+ */
+export function getTimeFromPreset(
+ currentValue: DateTimeWithPreset,
+ timeZone: string
+): DateTimeWithPreset {
+ const today = DateTime.now().setZone(timeZone);
+ const { start, end, preset } = currentValue;
+ let selectedPreset = preset;
+ let startDate: string;
+ let endDate: string;
+ switch (preset) {
+ case 'last 7 days':
+ startDate = today.minus({ days: 7 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+
+ case 'last 12 hours':
+ startDate = today.minus({ hours: 12 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ case 'last 30 days':
+ startDate = today.minus({ days: 30 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ case 'last 30 minutes':
+ startDate = today.minus({ minutes: 30 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ case 'last day':
+ startDate = today.minus({ days: 1 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ case 'last hour':
+ startDate = today.minus({ hours: 1 }).toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ case 'last month':
+ startDate = today.minus({ months: 1 }).startOf('month').toISO() ?? start;
+ endDate = today.minus({ months: 1 }).endOf('month').toISO() ?? end;
+ break;
+ case 'this month':
+ startDate = today.startOf('month').toISO() ?? start;
+ endDate = today.toISO() ?? end;
+ break;
+ default:
+ // Reset to provided values or empty strings if none provided
+ startDate = start;
+ endDate = end;
+ selectedPreset = 'reset';
+ }
+
+ return {
+ start: startDate,
+ end: endDate,
+ preset: selectedPreset,
+ timeZone,
+ };
+}
diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
index 15285321937..ee373ad8cc2 100644
--- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
+++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts
@@ -347,15 +347,16 @@ export const getCloudPulseMetricRequest = (
serviceType,
} = props;
const preset = duration.preset;
+ const presetDuration = getTimeDurationFromPreset(preset);
return {
absolute_time_duration:
- preset !== 'reset' && preset !== 'this month' && preset !== 'last month'
- ? undefined
- : { end: duration.end, start: duration.start },
+ presetDuration === undefined
+ ? { end: duration.end, start: duration.start }
+ : undefined,
entity_ids: getEntityIds(resources, entityIds, widget, serviceType),
filters: undefined,
group_by: !groupBy?.length ? undefined : groupBy,
- relative_time_duration: getTimeDurationFromPreset(preset),
+ relative_time_duration: presetDuration,
metrics: [
{
aggregate_function: widget.aggregate_function,
diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx
index d1616a870a4..f63645f5688 100644
--- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx
+++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx
@@ -3,7 +3,10 @@ import { DateTimeRangePicker } from '@linode/ui';
import { DateTime } from 'luxon';
import React from 'react';
-import { defaultTimeDuration } from '../Utils/CloudPulseDateTimePickerUtils';
+import {
+ defaultTimeDuration,
+ getTimeFromPreset,
+} from '../Utils/CloudPulseDateTimePickerUtils';
import type { DateTimeWithPreset, FilterValue } from '@linode/api-v4';
@@ -37,6 +40,8 @@ export const CloudPulseDateTimeRangePicker = React.memo(
if (!defaultSelected) {
defaultSelected = defaultTimeDuration(timezone);
+ } else {
+ defaultSelected = getTimeFromPreset(defaultSelected, timezone);
}
React.useEffect(() => {
From 0c73abb75957b51f753b81956c85b255802476ba Mon Sep 17 00:00:00 2001
From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
Date: Tue, 30 Sep 2025 18:12:49 +0200
Subject: [PATCH 39/54] fix: [UIE-9286] - User Menu: Hide IAM Beta badge for LA
(#12933)
* fix: [UIE-9286] - User Menu: Hide IAM Beta badge for LA
* Added changeset: IAM: Hide IAM Beta badge in User Menu for LA
---
packages/manager/.changeset/pr-12933-fixed-1759235571094.md | 5 +++++
.../src/features/TopMenu/UserMenu/UserMenuPopover.tsx | 4 ++--
2 files changed, 7 insertions(+), 2 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12933-fixed-1759235571094.md
diff --git a/packages/manager/.changeset/pr-12933-fixed-1759235571094.md b/packages/manager/.changeset/pr-12933-fixed-1759235571094.md
new file mode 100644
index 00000000000..9273a9c406a
--- /dev/null
+++ b/packages/manager/.changeset/pr-12933-fixed-1759235571094.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+IAM: Hide IAM Beta badge in User Menu for LA ([#12933](https://github.com/linode/manager/pull/12933))
diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
index aed316a400e..8559b58fc9a 100644
--- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
+++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
@@ -40,7 +40,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => {
const { data: account } = useAccount();
const { data: profile } = useProfile();
- const { isIAMEnabled } = useIsIAMEnabled();
+ const { isIAMEnabled, isIAMBeta } = useIsIAMEnabled();
const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({
globalGrantType: 'child_account_access',
@@ -114,7 +114,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => {
: iamRbacPrimaryNavChanges && !isIAMEnabled
? '/users'
: '/account/users',
- isBeta: iamRbacPrimaryNavChanges && isIAMEnabled,
+ isBeta: iamRbacPrimaryNavChanges && isIAMBeta,
},
{
display: 'Quotas',
From c63d15b6db44795218e7d5cc2e23f0259e30420b Mon Sep 17 00:00:00 2001
From: kagora-akamai
Date: Tue, 30 Sep 2025 18:36:27 +0200
Subject: [PATCH 40/54] upcoming: [DPS-34879] - Delivery bugfixes after
devcloud release (#12898)
---
...r-12898-upcoming-features-1758553903759.md | 5 ++
packages/api-v4/src/delivery/destinations.ts | 11 ++-
packages/api-v4/src/delivery/types.ts | 11 +--
...r-12898-upcoming-features-1758554025685.md | 5 ++
.../src/components/PrimaryNav/PrimaryNav.tsx | 4 +-
packages/manager/src/factories/delivery.ts | 2 +-
.../DestinationForm/DestinationEdit.tsx | 4 +-
.../Delivery/StreamFormDelivery.test.tsx | 3 +
.../Delivery/StreamFormDelivery.tsx | 14 ++-
.../Streams/StreamForm/StreamCreate.tsx | 3 +-
.../Streams/StreamForm/StreamEdit.test.tsx | 20 +++++
.../Streams/StreamForm/StreamEdit.tsx | 20 ++---
.../Streams/StreamForm/StreamForm.tsx | 8 +-
.../StreamForm/StreamFormGeneralInfo.tsx | 2 +-
.../Delivery/Streams/StreamForm/types.ts | 8 +-
.../Delivery/Streams/StreamsLanding.test.tsx | 6 +-
.../Delivery/Streams/StreamsLanding.tsx | 4 +-
.../src/features/Delivery/deliveryUtils.ts | 24 ++---
.../mocks/presets/crud/handlers/delivery.ts | 2 +-
...r-12898-upcoming-features-1758553940234.md | 5 ++
packages/validation/src/delivery.schema.ts | 90 +++++++++++++------
21 files changed, 170 insertions(+), 81 deletions(-)
create mode 100644 packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
create mode 100644 packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
create mode 100644 packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
diff --git a/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md b/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
new file mode 100644
index 00000000000..975a091c55a
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Upcoming Features
+---
+
+Logs Delivery Stream details type update and UpdateDestinationPayload update according to API docs ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/api-v4/src/delivery/destinations.ts b/packages/api-v4/src/delivery/destinations.ts
index 7069a23288b..1dcd4d6442c 100644
--- a/packages/api-v4/src/delivery/destinations.ts
+++ b/packages/api-v4/src/delivery/destinations.ts
@@ -1,4 +1,7 @@
-import { destinationSchema } from '@linode/validation';
+import {
+ createDestinationSchema,
+ updateDestinationSchema,
+} from '@linode/validation';
import { BETA_API_ROOT } from '../constants';
import Request, {
@@ -49,7 +52,7 @@ export const getDestinations = (params?: Params, filter?: Filter) =>
*/
export const createDestination = (data: CreateDestinationPayload) =>
Request(
- setData(data, destinationSchema),
+ setData(data, createDestinationSchema),
setURL(`${BETA_API_ROOT}/monitor/streams/destinations`),
setMethod('POST'),
);
@@ -65,7 +68,7 @@ export const updateDestination = (
data: UpdateDestinationPayload,
) =>
Request(
- setData(data, destinationSchema),
+ setData(data, updateDestinationSchema),
setURL(
`${BETA_API_ROOT}/monitor/streams/destinations/${encodeURIComponent(destinationId)}`,
),
@@ -92,7 +95,7 @@ export const deleteDestination = (destinationId: number) =>
*/
export const verifyDestination = (data: CreateDestinationPayload) =>
Request(
- setData(data, destinationSchema),
+ setData(data, createDestinationSchema),
setURL(`${BETA_API_ROOT}/monitor/streams/destinations/verify`),
setMethod('POST'),
);
diff --git a/packages/api-v4/src/delivery/types.ts b/packages/api-v4/src/delivery/types.ts
index 565686bb810..b9e6fbc48f4 100644
--- a/packages/api-v4/src/delivery/types.ts
+++ b/packages/api-v4/src/delivery/types.ts
@@ -21,7 +21,7 @@ export interface AuditData {
export interface Stream extends AuditData {
destinations: Destination[];
- details: StreamDetails;
+ details: StreamDetailsType;
id: number;
label: string;
primary_destination_id: number;
@@ -36,6 +36,8 @@ export interface StreamDetails {
is_auto_add_all_clusters_enabled?: boolean;
}
+export type StreamDetailsType = null | StreamDetails;
+
export const destinationType = {
CustomHttps: 'custom_https',
LinodeObjectStorage: 'linode_object_storage',
@@ -103,7 +105,7 @@ interface CustomHeader {
export interface CreateStreamPayload {
destinations: number[];
- details: StreamDetails;
+ details?: StreamDetailsType;
label: string;
status?: StreamStatus;
type: StreamType;
@@ -111,10 +113,9 @@ export interface CreateStreamPayload {
export interface UpdateStreamPayload {
destinations: number[];
- details: StreamDetails;
+ details?: StreamDetailsType;
label: string;
status: StreamStatus;
- type: StreamType;
}
export interface UpdateStreamPayloadWithId extends UpdateStreamPayload {
@@ -136,7 +137,7 @@ export interface CreateDestinationPayload {
type: DestinationType;
}
-export type UpdateDestinationPayload = CreateDestinationPayload;
+export type UpdateDestinationPayload = Omit;
export interface UpdateDestinationPayloadWithId
extends UpdateDestinationPayload {
diff --git a/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md b/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
new file mode 100644
index 00000000000..b886811a5b7
--- /dev/null
+++ b/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Logs Delivery fixes after devcloud release ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
index 03aa8db3ff5..e618d8103a0 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
@@ -40,7 +40,6 @@ export type NavEntity =
| 'Cloud Load Balancers'
| 'Dashboard'
| 'Databases'
- | 'Delivery'
| 'Domains'
| 'Firewalls'
| 'Help & Support'
@@ -49,6 +48,7 @@ export type NavEntity =
| 'Kubernetes'
| 'Linodes'
| 'Login History'
+ | 'Logs'
| 'Longview'
| 'Maintenance'
| 'Managed'
@@ -240,7 +240,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
to: '/longview',
},
{
- display: 'Delivery',
+ display: 'Logs',
hide: !flags.aclpLogs?.enabled,
to: '/logs/delivery',
isBeta: flags.aclpLogs?.beta,
diff --git a/packages/manager/src/factories/delivery.ts b/packages/manager/src/factories/delivery.ts
index 3fbb843b8df..da8c1f2400b 100644
--- a/packages/manager/src/factories/delivery.ts
+++ b/packages/manager/src/factories/delivery.ts
@@ -27,7 +27,7 @@ export const streamFactory = Factory.Sync.makeFactory({
destinations: Factory.each(() => [
{ ...destinationFactory.build(), id: 123 },
]),
- details: {},
+ details: null,
updated: '2025-07-30',
updated_by: 'username',
id: Factory.each((id) => id),
diff --git a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
index b040d6aebbf..520d90093e4 100644
--- a/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
+++ b/packages/manager/src/features/Delivery/Destinations/DestinationForm/DestinationEdit.tsx
@@ -4,7 +4,7 @@ import {
useDestinationQuery,
useUpdateDestinationMutation,
} from '@linode/queries';
-import { Box, CircleProgress, ErrorState } from '@linode/ui';
+import { Box, CircleProgress, ErrorState, omitProps } from '@linode/ui';
import { destinationFormSchema } from '@linode/validation';
import { useNavigate, useParams } from '@tanstack/react-router';
import { enqueueSnackbar } from 'notistack';
@@ -80,7 +80,7 @@ export const DestinationEdit = () => {
const onSubmit = () => {
const destination: UpdateDestinationPayloadWithId = {
id: destinationId,
- ...form.getValues(),
+ ...omitProps(form.getValues(), ['type']),
};
updateDestination(destination)
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx
index 45b5891553f..e7b78bebf3f 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx
@@ -86,6 +86,9 @@ describe('StreamFormDelivery', () => {
label: '',
type: destinationType.LinodeObjectStorage,
},
+ stream: {
+ destinations: [],
+ },
},
},
});
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 d880b6220a7..e2179724e3d 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Delivery/StreamFormDelivery.tsx
@@ -67,11 +67,19 @@ export const StreamFormDelivery = () => {
name: 'stream.destinations',
});
- const destinationNameFilterOptions = createFilterOptions();
+ const destinationNameFilterOptions = createFilterOptions({
+ stringify: (destination) => destination.label,
+ });
const findDestination = (id: number) =>
destinations?.find((destination) => destination.id === id);
+ const restDestinationForm = () => {
+ Object.values(controlPaths).forEach((controlPath) =>
+ setValue(controlPath, '')
+ );
+ };
+
const getDestinationForm = () => (
<>
{
onChange={(_, newValue) => {
const id = newValue?.id;
+ if (id === undefined && selectedDestinations.length > 0) {
+ restDestinationForm();
+ }
+
setValue('stream.destinations', id ? [id] : []);
const selectedDestination = id ? findDestination(id) : undefined;
if (selectedDestination) {
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
index 688227d3a02..590f72dd1ab 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamCreate.tsx
@@ -33,7 +33,8 @@ export const StreamCreate = () => {
defaultValues: {
stream: {
type: streamType.AuditLogs,
- details: {},
+ details: null,
+ destinations: [],
},
destination: {
type: destinationType.LinodeObjectStorage,
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 7520ac662b1..d85749da1b2 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.test.tsx
@@ -97,6 +97,26 @@ describe('StreamEdit', () => {
{ exact: false }
);
await userEvent.click(createNewTestDestination);
+ const hostInput = screen.getByLabelText('Host');
+ await waitFor(() => {
+ expect(hostInput).toBeDefined();
+ });
+ await userEvent.type(hostInput, 'Test');
+ const bucketInput = screen.getByLabelText('Bucket');
+ await userEvent.type(bucketInput, 'Test');
+ const regionAutocomplete = screen.getByLabelText('Region');
+ await userEvent.click(regionAutocomplete);
+ await userEvent.type(regionAutocomplete, 'US, Chi');
+ const chicagoRegion = await screen.findByText(
+ 'US, Chicago, IL (us-ord)'
+ );
+ await userEvent.click(chicagoRegion);
+ const accessKeyIDInput = screen.getByLabelText('Access Key ID');
+ await userEvent.type(accessKeyIDInput, 'Test');
+ const secretAccessKeyInput = screen.getByLabelText('Secret Access Key');
+ await userEvent.type(secretAccessKeyInput, 'Test');
+ const logPathPrefixInput = screen.getByLabelText('Log Path Prefix');
+ await userEvent.type(logPathPrefixInput, 'Test');
};
describe('when form properly filled out and Test Connection button clicked and connection verified positively', () => {
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
index f9bb8b845f6..889280b9e48 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamEdit.tsx
@@ -51,7 +51,8 @@ export const StreamEdit = () => {
defaultValues: {
stream: {
type: streamType.AuditLogs,
- details: {},
+ details: null,
+ destinations: [],
},
destination: {
type: destinationType.LinodeObjectStorage,
@@ -66,15 +67,14 @@ export const StreamEdit = () => {
});
useEffect(() => {
- if (stream) {
- const details =
- Object.keys(stream.details).length > 0
- ? {
- is_auto_add_all_clusters_enabled: false,
- cluster_ids: [],
- ...stream.details,
- }
- : {};
+ if (stream && destinations) {
+ const details = stream.details
+ ? {
+ is_auto_add_all_clusters_enabled: false,
+ cluster_ids: [],
+ ...stream.details,
+ }
+ : null;
const streamsDestinationIds = stream.destinations.map(({ id }) => id);
const destination = destinations?.find(
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
index 5e65f6341ff..d6cf126a6f3 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/StreamForm.tsx
@@ -27,7 +27,6 @@ import { StreamFormDelivery } from 'src/features/Delivery/Streams/StreamForm/Del
import { StreamFormClusters } from './Clusters/StreamFormClusters';
import { StreamFormGeneralInfo } from './StreamFormGeneralInfo';
-import type { UpdateDestinationPayload } from '@linode/api-v4';
import type { FormMode } from 'src/features/Delivery/Shared/types';
import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types';
@@ -87,9 +86,7 @@ export const StreamForm = (props: StreamFormProps) => {
let destinationId = destinations?.[0];
if (!destinationId) {
try {
- const destinationPayload:
- | CreateDestinationPayload
- | UpdateDestinationPayload = {
+ const destinationPayload: CreateDestinationPayload = {
...destination,
details: getDestinationPayloadDetails(destination.details),
};
@@ -133,7 +130,6 @@ export const StreamForm = (props: StreamFormProps) => {
await updateStream({
id: streamId,
label,
- type,
status: status as StreamStatus,
destinations: [destinationId],
details: payloadDetails,
@@ -186,7 +182,7 @@ export const StreamForm = (props: StreamFormProps) => {
{
if (value === streamType.LKEAuditLogs) {
setValue('stream.details.is_auto_add_all_clusters_enabled', false);
} else {
- setValue('stream.details', {});
+ setValue('stream.details', null);
}
};
diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts
index e205160f931..232d79b61f4 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts
+++ b/packages/manager/src/features/Delivery/Streams/StreamForm/types.ts
@@ -1,7 +1,11 @@
-import type { CreateStreamPayload } from '@linode/api-v4';
+import type { CreateStreamPayload, StreamDetailsType } from '@linode/api-v4';
import type { DestinationFormType } from 'src/features/Delivery/Shared/types';
+export interface StreamFromType extends Omit {
+ details: StreamDetailsType;
+}
+
export interface StreamAndDestinationFormType {
destination: DestinationFormType;
- stream: CreateStreamPayload;
+ stream: StreamFromType;
}
diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx
index 90fd9d2fbb0..dbf71d20663 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.test.tsx
@@ -186,8 +186,7 @@ describe('Streams Landing Table', () => {
status: 'inactive',
label: 'Stream 1',
destinations: [123],
- details: {},
- type: 'audit_logs',
+ details: null,
});
});
});
@@ -209,8 +208,7 @@ describe('Streams Landing Table', () => {
status: 'active',
label: 'Stream 1',
destinations: [123],
- details: {},
- type: 'audit_logs',
+ details: null,
});
});
});
diff --git a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx
index 4a6968edb04..1e27f0ccc67 100644
--- a/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx
+++ b/packages/manager/src/features/Delivery/Streams/StreamsLanding.tsx
@@ -63,7 +63,7 @@ export const StreamsLanding = () => {
label: { '+contains': search?.label },
}),
...(search?.status !== undefined && {
- status: { '+contains': search?.status },
+ status: search?.status,
}),
};
@@ -147,7 +147,6 @@ export const StreamsLanding = () => {
destinations,
details,
label,
- type,
status,
}: Stream) => {
updateStream({
@@ -155,7 +154,6 @@ export const StreamsLanding = () => {
destinations: destinations.map(({ id: destinationId }) => destinationId),
details,
label,
- type,
status:
status === streamStatus.Active
? streamStatus.Inactive
diff --git a/packages/manager/src/features/Delivery/deliveryUtils.ts b/packages/manager/src/features/Delivery/deliveryUtils.ts
index 7f7e3385cec..6d11130a22d 100644
--- a/packages/manager/src/features/Delivery/deliveryUtils.ts
+++ b/packages/manager/src/features/Delivery/deliveryUtils.ts
@@ -1,7 +1,11 @@
import {
type Destination,
+ type DestinationDetails,
+ type DestinationDetailsPayload,
isEmpty,
type Stream,
+ type StreamDetailsType,
+ type StreamType,
streamType,
} from '@linode/api-v4';
import { omitProps } from '@linode/ui';
@@ -11,12 +15,6 @@ import {
streamTypeOptions,
} from 'src/features/Delivery/Shared/types';
-import type {
- DestinationDetails,
- DestinationDetailsPayload,
- StreamDetails,
- StreamType,
-} from '@linode/api-v4';
import type {
FormMode,
LabelValueOption,
@@ -36,19 +34,21 @@ export const isFormInEditMode = (mode: FormMode) => mode === 'edit';
export const getStreamPayloadDetails = (
type: StreamType,
- details: StreamDetails
-): StreamDetails => {
- let payloadDetails: StreamDetails = {};
+ details: StreamDetailsType
+): StreamDetailsType => {
+ if (!details) {
+ return null;
+ }
if (!isEmpty(details) && type === streamType.LKEAuditLogs) {
if (details.is_auto_add_all_clusters_enabled) {
- payloadDetails = omitProps(details, ['cluster_ids']);
+ return omitProps(details, ['cluster_ids']);
} else {
- payloadDetails = omitProps(details, ['is_auto_add_all_clusters_enabled']);
+ return omitProps(details, ['is_auto_add_all_clusters_enabled']);
}
}
- return payloadDetails;
+ return null;
};
export const getDestinationPayloadDetails = (
diff --git a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
index 2d111289e32..b8dcf4a8cd0 100644
--- a/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
+++ b/packages/manager/src/mocks/presets/crud/handlers/delivery.ts
@@ -73,7 +73,7 @@ export const createStreams = (mockState: MockState) => [
destinations: payload['destinations'].map((destinationId: number) =>
destinations?.find(({ id }) => id === destinationId)
),
- details: payload['details'],
+ details: payload['details'] ?? null,
created: DateTime.now().toISO(),
updated: DateTime.now().toISO(),
});
diff --git a/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md b/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
new file mode 100644
index 00000000000..b30703f8269
--- /dev/null
+++ b/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Upcoming Features
+---
+
+Logs Delivery Stream and Destination details validation change for Update schemas ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/validation/src/delivery.schema.ts b/packages/validation/src/delivery.schema.ts
index f027add7128..28203983c26 100644
--- a/packages/validation/src/delivery.schema.ts
+++ b/packages/validation/src/delivery.schema.ts
@@ -1,4 +1,4 @@
-import { array, boolean, mixed, number, object, string } from 'yup';
+import { array, boolean, lazy, mixed, number, object, string } from 'yup';
import type { InferType, MixedSchema, Schema } from 'yup';
@@ -99,7 +99,7 @@ const destinationSchemaBase = object().shape({
export const destinationFormSchema = destinationSchemaBase;
-export const destinationSchema = destinationSchemaBase.shape({
+export const createDestinationSchema = destinationSchemaBase.shape({
details: mixed<
| InferType
| InferType
@@ -113,6 +113,30 @@ export const destinationSchema = destinationSchemaBase.shape({
}),
});
+export const updateDestinationSchema = createDestinationSchema
+ .omit(['type'])
+ .shape({
+ details: lazy((value) => {
+ if ('bucket_name' in value) {
+ return linodeObjectStorageDetailsPayloadSchema.noUnknown(
+ 'Object contains unknown fields for Linode Object Storage Details.',
+ );
+ }
+ if ('client_certificate_details' in value) {
+ return customHTTPsDetailsSchema.noUnknown(
+ 'Object contains unknown fields for Custom HTTPS Details.',
+ );
+ }
+
+ // fallback schema: force error
+ return mixed().test({
+ name: 'details-schema',
+ message: 'Details object does not match any known schema.',
+ test: () => false,
+ });
+ }),
+ });
+
// Logs Delivery Stream
const streamDetailsBase = object({
@@ -132,13 +156,13 @@ const streamDetailsSchema = streamDetailsBase.test(
},
);
-const detailsShouldBeEmpty = (schema: MixedSchema) =>
+const detailsShouldNotExistOrBeNull = (schema: MixedSchema) =>
schema
- .defined()
+ .nullable()
.test(
- 'details-should-be-empty',
- 'Empty details for type `audit_logs`',
- (value) => Object.keys(value).length === 0,
+ 'details-should-not-exist',
+ 'Details should be null or no details passed for type `audit_logs`',
+ (value, ctx) => !('details' in ctx) || value === null,
);
const streamSchemaBase = object({
@@ -151,33 +175,47 @@ const streamSchemaBase = object({
.oneOf(['audit_logs', 'lke_audit_logs'])
.required('Stream type is required.'),
destinations: array().of(number().defined()).ensure().min(1).required(),
- details: mixed | object>()
- .when('type', {
- is: 'lke_audit_logs',
- then: () => streamDetailsSchema.required(),
- otherwise: detailsShouldBeEmpty,
- })
- .required(),
+ details: mixed().when('type', {
+ is: 'lke_audit_logs',
+ then: () => streamDetailsSchema.required(),
+ otherwise: detailsShouldNotExistOrBeNull,
+ }),
});
export const createStreamSchema = streamSchemaBase;
-export const updateStreamSchema = streamSchemaBase.shape({
- status: mixed<'active' | 'inactive'>()
- .oneOf(['active', 'inactive'])
- .required(),
-});
+export const updateStreamSchema = streamSchemaBase
+ .omit(['type'])
+ .shape({
+ status: mixed<'active' | 'inactive'>()
+ .oneOf(['active', 'inactive'])
+ .required(),
+ details: lazy((value) => {
+ if (
+ value &&
+ typeof value === 'object' &&
+ ('cluster_ids' in value || 'is_auto_add_all_clusters_enabled' in value)
+ ) {
+ return streamDetailsSchema.required();
+ }
+
+ // fallback schema: detailsShouldNotExistOrBeNull
+ return detailsShouldNotExistOrBeNull(mixed());
+ }),
+ })
+ .noUnknown('Object contains unknown fields');
export const streamAndDestinationFormSchema = object({
stream: streamSchemaBase.shape({
destinations: array().of(number().required()).required(),
- details: mixed | object>()
- .when('type', {
- is: 'lke_audit_logs',
- then: () => streamDetailsBase.required(),
- otherwise: detailsShouldBeEmpty,
- })
- .required(),
+ details: mixed().when('type', {
+ is: 'lke_audit_logs',
+ then: () => streamDetailsBase.required(),
+ otherwise: (schema) =>
+ schema
+ .nullable()
+ .equals([null], 'Details must be null for audit_logs type'),
+ }) as Schema | null>,
}),
destination: destinationFormSchema.defined().when('stream.destinations', {
is: (value: never[]) => !value?.length,
From 20bd0961507bb8a534b093d7a24324d4b0919094 Mon Sep 17 00:00:00 2001
From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com>
Date: Tue, 30 Sep 2025 12:50:51 -0400
Subject: [PATCH 41/54] chore: [M3-10651] - Update CODEOWNERS.md (#12928)
* Update codeowners for teams
* absolute paths
* Update CODEOWNERS
Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
* Update codeowners iam
* Add dbaas
* Missing IAM path
---------
Co-authored-by: Jaalah Ramos
Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
---
CODEOWNERS | 45 ++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 42 insertions(+), 3 deletions(-)
diff --git a/CODEOWNERS b/CODEOWNERS
index 8b6b2774004..6a6ef68f7f7 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,5 +1,44 @@
-# Default code owners
-* @linode/frontend
+# UI Package
+/packages/ui @linode/ui-platform-design-systems
-# Frontend SDET code owners for Cypress tests
+# Metrics & Alerts
+/packages/api-v4/src/cloudpulse @linode/metrics-alerts
+/packages/validation/src/cloudpulse.schema.ts @linode/metrics-alerts
+/packages/manager/cypress/e2e/core/cloudpulse @linode/metrics-alerts
+/packages/manager/cypress/support/util/cloudpulse.ts @linode/metrics-alerts
+/packages/manager/cypress/support/constants/cloudpulse.ts @linode/metrics-alerts
+/packages/manager/cypress/support/intercepts/cloudpulse.ts @linode/metrics-alerts
+/packages/manager/src/routes/alerts @linode/metrics-alerts
+/packages/manager/src/routes/metrics @linode/metrics-alerts
+/packages/manager/src/factories/cloudpulse @linode/metrics-alerts
+/packages/manager/src/features/CloudPulse @linode/metrics-alerts
+/packages/manager/src/queries/cloudpulse @linode/metrics-alerts
+
+# IAM
+/packages/api-v4/src/iam @linode/iam
+/packages/manager/src/routes/IAM @linode/iam
+/packages/manager/cypress/component/features/IAM @linode/iam
+/packages/queries/src/iam @linode/iam
+/packages/manager/src/features/IAM @linode/iam
+/packages/manager/src/mocks/presets/crud/seeds/delegation.ts @linode/iam
+/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @linode/iam
+/packages/manager/src/mocks/presets/crud/delegation.ts @linode/iam
+/packages/utilities/src/factories/delegation.ts @linode/iam
+
+# DBaaS
+/packages/manager/src/features/Databases @linode/dbaas-ui
+/packages/manager/src/routes/databases @linode/dbaas-ui
+/packages/manager/src/queries/databases @linode/dbaas-ui
+/packages/manager/src/factories/databases.ts @linode/dbaas-ui
+/packages/queries/src/databases @linode/dbaas-ui
+/packages/api-v4/src/databases @linode/dbaas-ui
+/packages/validation/src/databases.schema.ts @linode/dbaas-ui
+/packages/manager/cypress/e2e/core/databases @linode/dbaas-ui
+/packages/manager/cypress/support/constants/databases.ts @linode/dbaas-ui
+/packages/manager/cypress/support/intercepts/databases.ts @linode/dbaas-ui
+
+# Cypress E2E Tests
/packages/manager/cypress/ @linode/frontend-sdet
+
+# Default Team
+* @linode/cloud-manager-code-reviewers
\ No newline at end of file
From 2edbcf21419ab1d7528ce920bd92be8de0cbe977 Mon Sep 17 00:00:00 2001
From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com>
Date: Thu, 2 Oct 2025 03:46:15 -0400
Subject: [PATCH 42/54] Move broadest/catch-all codeowner rules to top of file
(#12941)
---
CODEOWNERS | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
diff --git a/CODEOWNERS b/CODEOWNERS
index 6a6ef68f7f7..b5eef9f001a 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,3 +1,15 @@
+# Default Team
+# This is a catch all: any change that is not captured by a codeowner rule
+# below will result in`@linode/cloud-manager-code-reviewers` being assigned
+# for PR review.
+* @linode/cloud-manager-code-reviewers
+
+# Cypress E2E Tests
+# This is also a catch all: any change to E2E tests outside of the team-owned
+# files and directories will result in `@linode/frontend-sdet` being assigned
+# for PR review.
+/packages/manager/cypress/ @linode/frontend-sdet
+
# UI Package
/packages/ui @linode/ui-platform-design-systems
@@ -36,9 +48,3 @@
/packages/manager/cypress/e2e/core/databases @linode/dbaas-ui
/packages/manager/cypress/support/constants/databases.ts @linode/dbaas-ui
/packages/manager/cypress/support/intercepts/databases.ts @linode/dbaas-ui
-
-# Cypress E2E Tests
-/packages/manager/cypress/ @linode/frontend-sdet
-
-# Default Team
-* @linode/cloud-manager-code-reviewers
\ No newline at end of file
From 62f16d633b5d81f7d67b5a094787fba95411aaed Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Thu, 2 Oct 2025 09:51:48 +0200
Subject: [PATCH 43/54] fix: [UIE-9289] - Use abs value for Assign User
Autocomplete next fetch (#12925)
* Use abs value for handleScroll
* Added changeset: Use abs value for Assign User Autocomplete next fetch
---
.../.changeset/pr-12925-fixed-1759145046490.md | 5 +++++
.../Roles/RolesTable/AssignSelectedRolesDrawer.tsx | 13 ++++++++-----
2 files changed, 13 insertions(+), 5 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12925-fixed-1759145046490.md
diff --git a/packages/manager/.changeset/pr-12925-fixed-1759145046490.md b/packages/manager/.changeset/pr-12925-fixed-1759145046490.md
new file mode 100644
index 00000000000..7c7f054b912
--- /dev/null
+++ b/packages/manager/.changeset/pr-12925-fixed-1759145046490.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Use abs value for Assign User Autocomplete next fetch ([#12925](https://github.com/linode/manager/pull/12925))
diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx
index b803c17de0a..40fec42a1ea 100644
--- a/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx
+++ b/packages/manager/src/features/IAM/Roles/RolesTable/AssignSelectedRolesDrawer.tsx
@@ -143,11 +143,14 @@ export const AssignSelectedRolesDrawer = ({
const handleScroll = (event: React.SyntheticEvent) => {
const listboxNode = event.currentTarget;
- if (
- listboxNode.scrollTop + listboxNode.clientHeight >=
- listboxNode.scrollHeight &&
- hasNextPage
- ) {
+ const isAtBottom =
+ Math.abs(
+ listboxNode.scrollHeight -
+ listboxNode.clientHeight -
+ listboxNode.scrollTop
+ ) < 1;
+
+ if (isAtBottom && hasNextPage) {
fetchNextPage();
}
};
From 29cb53e313554d373a95ecd205bf792d07ef0489 Mon Sep 17 00:00:00 2001
From: aaleksee-akamai
Date: Thu, 2 Oct 2025 10:39:30 +0200
Subject: [PATCH 44/54] feat: [UIE-9248] - IAM RBAC: replace grants in Linodes
(#12932)
* feat: [UIE-9248] - IAM RBAC: replace grants with usePermission hook for Linodes
* Added changeset: IAM RBAC: replace grants with usePermission hook in Linodes
---
.../pr-12932-changed-1759233206741.md | 5 +++++
.../LinodeRescue/StandardRescueDialog.tsx | 20 ++++++++----------
.../LinodeDiskActionMenu.test.tsx | 3 +++
.../LinodeStorage/LinodeDiskActionMenu.tsx | 21 ++++++++-----------
.../LinodeStorage/LinodeDiskRow.tsx | 12 +----------
.../LinodeStorage/LinodeDisks.tsx | 12 +----------
6 files changed, 28 insertions(+), 45 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12932-changed-1759233206741.md
diff --git a/packages/manager/.changeset/pr-12932-changed-1759233206741.md b/packages/manager/.changeset/pr-12932-changed-1759233206741.md
new file mode 100644
index 00000000000..c92c8950014
--- /dev/null
+++ b/packages/manager/.changeset/pr-12932-changed-1759233206741.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+IAM RBAC: replace grants with usePermission hook in Linodes ([#12932](https://github.com/linode/manager/pull/12932))
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx
index 622be8630c3..6bb3bc119a8 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx
@@ -1,10 +1,8 @@
import {
useAllLinodeDisksQuery,
useAllVolumesQuery,
- useGrants,
useLinodeQuery,
useLinodeRescueMutation,
- useProfile,
} from '@linode/queries';
import {
ActionsPanel,
@@ -20,6 +18,7 @@ import { styled, useTheme } from '@mui/material/styles';
import { useSnackbar } from 'notistack';
import * as React from 'react';
+import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
import { useEventsPollingActions } from 'src/queries/events/events';
import { deviceSlots } from '../LinodeConfigs/constants';
@@ -95,13 +94,12 @@ export const StandardRescueDialog = (props: Props) => {
} = useAllVolumesQuery({}, { region: linode?.region }, open);
const isLoading = isLoadingLinodes || isLoadingDisks || isLoadingVolumes;
- const { data: profile } = useProfile();
- const { data: grants } = useGrants();
-
- const isReadOnly =
- Boolean(profile?.restricted) &&
- grants?.linode.find((grant) => grant.id === linodeId)?.permissions ===
- 'read_only';
+ const { data: permissions } = usePermissions(
+ 'linode',
+ ['rescue_linode'],
+ linodeId,
+ open
+ );
// We need the API to allow us to filter on `linode_id`
// const { data: volumes } = useAllVolumesQuery(
@@ -173,7 +171,7 @@ export const StandardRescueDialog = (props: Props) => {
})) ?? [],
};
- const disabled = isReadOnly;
+ const disabled = !permissions.rescue_linode;
const onSubmit = () => {
rescueLinode(createDevicesFromStrings(rescueDevices))
@@ -224,7 +222,7 @@ export const StandardRescueDialog = (props: Props) => {
) : (
- {isReadOnly && }
+ {!permissions.rescue_linode && }
{linodeId ? : null}
({
resize_linode: false,
delete_linode: false,
clone_linode: false,
+ create_image: true,
},
})),
}));
@@ -209,6 +210,7 @@ describe('LinodeDiskActionMenu', () => {
resize_linode: false,
delete_linode: false,
clone_linode: false,
+ create_image: false,
},
});
@@ -242,6 +244,7 @@ describe('LinodeDiskActionMenu', () => {
resize_linode: true,
delete_linode: true,
clone_linode: true,
+ create_image: true,
},
});
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx
index 3602f0dc84a..0c27e67ce6d 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskActionMenu.tsx
@@ -14,22 +14,13 @@ interface Props {
onDelete: () => void;
onRename: () => void;
onResize: () => void;
- readOnly?: boolean;
}
export const LinodeDiskActionMenu = (props: Props) => {
const navigate = useNavigate();
const [isOpen, setIsOpen] = React.useState(false);
- const {
- disk,
- linodeId,
- linodeStatus,
- onDelete,
- onRename,
- onResize,
- readOnly,
- } = props;
+ const { disk, linodeId, linodeStatus, onDelete, onRename, onResize } = props;
const { data: permissions, isLoading } = usePermissions(
'linode',
@@ -38,6 +29,10 @@ export const LinodeDiskActionMenu = (props: Props) => {
isOpen
);
+ const { data: imagePermissions } = usePermissions('account', [
+ 'create_image',
+ ]);
+
const poweredOnTooltip =
linodeStatus !== 'offline'
? 'Your Linode must be fully powered down in order to perform this action.'
@@ -67,7 +62,7 @@ export const LinodeDiskActionMenu = (props: Props) => {
: poweredOnTooltip,
},
{
- disabled: readOnly || !!swapTooltip,
+ disabled: !imagePermissions.create_image || !!swapTooltip,
onClick: () =>
navigate({
to: `/images/create/disk`,
@@ -77,7 +72,9 @@ export const LinodeDiskActionMenu = (props: Props) => {
},
}),
title: 'Create Disk Image',
- tooltip: readOnly ? noPermissionTooltip : swapTooltip,
+ tooltip: !imagePermissions.create_image
+ ? noPermissionTooltip
+ : swapTooltip,
},
{
disabled: !permissions.clone_linode,
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx
index ded59fdcd2b..a922f53e8da 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx
@@ -18,20 +18,11 @@ interface Props {
onDelete: () => void;
onRename: () => void;
onResize: () => void;
- readOnly: boolean;
}
export const LinodeDiskRow = React.memo((props: Props) => {
const { data: events } = useInProgressEvents();
- const {
- disk,
- linodeId,
- linodeStatus,
- onDelete,
- onRename,
- onResize,
- readOnly,
- } = props;
+ const { disk, linodeId, linodeStatus, onDelete, onRename, onResize } = props;
const diskEventLabelMap: Partial> = {
disk_create: 'Creating',
@@ -80,7 +71,6 @@ export const LinodeDiskRow = React.memo((props: Props) => {
onDelete={onDelete}
onRename={onRename}
onResize={onResize}
- readOnly={readOnly}
/>
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx
index 9b962de61b9..33a4bcc7927 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx
@@ -1,8 +1,4 @@
-import {
- useAllLinodeDisksQuery,
- useGrants,
- useLinodeQuery,
-} from '@linode/queries';
+import { useAllLinodeDisksQuery, useLinodeQuery } from '@linode/queries';
import { Box, Button, Paper, Stack, Typography } from '@linode/ui';
import { Hidden } from '@linode/ui';
import Grid from '@mui/material/Grid';
@@ -41,7 +37,6 @@ export const LinodeDisks = () => {
const { data: disks, error, isLoading } = useAllLinodeDisksQuery(id);
const { data: linode } = useLinodeQuery(id);
- const { data: grants } = useGrants();
const { data: permissions } = usePermissions(
'linode',
@@ -59,10 +54,6 @@ export const LinodeDisks = () => {
const linodeTotalDisk = linode?.specs.disk ?? 0;
- const readOnly =
- grants !== undefined &&
- grants.linode.some((g) => g.id === id && g.permissions === 'read_only');
-
const usedDiskSpace = addUsedDiskSpace(disks ?? []);
const hasFreeDiskSpace = linodeTotalDisk > usedDiskSpace;
@@ -107,7 +98,6 @@ export const LinodeDisks = () => {
onDelete={() => onDelete(disk)}
onRename={() => onRename(disk)}
onResize={() => onResize(disk)}
- readOnly={readOnly}
/>
));
};
From 203dac00e1bb2b9ed31af03f187c7307d0993ec3 Mon Sep 17 00:00:00 2001
From: aaleksee-akamai
Date: Thu, 2 Oct 2025 10:40:41 +0200
Subject: [PATCH 45/54] feat: [UIE-9250] - IAM Delegation: replace query
(#12913)
* feat: [UIE-9250] - IAM Parent/Child: replace query
* Added changeset: IAM Delegation: replace query with the new delegation ones
* Added changeset: IAM Delegation: useAllListMyDelegatedChildAccountsQuery to fetch all data
* add comments to queries
* add client-side filter for new endpoint
* small cleanup
---
.../pr-12913-added-1758796719861.md | 5 ++
.../features/Account/SwitchAccountDrawer.tsx | 2 +-
.../SwitchAccounts/ChildAccountList.tsx | 68 +++++++++++++++----
.../SessionExpirationDialog.tsx | 4 +-
.../useParentChildAuthentication.tsx | 52 ++++++++++----
.../src/features/IAM/hooks/useIsIAMEnabled.ts | 10 +++
.../pr-12913-added-1758796811602.md | 5 ++
packages/queries/src/account/account.ts | 17 +++--
packages/queries/src/iam/delegation.ts | 46 +++++++++++--
9 files changed, 166 insertions(+), 43 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12913-added-1758796719861.md
create mode 100644 packages/queries/.changeset/pr-12913-added-1758796811602.md
diff --git a/packages/manager/.changeset/pr-12913-added-1758796719861.md b/packages/manager/.changeset/pr-12913-added-1758796719861.md
new file mode 100644
index 00000000000..89a93e8c807
--- /dev/null
+++ b/packages/manager/.changeset/pr-12913-added-1758796719861.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+IAM Delegation: replace query with the new delegation ones ([#12913](https://github.com/linode/manager/pull/12913))
diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
index 33f245d5403..0bdf4754103 100644
--- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
+++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx
@@ -42,7 +42,7 @@ export const SwitchAccountDrawer = (props: Props) => {
const {
createToken,
- createTokenError,
+ error: createTokenError,
revokeToken,
updateCurrentToken,
validateParentToken,
diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
index ab10d75d8af..2ef7c4fe026 100644
--- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
+++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx
@@ -1,4 +1,7 @@
-import { useChildAccountsInfiniteQuery } from '@linode/queries';
+import {
+ useAllListMyDelegatedChildAccountsQuery,
+ useChildAccountsInfiniteQuery,
+} from '@linode/queries';
import {
Box,
Button,
@@ -8,10 +11,11 @@ import {
Stack,
Typography,
} from '@linode/ui';
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { Waypoint } from 'react-waypoint';
import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg';
+import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
import type { Filter, UserType } from '@linode/api-v4';
@@ -39,6 +43,8 @@ export const ChildAccountList = React.memo(
searchQuery,
userType,
}: ChildAccountListProps) => {
+ const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
+
const filter: Filter = {
['+order']: 'asc',
['+order_by']: 'company',
@@ -56,22 +62,54 @@ export const ChildAccountList = React.memo(
isInitialLoading,
isRefetching,
refetch: refetchChildAccounts,
- } = useChildAccountsInfiniteQuery({
- filter,
- headers:
- userType === 'proxy'
- ? {
- Authorization: currentTokenWithBearer,
- }
- : undefined,
+ } = useChildAccountsInfiniteQuery(
+ {
+ filter,
+ headers:
+ userType === 'proxy'
+ ? {
+ Authorization: currentTokenWithBearer,
+ }
+ : undefined,
+ },
+ isIAMDelegationEnabled === false
+ );
+ const {
+ data: allChildAccounts,
+ error: allChildAccountsError,
+ isLoading: allChildAccountsLoading,
+ isRefetching: allChildAccountsIsRefetching,
+ refetch: refetchAllChildAccounts,
+ } = useAllListMyDelegatedChildAccountsQuery({
+ params: {},
+ enabled: isIAMDelegationEnabled,
});
- const childAccounts = data?.pages.flatMap((page) => page.data);
+
+ const refetchFn = isIAMDelegationEnabled
+ ? refetchAllChildAccounts
+ : refetchChildAccounts;
+
+ const childAccounts = useMemo(() => {
+ if (isIAMDelegationEnabled) {
+ if (searchQuery && allChildAccounts) {
+ // Client-side filter: match company field with searchQuery (case-insensitive, contains)
+ const normalizedQuery = searchQuery.toLowerCase();
+ return allChildAccounts.filter((account) =>
+ account.company?.toLowerCase().includes(normalizedQuery)
+ );
+ }
+ return allChildAccounts;
+ }
+ return data?.pages.flatMap((page) => page.data);
+ }, [isIAMDelegationEnabled, searchQuery, allChildAccounts, data]);
if (
isInitialLoading ||
isLoading ||
isSwitchingChildAccounts ||
- isRefetching
+ isRefetching ||
+ allChildAccountsLoading ||
+ allChildAccountsIsRefetching
) {
return (
@@ -80,7 +118,7 @@ export const ChildAccountList = React.memo(
);
}
- if (childAccounts?.length === 0) {
+ if (childAccounts && childAccounts.length === 0) {
return (
There are no child accounts
@@ -92,7 +130,7 @@ export const ChildAccountList = React.memo(
);
}
- if (isError) {
+ if (isError || allChildAccountsError) {
return (
@@ -102,7 +140,7 @@ export const ChildAccountList = React.memo(
refetchChildAccounts()}
+ onClick={() => refetchFn()}
sx={(theme) => ({
marginTop: theme.spacing(2),
})}
diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx
index f25942cf0e2..b809a19c6a9 100644
--- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx
+++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx
@@ -36,8 +36,8 @@ export const SessionExpirationDialog = React.memo(
const {
createToken,
- createTokenError,
- createTokenLoading,
+ error: createTokenError,
+ loading: createTokenLoading,
revokeToken,
updateCurrentToken,
validateParentToken,
diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx
index b3e4891b213..79742411df4 100644
--- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx
+++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx
@@ -2,20 +2,26 @@ import {
deletePersonalAccessToken,
getPersonalAccessTokens,
} from '@linode/api-v4';
-import { useCreateChildAccountPersonalAccessTokenMutation } from '@linode/queries';
+import {
+ useCreateChildAccountPersonalAccessTokenMutation,
+ useGenerateChildAccountTokenQuery,
+} from '@linode/queries';
import { useCallback } from 'react';
+import React from 'react';
import {
getPersonalAccessTokenForRevocation,
isParentTokenValid,
updateCurrentTokenBasedOnUserType,
} from 'src/features/Account/SwitchAccounts/utils';
+import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
import { getStorage, storage } from 'src/utilities/storage';
import type { Token, UserType } from '@linode/api-v4';
export const useParentChildAuthentication = () => {
const currentTokenWithBearer = storage.authentication.token.get() ?? '';
+ const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
const {
error: createTokenError,
@@ -23,20 +29,38 @@ export const useParentChildAuthentication = () => {
mutateAsync: createProxyToken,
} = useCreateChildAccountPersonalAccessTokenMutation();
+ const {
+ error: generateTokenError,
+ isPending: generateTokenLoading,
+ mutateAsync: generateProxyToken,
+ } = useGenerateChildAccountTokenQuery();
+
+ const error = React.useMemo(
+ () => (isIAMDelegationEnabled ? generateTokenError : createTokenError),
+ [isIAMDelegationEnabled, createTokenError, generateTokenError]
+ );
+
+ const loading = React.useMemo(
+ () => (isIAMDelegationEnabled ? generateTokenLoading : createTokenLoading),
+ [isIAMDelegationEnabled, createTokenLoading, generateTokenLoading]
+ );
+
const createToken = useCallback(
async (euuid: string): Promise => {
- return createProxyToken({
- euuid,
- headers: {
- /**
- * Headers are required for proxy users when obtaining a proxy token.
- * For 'proxy' userType, use the stored parent token in the request.
- */
- Authorization: getStorage('authentication/parent_token/token'),
- },
- });
+ return isIAMDelegationEnabled
+ ? generateProxyToken({ euuid })
+ : createProxyToken({
+ euuid,
+ headers: {
+ /**
+ * Headers are required for proxy users when obtaining a proxy token.
+ * For 'proxy' userType, use the stored parent token in the request.
+ */
+ Authorization: getStorage('authentication/parent_token/token'),
+ },
+ });
},
- [createProxyToken]
+ [createProxyToken, generateProxyToken, isIAMDelegationEnabled]
);
const revokeToken = useCallback(async (): Promise => {
@@ -70,8 +94,8 @@ export const useParentChildAuthentication = () => {
return {
createToken,
- createTokenError,
- createTokenLoading,
+ error,
+ loading,
revokeToken,
updateCurrentToken,
validateParentToken,
diff --git a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts
index 0a0f3c7b73f..7cd61bf8280 100644
--- a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts
+++ b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts
@@ -73,3 +73,13 @@ export const checkIAMEnabled = async (
return false;
}
};
+
+/**
+ * Returns whether or not features related to the IAM Delegation project
+ * should be enabled.
+ */
+export const useIsIAMDelegationEnabled = () => {
+ const flags = useFlags();
+
+ return { isIAMDelegationEnabled: flags.iamDelegation?.enabled ?? false };
+};
diff --git a/packages/queries/.changeset/pr-12913-added-1758796811602.md b/packages/queries/.changeset/pr-12913-added-1758796811602.md
new file mode 100644
index 00000000000..9e845828e9c
--- /dev/null
+++ b/packages/queries/.changeset/pr-12913-added-1758796811602.md
@@ -0,0 +1,5 @@
+---
+"@linode/queries": Added
+---
+
+IAM Delegation: useAllListMyDelegatedChildAccountsQuery to fetch all data ([#12913](https://github.com/linode/manager/pull/12913))
diff --git a/packages/queries/src/account/account.ts b/packages/queries/src/account/account.ts
index 6f554902b29..735fe1c87c5 100644
--- a/packages/queries/src/account/account.ts
+++ b/packages/queries/src/account/account.ts
@@ -31,17 +31,22 @@ export const useMutateAccount = () =>
mutationFn: updateAccountInfo,
});
-export const useChildAccountsInfiniteQuery = (options: RequestOptions) => {
+export const useChildAccountsInfiniteQuery = (
+ options: RequestOptions,
+ enabled = true,
+) => {
const { data: profile } = useProfile();
const { data: grants } = useGrants();
const hasExplicitAuthToken = Boolean(options.headers?.Authorization);
- const enabled =
- (Boolean(profile?.user_type === 'parent') && !profile?.restricted) ||
- Boolean(grants?.global?.child_account_access) ||
- hasExplicitAuthToken;
+
+ const isEnabled = enabled
+ ? (Boolean(profile?.user_type === 'parent') && !profile?.restricted) ||
+ Boolean(grants?.global?.child_account_access) ||
+ hasExplicitAuthToken
+ : false;
return useInfiniteQuery, APIError[]>({
- enabled,
+ enabled: isEnabled,
getNextPageParam: ({ page, pages }) => {
if (page === pages) {
return undefined;
diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts
index f342c4fc452..41a3f51499e 100644
--- a/packages/queries/src/iam/delegation.ts
+++ b/packages/queries/src/iam/delegation.ts
@@ -9,6 +9,7 @@ import {
updateChildAccountDelegates,
updateDefaultDelegationAccess,
} from '@linode/api-v4';
+import { getAll } from '@linode/utilities';
import { createQueryKeys } from '@lukemorales/query-key-factory';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -46,10 +47,19 @@ export const delegationQueries = createQueryKeys('delegation', {
queryFn: () => getChildAccountDelegates({ euuid, params }),
queryKey: [euuid, params],
}),
- myDelegatedChildAccounts: (params: Params) => ({
- queryFn: () => getMyDelegatedChildAccounts({ params }),
- queryKey: [params],
- }),
+ myDelegatedChildAccounts: {
+ contextQueries: {
+ all: (params: Params) => ({
+ queryFn: () => getAllMyDelegatedChildAccounts(params),
+ queryKey: [params],
+ }),
+ paginated: (params: Params) => ({
+ queryFn: () => getMyDelegatedChildAccounts({ params }),
+ queryKey: [params],
+ }),
+ },
+ queryKey: null,
+ },
delegatedChildAccount: (euuid: string) => ({
queryFn: () => getDelegatedChildAccount({ euuid }),
queryKey: [euuid],
@@ -159,7 +169,25 @@ export const useGetMyDelegatedChildAccountsQuery = (
params: Params,
): UseQueryResult, APIError[]> => {
return useQuery({
- ...delegationQueries.myDelegatedChildAccounts(params),
+ ...delegationQueries.myDelegatedChildAccounts._ctx.paginated(params),
+ });
+};
+
+/**
+ * List all my delegated child accounts (fetches all pages of child accounts where user has view_child_account permission)
+ * - Purpose: Retrieve the full list of child accounts the current caller can manage via delegation, across all pages.
+ * - Scope: Only child accounts where the caller has an active delegate and required view permission; returns all results, not paginated.
+ * - Audience: Callers needing the complete set of accessible accounts for the current user.
+ * - Data: Account[] (limited profile fields) for `GET /iam/delegation/profile/child-accounts` (all pages).
+ * - Usage: Pass `enabled` to control query activation (e.g., only if IAM Delegation is enabled).
+ */
+export const useAllListMyDelegatedChildAccountsQuery = ({
+ params = {},
+ enabled = true,
+}) => {
+ return useQuery({
+ enabled,
+ ...delegationQueries.myDelegatedChildAccounts._ctx.all(params),
});
};
@@ -231,3 +259,11 @@ export const useUpdateDefaultDelegationAccessQuery = (): UseMutationResult<
},
});
};
+
+/**
+ * Fetches all my delegated child accounts for the current user (all pages).
+ */
+const getAllMyDelegatedChildAccounts = (_params: Params = {}) =>
+ getAll((params) =>
+ getMyDelegatedChildAccounts({ params: { ...params, ..._params } }),
+ )().then((data) => data.data);
From c0f5458b4fd6d3853e935c8fab5805b6990aefde Mon Sep 17 00:00:00 2001
From: dmcintyr-akamai
Date: Thu, 2 Oct 2025 08:57:25 -0400
Subject: [PATCH 46/54] test [M3-10622]: Smoke tests for Nvidia blackwell GPUs
on k8s create page (#12917)
* initial commit, kubernetes and linode
* cleanup
* simulate missing customer tag
* move tests to k8s file
* Added changeset: Smoke tests for nvidia blackwell gpu plan selection
* edits
* edits after review
* failing tests
* reverting change to region
* fix tests on devcloud
* cleanup
---
.../pr-12917-tests-1758825978456.md | 5 +
.../e2e/core/kubernetes/lke-create.spec.ts | 148 ++++++++++++++++++
2 files changed, 153 insertions(+)
create mode 100644 packages/manager/.changeset/pr-12917-tests-1758825978456.md
diff --git a/packages/manager/.changeset/pr-12917-tests-1758825978456.md b/packages/manager/.changeset/pr-12917-tests-1758825978456.md
new file mode 100644
index 00000000000..f7912eaf5d2
--- /dev/null
+++ b/packages/manager/.changeset/pr-12917-tests-1758825978456.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Smoke tests for nvidia blackwell gpu plan selection ([#12917](https://github.com/linode/manager/pull/12917))
diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
index d6665a7ff20..1539bc30bfa 100644
--- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts
@@ -5,6 +5,7 @@ import {
dedicatedTypeFactory,
linodeTypeFactory,
pluralize,
+ regionAvailabilityFactory,
regionFactory,
} from '@linode/utilities';
import {
@@ -1850,6 +1851,153 @@ describe('LKE cluster creation with LKE-E Post-LA', () => {
});
});
+/*
+ * Each test provided w/ array of 12 mock linode types. Type excluded if:
+ - flag enabled and id includes 'blackwell'
+ - enterprise tier and id includes 'gpu'
+ * If visible in table, rows are always enabled
+*/
+describe('smoketest for Nvidia Blackwell GPUs in kubernetes/create page', () => {
+ const mockRegion = regionFactory.build({
+ id: 'us-east',
+ label: 'Newark, NJ',
+ capabilities: [
+ 'GPU Linodes',
+ 'Linodes',
+ 'Kubernetes',
+ 'Kubernetes Enterprise',
+ ],
+ });
+
+ const mockBlackwellLinodeTypes = new Array(4).fill(null).map((_, index) =>
+ linodeTypeFactory.build({
+ id: `g3-gpu-rtxpro6000-blackwell-${index + 1}`,
+ label: `RTX PRO 6000 Blackwell x${index + 1}`,
+ class: 'gpu',
+ })
+ );
+ beforeEach(() => {
+ mockGetAccount(
+ accountFactory.build({
+ capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'],
+ })
+ );
+ mockGetRegions([mockRegion]).as('getRegions');
+
+ mockGetLinodeTypes(mockBlackwellLinodeTypes).as('getLinodeTypes');
+ const mockRegionAvailability = mockBlackwellLinodeTypes.map((type) =>
+ regionAvailabilityFactory.build({
+ plan: type.label,
+ available: true,
+ region: mockRegion.id,
+ })
+ );
+ mockGetRegionAvailability(mockRegion.id, mockRegionAvailability).as(
+ 'getRegionAvailability'
+ );
+ });
+
+ describe('standard tier', () => {
+ it('enabled feature flag includes blackwells', () => {
+ mockAppendFeatureFlags({
+ kubernetesBlackwellPlans: true,
+ }).as('getFeatureFlags');
+ cy.visitWithLogin('/kubernetes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']);
+
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().clear();
+ ui.regionSelect.find().type(`${mockRegion.label}{enter}`);
+ cy.wait('@getRegionAvailability');
+ // Navigate to "GPU" tab
+ ui.tabList.findTabByTitle('GPU').scrollIntoView();
+ ui.tabList.findTabByTitle('GPU').should('be.visible').click();
+
+ cy.findByRole('table', {
+ name: 'List of NVIDIA RTX PRO 6000 Blackwell Server Edition Plans',
+ }).within(() => {
+ cy.get('tbody tr')
+ .should('have.length', 4)
+ .each((row, index) => {
+ cy.wrap(row).within(() => {
+ cy.get('td')
+ .eq(0)
+ .within(() => {
+ cy.findByText(mockBlackwellLinodeTypes[index].label).should(
+ 'be.visible'
+ );
+ });
+ ui.button
+ .findByTitle('Configure Pool')
+ .should('be.visible')
+ .should('be.enabled');
+ });
+ });
+ });
+ });
+
+ it('disabled feature flag excludes blackwells', () => {
+ mockAppendFeatureFlags({
+ kubernetesBlackwellPlans: false,
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin('/kubernetes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']);
+
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().clear();
+ ui.regionSelect.find().type(`${mockRegion.label}{enter}`);
+ cy.wait('@getRegionAvailability');
+ // Navigate to "GPU" tab
+ // "GPU" tab hidden
+ ui.tabList.findTabByTitle('GPU').should('not.exist');
+ });
+ });
+ describe('enterprise tier hides GPU tab', () => {
+ beforeEach(() => {
+ // necessary to prevent crash after selecting Enterprise button
+ mockGetTieredKubernetesVersions('enterprise', [
+ latestEnterpriseTierKubernetesVersion,
+ ]).as('getEnterpriseTieredVersions');
+ });
+ it('enabled feature flag', () => {
+ mockAppendFeatureFlags({
+ kubernetesBlackwellPlans: true,
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin('/kubernetes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']);
+
+ cy.findByText('LKE Enterprise').click();
+ cy.wait(['@getEnterpriseTieredVersions']);
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().clear();
+ ui.regionSelect.find().type(`${mockRegion.label}{enter}`);
+ cy.wait('@getRegionAvailability');
+ // "GPU" tab hidden
+ ui.tabList.findTabByTitle('GPU').should('not.exist');
+ });
+
+ it('disabled feature flag', () => {
+ mockAppendFeatureFlags({
+ kubernetesBlackwellPlans: false,
+ }).as('getFeatureFlags');
+
+ cy.visitWithLogin('/kubernetes/create');
+ cy.wait(['@getFeatureFlags', '@getRegions', '@getLinodeTypes']);
+
+ ui.regionSelect.find().click();
+ ui.regionSelect.find().clear();
+ ui.regionSelect.find().type(`${mockRegion.label}{enter}`);
+ cy.findByText('LKE Enterprise').click();
+ cy.wait(['@getEnterpriseTieredVersions']);
+ 2;
+ // "GPU" tab hidden
+ ui.tabList.findTabByTitle('GPU').should('not.exist');
+ });
+ });
+});
+
/**
* Returns each plan in an array which is similar to the given plan.
*
From 65e4a44588861a331e443b473714758b3286ce94 Mon Sep 17 00:00:00 2001
From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com>
Date: Thu, 2 Oct 2025 09:52:02 -0400
Subject: [PATCH 47/54] fix: [M3-10619] - Display tax ids for pdf generation
(#12942)
* fix: [M3-10619] - Display tax ids for pdf generation
* Added changeset: Always show tax id's when available irrespective of date filtering
---------
Co-authored-by: Jaalah Ramos
---
.../pr-12942-fixed-1759355205373.md | 5 ++++
.../Billing/PdfGenerator/PdfGenerator.ts | 27 ++++++++-----------
2 files changed, 16 insertions(+), 16 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12942-fixed-1759355205373.md
diff --git a/packages/manager/.changeset/pr-12942-fixed-1759355205373.md b/packages/manager/.changeset/pr-12942-fixed-1759355205373.md
new file mode 100644
index 00000000000..480b85eb1fd
--- /dev/null
+++ b/packages/manager/.changeset/pr-12942-fixed-1759355205373.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Fixed
+---
+
+Always show tax id's when available irrespective of date filtering ([#12942](https://github.com/linode/manager/pull/12942))
diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
index 89abbb5a486..a0ee6c775b3 100644
--- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
+++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
@@ -229,27 +229,22 @@ export const printInvoice = async (
taxes && taxes?.date ? dateConversion(taxes.date) : Infinity;
/**
- * Users who have identified their country as one of the ones targeted by
- * one of our tax policies will have a `taxes` with at least a .date.
- * Customers with no country, or from a country we don't have a tax policy
- * for, will have a `taxes` of {}, and the following logic will skip them.
+ * Tax data is served from LaunchDarkly feature flags and displayed on invoices.
+ * Country-level tax IDs (like EU VAT, Japanese JCT, etc.) are always displayed
+ * when available, regardless of invoice date.
*
- * If taxes.date is defined, and the invoice we're about to print is after
- * that date, we want to add the customer's tax ID to the invoice.
+ * Provincial/state-level tax IDs still use date filtering to determine when
+ * they should be applied based on local tax policy implementation dates.
*
- * If in addition to the above, taxes is defined, it means
- * we have a corporate tax ID for the country and should display that in the left
- * side of the header.
+ * The source of truth for all tax data is LaunchDarkly, with examples:
*
- * The source of truth for all tax banners is LaunchDarkly, but as an example,
- * as of 2/20/2020 we have the following cases:
- *
- * VAT: Applies only to EU countries; started from 6/1/2019 and we have an EU tax id
- * - [M3-8277] For EU customers, invoices will include VAT for B2C transactions and exclude VAT for B2B transactions. Both VAT numbers will be shown on the invoice template for EU countries.
- * GMT: Applies to both Australia and India, but we only have a tax ID for Australia.
+ * EU VAT: Shows both EU VAT number and Switzerland VAT for B2B customers
+ * Japanese JCT: Shows Japan JCT tax ID and QI Registration number
+ * US/CA: Shows federal tax IDs and state-specific tax IDs when applicable
*/
const hasTax = !taxes?.date ? true : convertedInvoiceDate > TaxStartDate;
- const countryTax = hasTax ? taxes?.country_tax : undefined;
+ // Country-level tax IDs are always displayed when available
+ const countryTax = taxes?.country_tax;
const provincialTax = hasTax
? taxes?.provincial_tax_ids?.[account.state]
: undefined;
From 99247818295a7f5463d1dd7c1dc58c169f4ba77f Mon Sep 17 00:00:00 2001
From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com>
Date: Thu, 2 Oct 2025 10:03:16 -0400
Subject: [PATCH 48/54] =?UTF-8?q?change:=20[M3-10598]=20=E2=80=93=20Variou?=
=?UTF-8?q?s=20VPC=20IPv6=20copy=20changes=20and=20minor=20bug=20fixes=20(?=
=?UTF-8?q?#12924)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Update public IPv6 checkbox tooltip text and Auto-assign VPC IPv6 tooltip text; simplify logic for Auto-assign VPC IPv4 tooltip text; fix bug in logic for when to display em dashes for VPC IPv6 ranges in Subnet Linode row
* Additional copy changes, cleanup, and test updates
* 192.168.128.0/17 as stylized code, IPv4 + IPv6 (dual-stack) --> IPv4 + IPv6 (Dual Stack)
* Remove IPv4 range from Assign Linodes drawer title; update subnet field names in VPC Create flow and Subnet Create drawer; spacing in public IPv6 access checkbox tooltip
* Update VPC_IPV4_INPUT_HELPER_TEXT, VPC_AUTO_ASSIGN_IPV6_TOOLTIP, and VPC_IPV6_INPUT_HELPER_TEXT
* Update copy for DualStackVPCRangesDescription() -- 'Assign additional IP ranges' tooltip
* Update labels in various test files to get them passing
* Passing MultipleSubnetInput.test.tsx
* Added changeset: Assorted VPC IPv4 and VPC IPv6 copy
* Update label searchd for in setSubnetIpRange support method for Cypress tests
* Added changeset: Update vpcCreateDrawer.setSubnetIpRange page utility for Cypress tests
---------
Co-authored-by: Dajahi Wiley
---
.../pr-12924-changed-1759346651695.md | 5 ++
.../pr-12924-tests-1759352432371.md | 5 ++
.../cypress/e2e/core/vpc/vpc-create.spec.ts | 14 +++---
.../e2e/core/vpc/vpc-linodes-update.spec.ts | 10 ++--
.../support/ui/pages/vpc-create-drawer.ts | 2 +-
.../Linodes/LinodeCreate/Networking/VPC.tsx | 12 ++---
.../Linodes/LinodeCreate/VPC/VPC.test.tsx | 6 +--
.../features/Linodes/LinodeCreate/VPC/VPC.tsx | 14 +++---
.../AddInterfaceDrawer/VPC/VPCIPAddresses.tsx | 6 +--
.../AddInterfaceDrawer/VPC/VPCIPv4Address.tsx | 21 ++-------
.../AddInterfaceDrawer/VPC/VPCIPv6Address.tsx | 33 +++----------
.../VPCInterface/VPCIPAddresses.tsx | 3 +-
.../VPCInterface/VPCIPv4Address.tsx | 18 ++------
.../VPCInterface/VPCIPv6Address.tsx | 38 +++------------
.../FormComponents/SubnetContent.test.tsx | 2 +-
.../FormComponents/VPCTopSectionContent.tsx | 6 ++-
.../VPCCreate/MultipleSubnetInput.test.tsx | 2 +-
.../VPCs/VPCCreate/SubnetNode.test.tsx | 4 +-
.../features/VPCs/VPCCreate/SubnetNode.tsx | 7 ++-
.../VPCs/VPCCreate/VPCCreate.test.tsx | 12 ++---
.../VPCCreateDrawer/VPCCreateDrawer.test.tsx | 2 +-
.../SubnetAssignLinodesDrawer.test.tsx | 4 +-
.../VPCDetail/SubnetAssignLinodesDrawer.tsx | 46 ++++---------------
.../VPCDetail/SubnetCreateDrawer.test.tsx | 2 +-
.../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 2 +-
.../VPCs/VPCDetail/SubnetLinodeRow.tsx | 6 +--
.../VPCs/components/VPCRangesDescription.tsx | 4 +-
.../manager/src/features/VPCs/constants.tsx | 42 ++++++++++++-----
.../manager/src/features/VPCs/utils.test.ts | 16 -------
packages/manager/src/features/VPCs/utils.ts | 12 -----
30 files changed, 127 insertions(+), 229 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12924-changed-1759346651695.md
create mode 100644 packages/manager/.changeset/pr-12924-tests-1759352432371.md
diff --git a/packages/manager/.changeset/pr-12924-changed-1759346651695.md b/packages/manager/.changeset/pr-12924-changed-1759346651695.md
new file mode 100644
index 00000000000..106d4e8d7fe
--- /dev/null
+++ b/packages/manager/.changeset/pr-12924-changed-1759346651695.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+Assorted VPC IPv4 and VPC IPv6 copy ([#12924](https://github.com/linode/manager/pull/12924))
diff --git a/packages/manager/.changeset/pr-12924-tests-1759352432371.md b/packages/manager/.changeset/pr-12924-tests-1759352432371.md
new file mode 100644
index 00000000000..3aae92e17da
--- /dev/null
+++ b/packages/manager/.changeset/pr-12924-tests-1759352432371.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Update vpcCreateDrawer.setSubnetIpRange page utility for Cypress tests ([#12924](https://github.com/linode/manager/pull/12924))
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
index ef9807d401d..fd95d254972 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts
@@ -110,7 +110,7 @@ describe('VPC create flow', () => {
cy.findByText('Subnet Label').should('be.visible').click();
cy.focused().type(mockSubnets[0].label);
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
cy.focused().type(`{selectAll}{backspace}`);
});
@@ -123,7 +123,7 @@ describe('VPC create flow', () => {
cy.findByText(ipValidationErrorMessage1).should('be.visible');
// Enter a random non-IP address string to further test client side validation.
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
cy.focused().type(`{selectAll}{backspace}`);
cy.focused().type(randomString(18));
@@ -136,7 +136,7 @@ describe('VPC create flow', () => {
cy.findByText(ipValidationErrorMessage2).should('be.visible');
// Enter a valid IP address with an invalid network prefix to further test client side validation.
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
cy.focused().type(`{selectAll}{backspace}`);
cy.focused().type(mockInvalidIpRange);
@@ -149,7 +149,7 @@ describe('VPC create flow', () => {
cy.findByText(ipValidationErrorMessage2).should('be.visible');
// Replace invalid IP address range with valid range.
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
cy.focused().type(`{selectAll}{backspace}`);
cy.focused().type(mockSubnets[0].ipv4!);
@@ -165,7 +165,7 @@ describe('VPC create flow', () => {
getSubnetNodeSection(1)
.should('be.visible')
.within(() => {
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)').should('be.visible').click();
cy.focused().type(`{selectAll}{backspace}`);
cy.focused().type(mockSubnetToDelete.ipv4!);
});
@@ -209,7 +209,9 @@ describe('VPC create flow', () => {
cy.findByText('Subnet Label').should('be.visible').click();
cy.focused().type(mockSubnet.label);
- cy.findByText('Subnet IP Address Range').should('be.visible').click();
+ cy.findByText('Subnet IPv4 Range (CIDR)')
+ .should('be.visible')
+ .click();
cy.focused().type(`{selectAll}{backspace}`);
cy.focused().type(`${randomIp()}/${randomNumber(0, 32)}`);
});
diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
index 566894a6d20..6a5615b8c00 100644
--- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
+++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts
@@ -140,7 +140,7 @@ describe('VPC assign/unassign flows', () => {
.click();
ui.drawer
- .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`)
+ .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label}`)
.should('be.visible')
.within(() => {
// confirm that the user is warned that a reboot / shutdown is required
@@ -167,7 +167,7 @@ describe('VPC assign/unassign flows', () => {
.click();
// Auto-assign IPv4 checkbox checked by default
- cy.findByLabelText('Auto-assign VPC IPv4 address').should('be.checked');
+ cy.findByLabelText('Auto-assign VPC IPv4').should('be.checked');
cy.wait('@getLinodeConfigs');
@@ -277,7 +277,7 @@ describe('VPC assign/unassign flows', () => {
.click();
ui.drawer
- .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`)
+ .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label}`)
.should('be.visible')
.within(() => {
// confirm that the user is warned that a reboot / shutdown is required
@@ -304,9 +304,7 @@ describe('VPC assign/unassign flows', () => {
.click();
// Uncheck auto-assign checkbox and type in VPC IPv4
- cy.findByLabelText('Auto-assign VPC IPv4 address')
- .should('be.checked')
- .click();
+ cy.findByLabelText('Auto-assign VPC IPv4').should('be.checked').click();
cy.findByLabelText('VPC IPv4').should('be.visible').click();
cy.focused().type(mockVPCInterface.ipv4?.vpc ?? '10.0.0.7');
diff --git a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
index 20c5635dc4b..9713b2e5267 100644
--- a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
+++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts
@@ -57,7 +57,7 @@ export const vpcCreateDrawer = {
* @param subnetIndex - Optional index of subnet for which to update IP range.
*/
setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => {
- cy.findByText('Subnet IP Address Range', {
+ cy.findByText('Subnet IPv4 Range (CIDR)', {
selector: `label[for="subnet-ipv4-${subnetIndex}"]`,
})
.should('be.visible')
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx
index c9b737bb09b..5078f112ae0 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/Networking/VPC.tsx
@@ -23,8 +23,9 @@ import {
REGION_CAVEAT_HELPER_TEXT,
VPC_AUTO_ASSIGN_IPV4_TOOLTIP,
VPC_AUTO_ASSIGN_IPV6_TOOLTIP,
+ VPC_IPV4_INPUT_HELPER_TEXT,
+ VPC_IPV6_INPUT_HELPER_TEXT,
} from 'src/features/VPCs/constants';
-import { generateVPCIPv6InputHelperText } from 'src/features/VPCs/utils';
import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer';
import { useVPCDualStack } from 'src/hooks/useVPCDualStack';
@@ -167,7 +168,7 @@ export const VPC = ({ index }: Props) => {
disabled={!regionSupportsVPCs}
label={
- Auto-assign VPC IPv4 address
+ Auto-assign VPC IPv4
{
errors.linodeInterfaces?.[index]?.vpc?.ipv4
?.addresses?.[0]?.message
}
+ helperText={VPC_IPV4_INPUT_HELPER_TEXT}
label="VPC IPv4"
noMarginTop
onBlur={field.onBlur}
@@ -209,7 +211,7 @@ export const VPC = ({ index }: Props) => {
disabled={!regionSupportsVPCs}
label={
- Auto-assign VPC IPv6 address
+ Auto-assign VPC IPv6
{
errors.linodeInterfaces?.[index]?.vpc?.ipv6?.slaac?.[0]
?.range?.message
}
- helperText={generateVPCIPv6InputHelperText(
- selectedSubnet?.ipv6?.[0].range ?? ''
- )}
+ helperText={VPC_IPV6_INPUT_HELPER_TEXT}
label="VPC IPv6"
noMarginTop
onBlur={field.onBlur}
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx
index b172be8eefd..0408342deab 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.test.tsx
@@ -78,7 +78,7 @@ describe('VPC', () => {
},
});
- expect(getByLabelText('Auto-assign VPC IPv4 address')).toBeInTheDocument();
+ expect(getByLabelText('Auto-assign VPC IPv4')).toBeInTheDocument();
expect(
getByLabelText('Allow public IPv4 access (1:1 NAT)')
@@ -103,7 +103,7 @@ describe('VPC', () => {
},
});
- expect(getByLabelText('Auto-assign VPC IPv4 address')).toBeChecked();
+ expect(getByLabelText('Auto-assign VPC IPv4')).toBeChecked();
});
it('should uncheck the VPC IPv4 if a "ipv4.vpc" is a string value and show the VPC IP TextField', async () => {
@@ -122,7 +122,7 @@ describe('VPC', () => {
},
});
- expect(getByLabelText('Auto-assign VPC IPv4 address')).not.toBeChecked();
+ expect(getByLabelText('Auto-assign VPC IPv4')).not.toBeChecked();
expect(getByLabelText('VPC IPv4 (required)')).toBeVisible();
});
diff --git a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
index 3d41fe4bdd4..5e9f3b4595e 100644
--- a/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreate/VPC/VPC.tsx
@@ -30,8 +30,9 @@ import {
REGION_CAVEAT_HELPER_TEXT,
VPC_AUTO_ASSIGN_IPV4_TOOLTIP,
VPC_AUTO_ASSIGN_IPV6_TOOLTIP,
+ VPC_IPV4_INPUT_HELPER_TEXT,
+ VPC_IPV6_INPUT_HELPER_TEXT,
} from 'src/features/VPCs/constants';
-import { generateVPCIPv6InputHelperText } from 'src/features/VPCs/utils';
import { VPCCreateDrawer } from 'src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer';
import { useVPCDualStack } from 'src/hooks/useVPCDualStack';
import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics';
@@ -251,9 +252,7 @@ export const VPC = () => {
control={}
label={
-
- Auto-assign VPC IPv4 address
-
+ Auto-assign VPC IPv4
{
{
label={
- Auto-assign VPC IPv6 address
+ Auto-assign VPC IPv6
{
{
return (
{fields.map((field, index) => (
-
+
))}
{isDualStackVPC && }
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx
index 908950efdd1..52e5416d7a6 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/AddInterfaceDrawer/VPC/VPCIPv4Address.tsx
@@ -5,7 +5,6 @@ import {
Stack,
TextField,
TooltipIcon,
- Typography,
} from '@linode/ui';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
@@ -17,11 +16,10 @@ import type { CreateInterfaceFormValues } from '../utilities';
interface Props {
index: number;
- isDualStackVPC: boolean;
}
export const VPCIPv4Address = (props: Props) => {
- const { index, isDualStackVPC } = props;
+ const { index } = props;
const {
control,
formState: { errors },
@@ -45,24 +43,11 @@ export const VPCIPv4Address = (props: Props) => {
}
- label="Auto-assign VPC IPv4 address"
+ label="Auto-assign VPC IPv4"
onChange={(e, checked) => field.onChange(checked ? 'auto' : '')}
sx={{ pl: 0.4, mr: 0 }}
/>
-
- Automatically assign an IPv4 address as{' '}
- {isDualStackVPC ? 'a' : 'the'} private IP address for this
- Linode in the VPC.
-
- ) : (
- VPC_AUTO_ASSIGN_IPV4_TOOLTIP
- )
- }
- />
+
{field.value !== 'auto' && (
{
const {
control,
- getValues,
formState: { errors },
} = useFormContext();
- const { vpc } = getValues();
- const { data: subnet } = useSubnetQuery(
- vpc?.vpc_id ?? -1,
- vpc?.subnet_id ?? -1,
- Boolean(vpc?.vpc_id && vpc?.subnet_id)
- );
-
const error = errors.vpc?.ipv6?.message;
return (
@@ -49,27 +41,16 @@ export const VPCIPv6Address = () => {
}
- label="Auto-assign VPC IPv6 address"
+ label="Auto-assign VPC IPv6"
onChange={(e, checked) => field.onChange(checked ? 'auto' : '')}
sx={{ pl: 0.4, mr: 0 }}
/>
-
- Automatically assign an IPv6 address as a private IP address
- for this Linode in the VPC. A /52 IPv6 network
- prefix is allocated for the VPC.
-
- }
- />
+
{field.value !== 'auto' && (
{
* We currently enforce a hard limit of one IPv4 address per VPC interface.
* See VPC-2044.
*
- * @todo Eventually, when the API supports it, we should all the user to append/remove more VPC IPs
+ * @todo Eventually, when the API supports it, we should allow the user to append/remove more VPC IPs
*/
const { fields } = useFieldArray({
control,
@@ -50,7 +50,6 @@ export const VPCIPAddresses = (props: Props) => {
{fields.map((field, index) => (
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx
index 9fde079098b..fc88f4b5f06 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv4Address.tsx
@@ -5,7 +5,6 @@ import {
Stack,
TextField,
TooltipIcon,
- Typography,
} from '@linode/ui';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
@@ -20,12 +19,11 @@ import type {
interface Props {
index: number;
- isDualStackVPC: boolean;
linodeInterface: LinodeInterface;
}
export const VPCIPv4Address = (props: Props) => {
- const { index, linodeInterface, isDualStackVPC } = props;
+ const { index, linodeInterface } = props;
const {
control,
formState: { errors },
@@ -50,7 +48,7 @@ export const VPCIPv4Address = (props: Props) => {
}
- label="Auto-assign VPC IPv4 address"
+ label="Auto-assign VPC IPv4"
onChange={(e, checked) =>
field.onChange(
checked
@@ -63,17 +61,7 @@ export const VPCIPv4Address = (props: Props) => {
/>
- Automatically assign an IPv4 address as{' '}
- {isDualStackVPC ? 'a' : 'the'} private IP address for
- this Linode in the VPC.
-
- ) : (
- VPC_AUTO_ASSIGN_IPV4_TOOLTIP
- )
- }
+ text={VPC_AUTO_ASSIGN_IPV4_TOOLTIP}
/>
{field.value !== 'auto' && (
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx
index eeaa072c716..26bf66d49a1 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/EditInterfaceDrawer/VPCInterface/VPCIPv6Address.tsx
@@ -1,4 +1,3 @@
-import { useSubnetQuery } from '@linode/queries';
import {
Checkbox,
FormControlLabel,
@@ -6,15 +5,15 @@ import {
Stack,
TextField,
TooltipIcon,
- Typography,
} from '@linode/ui';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
-import { Code } from 'src/components/Code/Code';
import { ErrorMessage } from 'src/components/ErrorMessage';
-import { generateVPCIPv6InputHelperText } from 'src/features/VPCs/utils';
-import { useVPCDualStack } from 'src/hooks/useVPCDualStack';
+import {
+ VPC_AUTO_ASSIGN_IPV6_TOOLTIP,
+ VPC_IPV6_INPUT_HELPER_TEXT,
+} from 'src/features/VPCs/constants';
import type {
LinodeInterface,
@@ -32,18 +31,6 @@ export const VPCIPv6Address = (props: Props) => {
formState: { errors },
} = useFormContext();
- const { isDualStackEnabled } = useVPCDualStack();
-
- const { data: subnet } = useSubnetQuery(
- linodeInterface.vpc?.vpc_id ?? -1,
- linodeInterface.vpc?.subnet_id ?? -1,
- Boolean(
- isDualStackEnabled &&
- linodeInterface.vpc?.vpc_id &&
- linodeInterface.vpc?.subnet_id
- )
- );
-
const error = errors.vpc?.ipv6?.message;
return (
@@ -62,7 +49,7 @@ export const VPCIPv6Address = (props: Props) => {
}
- label="Auto-assign VPC IPv6 address"
+ label="Auto-assign VPC IPv6"
onChange={(e, checked) =>
field.onChange(
checked ? 'auto' : linodeInterface.vpc?.ipv6?.slaac[0].range
@@ -70,23 +57,12 @@ export const VPCIPv6Address = (props: Props) => {
}
sx={{ pl: 0.3, mr: 0 }}
/>
-
- Automatically assign an IPv6 address as a private IP address
- for this Linode in the VPC. A /52 IPv6 network
- prefix is allocated for the VPC.
-
- }
- />
+
{field.value !== 'auto' && (
{
expect(getByText('Subnets')).toBeVisible();
expect(getByText('Subnet Label')).toBeVisible();
- expect(getByText('Subnet IP Address Range')).toBeVisible();
+ expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible();
expect(getByText('Add another Subnet')).toBeVisible();
});
});
diff --git a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
index 94440f7ce22..1303dcdcd83 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/FormComponents/VPCTopSectionContent.tsx
@@ -213,7 +213,7 @@ export const VPCTopSectionContent = (props: Props) => {
sm: 12,
xs: 12,
}}
- heading="IPv4 + IPv6 (dual-stack)"
+ heading="IPv4 + IPv6 (Dual Stack)"
onClick={() => {
field.onChange([
{
@@ -244,7 +244,9 @@ export const VPCTopSectionContent = (props: Props) => {
The VPC supports both IPv4 and IPv6 addresses.
- {RFC1918HelperText}
+
+ For IPv4, {RFC1918HelperText}
+
For IPv6, the VPC is assigned an IPv6 prefix
length of /52 by default.
diff --git a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
index 6a2ed8e1d24..9e7d6199be0 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/MultipleSubnetInput.test.tsx
@@ -41,7 +41,7 @@ describe('MultipleSubnetInput', () => {
});
expect(getAllByText('Subnet Label')).toHaveLength(3);
- expect(getAllByText('Subnet IP Address Range')).toHaveLength(3);
+ expect(getAllByText('Subnet IPv4 Range (CIDR)')).toHaveLength(3);
getByDisplayValue('subnet 0');
getByDisplayValue('subnet 1');
getByDisplayValue('subnet 2');
diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
index b897c639fa7..e5e2482248a 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.test.tsx
@@ -65,7 +65,7 @@ describe('SubnetNode', () => {
});
screen.getByText('Subnet Label');
- screen.getByText('Subnet IP Address Range');
+ screen.getByText('Subnet IPv4 Range (CIDR)');
});
it('should show a removable button if not a drawer', () => {
@@ -133,7 +133,7 @@ describe('SubnetNode', () => {
},
});
- expect(screen.getByText('IPv6 Prefix Length')).toBeVisible();
+ expect(screen.getByText('Subnet IPv6 Prefix Length')).toBeVisible();
const select = screen.getByRole('combobox');
expect(select).toHaveValue('/56');
});
diff --git a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
index a0f1fbac83c..6476b71926c 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/SubnetNode.tsx
@@ -78,7 +78,7 @@ export const SubnetNode = (props: Props) => {
errorText={fieldState.error?.message}
helperText={!shouldDisplayIPv6 && availableIPv4HelperText}
inputId={`subnet-ipv4-${idx}`}
- label="Subnet IP Address Range"
+ label="Subnet IPv4 Range (CIDR)"
onBlur={field.onBlur}
onChange={field.onChange}
value={field.value}
@@ -96,11 +96,14 @@ export const SubnetNode = (props: Props) => {
numberOfAvailableIPv4Linodes,
calculateAvailableIPv6Linodes(field.value)
)}`}
- label="IPv6 Prefix Length"
+ label="Subnet IPv6 Prefix Length"
onChange={(_, option) => field.onChange(option.value)}
options={SUBNET_IPV6_PREFIX_LENGTHS}
sx={{
width: 140,
+ '& .MuiInputLabel-root': {
+ overflow: 'visible',
+ },
}}
value={SUBNET_IPV6_PREFIX_LENGTHS.find(
(option) => option.value === field.value
diff --git a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
index 868507687e7..d9b888e7cea 100644
--- a/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreate/VPCCreate.test.tsx
@@ -34,7 +34,7 @@ describe('VPC create page', () => {
expect(getByText('Description')).toBeVisible();
expect(getByText('Subnets')).toBeVisible();
expect(getByText('Subnet Label')).toBeVisible();
- expect(getByText('Subnet IP Address Range')).toBeVisible();
+ expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible();
expect(getByText('Add another Subnet')).toBeVisible();
expect(getByText('Create VPC')).toBeVisible();
});
@@ -46,7 +46,7 @@ describe('VPC create page', () => {
await userEvent.click(addSubnet);
const subnetLabels = screen.getAllByText('Subnet Label');
- const subnetIps = screen.getAllByText('Subnet IP Address Range');
+ const subnetIps = screen.getAllByText('Subnet IPv4 Range (CIDR)');
expect(subnetLabels).toHaveLength(2);
expect(subnetIps).toHaveLength(2);
@@ -55,14 +55,14 @@ describe('VPC create page', () => {
await userEvent.click(deleteSubnet);
const subnetLabelAfter = screen.getAllByText('Subnet Label');
- const subnetIpsAfter = screen.getAllByText('Subnet IP Address Range');
+ const subnetIpsAfter = screen.getAllByText('Subnet IPv4 Range (CIDR)');
expect(subnetLabelAfter).toHaveLength(1);
expect(subnetIpsAfter).toHaveLength(1);
});
it('should display that a subnet ip is invalid and require a subnet label if a user adds an invalid subnet ip', async () => {
renderWithTheme();
- const subnetIp = screen.getByText('Subnet IP Address Range');
+ const subnetIp = screen.getByText('Subnet IPv4 Range (CIDR)');
expect(subnetIp).toBeInTheDocument();
const createVPCButton = screen.getByText('Create VPC');
expect(createVPCButton).toBeInTheDocument();
@@ -99,7 +99,7 @@ describe('VPC create page', () => {
const description = screen.getByRole('textbox', { name: /description/i });
expect(description).toBeDisabled();
expect(getByLabelText('Subnet Label')).toBeDisabled();
- expect(getByLabelText('Subnet IP Address Range')).toBeDisabled();
+ expect(getByLabelText('Subnet IPv4 Range (CIDR)')).toBeDisabled();
expect(getByText('Add another Subnet')).toBeDisabled();
expect(getByText('Create VPC')).toBeDisabled();
});
@@ -117,7 +117,7 @@ describe('VPC create page', () => {
const description = screen.getByRole('textbox', { name: /description/i });
expect(description).toBeEnabled();
expect(getByLabelText('Subnet Label')).toBeEnabled();
- expect(getByLabelText('Subnet IP Address Range')).toBeEnabled();
+ expect(getByLabelText('Subnet IPv4 Range (CIDR)')).toBeEnabled();
expect(getByText('Add another Subnet')).toBeEnabled();
expect(getByText('Create VPC')).toBeEnabled();
});
diff --git a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
index fff4e5bc42c..ba8c730315f 100644
--- a/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCCreateDrawer/VPCCreateDrawer.test.tsx
@@ -38,7 +38,7 @@ describe('VPC Create Drawer', () => {
expect(getByText('Description')).toBeVisible();
expect(getByText('Subnets')).toBeVisible();
expect(getByText('Subnet Label')).toBeVisible();
- expect(getByText('Subnet IP Address Range')).toBeVisible();
+ expect(getByText('Subnet IPv4 Range (CIDR)')).toBeVisible();
expect(getByText('Add another Subnet')).toBeVisible();
expect(getByRole('button', { name: 'Create VPC' })).toBeVisible();
expect(getByText('Cancel')).toBeVisible();
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx
index 549911f7cba..b744e8f27cb 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx
@@ -59,9 +59,7 @@ describe('Subnet Assign Linodes Drawer', () => {
);
- const header = getByText(
- 'Assign Linodes to subnet: subnet-1 (10.0.0.0/24)'
- );
+ const header = getByText('Assign Linodes to subnet: subnet-1');
expect(header).toBeVisible();
const notice = getByTestId('subnet-linode-action-notice');
expect(notice).toBeVisible();
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
index b28deda1f32..ada602f3e86 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx
@@ -24,7 +24,6 @@ import { useTheme } from '@mui/material/styles';
import { useFormik } from 'formik';
import * as React from 'react';
-import { Code } from 'src/components/Code/Code';
import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV';
import { Link } from 'src/components/Link';
import { RemovableSelectionsListTable } from 'src/components/RemovableSelectionsList/RemovableSelectionsListTable';
@@ -37,6 +36,9 @@ import { getDefaultFirewallForInterfacePurpose } from 'src/features/Linodes/Lino
import {
REMOVABLE_SELECTIONS_LINODES_TABLE_HEADERS,
VPC_AUTO_ASSIGN_IPV4_TOOLTIP,
+ VPC_AUTO_ASSIGN_IPV6_TOOLTIP,
+ VPC_IPV4_INPUT_HELPER_TEXT,
+ VPC_IPV6_INPUT_HELPER_TEXT,
VPC_MULTIPLE_CONFIGURATIONS_LEARN_MORE_LINK,
} from 'src/features/VPCs/constants';
import { useUnassignLinode } from 'src/hooks/useUnassignLinode';
@@ -50,7 +52,6 @@ import {
REGIONAL_LINODE_MESSAGE,
} from '../constants';
import {
- generateVPCIPv6InputHelperText,
getLinodeInterfaceIPv4Ranges,
getLinodeInterfacePrimaryIPv4,
getVPCInterfacePayload,
@@ -587,9 +588,7 @@ export const SubnetAssignLinodesDrawer = (
isFetching={isFetching}
onClose={handleOnClose}
open={open}
- title={`Assign Linodes to subnet: ${subnet?.label ?? 'Unknown'} (${
- subnet?.ipv4 ?? subnet?.ipv6 ?? 'Unknown'
- })`}
+ title={`Assign Linodes to subnet: ${subnet?.label ?? 'Unknown'}`}
>
{!userCanAssignLinodes && (
Auto-assign VPC IPv4 address}
+ label={Auto-assign VPC IPv4}
sx={{ marginRight: 0 }}
/>
-
- Automatically assign an IPv4 address as{' '}
- {showIPv6Content ? 'a' : 'the'} private IP address for
- this Linode in the VPC.
-
- ) : (
- VPC_AUTO_ASSIGN_IPV4_TOOLTIP
- )
- }
- />
+
{!autoAssignVPCIPv4Address && (
{
setFieldValue('chosenIPv4', e.target.value);
setAssignLinodesErrors({});
}}
- style={{
- marginBottom: showIPv6Content ? theme.spacingFunction(24) : 0,
- }}
value={values.chosenIPv4}
/>
)}
@@ -698,29 +682,19 @@ export const SubnetAssignLinodesDrawer = (
}
data-testid="vpc-ipv6-checkbox"
disabled={!userCanAssignLinodes}
- label={
- Auto-assign VPC IPv6 address
- }
+ label={Auto-assign VPC IPv6}
sx={{ marginRight: 0 }}
/>
- Automatically assign an IPv6 address as a private IP
- address for this Linode in the VPC. A /52{' '}
- IPv6 network prefix is allocated for the VPC.
-
- }
+ text={VPC_AUTO_ASSIGN_IPV6_TOOLTIP}
/>
{!autoAssignVPCIPv6Address && (
{
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.test.tsx
index 1ee26eae11e..fb70c9cb76b 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.test.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.test.tsx
@@ -88,7 +88,7 @@ describe('Create Subnet Drawer', () => {
},
});
- const ipv4Input = screen.getByText('Subnet IPv4 Address Range');
+ const ipv4Input = screen.getByText('Subnet IPv4 Range (CIDR)');
expect(ipv4Input).toBeVisible();
expect(ipv4Input).toBeEnabled();
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx
index 7904bc0a33f..01f5155186f 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx
@@ -147,7 +147,7 @@ export const SubnetCreateDrawer = (props: Props) => {
errorText={fieldState.error?.message}
label={
shouldDisplayIPv6
- ? 'Subnet IPv4 Address Range'
+ ? 'Subnet IPv4 Range (CIDR)'
: 'Subnet IP Address Range'
}
onBlur={field.onBlur}
diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
index bc078a88486..24f5cfed09d 100644
--- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
+++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx
@@ -385,9 +385,7 @@ const getIPRangesCellContents = (
determineNoneSingleOrMultipleWithChip(ipv6Ranges);
// For IPv6 columns, we want to display em dashes instead of 'None' in the cells to help indicate the VPC/subnet does not support IPv6
- return noneSingleOrMultipleWithChipIPV6 === 'None'
- ? '—'
- : noneSingleOrMultipleWithChipIPV6;
+ return !interfaceData.ipv6 ? '—' : noneSingleOrMultipleWithChipIPV6;
} else {
const linodeInterfaceVPCRanges =
ipType === 'ipv4'
@@ -399,7 +397,7 @@ const getIPRangesCellContents = (
);
// For IPv6 columns, we want to display em dashes instead of 'None' in the cells to help indicate the VPC/subnet does not support IPv6
- return ipType === 'ipv6' && noneSingleOrMultipleWithChip === 'None'
+ return ipType === 'ipv6' && !interfaceData.vpc?.ipv6
? '—'
: noneSingleOrMultipleWithChip;
}
diff --git a/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx b/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx
index 4c1ee246cb8..e6d0b4df449 100644
--- a/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx
+++ b/packages/manager/src/features/VPCs/components/VPCRangesDescription.tsx
@@ -39,8 +39,8 @@ export const VPCIPv6RangesDescription = (props: TypographyProps) => {
export const DualStackVPCRangesDescription = (props: TypographyProps) => {
return (
- If you need more IPs, you can add IPv4 and IPv6 address ranges to let your
- VPC connect to services running on this Linode.{' '}
+ You can add IPv4 and IPv6 address ranges to let your VPC connect to
+ services running on this Linode.{' '}
Learn more.
);
diff --git a/packages/manager/src/features/VPCs/constants.tsx b/packages/manager/src/features/VPCs/constants.tsx
index 6a40c4217c4..2bb73f1fd31 100644
--- a/packages/manager/src/features/VPCs/constants.tsx
+++ b/packages/manager/src/features/VPCs/constants.tsx
@@ -1,4 +1,4 @@
-import { Typography } from '@linode/ui';
+import { Stack, Typography } from '@linode/ui';
import React from 'react';
import { Code } from 'src/components/Code/Code';
@@ -31,18 +31,20 @@ export const MULTIPLE_CONFIGURATIONS_MESSAGE =
export const VPC_AUTO_ASSIGN_IPV4_TOOLTIP =
'Automatically assign an IPv4 address as the private IP address for this Linode in the VPC.';
-export const VPC_AUTO_ASSIGN_IPV6_TOOLTIP = (
-
- Automatically assign an IPv6 address from the subnet’s allocated{' '}
- /64 prefix block.
-
-);
+export const VPC_IPV4_INPUT_HELPER_TEXT =
+ 'Define an IP address derived from the subnet IPv4 range.';
+
+export const VPC_AUTO_ASSIGN_IPV6_TOOLTIP =
+ 'Automatically assign an IPv6 address for this Linode in the VPC.';
+
+export const VPC_IPV6_INPUT_HELPER_TEXT =
+ 'Define a /64 prefix derived from the subnet IPv6 range.';
export const CANNOT_CREATE_VPC_MESSAGE =
"You don't have permissions to create a new VPC. Please contact an account administrator for details.";
export const VPC_CREATE_FORM_SUBNET_HELPER_TEXT =
- 'Each VPC can further segment itself into distinct networks through the use of multiple subnets. These subnets can isolate various functionality of an application.';
+ 'A VPC can be divided into multiple subnets to create isolated network segments. Subnets help separate different parts of your application, such as databases, frontend services, and backend services.';
export const VPC_CREATE_FORM_VPC_HELPER_TEXT =
'A VPC is an isolated network that enables private communication between Compute Instances within the same data center.';
@@ -59,11 +61,27 @@ export const ASSIGN_IP_RANGES_TITLE = 'Assign additional IP ranges';
export const PUBLIC_IPV4_ACCESS_CHECKBOX_TOOLTIP =
'Allow IPv4 access to the internet using 1:1 NAT on the VPC interface.';
-export const PUBLIC_IPV6_ACCESS_CHECKBOX_TOOLTIP =
- "To enable IPv6 internet access, assign a globally routed IPv6 prefix to the subnet and enable the interface's Public setting.";
+export const PUBLIC_IPV6_ACCESS_CHECKBOX_TOOLTIP = (
+
+
+ Enable to allow two-way IPv6 traffic between your VPC and the internet.
+
+
+ Disable to restrict IPv6 traffic to within the VPC.
+
+
+ When enabled, Linodes will be publicly reachable over IPv6 unless
+ restricted by a Cloud Firewall.
+
+
+);
-export const RFC1918HelperText =
- 'The VPC can use the entire RFC 1918 specified range for subnetting except for 192.168.128.0/17.';
+export const RFC1918HelperText = (
+
+ VPCs can use the full RFC 1918 private IP address range for subnetting,
+ except for 192.168.128.0/17, which is reserved.
+
+);
// Linode Config dialog helper text for unrecommended configurations
export const LINODE_UNREACHABLE_HELPER_TEXT =
diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts
index bf5e3997a9b..0d6682a0337 100644
--- a/packages/manager/src/features/VPCs/utils.test.ts
+++ b/packages/manager/src/features/VPCs/utils.test.ts
@@ -12,7 +12,6 @@ import {
} from 'src/factories/subnets';
import {
- generateVPCIPv6InputHelperText,
getLinodeInterfaceIPv4Ranges,
getLinodeInterfacePrimaryIPv4,
getUniqueLinodesFromSubnets,
@@ -385,18 +384,3 @@ describe('transformLinodeInterfaceErrorsToFormikErrors', () => {
]);
});
});
-
-describe('generateVPCIPv6InputHelperText', () => {
- it('returns null when subnetIPv6Range is falsy', () => {
- expect(generateVPCIPv6InputHelperText(undefined)).toBeNull();
- expect(generateVPCIPv6InputHelperText('')).toBeNull();
- });
-
- it('returns helper text that correctly represents the number of fixed hextets', () => {
- const result = generateVPCIPv6InputHelperText('2600:3c03::/64');
- expect(result).toBe('The first 4 hextets of 2600:3c03::/64 are fixed.');
-
- const result2 = generateVPCIPv6InputHelperText('2600:3c03::/56');
- expect(result2).toBe('The first 3.5 hextets of 2600:3c03::/56 are fixed.');
- });
-});
diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts
index 6d4057c487a..5e96fb4388e 100644
--- a/packages/manager/src/features/VPCs/utils.ts
+++ b/packages/manager/src/features/VPCs/utils.ts
@@ -240,15 +240,3 @@ export const transformLinodeInterfaceErrorsToFormikErrors = (
return errors;
};
-
-export const generateVPCIPv6InputHelperText = (subnetIPv6Range?: string) => {
- if (!subnetIPv6Range) {
- return null;
- }
-
- const [, ipv6Mask] = subnetIPv6Range.split('/');
-
- const fixedHextets = Number(ipv6Mask) / 16;
-
- return `The first ${fixedHextets} hextets of ${subnetIPv6Range} are fixed.`;
-};
From 726b2a1b3c5ce8ddbf480d2d426d729fe450d962 Mon Sep 17 00:00:00 2001
From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com>
Date: Thu, 2 Oct 2025 10:12:23 -0400
Subject: [PATCH 49/54] test: [M3-10506] - Fix flaky Object Storage
Multicluster object upload test (#12847)
* M3-10506 Fix flaky Object Storage Multicluster object upload test
* Minor fix
* Added changeset: Fix flaky Object Storage Multicluster object upload test
---------
Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com>
---
.../.changeset/pr-12847-tests-1757523022214.md | 5 +++++
...object-storage-objects-multicluster.spec.ts | 18 +++++++++++++++++-
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 packages/manager/.changeset/pr-12847-tests-1757523022214.md
diff --git a/packages/manager/.changeset/pr-12847-tests-1757523022214.md b/packages/manager/.changeset/pr-12847-tests-1757523022214.md
new file mode 100644
index 00000000000..82afaa0e823
--- /dev/null
+++ b/packages/manager/.changeset/pr-12847-tests-1757523022214.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tests
+---
+
+Fix flaky Object Storage Multicluster object upload test ([#12847](https://github.com/linode/manager/pull/12847))
diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
index 38e6e618872..95814459df2 100644
--- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
+++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts
@@ -45,12 +45,13 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => {
const setUpBucketMulticluster = (
label: string,
regionId: string,
- cors_enabled: boolean = true
+ cors_enabled: boolean = false
) => {
return createBucket(
createObjectStorageBucketFactoryGen1.build({
// to avoid 400 responses from the API.
cluster: undefined,
+ // disable CORS to avoid 400 responses from the API.
cors_enabled,
label,
@@ -153,6 +154,21 @@ describe('Object Storage Multicluster objects', () => {
{ name: '2.jpg', path: 'object-storage-files/2.jpg' },
];
+ cy.on('fail', (err) => {
+ if (
+ err.name === 'CypressError' &&
+ err.message.includes('uploadObject') &&
+ err.message.includes('Timed out')
+ ) {
+ // Handle the timeout error and retry
+ uploadFile(bucketFiles[0].path, bucketFiles[0].name);
+ cy.wait('@uploadObject', { timeout: 160000 });
+ // Return false to prevent test failure
+ return false;
+ }
+ throw err;
+ });
+
cy.defer(
() => setUpBucketMulticluster(bucketLabel, bucketRegionId),
'creating Object Storage bucket'
From 22aec424d8c6cf68e016885ebb32136758ec92ab Mon Sep 17 00:00:00 2001
From: smans-akamai
Date: Thu, 2 Oct 2025 11:07:12 -0400
Subject: [PATCH 50/54] feat: [UIE-9181] - DBaaS - Display hostname in summary
tables based on VPC configuration and refactor connection details (#12939)
* feat: [UIE-9181] - DBaaS - Display hostname in summary tables based on VPC configuration and refactor connection details tables
* Adding changesets
* Addressing initial feedback
* Applying additional feedback
---
.../pr-12939-added-1759348628476.md | 5 +
.../pr-12939-changed-1759348579491.md | 5 +
.../ConnectionDetailsHostRows.test.tsx | 186 ++++++++++++
.../ConnectionDetailsHostRows.tsx | 138 +++++++++
.../ConnectionDetailsRow.test.tsx | 20 ++
.../DatabaseDetail/ConnectionDetailsRow.tsx | 29 ++
.../DatabaseManageNetworking.tsx | 72 +----
.../DatabaseSummaryConnectionDetails.tsx | 266 +++++-------------
.../src/features/Databases/constants.ts | 9 +
packages/manager/src/mocks/serverHandlers.ts | 8 +
10 files changed, 478 insertions(+), 260 deletions(-)
create mode 100644 packages/manager/.changeset/pr-12939-added-1759348628476.md
create mode 100644 packages/manager/.changeset/pr-12939-changed-1759348579491.md
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx
create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx
diff --git a/packages/manager/.changeset/pr-12939-added-1759348628476.md b/packages/manager/.changeset/pr-12939-added-1759348628476.md
new file mode 100644
index 00000000000..d72e33a9b22
--- /dev/null
+++ b/packages/manager/.changeset/pr-12939-added-1759348628476.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+ConnectionDetailsRow and ConnectionDetailsHostRows components to manage connection details table content ([#12939](https://github.com/linode/manager/pull/12939))
diff --git a/packages/manager/.changeset/pr-12939-changed-1759348579491.md b/packages/manager/.changeset/pr-12939-changed-1759348579491.md
new file mode 100644
index 00000000000..8619873bdc9
--- /dev/null
+++ b/packages/manager/.changeset/pr-12939-changed-1759348579491.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Changed
+---
+
+DBaaS - Host field in connection details table renders based on VPC configuration and host fields are synced between Details and Networking tabs ([#12939](https://github.com/linode/manager/pull/12939))
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx
new file mode 100644
index 00000000000..712a21a2651
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx
@@ -0,0 +1,186 @@
+import React from 'react';
+
+import { databaseFactory } from 'src/factories/databases';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { ConnectionDetailsHostRows } from './ConnectionDetailsHostRows';
+
+import type { Database } from '@linode/api-v4/lib/databases';
+
+const DEFAULT_PRIMARY = 'private-db-mysql-default-primary.net';
+const DEFAULT_STANDBY = 'db-mysql-default-standby.net';
+
+const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net';
+const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net';
+
+describe('ConnectionDetailsHostRows', () => {
+ it('should display correctly for default database', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: DEFAULT_PRIMARY,
+ secondary: undefined,
+ standby: DEFAULT_STANDBY,
+ },
+ platform: 'rdbms-default',
+ private_network: null, // Added to test that Host field renders
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+
+ expect(queryAllByText('Host')).toHaveLength(1);
+ expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1);
+
+ expect(queryAllByText('Read-only Host')).toHaveLength(1);
+ });
+
+ it('should display N/A for default DB with blank read-only Host field', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: DEFAULT_PRIMARY,
+ secondary: undefined,
+ standby: undefined,
+ },
+ platform: 'rdbms-default',
+ });
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+
+ expect(queryAllByText('N/A')).toHaveLength(1);
+ });
+
+ it('should display Host rows correctly for legacy db', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: LEGACY_PRIMARY,
+ secondary: LEGACY_SECONDARY,
+ standby: undefined,
+ },
+ id: 22,
+ platform: 'rdbms-legacy',
+ port: 3306,
+ ssl_connection: true,
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+
+ expect(queryAllByText('Host')).toHaveLength(1);
+ expect(queryAllByText(LEGACY_PRIMARY)).toHaveLength(1);
+
+ expect(queryAllByText('Private Network Host')).toHaveLength(1);
+ expect(queryAllByText(LEGACY_SECONDARY)).toHaveLength(1);
+ });
+
+ it('should display provisioning text when hosts are not available', () => {
+ const database = databaseFactory.build({
+ hosts: undefined,
+ platform: 'rdbms-default',
+ }) as Database;
+
+ const { getByText } = renderWithTheme(
+
+ );
+
+ const hostNameProvisioningText = getByText(
+ 'Your hostname will appear here once it is available.'
+ );
+
+ expect(hostNameProvisioningText).toBeInTheDocument();
+ });
+
+ it('should display Host when VPC is not configured', () => {
+ const privateStrIndex = DEFAULT_PRIMARY.indexOf('-');
+ const baseHostName = DEFAULT_PRIMARY.slice(privateStrIndex + 1);
+
+ const database = databaseFactory.build({
+ hosts: {
+ primary: baseHostName,
+ },
+ platform: 'rdbms-default',
+ private_network: null, // VPC not configured
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+
+ expect(queryAllByText('Host')).toHaveLength(1);
+ expect(queryAllByText(baseHostName)).toHaveLength(1);
+ });
+
+ it('should display Private Host field when VPC is configured with public access as false', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: DEFAULT_PRIMARY,
+ secondary: undefined,
+ standby: undefined,
+ },
+ platform: 'rdbms-default',
+ private_network: {
+ public_access: false,
+ subnet_id: 1,
+ vpc_id: 123,
+ },
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+ expect(queryAllByText('Private Host')).toHaveLength(1);
+ expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1);
+ });
+
+ it('should display both Private Host and Public Host fields when VPC is configured with public access as true', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: DEFAULT_PRIMARY,
+ secondary: undefined,
+ standby: undefined,
+ },
+ platform: 'rdbms-default',
+ private_network: {
+ public_access: true,
+ subnet_id: 1,
+ vpc_id: 123,
+ },
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+ // Verify that both Private Host and Public Host fields are rendered
+ expect(queryAllByText('Private Host')).toHaveLength(1);
+ expect(queryAllByText('Public Host')).toHaveLength(1);
+
+ // Verify that the Private hostname is rendered correctly
+ expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1);
+ // Verify that the Public hostname is rendered correctly
+ const privateStrIndex = DEFAULT_PRIMARY.indexOf('-');
+ const baseHostName = DEFAULT_PRIMARY.slice(privateStrIndex + 1);
+ const expectedPublicHostname = `public-${baseHostName}`;
+ expect(queryAllByText(expectedPublicHostname)).toHaveLength(1);
+ });
+
+ it('should display Read-only Host when read-only host is available', () => {
+ const database = databaseFactory.build({
+ hosts: {
+ primary: DEFAULT_PRIMARY,
+ secondary: undefined,
+ standby: DEFAULT_STANDBY,
+ },
+ platform: 'rdbms-default',
+ }) as Database;
+
+ const { queryAllByText } = renderWithTheme(
+
+ );
+
+ expect(queryAllByText('Read-only Host')).toHaveLength(1);
+ expect(queryAllByText(DEFAULT_STANDBY)).toHaveLength(1);
+ });
+});
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx
new file mode 100644
index 00000000000..ca9e7ed6298
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx
@@ -0,0 +1,138 @@
+import { TooltipIcon, Typography } from '@linode/ui';
+import * as React from 'react';
+
+import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
+
+import {
+ SUMMARY_HOST_TOOLTIP_COPY,
+ SUMMARY_PRIVATE_HOST_COPY,
+ SUMMARY_PRIVATE_HOST_LEGACY_COPY,
+} from '../constants';
+import { getReadOnlyHost, isLegacyDatabase } from '../utilities';
+import { ConnectionDetailsRow } from './ConnectionDetailsRow';
+import { useStyles } from './DatabaseSummary/DatabaseSummaryConnectionDetails.style';
+
+import type { Database } from '@linode/api-v4/lib/databases/types';
+
+interface ConnectionDetailsHostRowsProps {
+ database: Database;
+}
+
+/**
+ * This component is responsible for conditionally rendering the Private Host, Public Host, and Read-only Host rows that get displayed in
+ * the Connection Details tables that appear in the Database Summary and Networking tabs */
+export const ConnectionDetailsHostRows = (
+ props: ConnectionDetailsHostRowsProps
+) => {
+ const { database } = props;
+ const { classes } = useStyles();
+
+ const sxTooltipIcon = {
+ marginLeft: '4px',
+ padding: '0px',
+ };
+
+ const hostTooltipComponentProps = {
+ tooltip: {
+ style: {
+ minWidth: 285,
+ },
+ },
+ };
+
+ const isLegacy = isLegacyDatabase(database);
+ const hasVPC = Boolean(database?.private_network?.vpc_id);
+ const hasPublicVPC = hasVPC && database?.private_network?.public_access;
+
+ const getHostContent = (
+ mode: 'default' | 'private' | 'public' = 'default'
+ ) => {
+ let primaryHostName = database.hosts?.primary;
+
+ if (mode === 'public' && primaryHostName) {
+ // Remove 'private-' substring at the beginning of the hostname and replace it with 'public-'
+ const privateStrIndex = database.hosts.primary.indexOf('-');
+ const baseHostName = database.hosts.primary.slice(privateStrIndex + 1);
+ primaryHostName = `public-${baseHostName}`;
+ }
+
+ if (primaryHostName) {
+ return (
+ <>
+ {primaryHostName}
+
+ {!isLegacy && (
+
+ )}
+ >
+ );
+ }
+
+ return (
+
+
+ Your hostname will appear here once it is available.
+
+
+ );
+ };
+
+ const getReadOnlyHostContent = () => {
+ const defaultValue = isLegacy ? '-' : 'N/A';
+ const value = getReadOnlyHost(database) || defaultValue;
+ const hasHost = value !== '-' && value !== 'N/A';
+ return (
+ <>
+ {value}
+ {value && hasHost && (
+
+ )}
+ {isLegacy && (
+
+ )}
+ {!isLegacy && hasHost && (
+
+ )}
+ >
+ );
+ };
+
+ return (
+ <>
+
+ {getHostContent(hasVPC ? 'private' : 'default')}
+
+ {hasPublicVPC && (
+
+ {getHostContent('public')}
+
+ )}
+
+ {getReadOnlyHostContent()}
+
+ >
+ );
+};
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx
new file mode 100644
index 00000000000..3fa87196cf3
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { ConnectionDetailsRow } from './ConnectionDetailsRow';
+
+describe('ConnectionDetailsRow', () => {
+ it('should render provided label and children', async () => {
+ const { getByText } = renderWithTheme(
+
+ Test Children Prop
+
+ );
+ const testLabel = getByText('Test Label');
+ const testChildrenProp = getByText('Test Children Prop');
+
+ expect(testLabel).toBeInTheDocument();
+ expect(testChildrenProp).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx
new file mode 100644
index 00000000000..cebfd7593c7
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx
@@ -0,0 +1,29 @@
+import { Grid } from '@mui/material';
+import * as React from 'react';
+
+import {
+ StyledLabelTypography,
+ StyledValueGrid,
+} from './DatabaseSummary/DatabaseSummaryClusterConfiguration.style';
+
+interface ConnectionDetailsRowProps {
+ children: React.ReactNode;
+ label: string;
+}
+
+export const ConnectionDetailsRow = (props: ConnectionDetailsRowProps) => {
+ const { children, label } = props;
+ return (
+ <>
+
+ {label}
+
+ {children}
+ >
+ );
+};
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx
index 624bef08e12..3e35fa76bbb 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx
@@ -6,7 +6,6 @@ import {
ErrorState,
Typography,
} from '@linode/ui';
-import { Grid } from '@mui/material';
import React from 'react';
import { makeStyles } from 'tss-react/mui';
@@ -14,12 +13,9 @@ import { Link } from 'src/components/Link';
import { useFlags } from 'src/hooks/useFlags';
import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../../constants';
-import { getReadOnlyHost } from '../../utilities';
-import {
- StyledGridContainer,
- StyledLabelTypography,
- StyledValueGrid,
-} from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style';
+import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows';
+import { ConnectionDetailsRow } from '../ConnectionDetailsRow';
+import { StyledGridContainer } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style';
import DatabaseManageNetworkingDrawer from './DatabaseManageNetworkingDrawer';
import { DatabaseNetworkingUnassignVPCDialog } from './DatabaseNetworkingUnassignVPCDialog';
@@ -64,10 +60,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => {
flexDirection: 'column',
},
},
- provisioningText: {
- font: theme.font.normal,
- fontStyle: 'italic',
- },
}));
const flags = useFlags();
@@ -80,8 +72,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => {
const vpcId = Number(database.private_network?.vpc_id);
const hasVPCConfigured = Boolean(vpcId);
const gridContainerSize = { lg: 7, md: 10 };
- const gridValueSize = { md: 8, xs: 9 };
- const gridLabelSize = { md: 4, xs: 3 };
const {
data: vpcs,
@@ -99,12 +89,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => {
);
const hasVPCs = Boolean(vpcs && vpcs.length > 0);
- const readOnlyHost = () => {
- const defaultValue = 'N/A';
- const value = getReadOnlyHost(database) || defaultValue;
- return {value};
- };
-
const onManageAccess = () => {
setIsManageNetworkingDrawerOpen(true);
};
@@ -158,54 +142,26 @@ export const DatabaseManageNetworking = ({ database }: Props) => {
-
- Connection Type
-
-
+
{hasVPCConfigured ? 'VPC' : 'Public'}
-
+
+
{hasVPCConfigured && (
<>
-
- VPC
-
-
+
{currentVPC?.label}
-
-
- Subnet
-
-
+
+
{`${currentSubnet?.label} (${currentSubnet?.ipv4})`}
-
+
>
)}
-
- Host
-
-
- {database.hosts?.primary ? (
- database.hosts?.primary
- ) : (
-
- Your hostname will appear here once it is available.
-
- )}
-
-
- Read-only Host
-
- {readOnlyHost()}
+
{hasVPCConfigured && (
- <>
-
- Public Access
-
-
- {database?.private_network?.public_access ? 'Yes' : 'No'}
-
- >
+
+ {database?.private_network?.public_access ? 'Yes' : 'No'}
+
)}
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx
index 89bc3c80c51..a725b87d4fc 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx
@@ -2,7 +2,6 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases';
import { useDatabaseCredentialsQuery } from '@linode/queries';
import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui';
import { downloadFile } from '@linode/utilities';
-import Grid from '@mui/material/Grid';
import { Button } from 'akamai-cds-react-components';
import { useSnackbar } from 'notistack';
import * as React from 'react';
@@ -14,12 +13,10 @@ import { DB_ROOT_USERNAME } from 'src/constants';
import { useFlags } from 'src/hooks/useFlags';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
-import { getReadOnlyHost, isDefaultDatabase } from '../../utilities';
-import {
- StyledGridContainer,
- StyledLabelTypography,
- StyledValueGrid,
-} from './DatabaseSummaryClusterConfiguration.style';
+import { isDefaultDatabase } from '../../utilities';
+import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows';
+import { ConnectionDetailsRow } from '../ConnectionDetailsRow';
+import { StyledGridContainer } from './DatabaseSummaryClusterConfiguration.style';
import { useStyles } from './DatabaseSummaryConnectionDetails.style';
import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types';
@@ -34,15 +31,13 @@ const sxTooltipIcon = {
padding: '0px',
};
-const privateHostCopy =
- 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.';
-
export const DatabaseSummaryConnectionDetails = (props: Props) => {
const { database } = props;
const { classes } = useStyles();
const { enqueueSnackbar } = useSnackbar();
const flags = useFlags();
const isLegacy = database.platform !== 'rdbms-default';
+ const hasVPC = Boolean(database?.private_network?.vpc_id);
const displayConnectionType =
flags.databaseVpc && isDefaultDatabase(database);
@@ -67,16 +62,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => {
const password =
showCredentials && credentials ? credentials?.password : '••••••••••';
- const hostTooltipComponentProps = {
- tooltip: {
- style: {
- minWidth: 285,
- },
- },
- };
- const HOST_TOOLTIP_COPY =
- 'Use the IPv6 address (AAAA record) for this hostname to avoid network transfer charges when connecting to this database from Linodes within the same region.';
-
const handleShowPasswordClick = () => {
setShowPassword((showCredentials) => !showCredentials);
};
@@ -117,35 +102,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => {
const disableShowBtn = ['failed', 'provisioning'].includes(database.status);
const disableDownloadCACertificateBtn = database.status === 'provisioning';
- const readOnlyHost = () => {
- const defaultValue = isLegacy ? '-' : 'N/A';
- const value = getReadOnlyHost(database) || defaultValue;
- const hasHost = value !== '-' && value !== 'N/A';
- return (
- <>
- {value}
- {value && hasHost && (
-
- )}
- {isLegacy && (
-
- )}
- {!isLegacy && hasHost && (
-
- )}
- >
- );
- };
-
const credentialsBtn = (handleClick: () => void, btnText: string) => {
return (
{
>
);
+ const CredentialsContent = (
+ <>
+ {password}
+ {showCredentials && credentialsLoading ? (
+
+
+
+ ) : credentialsError ? (
+ <>
+ Error retrieving credentials.
+ {credentialsBtn(() => getDatabaseCredentials(), 'Retry')}
+ >
+ ) : (
+ credentialsBtn(
+ handleShowPasswordClick,
+ showCredentials && credentials ? 'Hide' : 'Show'
+ )
+ )}
+ {disableShowBtn && (
+
+ )}
+ {showCredentials && credentials && (
+
+ )}
+ >
+ );
+
return (
<>
Connection Details
-
- Username
-
- {username}
-
- Password
-
-
- {password}
- {showCredentials && credentialsLoading ? (
-
-
-
- ) : credentialsError ? (
- <>
-
- Error retrieving credentials.
-
- {credentialsBtn(() => getDatabaseCredentials(), 'Retry')}
- >
- ) : (
- credentialsBtn(
- handleShowPasswordClick,
- showCredentials && credentials ? 'Hide' : 'Show'
- )
- )}
- {disableShowBtn && (
-
- )}
- {showCredentials && credentials && (
-
- )}
-
-
- Database name
-
-
+ {username}
+
+ {CredentialsContent}
+
+
{isLegacy ? database.engine : 'defaultdb'}
-
-
- Host
-
-
- {database.hosts?.primary ? (
- <>
- {database.hosts?.primary}
-
- {!isLegacy && (
-
- )}
- >
- ) : (
-
-
- Your hostname will appear here once it is available.
-
-
- )}
-
-
-
- {isLegacy ? 'Private Network Host' : 'Read-only Host'}
-
-
-
- {readOnlyHost()}
-
-
- Port
-
-
+
+
+
{database.port}
-
-
- SSL
-
-
+
+
{database.ssl_connection ? 'ENABLED' : 'DISABLED'}
-
+
{displayConnectionType && (
- <>
-
+ ({
+ marginRight: theme.spacingFunction(20),
+ })}
+ >
+ {hasVPC ? 'VPC' : 'Public'}
+
+
- Connection Type
-
-
- ({
- marginRight: theme.spacingFunction(20),
- })}
- >
- {database?.private_network?.vpc_id ? 'VPC' : 'Public'}
-
-
- View Details
-
-
- >
+ View Details
+
+
)}
diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts
index f6b47b107d3..c09afb4d38c 100644
--- a/packages/manager/src/features/Databases/constants.ts
+++ b/packages/manager/src/features/Databases/constants.ts
@@ -53,6 +53,15 @@ export const BACKUPS_INVALID_TIME_VALIDATON_TEXT =
export const BACKUPS_UNABLE_TO_RESTORE_TEXT =
'You can restore a backup after the first backup is completed.';
+export const SUMMARY_HOST_TOOLTIP_COPY =
+ 'Use the IPv6 address (AAAA record) for this hostname to avoid network transfer charges when connecting to this database from Linodes within the same region.';
+
+export const SUMMARY_PRIVATE_HOST_COPY =
+ "The private hostname resolves to an internal IP address and can only be used to access the database cluster from other Linode instances within the same VPC. This connection is secured and doesn't incur transfer costs.";
+
+export const SUMMARY_PRIVATE_HOST_LEGACY_COPY =
+ 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.';
+
// Links
export const LEARN_MORE_LINK_LEGACY =
'https://techdocs.akamai.com/cloud-computing/docs/manage-access-controls';
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index b067c432890..f7871b31f79 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -195,9 +195,17 @@ const makeMockDatabase = (params: PathParams): Database => {
db.ssl_connection = true;
}
const database = databaseFactory.build(db);
+
if (database.platform !== 'rdbms-default') {
delete database.private_network;
}
+
+ if (database.platform === 'rdbms-default' && !!database.private_network) {
+ // When a database is configured with a VPC, the primary host is prepended with 'private-'
+ const privateHost = `private-${database.hosts.primary}`;
+ database.hosts.primary = privateHost;
+ }
+
return database;
};
From 483095e3d2f83d877bdf01738b1e625ec90d752f Mon Sep 17 00:00:00 2001
From: Banks Nussman
Date: Thu, 2 Oct 2025 12:37:40 -0400
Subject: [PATCH 51/54] bump versions (Cloud Manager v1.152.0 and other
packages)
---
packages/api-v4/package.json | 2 +-
packages/manager/package.json | 2 +-
packages/queries/package.json | 2 +-
packages/shared/package.json | 4 ++--
packages/utilities/package.json | 4 ++--
packages/validation/package.json | 2 +-
6 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json
index 2f80884cfe8..559860d778f 100644
--- a/packages/api-v4/package.json
+++ b/packages/api-v4/package.json
@@ -1,6 +1,6 @@
{
"name": "@linode/api-v4",
- "version": "0.149.0",
+ "version": "0.150.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/package.json b/packages/manager/package.json
index f107a7c90e0..6232508998d 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.151.1",
+ "version": "1.152.0",
"private": true,
"type": "module",
"bugs": {
diff --git a/packages/queries/package.json b/packages/queries/package.json
index da2ebc09b46..283eddb8e89 100644
--- a/packages/queries/package.json
+++ b/packages/queries/package.json
@@ -1,6 +1,6 @@
{
"name": "@linode/queries",
- "version": "0.14.0",
+ "version": "0.15.0",
"description": "Linode Utility functions library",
"main": "src/index.js",
"module": "src/index.ts",
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 781bde21a8d..f586871fe92 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@linode/shared",
- "version": "0.8.0",
+ "version": "0.9.0",
"description": "Linode shared feature component library",
"main": "src/index.ts",
"module": "src/index.ts",
@@ -49,4 +49,4 @@
"@types/react-dom": "^19.1.6",
"vite-plugin-svgr": "^3.2.0"
}
-}
\ No newline at end of file
+}
diff --git a/packages/utilities/package.json b/packages/utilities/package.json
index 0de28898e10..1d7b0441c4f 100644
--- a/packages/utilities/package.json
+++ b/packages/utilities/package.json
@@ -1,6 +1,6 @@
{
"name": "@linode/utilities",
- "version": "0.9.0",
+ "version": "0.10.0",
"description": "Linode Utility functions library",
"main": "src/index.ts",
"module": "src/index.ts",
@@ -46,4 +46,4 @@
"@types/react-dom": "^19.1.6",
"factory.ts": "^0.5.1"
}
-}
\ No newline at end of file
+}
diff --git a/packages/validation/package.json b/packages/validation/package.json
index 00692ae8baa..14e7040c24c 100644
--- a/packages/validation/package.json
+++ b/packages/validation/package.json
@@ -1,6 +1,6 @@
{
"name": "@linode/validation",
- "version": "0.75.0",
+ "version": "0.76.0",
"description": "Yup validation schemas for use with the Linode APIv4",
"type": "module",
"main": "lib/index.cjs",
From 169996a94974038490e25c90b55b17df2db82692 Mon Sep 17 00:00:00 2001
From: Banks Nussman
Date: Thu, 2 Oct 2025 12:42:25 -0400
Subject: [PATCH 52/54] add changelogs
---
...r-12851-upcoming-features-1757490281299.md | 5 --
.../pr-12867-changed-1758062421827.md | 5 --
.../pr-12870-changed-1757668838404.md | 5 --
.../pr-12887-changed-1758097710169.md | 5 --
.../pr-12895-added-1758540546949.md | 5 --
...r-12898-upcoming-features-1758553903759.md | 5 --
.../pr-12905-added-1758635135139.md | 5 --
.../pr-12912-changed-1758782562755.md | 5 --
.../pr-12919-added-1758910984747.md | 5 --
packages/api-v4/CHANGELOG.md | 21 ++++++
...r-12802-upcoming-features-1756895590487.md | 5 --
...r-12810-upcoming-features-1757334461818.md | 5 --
.../pr-12825-tech-stories-1757923303722.md | 5 --
.../pr-12838-tech-stories-1757350386803.md | 5 --
.../pr-12842-tests-1757434078486.md | 5 --
.../pr-12847-tests-1757523022214.md | 5 --
...r-12851-upcoming-features-1757490359796.md | 5 --
.../pr-12867-tech-stories-1758062471882.md | 5 --
.../pr-12869-tech-stories-1757684471209.md | 5 --
...r-12870-upcoming-features-1757668940949.md | 5 --
.../pr-12880-changed-1758009806570.md | 5 --
.../pr-12881-fixed-1758025080641.md | 5 --
.../pr-12886-tests-1758051113240.md | 5 --
...r-12887-upcoming-features-1758097638883.md | 5 --
.../pr-12891-fixed-1758180544654.md | 5 --
.../pr-12892-added-1758187361526.md | 5 --
.../pr-12893-tech-stories-1758193861545.md | 5 --
.../pr-12894-fixed-1758298095602.md | 5 --
...r-12898-upcoming-features-1758554025685.md | 5 --
.../pr-12901-changed-1758617282890.md | 5 --
.../pr-12902-changed-1758618381547.md | 5 --
.../pr-12904-changed-1758623601366.md | 5 --
...r-12905-upcoming-features-1758635267264.md | 5 --
.../pr-12906-added-1758634850982.md | 5 --
.../pr-12907-added-1758807632176.md | 5 --
.../pr-12909-added-1758714711573.md | 5 --
...r-12910-upcoming-features-1758728184885.md | 5 --
.../pr-12911-tech-stories-1758736596975.md | 5 --
...r-12912-upcoming-features-1758782466180.md | 5 --
.../pr-12913-added-1758796719861.md | 5 --
.../pr-12914-added-1758802782862.md | 5 --
.../pr-12915-changed-1758901979976.md | 5 --
.../pr-12916-fixed-1758811311336.md | 5 --
.../pr-12917-tests-1758825978456.md | 5 --
.../pr-12919-changed-1758911173078.md | 5 --
.../pr-12921-changed-1758898196076.md | 5 --
.../pr-12922-fixed-1758907285299.md | 5 --
.../pr-12923-changed-1758915343839.md | 5 --
.../pr-12924-changed-1759346651695.md | 5 --
.../pr-12924-tests-1759352432371.md | 5 --
.../pr-12925-fixed-1759145046490.md | 5 --
.../pr-12926-fixed-1759149991132.md | 5 --
.../pr-12932-changed-1759233206741.md | 5 --
.../pr-12933-fixed-1759235571094.md | 5 --
.../pr-12939-added-1759348628476.md | 5 --
.../pr-12939-changed-1759348579491.md | 5 --
.../pr-12942-fixed-1759355205373.md | 5 --
packages/manager/CHANGELOG.md | 68 +++++++++++++++++++
...r-12802-upcoming-features-1758630165951.md | 5 --
.../pr-12867-removed-1758062601387.md | 6 --
.../pr-12887-changed-1758177198097.md | 5 --
.../pr-12888-added-1758193857654.md | 5 --
.../pr-12895-added-1758540593030.md | 5 --
.../pr-12913-added-1758796811602.md | 5 --
.../pr-12919-added-1758911037397.md | 5 --
packages/queries/CHANGELOG.md | 23 +++++++
packages/shared/CHANGELOG.md | 6 ++
.../pr-12919-added-1758911126565.md | 5 --
packages/utilities/CHANGELOG.md | 7 ++
...r-12851-upcoming-features-1757490426873.md | 5 --
...r-12898-upcoming-features-1758553940234.md | 5 --
packages/validation/CHANGELOG.md | 8 +++
72 files changed, 133 insertions(+), 331 deletions(-)
delete mode 100644 packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
delete mode 100644 packages/api-v4/.changeset/pr-12867-changed-1758062421827.md
delete mode 100644 packages/api-v4/.changeset/pr-12870-changed-1757668838404.md
delete mode 100644 packages/api-v4/.changeset/pr-12887-changed-1758097710169.md
delete mode 100644 packages/api-v4/.changeset/pr-12895-added-1758540546949.md
delete mode 100644 packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
delete mode 100644 packages/api-v4/.changeset/pr-12905-added-1758635135139.md
delete mode 100644 packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
delete mode 100644 packages/api-v4/.changeset/pr-12919-added-1758910984747.md
delete mode 100644 packages/manager/.changeset/pr-12802-upcoming-features-1756895590487.md
delete mode 100644 packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
delete mode 100644 packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md
delete mode 100644 packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md
delete mode 100644 packages/manager/.changeset/pr-12842-tests-1757434078486.md
delete mode 100644 packages/manager/.changeset/pr-12847-tests-1757523022214.md
delete mode 100644 packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
delete mode 100644 packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md
delete mode 100644 packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md
delete mode 100644 packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md
delete mode 100644 packages/manager/.changeset/pr-12880-changed-1758009806570.md
delete mode 100644 packages/manager/.changeset/pr-12881-fixed-1758025080641.md
delete mode 100644 packages/manager/.changeset/pr-12886-tests-1758051113240.md
delete mode 100644 packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md
delete mode 100644 packages/manager/.changeset/pr-12891-fixed-1758180544654.md
delete mode 100644 packages/manager/.changeset/pr-12892-added-1758187361526.md
delete mode 100644 packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
delete mode 100644 packages/manager/.changeset/pr-12894-fixed-1758298095602.md
delete mode 100644 packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
delete mode 100644 packages/manager/.changeset/pr-12901-changed-1758617282890.md
delete mode 100644 packages/manager/.changeset/pr-12902-changed-1758618381547.md
delete mode 100644 packages/manager/.changeset/pr-12904-changed-1758623601366.md
delete mode 100644 packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
delete mode 100644 packages/manager/.changeset/pr-12906-added-1758634850982.md
delete mode 100644 packages/manager/.changeset/pr-12907-added-1758807632176.md
delete mode 100644 packages/manager/.changeset/pr-12909-added-1758714711573.md
delete mode 100644 packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
delete mode 100644 packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md
delete mode 100644 packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
delete mode 100644 packages/manager/.changeset/pr-12913-added-1758796719861.md
delete mode 100644 packages/manager/.changeset/pr-12914-added-1758802782862.md
delete mode 100644 packages/manager/.changeset/pr-12915-changed-1758901979976.md
delete mode 100644 packages/manager/.changeset/pr-12916-fixed-1758811311336.md
delete mode 100644 packages/manager/.changeset/pr-12917-tests-1758825978456.md
delete mode 100644 packages/manager/.changeset/pr-12919-changed-1758911173078.md
delete mode 100644 packages/manager/.changeset/pr-12921-changed-1758898196076.md
delete mode 100644 packages/manager/.changeset/pr-12922-fixed-1758907285299.md
delete mode 100644 packages/manager/.changeset/pr-12923-changed-1758915343839.md
delete mode 100644 packages/manager/.changeset/pr-12924-changed-1759346651695.md
delete mode 100644 packages/manager/.changeset/pr-12924-tests-1759352432371.md
delete mode 100644 packages/manager/.changeset/pr-12925-fixed-1759145046490.md
delete mode 100644 packages/manager/.changeset/pr-12926-fixed-1759149991132.md
delete mode 100644 packages/manager/.changeset/pr-12932-changed-1759233206741.md
delete mode 100644 packages/manager/.changeset/pr-12933-fixed-1759235571094.md
delete mode 100644 packages/manager/.changeset/pr-12939-added-1759348628476.md
delete mode 100644 packages/manager/.changeset/pr-12939-changed-1759348579491.md
delete mode 100644 packages/manager/.changeset/pr-12942-fixed-1759355205373.md
delete mode 100644 packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md
delete mode 100644 packages/queries/.changeset/pr-12867-removed-1758062601387.md
delete mode 100644 packages/queries/.changeset/pr-12887-changed-1758177198097.md
delete mode 100644 packages/queries/.changeset/pr-12888-added-1758193857654.md
delete mode 100644 packages/queries/.changeset/pr-12895-added-1758540593030.md
delete mode 100644 packages/queries/.changeset/pr-12913-added-1758796811602.md
delete mode 100644 packages/queries/.changeset/pr-12919-added-1758911037397.md
delete mode 100644 packages/utilities/.changeset/pr-12919-added-1758911126565.md
delete mode 100644 packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
delete mode 100644 packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
diff --git a/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md b/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
deleted file mode 100644
index ec645e1fbc0..00000000000
--- a/packages/api-v4/.changeset/pr-12851-upcoming-features-1757490281299.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Upcoming Features
----
-
-Update Destination's details interface ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md b/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md
deleted file mode 100644
index 2a5694e7fde..00000000000
--- a/packages/api-v4/.changeset/pr-12867-changed-1758062421827.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Changed
----
-
-All kubernetes endpoints from `/v4` to `/v4beta`; clean up duplicate endpoints ([#12867](https://github.com/linode/manager/pull/12867))
diff --git a/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md b/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md
deleted file mode 100644
index 6b86ccfa93f..00000000000
--- a/packages/api-v4/.changeset/pr-12870-changed-1757668838404.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Changed
----
-
-CloudPulse-Alerts: Update `CloudPulseAlertsPayload` in types.ts ([#12870](https://github.com/linode/manager/pull/12870))
diff --git a/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md b/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md
deleted file mode 100644
index 50d87c6549c..00000000000
--- a/packages/api-v4/.changeset/pr-12887-changed-1758097710169.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Changed
----
-
-ACLP: update `group_by` property to optional for `Widgets` and `CloudPulseMetricRequest` interface ([#12887](https://github.com/linode/manager/pull/12887))
diff --git a/packages/api-v4/.changeset/pr-12895-added-1758540546949.md b/packages/api-v4/.changeset/pr-12895-added-1758540546949.md
deleted file mode 100644
index 6da438424cc..00000000000
--- a/packages/api-v4/.changeset/pr-12895-added-1758540546949.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Added
----
-
-IAM Parent/Child - Implement new delegation types and endpoints definitions ([#12895](https://github.com/linode/manager/pull/12895))
diff --git a/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md b/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
deleted file mode 100644
index 975a091c55a..00000000000
--- a/packages/api-v4/.changeset/pr-12898-upcoming-features-1758553903759.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Upcoming Features
----
-
-Logs Delivery Stream details type update and UpdateDestinationPayload update according to API docs ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/api-v4/.changeset/pr-12905-added-1758635135139.md b/packages/api-v4/.changeset/pr-12905-added-1758635135139.md
deleted file mode 100644
index e96d51ceb49..00000000000
--- a/packages/api-v4/.changeset/pr-12905-added-1758635135139.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Added
----
-
-CloudPulse-Metrics: Update `CloudPulseServiceType` and constant `capabilityServiceTypeMapping` at `types.ts` ([#12905](https://github.com/linode/manager/pull/12905))
diff --git a/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md b/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
deleted file mode 100644
index a169768c34a..00000000000
--- a/packages/api-v4/.changeset/pr-12912-changed-1758782562755.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Changed
----
-
-CloudPulse-Metrics: Update `CloudPulseMetricsRequest` and `JWETokenPayLoad` type at `types.ts` ([#12912](https://github.com/linode/manager/pull/12912))
diff --git a/packages/api-v4/.changeset/pr-12919-added-1758910984747.md b/packages/api-v4/.changeset/pr-12919-added-1758910984747.md
deleted file mode 100644
index 039d2956a93..00000000000
--- a/packages/api-v4/.changeset/pr-12919-added-1758910984747.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/api-v4": Added
----
-
-Region VPC availability types and endpoints ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md
index 7ce5c1d5d85..d31de352b07 100644
--- a/packages/api-v4/CHANGELOG.md
+++ b/packages/api-v4/CHANGELOG.md
@@ -1,3 +1,24 @@
+## [2025-10-07] - v0.150.0
+
+
+### Added:
+
+- IAM Parent/Child - Implement new delegation types and endpoints definitions ([#12895](https://github.com/linode/manager/pull/12895))
+- CloudPulse-Metrics: Update `CloudPulseServiceType` and constant `capabilityServiceTypeMapping` at `types.ts` ([#12905](https://github.com/linode/manager/pull/12905))
+- Region VPC availability types and endpoints ([#12919](https://github.com/linode/manager/pull/12919))
+
+### Changed:
+
+- All kubernetes endpoints from `/v4` to `/v4beta`; clean up duplicate endpoints ([#12867](https://github.com/linode/manager/pull/12867))
+- CloudPulse-Alerts: Update `CloudPulseAlertsPayload` in types.ts ([#12870](https://github.com/linode/manager/pull/12870))
+- ACLP: update `group_by` property to optional for `Widgets` and `CloudPulseMetricRequest` interface ([#12887](https://github.com/linode/manager/pull/12887))
+- CloudPulse-Metrics: Update `CloudPulseMetricsRequest` and `JWETokenPayLoad` type at `types.ts` ([#12912](https://github.com/linode/manager/pull/12912))
+
+### Upcoming Features:
+
+- Update Destination's details interface ([#12851](https://github.com/linode/manager/pull/12851))
+- Logs Delivery Stream details type update and UpdateDestinationPayload update according to API docs ([#12898](https://github.com/linode/manager/pull/12898))
+
## [2025-09-23] - v0.149.0
### Added:
diff --git a/packages/manager/.changeset/pr-12802-upcoming-features-1756895590487.md b/packages/manager/.changeset/pr-12802-upcoming-features-1756895590487.md
deleted file mode 100644
index 63b9b9655a0..00000000000
--- a/packages/manager/.changeset/pr-12802-upcoming-features-1756895590487.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-Fix Datastream Stream/Destinations table search input focus, and empty search results layout ([#12802](https://github.com/linode/manager/pull/12802))
diff --git a/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md b/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
deleted file mode 100644
index d3d8e218f4c..00000000000
--- a/packages/manager/.changeset/pr-12810-upcoming-features-1757334461818.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-IAM RBAC: Implements IAM RBAC permissions for VPC Details page ([#12810](https://github.com/linode/manager/pull/12810))
diff --git a/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md b/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md
deleted file mode 100644
index 280f1c7085d..00000000000
--- a/packages/manager/.changeset/pr-12825-tech-stories-1757923303722.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-Refactor IAM permission/entities truncation utilities ([#12825](https://github.com/linode/manager/pull/12825))
diff --git a/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md b/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md
deleted file mode 100644
index 5931a02aabe..00000000000
--- a/packages/manager/.changeset/pr-12838-tech-stories-1757350386803.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-Update Node.js from `20.17` to `22.19` ([#12838](https://github.com/linode/manager/pull/12838))
diff --git a/packages/manager/.changeset/pr-12842-tests-1757434078486.md b/packages/manager/.changeset/pr-12842-tests-1757434078486.md
deleted file mode 100644
index 9d0d850a651..00000000000
--- a/packages/manager/.changeset/pr-12842-tests-1757434078486.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Add tests for Linode Interface Networking table - details drawer and adding a VLAN interface ([#12842](https://github.com/linode/manager/pull/12842))
diff --git a/packages/manager/.changeset/pr-12847-tests-1757523022214.md b/packages/manager/.changeset/pr-12847-tests-1757523022214.md
deleted file mode 100644
index 82afaa0e823..00000000000
--- a/packages/manager/.changeset/pr-12847-tests-1757523022214.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Fix flaky Object Storage Multicluster object upload test ([#12847](https://github.com/linode/manager/pull/12847))
diff --git a/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md b/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
deleted file mode 100644
index 83714b72577..00000000000
--- a/packages/manager/.changeset/pr-12851-upcoming-features-1757490359796.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-Generate Destination's sample Path based on Stream Type or custom value ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md b/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md
deleted file mode 100644
index 70e2547f0d8..00000000000
--- a/packages/manager/.changeset/pr-12867-tech-stories-1758062471882.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-Clean up logic for toggling between kubernetes `/v4` and `/v4beta` endpoints ([#12867](https://github.com/linode/manager/pull/12867))
diff --git a/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md b/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md
deleted file mode 100644
index eb838a8b1ce..00000000000
--- a/packages/manager/.changeset/pr-12869-tech-stories-1757684471209.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-Add dependency resolution for `brace-expansion` ([#12869](https://github.com/linode/manager/pull/12869))
diff --git a/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md b/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md
deleted file mode 100644
index 00c98269c59..00000000000
--- a/packages/manager/.changeset/pr-12870-upcoming-features-1757668940949.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-CloudPulse-Alerts: Add `useAlertsMutation.ts`, update `AlertInformationActionTable.tsx` to handle api integration for mutliple services ([#12870](https://github.com/linode/manager/pull/12870))
diff --git a/packages/manager/.changeset/pr-12880-changed-1758009806570.md b/packages/manager/.changeset/pr-12880-changed-1758009806570.md
deleted file mode 100644
index 095e49a3929..00000000000
--- a/packages/manager/.changeset/pr-12880-changed-1758009806570.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-UIE/RBAC LA gating for useQueryWithPermissions ([#12880](https://github.com/linode/manager/pull/12880))
diff --git a/packages/manager/.changeset/pr-12881-fixed-1758025080641.md b/packages/manager/.changeset/pr-12881-fixed-1758025080641.md
deleted file mode 100644
index 6450a71283c..00000000000
--- a/packages/manager/.changeset/pr-12881-fixed-1758025080641.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-IAM RBAC: fix tooltips in volumes ([#12881](https://github.com/linode/manager/pull/12881))
diff --git a/packages/manager/.changeset/pr-12886-tests-1758051113240.md b/packages/manager/.changeset/pr-12886-tests-1758051113240.md
deleted file mode 100644
index c6a4d334d42..00000000000
--- a/packages/manager/.changeset/pr-12886-tests-1758051113240.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Add LKE-E Post-LA feature flag smoke tests ([#12886](https://github.com/linode/manager/pull/12886))
diff --git a/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md b/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md
deleted file mode 100644
index 3b25767d831..00000000000
--- a/packages/manager/.changeset/pr-12887-upcoming-features-1758097638883.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-ACLP: add `Group By` option on `Global Filters` and `Widget Filters` ([#12887](https://github.com/linode/manager/pull/12887))
diff --git a/packages/manager/.changeset/pr-12891-fixed-1758180544654.md b/packages/manager/.changeset/pr-12891-fixed-1758180544654.md
deleted file mode 100644
index 7e257eb3deb..00000000000
--- a/packages/manager/.changeset/pr-12891-fixed-1758180544654.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-Disable `Add Metric and Add Dimension Filter` without serviceType; skip `useResources` if no supported regions in CloudPulse Alerting ([#12891](https://github.com/linode/manager/pull/12891))
diff --git a/packages/manager/.changeset/pr-12892-added-1758187361526.md b/packages/manager/.changeset/pr-12892-added-1758187361526.md
deleted file mode 100644
index 6e9001c7e3d..00000000000
--- a/packages/manager/.changeset/pr-12892-added-1758187361526.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-IAM RBAC: disable fields in the drawer ([#12892](https://github.com/linode/manager/pull/12892))
diff --git a/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md b/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
deleted file mode 100644
index cfdfe96c005..00000000000
--- a/packages/manager/.changeset/pr-12893-tech-stories-1758193861545.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893))
diff --git a/packages/manager/.changeset/pr-12894-fixed-1758298095602.md b/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
deleted file mode 100644
index b41af7cdf2c..00000000000
--- a/packages/manager/.changeset/pr-12894-fixed-1758298095602.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-Navigation after successful volume deletion ([#12894](https://github.com/linode/manager/pull/12894))
diff --git a/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md b/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
deleted file mode 100644
index b886811a5b7..00000000000
--- a/packages/manager/.changeset/pr-12898-upcoming-features-1758554025685.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-Logs Delivery fixes after devcloud release ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/manager/.changeset/pr-12901-changed-1758617282890.md b/packages/manager/.changeset/pr-12901-changed-1758617282890.md
deleted file mode 100644
index 793be65208e..00000000000
--- a/packages/manager/.changeset/pr-12901-changed-1758617282890.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-Improve role selection UX in change role drawer ([#12901](https://github.com/linode/manager/pull/12901))
diff --git a/packages/manager/.changeset/pr-12902-changed-1758618381547.md b/packages/manager/.changeset/pr-12902-changed-1758618381547.md
deleted file mode 100644
index 8e7b62d4c1b..00000000000
--- a/packages/manager/.changeset/pr-12902-changed-1758618381547.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-IAM RBAC: replace grants with usePermission hook for Firewalls ([#12902](https://github.com/linode/manager/pull/12902))
diff --git a/packages/manager/.changeset/pr-12904-changed-1758623601366.md b/packages/manager/.changeset/pr-12904-changed-1758623601366.md
deleted file mode 100644
index 69f7ebf8a8a..00000000000
--- a/packages/manager/.changeset/pr-12904-changed-1758623601366.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-Getting started link on the volume details page ([#12904](https://github.com/linode/manager/pull/12904))
diff --git a/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md b/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
deleted file mode 100644
index 9fc7c721a9a..00000000000
--- a/packages/manager/.changeset/pr-12905-upcoming-features-1758635267264.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-CloudPulse-Metrics: Add new component at `CloudPulseEndpointsSelect.tsx` ([#12905](https://github.com/linode/manager/pull/12905))
diff --git a/packages/manager/.changeset/pr-12906-added-1758634850982.md b/packages/manager/.changeset/pr-12906-added-1758634850982.md
deleted file mode 100644
index 27db11bdf09..00000000000
--- a/packages/manager/.changeset/pr-12906-added-1758634850982.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-IAM delegation feature flag ([#12906](https://github.com/linode/manager/pull/12906))
diff --git a/packages/manager/.changeset/pr-12907-added-1758807632176.md b/packages/manager/.changeset/pr-12907-added-1758807632176.md
deleted file mode 100644
index 0133f0b9131..00000000000
--- a/packages/manager/.changeset/pr-12907-added-1758807632176.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-Split WireGuard into separate server and client apps; add Jaeger and Cribl Marketplace apps ([#12907](https://github.com/linode/manager/pull/12907))
diff --git a/packages/manager/.changeset/pr-12909-added-1758714711573.md b/packages/manager/.changeset/pr-12909-added-1758714711573.md
deleted file mode 100644
index 73b0afaaf7c..00000000000
--- a/packages/manager/.changeset/pr-12909-added-1758714711573.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-IAM RBAC: disable fields in the drawer for deleting and managing images ([#12909](https://github.com/linode/manager/pull/12909))
diff --git a/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md b/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
deleted file mode 100644
index 7479722aa89..00000000000
--- a/packages/manager/.changeset/pr-12910-upcoming-features-1758728184885.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-ACLP-Alerting: Object Storage service onboarding for Alerts UI ([#12910](https://github.com/linode/manager/pull/12910))
diff --git a/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md b/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md
deleted file mode 100644
index df6b48b9518..00000000000
--- a/packages/manager/.changeset/pr-12911-tech-stories-1758736596975.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tech Stories
----
-
-Remove deprecated `lkeEnterprise` flag from Flags interface ([#12911](https://github.com/linode/manager/pull/12911))
diff --git a/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md b/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
deleted file mode 100644
index f7705437af0..00000000000
--- a/packages/manager/.changeset/pr-12912-upcoming-features-1758782466180.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Upcoming Features
----
-
-CloudPulse-Metrics: Handle special conditions for `objectstorage` service addition, add related filters at `FilterConfig.ts`, integrate related component `CloudPulseEndpointsSelect.tsx` ([#12912](https://github.com/linode/manager/pull/12912))
diff --git a/packages/manager/.changeset/pr-12913-added-1758796719861.md b/packages/manager/.changeset/pr-12913-added-1758796719861.md
deleted file mode 100644
index 89a93e8c807..00000000000
--- a/packages/manager/.changeset/pr-12913-added-1758796719861.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-IAM Delegation: replace query with the new delegation ones ([#12913](https://github.com/linode/manager/pull/12913))
diff --git a/packages/manager/.changeset/pr-12914-added-1758802782862.md b/packages/manager/.changeset/pr-12914-added-1758802782862.md
deleted file mode 100644
index f2942070d74..00000000000
--- a/packages/manager/.changeset/pr-12914-added-1758802782862.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-IAM delegation mock data ([#12914](https://github.com/linode/manager/pull/12914))
diff --git a/packages/manager/.changeset/pr-12915-changed-1758901979976.md b/packages/manager/.changeset/pr-12915-changed-1758901979976.md
deleted file mode 100644
index 3c632ced0c8..00000000000
--- a/packages/manager/.changeset/pr-12915-changed-1758901979976.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-ACLP: update default `ACLP Time Range Picker Preset` to `1 hour` ([#12915](https://github.com/linode/manager/pull/12915))
diff --git a/packages/manager/.changeset/pr-12916-fixed-1758811311336.md b/packages/manager/.changeset/pr-12916-fixed-1758811311336.md
deleted file mode 100644
index e3d892c2882..00000000000
--- a/packages/manager/.changeset/pr-12916-fixed-1758811311336.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-LKE create request for standard cluster can contain LKE-E-specific payload data ([#12916](https://github.com/linode/manager/pull/12916))
diff --git a/packages/manager/.changeset/pr-12917-tests-1758825978456.md b/packages/manager/.changeset/pr-12917-tests-1758825978456.md
deleted file mode 100644
index f7912eaf5d2..00000000000
--- a/packages/manager/.changeset/pr-12917-tests-1758825978456.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Smoke tests for nvidia blackwell gpu plan selection ([#12917](https://github.com/linode/manager/pull/12917))
diff --git a/packages/manager/.changeset/pr-12919-changed-1758911173078.md b/packages/manager/.changeset/pr-12919-changed-1758911173078.md
deleted file mode 100644
index c96c13d855a..00000000000
--- a/packages/manager/.changeset/pr-12919-changed-1758911173078.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-Check Region VPC availability for IPv6 prefix lengths instead of hardcoded prefix lengths ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/manager/.changeset/pr-12921-changed-1758898196076.md b/packages/manager/.changeset/pr-12921-changed-1758898196076.md
deleted file mode 100644
index 1de573b95b4..00000000000
--- a/packages/manager/.changeset/pr-12921-changed-1758898196076.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-IAM Delegation: remove ProxyUserTable ([#12921](https://github.com/linode/manager/pull/12921))
diff --git a/packages/manager/.changeset/pr-12922-fixed-1758907285299.md b/packages/manager/.changeset/pr-12922-fixed-1758907285299.md
deleted file mode 100644
index 7f8b9a61ee7..00000000000
--- a/packages/manager/.changeset/pr-12922-fixed-1758907285299.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-Inaccurate Upgrade Version modal copy for LKE-E clusters and overly verbose modal title ([#12922](https://github.com/linode/manager/pull/12922))
diff --git a/packages/manager/.changeset/pr-12923-changed-1758915343839.md b/packages/manager/.changeset/pr-12923-changed-1758915343839.md
deleted file mode 100644
index 05e26385bf7..00000000000
--- a/packages/manager/.changeset/pr-12923-changed-1758915343839.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-Add padding inside the ManagedDashboardCard component ([#12923](https://github.com/linode/manager/pull/12923))
diff --git a/packages/manager/.changeset/pr-12924-changed-1759346651695.md b/packages/manager/.changeset/pr-12924-changed-1759346651695.md
deleted file mode 100644
index 106d4e8d7fe..00000000000
--- a/packages/manager/.changeset/pr-12924-changed-1759346651695.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-Assorted VPC IPv4 and VPC IPv6 copy ([#12924](https://github.com/linode/manager/pull/12924))
diff --git a/packages/manager/.changeset/pr-12924-tests-1759352432371.md b/packages/manager/.changeset/pr-12924-tests-1759352432371.md
deleted file mode 100644
index 3aae92e17da..00000000000
--- a/packages/manager/.changeset/pr-12924-tests-1759352432371.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Tests
----
-
-Update vpcCreateDrawer.setSubnetIpRange page utility for Cypress tests ([#12924](https://github.com/linode/manager/pull/12924))
diff --git a/packages/manager/.changeset/pr-12925-fixed-1759145046490.md b/packages/manager/.changeset/pr-12925-fixed-1759145046490.md
deleted file mode 100644
index 7c7f054b912..00000000000
--- a/packages/manager/.changeset/pr-12925-fixed-1759145046490.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-Use abs value for Assign User Autocomplete next fetch ([#12925](https://github.com/linode/manager/pull/12925))
diff --git a/packages/manager/.changeset/pr-12926-fixed-1759149991132.md b/packages/manager/.changeset/pr-12926-fixed-1759149991132.md
deleted file mode 100644
index d97a5e4c846..00000000000
--- a/packages/manager/.changeset/pr-12926-fixed-1759149991132.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` and `CloudPulseRegionSelect.tsx` to handle saved preference clearance for linode region filter ([#12926](https://github.com/linode/manager/pull/12926))
diff --git a/packages/manager/.changeset/pr-12932-changed-1759233206741.md b/packages/manager/.changeset/pr-12932-changed-1759233206741.md
deleted file mode 100644
index c92c8950014..00000000000
--- a/packages/manager/.changeset/pr-12932-changed-1759233206741.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-IAM RBAC: replace grants with usePermission hook in Linodes ([#12932](https://github.com/linode/manager/pull/12932))
diff --git a/packages/manager/.changeset/pr-12933-fixed-1759235571094.md b/packages/manager/.changeset/pr-12933-fixed-1759235571094.md
deleted file mode 100644
index 9273a9c406a..00000000000
--- a/packages/manager/.changeset/pr-12933-fixed-1759235571094.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-IAM: Hide IAM Beta badge in User Menu for LA ([#12933](https://github.com/linode/manager/pull/12933))
diff --git a/packages/manager/.changeset/pr-12939-added-1759348628476.md b/packages/manager/.changeset/pr-12939-added-1759348628476.md
deleted file mode 100644
index d72e33a9b22..00000000000
--- a/packages/manager/.changeset/pr-12939-added-1759348628476.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Added
----
-
-ConnectionDetailsRow and ConnectionDetailsHostRows components to manage connection details table content ([#12939](https://github.com/linode/manager/pull/12939))
diff --git a/packages/manager/.changeset/pr-12939-changed-1759348579491.md b/packages/manager/.changeset/pr-12939-changed-1759348579491.md
deleted file mode 100644
index 8619873bdc9..00000000000
--- a/packages/manager/.changeset/pr-12939-changed-1759348579491.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Changed
----
-
-DBaaS - Host field in connection details table renders based on VPC configuration and host fields are synced between Details and Networking tabs ([#12939](https://github.com/linode/manager/pull/12939))
diff --git a/packages/manager/.changeset/pr-12942-fixed-1759355205373.md b/packages/manager/.changeset/pr-12942-fixed-1759355205373.md
deleted file mode 100644
index 480b85eb1fd..00000000000
--- a/packages/manager/.changeset/pr-12942-fixed-1759355205373.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/manager": Fixed
----
-
-Always show tax id's when available irrespective of date filtering ([#12942](https://github.com/linode/manager/pull/12942))
diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md
index 23d07c71a98..a20cfcf4e8f 100644
--- a/packages/manager/CHANGELOG.md
+++ b/packages/manager/CHANGELOG.md
@@ -4,6 +4,74 @@ 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-10-07] - v1.152.0
+
+
+### Added:
+
+- IAM RBAC: disable fields in the drawer ([#12892](https://github.com/linode/manager/pull/12892))
+- IAM delegation feature flag ([#12906](https://github.com/linode/manager/pull/12906))
+- Split WireGuard into separate server and client apps; add Jaeger and Cribl Marketplace apps ([#12907](https://github.com/linode/manager/pull/12907))
+- IAM RBAC: disable fields in the drawer for deleting and managing images ([#12909](https://github.com/linode/manager/pull/12909))
+- IAM Delegation: replace query with the new delegation ones ([#12913](https://github.com/linode/manager/pull/12913))
+- IAM delegation mock data ([#12914](https://github.com/linode/manager/pull/12914))
+- ConnectionDetailsRow and ConnectionDetailsHostRows components to manage connection details table content ([#12939](https://github.com/linode/manager/pull/12939))
+
+### Changed:
+
+- UIE/RBAC LA gating for useQueryWithPermissions ([#12880](https://github.com/linode/manager/pull/12880))
+- Improve role selection UX in change role drawer ([#12901](https://github.com/linode/manager/pull/12901))
+- IAM RBAC: replace grants with usePermission hook for Firewalls ([#12902](https://github.com/linode/manager/pull/12902))
+- Getting started link on the volume details page ([#12904](https://github.com/linode/manager/pull/12904))
+- ACLP: update default `ACLP Time Range Picker Preset` to `1 hour` ([#12915](https://github.com/linode/manager/pull/12915))
+- Check Region VPC availability for IPv6 prefix lengths instead of hardcoded prefix lengths ([#12919](https://github.com/linode/manager/pull/12919))
+- IAM Delegation: remove ProxyUserTable ([#12921](https://github.com/linode/manager/pull/12921))
+- Add padding inside the ManagedDashboardCard component ([#12923](https://github.com/linode/manager/pull/12923))
+- Assorted VPC IPv4 and VPC IPv6 copy ([#12924](https://github.com/linode/manager/pull/12924))
+- IAM RBAC: replace grants with usePermission hook in Linodes ([#12932](https://github.com/linode/manager/pull/12932))
+- DBaaS - Host field in connection details table renders based on VPC configuration and host fields are synced between Details and Networking tabs ([#12939](https://github.com/linode/manager/pull/12939))
+
+### Fixed:
+
+- IAM RBAC: fix tooltips in volumes ([#12881](https://github.com/linode/manager/pull/12881))
+- Disable `Add Metric and Add Dimension Filter` without serviceType; skip `useResources` if no supported regions in CloudPulse Alerting ([#12891](https://github.com/linode/manager/pull/12891))
+- Navigation after successful volume deletion ([#12894](https://github.com/linode/manager/pull/12894))
+- LKE create request for standard cluster can contain LKE-E-specific payload data ([#12916](https://github.com/linode/manager/pull/12916))
+- Inaccurate Upgrade Version modal copy for LKE-E clusters and overly verbose modal title ([#12922](https://github.com/linode/manager/pull/12922))
+- Use abs value for Assign User Autocomplete next fetch ([#12925](https://github.com/linode/manager/pull/12925))
+- CloudPulse-Metrics: Update `CloudPulseDashboardFilterBuilder.tsx` and `CloudPulseRegionSelect.tsx` to handle saved preference clearance for linode region filter ([#12926](https://github.com/linode/manager/pull/12926))
+- IAM: Hide IAM Beta badge in User Menu for LA ([#12933](https://github.com/linode/manager/pull/12933))
+- Always show tax id's when available irrespective of date filtering ([#12942](https://github.com/linode/manager/pull/12942))
+
+### Tech Stories:
+
+- Refactor IAM permission/entities truncation utilities ([#12825](https://github.com/linode/manager/pull/12825))
+- Update Node.js from `20.17` to `22.19` ([#12838](https://github.com/linode/manager/pull/12838))
+- Clean up logic for toggling between kubernetes `/v4` and `/v4beta` endpoints ([#12867](https://github.com/linode/manager/pull/12867))
+- Add dependency resolution for `brace-expansion` ([#12869](https://github.com/linode/manager/pull/12869))
+- IAM - Improve type safety in `usePermissions` ([#12893](https://github.com/linode/manager/pull/12893))
+- Remove deprecated `lkeEnterprise` flag from Flags interface ([#12911](https://github.com/linode/manager/pull/12911))
+
+### Tests:
+
+- Add tests for Linode Interface Networking table - details drawer and adding a VLAN interface ([#12842](https://github.com/linode/manager/pull/12842))
+- Fix flaky Object Storage Multicluster object upload test ([#12847](https://github.com/linode/manager/pull/12847))
+- Add LKE-E Post-LA feature flag smoke tests ([#12886](https://github.com/linode/manager/pull/12886))
+- Smoke tests for nvidia blackwell gpu plan selection ([#12917](https://github.com/linode/manager/pull/12917))
+- Update vpcCreateDrawer.setSubnetIpRange page utility for Cypress tests ([#12924](https://github.com/linode/manager/pull/12924))
+
+### Upcoming Features:
+
+- Fix Datastream Stream/Destinations table search input focus, and empty search results layout ([#12802](https://github.com/linode/manager/pull/12802))
+- IAM RBAC: Implements IAM RBAC permissions for VPC Details page ([#12810](https://github.com/linode/manager/pull/12810))
+- Generate Destination's sample Path based on Stream Type or custom value ([#12851](https://github.com/linode/manager/pull/12851))
+- CloudPulse-Alerts: Add `useAlertsMutation.ts`, update `AlertInformationActionTable.tsx` to handle api integration for mutliple services ([#12870](https://github.com/linode/manager/pull/12870))
+- ACLP: add `Group By` option on `Global Filters` and `Widget Filters` ([#12887](https://github.com/linode/manager/pull/12887))
+- Logs Delivery fixes after devcloud release ([#12898](https://github.com/linode/manager/pull/12898))
+- CloudPulse-Metrics: Add new component at `CloudPulseEndpointsSelect.tsx` ([#12905](https://github.com/linode/manager/pull/12905))
+- ACLP-Alerting: Object Storage service onboarding for Alerts UI ([#12910](https://github.com/linode/manager/pull/12910))
+- CloudPulse-Metrics: Handle special conditions for `objectstorage` service addition, add related filters at `FilterConfig.ts`, integrate related component `CloudPulseEndpointsSelect.tsx` ([#12912](https://github.com/linode/manager/pull/12912))
+
## [2025-09-25] - v1.151.1
### Added:
diff --git a/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md b/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md
deleted file mode 100644
index a921d84ae12..00000000000
--- a/packages/queries/.changeset/pr-12802-upcoming-features-1758630165951.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Upcoming Features
----
-
-Logs Delivery Streams/Destinations update useAll queries ([#12802](https://github.com/linode/manager/pull/12802))
diff --git a/packages/queries/.changeset/pr-12867-removed-1758062601387.md b/packages/queries/.changeset/pr-12867-removed-1758062601387.md
deleted file mode 100644
index 3ced1ee669a..00000000000
--- a/packages/queries/.changeset/pr-12867-removed-1758062601387.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@linode/queries": Removed
----
-
-`isUsingBetaEndpoint` logic for kubernetes queries since all kubernetes endpoints
-now use /v4beta ([#12867](https://github.com/linode/manager/pull/12867))
diff --git a/packages/queries/.changeset/pr-12887-changed-1758177198097.md b/packages/queries/.changeset/pr-12887-changed-1758177198097.md
deleted file mode 100644
index c6ea8ebcbd9..00000000000
--- a/packages/queries/.changeset/pr-12887-changed-1758177198097.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Changed
----
-
-ACLP: update metric definition queries cache time to inifinity ([#12887](https://github.com/linode/manager/pull/12887))
diff --git a/packages/queries/.changeset/pr-12888-added-1758193857654.md b/packages/queries/.changeset/pr-12888-added-1758193857654.md
deleted file mode 100644
index e7013aec397..00000000000
--- a/packages/queries/.changeset/pr-12888-added-1758193857654.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Added
----
-
-IAM RBAC: useAllAccountEntities to fetch all pages client-side via getAll, preventing missing items on large accounts ([#12888](https://github.com/linode/manager/pull/12888))
diff --git a/packages/queries/.changeset/pr-12895-added-1758540593030.md b/packages/queries/.changeset/pr-12895-added-1758540593030.md
deleted file mode 100644
index a3664d63328..00000000000
--- a/packages/queries/.changeset/pr-12895-added-1758540593030.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Added
----
-
-IAM Parent/Child - Implement new delegation query hooks ([#12895](https://github.com/linode/manager/pull/12895))
diff --git a/packages/queries/.changeset/pr-12913-added-1758796811602.md b/packages/queries/.changeset/pr-12913-added-1758796811602.md
deleted file mode 100644
index 9e845828e9c..00000000000
--- a/packages/queries/.changeset/pr-12913-added-1758796811602.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Added
----
-
-IAM Delegation: useAllListMyDelegatedChildAccountsQuery to fetch all data ([#12913](https://github.com/linode/manager/pull/12913))
diff --git a/packages/queries/.changeset/pr-12919-added-1758911037397.md b/packages/queries/.changeset/pr-12919-added-1758911037397.md
deleted file mode 100644
index 488397793cd..00000000000
--- a/packages/queries/.changeset/pr-12919-added-1758911037397.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/queries": Added
----
-
-Region VPC availability queries ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/queries/CHANGELOG.md b/packages/queries/CHANGELOG.md
index 98ff6d86594..6906ca12c33 100644
--- a/packages/queries/CHANGELOG.md
+++ b/packages/queries/CHANGELOG.md
@@ -1,3 +1,26 @@
+## [2025-10-07] - v0.15.0
+
+
+### Added:
+
+- IAM RBAC: useAllAccountEntities to fetch all pages client-side via getAll, preventing missing items on large accounts ([#12888](https://github.com/linode/manager/pull/12888))
+- IAM Parent/Child - Implement new delegation query hooks ([#12895](https://github.com/linode/manager/pull/12895))
+- IAM Delegation: useAllListMyDelegatedChildAccountsQuery to fetch all data ([#12913](https://github.com/linode/manager/pull/12913))
+- Region VPC availability queries ([#12919](https://github.com/linode/manager/pull/12919))
+
+### Changed:
+
+- ACLP: update metric definition queries cache time to inifinity ([#12887](https://github.com/linode/manager/pull/12887))
+
+### Removed:
+
+- `isUsingBetaEndpoint` logic for kubernetes queries since all kubernetes endpoints
+now use /v4beta ([#12867](https://github.com/linode/manager/pull/12867))
+
+### Upcoming Features:
+
+- Logs Delivery Streams/Destinations update useAll queries ([#12802](https://github.com/linode/manager/pull/12802))
+
## [2025-09-23] - v0.14.0
### Upcoming Features:
diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md
index dc146b6d71c..fbc2689765d 100644
--- a/packages/shared/CHANGELOG.md
+++ b/packages/shared/CHANGELOG.md
@@ -1,3 +1,9 @@
+## [2025-10-07] - v0.9.0
+
+### Changed:
+
+- Update `useIsLinodeAclpSubscribed` to reflect updated API fields ([#12870](https://github.com/linode/manager/pull/12870))
+
## [2025-09-09] - v0.8.0
### Tests:
diff --git a/packages/utilities/.changeset/pr-12919-added-1758911126565.md b/packages/utilities/.changeset/pr-12919-added-1758911126565.md
deleted file mode 100644
index 6c09402824a..00000000000
--- a/packages/utilities/.changeset/pr-12919-added-1758911126565.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/utilities": Added
----
-
-Added `regionVPCAvailabilityFactory` in regions.ts ([#12919](https://github.com/linode/manager/pull/12919))
diff --git a/packages/utilities/CHANGELOG.md b/packages/utilities/CHANGELOG.md
index 8dd220b6fb8..45657d0fdc0 100644
--- a/packages/utilities/CHANGELOG.md
+++ b/packages/utilities/CHANGELOG.md
@@ -1,3 +1,10 @@
+## [2025-10-07] - v0.10.0
+
+
+### Added:
+
+- Added `regionVPCAvailabilityFactory` in regions.ts ([#12919](https://github.com/linode/manager/pull/12919))
+
## [2025-09-23] - v0.9.0
### Changed:
diff --git a/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md b/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
deleted file mode 100644
index bb3e3056264..00000000000
--- a/packages/validation/.changeset/pr-12851-upcoming-features-1757490426873.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/validation": Upcoming Features
----
-
-Update validation schema for Destination - Details - Path ([#12851](https://github.com/linode/manager/pull/12851))
diff --git a/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md b/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
deleted file mode 100644
index b30703f8269..00000000000
--- a/packages/validation/.changeset/pr-12898-upcoming-features-1758553940234.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@linode/validation": Upcoming Features
----
-
-Logs Delivery Stream and Destination details validation change for Update schemas ([#12898](https://github.com/linode/manager/pull/12898))
diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md
index 704ffd9b103..1381f78933a 100644
--- a/packages/validation/CHANGELOG.md
+++ b/packages/validation/CHANGELOG.md
@@ -1,3 +1,11 @@
+## [2025-10-07] - v0.76.0
+
+
+### Upcoming Features:
+
+- Update validation schema for Destination - Details - Path ([#12851](https://github.com/linode/manager/pull/12851))
+- Logs Delivery Stream and Destination details validation change for Update schemas ([#12898](https://github.com/linode/manager/pull/12898))
+
## [2025-09-23] - v0.75.0
### Changed:
From f6c97ae88d62b312e3b6757d7378f0fb509516f3 Mon Sep 17 00:00:00 2001
From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com>
Date: Fri, 3 Oct 2025 15:13:24 -0400
Subject: [PATCH 53/54] fix: [M3-10619] - PDF generation fix (#12955)
* PDF generation fix
* Revert whole file
* PDF generation fix
---------
Co-authored-by: Jaalah Ramos
---
packages/manager/src/App.tsx | 6 ++---
.../Billing/PdfGenerator/PdfGenerator.ts | 27 +++++++++++--------
2 files changed, 19 insertions(+), 14 deletions(-)
diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx
index ace9e20e6e7..cac1e8d2b6c 100644
--- a/packages/manager/src/App.tsx
+++ b/packages/manager/src/App.tsx
@@ -20,6 +20,9 @@ export const App = withDocumentTitleProvider(
window.location.pathname === '/oauth/callback' ||
window.location.pathname === '/admin/callback';
+ const { isLoading } = useInitialRequests();
+ const { areFeatureFlagsLoading } = useSetupFeatureFlags();
+
if (isAuthCallback) {
return (
@@ -29,9 +32,6 @@ export const App = withDocumentTitleProvider(
);
}
- const { isLoading } = useInitialRequests();
- const { areFeatureFlagsLoading } = useSetupFeatureFlags();
-
if (isLoading || areFeatureFlagsLoading) {
return ;
}
diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
index a0ee6c775b3..89abbb5a486 100644
--- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
+++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts
@@ -229,22 +229,27 @@ export const printInvoice = async (
taxes && taxes?.date ? dateConversion(taxes.date) : Infinity;
/**
- * Tax data is served from LaunchDarkly feature flags and displayed on invoices.
- * Country-level tax IDs (like EU VAT, Japanese JCT, etc.) are always displayed
- * when available, regardless of invoice date.
+ * Users who have identified their country as one of the ones targeted by
+ * one of our tax policies will have a `taxes` with at least a .date.
+ * Customers with no country, or from a country we don't have a tax policy
+ * for, will have a `taxes` of {}, and the following logic will skip them.
*
- * Provincial/state-level tax IDs still use date filtering to determine when
- * they should be applied based on local tax policy implementation dates.
+ * If taxes.date is defined, and the invoice we're about to print is after
+ * that date, we want to add the customer's tax ID to the invoice.
*
- * The source of truth for all tax data is LaunchDarkly, with examples:
+ * If in addition to the above, taxes is defined, it means
+ * we have a corporate tax ID for the country and should display that in the left
+ * side of the header.
*
- * EU VAT: Shows both EU VAT number and Switzerland VAT for B2B customers
- * Japanese JCT: Shows Japan JCT tax ID and QI Registration number
- * US/CA: Shows federal tax IDs and state-specific tax IDs when applicable
+ * The source of truth for all tax banners is LaunchDarkly, but as an example,
+ * as of 2/20/2020 we have the following cases:
+ *
+ * VAT: Applies only to EU countries; started from 6/1/2019 and we have an EU tax id
+ * - [M3-8277] For EU customers, invoices will include VAT for B2C transactions and exclude VAT for B2B transactions. Both VAT numbers will be shown on the invoice template for EU countries.
+ * GMT: Applies to both Australia and India, but we only have a tax ID for Australia.
*/
const hasTax = !taxes?.date ? true : convertedInvoiceDate > TaxStartDate;
- // Country-level tax IDs are always displayed when available
- const countryTax = taxes?.country_tax;
+ const countryTax = hasTax ? taxes?.country_tax : undefined;
const provincialTax = hasTax
? taxes?.provincial_tax_ids?.[account.state]
: undefined;
From 5ce62d241a360d52ed64528a306a462385f0386c Mon Sep 17 00:00:00 2001
From: mpolotsk-akamai <157619599+mpolotsk-akamai@users.noreply.github.com>
Date: Tue, 7 Oct 2025 17:14:39 +0200
Subject: [PATCH 54/54] fix: [UIE-9340] - IAM: User Menu Beta badge fix
(#12962)
* fix: [UIE-9340] - IAM: User Menu Beta badge fix
* fix: [UIE-9340] - changelog update
* fix: [UIE-9340] - changelog update
---
.../manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
index 8559b58fc9a..91d51143ab3 100644
--- a/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
+++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenuPopover.tsx
@@ -114,7 +114,7 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => {
: iamRbacPrimaryNavChanges && !isIAMEnabled
? '/users'
: '/account/users',
- isBeta: iamRbacPrimaryNavChanges && isIAMBeta,
+ isBeta: iamRbacPrimaryNavChanges && isIAMEnabled && isIAMBeta,
},
{
display: 'Quotas',