diff --git a/packages/api-v4/.changeset/pr-12248-upcoming-features-1748955611165.md b/packages/api-v4/.changeset/pr-12248-upcoming-features-1748955611165.md new file mode 100644 index 00000000000..0384b3b1e7b --- /dev/null +++ b/packages/api-v4/.changeset/pr-12248-upcoming-features-1748955611165.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Beta ACLP alerts property to the `CreateLinodeRequest` type ([#12248](https://github.com/linode/manager/pull/12248)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 1d5f23d5a41..0e8303560fd 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -349,3 +349,23 @@ export interface DeleteAlertPayload { alertId: number; serviceType: string; } + +/** + * Represents the payload for CloudPulse alerts, included only when the ACLP beta mode is enabled. + * + * In Beta mode, the `alerts` object contains enabled system and user alert IDs. + * - Legacy mode: `alerts` is not included (read-only mode). + * - Beta mode: `alerts` is passed and editable. + */ +export interface CloudPulseAlertsPayload { + /** + * Array of enabled system alert IDs in ACLP (Beta) mode. + * Only included in Beta mode. + */ + system: number[]; + /** + * Array of enabled user alert IDs in ACLP (Beta) mode. + * Only included in Beta mode. + */ + user: number[]; +} diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index cb71e96a4fa..44c7477c36d 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,3 +1,4 @@ +import type { CloudPulseAlertsPayload } from '../cloudpulse/types'; import type { IPAddress, IPRange } from '../networking/types'; import type { LinodePlacementGroupPayload } from '../placement-groups/types'; import type { Region, RegionSite } from '../regions'; @@ -538,6 +539,10 @@ export interface CreateLinodePlacementGroupPayload { } export interface CreateLinodeRequest { + /** + * Beta Aclp alerts + */ + alerts?: CloudPulseAlertsPayload | null; /** * A list of public SSH keys that will be automatically appended to the root user’s * `~/.ssh/authorized_keys`file when deploying from an Image. diff --git a/packages/manager/.changeset/pr-12248-upcoming-features-1748955382171.md b/packages/manager/.changeset/pr-12248-upcoming-features-1748955382171.md new file mode 100644 index 00000000000..a2ca57a5fce --- /dev/null +++ b/packages/manager/.changeset/pr-12248-upcoming-features-1748955382171.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Assigning alert definitions to a Linode during creation ([#12248](https://github.com/linode/manager/pull/12248)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 8441a05bed0..d3c5481cccb 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -21,7 +21,12 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; import { AlertInformationActionRow } from './AlertInformationActionRow'; -import type { Alert, APIError, EntityAlertUpdatePayload } from '@linode/api-v4'; +import type { + Alert, + APIError, + CloudPulseAlertsPayload, + EntityAlertUpdatePayload, +} from '@linode/api-v4'; export interface AlertInformationActionTableProps { /** @@ -36,19 +41,28 @@ export interface AlertInformationActionTableProps { /** * Id of the selected entity + * Only use in edit flow */ - entityId: string; + entityId?: string; /** * Name of the selected entity + * Only use in edit flow */ - entityName: string; + entityName?: string; /** * Error received from API */ error?: APIError[] | null; + /** + * Called when an alert is toggled on or off. + * Only use in create flow. + * @param payload enabled alerts ids + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + /** * Column name by which columns will be ordered by default */ @@ -67,10 +81,37 @@ export interface TableColumnHeader { label: string; } +export interface AlertRowPropsOptions { + /** + * Enabled alerts payload + */ + enabledAlerts: CloudPulseAlertsPayload; + + /** + * Id of the entity + * Only use in edit flow. + */ + entityId?: string; + + /** + * Callback function to handle alert toggle + * Only use in create flow. + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; +} + export const AlertInformationActionTable = ( props: AlertInformationActionTableProps ) => { - const { alerts, columns, entityId, entityName, error, orderByColumn } = props; + const { + alerts, + columns, + entityId, + entityName, + error, + orderByColumn, + onToggleAlert, + } = props; const _error = error ? getAPIErrorOrDefault(error, 'Error while fetching the alerts') @@ -79,16 +120,43 @@ export const AlertInformationActionTable = ( const [selectedAlert, setSelectedAlert] = React.useState({} as Alert); const [isDialogOpen, setIsDialogOpen] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); + const [enabledAlerts, setEnabledAlerts] = + React.useState({ + system: [], + user: [], + }); const { mutateAsync: addEntity } = useAddEntityToAlert(); const { mutateAsync: removeEntity } = useRemoveEntityFromAlert(); + const getAlertRowProps = (alert: Alert, options: AlertRowPropsOptions) => { + const { entityId, enabledAlerts, onToggleAlert } = options; + + // Ensure that at least one of entityId or onToggleAlert is provided + if (!(entityId || onToggleAlert)) { + return null; + } + + const isEditMode = !!entityId; + + const handleToggle = isEditMode + ? handleToggleEditFlow + : handleToggleCreateFlow; + const status = isEditMode + ? alert.entity_ids.includes(entityId) + : enabledAlerts[alert.type].includes(alert.id); + + return { handleToggle, status }; + }; + const handleCancel = () => { setIsDialogOpen(false); }; const handleConfirm = React.useCallback( (alert: Alert, currentStatus: boolean) => { + if (entityId === undefined) return; + const payload: EntityAlertUpdatePayload = { alert, entityId, @@ -117,12 +185,31 @@ export const AlertInformationActionTable = ( }, [addEntity, enqueueSnackbar, entityId, entityName, removeEntity] ); - const handleToggle = (alert: Alert) => { + + const handleToggleEditFlow = (alert: Alert) => { setIsDialogOpen(true); setSelectedAlert(alert); }; - const isEnabled = selectedAlert.entity_ids?.includes(entityId) ?? false; + const handleToggleCreateFlow = (alert: Alert) => { + if (!onToggleAlert) return; + + setEnabledAlerts((prev: CloudPulseAlertsPayload) => { + const newPayload = { ...prev }; + const index = newPayload[alert.type].indexOf(alert.id); + // If the alert is already in the payload, remove it, otherwise add it + if (index !== -1) { + newPayload[alert.type].splice(index, 1); + } else { + newPayload[alert.type].push(alert.id); + } + + onToggleAlert(newPayload); + return newPayload; + }); + }; + + const isEnabled = selectedAlert.entity_ids?.includes(entityId ?? '') ?? false; return ( <> @@ -170,14 +257,24 @@ export const AlertInformationActionTable = ( length={paginatedAndOrderedAlerts.length} loading={false} /> - {paginatedAndOrderedAlerts?.map((alert) => ( - - ))} + {paginatedAndOrderedAlerts?.map((alert) => { + const rowProps = getAlertRowProps(alert, { + enabledAlerts, + entityId, + onToggleAlert, + }); + + if (!rowProps) return null; + + return ( + + ); + })} diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index 8ab9de54e80..68b7f1c65cc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -23,18 +23,28 @@ import { } from '../Utils/utils'; import { AlertInformationActionTable } from './AlertInformationActionTable'; -import type { AlertDefinitionType } from '@linode/api-v4'; +import type { + AlertDefinitionType, + CloudPulseAlertsPayload, +} from '@linode/api-v4'; interface AlertReusableComponentProps { /** * Id for the selected entity */ - entityId: string; + entityId?: string; /** * Name of the selected entity */ - entityName: string; + entityName?: string; + + /** + * Called when an alert is toggled on or off. + * Only use in create flow. + * @param payload enabled alerts ids + */ + onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; /** * Service type of selected entity @@ -43,7 +53,7 @@ interface AlertReusableComponentProps { } export const AlertReusableComponent = (props: AlertReusableComponentProps) => { - const { entityId, entityName, serviceType } = props; + const { entityId, entityName, onToggleAlert, serviceType } = props; const { data: alerts, error, @@ -70,6 +80,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { if (isLoading) { return ; } + return ( @@ -125,6 +136,7 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { entityId={entityId} entityName={entityName} error={error} + onToggleAlert={onToggleAlert} orderByColumn="Alert Name" /> diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx index 86797e65a1c..0cf52fc2673 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Actions.tsx @@ -74,10 +74,9 @@ export const Actions = () => { setIsAPIAwarenessModalOpen(false)} - payLoad={getLinodeCreatePayload( - structuredClone(getValues()), - isLinodeInterfacesEnabled - )} + payLoad={getLinodeCreatePayload(structuredClone(getValues()), { + isShowingNewNetworkingUI: isLinodeInterfacesEnabled, + })} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts/Alerts.tsx b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts/Alerts.tsx index 66356880eeb..4c74ff1ae93 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts/Alerts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/AdditionalOptions/Alerts/Alerts.tsx @@ -1,17 +1,33 @@ import { usePreferences } from '@linode/queries'; -import { Accordion, BetaChip, Notice } from '@linode/ui'; +import { Accordion, BetaChip } from '@linode/ui'; import * as React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import { AlertReusableComponent } from 'src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent'; import { AclpPreferenceToggle } from 'src/features/Linodes/AclpPreferenceToggle'; import { LinodeSettingsAlertsPanel } from 'src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel'; import { useFlags } from 'src/hooks/useFlags'; +import type { LinodeCreateFormValues } from '../../utilities'; +import type { CloudPulseAlertsPayload } from '@linode/api-v4'; + export const Alerts = () => { const flags = useFlags(); const { data: isAclpAlertsPreferenceBeta } = usePreferences( (preferences) => preferences?.isAclpAlertsBeta ); + const { control } = useFormContext(); + const { field } = useController({ + control, + name: 'alerts', + defaultValue: { system: [], user: [] }, + }); + + const handleToggleAlert = (updatedAlerts: CloudPulseAlertsPayload) => { + field.onChange(updatedAlerts); + }; + return ( { > {flags.aclpBetaServices?.alerts && } {flags.aclpBetaServices?.alerts && isAclpAlertsPreferenceBeta ? ( - ACLP Alerts coming soon... + ) : ( )} diff --git a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx index a9e4af86f54..6650085035e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/index.tsx @@ -3,6 +3,7 @@ import { useCloneLinodeMutation, useCreateLinodeMutation, useMutateAccountAgreements, + usePreferences, useProfile, } from '@linode/queries'; import { CircleProgress, Notice, Stack } from '@linode/ui'; @@ -23,6 +24,7 @@ import { TabList } from 'src/components/Tabs/TabList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; import { @@ -80,6 +82,12 @@ export const LinodeCreate = () => { const { data: profile } = useProfile(); const { isLinodeCloneFirewallEnabled } = useIsLinodeCloneFirewallEnabled(); + const { data: isAclpAlertsPreferenceBeta } = usePreferences( + (preferences) => preferences?.isAclpAlertsBeta + ); + + const flags = useFlags(); + const queryClient = useQueryClient(); const form = useForm({ @@ -124,7 +132,11 @@ export const LinodeCreate = () => { }; const onSubmit: SubmitHandler = async (values) => { - const payload = getLinodeCreatePayload(values, isLinodeInterfacesEnabled); + const payload = getLinodeCreatePayload(values, { + isShowingNewNetworkingUI: isLinodeInterfacesEnabled, + isAclpIntegration: flags.aclpBetaServices?.alerts, + isAclpAlertsPreferenceBeta, + }); try { const linode = diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx index 0e3597fd75a..ca168f3c1f3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.test.tsx @@ -54,7 +54,9 @@ describe('getLinodeCreatePayload', () => { it('should return a basic payload', () => { const values = createLinodeRequestFactory.build() as LinodeCreateFormValues; - expect(getLinodeCreatePayload(values, false)).toEqual(values); + expect( + getLinodeCreatePayload(values, { isShowingNewNetworkingUI: false }) + ).toEqual(values); }); it('should base64 encode metadata', () => { @@ -62,7 +64,9 @@ describe('getLinodeCreatePayload', () => { metadata: { user_data: userData }, }) as LinodeCreateFormValues; - expect(getLinodeCreatePayload(values, false)).toEqual({ + expect( + getLinodeCreatePayload(values, { isShowingNewNetworkingUI: false }) + ).toEqual({ ...values, metadata: { user_data: base64UserData }, }); @@ -73,7 +77,9 @@ describe('getLinodeCreatePayload', () => { placement_group: {}, }) as LinodeCreateFormValues; - expect(getLinodeCreatePayload(values, false)).toEqual({ + expect( + getLinodeCreatePayload(values, { isShowingNewNetworkingUI: false }) + ).toEqual({ ...values, placement_group: undefined, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts index 979c13c19de..a5f003aee40 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreate/utilities.ts @@ -70,6 +70,12 @@ interface ParsedLinodeCreateQueryParams { type: LinodeCreateType | undefined; } +interface LinodeCreatePayloadOptions { + isAclpAlertsPreferenceBeta?: boolean; + isAclpIntegration?: boolean; + isShowingNewNetworkingUI: boolean; +} + /** * Hook that allows you to read and manage Linode Create flow query params. * @@ -164,8 +170,14 @@ export const tabs: LinodeCreateType[] = [ */ export const getLinodeCreatePayload = ( formValues: LinodeCreateFormValues, - isShowingNewNetworkingUI: boolean + options: LinodeCreatePayloadOptions ): CreateLinodeRequest => { + const { + isShowingNewNetworkingUI, + isAclpIntegration, + isAclpAlertsPreferenceBeta, + } = options; + const values: CreateLinodeRequest = omitProps(formValues, [ 'linode', 'hasSignedEUAgreement', @@ -173,6 +185,10 @@ export const getLinodeCreatePayload = ( 'linodeInterfaces', ]); + if (!isAclpIntegration || !isAclpAlertsPreferenceBeta) { + values.alerts = undefined; + } + if (values.metadata?.user_data) { values.metadata.user_data = utoa(values.metadata.user_data); } diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 3e2d635cf25..2a9152c9426 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -652,6 +652,11 @@ const CreateVlanInterfaceSchema = object({ ipam_address: string().nullable(), }); +const AclpAlertsPayloadSchema = object({ + system: array().of(number().defined()).required(), + user: array().of(number().defined()).required(), +}); + export const CreateVPCInterfaceSchema = object({ subnet_id: number().required('Subnet is required.'), ipv4: object({ @@ -825,4 +830,5 @@ export const CreateLinodeSchema = object({ placement_group: PlacementGroupPayloadSchema.notRequired().default(undefined), disk_encryption: DiskEncryptionSchema, maintenance_policy_id: number().notRequired().nullable(), + alerts: AclpAlertsPayloadSchema.notRequired().default(undefined), });