diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index f5780d41405..e1313ed83c4 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -244,6 +244,104 @@ console.log(errorMap); } ``` +#### Scrolling to errors + +For deep forms, we provide a utility that will scroll to the first error encountered within a defined container. We do this to improve error visibility, because the user can be unaware of an error that isn't in the viewport. +An error can be a notice (API error) or a Formik field error. In order to implement this often needed functionality, we must declare a form or form container via ref, then pass it to the `scrollErrorIntoViewV2` util (works both for class & functional components). + +Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorIntoViewV2`. + +Since Cloud Manager uses different ways of handling forms and validation, the `scrollErrorIntoViewV2` util should be implemented using the following patterns to ensure consistency. + +##### Formik +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const { + values, + // other handlers + } = useFormik({ + initialValues: {}, + onSubmit: mySubmitFormHandler, + validate: () => { + scrollErrorIntoViewV2(formRef); + }, + validationSchema: myValidationSchema, + }); + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + +##### React Hook Forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver: myResolvers, + // other methods + }); + + return ( + +
scrollErrorIntoViewV2(formRef))} + ref={formContainerRef} + > + + {/* form fields */} + + + + ); +}; +``` + +##### Uncontrolled forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const handleSubmit = () => { + try { + // form submission logic + } catch { + scrollErrorIntoViewV2(formContainerRef); + } + }; + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + ### Toast / Event Message Punctuation **Best practice:** - If a message is a sentence or a sentence fragment with a subject and a verb, add punctuation. Otherwise, leave punctuation off. diff --git a/package.json b/package.json index 4c7b5804b7d..4211ec10959 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "coverage": "yarn workspace linode-manager coverage", "coverage:summary": "yarn workspace linode-manager coverage:summary", "junit:summary": "ts-node scripts/junit-summary/index.ts", + "generate-tod": "ts-node scripts/tod-payload/index.ts", "docs": "bunx vitepress@1.0.0-rc.44 dev docs" }, "resolutions": { diff --git a/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md b/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md new file mode 100644 index 00000000000..2f5298f117d --- /dev/null +++ b/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/api-v4/.changeset/pr-10468-added-1716219170108.md b/packages/api-v4/.changeset/pr-10468-added-1716219170108.md new file mode 100644 index 00000000000..2a5eab0136d --- /dev/null +++ b/packages/api-v4/.changeset/pr-10468-added-1716219170108.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint for `object-storage/types` ([#10468](https://github.com/linode/manager/pull/10468)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7f2aecd62b1..08b89d605e8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -63,6 +63,7 @@ export type AccountCapability = | 'Akamai Cloud Load Balancer' | 'Block Storage' | 'Cloud Firewall' + | 'Disk Encryption' | 'Kubernetes' | 'Linodes' | 'LKE HA Control Planes' diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index 8328de83245..53b3d3d63e0 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -11,8 +11,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page } from '../types'; +import type { CreateImagePayload, Image, ImageUploadPayload, diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c8d25118e35..8e2d176572c 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -1,4 +1,4 @@ -import type { EncryptionStatus } from 'src/linodes'; +import type { EncryptionStatus } from '../linodes'; export interface KubernetesCluster { created: string; diff --git a/packages/api-v4/src/object-storage/index.ts b/packages/api-v4/src/object-storage/index.ts index f4a9bdf8d18..e2985222d3f 100644 --- a/packages/api-v4/src/object-storage/index.ts +++ b/packages/api-v4/src/object-storage/index.ts @@ -9,3 +9,5 @@ export * from './objects'; export * from './objectStorageKeys'; export * from './types'; + +export * from './prices'; diff --git a/packages/api-v4/src/object-storage/prices.ts b/packages/api-v4/src/object-storage/prices.ts new file mode 100644 index 00000000000..2907a6a110c --- /dev/null +++ b/packages/api-v4/src/object-storage/prices.ts @@ -0,0 +1,16 @@ +import { Params, PriceType, ResourcePage } from 'src/types'; +import { API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL } from '../request'; + +/** + * getObjectStorageTypes + * + * Return a paginated list of available Object Storage types; used for pricing. + * This endpoint does not require authentication. + */ +export const getObjectStorageTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/object-storage/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 01a7bd31e64..30d6e03826c 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -5,6 +5,7 @@ export type Capabilities = | 'Block Storage' | 'Block Storage Migrations' | 'Cloud Firewall' + | 'Disk Encryption' | 'GPU Linodes' | 'Kubernetes' | 'Linodes' diff --git a/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md b/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md new file mode 100644 index 00000000000..dec693443fb --- /dev/null +++ b/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add script to generate internal test results payload ([#10422](https://github.com/linode/manager/pull/10422)) diff --git a/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md b/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md new file mode 100644 index 00000000000..0e9637d3b7f --- /dev/null +++ b/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create Refactor - Scroll Errors Into View ([#10454](https://github.com/linode/manager/pull/10454)) diff --git a/packages/manager/.changeset/pr-10462-tests-1716303824484.md b/packages/manager/.changeset/pr-10462-tests-1716303824484.md new file mode 100644 index 00000000000..8a4005759be --- /dev/null +++ b/packages/manager/.changeset/pr-10462-tests-1716303824484.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for Disk Encryption in Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md b/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md new file mode 100644 index 00000000000..09bbf5b63a3 --- /dev/null +++ b/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Disk Encryption section to Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/.changeset/pr-10468-changed-1716219242980.md b/packages/manager/.changeset/pr-10468-changed-1716219242980.md new file mode 100644 index 00000000000..729048a7d33 --- /dev/null +++ b/packages/manager/.changeset/pr-10468-changed-1716219242980.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use dynamic pricing with `object-storage/types` endpoint ([#10468](https://github.com/linode/manager/pull/10468)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717780880.md b/packages/manager/.changeset/pr-10469-tests-1715717780880.md new file mode 100644 index 00000000000..9ab45c98475 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717780880.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 end-to-end tests ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717804841.md b/packages/manager/.changeset/pr-10469-tests-1715717804841.md new file mode 100644 index 00000000000..8baca1f3156 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717804841.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VLAN flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717849362.md b/packages/manager/.changeset/pr-10469-tests-1715717849362.md new file mode 100644 index 00000000000..3d0b7fb4c80 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717849362.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VPC flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717865422.md b/packages/manager/.changeset/pr-10469-tests-1715717865422.md new file mode 100644 index 00000000000..1ee3be6625a --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717865422.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for SSH key flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717889337.md b/packages/manager/.changeset/pr-10469-tests-1715717889337.md new file mode 100644 index 00000000000..6f5ee7bf11e --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717889337.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for cloud-init flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1716471500474.md b/packages/manager/.changeset/pr-10469-tests-1716471500474.md new file mode 100644 index 00000000000..090c468a04d --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1716471500474.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for Linode Create v2 flow ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10473-tests-1715795923381.md b/packages/manager/.changeset/pr-10473-tests-1715795923381.md new file mode 100644 index 00000000000..e803729ce57 --- /dev/null +++ b/packages/manager/.changeset/pr-10473-tests-1715795923381.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Integration Test for Linode Create with Placement Group ([#10473](https://github.com/linode/manager/pull/10473)) diff --git a/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md b/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md new file mode 100644 index 00000000000..ccf6bb170dd --- /dev/null +++ b/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Encrypted/Not Encrypted status to LKE Node Pool table ([#10480](https://github.com/linode/manager/pull/10480)) diff --git a/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md b/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md new file mode 100644 index 00000000000..290bea04284 --- /dev/null +++ b/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove aria-label from TableRow ([#10485](https://github.com/linode/manager/pull/10485)) diff --git a/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md b/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md new file mode 100644 index 00000000000..4f595dc7d54 --- /dev/null +++ b/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reset errors in PlacementGroupDeleteModal ([#10486](https://github.com/linode/manager/pull/10486)) diff --git a/packages/manager/.changeset/pr-10489-removed-1716225501082.md b/packages/manager/.changeset/pr-10489-removed-1716225501082.md new file mode 100644 index 00000000000..1564d68e00b --- /dev/null +++ b/packages/manager/.changeset/pr-10489-removed-1716225501082.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +`parentChildAccountAccess` feature flag ([#10489](https://github.com/linode/manager/pull/10489)) diff --git a/packages/manager/.changeset/pr-10490-fixed-1716227619276.md b/packages/manager/.changeset/pr-10490-fixed-1716227619276.md new file mode 100644 index 00000000000..4e2fdf04d94 --- /dev/null +++ b/packages/manager/.changeset/pr-10490-fixed-1716227619276.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Duplicate speedtest helper text in Create Cluster form ([#10490](https://github.com/linode/manager/pull/10490)) diff --git a/packages/manager/.changeset/pr-10493-tests-1716312638686.md b/packages/manager/.changeset/pr-10493-tests-1716312638686.md new file mode 100644 index 00000000000..e0687a7fdbf --- /dev/null +++ b/packages/manager/.changeset/pr-10493-tests-1716312638686.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add cypress assertion and test for placement group deletion error handling ([#10493](https://github.com/linode/manager/pull/10493)) diff --git a/packages/manager/.changeset/pr-10495-fixed-1716307990793.md b/packages/manager/.changeset/pr-10495-fixed-1716307990793.md new file mode 100644 index 00000000000..6c15b21bcfa --- /dev/null +++ b/packages/manager/.changeset/pr-10495-fixed-1716307990793.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +`RegionSelect` unexpected keyboard behavior ([#10495](https://github.com/linode/manager/pull/10495)) diff --git a/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md b/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md new file mode 100644 index 00000000000..e97bd3728cf --- /dev/null +++ b/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Make all tooltips Interactive and prevent `disableInteractive` for future usage ([#10501](https://github.com/linode/manager/pull/10501)) diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 3c114a06a7d..40a4b5761ef 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -27,11 +27,6 @@ import { } from 'support/util/random'; import type { CancelAccount } from '@linode/api-v4'; import { mockWebpageUrl } from 'support/intercepts/general'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Account cancellation', () => { /* @@ -325,12 +320,6 @@ describe('Parent/Child account cancellation', () => { const cancellationComments = randomPhrase(); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); mockCancelAccountError(cancellationPaymentErrorMessage, 409).as( diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index 797e97edccf..bd33a939c29 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -6,12 +6,7 @@ import { profileFactory } from 'src/factories'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { formatDate } from 'src/utilities/formatDate'; import { mockGetAccountLogins } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { PARENT_USER } from 'src/features/Account/constants'; describe('Account login history', () => { @@ -42,15 +37,9 @@ describe('Account login history', () => { 'getAccountLogins' ); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table is visible. cy.findByText( @@ -114,15 +103,9 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. cy.findByText( @@ -149,15 +132,9 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. cy.findByText( diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 72f3a3bd68b..49eb9c30630 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,10 +1,5 @@ import { Profile } from '@linode/api-v4'; import { profileFactory } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { getProfile } from 'support/api/account'; import { interceptGetProfile } from 'support/intercepts/profile'; @@ -18,12 +13,6 @@ const verifyUsernameAndEmail = ( tooltip: string, checkEmail: boolean ) => { - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(mockRestrictedProxyProfile); // Navigate to User Profile page diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 5f616fe4de6..270f5568f23 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -15,11 +15,6 @@ import { } from 'support/intercepts/profile'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; describe('Personal access tokens', () => { @@ -278,24 +273,13 @@ describe('Personal access tokens', () => { }); const proxyUserProfile = profileFactory.build({ user_type: 'proxy' }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(proxyUserProfile); mockGetPersonalAccessTokens([proxyToken]).as('getTokens'); mockGetAppTokens([]).as('getAppTokens'); mockRevokePersonalAccessToken(proxyToken.id).as('revokeToken'); cy.visitWithLogin('/profile/tokens'); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getTokens', - '@getAppTokens', - ]); + cy.wait(['@getTokens', '@getAppTokens']); // Find token in list, confirm "Rename" is disabled and tooltip displays. cy.findByText(proxyToken.label) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index e67d5695332..d8f21fa1366 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -11,14 +11,9 @@ import { mockUpdateUser, mockUpdateUserGrants, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Message shown when user has unrestricted account access. @@ -504,12 +499,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockActiveUser, mockRestrictedUser]).as('getUsers'); mockGetUser(mockActiveUser); mockGetUserGrants(mockActiveUser.username, mockUserGrants); @@ -520,7 +509,6 @@ describe('User permission management', () => { ); mockGetUser(mockRestrictedUser); mockGetUserGrants(mockRestrictedUser.username, mockUserGrants); - cy.wait(['@getClientStream', '@getFeatureFlags']); cy.get('[data-qa-global-section]') .should('be.visible') @@ -573,12 +561,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -590,8 +572,6 @@ describe('User permission management', () => { `/account/users/${mockRestrictedProxyUser.username}/permissions` ); - cy.wait(['@getClientStream', '@getFeatureFlags']); - cy.findByText('Parent User Permissions', { exact: false }).should( 'be.visible' ); diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index 23279de91af..2b2fd767d4f 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -7,13 +7,8 @@ import { mockGetUsers, } from 'support/intercepts/account'; import { mockGetSecurityQuestions } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { verificationBannerNotice } from 'support/constants/user'; describe('User verification banner', () => { @@ -46,12 +41,6 @@ describe('User verification banner', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream(); - mockGetUsers([mockRestrictedProxyUser]); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -128,12 +117,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -211,12 +194,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index e6c79be4da4..4597d099fda 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -10,16 +10,11 @@ import { mockGetUsers, mockDeleteUser, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { PARENT_USER } from 'src/features/Account/constants'; @@ -65,12 +60,6 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { global: { child_account_access: enableChildAccountAccess }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers(mockUsers).as('getUsers'); mockGetUser(mockRestrictedParentWithoutChildAccountAccess); @@ -114,7 +103,7 @@ describe('Users landing page', () => { // Confirm that "Child account access" column is present cy.findByText('Child Account Access').should('be.visible'); mockUsers.forEach((user) => { - cy.get(`[aria-label="User ${user.username}"]`) + cy.get(`[data-qa-table-row="${user.username}"]`) .should('be.visible') .within(() => { if ( @@ -228,12 +217,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); @@ -276,12 +259,6 @@ describe('Users landing page', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -466,12 +443,6 @@ describe('Users landing page', () => { restricted: true, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); @@ -565,7 +536,6 @@ describe('Users landing page', () => { expect(intercept.request.body['restricted']).to.equal(newUser.restricted); }); cy.wait('@getUsers'); - cy.wait(['@getClientStream', '@getFeatureFlags']); // the new user is displayed in the user list cy.findByText(newUser.username).should('be.visible'); @@ -587,12 +557,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser, additionalUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index dbd4d28727f..0db765463af 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -2,16 +2,6 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { profileFactory } from '@src/factories'; - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; - -import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { randomLabel } from 'support/util/random'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -161,32 +151,3 @@ describe('Billing Contact', () => { }); }); }); - -describe('Parent/Child feature disabled', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); - }); - - it('disables company name for Parent users', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - user_type: 'parent', - }); - - mockGetProfile(mockProfile); - cy.visitWithLogin('/account/billing/edit'); - - ui.drawer - .findByTitle('Edit Billing Contact Info') - .should('be.visible') - .within(() => { - cy.findByLabelText('Company Name') - .should('be.visible') - .should('be.disabled'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 55566712282..6869a54a91b 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -7,16 +7,11 @@ import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Tooltip message that appears on disabled billing action buttons for restricted @@ -229,170 +224,118 @@ describe('restricted user billing flows', () => { mockGetPaymentMethods(mockPaymentMethods); }); - // TODO Delete all of these tests when Parent/Child launches and flag is removed. - describe('Parent/Child feature disabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); + /* + * - Confirms that users with read-only account access cannot edit billing information. + * - Confirms UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects read-only account access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information with read-only account access', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + const mockGrants = grantsFactory.build({ + global: { + account_access: 'read_only', + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` + ); + }); + + /* + * - Confirms that child users cannot edit billing information. + * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects child user access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information as child account', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'child', }); - /* - * - Smoke test to confirm that regular users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - * - Confirms that payment method action menu items are enabled. - */ - it('can edit billing information', () => { - // The flow prior to Parent/Child does not account for user privileges, instead relying - // on the API to forbid actions when the user does not have the required privileges. - // Because the API is doing the heavy lifting, we only need to ensure that the billing action - // buttons behave as expected for this smoke test. - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - user_type: 'default', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUser = accountUserFactory.build({ + username: mockProfile.username, }); + + mockGetProfile(mockProfile); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${PARENT_USER} to request the necessary permissions.` + ); }); - describe('Parent/Child feature enabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - // TODO Delete this `beforeEach()` block when Parent/Child launches and flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); + /* + * - Smoke test to confirm that regular and parent users can edit billing information. + * - Confirms that billing action buttons are enabled and open their respective drawers on click. + */ + it('can edit billing information as a regular user and as a parent user', () => { + const mockProfileRegular = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Confirms that users with read-only account access cannot edit billing information. - * - Confirms UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects read-only account access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information with read-only account access', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: true, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - restricted: true, - user_type: 'default', - }); - - const mockGrants = grantsFactory.build({ - global: { - account_access: 'read_only', - }, - }); - - mockGetProfile(mockProfile); - mockGetProfileGrants(mockGrants); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); + const mockUserRegular = accountUserFactory.build({ + username: mockProfileRegular.username, + user_type: 'default', + restricted: false, }); - /* - * - Confirms that child users cannot edit billing information. - * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects child user access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information as child account', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - user_type: 'child', - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - }); - - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${PARENT_USER} to request the necessary permissions.` - ); + const mockProfileParent = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Smoke test to confirm that regular and parent users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - */ - it('can edit billing information as a regular user and as a parent user', () => { - const mockProfileRegular = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserRegular = accountUserFactory.build({ - username: mockProfileRegular.username, - user_type: 'default', - restricted: false, - }); - - const mockProfileParent = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserParent = accountUserFactory.build({ - username: mockProfileParent.username, - user_type: 'parent', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfileRegular); - mockGetUser(mockUserRegular); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileRegular.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); - - // Confirm button behavior for parent users. - mockGetProfile(mockProfileParent); - mockGetUser(mockUserParent); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileParent.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUserParent = accountUserFactory.build({ + username: mockProfileParent.username, + user_type: 'parent', + restricted: false, }); + + // Confirm button behavior for regular users. + mockGetProfile(mockProfileRegular); + mockGetUser(mockUserRegular); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileRegular.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); + + // Confirm button behavior for parent users. + mockGetProfile(mockProfileParent); + mockGetUser(mockUserParent); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileParent.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts new file mode 100644 index 00000000000..bf7774cc0bd --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -0,0 +1,77 @@ +/** + * @file Smoke tests for Linode Create flow across common mobile viewport sizes. + */ + +import { linodeFactory } from 'src/factories'; +import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Linode create mobile smoke', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + MOBILE_VIEWPORTS.forEach((viewport) => { + /* + * - Confirms Linode create flow can be completed on common mobile screen sizes + * - Creates a basic Nanode and confirms interactions succeed and outgoing request contains expected data. + */ + it(`can create Linode (${viewport.label})`, () => { + const mockLinodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + + cy.viewport(viewport.width, viewport.height); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlanCard('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Nanode 1 GB').should('be.visible'); + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(mockLinodeRegion.label).should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestBody = xhr.request.body; + + expect(requestBody['image']).to.equal('linode/debian11'); + expect(requestBody['label']).to.equal(mockLinode.label); + expect(requestBody['region']).to.equal(mockLinodeRegion.id); + expect(requestBody['type']).to.equal('g6-nanode-1'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts new file mode 100644 index 00000000000..07a04310671 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -0,0 +1,186 @@ +import { + accountUserFactory, + linodeFactory, + sshKeyFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { linodeCreatePage } from 'support/ui/pages'; +import { ui } from 'support/ui'; +import { mockCreateSSHKey } from 'support/intercepts/profile'; + +describe('Create Linode with SSH Key', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow when creating a Linode with an authorized SSH key. + * - Confirms that existing SSH keys are listed on page and can be selected. + * - Confirms that outgoing Linode create API request contains authorized user for chosen key. + */ + it('can add an existing SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [mockSshKey.label], + }); + + mockGetUsers([mockUser]); + mockGetUser(mockUser); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that SSH key is listed, then select it. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockUser.username); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the selected SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); + + /* + * - Confirms UI flow when creating and selecting an SSH key during Linode create flow. + * - Confirms that new SSH key is automatically shown in Linode create page. + * - Confirms that outgoing Linode create API request contains authorized user for new key. + */ + it('can add a new SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa ${randomString(16)}`, + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [], + }); + + const mockUserWithKey = { + ...mockUser, + ssh_keys: [mockSshKey.label], + }; + + mockGetUser(mockUser); + mockGetUsers([mockUser]); + mockCreateLinode(mockLinode).as('createLinode'); + mockCreateSSHKey(mockSshKey).as('createSSHKey'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that no SSH keys are listed for the mocked user. + cy.findByText(mockUser.username) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('None').should('be.visible'); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).should( + 'be.disabled' + ); + }); + + // Click "Add an SSH Key" and enter a label and the public key, then submit. + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetUsers([mockUserWithKey]).as('refetchUsers'); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + cy.findByLabelText('Label').type(mockSshKey.label); + cy.findByLabelText('SSH Public Key').type(mockSshKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createSSHKey', '@refetchUsers']); + + // Confirm that the new SSH key is listed, and select it to be added to the Linode. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the new SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts new file mode 100644 index 00000000000..21096becdf3 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -0,0 +1,149 @@ +import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with user data', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with cloud-init user data specified. + * - Confirms that outgoing API request contains expected user data payload. + */ + it('can specify user data during Linode Create flow', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const userDataFixturePath = 'user-data/user-data-config-basic.yml'; + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + // Fill out create form, selecting a region and image that both have + // cloud-init capabilities. + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Expand "Add User Data" accordion and enter user data config. + ui.accordionHeading + .findByTitle('Add User Data') + .should('be.visible') + .click(); + + cy.fixture(userDataFixturePath).then((userDataContents) => { + ui.accordion.findByTitle('Add User Data').within(() => { + cy.findByText('User Data').click(); + cy.focused().type(userDataContents); + }); + + // Submit form to create Linode and confirm that outgoing API request + // contains expected user data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['metadata']['user_data']).to.equal( + btoa(userDataContents) + ); + }); + }); + }); + + /* + * - Confirms UI flow when creating a Linode using a region that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected region lacks cloud-init. + */ + it('cannot specify user data when selected region does not support it', () => { + const mockLinodeRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockGetRegions([mockLinodeRegion]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected region + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); + + /* + * - Confirms UI flow when creating a Linode using an image that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected image lacks cloud-init. + */ + it('cannot specify user data when selected image does not support it', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const mockImage = imageFactory.build({ + id: `linode/${randomLabel()}`, + label: randomLabel(), + created_by: 'linode', + is_public: true, + vendor: 'Debian', + // `cloud-init` is omitted from Image capabilities. + capabilities: [], + }); + + mockGetImage(mockImage.id, mockImage); + mockGetAllImages([mockImage]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage(mockImage.label); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected image + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts new file mode 100644 index 00000000000..7b3d495de94 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -0,0 +1,240 @@ +import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { + randomIp, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Create Linode with VLANs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign existing VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([mockVlan]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and select existing VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(mockVlan.label) + .should('be.visible') + .click(); + + cy.findByLabelText(/IPAM Address/) + .should('be.enabled') + .type(mockVlan.cidr_block); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal( + mockVlan.cidr_block + ); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm VLAN creation and attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for new VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign new VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and specify new VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(`Create "${mockVlan.label}"`) + .should('be.visible') + .click(); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal(''); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm that VLANs cannot be assigned to Linodes in regions without capability. + * - Confirms that VLAN fields are disabled before and after selecting a region. + */ + it('cannot assign VLANs in regions without capability', () => { + const availabilityNotice = + 'VLANs are currently available in select regions.'; + + const nonVlanRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vlanRegion = regionFactory.build({ + capabilities: ['Linodes', 'Vlans'], + }); + + mockGetRegions([nonVlanRegion, vlanRegion]); + cy.visitWithLogin('/linodes/create'); + + // Expand VLAN accordion, confirm VLAN availability notice is displayed and + // that VLAN fields are disabled while no region is selected. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.contains(availabilityNotice).should('be.visible'); + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + + // Select a region that is known not to have VLAN capability. + linodeCreatePage.selectRegionById(nonVlanRegion.id); + + // Confirm that VLAN fields are still disabled. + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts new file mode 100644 index 00000000000..668c344f2de --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -0,0 +1,267 @@ +import { + linodeFactory, + regionFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateVPC, + mockCreateVPCError, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { linodeCreatePage, vpcCreateDrawer } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomIp, + randomLabel, + randomNumber, + randomPhrase, + randomString, +} from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with VPCs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. + * - Confirms that VPC assignment is reflected in create summary section. + * - Confirms that outgoing API request contains expected VPC interface data. + */ + it('can assign existing VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: `${randomIp()}/0`, + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + // + }); + + mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that mocked VPC is shown in the Autocomplete, and then select it. + cy.findByText('Assign VPC').click().type(`${mockVPC.label}`); + + ui.autocompletePopper + .findByTitle(mockVPC.label) + .should('be.visible') + .click(); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Confirm VPC assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VPC Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow to create a Linode with a new VPC assigned using mock API data. + * - Creates a VPC and a subnet from within the Linode Create flow. + * - Confirms that Cloud responds gracefully when VPC create API request fails. + * - Confirms that outgoing API request contains correct VPC interface data. + */ + it('can assign new VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockErrorMessage = 'An unknown error occurred.'; + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + description: randomPhrase(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockGetVPCs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.findByText('Create VPC').should('be.visible').click(); + + ui.drawer + .findByTitle('Create VPC') + .should('be.visible') + .within(() => { + vpcCreateDrawer.setLabel(mockVPC.label); + vpcCreateDrawer.setDescription(mockVPC.description); + vpcCreateDrawer.setSubnetLabel(mockSubnet.label); + vpcCreateDrawer.setSubnetIpRange(mockSubnet.ipv4!); + + // Confirm that unexpected API errors are handled gracefully upon + // failed VPC creation. + mockCreateVPCError(mockErrorMessage, 500).as('createVpc'); + vpcCreateDrawer.submit(); + + cy.wait('@createVpc'); + cy.findByText(mockErrorMessage).scrollIntoView().should('be.visible'); + + // Create VPC with successful API response mocked. + mockCreateVPC(mockVPC).as('createVpc'); + vpcCreateDrawer.submit(); + }); + + // Attempt to create Linode before selecting a VPC subnet, and confirm + // that validation error appears. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Subnet is required.').should('be.visible'); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Check box to assign public IPv4. + cy.findByText('Assign a public IPv4 address for this Linode') + .should('be.visible') + .click(); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.deep.equal({ nat_1_1: 'any' }); + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow when attempting to assign VPC to Linode in region without capability. + * - Confirms that VPCs selection is disabled. + * - Confirms that notice text is present to explain that VPCs are unavailable. + */ + it('cannot assign VPCs to Linodes in regions without VPC capability', () => { + const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vpcNotAvailableMessage = + 'VPC is not available in the selected region.'; + + mockGetRegions([mockRegion]); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectRegionById(mockRegion.id); + + cy.findByLabelText('Assign VPC') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + cy.findByText(vpcNotAvailableMessage).should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index a7d7a8c2fb9..92c99fa4884 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -1,514 +1,127 @@ -import { - containsVisible, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +/** + * @file Linode Create end-to-end tests. + */ + import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; -import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; -import { - subnetFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - regionFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, -} from '@src/factories'; -import { authenticate } from 'support/api/authentication'; +import { randomLabel, randomString } from 'support/util/random'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - mockCreateLinode, - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { linodeCreatePage } from 'support/ui/pages'; +import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; - -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'uk', - id: 'eu-west', - label: 'London, UK', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'sg', - id: 'ap-south', - label: 'Singapore, SG', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-east', - label: 'Newark, NJ', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-central', - label: 'Dallas, TX', - }), -]; - authenticate(); -describe('create linode', () => { +describe('Create Linode', () => { before(() => { cleanUp('linodes'); }); - /* - * Region select test. - * - * TODO: Cypress - * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 - * - * - Confirms that region select dropdown is visible and interactive. - * - Confirms that region select dropdown is populated with expected regions. - * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. - * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. - */ - it('region select', () => { - mockGetRegions(mockRegions).as('getRegions'); - - cy.visitWithLogin('linodes/create'); - - cy.wait(['@getRegions']); - - // Confirm that region select dropdown is visible and interactive. - ui.regionSelect.find().click(); - cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); - - // Confirm that region select dropdown are grouped by region, - // sorted alphabetically, with North America first. - cy.get('.MuiAutocomplete-groupLabel') - .should('have.length', 3) - .should((group) => { - expect(group[0]).to.contain('North America'); - expect(group[1]).to.contain('Asia'); - expect(group[2]).to.contain('Europe'); - }); - - // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. - cy.get('[data-qa-option]').should('exist').should('have.length', 4); - mockRegions.forEach((region) => { - cy.get('[data-qa-option]').contains(region.label); + // Enable the `linodeCreateRefactor` feature flag. + // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), }); - - // Select an option - cy.findByTestId('eu-west').click(); - // Confirm the popper is closed - cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); - // Confirm that the selected region is displayed in the input field. - cy.get('[data-testid="textfield-input"]').should( - 'have.value', - 'London, UK (eu-west)' - ); - - // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); - }); - - it('creates a nanode', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - containsVisible('PROVISIONING'); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL') - .should('be.visible') - .should('have.attr', 'data-selected'); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - '"booted": true', - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - `--booted true`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); + mockGetFeatureFlagClientstream(); }); /* - * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing docs link is shown in "Region" section. - * - Confirms that backups pricing is correct when selecting a region with a different price structure. + * End-to-end tests to create Linodes for each available plan type. */ - it('shows DC-specific pricing information during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - )!; - const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id - )!; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes']); - - mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - getClick('[data-qa-deploy-linode]'); - - // A message is shown to instruct users to select a region in order to view plans and prices - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - 'Plan is required.' - ); - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - dcPricingPlanPlaceholder - ); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - // Confirm there is a docs link to the pricing page. - cy.findByText(dcPricingDocsLabel) - .should('be.visible') - .should('have.attr', 'href', dcPricingDocsUrl); - - ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); + describe('End-to-end', () => { + // Run an end-to-end test to create a basic Linode for each plan type described below. + describe('By plan type', () => { + [ + { + planType: 'Shared CPU', + planLabel: 'Nanode 1 GB', + planId: 'g6-nanode-1', + }, + { + planType: 'Dedicated CPU', + planLabel: 'Dedicated 4 GB', + planId: 'g6-dedicated-2', + }, + { + planType: 'High Memory', + planLabel: 'Linode 24 GB', + planId: 'g7-highmem-1', + }, + { + planType: 'Premium CPU', + planLabel: 'Premium 4 GB', + planId: 'g7-premium-2', + }, + // TODO Include GPU plan types. + ].forEach((planConfig) => { + /* + * - Parameterized end-to-end test to create a Linode for each plan type. + * - Confirms that a Linode of the given plan type can be deployed. + */ + it(`creates a ${planConfig.planType} Linode`, () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Premium Plans'], + }); + const linodeLabel = randomLabel(); + + interceptCreateLinode().as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan( + planConfig.planType, + planConfig.planLabel + ); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(planConfig.planLabel).should('be.visible'); + }); + + // Create Linode and confirm it's provisioned as expected. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const responsePayload = xhr.response?.body; + + // Confirm that API request and response contain expected data + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['region']).to.equal(linodeRegion.id); + expect(requestPayload['type']).to.equal(planConfig.planId); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(planConfig.planId); + + // Confirm that Cloud redirects to details page + cy.url().should('endWith', `/linodes/${responsePayload['id']}`); + }); + + // TODO Confirm whether or not toast notification should appear here. + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + }); }); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { - const region: Region = getRegionById('us-southeast'); - const mockNoVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes'], - }); - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockNoVPCRegion]).as('getRegions'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible( - 'Allow Linode to communicate in an isolated environment.' - ); - // Helper text appears if VPC is not available in selected region. - containsVisible('VPC is not available in the selected region.'); - }); - }); - - it('assigns a VPC to the linode during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockSubnet = subnetFactory.build({ - id: randomNumber(2), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - region: 'us-southeast', - subnets: [mockSubnet], - }); - const mockVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], - }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', - }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', - active: true, - }); - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - interfaces: [ - // The order of this array is significant. Index 0 (eth0) should be public. - mockPublicConfigInterface, - mockVlanConfigInterface, - mockVpcConfigInterface, - ], - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockVPCRegion]).as('getRegions'); - - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockGetVPCs([mockVPC]).as('getVPCs'); - mockCreateLinode(mockLinode).as('linodeCreated'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.get('[data-qa-enhanced-select="None"]') - .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); - // select subnet - cy.findByText('Select Subnet') - .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - - fbtClick('Configurations'); - //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts new file mode 100644 index 00000000000..0f9e5fc2238 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -0,0 +1,603 @@ +/** + * @file Integration tests and end-to-end tests for legacy Linode Create flow. + */ +// TODO Delete this test file when `linodeCreateRefactor` feature flag is retired. +// Move out any tests (e.g. region select test) for flows that aren't covered by new tests in the meantime. + +import { + containsVisible, + fbtClick, + fbtVisible, + getClick, + getVisible, +} from 'support/helpers'; +import { ui } from 'support/ui'; +import { apiMatcher } from 'support/util/intercepts'; +import { randomString, randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { getRegionById } from 'support/util/regions'; +import { + accountFactory, + subnetFactory, + vpcFactory, + linodeFactory, + linodeConfigFactory, + regionFactory, + VLANFactory, + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/DiskEncryption/DiskEncryption'; + +import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }), +]; + +authenticate(); +describe('create linode', () => { + before(() => { + cleanUp('linodes'); + }); + + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + cy.visitWithLogin('linodes/create'); + + cy.wait(['@getRegions']); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + // Confirm that the selected region is displayed in the input field. + cy.get('[data-testid="textfield-input"]').should( + 'have.value', + 'London, UK (eu-west)' + ); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); + + it('creates a nanode', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.get('[data-qa-deploy-linode]'); + cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); + fbtClick('Shared CPU'); + getClick('[id="g6-nanode-1"]'); + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + containsVisible('PROVISIONING'); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it('creates a linode via CLI', () => { + const linodeLabel = randomLabel(); + const linodePass = randomString(32); + const linodeRegion = chooseRegion(); + + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.autocompletePopper + .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) + .should('exist') + .click(); + + cy.get('[id="g6-dedicated-2"]').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .should('be.enabled') + .clear() + .type(linodeLabel); + + cy.findByLabelText('Root Password') + .should('be.visible') + .should('be.enabled') + .type(linodePass); + + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + // Switch to cURL view if necessary. + cy.findByText('cURL') + .should('be.visible') + .should('have.attr', 'data-selected'); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + '"booted": true', + ].forEach((line: string) => + cy.findByText(line, { exact: false }).should('be.visible') + ); + + cy.findByText('Linode CLI').should('be.visible').click(); + + [ + `--region ${linodeRegion.id}`, + '--type g6-dedicated-2', + `--label ${linodeLabel}`, + `--root_pass ${linodePass}`, + `--booted true`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms DC-specific pricing UI flow works as expected during Linode creation. + * - Confirms that pricing docs link is shown in "Region" section. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. + */ + it('shows DC-specific pricing information during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const initialRegion = getRegionById('us-west'); + const newRegion = getRegionById('us-east'); + + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: initialRegion.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + )!; + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + )!; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes']); + + mockCreateLinode(mockLinode).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + getClick('[data-qa-deploy-linode]'); + + // A message is shown to instruct users to select a region in order to view plans and prices + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + 'Plan is required.' + ); + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + dcPricingPlanPlaceholder + ); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { + const region: Region = getRegionById('us-southeast'); + const mockNoVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes'], + }); + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockNoVPCRegion]).as('getRegions'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible( + 'Allow Linode to communicate in an isolated environment.' + ); + // Helper text appears if VPC is not available in selected region. + containsVisible('VPC is not available in the selected region.'); + }); + }); + + it('assigns a VPC to the linode during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const region: Region = getRegionById('us-southeast'); + const diskLabel: string = 'Debian 10 Disk'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); + const mockVLANs: VLAN[] = VLANFactory.buildList(2); + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + }); + const mockVPC = vpcFactory.build({ + id: randomNumber(), + region: 'us-southeast', + subnets: [mockSubnet], + }); + const mockVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes', 'VPCs', 'Vlans'], + }); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); + const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + purpose: 'vpc', + active: true, + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + mockVlanConfigInterface, + mockVpcConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: diskLabel, + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockVPCRegion]).as('getRegions'); + + mockGetVLANs(mockVLANs); + mockGetVPC(mockVPC).as('getVPC'); + mockGetVPCs([mockVPC]).as('getVPCs'); + mockCreateLinode(mockLinode).as('linodeCreated'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getLinodeTypes', + '@getClientStream', + '@getFeatureFlags', + '@getVPCs', + ]); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present, and the VPC in the same region of + // the linode can be selected. + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible('Assign this Linode to an existing VPC.'); + // select VPC + cy.get('[data-qa-enhanced-select="None"]') + .should('be.visible') + .click() + .type(`${mockVPC.label}{enter}`); + // select subnet + cy.findByText('Select Subnet') + .should('be.visible') + .click() + .type(`${mockSubnet.label}{enter}`); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + + fbtClick('Configurations'); + //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); + + // Confirm that VLAN and VPC have been assigned. + cy.findByLabelText('List of Configurations').within(() => { + cy.get('tr').should('have.length', 2); + containsVisible(`${mockConfig.label} – GRUB 2`); + containsVisible('eth0 – Public Internet'); + containsVisible(`eth2 – VPC: ${mockVPC.label}`); + }); + }); + + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 85735a3cf48..76459bd7b5b 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -36,16 +36,6 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. const objNotes = { - // When enabling OBJ using a region with a regular pricing structure, when OBJ DC-specific pricing is disabled. - regularPricing: /Linode Object Storage costs a flat rate of \$5\/month, and includes 250 GB of storage and 1 TB of outbound data transfer. Beyond that, it.*s \$0.02 per GB per month./, - - // When enabling OBJ using a region with special pricing during the free beta period (OBJ DC-specific pricing is disabled). - dcSpecificBetaPricing: /Object Storage for .* is currently in beta\. During the beta period, Object Storage in these regions is free\. After the beta period, customers will be notified before charges for this service begin./, - - // When enabling OBJ without having selected a region, when OBJ DC-specific pricing is disabled. - dcPricingGenericExplanation: - 'Pricing for monthly rate and overage costs will depend on the data center you select for deployment.', - // When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled. objDCPricing: 'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.', diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index c53fdf988c3..cff27fd1f4f 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -237,7 +237,7 @@ describe('object storage end-to-end tests', () => { cy.wait('@uploadObject'); cy.reload(); - cy.findByLabelText(bucketFiles[0].name).should('be.visible'); + cy.findByText(bucketFiles[0].name).should('be.visible'); ui.button.findByTitle('Delete').should('be.visible').click(); ui.dialog diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index cb1300418d2..c20ba294325 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -21,10 +21,6 @@ import { mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockAllApiRequests } from 'support/intercepts/general'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { @@ -33,7 +29,6 @@ import { } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { assertLocalStorageValue } from 'support/util/local-storage'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { grantsFactory } from '@src/factories/grants'; @@ -156,14 +151,6 @@ describe('Parent/Child account switching', () => { * Tests to confirm that Parent account users can switch to Child accounts as expected. */ describe('From Parent to Child', () => { - beforeEach(() => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after feature launch and clean-up. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that Parent account user can switch to Child account from Account Billing page. * - Confirms that Child account information is displayed in user menu button after switch. @@ -326,13 +313,6 @@ describe('Parent/Child account switching', () => { * Tests to confirm that Parent account users can switch back from Child accounts as expected. */ describe('From Child to Parent', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that a Child account Proxy user can switch back to a Parent account from Billing page. * - Confirms that Parent account information is displayed in user menu button after switch. diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts index bf91b178e3b..0c03b94d23e 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -1,9 +1,4 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { accountFactory, accountUserFactory, @@ -33,14 +28,6 @@ const mockChildAccountProxyProfile = profileFactory.build({ }); describe('Parent/Child token expiration', () => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after launch and clean-up. - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms flow when a Proxy user attempts to switch back to a Parent account with expired auth token. * - Uses mock API and local storage data. diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts new file mode 100644 index 00000000000..c0ad5c28d4c --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -0,0 +1,209 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + accountFactory, + linodeFactory, + placementGroupFactory, +} from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { ui } from 'support/ui/'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreatePlacementGroup, + mockGetPlacementGroups, +} from 'support/intercepts/placement-groups'; +import { randomString } from 'support/util/random'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + +import type { Region } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const mockAccount = accountFactory.build(); +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes', 'Placement Group'], + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + country: 'us', + }), +]; + +describe('Linode create flow with Placement Group', () => { + beforeEach(() => { + mockGetAccount(mockAccount); + mockGetRegions(mockRegions).as('getRegions'); + // TODO Remove feature flag mocks when `placementGroups` flag is retired. + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + linodeCreateRefactor: makeFeatureFlagData( + false + ), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms Placement Group create UI flow using mock API data. + * - Confirms that outgoing Placement Group create request contains expected data. + * - Confirms that Cloud automatically updates to list new Placement Group on landing page. + */ + it('can create a linode with a newly created Placement Group', () => { + cy.visitWithLogin('/linodes/create'); + + cy.findByText( + 'Select a Region for your Linode to see existing placement groups.' + ).should('be.visible'); + + // Region without capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[1].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + cy.findByText('Placement Groups in Dallas, TX (us-central)').should( + 'be.visible' + ); + cy.get('[data-testid="placement-groups-no-capability-notice"]').should( + 'be.visible' + ); + ui.tooltip + .findByText('Regions that support placement groups') + .should('be.visible') + .click(); + cy.get('[data-testid="supported-pg-region-us-east"]').should('be.visible'); + + // Region with capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + // Choose Placement Group + // No Placement Group available + cy.findByText('Placement Groups in Newark, NJ (us-east)').should( + 'be.visible' + ); + // Open the select + cy.get('[data-testid="placement-groups-select"] input').click(); + cy.findByText('There are no placement groups in this region.').click(); + // Close the select + cy.get('[data-testid="placement-groups-select"] input').click(); + + // Create a Placement Group + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + + const mockPlacementGroup = placementGroupFactory.build({ + label: 'pg-1-us-east', + region: mockRegions[0].id, + affinity_type: 'anti_affinity:local', + is_strict: true, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockCreatePlacementGroup(mockPlacementGroup).as('createPlacementGroup'); + + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + // Confirm that the drawer contains the expected default information. + // - A selection region + // - An Affinity Type Enforcement message + // - a disabled "Create Placement Group" button. + cy.findByText('Newark, NJ (us-east)').should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.disabled'); + + // Enter label and submit form. + cy.findByLabelText('Label').type(mockPlacementGroup.label); + + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createPlacementGroup').then((xhr) => { + const requestPayload = xhr.request?.body; + expect(requestPayload['affinity_type']).to.equal('anti_affinity:local'); + expect(requestPayload['is_strict']).to.equal(true); + expect(requestPayload['label']).to.equal(mockPlacementGroup.label); + expect(requestPayload['region']).to.equal(mockRegions[0].id); + }); + + // Confirm that the drawer closes and a success message is displayed. + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully created.` + ); + + // Select the newly created Placement Group. + cy.wait('@getPlacementGroups'); + cy.get('[data-testid="placement-groups-select"] input').should( + 'have.value', + mockPlacementGroup.label + ); + + const linodeLabel = 'linode-with-placement-group'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: mockRegions[0].id, + placement_group: { + id: mockPlacementGroup.id, + }, + }); + + // Confirm the Placement group assignment is accounted for in the summary. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText('Assigned to Placement Group').should('be.visible'); + }); + + // Type in a label, password and submit the form. + mockCreateLinode(mockLinode).as('createLinode'); + cy.get('#linode-label').clear().type('linode-with-placement-group'); + cy.get('#root-password').type(randomString(32)); + + cy.get('[data-qa-deploy-linode]').click(); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request?.body; + + expect(requestPayload['region']).to.equal(mockRegions[0].id); + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['placement_group'].id).to.equal( + mockPlacementGroup.id + ); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts index 088b40ce4c1..ceb4b9669b9 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -17,6 +17,8 @@ import { import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + const mockAccount = accountFactory.build(); describe('Placement Group create flow', () => { @@ -69,8 +71,6 @@ describe('Placement Group create flow', () => { }); const placementGroupLimitMessage = `Maximum placement groups in region: ${mockPlacementGroupRegion.placement_group_limits.maximum_pgs_per_customer}`; - const affinityTypeMessage = - 'Once you create a placement group, you cannot change its Affinity Type Enforcement setting.'; mockGetRegions(mockRegions); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -103,7 +103,9 @@ describe('Placement Group create flow', () => { .type(`${mockPlacementGroupRegion.label}{enter}`); cy.findByText(placementGroupLimitMessage).should('be.visible'); - cy.findByText(affinityTypeMessage).should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); ui.buttonGroup .findButtonByTitle('Create Placement Group') diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index e99a3ffecf7..c1dfd05220a 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -13,6 +13,8 @@ import { mockDeletePlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, + mockDeletePlacementGroupError, + mockUnassignPlacementGroupLinodesError, } from 'support/intercepts/placement-groups'; import { accountFactory, @@ -42,6 +44,9 @@ const unassignWarning = const emptyStateMessage = 'Control the physical placement or distribution of Linode instances within a data center or availability zone.'; +// Error message that when an unexpected error occurs. +const PlacementGroupErrorMessage = 'An unknown error has occurred.'; + describe('Placement Group deletion', () => { beforeEach(() => { // TODO Remove feature flag mocks when `placementGroups` flag is retired. @@ -60,8 +65,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. * - Confirms that UI automatically updates to reflect deleted Placement Group. * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can retry and continue with deletion when unexpected error happens. */ - it('can delete without Linodes assigned', () => { + it('can delete without Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), @@ -76,7 +82,6 @@ describe('Placement Group deletion', () => { cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); - // Click "Delete" button next to the mock Placement Group. cy.findByText(mockPlacementGroup.label) .should('be.visible') .closest('tr') @@ -88,6 +93,30 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -99,8 +128,6 @@ describe('Placement Group deletion', () => { cy.findByText(deletionWarning).should('be.visible'); cy.findByText(unassignWarning).should('not.exist'); - cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); - ui.button .findByTitle('Delete') .should('be.visible') @@ -122,8 +149,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. * - Confirms that UI automatically updates to reflect unassigned Linodes during deletion. * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that user can retry and continue with unassignment when unexpected error happens. */ - it('can delete with Linodes assigned', () => { + it('can delete with Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); // Linodes that are assigned to the Placement Group being deleted. @@ -176,6 +204,36 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + // Confirm deletion warning appears and that form cannot be submitted // while Linodes are assigned. ui.dialog @@ -256,4 +314,243 @@ describe('Placement Group deletion', () => { cy.findByText(mockPlacementGroup.label).should('not.exist'); cy.findByText(secondMockPlacementGroup.label).should('be.visible'); }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. + * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can delete without Linodes assigned when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + // The dialog can be closed after an unexpect error show up + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud + mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetPlacementGroups([]).as('getPlacementGroups'); + + // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms deletion flow when Placement Group has one or more Linodes assigned to it. + * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can unassign Linode when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + + // Linodes that are assigned to the Placement Group being deleted. + const mockPlacementGroupLinodes = buildArray(3, () => + linodeFactory.build({ + label: randomLabel(), + id: randomNumber(), + region: mockPlacementGroupRegion.id, + }) + ); + + // Placement Group that will be deleted. + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: mockPlacementGroupLinodes.map((linode) => ({ + linode_id: linode.id, + is_compliant: true, + })), + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + // Second unrelated Placement Group to verify landing page content after deletion. + const secondMockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); + mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( + 'getPlacementGroups' + ); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + // Click "Delete" button next to the mock Placement Group. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group to reopen the dialog + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm deletion warning appears and that form cannot be submitted + // while Linodes are assigned. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + // Unassign each Linode. + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index a8176d26e80..4738d91e962 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -23,7 +23,7 @@ describe('Delete stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -47,7 +47,7 @@ describe('Delete stackscripts', () => { }); // The StackScript is deleted successfully. - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -73,7 +73,7 @@ describe('Delete stackscripts', () => { cy.findByText(stackScripts[0].label).should('not.exist'); // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. - cy.get(`[aria-label="${stackScripts[1].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[1].label}"]`) .closest('tr') .within(() => { ui.actionMenu diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index 2727f612e2c..dd039c7bb1d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -36,7 +36,7 @@ describe('Display stackscripts', () => { cy.wait('@getStackScripts'); stackScripts.forEach((stackScript) => { - cy.get(`[aria-label="${stackScript.label}"]`) + cy.get(`[data-qa-table-row="${stackScript.label}"]`) .closest('tr') .within(() => { cy.findByText(stackScript.deployments_total).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 32144f33cab..7e52b8c6362 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -95,14 +95,12 @@ describe('Update stackscripts', () => { cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( 'getStackScript' ); @@ -205,14 +203,12 @@ describe('Update stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .should('be.visible') @@ -234,14 +230,12 @@ describe('Update stackscripts', () => { }); // The status of the StackScript will become public - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .should('be.visible') 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 cf132d44e2c..1c030b31225 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 @@ -192,8 +192,8 @@ describe('VPC assign/unassign flows', () => { .click(); }); - cy.get('[aria-label="View Details"]') - .closest('tbody') + cy.get('[data-qa-table-row="collapsible-table-headers-row"]') + .siblings('tbody') .within(() => { // after assigning Linode(s) to a VPC, VPC page increases number in 'Linodes' column cy.findByText('1').should('be.visible'); diff --git a/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml new file mode 100644 index 00000000000..b8bc4e3163e --- /dev/null +++ b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml @@ -0,0 +1,11 @@ +#cloud-config + +# Sample cloud-init config data file. +# See also: https://cloudinit.readthedocs.io/en/latest/explanation/format.html + +groups: + - foo-group + +users: + - name: foo + primary_group: foo-group diff --git a/packages/manager/cypress/support/constants/environment.ts b/packages/manager/cypress/support/constants/environment.ts new file mode 100644 index 00000000000..545bcd9bc8a --- /dev/null +++ b/packages/manager/cypress/support/constants/environment.ts @@ -0,0 +1,21 @@ +/** + * @file Constants related to test environment. + */ + +export interface ViewportSize { + width: number; + height: number; + label?: string; +} + +// Array of common mobile viewports against which to test. +export const MOBILE_VIEWPORTS: ViewportSize[] = [ + { + // iPhone 14 Pro, iPhone 15, iPhone 15 Pro, etc. + label: 'iPhone 15', + width: 393, + height: 852, + }, + // TODO Evaluate what devices to include here and how long to allow this list to be. Tablets? + // Do we want to keep this short, or make it long and just choose a random subset each time we do mobile testing? +]; diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts new file mode 100644 index 00000000000..f6eb377242c --- /dev/null +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -0,0 +1,10 @@ +/** + * @file Constants related to Linode tests. + */ + +/** + * Length of time to wait for a Linode to be created. + * + * Equals 3 minutes. + */ +export const LINODE_CREATE_TIMEOUT = 180_000; diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index 244e969867a..a9a38804c06 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Image API requests. */ -import { imageFactory } from '@src/factories'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { getFilters } from 'support/util/request'; -import type { Image, ImageStatus } from '@linode/api-v4'; +import type { Image } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; /** * Intercepts POST request to create a machine image and mocks the response. @@ -92,20 +92,15 @@ export const mockGetRecoveryImages = ( * @returns Cypress chainable. */ export const mockGetImage = ( - label: string, - id: string, - status: ImageStatus + imageId: string, + image: Image ): Cypress.Chainable => { - const encodedId = encodeURIComponent(id); - return cy.intercept('GET', apiMatcher(`images/${encodedId}*`), (req) => { - return req.reply( - imageFactory.build({ - id, - label, - status, - }) - ); - }); + const encodedId = encodeURIComponent(imageId); + return cy.intercept( + 'GET', + apiMatcher(`images/${encodedId}*`), + makeResponse(image) + ); }; /** diff --git a/packages/manager/cypress/support/intercepts/placement-groups.ts b/packages/manager/cypress/support/intercepts/placement-groups.ts index 73c1f92d6b7..4511a188885 100644 --- a/packages/manager/cypress/support/intercepts/placement-groups.ts +++ b/packages/manager/cypress/support/intercepts/placement-groups.ts @@ -1,6 +1,5 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; - import type { PlacementGroup } from '@linode/api-v4'; import { makeResponse } from 'support/util/response'; import { makeErrorResponse } from 'support/util/errors'; @@ -154,3 +153,25 @@ export const mockUnassignPlacementGroupLinodesError = ( makeErrorResponse(errorMessage, errorCode) ); }; + +/** + * Intercepts POST request to delete a Placement Group and mocks an HTTP error response. + * + * By default, a 500 response is mocked. + * + * @param errorMessage - Optional error message with which to mock response. + * @param errorCode - Optional error code with which to mock response. Default is `500`. + * + * @returns Cypress chainable. + */ +export const mockDeletePlacementGroupError = ( + placementGroupId: number, + errorMessage: string = 'An error has occurred', + errorCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`placement/groups/${placementGroupId}`), + makeErrorResponse(errorMessage, errorCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 3ed9a751483..f9053602749 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -13,6 +13,7 @@ import type { Profile, SecurityQuestionsData, SecurityQuestionsPayload, + SSHKey, Token, UserPreferences, } from '@linode/api-v4'; @@ -388,3 +389,48 @@ export const mockResetOAuthApps = ( oauthApp ); }; + +/** + * Intercepts GET request to fetch SSH keys and mocks the response. + * + * @param sshKeys - Array of SSH key objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKeys = (sshKeys: SSHKey[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('/profile/sshkeys*'), + paginateResponse(sshKeys) + ); +}; + +/** + * Intercepts GET request to fetch an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/profile/sshkeys/${sshKey.id}`), + makeResponse(sshKey) + ); +}; + +/** + * Intercepts POST request to create an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('/profile/sshkeys'), + makeResponse(sshKey) + ); +}; diff --git a/packages/manager/cypress/support/ui/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 523de4ed3fa..31e8f25cd32 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -1,3 +1,23 @@ +/** + * UI helpers for accordion panel headings. + */ +export const accordionHeading = { + /** + * Finds an accordion with the given title. + * + * @param title - Title of the accordion header to find. + * + * @returns Cypress chainable. + */ + findByTitle: (title: string) => { + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy.findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }); + }, +}; + /** * UI helpers for accordion panels. */ @@ -19,6 +39,13 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy + .findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }) + .closest('[data-qa-panel]') + .find('[data-qa-panel-details]'); }, }; diff --git a/packages/manager/cypress/support/ui/pages/index.ts b/packages/manager/cypress/support/ui/pages/index.ts new file mode 100644 index 00000000000..f08f42cd5a9 --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/index.ts @@ -0,0 +1,15 @@ +/** + * @file Index file for Cypress page utility re-exports. + * + * Page utilities are basic JavaScript objects containing functions to perform + * common page-specific interactions. They allow us to minimize code duplication + * across tests that interact with similar pages. + * + * Page utilities are NOT page objects in the traditional UI testing sense. + * Specifically, page utility objects should NOT have state, and page utilities + * should only be concerned with interacting with or asserting the state of + * the DOM. + */ + +export * from './linode-create-page'; +export * from './vpc-create-drawer'; diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts new file mode 100644 index 00000000000..1f24a0899ca --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -0,0 +1,94 @@ +/** + * @file Page utilities for Linode Create page (v2 implementation). + */ + +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the Linode create page. + */ +export const linodeCreatePage = { + /** + * Sets the Linode's label. + * + * @param linodeLabel - Linode label to set. + */ + setLabel: (linodeLabel: string) => { + cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); + }, + + /** + * Sets the Linode's root password. + * + * @param linodePassword - Root password to set. + */ + setRootPassword: (linodePassword: string) => { + cy.findByLabelText('Root Password').as('rootPasswordField').click(); + + cy.get('@rootPasswordField').type(linodePassword, { log: false }); + }, + + /** + * Selects the Image with the given name. + * + * @param imageName - Name of Image to select. + */ + selectImage: (imageName: string) => { + cy.findByText('Choose a Distribution') + .closest('[data-qa-paper]') + .within(() => { + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle(imageName) + .should('be.visible') + .click(); + }); + }, + + /** + * Select the Region with the given ID. + * + * @param regionId - ID of Region to select. + */ + selectRegionById: (regionId: string) => { + ui.regionSelect.find().click().type(`${regionId}{enter}`); + }, + + /** + * Select the given Linode plan. + * + * Assumes that plans are displayed in a table. + * + * @param planTabTitle - Title of tab where desired plan is located. + * @param planTitle - Title of desired plan. + */ + selectPlan: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.get(`[data-qa-plan-row="${planTitle}"]`) + .closest('tr') + .should('be.visible') + .click(); + }); + }, + + /** + * Select the given Linode plan selection card. + * + * Useful for testing Linode create page against mobile viewports. + * + * Assumes that plans are displayed as selection cards. + */ + selectPlanCard: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.findByText(planTitle) + .should('be.visible') + .as('selectionCard') + .scrollIntoView(); + + cy.get('@selectionCard').click(); + }); + }, +}; diff --git a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts new file mode 100644 index 00000000000..20c5635dc4b --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the VPC create drawer. + * + * Assumes that selection context is limited to only the drawer. + */ +export const vpcCreateDrawer = { + /** + * Sets the VPC create form's label field. + * + * @param vpcLabel - VPC label to set. + */ + setLabel: (vpcLabel: string) => { + cy.findByLabelText('VPC Label') + .should('be.visible') + .type(`{selectall}{del}${vpcLabel}`); + }, + + /** + * Sets the VPC create form's description field. + * + * @param vpcDescription - VPC description to set. + */ + setDescription: (vpcDescription: string) => { + cy.findByLabelText('Description', { exact: false }) + .should('be.visible') + .type(`{selectall}{del}${vpcDescription}`); + }, + + /** + * Sets the VPC create form's subnet label. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetLabel - Label to set. + * @param subnetIndex - Optional index of subnet for which to update label. + */ + setSubnetLabel: (subnetLabel: string, subnetIndex: number = 0) => { + cy.findByText('Subnet Label', { + selector: `label[for="subnet-label-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetLabel}`); + }, + + /** + * Sets the VPC create form's subnet IP address. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetIpRange - IP range to set. + * @param subnetIndex - Optional index of subnet for which to update IP range. + */ + setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => { + cy.findByText('Subnet IP Address Range', { + selector: `label[for="subnet-ipv4-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetIpRange}`); + }, + + /** + * Submits the VPC create form. + */ + submit: () => { + ui.buttonGroup + .findButtonByTitle('Create VPC') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }, +}; diff --git a/packages/manager/public/assets/white/lamp_flame.svg b/packages/manager/public/assets/white/lamp.svg similarity index 100% rename from packages/manager/public/assets/white/lamp_flame.svg rename to packages/manager/public/assets/white/lamp.svg diff --git a/packages/manager/src/assets/icons/divider-vertical.svg b/packages/manager/src/assets/icons/divider-vertical.svg new file mode 100644 index 00000000000..79add159022 --- /dev/null +++ b/packages/manager/src/assets/icons/divider-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/lock.svg b/packages/manager/src/assets/icons/lock.svg new file mode 100644 index 00000000000..ca135909b4f --- /dev/null +++ b/packages/manager/src/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/unlock.svg b/packages/manager/src/assets/icons/unlock.svg new file mode 100644 index 00000000000..ce413046282 --- /dev/null +++ b/packages/manager/src/assets/icons/unlock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index 94f444657a0..d4c0613adbd 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -2,8 +2,17 @@ import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { + DISK_ENCRYPTION_GENERAL_DESCRIPTION, + DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, +} from 'src/components/DiskEncryption/constants'; +import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Paper } from 'src/components/Paper'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { Divider } from '../Divider'; import UserSSHKeyPanel from './UserSSHKeyPanel'; @@ -31,6 +40,8 @@ interface Props { className?: string; disabled?: boolean; disabledReason?: JSX.Element | string; + diskEncryptionEnabled?: boolean; + displayDiskEncryption?: boolean; error?: string; handleChange: (value: string) => void; heading?: string; @@ -41,9 +52,10 @@ interface Props { passwordHelperText?: string; placeholder?: string; required?: boolean; + selectedRegion?: string; setAuthorizedUsers?: (usernames: string[]) => void; small?: boolean; - tooltipInteractive?: boolean; + toggleDiskEncryptionEnabled?: () => void; } export const AccessPanel = (props: Props) => { @@ -52,6 +64,8 @@ export const AccessPanel = (props: Props) => { className, disabled, disabledReason, + diskEncryptionEnabled, + displayDiskEncryption, error, handleChange: _handleChange, hideStrengthLabel, @@ -61,15 +75,51 @@ export const AccessPanel = (props: Props) => { passwordHelperText, placeholder, required, + selectedRegion, setAuthorizedUsers, - tooltipInteractive, + toggleDiskEncryptionEnabled, } = props; const { classes, cx } = useStyles(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const regions = useRegionsQuery().data ?? []; + + const regionSupportsDiskEncryption = doesRegionSupportFeature( + selectedRegion ?? '', + regions, + 'Disk Encryption' + ); + const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); + /** + * Display the "Disk Encryption" section if: + * 1) the feature is enabled + * 2) "displayDiskEncryption" is explicitly passed -- + * gets used in several places, but we don't want to display Disk Encryption in all + * 3) toggleDiskEncryptionEnabled is defined + */ + const diskEncryptionJSX = + isDiskEncryptionFeatureEnabled && + displayDiskEncryption && + toggleDiskEncryptionEnabled !== undefined ? ( + <> + + + + ) : null; + return ( { className )} > + {isDiskEncryptionFeatureEnabled && ( + ({ paddingBottom: theme.spacing(2) })} + variant="h2" + > + Security + + )} }> { onChange={handleChange} placeholder={placeholder || 'Enter a password.'} required={required} - tooltipInteractive={tooltipInteractive} value={password || ''} /> @@ -110,6 +167,7 @@ export const AccessPanel = (props: Props) => { /> ) : null} + {diskEncryptionJSX} ); }; diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index db476a48411..7b6123890b3 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -114,7 +114,6 @@ const BackupStatus = (props: Props) => { padding: 0, }} classes={{ tooltip: classes.tooltip }} - interactive status="help" text={backupsUnavailableMessage} /> diff --git a/packages/manager/src/components/Checkbox.tsx b/packages/manager/src/components/Checkbox.tsx index 5161f1536f9..fd2db5a9b8c 100644 --- a/packages/manager/src/components/Checkbox.tsx +++ b/packages/manager/src/components/Checkbox.tsx @@ -5,8 +5,8 @@ import * as React from 'react'; import CheckboxIcon from 'src/assets/icons/checkbox.svg'; import CheckboxCheckedIcon from 'src/assets/icons/checkboxChecked.svg'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { TooltipIcon } from 'src/components/TooltipIcon'; interface Props extends CheckboxProps { /** @@ -17,11 +17,6 @@ interface Props extends CheckboxProps { * Renders a `FormControlLabel` that controls the underlying Checkbox with a label of `text` */ text?: JSX.Element | string; - /** - * Whether or not the tooltip is interactive - * @default false - */ - toolTipInteractive?: boolean; /** * Renders a tooltip to the right of the Checkbox */ @@ -44,7 +39,7 @@ interface Props extends CheckboxProps { * - If the user clicks the Back button, any changes made to checkboxes should be discarded and the original settings reinstated. */ export const Checkbox = (props: Props) => { - const { sxFormLabel, text, toolTipInteractive, toolTipText, ...rest } = props; + const { sxFormLabel, text, toolTipText, ...rest } = props; const BaseCheckbox = ( { return ( <> {CheckboxComponent} - {toolTipText ? ( - - ) : null} + {toolTipText ? : null} ); }; diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index e266cbb1e9c..0fe1a57cbae 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -27,7 +27,9 @@ export const CollapsibleTable = (props: Props) => { return ( - {TableRowHead} + + {TableRowHead} + {TableItems.length === 0 && TableRowEmpty} {TableItems.map((item) => { diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx index 7d99a8b9e46..46dbcb2fbb4 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx @@ -12,7 +12,11 @@ import { describe('DiskEncryption', () => { it('should render a header', () => { const { getByTestId } = renderWithTheme( - + ); const heading = getByTestId(headerTestId); @@ -23,7 +27,11 @@ describe('DiskEncryption', () => { it('should render a description', () => { const { getByTestId } = renderWithTheme( - + ); const description = getByTestId(descriptionTestId); @@ -33,7 +41,11 @@ describe('DiskEncryption', () => { it('should render a checkbox', () => { const { getByTestId } = renderWithTheme( - + ); const checkbox = getByTestId(checkboxTestId); diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx index 3847cdd223d..e7cf9222262 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx @@ -8,8 +8,8 @@ export interface DiskEncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; disabledReason?: string; - // encryptionStatus - // toggleEncryption + isEncryptDiskChecked: boolean; + toggleDiskEncryptionEnabled: () => void; } export const headerTestId = 'disk-encryption-header'; @@ -17,9 +17,13 @@ export const descriptionTestId = 'disk-encryption-description'; export const checkboxTestId = 'encrypt-disk-checkbox'; export const DiskEncryption = (props: DiskEncryptionProps) => { - const { descriptionCopy, disabled, disabledReason } = props; - - const [checked, setChecked] = React.useState(false); // @TODO LDE: temporary placeholder until toggleEncryption logic is in place + const { + descriptionCopy, + disabled, + disabledReason, + isEncryptDiskChecked, + toggleDiskEncryptionEnabled, + } = props; return ( <> @@ -41,10 +45,10 @@ export const DiskEncryption = (props: DiskEncryptionProps) => { flexDirection="row" > setChecked(!checked)} // @TODO LDE: toggleEncryption will be used here + onChange={toggleDiskEncryptionEnabled} text="Encrypt Disk" toolTipText={disabled ? disabledReason : ''} /> diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index e38849fe918..5d0ffe10ec8 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -16,3 +16,9 @@ export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = 'Disk encryption is not available in the selected region.'; + +export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = + 'Virtual Machine Backups are not encrypted.'; + +export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = + 'To enable disk encryption, delete the node pool and create a new node pool. New node pools are always encrypted.'; diff --git a/packages/manager/src/components/DiskEncryption/utils.ts b/packages/manager/src/components/DiskEncryption/utils.ts new file mode 100644 index 00000000000..8beaab70d68 --- /dev/null +++ b/packages/manager/src/components/DiskEncryption/utils.ts @@ -0,0 +1,31 @@ +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; + +/** + * Hook to determine if the Disk Encryption feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns { boolean } - Whether the Disk Encryption feature is enabled for the current user. + */ +export const useIsDiskEncryptionFeatureEnabled = (): { + isDiskEncryptionFeatureEnabled: boolean; +} => { + const { data: account, error } = useAccount(); + const flags = useFlags(); + + if (error || !flags) { + return { isDiskEncryptionFeatureEnabled: false }; + } + + const hasAccountCapability = account?.capabilities?.includes( + 'Disk Encryption' + ); + + const isFeatureFlagEnabled = flags.linodeDiskEncryption; + + const isDiskEncryptionFeatureEnabled = Boolean( + hasAccountCapability && isFeatureFlagEnabled + ); + + return { isDiskEncryptionFeatureEnabled }; +}; diff --git a/packages/manager/src/components/Paper.tsx b/packages/manager/src/components/Paper.tsx index c12f8ce9530..61bc8f129c3 100644 --- a/packages/manager/src/components/Paper.tsx +++ b/packages/manager/src/components/Paper.tsx @@ -30,6 +30,7 @@ export const Paper = (props: Props) => { {props.error && {props.error}} diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index 7f76d06bf24..a8e092169f2 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -19,7 +19,6 @@ const PasswordInput = (props: Props) => { hideStrengthLabel, hideValidation, required, - tooltipInteractive, value, ...rest } = props; @@ -33,7 +32,6 @@ const PasswordInput = (props: Props) => { {...rest} fullWidth required={required} - tooltipInteractive={tooltipInteractive} tooltipText={disabledReason} value={value} /> diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 71a8451ab38..d1603412ef5 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -112,12 +112,6 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { onChange={(_, selectedOption: RegionSelectOption) => { handleRegionChange(selectedOption); }} - onKeyDown={(e) => { - if (e.key !== 'Tab') { - setSelectedRegion(null); - handleRegionChange(null); - } - }} renderOption={(props, option) => { return ( @@ -174,7 +180,7 @@ export const LinodeSelectTable = (props: Props) => { handleSelection={() => handleSelect(linode)} key={linode.id} linode={linode} - selected={linode.id === selectedLinode?.id} + selected={linode.id === field.value?.id} /> ))} {data?.results === 0 && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 8824590eaf7..b1c76485e89 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -333,7 +333,7 @@ const defaultValuesForStackScripts = { defaultPublicInterface, ], region: '', - stackscript_id: null, + stackscript_id: undefined, type: '', }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx index 1f2c13bc4e9..cf1e73cbeed 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx @@ -31,6 +31,7 @@ const props: AddonsPanelProps = { changeBackups: vi.fn(), createType: 'fromLinode', disabled: false, + diskEncryptionEnabled: false, handleVLANChange: vi.fn(), ipamAddress: 'ipadAddress', ipamError: 'test ipad error', diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx index a90893d4389..d4811b676e8 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx @@ -1,3 +1,4 @@ +import { useMediaQuery } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -5,6 +6,8 @@ import { Link } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { Checkbox } from 'src/components/Checkbox'; import { Currency } from 'src/components/Currency'; +import { DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Divider } from 'src/components/Divider'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Notice } from 'src/components/Notice/Notice'; @@ -32,6 +35,7 @@ export interface AddonsPanelProps { changeBackups: () => void; createType: CreateTypes; disabled?: boolean; + diskEncryptionEnabled: boolean; handleVLANChange: (updatedInterface: Interface) => void; ipamAddress: string; ipamError?: string; @@ -55,6 +59,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { changeBackups, createType, disabled, + diskEncryptionEnabled, handleVLANChange, ipamAddress, ipamError, @@ -73,6 +78,12 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { const theme = useTheme(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const matchesMdUp = useMediaQuery(theme.breakpoints.up('md')); + const { data: image } = useImageQuery( selectedImageID ?? '', Boolean(selectedImageID) @@ -142,6 +153,9 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { } }, [selectedLinodeID]); + const isBackupsBoxChecked = + (accountBackups && !isEdgeRegionSelected) || props.backups; + return ( <> {showVlans && ( @@ -185,9 +199,6 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { { isBareMetal || isEdgeRegionSelected } + checked={isBackupsBoxChecked} data-testid="backups" onChange={changeBackups} /> @@ -208,6 +220,18 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { } /> + {isDiskEncryptionFeatureEnabled && + diskEncryptionEnabled && + isBackupsBoxChecked && ( + + )} {accountBackups && !isEdgeRegionSelected ? ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 6a7c33dee76..807f570a377 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -1,11 +1,13 @@ import { PlacementGroup } from '@linode/api-v4'; import { CreateLinodePlacementGroupPayload, + EncryptionStatus, InterfacePayload, PriceObject, restoreBackup, } from '@linode/api-v4/lib/linodes'; import { Tag } from '@linode/api-v4/lib/tags/types'; +import { CreateLinodeSchema } from '@linode/validation/lib/linodes.schema'; import Grid from '@mui/material/Unstable_Grid2'; import cloneDeep from 'lodash.clonedeep'; import * as React from 'react'; @@ -76,6 +78,7 @@ import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { SelectFirewallPanel } from '../../../components/SelectFirewallPanel/SelectFirewallPanel'; import { AddonsPanel } from './AddonsPanel'; @@ -117,6 +120,7 @@ export interface LinodeCreateProps { autoassignIPv4WithinVPC: boolean; checkValidation: LinodeCreateValidation; createType: CreateTypes; + diskEncryptionEnabled: boolean; firewallId?: number; handleAgreementChange: () => void; handleFirewallChange: (firewallId: number) => void; @@ -146,6 +150,7 @@ export interface LinodeCreateProps { toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; toggleBackupsEnabled: () => void; + toggleDiskEncryptionEnabled: () => void; togglePrivateIPEnabled: () => void; typeDisplayInfo: TypeInfo; updateDiskSize: (size: number) => void; @@ -194,6 +199,7 @@ type CombinedProps = AllFormStateAndHandlers & WithTypesProps; interface State { + hasError: boolean; numberOfNodes: number; planKey: string; selectedTab: number; @@ -238,6 +244,7 @@ export class LinodeCreate extends React.PureComponent< } this.state = { + hasError: false, numberOfNodes: 0, planKey: v4(), selectedTab: preSelectedTab !== -1 ? preSelectedTab : 0, @@ -281,6 +288,7 @@ export class LinodeCreate extends React.PureComponent< const { selectedTab, stackScriptSelectedTab } = this.state; const { + account, accountBackupsEnabled, errors, flags, @@ -345,6 +353,13 @@ export class LinodeCreate extends React.PureComponent< this.props.backupsEnabled || accountBackupsEnabled ); + const hasDiskEncryptionAccountCapability = account.data?.capabilities?.includes( + 'Disk Encryption' + ); + + const isDiskEncryptionFeatureEnabled = + flags.linodeDiskEncryption && hasDiskEncryptionAccountCapability; + const displaySections = []; if (imageDisplayInfo) { displaySections.push(imageDisplayInfo); @@ -362,6 +377,12 @@ export class LinodeCreate extends React.PureComponent< selectedRegionID ?? '' ); + const regionSupportsDiskEncryption = doesRegionSupportFeature( + this.props.selectedRegionID ?? '', + this.props.regionsData, + 'Disk Encryption' + ); + if (typeDisplayInfo) { const typeDisplayInfoCopy = cloneDeep(typeDisplayInfo); @@ -419,6 +440,16 @@ export class LinodeCreate extends React.PureComponent< ); } + if ( + isDiskEncryptionFeatureEnabled && + regionSupportsDiskEncryption && + this.props.diskEncryptionEnabled + ) { + displaySections.push({ + title: 'Encrypted', + }); + } + if (this.props.vlanLabel) { displaySections.push({ title: 'VLAN Attached', @@ -487,7 +518,7 @@ export class LinodeCreate extends React.PureComponent< ); return ( - + {hasErrorFor.none && !!showGeneralError && ( @@ -694,12 +725,18 @@ export class LinodeCreate extends React.PureComponent< ? 'You must select an image to set a root password' : '' } + toggleDiskEncryptionEnabled={ + this.props.toggleDiskEncryptionEnabled + } authorizedUsers={this.props.authorized_users} data-qa-access-panel disabled={!this.props.selectedImageID || userCannotCreateLinode} + diskEncryptionEnabled={this.props.diskEncryptionEnabled} + displayDiskEncryption error={hasErrorFor.root_pass} handleChange={this.props.updatePassword} password={this.props.password} + selectedRegion={this.props.selectedRegionID} setAuthorizedUsers={this.props.setAuthorizedUsers} /> )} @@ -758,6 +795,9 @@ export class LinodeCreate extends React.PureComponent< /> )} { + scrollErrorIntoViewV2(this.createLinodeFormRef); + }); + } this.props.handleSubmitForm(payload, this.props.selectedLinodeID); sendLinodeCreateFormSubmitEvent( 'Create Linode', @@ -863,6 +913,8 @@ export class LinodeCreate extends React.PureComponent< ); }; + createLinodeFormRef = React.createRef(); + filterTypes = () => { const { createType, typesData } = this.props; const { selectedTab } = this.state; @@ -888,6 +940,25 @@ export class LinodeCreate extends React.PureComponent< 'VPCs' ); + const regionSupportsDiskEncryption = doesRegionSupportFeature( + this.props.selectedRegionID ?? '', + this.props.regionsData, + 'Disk Encryption' + ); + + const hasDiskEncryptionAccountCapability = this.props.account.data?.capabilities?.includes( + 'Disk Encryption' + ); + + const isDiskEncryptionFeatureEnabled = + this.props.flags.linodeDiskEncryption && + hasDiskEncryptionAccountCapability; + + const diskEncryptionPayload: EncryptionStatus = this.props + .diskEncryptionEnabled + ? 'enabled' + : 'disabled'; + const placement_group_payload: CreateLinodePlacementGroupPayload = { id: this.props.placementGroupSelection?.id ?? -1, }; @@ -900,6 +971,10 @@ export class LinodeCreate extends React.PureComponent< backup_id: this.props.selectedBackupID, backups_enabled: this.props.backupsEnabled, booted: true, + disk_encryption: + isDiskEncryptionFeatureEnabled && regionSupportsDiskEncryption + ? diskEncryptionPayload + : undefined, firewall_id: this.props.firewallId !== -1 ? this.props.firewallId : undefined, image: this.props.selectedImageID, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index b83d8c280be..46fb787da6a 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -72,7 +72,6 @@ import { ExtendedIP } from 'src/utilities/ipUtils'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { validatePassword } from 'src/utilities/validatePassword'; import { deriveDefaultLabel } from './deriveDefaultLabel'; @@ -107,6 +106,7 @@ interface State { backupsEnabled: boolean; customLabel?: string; disabledClasses?: LinodeTypeClass[]; + diskEncryptionEnabled?: boolean; errors?: APIError[]; formIsSubmitting: boolean; password: string; @@ -157,6 +157,7 @@ const defaultState: State = { backupsEnabled: false, customLabel: undefined, disabledClasses: [], + diskEncryptionEnabled: true, errors: undefined, formIsSubmitting: false, password: '', @@ -293,6 +294,7 @@ class LinodeCreateContainer extends React.PureComponent { } autoassignIPv4WithinVPC={this.state.autoassignIPv4WithinVPCEnabled} checkValidation={this.checkValidation} + diskEncryptionEnabled={this.state.diskEncryptionEnabled ?? false} firewallId={this.state.selectedfirewallId} handleAgreementChange={this.handleAgreementChange} handleFirewallChange={this.handleFirewallChange} @@ -319,6 +321,7 @@ class LinodeCreateContainer extends React.PureComponent { setSelectedVPC={this.handleVPCChange} toggleAssignPublicIPv4Address={this.toggleAssignPublicIPv4Address} toggleBackupsEnabled={this.toggleBackupsEnabled} + toggleDiskEncryptionEnabled={this.toggleDiskEncryptionEnabled} togglePrivateIPEnabled={this.togglePrivateIPEnabled} typeDisplayInfo={this.getTypeInfo()} typesData={extendedTypeData} @@ -350,13 +353,10 @@ class LinodeCreateContainer extends React.PureComponent { this.setState({ errors: undefined, showApiAwarenessModal: true }); } catch (error) { const processedErrors = convertYupToLinodeErrors(error); - this.setState( - () => ({ - errors: getAPIErrorOrDefault(processedErrors), - formIsSubmitting: false, - }), - () => scrollErrorIntoView() - ); + this.setState(() => ({ + errors: getAPIErrorOrDefault(processedErrors), + formIsSubmitting: false, + })); } }; @@ -738,19 +738,14 @@ class LinodeCreateContainer extends React.PureComponent { if (payload.root_pass) { const passwordError = validatePassword(payload.root_pass); if (passwordError) { - this.setState( - { - errors: [ - { - field: 'root_pass', - reason: passwordError, - }, - ], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [ + { + field: 'root_pass', + reason: passwordError, + }, + ], + }); return; } } @@ -763,24 +758,19 @@ class LinodeCreateContainer extends React.PureComponent { )!, }); if (error) { - this.setState( - { - errors: [ - { - field: 'placement_group', - reason: `${this.state.placementGroupSelection?.label} (${ - this.state.placementGroupSelection?.affinity_type === - 'affinity:local' - ? 'Affinity' - : 'Anti-affinity' - }) doesn't have any capacity for this Linode.`, - }, - ], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [ + { + field: 'placement_group', + reason: `${this.state.placementGroupSelection?.label} (${ + this.state.placementGroupSelection?.affinity_type === + 'affinity:local' + ? 'Affinity' + : 'Anti-affinity' + }) doesn't have any capacity for this Linode.`, + }, + ], + }); return; } } @@ -799,17 +789,14 @@ class LinodeCreateContainer extends React.PureComponent { // Situation: 'Auto-assign a VPC IPv4 address for this Linode in the VPC' checkbox // unchecked but a valid VPC IPv4 not provided if (!this.state.autoassignIPv4WithinVPCEnabled && !validVPCIPv4) { - return this.setState( - () => ({ - errors: [ - { - field: 'ipv4.vpc', - reason: 'Must be a valid IPv4 address, e.g. 192.168.2.0', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'ipv4.vpc', + reason: 'Must be a valid IPv4 address, e.g. 192.168.2.0', + }, + ], + })); } } @@ -819,58 +806,44 @@ class LinodeCreateContainer extends React.PureComponent { * if create, run create action */ if (createType === 'fromLinode' && !linodeID) { - return this.setState( - () => ({ - errors: [ - { - field: 'linode_id', - reason: 'You must select a Linode to clone from', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'linode_id', + reason: 'You must select a Linode to clone from', + }, + ], + })); } if (createType === 'fromBackup' && !this.state.selectedBackupID) { /* a backup selection is also required */ - this.setState( - { - errors: [{ field: 'backup_id', reason: 'You must select a Backup.' }], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [{ field: 'backup_id', reason: 'You must select a Backup.' }], + }); return; } if (createType === 'fromStackScript' && !this.state.selectedStackScriptID) { - return this.setState( - () => ({ - errors: [ - { - field: 'stackscript_id', - reason: 'You must select a StackScript.', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'stackscript_id', + reason: 'You must select a StackScript.', + }, + ], + })); } if (createType === 'fromApp' && !this.state.selectedStackScriptID) { - return this.setState( - () => ({ - errors: [ - { - field: 'stackscript_id', - reason: 'You must select a Marketplace App.', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'stackscript_id', + reason: 'You must select a Marketplace App.', + }, + ], + })); } const request = @@ -938,13 +911,10 @@ class LinodeCreateContainer extends React.PureComponent { this.props.history.push(`/linodes/${response.id}`); }) .catch((error) => { - this.setState( - () => ({ - errors: getAPIErrorOrDefault(error), - formIsSubmitting: false, - }), - () => scrollErrorIntoView() - ); + this.setState(() => ({ + errors: getAPIErrorOrDefault(error), + formIsSubmitting: false, + })); }); }; @@ -972,6 +942,10 @@ class LinodeCreateContainer extends React.PureComponent { toggleBackupsEnabled = () => this.setState({ backupsEnabled: !this.state.backupsEnabled }); + toggleDiskEncryptionEnabled = () => { + this.setState({ diskEncryptionEnabled: !this.state.diskEncryptionEnabled }); + }; + togglePrivateIPEnabled = () => this.setState({ privateIPEnabled: !this.state.privateIPEnabled }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx index eda08d52851..24ae144710c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx @@ -38,7 +38,6 @@ export const UserDataAccordionHeading = (props: Props) => { } - interactive status="help" sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 800f43a202e..b72943e99c6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -62,7 +62,7 @@ import { } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; import { ExtendedIP } from 'src/utilities/ipUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { ExtendedInterface, @@ -245,6 +245,7 @@ const deviceCounterDefault = 1; const finnixDiskID = 25669; export const LinodeConfigDialog = (props: Props) => { + const formContainerRef = React.useRef(null); const { config, isReadOnly, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); @@ -304,7 +305,10 @@ export const LinodeConfigDialog = (props: Props) => { const { resetForm, setFieldValue, values, ...formik } = useFormik({ initialValues: defaultFieldsValues, onSubmit: (values) => onSubmit(values), - validate: (values) => onValidate(values), + validate: (values) => { + onValidate(values); + scrollErrorIntoViewV2(formContainerRef); + }, validateOnChange: false, validateOnMount: false, }); @@ -449,7 +453,6 @@ export const LinodeConfigDialog = (props: Props) => { error, 'An unexpected error occurred.' ); - scrollErrorIntoView('linode-config-dialog'); }; /** Editing */ @@ -687,7 +690,7 @@ export const LinodeConfigDialog = (props: Props) => { open={open} title={`${config ? 'Edit' : 'Add'} Configuration`} > - + {generalError && ( @@ -956,7 +959,6 @@ export const LinodeConfigDialog = (props: Props) => { paddingBottom: 0, paddingTop: 0, }} - interactive status="help" sx={{ tooltip: { maxWidth: 350 } }} text={networkInterfacesHelperText} @@ -1123,7 +1125,6 @@ export const LinodeConfigDialog = (props: Props) => { } checked={values.helpers.network} disabled={isReadOnly} - interactive={true} onChange={formik.handleChange} /> } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx index 301a71df7ea..ea130e2c723 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx @@ -37,11 +37,7 @@ export const LinodeFirewallsRow = (props: LinodeFirewallsRowProps) => { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx index a5ccf0ccbf3..a1cbda31ec4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx @@ -81,7 +81,6 @@ export const UnifiedMigrationPanel = (props: Props) => { )} } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 375 : 300} @@ -111,7 +110,6 @@ export const UnifiedMigrationPanel = (props: Props) => { } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 450 : 300} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 42dbda3b996..c1de987b624 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -93,7 +93,6 @@ export const LinodeRow = (props: Props) => { return ( {
Maintenance Scheduled } diff --git a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx b/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx index fce59111dd2..eafaa1455f2 100644 --- a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx +++ b/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx @@ -23,7 +23,6 @@ export const PublicIpsUnassignedTooltip = ( . } - interactive status="help" sxTooltipIcon={sxTooltipIcon} /> diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx index 18e98ca1739..e592083ae7a 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx @@ -25,10 +25,7 @@ export const LoadBalancerRow = ({ handlers, loadBalancer }: Props) => { const { hostname, id, label, regions } = loadBalancer; return ( - + {label} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx index f439417fab5..b4e99046a7e 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx @@ -12,7 +12,7 @@ export const ConnectionRow = (props: Props) => { const { connection } = props; return ( - + {connection.name} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx index c1c2533894d..001531b60f3 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx @@ -12,7 +12,7 @@ export const LongviewServiceRow = (props: Props) => { const { service } = props; return ( - + {service.name} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx index 9848ed51c23..4089557afda 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx @@ -13,8 +13,8 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { formatCPU } from 'src/features/Longview/shared/formatters'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { readableBytes } from 'src/utilities/unitConversions'; -import { StyledDiv, StyledTable } from './ProcessesTable.styles'; +import { StyledDiv, StyledTable } from './ProcessesTable.styles'; import { Process } from './types'; export interface ProcessesTableProps { @@ -176,7 +176,6 @@ export const ProcessesTableRow = React.memo((props: ProcessTableRowProps) => { onKeyUp={(e: any) => e.key === 'Enter' && setSelectedProcess({ name, user }) } - ariaLabel={`${name} for ${user}`} data-testid="longview-service-row" forceIndex onClick={() => setSelectedProcess({ name, user })} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index 8ddf038b9cf..d34a2585c31 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -151,7 +151,7 @@ export const TopProcessRow = React.memo((props: TopProcessRowProps) => { const memInBytes = mem * 1024; return ( - + {name} diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx index cb458570101..6a1d777ae9b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx @@ -24,6 +24,7 @@ import { UseAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + import { StyledChip, StyledClientCell, @@ -228,15 +229,15 @@ export const LongviewPlans = (props: LongviewPlansProps) => { @@ -355,25 +356,24 @@ export const LongviewSubscriptionRow = React.memo( return ( {plan} diff --git a/packages/manager/src/features/Longview/LongviewPackageRow.tsx b/packages/manager/src/features/Longview/LongviewPackageRow.tsx index 97923ff4f06..15d96efb7ee 100644 --- a/packages/manager/src/features/Longview/LongviewPackageRow.tsx +++ b/packages/manager/src/features/Longview/LongviewPackageRow.tsx @@ -16,7 +16,7 @@ export const LongviewPackageRow = (props: Props) => { const theme = useTheme(); return ( - + {lvPackage.name}
{lvPackage.current}
diff --git a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx index 78ecc931b69..a92b68ffe5e 100644 --- a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx +++ b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx @@ -17,7 +17,7 @@ export const ContactsRow = (props: ContactsRowProps) => { const { contact, openDialog, openDrawer } = props; return ( - + {contact.name} {contact.group} diff --git a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx index 06ebbcef2c8..87bd3ebf6a9 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx @@ -19,7 +19,6 @@ export const CredentialRow = (props: CredentialRowProps) => { return ( { return ( { return ( { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index e67572e3fa4..e9a20f8274a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -30,7 +30,7 @@ export const NodeBalancerTableRow = (props: Props) => { 0; return ( - + {label} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx index e3b3dcafeff..f37af952753 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -21,7 +21,7 @@ export const FolderTableRow = (props: Props) => { const { displayName, folderName, handleClickDelete } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx index ea11d2f4370..96d32373fd1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx @@ -36,7 +36,7 @@ export const ObjectTableRow = (props: Props) => { } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index a8cf7a76563..48a52341fd7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -63,7 +63,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const regionsLookup = regions && getRegionsByRegionId(regions); return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 3abd6957b22..f470c3d3d0f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -19,6 +19,7 @@ import { useCreateBucketMutation, useObjectStorageBuckets, useObjectStorageClusters, + useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -26,6 +27,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import ClusterSelect from './ClusterSelect'; @@ -74,6 +76,14 @@ export const CreateBucketDrawer = (props: Props) => { : undefined, }); + const { + data: types, + isError: isErrorTypes, + isLoading: isLoadingTypes, + } = useObjectStorageTypesQuery(isOpen); + + const isInvalidPrice = !types || isErrorTypes; + const { error, isLoading, @@ -199,9 +209,15 @@ export const CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: !formik.values.cluster || - (showGDPRCheckbox && !hasSignedAgreement), + (showGDPRCheckbox && !hasSignedAgreement) || + isErrorTypes, label: 'Create Bucket', - loading: isLoading, + loading: + isLoading || Boolean(clusterRegion?.[0]?.id && isLoadingTypes), + tooltipText: + !isLoadingTypes && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx index efc2669ea5a..43330dcaa49 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -1,7 +1,14 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { + OBJ_STORAGE_PRICE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -11,12 +18,37 @@ import { OveragePricing, } from './OveragePricing'; -describe('OveragePricing', () => { +const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), +]; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + +describe('OveragePricing', async () => { + beforeAll(() => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: mockObjectStorageTypes, + }); + }); + it('Renders base overage pricing for a region without price increases', () => { const { getByText } = renderWithTheme( ); - getByText(`$${OBJ_STORAGE_PRICE.storage_overage} per GB`, { exact: false }); + getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, { + exact: false, + }); getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, { exact: false, }); @@ -24,10 +56,9 @@ describe('OveragePricing', () => { it('Renders DC-specific overage pricing for a region with price increases', () => { const { getByText } = renderWithTheme(); - getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].storage_overage} per GB`, - { exact: false } - ); + getByText(`$${mockObjectStorageTypes[1].region_prices[1].hourly} per GB`, { + exact: false, + }); getByText( `$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`, { exact: false } @@ -59,4 +90,40 @@ describe('OveragePricing', () => { expect(tooltip).toBeInTheDocument(); expect(getByText(GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT)).toBeVisible(); }); + + it('Renders a loading state while prices are loading', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('progressbar')).toBeVisible(); + }); + + it('Renders placeholder unknown pricing when there is an error', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isError: true, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); + + it('Renders placeholder unknown pricing when prices are undefined', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index 25279f3efc9..727c20d5cdc 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -2,10 +2,18 @@ import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import React from 'react'; +import { CircularProgress } from 'src/components/CircularProgress'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { + OBJ_STORAGE_PRICE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { + getDCSpecificPriceByType, + objectStoragePriceIncreaseMap, +} from 'src/utilities/pricing/dynamicPricing'; interface Props { regionId: Region['id']; @@ -18,23 +26,40 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT = export const OveragePricing = (props: Props) => { const { regionId } = props; + + const { data: types, isError, isLoading } = useObjectStorageTypesQuery(); + + const overageType = types?.find( + (type) => type.id === 'objectstorage-overage' + ); + + const storageOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: overageType, + }); + const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty( regionId ); - return ( + return isLoading ? ( + + ) : ( <> For this region, additional storage costs{' '} $ - {isDcSpecificPricingRegion - ? objectStoragePriceIncreaseMap[regionId].storage_overage - : OBJ_STORAGE_PRICE.storage_overage}{' '} + {storageOveragePrice && !isError + ? parseFloat(storageOveragePrice) + : UNKNOWN_PRICE}{' '} per GB . + Outbound transfer will cost{' '} diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx index e36ba887af3..834cd38f4ec 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx @@ -1,7 +1,11 @@ import { fireEvent, render } from '@testing-library/react'; import * as React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import { @@ -23,7 +27,29 @@ const props: EnableObjectStorageProps = { open: true, }; +const queryMocks = vi.hoisted(() => ({ + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + describe('EnableObjectStorageModal', () => { + beforeAll(() => { + const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: mockObjectStorageTypes, + }); + }); + it('includes a header', () => { const { getAllByText } = render( wrapWithTheme() @@ -37,7 +63,7 @@ describe('EnableObjectStorageModal', () => { ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -51,7 +77,7 @@ describe('EnableObjectStorageModal', () => { /> ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -60,11 +86,27 @@ describe('EnableObjectStorageModal', () => { const { getByText } = render( wrapWithTheme() ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); + it('displays placeholder unknown pricing and disables the primary action button if pricing is not available', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + isError: true, + }); + + const { getByTestId, getByText } = render( + wrapWithTheme() + ); + + const primaryActionButton = getByTestId('enable-obj'); + + expect(getByText(`${UNKNOWN_PRICE}/month`, { exact: false })).toBeVisible(); + expect(primaryActionButton).toBeDisabled(); + }); + it('includes a link to linode.com/pricing', () => { const { getByText } = render( wrapWithTheme() diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx index 8e990d54e31..fbc6d26cbd4 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx @@ -7,7 +7,12 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { + PRICES_RELOAD_ERROR_NOTICE_TEXT, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; export const OBJ_STORAGE_STORAGE_AMT = '250 GB'; export const OBJ_STORAGE_NETWORK_TRANSFER_AMT = '1 TB'; @@ -22,17 +27,41 @@ export const EnableObjectStorageModal = React.memo( (props: EnableObjectStorageProps) => { const { handleSubmit, onClose, open, regionId } = props; + const { data: types, isError, isLoading } = useObjectStorageTypesQuery( + Boolean(regionId) + ); + + const isInvalidPrice = Boolean(regionId) && (!types || isError); + + const objectStorageType = types?.find( + (type) => type.id === 'objectstorage' + ); + + const price = regionId + ? getDCSpecificPriceByType({ + decimalPrecision: 0, + regionId, + type: objectStorageType, + }) + : objectStorageType?.price.monthly; + return ( ( { onClose(); handleSubmit(); }, + tooltipText: + !isLoading && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', }} secondaryButtonProps={{ 'data-testid': 'cancel', @@ -51,7 +80,7 @@ export const EnableObjectStorageModal = React.memo( Object Storage costs a flat rate of{' '} - ${OBJ_STORAGE_PRICE.monthly}/month, and includes{' '} + ${price ?? UNKNOWN_PRICE}/month, and includes{' '} {OBJ_STORAGE_STORAGE_AMT} of storage. When you enable Object Storage,{' '} {OBJ_STORAGE_NETWORK_TRANSFER_AMT} of outbound data transfer will be added to your global network transfer pool. diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index f5cb1bea442..64f5906ca52 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -2,6 +2,9 @@ import { oneClickAppFactory } from 'src/factories/stackscripts'; import type { OCA } from './types'; +/** + * @deprecated See oneClickAppsv2.ts + */ export const oneClickApps: OCA[] = [ { alt_description: 'Free open source control panel with a mobile app.', diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts new file mode 100644 index 00000000000..f8106bcd63e --- /dev/null +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -0,0 +1,2459 @@ +import { oneClickAppFactory } from 'src/factories/stackscripts'; + +import type { OCA } from './types'; + +/** + * This object maps a StackScript ID to additional information. + * + * A marketplace app must be listed here with the correct ID + * for it to be visible to users. + */ +export const oneClickApps: Record = { + 0: { + ...oneClickAppFactory.build({ + name: 'E2E Test App', + }), + }, + 401697: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '135478', + start: '176086', + }, + description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`, + logo_url: 'wordpress.svg', + name: 'WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/', + title: 'Deploy WordPress through the Linode Marketplace', + }, + ], + summary: + 'Flexible, open source content management system (CMS) for content-focused websites of any kind.', + website: 'https://wordpress.org/', + }, + 401698: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '1b64a5', + start: '0678be', + }, + description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`, + logo_url: 'drupal.svg', + name: 'Drupal', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/drupal/', + title: 'Deploy Drupal through the Linode Marketplace', + }, + ], + summary: `Powerful content management system built on PHP and supported by a database engine.`, + website: 'https://www.drupal.org/', + }, + 401701: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: 'bfa477', + start: '3c4043', + }, + description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application + frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`, + logo_url: 'lamp.svg', + name: 'LAMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lamp-stack/', + title: 'Deploy a LAMP Stack through the Linode Marketplace', + }, + ], + summary: `Build PHP-based applications with the LAMP software stack: Linux, Apache, MySQL, and PHP.`, + }, + 401702: { + alt_description: 'React and Node.js stack.', + alt_name: 'Web stack', + categories: [], + colors: { + end: '256291', + start: '30383a', + }, + description: `MERN is a full stack platform that contains everything you need to build a web application: MongoDB, a document database used to persist your application's data; Express, which serves as the web application framework; React, used to build your application's user interfaces; + and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your + existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`, + logo_url: 'mern.svg', + name: 'MERN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mern-stack/', + title: 'Deploy a MERN Stack through the Linode Marketplace', + }, + ], + summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, + }, + 401706: { + alt_description: 'Virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '51171a', + start: '88171a', + }, + description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up + standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + logo_url: 'wireguard.svg', + name: 'WireGuard®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', + title: 'Deploy WireGuard through the Linode Marketplace', + }, + ], + summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, + website: 'https://www.wireguard.com/', + }, + 401707: { + alt_description: 'Popular Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '21153e', + start: '48357d', + }, + description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing. + Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`, + logo_url: 'gitlab.svg', + name: 'GitLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitlab/', + title: 'Deploy GitLab through the Linode Marketplace', + }, + ], + summary: + 'More than a self-hosted Git repository: use GitLab to manage all the stages of your DevOps life cycle.', + website: 'https://about.gitlab.com/', + }, + 401708: { + alt_description: 'Popular secure WordPress ecommerce online store plugin.', + alt_name: 'Ecommerce site', + categories: ['Website'], + colors: { + end: '743b8a', + start: '96588a', + }, + description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`, + logo_url: 'woocommerce.svg', + name: 'WooCommerce', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/woocommerce/', + title: 'Deploy WooCommerce through the Linode Marketplace', + }, + ], + summary: `Highly customizable, secure, open source eCommerce platform built to integrate with Wordpress.`, + website: 'https://woocommerce.com/features/', + }, + 401709: { + alt_description: 'Classic open world survival crafting game.', + alt_name: 'World building game', + categories: ['Games'], + colors: { + end: 'd0c8c4', + start: '97948f', + }, + description: `With over 100 million users around the world, Minecraft is the most popular online game of all time. Less of a game and more of a lifestyle choice, you and other players are free to build and explore in a 3D generated world made up of millions of mineable blocks. Collect resources by leveling mountains, + taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive + Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`, + logo_url: 'minecraft.svg', + name: 'Minecraft: Java Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/minecraft/', + title: 'Deploy a Minecraft Server through the Linode Marketplace', + }, + ], + summary: `Build, explore, and adventure in your own 3D generated world.`, + website: 'https://www.minecraft.net/', + }, + 401719: { + alt_description: 'Popular virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '193766', + start: 'ea7e20', + }, + description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`, + logo_url: 'openvpn.svg', + name: 'OpenVPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openvpn/', + title: 'Deploy OpenVPN through the Linode Marketplace', + }, + ], + summary: `Open-source virtual private network (VPN) application. OpenVPN securely connects your computer to your servers, or to the public Internet.`, + website: 'https://openvpn.net/', + }, + 593835: { + alt_description: 'Popular WordPress server management.', + alt_name: 'WordPress control panel', + categories: ['Control Panels'], + colors: { + end: '4b5868', + start: '53bce6', + }, + description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`, + logo_url: 'plesk.svg', + name: 'Plesk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plesk/', + title: 'Deploy Plesk through the Linode Marketplace', + }, + ], + summary: + 'A secure, scalable, and versatile website and WordPress management platform.', + website: 'https://www.plesk.com/', + }, + 595742: { + alt_description: + 'Linux-based web hosting control panel for managing websites, servers, databases, and more.', + alt_name: 'Web server automation and control panel', + categories: ['Control Panels'], + colors: { + end: '141d25', + start: 'ff6c2c', + }, + description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`, + logo_url: 'cpanel.svg', + name: 'cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cpanel/', + title: 'Deploy cPanel through the Linode Marketplace', + }, + ], + summary: + 'The leading hosting automation platform that has simplified site and server management for 20 years.', + website: 'https://www.cpanel.net/', + }, + 604068: { + alt_description: 'Secure SOCKS5 web proxy with data encryption.', + alt_name: 'VPN proxy', + categories: ['Security'], + colors: { + end: '8d8d8d', + start: '227dc0', + }, + description: + 'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.', + logo_url: 'shadowsocks.svg', + name: 'Shadowsocks', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/shadowsocks/', + title: 'Deploy Shadowsocks through the Linode Marketplace', + }, + ], + summary: + 'A secure socks5 proxy, designed to protect your Internet traffic.', + website: 'https://shadowsocks.org/', + }, + 606691: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: '005138', + start: '2e7d32', + }, + description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`, + logo_url: 'lemp.svg', + name: 'LEMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lemp-stack/', + title: 'Deploy a LEMP Stack through the Linode Marketplace', + }, + ], + summary: `The LEMP stack replaces the Apache web server component with NGINX (“Engine-X”), providing the E in the acronym: Linux, NGINX, MySQL/MariaDB, PHP.`, + }, + 607026: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '8a9177', + start: '1d758f', + }, + description: `MySQL, or MariaDB for Linux distributions, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`, + logo_url: 'mysql.svg', + name: 'MySQL/MariaDB', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mysql/', + title: 'Deploy MySQL/MariaDB through the Linode Marketplace', + }, + ], + summary: `World's most popular open source database.`, + website: 'https://www.mysql.com/', + }, + 607401: { + alt_description: 'CI/CD tool to delegate automation tasks and jobs.', + alt_name: 'Free automation tool', + categories: ['Development'], + colors: { + end: 'd24939', + start: 'd33833', + }, + description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`, + logo_url: 'jenkins.svg', + name: 'Jenkins', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jenkins/', + title: 'Deploy Jenkins through the Linode Marketplace', + }, + ], + summary: `A tool that gives you access to a massive library of plugins to support automation in your project's lifecycle.`, + website: 'https://jenkins.io/', + }, + 607433: { + alt_description: + 'Popular container tool to build cloud-native applications.', + alt_name: 'Container builder', + categories: ['Development'], + colors: { + end: '1e65c9', + start: '2496ed', + }, + description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`, + logo_url: 'docker.svg', + name: 'Docker', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/docker/', + title: 'Deploy Docker through the Linode Marketplace', + }, + ], + summary: `Securely build, share and run modern applications anywhere.`, + website: 'https://www.docker.com/', + }, + 607488: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redis.svg', + name: 'Marketplace App for Redis®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis/', + title: 'Deploy Redis® through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 609018: { + alt_description: + 'Web interface for MySQL/MariaDB operations and server administration.', + alt_name: 'SQL database GUI', + categories: ['Databases'], + colors: { + end: '6c78af', + start: 'f89d10', + }, + description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`, + logo_url: 'phpmyadmin.svg', + name: 'phpMyAdmin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/phpmyadmin/', + title: 'Deploy phpMyAdmin through the Linode Marketplace', + }, + ], + summary: 'Popular free administration tool for MySQL and MariaDB.', + website: 'https://www.phpmyadmin.net/', + }, + 609048: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Web application framework', + categories: ['Development'], + colors: { + end: 'fa9999', + start: '722b20', + }, + description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`, + logo_url: 'rubyonrails.svg', + name: 'Ruby on Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/ruby-on-rails/', + title: 'Deploy Ruby on Rails through the Linode Marketplace', + }, + ], + summary: `Ruby on Rails is a web framework that allows web designers and developers to implement dynamic, fully featured web applications.`, + website: 'https://rubyonrails.org/', + }, + 609175: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '136149', + start: '0a2e1f', + }, + description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`, + logo_url: 'django.svg', + name: 'Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/django/', + title: 'Deploy Django through the Linode Marketplace', + }, + ], + summary: `A framework for simplifying the process of building your web applications more quickly and with less code.`, + website: 'https://www.djangoproject.com/', + }, + 609392: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '1e2122', + start: '363b3d', + }, + description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`, + logo_url: 'flask.svg', + name: 'Flask', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/flask/', + title: 'Deploy Flask through the Linode Marketplace', + }, + ], + summary: `A quick light-weight web framework for Python that includes several utilities and libraries you can use to create a web application.`, + website: 'https://www.palletsprojects.com/p/flask/', + }, + 611376: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, + logo_url: 'postgresql.svg', + name: 'PostgreSQL', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql/', + title: 'Deploy PostgreSQL through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 611895: { + alt_description: 'Angular and Node.js stack.', + alt_name: 'Web framework', + categories: ['Development'], + colors: { + end: '686868', + start: '323232', + }, + description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`, + logo_url: 'mean.svg', + name: 'MEAN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mean-stack/', + title: 'Deploy a MEAN Stack through the Linode Marketplace', + }, + ], + summary: `A MEAN (MongoDB, Express, Angular, Node.js) stack is a free and open-source web software bundle used to build modern web applications.`, + website: 'http://meanjs.org/', + }, + 632758: { + alt_description: + 'File storage alternative to Dropbox and office suite alternative to Microsoft Office.', + alt_name: 'File storage management & business tool suite', + categories: ['Productivity'], + colors: { + end: '2a2a36', + start: '16a5f3', + }, + description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`, + logo_url: 'nextcloud.svg', + name: 'Nextcloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nextcloud/', + title: 'Deploy Nextcloud through the Linode Marketplace', + }, + ], + summary: `A safe home for all your data.`, + }, + 662118: { + alt_description: 'Free internet radio station management and hosting.', + alt_name: 'Online radio station builder', + categories: ['Media and Entertainment'], + colors: { + end: '0b1b64', + start: '1f8df5', + }, + description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`, + logo_url: 'azuracast.svg', + name: 'Azuracast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/azuracast/', + title: 'Deploy AzuraCast through the Linode Marketplace', + }, + ], + summary: 'Open source, self-hosted web radio tool.', + website: 'https://www.azuracast.com/', + }, + 662119: { + alt_description: + 'Video / media library storage and sharing across TVs, phones, computers, and more.', + alt_name: 'Media server', + categories: [], + colors: { + end: '332c37', + start: 'e5a00d', + }, + description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`, + logo_url: 'plex.svg', + name: 'Plex', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plex/', + title: 'Deploy Plex Media Server through the Linode Marketplace', + }, + ], + summary: + 'Media server and streaming service to stay entertained across devices.', + website: 'https://www.plex.tv/', + }, + 662121: { + alt_description: 'Open source video conferencing alternative to Zoom.', + alt_name: 'Video chat and video conferencing', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`, + logo_url: 'jitsi.svg', + name: 'Jitsi', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi/', + title: 'Deploy Jitsi through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 688890: { + alt_description: 'Server work queue management.', + alt_name: 'Message broker', + categories: ['Development'], + colors: { + end: 'ff6600', + start: 'a9b5af', + }, + description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`, + logo_url: 'rabbitmq.svg', + name: 'RabbitMQ', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rabbitmq/', + title: 'Deploy RabbitMQ through the Linode Marketplace', + }, + ], + summary: 'Most popular open source message broker.', + website: 'https://www.rabbitmq.com/', + }, + 688891: { + alt_description: 'Open source community forum alternative to Reddit.', + alt_name: 'Chat forum', + categories: ['Media and Entertainment'], + colors: { + end: 'eae692', + start: '13b3ed', + }, + description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`, + logo_url: 'discourse.svg', + name: 'Discourse', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/discourse/', + title: 'Deploy Discourse through the Linode Marketplace', + }, + ], + summary: + 'Open source community and discussion forum for customers, teams, fans, and more.', + website: 'https://www.discourse.org/', + }, + 688902: { + alt_description: + 'Control panel to deploy and manage LAMP stack applications.', + alt_name: 'Single user control panel', + categories: ['Control Panels'], + colors: { + end: '445289', + start: 'f1b55d', + }, + description: `Lightweight control panel with a suite of features to streamline app management.`, + logo_url: 'webuzo.svg', + name: 'Webuzo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/webuzo/', + title: 'Deploy Webuzo through the Linode Marketplace', + }, + ], + summary: + 'LAMP stack and single user control panel to simplify app deployment in the cloud.', + website: 'http://www.webuzo.com/', + }, + 688903: { + alt_description: 'Fancy development text editor.', + alt_name: 'Text editor', + categories: ['Development'], + colors: { + end: '0066b8', + start: '23a9f2', + }, + description: `Launch a portable development environment to speed up tests, downloads, and more.`, + logo_url: 'vscodeserver.svg', + name: 'VS Code Server', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/vscode/', + title: 'Deploy VS Code through the Linode Marketplace', + }, + ], + summary: 'Run VS code in the cloud, right from your browser.', + website: 'https://github.com/cdr/code-server', + }, + 688911: { + alt_description: 'Open source, self-hosted Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '34495e', + start: '609926', + }, + description: `Self-hosted Git service built and maintained by a large developer community.`, + logo_url: 'gitea.svg', + name: 'Gitea', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitea/', + title: 'Deploy Gitea through the Linode Marketplace', + }, + ], + summary: 'Git with a cup of tea - A painless self-hosted Git service.', + website: 'https://gitea.io/', + }, + 688912: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '4395ff', + start: '0166ff', + }, + description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`, + logo_url: 'keplerbuilder.svg', + name: 'Kepler Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kepler/', + title: 'Deploy Kepler through the Linode Marketplace', + }, + ], + summary: 'Powerful drag & drop WordPress website builder.', + website: 'https://kepler.app/', + }, + 688914: { + alt_description: 'Desktop cloud hosting.', + alt_name: 'Virtual desktop', + categories: ['Development'], + colors: { + end: '213121', + start: '304730', + }, + description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`, + logo_url: 'guacamole.svg', + name: 'Guacamole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/guacamole/', + title: 'Deploy Apache Guacamole through the Linode Marketplace', + }, + ], + summary: 'Free open source clientless remote desktop gateway.', + website: 'https://guacamole.apache.org/', + }, + 691620: { + alt_description: 'File storage alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '0168ad', + start: '3e8cc1', + }, + description: `File synchronization across multiple users’ computers and other devices to keep everyone working without interruption.`, + logo_url: 'filecloud.svg', + name: 'FileCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/filecloud/', + title: 'Deploy FileCloud through the Linode Marketplace', + }, + ], + summary: 'Enterprise file sharing to manage and sync from any device.', + website: 'https://www.getfilecloud.com', + }, + 691621: { + alt_description: + 'Host multiple apps on one server and control panel, including WordPress, GitLab, and Nextcloud.', + alt_name: 'Cloud app and website control panel', + categories: ['Website'], + colors: { + end: '212121', + start: '03a9f4', + }, + description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`, + logo_url: 'cloudron.svg', + name: 'Cloudron', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cloudron/', + title: 'Deploy Cloudron through the Linode Marketplace', + }, + ], + summary: + 'End-to-end deployment and automatic updates for a range of essential applications.', + website: 'https://docs.cloudron.io', + }, + 691622: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '3d596d', + start: '33cccc', + }, + description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`, + logo_url: 'openlitespeedwordpress.svg', + name: 'OpenLiteSpeed WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-wordpress/', + title: 'Deploy OpenLiteSpeed Wordpress through the Linode Marketplace', + }, + ], + summary: 'Blazing fast, open source alternative to LiteSpeed Web Server.', + website: 'https://openlitespeed.org/', + }, + 692092: { + alt_description: 'Limited user, hardened SSH, Fail2Ban Linode server.', + alt_name: 'Secure server tool', + categories: ['Security'], + colors: { + end: '32363b', + start: '01b058', + }, + description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`, + logo_url: 'secureyourserver.svg', + name: 'Secure Your Server', + related_guides: [ + { + href: 'https://www.linode.com/docs/guides/set-up-and-secure/', + title: 'Securing your Server', + }, + ], + summary: `Harden your Linode before you deploy with the Secure Your Server One-Click App.`, + }, + 741206: { + alt_description: + 'Web hosting control panel for managing websites, including WordPress.', + alt_name: 'Web hosting control panel', + categories: ['Control Panels'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`, + logo_url: 'cyberpanel.svg', + name: 'CyberPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cyberpanel/', + title: 'Deploy CyberPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation hosting control panel by OpenLiteSpeed.', + website: 'https://docs.litespeedtech.com/cloud/images/cyberpanel/', + }, + 741207: { + alt_description: 'Web interface for managing Docker containers.', + alt_name: 'Docker GUI', + categories: ['Development'], + colors: { + end: 'c4c4c4', + start: '41b883', + }, + description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`, + logo_url: 'yacht.svg', + name: 'Yacht', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/yacht/', + title: 'Deploy Yacht through the Linode Marketplace', + }, + ], + summary: 'Intuitive web interface for managing Docker containers.', + website: 'https://github.com/SelfhostedPro/Yacht/', + }, + 741208: { + alt_description: 'Enterprise infrastructure and IT resource montioring.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: '252730', + start: 'd40000', + }, + description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resources– all in one tool.`, + logo_url: 'zabbix.svg', + name: 'Zabbix', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/zabbix/', + title: 'Deploy Zabbix through the Linode Marketplace', + }, + ], + summary: 'Enterprise-class open source distributed monitoring solution.', + website: 'https://www.zabbix.com', + }, + 774829: { + alt_description: 'Host multiple sites on a Linode.', + alt_name: 'Website control panel', + categories: ['Control Panels'], + colors: { + end: 'a25c57', + start: '4c3148', + }, + description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and email– all in an intuitive interface.`, + logo_url: 'serverwand.svg', + name: 'ServerWand', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/serverwand/', + title: 'Deploy ServerWand through the Linode Marketplace', + }, + ], + summary: + 'Magical control panel for hosting websites and managing your servers.', + website: 'https://serverwand.com/', + }, + 804143: { + alt_description: 'Open source project management tool.', + alt_name: 'Ticket management project management tool', + categories: ['Productivity'], + colors: { + end: '0a0a0a', + start: '4cff4c', + }, + description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`, + logo_url: 'peppermint.svg', + name: 'Peppermint', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/peppermint/', + title: 'Deploy Peppermint through the Linode Marketplace', + }, + ], + summary: 'Simple yet scalable open source ticket management.', + website: 'https://peppermint.sh/', + }, + 804144: { + alt_description: + 'Free high-performance media streaming, including livestreaming.', + alt_name: 'Free media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Community Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaserver/', + title: 'Deploy Ant Media Server through the Linode Marketplace', + }, + ], + summary: 'A reliable, flexible and scalable video streaming solution.', + website: 'https://antmedia.io/', + }, + 804172: { + alt_description: 'Video and audio live streaming alternative to Twitch.', + alt_name: 'Live streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '2086e1', + start: '7871ff', + }, + description: `A live streaming and chat server for use with existing popular broadcasting software.`, + logo_url: 'owncast.svg', + name: 'Owncast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncast/', + title: 'Deploy Owncast through the Linode Marketplace', + }, + ], + summary: + 'The standalone “Twitch in a Box” open source streaming and chat solution.', + website: 'https://owncast.online/', + }, + 869127: { + alt_description: 'Open source course builder and education tool.', + alt_name: 'Online course CMS', + categories: ['Website'], + colors: { + end: '494949', + start: 'ff7800', + }, + description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`, + logo_url: 'moodle.svg', + name: 'Moodle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/moodle/', + title: 'Deploy Moodle through the Linode Marketplace', + }, + ], + summary: + 'World’s most popular learning management system built and maintained by an active developer community.', + website: 'https://docs.moodle.org/', + }, + 869129: { + alt_description: 'Free open source control panel with a mobile app.', + alt_name: 'Free infrastructure control panel', + categories: ['Control Panels'], + colors: { + end: 'a3a3a3', + start: '20a53a', + }, + description: `Feature-rich alternative control panel for users who need critical control panel functionality but don’t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`, + logo_url: 'aapanel.svg', + name: 'aaPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/aapanel/', + title: 'Deploy aaPanel through the Linode Marketplace', + }, + ], + summary: + 'Popular open source free control panel with robust features and a mobile app.', + website: 'https://www.aapanel.com/reference.html', + }, + 869153: { + alt_description: 'Data security, data observability, data automation.', + alt_name: 'Data management', + categories: ['Development'], + colors: { + end: 'ed0181', + start: 'f89f24', + }, + description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`, + logo_url: 'splunk.svg', + name: 'Splunk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/splunk/', + title: 'Deploy Splunk through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.splunk.com/Documentation/Splunk', + }, + 869155: { + alt_description: + 'Image hosting and sharing alternative to Google Photos and Flickr.', + alt_name: 'Photo library and image library', + categories: ['Media and Entertainment'], + colors: { + end: '8e44ad', + start: '23a8e0', + }, + description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linode’s S3-compatible Object Storage) and connect to Chevereto using API keys.`, + logo_url: 'chevereto.svg', + name: 'Chevereto', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/chevereto/', + title: 'Deploy Chevereto through the Linode Marketplace', + }, + ], + summary: + 'Self-host your own open source image library to easily upload, collaborate, and share images on your terms.', + website: 'https://v3-docs.chevereto.com/', + }, + 869156: { + alt_description: + 'File storage and sharing alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '252730', + start: '1f4c8f', + }, + description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`, + logo_url: 'nirvashare.svg', + name: 'NirvaShare', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nirvashare/', + title: 'Deploy NirvaShare through the Linode Marketplace', + }, + ], + summary: + 'Secure file sharing for better collaboration with employees, partners, vendors, and more.', + website: 'https://nirvashare.com/setup-guide/', + }, + 869158: { + alt_description: + 'SQL and NoSQL database interface and monitoring for MySQL, PostgreSQL, and more.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: '3f434c', + start: '0589de', + }, + description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`, + logo_url: 'clustercontrol.svg', + name: 'ClusterControl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/clustercontrol/', + title: 'Deploy ClusterControl through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.severalnines.com/docs/clustercontrol/', + }, + 869623: { + alt_description: 'Enterprise-ready backups tool.', + alt_name: 'Server backups management and control panel', + categories: ['Control Panels'], + colors: { + end: '1f2c38', + start: 'ff6c2c', + }, + description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linode’s S3-compatible Object Storage.`, + logo_url: 'jetbackup.svg', + name: 'JetBackup', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jetbackup/', + title: 'Deploy JetBackup through the Linode Marketplace', + }, + ], + summary: + 'Advanced customizable backups to integrate with your preferred control panel.', + website: 'https://docs.jetapps.com/', + }, + 912262: { + alt_description: 'Container registry for Kubernetes.', + alt_name: 'Container registry for Kubernetes.', + categories: ['Development'], + colors: { + end: '4495d7', + start: '60b932', + }, + description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`, + logo_url: 'harbor.svg', + name: 'Harbor', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/harbor/', + title: 'Deploy Harbor through the Linode Marketplace', + }, + ], + summary: 'Cloud native container registry for Kubernetes and more.', + website: 'https://goharbor.io/docs', + }, + 912264: { + alt_description: 'Free alternative to Slack, Microsoft Teams, and Skype.', + alt_name: 'Chat software', + categories: ['Productivity'], + colors: { + end: '030d1a', + start: 'f5445c', + }, + description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`, + logo_url: 'rocketchat.svg', + name: 'Rocket.Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rocketchat/', + title: 'Deploy Rocket.Chat through the Linode Marketplace', + }, + ], + summary: 'Feature-rich self-hosted chat and collaboration platform.', + website: 'https://docs.rocket.chat/', + }, + 913276: { + alt_description: + 'Security analytics for intrusion attempts and user action monitoring.', + alt_name: 'Security monitoring', + categories: ['Security'], + colors: { + end: 'ffb600', + start: '00a9e5', + }, + description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`, + logo_url: 'wazuh.svg', + name: 'Wazuh', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wazuh/', + title: 'Deploy Wazuh through the Linode Marketplace', + }, + ], + summary: 'Free open source security monitoring solution.', + website: 'https://documentation.wazuh.com/current/index.html', + }, + 913277: { + alt_description: 'Free penetration testing tool using client-side vectors.', + alt_name: 'Penetration testing tool for security research', + categories: ['Security'], + colors: { + end: '000f21', + start: '4a80a9', + }, + description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`, + logo_url: 'beef.svg', + name: 'BeEF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/beef/', + title: 'Deploy BeEF through the Linode Marketplace', + }, + ], + summary: + 'Browser Exploitation Framework (BeEF) is an open source web browser penetration tool.', + website: 'https://github.com/beefproject/beef', + }, + 923029: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '5cbf8a', + start: '318640', + }, + description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`, + logo_url: 'openlitespeeddjango.svg', + name: 'OpenLiteSpeed Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-django/', + title: 'Deploy OpenLiteSpeed Django through the Linode Marketplace', + }, + ], + summary: 'OLS web server with Django development framework.', + website: 'https://docs.litespeedtech.com/cloud/images/django/', + }, + 923030: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Ruby web application framework.', + categories: ['Development'], + colors: { + end: 'd94b7a', + start: '8e1a4a', + }, + description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`, + logo_url: 'openlitespeedrails.svg', + name: 'OpenLiteSpeed Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-rails/', + title: 'Deploy OpenLiteSpeed Rails through the Linode Marketplace ', + }, + ], + summary: 'OLS web server with Ruby and CertBot.', + website: 'https://docs.litespeedtech.com/cloud/images/rails/', + }, + 923031: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`, + logo_url: 'openlitespeednodejs.svg', + name: 'OpenLiteSpeed NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-nodejs/', + title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace', + }, + ], + summary: 'OLS web server with NodeJS JavaScript runtime environment.', + website: 'https://docs.litespeedtech.com/cloud/images/nodejs/', + }, + 923032: { + alt_description: 'Optimized control panel server.', + alt_name: 'Web server control panel', + categories: ['Website'], + colors: { + end: '6e92c7', + start: '353785', + }, + description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`, + logo_url: 'litespeedcpanel.svg', + name: 'LiteSpeed cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/litespeed-cpanel/', + title: 'Deploy LiteSpeed cPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation web server with cPanel and WHM.', + website: 'https://docs.litespeedtech.com/cp/cpanel/', + }, + 923033: { + alt_description: + 'Free accounting software. QuickBooks alternative for freelancers and small businesses.', + alt_name: 'Open source accounting software', + categories: ['Productivity'], + colors: { + end: '55588b', + start: '6ea152', + }, + description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`, + logo_url: 'akaunting.svg', + name: 'Akaunting', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/akaunting/', + title: 'Deploy Akaunting through the Linode Marketplace', + }, + ], + summary: + 'Free and open source accounting software you can use in your browser.', + website: 'https://akaunting.com', + }, + 923036: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '555555', + start: 'f47564', + }, + description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`, + logo_url: 'restyaboard.svg', + name: 'Restyaboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/restyaboard/', + title: 'Deploy Restyaboard through the Linode Marketplace', + }, + ], + summary: 'Free and open source project management tool.', + website: 'https://restya.com', + }, + 923037: { + alt_description: 'Virtual private network.', + alt_name: 'WireGuard VPN', + categories: ['Security'], + colors: { + end: '333333', + start: '1f76b7', + }, + description: `Feature-rich, self-hosted VPN based on WireGuard® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`, + logo_url: 'warpspeed.svg', + name: 'WarpSpeed', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/warpspeed/', + title: 'Deploy WarpSpeed VPN through the Linode Marketplace', + }, + ], + summary: 'Secure low-latency VPN powered by WireGuard® protocol.', + website: 'https://bunker.services/products/warpspeed', + }, + 925530: { + alt_description: 'Virtual private network.', + alt_name: 'VPN', + categories: ['Security'], + colors: { + end: '1a32b1', + start: '2ec1cf', + }, + description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`, + logo_url: 'utunnel.svg', + name: 'UTunnel VPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/utunnel/', + title: 'Deploy UTunnel VPN through the Linode Marketplace', + }, + ], + summary: + 'A powerful, user-friendly Virtual Private Network (VPN) server application that supports multiple VPN protocols.', + website: 'https://www.utunnel.io/linode-vpn-server.html', + }, + 925722: { + alt_description: 'Virtual private network for businesses and teams.', + alt_name: 'Enterprise VPN', + categories: ['Security'], + colors: { + end: '2e72d2', + start: '2e4153', + }, + description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`, + logo_url: 'pritunl.svg', + name: 'Pritunl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pritunl/', + title: 'Deploy Pritunl through the Linode Marketplace', + }, + ], + summary: 'Enterprise open source VPN.', + website: 'https://docs.pritunl.com/docs', + }, + 954759: { + alt_description: 'Time series database and database monitoring/metrics.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: 'af3e56', + start: '6a1e6e', + }, + description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`, + logo_url: 'victoriametricssingle.svg', + name: 'VictoriaMetrics Single', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/victoriametrics-single/', + title: 'Deploy VictoriaMetrics Single through the Linode Marketplace', + }, + ], + summary: + 'Free and open source time series database (TSDB) and monitoring solution.', + website: 'https://victoriametrics.com/', + }, + 970522: { + alt_description: 'Popular DNS privacy sinkhole.', + alt_name: 'Network ad blocking', + categories: ['Security'], + colors: { + end: 'f60d1a', + start: '96060c', + }, + description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`, + logo_url: 'pihole.svg', + name: 'Pi-hole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pihole/', + title: 'Deploy Pi-hole through the Linode Marketplace', + }, + ], + summary: 'Free, open source, and highly scalable DNS sinkhole.', + website: 'https://pi-hole.net/', + }, + 970523: { + alt_description: + 'Infrastructure monitoring and aler alternative to Uptime Robot.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: 'baecca', + start: '67de92', + }, + description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`, + logo_url: 'uptimekuma.svg', + name: 'Uptime Kuma', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/uptime-kuma/', + title: 'Deploy Uptime Kuma through the Linode Marketplace', + }, + ], + summary: 'Free, comprehensive, and “fancy” monitoring solution.', + website: 'https://github.com/louislam/uptime-kuma', + }, + 970559: { + alt_description: 'Markdown-based website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: 'b987cf', + start: '1a0629', + }, + description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`, + logo_url: 'grav.svg', + name: 'Grav', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/grav/', + title: 'Deploy Grav through the Linode Marketplace', + }, + ], + summary: 'Modern and open source flat-file content management system.', + website: 'https://getgrav.org/', + }, + 970561: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '333333', + start: '3d853c', + }, + description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, + logo_url: 'nodejs.svg', + name: 'NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/', + title: 'Deploy NodeJS through the Linode Marketplace', + }, + ], + summary: + 'Popular and versatile open source JavaScript run-time environment.', + website: 'https://nodejs.org/', + }, + 971042: { + alt_description: 'Database low-code/no-code application builder.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: 'ff8e42', + start: '995ad9', + }, + description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`, + logo_url: 'saltcorn.svg', + name: 'Saltcorn', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/saltcorn/', + title: 'Deploy Saltcorn through the Linode Marketplace', + }, + ], + summary: 'Open source, no-code database application builder.', + website: 'https://saltcorn.com/', + }, + 971043: { + alt_description: + 'Open source marketing and business platform with a CRM and email marketing.', + alt_name: 'Marketing tool suite', + categories: ['Productivity'], + colors: { + end: '027e84', + start: '55354c', + }, + description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`, + logo_url: 'odoo.svg', + name: 'Odoo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/odoo/', + title: 'Deploy Odoo through the Linode Marketplace', + }, + ], + summary: + 'Open source, all-in-one business app suite with more than 7 million users.', + website: 'https://www.odoo.com/', + }, + 971045: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '1d52ad', + start: '2997f8', + }, + description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`, + logo_url: 'focalboard.svg', + name: 'Focalboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/focalboard/', + title: 'Deploy Focalboard through the Linode Marketplace', + }, + ], + summary: 'Free open source project management tool.', + website: 'https://www.focalboard.com/', + }, + 985364: { + alt_description: 'Monitoring server.', + alt_name: 'Server monitoring and visualization', + categories: ['Monitoring'], + colors: { + end: 'e6522c', + start: 'f9b716', + }, + description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, + logo_url: 'prometheusgrafana.svg', + name: 'Prometheus & Grafana', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/prometheus-grafana/', + title: 'Deploy Prometheus & Grafana through the Linode Marketplace', + }, + ], + summary: 'Open source metrics and monitoring for real-time insights.', + website: 'https://prometheus.io/docs/visualization/grafana/', + }, + 985372: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '5090cd', + start: 'f2a13e', + }, + description: `Free open source CMS optimized for building custom functionality and design.`, + logo_url: 'joomla.svg', + name: 'Joomla', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joomla/', + title: 'Deploy Joomla through the Linode Marketplace', + }, + ], + summary: 'Flexible and security-focused content management system.', + website: 'https://www.joomla.org/', + }, + 985374: { + alt_description: + 'Low latency live streaming including WebRTC streaming, CMAF, and HLS.', + alt_name: 'Media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Enterprise Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaenterpriseserver/', + title: + 'Deploy Ant Media Enterprise Edition through the Linode Marketplace', + }, + ], + summary: 'Highly scalable and feature-rich live video streaming platform.', + website: 'https://antmedia.io/', + }, + 985380: { + alt_description: + 'Digital note-taking application alternative to Evernote and OneNote.', + alt_name: 'Multimedia note-taking and digital notebook', + categories: ['Website'], + colors: { + end: '509df9', + start: '043872', + }, + description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`, + logo_url: 'joplin.svg', + name: 'Joplin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joplin/', + title: 'Deploy Joplin through the Linode Marketplace', + }, + ], + summary: 'Open source multimedia note-taking app.', + website: 'https://joplinapp.org/', + }, + 1008123: { + alt_description: 'Audio and video streaming with E2E data encryption.', + alt_name: 'Live streaming', + categories: ['Media and Entertainment'], + colors: { + end: '4d8eff', + start: '346ee0', + }, + description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`, + logo_url: 'liveswitch.svg', + name: 'LiveSwitch', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/liveswitch/', + title: 'Deploy LiveSwitch through the Linode Marketplace', + }, + ], + summary: 'High quality and reliable interactive live streaming.', + website: 'https://www.liveswitch.io/', + }, + 1008125: { + alt_description: + 'Flexible control panel to simplify SSL certificates and push code from GitHub.', + alt_name: 'Server control panel', + categories: ['Control Panels'], + colors: { + end: '000000', + start: '059669', + }, + description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`, + logo_url: 'easypanel.svg', + name: 'Easypanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/easypanel/', + title: 'Deploy Easypanel through the Linode Marketplace', + }, + ], + summary: 'Modern server control panel based on Docker.', + website: 'https://easypanel.io/', + }, + 1017300: { + alt_description: + 'Security research and testing platform with hundreds of tools for reverse engineering, penetration testing, and more.', + alt_name: 'Security research', + categories: ['Security'], + colors: { + end: '2fa1bc', + start: '267ff7', + }, + description: `Kali Linux is an open source, Debian-based Linux distribution that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`, + logo_url: 'kalilinux.svg', + name: 'Kali Linux', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kali-linux/', + title: 'Deploy Kali Linux through the Linode Marketplace', + }, + ], + summary: + 'Popular Linux distribution and tool suite for penetration testing and security research.', + website: 'https://www.kali.org/', + }, + 1037036: { + alt_description: + 'Application builder for forms, portals, admin panels, and more.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: '000000', + start: '9981f5', + }, + description: + 'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.', + logo_url: 'budibase.svg', + name: 'Budibase', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/budibase/', + title: 'Deploy Budibase through the Linode Marketplace', + }, + ], + summary: 'Low-code platform for building modern business applications.', + website: 'https://docs.budibase.com/docs', + }, + 1037037: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad', + title: 'Deploy HashiCorp Nomad through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1037038: { + alt_description: 'HashiCorp password and secrets management storage.', + alt_name: 'Security secrets management', + categories: ['Security'], + colors: { + end: '545556', + start: 'ffd712', + }, + description: + 'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.', + logo_url: 'vault.svg', + name: 'HashiCorp Vault', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-vault', + title: 'Deploy HashiCorp Vault through the Linode Marketplace', + }, + ], + summary: 'An open source, centralized secrets management system.', + website: 'https://www.vaultproject.io/docs', + }, + 1051714: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'Website builder', + categories: ['Development'], + colors: { + end: '4592ff', + start: '4592ff', + }, + description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`, + logo_url: 'microweber.svg', + name: 'Microweber', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/microweber/', + title: 'Deploy Microweber through the Linode Marketplace', + }, + ], + summary: `Drag and drop CMS and website builder.`, + website: 'https://microweber.com/', + }, + 1068726: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, + logo_url: 'postgresqlmarketplaceocc.svg', + name: 'PostgreSQL Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql-cluster/', + title: 'Deploy PostgreSQL Cluster through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 1088136: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '000000', + start: 'EC7704', + }, + description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`, + logo_url: 'galeramarketplaceocc.svg', + name: 'Galera Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/galera-cluster/', + title: 'Deploy Galera Cluster through the Linode Marketplace', + }, + ], + summary: `Multi-master MariaDB database cluster.`, + website: 'https://galeracluster.com/', + }, + 1096122: { + alt_description: 'Open source Twitter alternative.', + alt_name: 'Open source social media', + categories: ['Media and Entertainment'], + colors: { + end: '563ACC', + start: '6364FF', + }, + description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`, + logo_url: 'mastodon.svg', + name: 'Mastodon', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mastodon/', + title: 'Deploy Mastodon through the Linode Marketplace', + }, + ], + summary: + 'Mastodon is an open-source and decentralized micro-blogging platform.', + website: 'https://docs.joinmastodon.org/', + }, + 1102900: { + alt_description: + 'Open-source workflow management platform for data engineering pipelines.', + alt_name: 'Workflow management platform', + categories: ['Development'], + colors: { + end: 'E43921', + start: '00C7D4', + }, + description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`, + logo_url: 'apacheairflow.svg', + name: 'Apache Airflow', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-airflow/', + title: 'Deploy Apache Airflow through the Linode Marketplace', + }, + ], + summary: + 'Open source workflow management platform for data engineering pipelines.', + website: 'https://airflow.apache.org/', + }, + 1102902: { + alt_description: 'Web Application Firewall.', + alt_name: 'Community WAF', + categories: ['Security'], + colors: { + end: '00C1A9', + start: '22324F', + }, + description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`, + logo_url: 'haltdos.svg', + name: 'HaltDOS Community WAF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/haltdos-community-waf/', + title: 'Deploy Haltdos Community WAF through the Linode Marketplace', + }, + ], + summary: 'User-friendly web application firewall.', + website: 'https://www.haltdos.com/', + }, + 1102904: { + alt_description: + 'A simple SQL interface to store and search unstructured data.', + alt_name: 'SuperinsightDB', + categories: ['Databases'], + colors: { + end: 'C54349', + start: 'E6645F', + }, + description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`, + logo_url: 'superinsight.svg', + name: 'Superinsight', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/superinsight/', + title: 'Deploy Superinsight through the Linode Marketplace', + }, + ], + summary: 'Relational database for unstructured data.', + website: 'https://www.superinsight.ai/', + }, + 1102905: { + alt_description: + 'No-code platform for Kubernetes developers and operators.', + alt_name: 'Go Paddle', + categories: ['Development'], + colors: { + end: '252930', + start: '3a5bfd', + }, + description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddle’s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`, + logo_url: 'gopaddle.svg', + name: 'Gopaddle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gopaddle/', + title: 'Deploy Gopaddle through the Linode Marketplace', + }, + ], + summary: + 'Simple low-code platform for Kubernetes developers and operators.', + website: 'https://gopaddle.io/', + }, + 1102906: { + alt_description: 'Password Manager', + alt_name: 'Pass Key', + categories: ['Security'], + colors: { + end: '3A5EFF', + start: '709cff', + }, + description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`, + logo_url: 'passky.svg', + name: 'Passky', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passky/', + title: 'Deploy Passky through the Linode Marketplace', + }, + ], + summary: 'Simple open source password manager.', + website: 'https://passky.org/', + }, + 1102907: { + alt_description: 'Office Suite', + alt_name: 'Office Docs', + categories: ['Productivity'], + colors: { + end: 'ff6f3d', + start: 'ffa85b', + }, + description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`, + logo_url: 'onlyoffice.svg', + name: 'ONLYOFFICE Docs', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/onlyoffice/', + title: 'Deploy ONLYOFFICE Docs through the Linode Marketplace', + }, + ], + summary: 'Open source comprehensive office suite.', + website: 'https://www.onlyoffice.com/', + }, + 1132204: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redissentinelmarketplaceocc.svg', + name: 'Marketplace App for Redis® Sentinel Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis-cluster/', + title: + 'Deploy Redis® Sentinel Cluster through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 1160816: { + alt_description: 'Self-hosted file sharing and collaboration platform.', + alt_name: 'Collabrative file sharing', + categories: ['Productivity'], + colors: { + end: '041e42', + start: '041e42', + }, + description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`, + logo_url: 'owncloud.svg', + name: 'ownCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncloud/', + title: 'Deploy ownCloud through the Linode Marketplace', + }, + ], + summary: + 'Dropbox and OneDrive alternative that lets you remain in control of your files.', + website: 'https://doc.owncloud.com/docs/next/', + }, + 1160820: { + alt_description: + 'A self-hosted backend-as-a-service platform that provides developers with all the core APIs required to build any application.', + alt_name: 'Self-hosted backend-as-a-service', + categories: ['Development'], + colors: { + end: 'f02e65', + start: 'f02e65', + }, + description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`, + logo_url: 'appwrite.svg', + name: 'Appwrite', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/appwrite/', + title: 'Deploy Appwrite through the Linode Marketplace', + }, + ], + summary: + 'Appwrite is an open-source, cross-platform and technology-agnostic alternative to Firebase, providing all the core APIs necessary for web, mobile and Flutter development.', + website: 'https://appwrite.io/', + }, + 1177225: { + alt_description: 'A safe home for all your data.', + alt_name: + 'Spreadsheet style interface with the power of a relational database.', + categories: ['Productivity'], + colors: { + end: 'FF8000', + start: 'FF8000', + }, + description: `Self-hosted database for a variety of management projects.`, + logo_url: 'seatable.svg', + name: 'Seatable', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/seatable/', + title: 'Deploy Seatable through the Linode Marketplace', + }, + ], + summary: + 'Collaborative web interface for data backed project and process management.', + website: 'https://seatable.io/docs/?lang=auto', + }, + 1177605: { + alt_description: + 'Retool open-source alternative, with low-code UI components.', + alt_name: 'Low-code development platform', + categories: ['Security'], + colors: { + end: 'FF58BE', + start: '654AEC', + }, + description: + 'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.', + logo_url: 'illabuilder.svg', + name: 'Illa Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/illa-builder', + title: 'Deploy Illa Builder through the Linode Marketplace', + }, + ], + summary: 'An open-source, low-code development platform.', + website: 'https://github.com/illacloud/illa-builder', + }, + 1226544: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-cluster', + title: 'Deploy HashiCorp Nomad Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1226545: { + alt_description: + 'HashiCorp Nomad clients for horizontally scaling a Nomad One-Click Cluster', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Clients Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-clients-cluster', + title: + 'Deploy HashiCorp Nomad Clients Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1243759: { + alt_description: 'FFmpeg encoder plugins.', + alt_name: 'Premium video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept FFmpeg Plugins Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-ffmpeg-plugins-demo/', + title: + 'Deploy MainConcept FFmpeg Plugins Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', + website: 'https://www.mainconcept.com/ffmpeg', + }, + 1243760: { + alt_description: 'Live video encoding engine.', + alt_name: 'Real time video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept Live Encoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder-demo/', + title: + 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', + }, + ], + summary: 'MainConcept Live Encoder is a real time video encoding engine.', + website: 'https://www.mainconcept.com/live-encoder', + }, + 1243762: { + alt_description: 'Panasonic camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept P2 AVC ULTRA Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-p2-avc-ultra-demo/', + title: + 'Deploy MainConcept P2 AVC ULTRA Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept P2 AVC ULTRA Transcoder is a Docker container for file-based transcoding of media files into professional Panasonic camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243763: { + alt_description: 'Sony camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XAVC Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xavc-transcoder-demo/', + title: + 'Deploy MainConcept XAVC Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XAVC Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243764: { + alt_description: 'Sony XDCAM format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XDCAM Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xdcam-transcoder-demo/', + title: + 'Deploy MainConcept XDCAM Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XDCAM Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243780: { + alt_description: 'A private by design messaging platform.', + alt_name: 'Anonymous messaging platform.', + categories: ['Productivity'], + colors: { + end: '70f0f9', + start: '11182f', + }, + description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`, + logo_url: 'simplexchat.svg', + name: 'SimpleX Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/simplex/', + title: 'Deploy SimpleX chat through the Linode Marketplace', + }, + ], + summary: 'Private by design messaging server.', + website: 'https://simplex.chat', + }, + 1298017: { + alt_description: 'Data science notebook.', + alt_name: 'Data science and machine learning development environment.', + categories: ['Productivity'], + colors: { + end: '9e9e9e', + start: 'f37626', + }, + description: + 'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.', + logo_url: 'jupyter.svg', + name: 'JupyterLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jupyterlab/', + title: 'Deploy JupyterLab through the Linode Marketplace', + }, + ], + summary: 'Data science development environment.', + website: 'https://jupyter.org', + }, + 1308539: { + alt_description: `Microservice centeric stream processing.`, + alt_name: 'Microservice messaging bus', + categories: ['Development'], + colors: { + end: '000000', + start: '0086FF', + }, + description: + 'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.', + logo_url: 'nats.svg', + name: 'NATS Single Node', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nats-single-node/', + title: 'Deploy NATS single node through the Linode Marketplace', + }, + ], + summary: 'Cloud native application messaging service.', + website: 'https://nats.io', + }, + 1329430: { + alt_description: 'Password Manager', + alt_name: 'Passbolt', + categories: ['Security'], + colors: { + end: 'D40101', + start: '171717', + }, + description: `Passbolt is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, + logo_url: 'passbolt.svg', + name: 'Passbolt', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', + title: 'Deploy Passbolt through the Linode Marketplace', + }, + ], + summary: 'Open-source password manager for teams and businesses.', + website: 'https://www.passbolt.com/', + }, + 1329462: { + alt_description: + 'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.', + alt_name: 'Multiplayer Game Servers', + categories: ['Games'], + colors: { + end: 'F6BD0C', + start: '000000', + }, + description: `Self hosted multiplayer game servers.`, + logo_url: 'linuxgsm.svg', + name: 'LinuxGSM', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/linuxgsm/', + title: 'Deploy LinuxGSM through the Linode Marketplace', + }, + ], + summary: 'Simple command line multiplayer game servers.', + website: 'https://docs.linuxgsm.com', + }, + 1350733: { + alt_description: + 'Open source video conferencing cluster, alternative to Zoom.', + alt_name: 'Video chat and video conferencing cluster', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, + logo_url: 'jitsi.svg', + name: 'Jitsi Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi-cluster/', + title: 'Deploy Jitsi Cluster through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 1350783: { + alt_description: 'Open source, highly available, shared filesystem.', + alt_name: 'GlusterFS', + categories: ['Development'], + colors: { + end: '784900', + start: 'D4AC5C', + }, + description: + 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', + logo_url: 'glusterfs.svg', + name: 'GlusterFS Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/glusterfs-cluster/', + title: 'Deploy GlusterFS Cluster through the Linode Marketplace', + }, + ], + summary: 'Open source network filesystem.', + website: 'https://www.gluster.org/', + }, +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx index 6a25fe2747b..a171803e064 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx @@ -8,6 +8,8 @@ import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from './constants'; + import type { FormikHelpers } from 'formik'; interface Props { @@ -29,7 +31,7 @@ export const PlacementGroupsAffinityTypeEnforcementRadioGroup = ( return ( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index ac370b557d3..43b451c6cca 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -45,10 +45,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { error: deletePlacementError, isLoading: deletePlacementLoading, mutateAsync: deletePlacementGroup, + reset: resetDeletePlacementGroup, } = useDeletePlacementGroup(selectedPlacementGroup?.id ?? -1); const { error: unassignLinodeError, mutateAsync: unassignLinodes, + reset: resetUnassignLinodes, } = useUnassignLinodesFromPlacementGroup(selectedPlacementGroup?.id ?? -1); const [assignedLinodes, setAssignedLinodes] = React.useState< Linode[] | undefined @@ -85,6 +87,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { variant: 'success', } ); + handleClose(); + }; + + const handleClose = () => { + resetDeletePlacementGroup(); + resetUnassignLinodes(); onClose(); }; @@ -108,7 +116,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { width: 500, }, }} - onClose={onClose} + onClose={handleClose} open={open} title="Delete Placement Group" > @@ -130,7 +138,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { label="Placement Group" loading={deletePlacementLoading} onClick={onDelete} - onClose={onClose} + onClose={handleClose} open={open} title={`Delete Placement Group ${selectedPlacementGroup.label}`} > diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx index 196308c156d..9c8b4d1cbe3 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -28,10 +28,7 @@ export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { }); return ( - + {label} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index b533dd93ab4..e9124854f1c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -106,7 +106,10 @@ export const PlacementGroupsDetailPanel = (props: Props) => { allRegionsWithPlacementGroupCapability?.length ? ( {allRegionsWithPlacementGroupCapability?.map((region) => ( - + {region.label} ({region.id}) ))} @@ -115,6 +118,7 @@ export const PlacementGroupsDetailPanel = (props: Props) => { NO_REGIONS_SUPPORT_PLACEMENT_GROUPS_MESSAGE ) } + dataQaTooltip="Regions that support placement groups" displayText="regions" minWidth={225} />{' '} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx index 900dc4a0906..2664c099451 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx @@ -55,10 +55,7 @@ export const PlacementGroupsRow = React.memo( ]; return ( - + { const { title, type } = props; - const flags = useFlags(); const { data: profile } = useProfile(); const { handleOrderChange, order, orderBy } = useOrder( { @@ -83,9 +81,7 @@ export const APITokenTable = (props: Props) => { { '+order': order, '+order_by': orderBy } ); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && profile?.user_type === 'proxy' - ); + const isProxyUser = Boolean(profile?.user_type === 'proxy'); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [isRevokeOpen, setIsRevokeOpen] = React.useState(false); @@ -146,11 +142,7 @@ export const APITokenTable = (props: Props) => { const renderRows = (tokens: Token[]) => { return tokens.map((token: Token) => ( - + {token.label} diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 0d9ece4ab70..7b23c2f67f7 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { appTokenFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { profileFactory } from 'src/factories/profile'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; @@ -123,9 +123,7 @@ describe('Create API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByText } = renderWithTheme(); const childScope = getByText('Child Account Access'); expect(childScope).toBeInTheDocument(); }); @@ -139,10 +137,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); @@ -154,10 +149,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index e2eacbe7679..71c93899c2c 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -17,7 +17,6 @@ import { TextField } from 'src/components/TextField'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useProfile } from 'src/queries/profile'; import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; @@ -94,8 +93,6 @@ export const CreateAPITokenDrawer = (props: Props) => { const expiryTups = genExpiryTups(); const { onClose, open, showSecret } = props; - const flags = useFlags(); - const initialValues = { expiry: expiryTups[0][1], label: '', @@ -204,9 +201,7 @@ export const CreateAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index d526fa0fe7d..2a5a4e7f6c7 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -52,9 +52,7 @@ describe('View API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByTestId } = renderWithTheme(); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( ariaLabel, @@ -70,8 +68,7 @@ describe('View API Token Drawer', () => { }); const { getByTestId } = renderWithTheme( - , - { flags: { parentChildAccountAccess: true } } + ); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -91,8 +88,7 @@ describe('View API Token Drawer', () => { , - { flags: { parentChildAccountAccess: true } } + /> ); for (const permissionName of basePerms) { // We only expect account to have read/write for this test @@ -117,8 +113,7 @@ describe('View API Token Drawer', () => { scopes: 'databases:read_only domains:read_write child_account:read_write events:read_write firewall:read_write images:read_write ips:read_write linodes:read_only lke:read_only longview:read_write nodebalancers:read_write object_storage:read_only stackscripts:read_write volumes:read_only vpc:read_write', })} - />, - { flags: { parentChildAccountAccess: true } } + /> ); const expectedScopeLevels = { @@ -150,29 +145,20 @@ describe('View API Token Drawer', () => { }); describe('Parent/Child: User Roles', () => { - const setupAndRender = (userType: UserType, enableFeatureFlag = true) => { + const setupAndRender = (userType: UserType) => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ user_type: userType }), }); - return renderWithTheme(, { - flags: { parentChildAccountAccess: enableFeatureFlag }, - }); + return renderWithTheme(); }; - const testChildScopeNotDisplayed = ( - userType: UserType, - enableFeatureFlag = true - ) => { - const { queryByText } = setupAndRender(userType, enableFeatureFlag); + const testChildScopeNotDisplayed = (userType: UserType) => { + const { queryByText } = setupAndRender(userType); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); }; - it('should not display the Child Account Access when feature flag is disabled', () => { - testChildScopeNotDisplayed('parent', false); - }); - it('should not display the Child Account Access scope for a user account without a parent user type', () => { testChildScopeNotDisplayed('default'); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 87aa61b8be6..94339546e54 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -7,7 +7,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useProfile } from 'src/queries/profile'; @@ -27,8 +26,6 @@ interface Props { export const ViewAPITokenDrawer = (props: Props) => { const { onClose, open, token } = props; - const flags = useFlags(); - const { data: profile } = useProfile(); const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ @@ -39,9 +36,7 @@ export const ViewAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx index ef75958b641..1018568b59f 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx @@ -86,7 +86,7 @@ const TrustedDevices = () => { return data?.data.map((device) => { return ( - + {device.user_agent} {device.last_remote_addr} diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 17a523aa797..87ad2378350 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -103,7 +103,6 @@ export const DisplaySettings = () => { marginTop: '-2px', padding: 0, }} - interactive status="help" text={tooltipIconText} /> diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index b82ba542499..974e8726b88 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -90,7 +90,7 @@ const OAuthClients = () => { } return data?.data.map(({ id, label, public: isPublic, redirect_uri }) => ( - + {label} {isPublic ? 'Public' : 'Private'} diff --git a/packages/manager/src/features/Search/ResultRow.tsx b/packages/manager/src/features/Search/ResultRow.tsx index d06ea4efd0d..0fcb85abc83 100644 --- a/packages/manager/src/features/Search/ResultRow.tsx +++ b/packages/manager/src/features/Search/ResultRow.tsx @@ -24,7 +24,7 @@ export const ResultRow = (props: ResultRowProps) => { const { result } = props; return ( - + {result.label} diff --git a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx index 0bfa67c876d..8d253c45ca3 100644 --- a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx @@ -93,7 +93,7 @@ export class StackScriptSelectionRow extends React.Component< }; return ( - + { }; return ( - + {renderLabel()} diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx index 2b787736273..61d1a9325c9 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx @@ -14,7 +14,6 @@ interface Props { isPassword?: boolean; placeholder?: string; tooltip?: JSX.Element; - tooltipInteractive?: boolean; updateFormState: (key: string, value: any) => void; value: string; } @@ -41,14 +40,7 @@ class UserDefinedText extends React.Component { }; renderPasswordField = () => { - const { - error, - field, - isOptional, - placeholder, - tooltip, - tooltipInteractive, - } = this.props; + const { error, field, isOptional, placeholder, tooltip } = this.props; return ( { password={this.props.value} placeholder={placeholder} required={!isOptional} - tooltipInteractive={tooltipInteractive} /> ); }; diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx index ab85913ec11..691a0549165 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx @@ -102,7 +102,6 @@ const renderField = ( isOptional={isOptional} isPassword={true} placeholder={isTokenPassword ? 'Enter your token' : field.example} - tooltipInteractive={isTokenPassword} updateFor={[field.label, udf_data[field.name], error]} updateFormState={handleChange} /** diff --git a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx index 276c52bf31f..031f71c2f19 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx @@ -45,7 +45,6 @@ export const TicketRow = ({ ticket }: Props) => { return ( { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Parent Company')).toBeInTheDocument(); @@ -56,9 +54,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -78,9 +74,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('child-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -103,9 +97,7 @@ describe('UserMenu', () => { }) ); - const { findByText, queryByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText, queryByText } = renderWithTheme(); expect(await findByText('regular-user')).toBeInTheDocument(); // Should not be displayed for regular users, only parent/child/proxy users. @@ -124,9 +116,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -151,9 +141,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, queryByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, queryByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -173,9 +161,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -212,9 +198,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 477e86e66af..2cfd5d58f69 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -21,7 +21,6 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; import { useGrants, useProfile } from 'src/queries/profile'; @@ -64,7 +63,6 @@ export const UserMenu = React.memo(() => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { enqueueSnackbar } = useSnackbar(); - const flags = useFlags(); const sessionContext = React.useContext(switchAccountSessionContext); const hasGrant = (grant: GlobalGrantTypes) => @@ -72,39 +70,33 @@ export const UserMenu = React.memo(() => { const isRestrictedUser = profile?.restricted ?? false; const hasAccountAccess = !isRestrictedUser || hasGrant('account_access'); const hasReadWriteAccountAccess = hasGrant('account_access') === 'read_write'; - const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); const isParentUser = profile?.user_type === 'parent'; const isProxyUser = profile?.user_type === 'proxy'; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', }); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - ((!isChildAccountAccessRestricted && isParentUser) || isProxyUser); + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; const companyNameOrEmail = getCompanyNameOrEmail({ company: account?.company, - isParentChildFeatureEnabled: hasParentChildAccountAccess, profile, }); const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); // Used for fetching parent profile and account data by making a request with the parent's token. - const proxyHeaders = - hasParentChildAccountAccess && isProxyUser - ? { - Authorization: getStorage(`authentication/parent_token/token`), - } - : undefined; + const proxyHeaders = isProxyUser + ? { + Authorization: getStorage(`authentication/parent_token/token`), + } + : undefined; const { data: parentProfile } = useProfile({ headers: proxyHeaders }); - const userName = - (hasParentChildAccountAccess && isProxyUser ? parentProfile : profile) - ?.username ?? ''; + const userName = (isProxyUser ? parentProfile : profile)?.username ?? ''; const matchesSmDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm') diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts index a1d8be27b1b..8a960cac142 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts @@ -13,7 +13,6 @@ describe('getCompanyNameOrEmail', () => { newUserTypes.forEach((newUserType: UserType) => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: newUserType }), }); const expected = MOCK_COMPANY_NAME; @@ -26,7 +25,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: parentEmail, user_type: 'parent', @@ -41,7 +39,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: childEmail, user_type: 'child', @@ -54,24 +51,9 @@ describe('getCompanyNameOrEmail', () => { it('returns undefined for the company/email of a regular (default) user', async () => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: 'default' }), }); const expected = undefined; expect(actual).toEqual(expected); }); - - it('returns undefined for the company/email of all users when the parent/child feature is not enabled', async () => { - const allUserTypes = ['parent', 'child', 'proxy', 'default']; - - allUserTypes.forEach((userType: UserType) => { - const actual = getCompanyNameOrEmail({ - company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: false, - profile: profileFactory.build({ user_type: userType }), - }); - const expected = undefined; - expect(actual).toEqual(expected); - }); - }); }); diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.ts index 54b7ca28b75..d63d4b3307f 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.ts @@ -2,7 +2,6 @@ import { Profile } from '@linode/api-v4'; export interface CompanyNameOrEmailOptions { company: string | undefined; - isParentChildFeatureEnabled: boolean; profile: Profile | undefined; } @@ -13,20 +12,16 @@ export interface CompanyNameOrEmailOptions { */ export const getCompanyNameOrEmail = ({ company, - isParentChildFeatureEnabled, profile, }: CompanyNameOrEmailOptions) => { - const isParentChildOrProxyUser = profile?.user_type !== 'default'; - const isParentUser = profile?.user_type === 'parent'; - // Return early if we do not need the company name or email. - if (!isParentChildFeatureEnabled || !profile || !isParentChildOrProxyUser) { + if (!profile || profile.user_type === 'default') { return undefined; } // For parent users lacking `account_access`: without a company name to identify an account, fall back on the email. // We do not need to do this for child users lacking `account_access` because we do not need to display the email. - if (isParentUser && !company) { + if (profile.user_type === 'parent' && !company) { return profile.email; } diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index ad12a3eb673..c32ad5c8ab9 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -46,7 +46,7 @@ import { PARENT_USER, grantTypeMap } from 'src/features/Account/constants'; import { accountQueries } from 'src/queries/account/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { StyledCircleProgress, @@ -109,10 +109,10 @@ class UserPermissions extends React.Component { const { currentUsername } = this.props; return ( - +
{loading ? : this.renderBody()} - +
); } @@ -184,6 +184,8 @@ class UserPermissions extends React.Component { } }; + formContainerRef = React.createRef(); + getTabInformation = (grants: Grants) => this.entityPerms.reduce( (acc: TabInfo, entity: GrantType) => { @@ -232,7 +234,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } }; @@ -255,7 +257,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); } } }; @@ -736,7 +738,7 @@ class UserPermissions extends React.Component { ), isSavingGlobal: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } @@ -794,7 +796,7 @@ class UserPermissions extends React.Component { ), isSavingEntity: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); }; diff --git a/packages/manager/src/features/Users/UserRow.test.tsx b/packages/manager/src/features/Users/UserRow.test.tsx index eabb65e99d4..3841f20d1d3 100644 --- a/packages/manager/src/features/Users/UserRow.test.tsx +++ b/packages/manager/src/features/Users/UserRow.test.tsx @@ -66,9 +66,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Enabled')).toBeVisible(); }); @@ -91,9 +89,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Disabled')).toBeVisible(); }); @@ -118,9 +114,7 @@ describe('UserRow', () => { ); const { queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(queryByText('Enabled')).not.toBeInTheDocument(); }); @@ -145,9 +139,7 @@ describe('UserRow', () => { ); const { findByText, queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); // Renders Username, Email, and Account Access fields for a proxy user. diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 6344bd37980..2f959791f2c 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -10,7 +10,6 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountUserGrants } from 'src/queries/account/users'; import { useProfile } from 'src/queries/profile'; import { capitalize } from 'src/utilities/capitalize'; @@ -25,18 +24,14 @@ interface Props { } export const UserRow = ({ onDelete, user }: Props) => { - const flags = useFlags(); const { data: grants } = useAccountUserGrants(user.username); const { data: profile } = useProfile(); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && user.user_type === 'proxy' - ); - const showChildAccountAccessCol = - flags.parentChildAccountAccess && profile?.user_type === 'parent'; + const isProxyUser = Boolean(user.user_type === 'proxy'); + const showChildAccountAccessCol = profile?.user_type === 'parent'; return ( - + diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index 2997ec92bb1..f927a5dfe8c 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -10,9 +10,9 @@ import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { Typography } from 'src/components/Typography'; import { PARENT_USER } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccountUsers } from 'src/queries/account/users'; import { useProfile } from 'src/queries/profile'; @@ -23,7 +23,6 @@ import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; export const UsersLanding = () => { const theme = useTheme(); @@ -32,7 +31,6 @@ export const UsersLanding = () => { ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); - const flags = useFlags(); const { data: profile } = useProfile(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); const matchesLgUp = useMediaQuery(theme.breakpoints.up('lg')); @@ -41,8 +39,7 @@ export const UsersLanding = () => { const order = useOrder(); const showProxyUserTable = - flags.parentChildAccountAccess && - (profile?.user_type === 'child' || profile?.user_type === 'proxy'); + profile?.user_type === 'child' || profile?.user_type === 'proxy'; const usersFilter: Filter = { ['+order']: order.order, @@ -67,8 +64,7 @@ export const UsersLanding = () => { error: proxyUserError, isInitialLoading: isLoadingProxyUser, } = useAccountUsers({ - enabled: - flags.parentChildAccountAccess && showProxyUserTable && !isRestrictedUser, + enabled: showProxyUserTable && !isRestrictedUser, filters: { user_type: 'proxy' }, }); @@ -77,9 +73,7 @@ export const UsersLanding = () => { }); const showChildAccountAccessCol = Boolean( - flags.parentChildAccountAccess && - profile?.user_type === 'parent' && - !isChildAccountAccessRestricted + profile?.user_type === 'parent' && !isChildAccountAccessRestricted ); // Parent/Child accounts include additional "child account access" column. diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx index 946302aa5db..795aa393d5f 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -54,7 +54,6 @@ export const AssignIPRanges = (props: Props) => { marginLeft: theme.spacing(0.5), padding: theme.spacing(0.5), }} - interactive status="help" text={IPv4RangesDescriptionJSX} /> diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 0df5ff9bd16..99d256362d6 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -1,8 +1,8 @@ import { APIError, Firewall, Linode } from '@linode/api-v4'; import { Config, Interface } from '@linode/api-v4/lib/linodes/types'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; -import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -148,7 +148,6 @@ export const SubnetLinodeRow = (props: Props) => {
} icon={} - interactive status="other" sxTooltipIcon={{ paddingLeft: 0 }} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index 690e6785fd9..cbdcf2496fd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -37,11 +37,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { ]; return ( - + {label} diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c0dceb9e76e..8cc5bb94aa7 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -76,6 +76,8 @@ import { objectStorageBucketFactory, objectStorageClusterFactory, objectStorageKeyFactory, + objectStorageTypeFactory, + objectStorageOverageTypeFactory, paymentFactory, paymentMethodFactory, placementGroupFactory, @@ -849,9 +851,14 @@ export const handlers = [ return HttpResponse.json(cluster); }), http.get('*/lke/clusters/:clusterId/pools', async () => { - const pools = nodePoolFactory.buildList(10); + const encryptedPools = nodePoolFactory.buildList(5); + const unencryptedPools = nodePoolFactory.buildList(5, { + disk_encryption: 'disabled', + }); nodePoolFactory.resetSequenceNumber(); - return HttpResponse.json(makeResourcePage(pools)); + return HttpResponse.json( + makeResourcePage([...encryptedPools, ...unencryptedPools]) + ); }), http.get('*/lke/clusters/*/api-endpoints', async () => { const endpoints = kubeEndpointFactory.buildList(2); @@ -919,6 +926,13 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(configs)); }), + http.get('*/v4/object-storage/types', () => { + const objectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + return HttpResponse.json(makeResourcePage(objectStorageTypes)); + }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); return HttpResponse.json({ diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 052a81d7989..44de89ed1a9 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -20,11 +20,17 @@ import { getClusters, getObjectList, getObjectStorageKeys, + getObjectStorageTypes, getObjectURL, getSSLCert, uploadSSLCert, } from '@linode/api-v4'; -import { APIError, Params, ResourcePage } from '@linode/api-v4/lib/types'; +import { + APIError, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; import { QueryClient, useInfiniteQuery, @@ -406,3 +412,16 @@ export const useBucketSSLDeleteMutation = (cluster: string, bucket: string) => { }, }); }; + +const getAllObjectStorageTypes = () => + getAll((params) => getObjectStorageTypes(params))().then( + (data) => data.data + ); + +export const useObjectStorageTypesQuery = (enabled = true) => + useQuery({ + queryFn: getAllObjectStorageTypes, + queryKey: [queryKey, 'types'], + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 991e605d3cd..bc0b994fe6b 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -57,8 +57,7 @@ export const sendFormEvent = ( } else if (eventType === 'formError' && 'formError' in eventPayload) { formEventPayload['formError'] = eventPayload.formError.replace(/\|/g, ''); } - - window._satellite.track(eventType, formEventPayload); + // window._satellite.track(eventType, formEventPayload); } }; diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts index 4381a738abc..56c73482029 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts @@ -75,6 +75,28 @@ describe('getDCSpecificPricingByType', () => { ).toBe('14.00'); }); + it('calculates dynamic pricing for a region without an increase on an hourly interval to the specified decimal', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'us-east', + type: mockNodeBalancerType, + }) + ).toBe('0.015'); + }); + + it('calculates dynamic pricing for a region with an increase on an hourly interval to the specified decimal', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'id-cgk', + type: mockNodeBalancerType, + }) + ).toBe('0.018'); + }); + it('calculates dynamic pricing for a volume based on size', () => { expect( getDCSpecificPriceByType({ diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index 90598a37e6f..b190b0c5d64 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -21,6 +21,16 @@ export interface DataCenterPricingOptions { } export interface DataCenterPricingByTypeOptions { + /** + * The number of decimal places to return for the price. + * @default 2 + */ + decimalPrecision?: number; + /** + * The time period for which to find pricing data for (hourly or monthly). + * @default monthly + */ + interval?: 'hourly' | 'monthly'; /** * The `id` of the region we intended to get the price for. * @example us-east @@ -94,6 +104,8 @@ export const getDCSpecificPrice = ({ * @returns a data center specific price or undefined if this cannot be calculated */ export const getDCSpecificPriceByType = ({ + decimalPrecision = 2, + interval = 'monthly', regionId, size, type, @@ -101,19 +113,18 @@ export const getDCSpecificPriceByType = ({ if (!regionId || !type) { return undefined; } - // Apply the DC-specific price if it exists; otherwise, use the base price. const price = type.region_prices.find((region_price: RegionPrice) => { return region_price.id === regionId; - })?.monthly ?? type.price.monthly; + })?.[interval] ?? type.price?.[interval]; // If pricing is determined by size of the entity if (size && price) { - return (size * price).toFixed(2); + return (size * price).toFixed(decimalPrecision); } - return price?.toFixed(2) ?? undefined; + return price?.toFixed(decimalPrecision) ?? undefined; }; export const renderMonthlyPriceToCorrectDecimalPlace = ( diff --git a/packages/manager/src/utilities/scrollErrorIntoView.ts b/packages/manager/src/utilities/scrollErrorIntoView.ts index dc4bd139771..3ea821ac59e 100644 --- a/packages/manager/src/utilities/scrollErrorIntoView.ts +++ b/packages/manager/src/utilities/scrollErrorIntoView.ts @@ -1,3 +1,7 @@ +/** + * @deprecated + * Use `scrollErrorIntoViewV2` instead. + */ export const scrollErrorIntoView = ( errorGroup?: string, options?: ScrollIntoViewOptions diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx new file mode 100644 index 00000000000..2797d737b59 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx @@ -0,0 +1,45 @@ +import { scrollErrorIntoViewV2 } from './scrollErrorIntoViewV2'; + +import type { Mock } from 'vitest'; + +describe('scrollErrorIntoViewV2', () => { + it('should scroll to the error element when it exists', () => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + const errorElement = document.createElement('div'); + errorElement.classList.add('error-for-scroll'); + const formContainer = document.createElement('div'); + formContainer.appendChild(errorElement); + + const formContainerRef = { + current: formContainer, + }; + + const observeMock = vi.fn(); + const disconnectMock = vi.fn(); + const takeRecords = vi.fn(); + window.MutationObserver = vi.fn(() => ({ + disconnect: disconnectMock, + observe: observeMock, + takeRecords, + })); + + scrollErrorIntoViewV2(formContainerRef); + + expect(observeMock).toHaveBeenCalledWith(formContainer, { + attributes: true, + childList: true, + subtree: true, + }); + + const mutationCallback = (window.MutationObserver as Mock).mock.calls[0][0]; + mutationCallback([{ target: formContainer, type: 'childList' }]); + + expect(errorElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + expect(disconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.ts b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts new file mode 100644 index 00000000000..25c3250f041 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts @@ -0,0 +1,47 @@ +/** + * This utility is the version 2 of the scrollErrorIntoView utility. + * It should be the preferred utility in formik forms. + * It uses a MutationObserver to solve the issue of the form not always being + * fully rendered when the scrollErrorIntoView function is called, resulting in + * some instances in the error not being scrolled into view. + * + * If there are multiple form errors, the first one will be scrolled into view. + * + * @param formContainerRef A React ref to the form element (or a form container since we're not always semantically aligned on form markup) that contains a potential field error. + */ +export const scrollErrorIntoViewV2 = ( + formContainerRef: React.RefObject +) => { + if (!formContainerRef.current) { + return; + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + (mutation.type === 'childList' || mutation.type === 'attributes') && + formContainerRef.current + ) { + const errorElement = formContainerRef.current.querySelector( + '[class*="error-for-scroll"]' + ); + if (errorElement) { + errorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + observer.disconnect(); + } + } + }); + }); + + observer.observe(formContainerRef.current, { + attributes: true, + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); +}; diff --git a/packages/validation/.changeset/pr-10462-changed-1715896899402.md b/packages/validation/.changeset/pr-10462-changed-1715896899402.md new file mode 100644 index 00000000000..d925f5b751b --- /dev/null +++ b/packages/validation/.changeset/pr-10462-changed-1715896899402.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index f88834a0dd5..6553093ac9f 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -274,12 +274,10 @@ const PlacementGroupPayloadSchema = object({ id: number().notRequired().nullable(true), }); -const DiskEncryptionSchema = object({ - disk_encryption: string() - .oneOf(['enabled', 'disabled']) - .nullable() - .notRequired(), -}); +const DiskEncryptionSchema = string() + .oneOf(['enabled', 'disabled']) + .notRequired() + .nullable(true); export const CreateLinodeSchema = object({ type: string().ensure().required('Plan is required.'), diff --git a/scripts/tod-payload/index.ts b/scripts/tod-payload/index.ts new file mode 100644 index 00000000000..7a337f4428e --- /dev/null +++ b/scripts/tod-payload/index.ts @@ -0,0 +1,67 @@ +/** + * @file Script to generate a TOD test results payload given a path containing JUnit XML files. + */ + +import { program } from 'commander'; +import * as fs from 'fs/promises'; +import { resolve } from 'path'; + +program + .name('tod-payload') + .description('Output TOD test result payload') + .version('0.1.0') + .arguments('') + .option('-n, --appName ', 'Application name') + .option('-b, --appBuild ', 'Application build identifier') + .option('-u, --appBuildUrl ', 'Application build URL') + .option('-v, --appVersion ', 'Application version') + .option('-t, --appTeam ', 'Application team name') + .option('-f, --fail', 'Treat payload as failure') + .option('-t, --tag ', 'Optional tag for run') + + .action((junitPath: string) => { + return main(junitPath); + }); + +const main = async (junitPath: string) => { + const resolvedJunitPath = resolve(junitPath); + + // Create an array of absolute file paths to JUnit XML report files. + // Account for cases where `resolvedJunitPath` is a path to a directory + // or a path to an individual JUnit file. + const junitFiles = await (async () => { + const stats = await fs.lstat(resolvedJunitPath); + if (stats.isDirectory()) { + return (await fs.readdir(resolvedJunitPath)) + .filter((dirItem: string) => { + return dirItem.endsWith('.xml') + }) + .map((dirItem: string) => { + return resolve(resolvedJunitPath, dirItem); + }); + } + return [resolvedJunitPath]; + })(); + + // Read all of the JUnit files. + const junitContents = await Promise.all(junitFiles.map((junitFile) => { + return fs.readFile(junitFile, 'utf8'); + })); + + const payload = JSON.stringify({ + team: program.opts()['appTeam'], + name: program.opts()['appName'], + buildName: program.opts()['appBuild'], + semanticVersion: program.opts()['appVersion'], + buildUrl: program.opts()['appBuildUrl'], + pass: !program.opts()['fail'], + tag: !!program.opts()['tag'] ? program.opts()['tag'] : undefined, + xunitResults: junitContents.map((junitContent) => { + return btoa(junitContent); + }), + }); + + console.log(payload); +}; + +program.parse(process.argv);