diff --git a/packages/api-v4/.changeset/pr-11040-changed-1727911382574.md b/packages/api-v4/.changeset/pr-11040-changed-1727911382574.md new file mode 100644 index 00000000000..de7ef295625 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11040-changed-1727911382574.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Databases types to have UpdateDatabasePayload include cluster_size and export the Engines type ([#11040](https://github.com/linode/manager/pull/11040)) diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index feb7987fde2..686c0b50fbd 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -12,7 +12,7 @@ export interface DatabaseClusterSizeObject { price: DatabasePriceObject; } -type Engines = Record; +export type Engines = Record; export interface DatabaseType extends BaseType { class: DatabaseTypeClass; engines: Engines; @@ -197,6 +197,7 @@ export type Database = BaseDatabase & Partial; export interface UpdateDatabasePayload { + cluster_size?: number; label?: string; allow_list?: string[]; updates?: UpdatesSchedule; diff --git a/packages/manager/.changeset/pr-11040-added-1727911099315.md b/packages/manager/.changeset/pr-11040-added-1727911099315.md new file mode 100644 index 00000000000..188deb77757 --- /dev/null +++ b/packages/manager/.changeset/pr-11040-added-1727911099315.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Number of Nodes selector for DBaaS GA Resize ([#11040](https://github.com/linode/manager/pull/11040)) diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts index 1dab1c0e0ad..fb2d9123c87 100644 --- a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -149,7 +149,7 @@ describe('Resizing existing clusters', () => { if (!desiredPlanPrice) { throw new Error('Unable to find mock plan type'); } - cy.get('[data-testid="summary"]').within(() => { + cy.get('[data-testid="resizeSummary"]').within(() => { cy.contains(`${nodeType.label}`).should('be.visible'); cy.contains(`$${desiredPlanPrice.monthly}/month`).should( 'be.visible' diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index ab8868f93f8..a810aac6ad8 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -188,7 +188,7 @@ const getEngineOptions = (engines: DatabaseEngine[]) => { ); }; -interface NodePricing { +export interface NodePricing { double: DatabasePriceObject | undefined; multi: DatabasePriceObject | undefined; single: DatabasePriceObject | undefined; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx index 0362763350c..3f3b2d46e57 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.test.tsx @@ -1,5 +1,4 @@ import { - fireEvent, queryByAttribute, waitForElementToBeRemoved, } from '@testing-library/react'; @@ -7,12 +6,17 @@ import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Router } from 'react-router-dom'; -import { databaseFactory, databaseTypeFactory } from 'src/factories'; +import { + accountFactory, + databaseFactory, + databaseTypeFactory, +} from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import { DatabaseResize } from './DatabaseResize'; +import userEvent from '@testing-library/user-event'; const loadingTestId = 'circle-progress'; @@ -25,6 +29,29 @@ describe('database resize', () => { }); it('should render a loading state', async () => { + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-nanode-1', + label: `Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + + server.use( + http.get('*/databases/types', () => { + return HttpResponse.json( + makeResourcePage([...standardTypes, ...dedicatedTypes]) + ); + }), + http.get('*/account', () => { + const account = accountFactory.build(); + return HttpResponse.json(account); + }) + ); + const { getByTestId } = renderWithTheme( ); @@ -49,6 +76,10 @@ describe('database resize', () => { return HttpResponse.json( makeResourcePage([...standardTypes, ...dedicatedTypes]) ); + }), + http.get('*/account', () => { + const account = accountFactory.build(); + return HttpResponse.json(account); }) ); @@ -86,6 +117,10 @@ describe('database resize', () => { return HttpResponse.json( makeResourcePage([...standardTypes, ...dedicatedTypes]) ); + }), + http.get('*/account', () => { + const account = accountFactory.build(); + return HttpResponse.json(account); }) ); }); @@ -111,13 +146,13 @@ describe('database resize', () => { ); await waitForElementToBeRemoved(getByTestId(loadingTestId)); const getById = queryByAttribute.bind(null, 'id'); - fireEvent.click(getById(container, examplePlanType)); + await userEvent.click(getById(container, examplePlanType)); const resizeButton = getByText(/Resize Database Cluster/i); expect(resizeButton.closest('button')).toHaveAttribute( 'aria-disabled', 'false' ); - fireEvent.click(resizeButton); + await userEvent.click(resizeButton); getByText(`Resize Database Cluster ${database.label}?`); }); @@ -133,6 +168,308 @@ describe('database resize', () => { }); }); + describe('on rendering of page and isDatabasesGAEnabled is true and the Shared CPU tab is preselected ', () => { + beforeEach(() => { + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-nanode-1', + label: `New DBaaS - Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + const mockDedicatedTypes = [ + databaseTypeFactory.build({ + class: 'dedicated', + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + }), + ]; + + server.use( + http.get('*/databases/types', () => { + return HttpResponse.json( + makeResourcePage([...mockDedicatedTypes, ...standardTypes]) + ); + }), + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases', 'Managed Databases Beta'], + }); + return HttpResponse.json(account); + }) + ); + }); + + it('should render set node section', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 3, + type: 'g6-nanode-1', + engine: 'mysql', + }); + const { getByTestId, getByText } = renderWithTheme( + , + { flags } + ); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + expect(getByText('Set Number of Nodes')).toBeDefined(); + expect( + getByText('Please select a plan or set the number of nodes.') + ).toBeDefined(); + }); + + it('should render the correct number of node radio buttons, associated costs, and summary', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 3, + type: 'g6-nanode-1', + }); + const { getByTestId } = renderWithTheme( + , + { flags } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const nodeRadioBtns = getByTestId('database-nodes'); + expect(nodeRadioBtns.children.length).toBe(2); + expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); + expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); + + const expectedCurrentSummary = + 'Current Cluster: New DBaaS - Nanode 1 GB $60/month 3 Nodes - HA $140/month'; + const currentSummary = getByTestId('currentSummary'); + expect(currentSummary).toHaveTextContent(expectedCurrentSummary); + + const expectedResizeSummary = + 'Resized Cluster: Please select a plan or set the number of nodes.'; + const resizeSummary = getByTestId('resizeSummary'); + expect(resizeSummary).toHaveTextContent(expectedResizeSummary); + }); + + it('should preselect cluster size in Set Number of Nodes', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 3, + type: 'g6-nanode-1', + }); + const { getByTestId } = renderWithTheme( + , + { flags } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const selectedNodeRadioButton = getByTestId( + `database-node-${mockDatabase.cluster_size}` + ).children[0].children[0] as HTMLInputElement; + expect(selectedNodeRadioButton).toBeChecked(); + }); + + it('should disable visible lower node selections', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 3, + type: 'g6-nanode-1', + }); + const { getByTestId } = renderWithTheme( + , + { flags } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const selectedNodeRadioButton = getByTestId('database-node-1').children[0] + .children[0] as HTMLInputElement; + expect(selectedNodeRadioButton).toBeDisabled(); + }); + + it('should set price, enable resize button, and update resize summary when a new number of nodes is selected', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 1, + type: 'g6-nanode-1', + }); + const { getByTestId, getByText } = renderWithTheme( + , + { flags } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + // Mock clicking 3 Nodes option + const selectedNodeRadioButton = getByTestId('database-node-3').children[0] + .children[0] as HTMLInputElement; + await userEvent.click(selectedNodeRadioButton); + const resizeButton = getByText(/Resize Database Cluster/i).closest( + 'button' + ) as HTMLButtonElement; + expect(resizeButton.disabled).toBeFalsy(); + + const expectedSummaryText = + 'Resized Cluster: New DBaaS - Nanode 1 GB $60/month 3 Nodes - HA $140/month'; + const summary = getByTestId('resizeSummary'); + expect(summary).toHaveTextContent(expectedSummaryText); + }); + + it('should disable the resize button if node selection is set back to current', async () => { + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + const mockDatabase = databaseFactory.build({ + cluster_size: 1, + type: 'g6-nanode-1', + }); + const { getByTestId, getByText } = renderWithTheme( + , + { flags } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + // Mock clicking 3 Nodes option + const threeNodesRadioButton = getByTestId('database-node-3').children[0] + .children[0] as HTMLInputElement; + await userEvent.click(threeNodesRadioButton); + const resizeButton = getByText(/Resize Database Cluster/i).closest( + 'button' + ); + expect(resizeButton).toBeEnabled(); + // Mock clicking 1 Node option + const oneNodeRadioButton = getByTestId('database-node-1').children[0] + .children[0] as HTMLInputElement; + await userEvent.click(oneNodeRadioButton); + expect(resizeButton).toBeDisabled(); + }); + }); + + describe('on rendering of page and isDatabasesGAEnabled is true and the Dedicated CPU tab is preselected', () => { + beforeEach(() => { + // Mock database types + const mockDedicatedTypes = [ + databaseTypeFactory.build({ + class: 'dedicated', + disk: 81920, + id: 'g6-dedicated-2', + label: 'Dedicated 4 GB', + memory: 4096, + }), + databaseTypeFactory.build({ + class: 'dedicated', + disk: 163840, + id: 'g6-dedicated-4', + label: 'Dedicated 8 GB', + memory: 8192, + }), + ]; + + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-nanode-1', + label: `New DBaaS - Nanode 1 GB`, + memory: 1024, + }), + ]; + + server.use( + http.get('*/databases/types', () => { + return HttpResponse.json( + makeResourcePage([...mockDedicatedTypes, ...standardTypes]) + ); + }), + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases', 'Managed Databases Beta'], + }); + return HttpResponse.json(account); + }) + ); + }); + + it('should render node selection for dedicated tab with default summary', async () => { + const mockDatabase = databaseFactory.build({ + type: 'g6-dedicated-2', + cluster_size: 3, + }); + + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + + const { getByTestId } = renderWithTheme( + , + { flags } + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByTestId('database-nodes')).toBeDefined(); + expect(getByTestId('database-node-1')).toBeDefined(); + expect(getByTestId('database-node-2')).toBeDefined(); + expect(getByTestId('database-node-3')).toBeDefined(); + }); + + it('should disable lower node selections', async () => { + const mockDatabase = databaseFactory.build({ + type: 'g6-dedicated-2', + cluster_size: 3, + }); + + const flags = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + + const { getByTestId } = renderWithTheme( + , + { flags } + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + getByTestId('database-node-1').children[0].children[0] + ).toBeDisabled(); + expect( + getByTestId('database-node-2').children[0].children[0] + ).toBeDisabled(); + expect( + getByTestId('database-node-3').children[0].children[0] + ).toBeEnabled(); + }); + }); + describe('should be disabled smaller plans', () => { const database = databaseFactory.build({ type: 'g6-dedicated-8', @@ -165,6 +502,10 @@ describe('database resize', () => { server.use( http.get('*/databases/types', () => { return HttpResponse.json(makeResourcePage([...dedicatedTypes])); + }), + http.get('*/account', () => { + const account = accountFactory.build(); + return HttpResponse.json(account); }) ); const { getByTestId } = renderWithTheme( @@ -184,6 +525,26 @@ describe('database resize', () => { type: 'g6-dedicated-8', }); it('should disable Shared Plans Tab', async () => { + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-nanode-1', + label: `Nanode 1 GB`, + memory: 1024, + }), + ]; + server.use( + http.get('*/databases/types', () => { + return HttpResponse.json( + makeResourcePage([...dedicatedTypes, ...standardTypes]) + ); + }), + http.get('*/account', () => { + const account = accountFactory.build(); + return HttpResponse.json(account); + }) + ); + const { getByTestId, getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index 79a566b77a7..ffe15698e5a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -23,13 +23,45 @@ import { import { DatabaseResizeCurrentConfiguration } from './DatabaseResizeCurrentConfiguration'; import type { + ClusterSize, Database, DatabaseClusterSizeObject, DatabasePriceObject, DatabaseType, Engine, + UpdateDatabasePayload, } from '@linode/api-v4'; -import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; +import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; +import { determineInitialPlanCategoryTab } from 'src/features/components/PlansPanel/utils'; +import { NodePricing } from '../../DatabaseCreate/DatabaseCreate'; +import { useIsDatabasesEnabled } from '../../utilities'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Radio } from 'src/components/Radio/Radio'; +import { Divider } from 'src/components/Divider'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { StyledChip } from 'src/features/components/PlansPanel/PlanSelection.styles'; +import { makeStyles } from 'tss-react/mui'; +import { Theme } from '@mui/material/styles'; + +const useStyles = makeStyles()((theme: Theme) => ({ + formControlLabel: { + marginBottom: theme.spacing(), + }, + disabledOptionLabel: { + color: + theme.palette.mode === 'dark' ? theme.color.grey6 : theme.color.grey1, + }, + summarySpanBorder: { + borderRight: `1px solid ${theme.borderColors.borderTypography}`, + color: theme.textColors.tableStatic, + paddingRight: theme.spacing(1), + marginRight: theme.spacing(1), + marginLeft: theme.spacing(1), + }, + nodeSpanSpacing: { + marginRight: theme.spacing(1), + }, +})); interface Props { database: Database; @@ -37,14 +69,21 @@ interface Props { } export const DatabaseResize = ({ database, disabled = false }: Props) => { + const { classes } = useStyles(); const history = useHistory(); - const [planSelected, setPlanSelected] = React.useState(); + const [planSelected, setPlanSelected] = React.useState( + database.type + ); const [summaryText, setSummaryText] = React.useState<{ - numberOfNodes: number; + numberOfNodes: ClusterSize; plan: string; price: string; + basePrice: string; }>(); + const [nodePricing, setNodePricing] = React.useState< + NodePricing | undefined + >(); // This will be set to `false` once one of the configuration is selected from available plan. This is used to disable the // "Resize" button unless there have been changes to the form. const [ @@ -57,6 +96,15 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { setIsResizeConfirmationDialogOpen, ] = React.useState(false); + const [selectedTab, setSelectedTab] = React.useState(0); + const { + isDatabasesV2Enabled, + isDatabasesGAEnabled, + } = useIsDatabasesEnabled(); + const [clusterSize, setClusterSize] = React.useState( + database.cluster_size + ); + const { error: resizeError, isPending: submitInProgress, @@ -72,9 +120,21 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { const { enqueueSnackbar } = useSnackbar(); const onResize = () => { - updateDatabase({ - type: planSelected, - }).then(() => { + const payload: UpdateDatabasePayload = {}; + + if ( + clusterSize && + clusterSize > database.cluster_size && + isDatabasesGAEnabled + ) { + payload.cluster_size = clusterSize; + } + + if (planSelected) { + payload.type = planSelected; + } + + updateDatabase(payload).then(() => { enqueueSnackbar(`Database cluster ${database.label} is being resized.`, { variant: 'info', }); @@ -92,20 +152,44 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ); - const summaryPanel = ( + const resizeSummary = ( <> - Summary ({ marginTop: theme.spacing(2), })} - data-testid="summary" + data-testid="resizeSummary" > {summaryText ? ( <> - {summaryText.plan}{' '} - {summaryText.numberOfNodes} Node - {summaryText.numberOfNodes > 1 ? 's' : ''}: {summaryText.price} + + {isDatabasesGAEnabled + ? 'Resized Cluster: ' + summaryText.plan + : summaryText.plan} + {' '} + {isDatabasesGAEnabled ? ( + + {summaryText.basePrice} + + ) : null} + + {' '} + {summaryText.numberOfNodes} Node + {summaryText.numberOfNodes > 1 ? 's' : ''} + {!isDatabasesGAEnabled ? ': ' : ' - HA '} + + {summaryText.price} + + ) : isDatabasesGAEnabled ? ( + <> + Resized Cluster:{' '} + Please select a plan or set the number of nodes. ) : ( 'Please select a plan.' @@ -136,45 +220,94 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ); - React.useEffect(() => { - if (!planSelected || !dbTypes) { - return; - } - + const setSummaryAndPrices = ( + databaseTypeId: string, + engine: Engine, + dbTypes: DatabaseType[] + ) => { const selectedPlanType = dbTypes.find( - (type: DatabaseType) => type.id === planSelected + (type: DatabaseType) => type.id === databaseTypeId ); - if (!selectedPlanType) { + if (selectedPlanType) { + // When plan is found, set node pricing + const nodePricingDetails = { + double: selectedPlanType.engines[engine]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 2 + )?.price, + multi: selectedPlanType.engines[engine]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 3 + )?.price, + single: selectedPlanType.engines[engine]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 1 + )?.price, + }; + setNodePricing(nodePricingDetails); + } else { + // If plan is not found, clear plan selection setPlanSelected(undefined); + } + + if (!selectedPlanType || !clusterSize) { setSummaryText(undefined); setShouldSubmitBeDisabled(true); return; } - const engineType = database.engine.split('/')[0] as Engine; - const price = selectedPlanType.engines[engineType].find( - (cluster: DatabaseClusterSizeObject) => - cluster.quantity === database.cluster_size + const price = selectedPlanType.engines[engine].find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === clusterSize )?.price as DatabasePriceObject; - - setShouldSubmitBeDisabled(false); + const resizeBasePrice = selectedPlanType.engines[engine][0] + .price as DatabasePriceObject; + const currentPlanPrice = `$${resizeBasePrice?.monthly}/month`; setSummaryText({ - numberOfNodes: database.cluster_size, + numberOfNodes: clusterSize, plan: formatStorageUnits(selectedPlanType.label), - price: `$${price?.monthly}/month or $${price?.hourly}/hour`, + price: isDatabasesGAEnabled + ? `$${price?.monthly}/month` + : `$${price?.monthly}/month or $${price?.hourly}/hour`, + basePrice: currentPlanPrice, }); + + setShouldSubmitBeDisabled(false); + return; + }; + + React.useEffect(() => { + const nodeSelected = clusterSize && clusterSize > database.cluster_size; + const isSamePlanSelected = planSelected === database.type; + if (!dbTypes) { + return; + } + // Set default message and disable submit when no new selection is made + if (!nodeSelected && (!planSelected || isSamePlanSelected)) { + setShouldSubmitBeDisabled(true); + setSummaryText(undefined); + return; + } + const engineType = database.engine.split('/')[0] as Engine; + // When only a higher node selection is made and plan has not been changed + if (isDatabasesGAEnabled && nodeSelected && isSamePlanSelected) { + setSummaryAndPrices(database.type, engineType, dbTypes); + } + // No plan selection or plan selection is unchanged + if (!planSelected || isSamePlanSelected) { + return; + } + // When a new plan is selected + setSummaryAndPrices(planSelected, engineType, dbTypes); }, [ dbTypes, database.engine, database.type, planSelected, database.cluster_size, + clusterSize, ]); const selectedEngine = database.engine.split('/')[0] as Engine; - const displayTypes: PlanSelectionType[] = React.useMemo(() => { + const displayTypes: PlanSelectionWithDatabaseType[] = React.useMemo(() => { if (!dbTypes) { return []; } @@ -210,9 +343,170 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { ? type.disk < currentPlanDisk : type.disk <= currentPlanDisk ); + const currentEngine = database.engine.split('/')[0] as Engine; + const currentPrice = currentPlan?.engines[currentEngine].find( + (cluster: DatabaseClusterSizeObject) => + cluster.quantity === database.cluster_size + )?.price as DatabasePriceObject; + const currentBasePrice = currentPlan?.engines[currentEngine][0] + .price as DatabasePriceObject; + const currentNodePrice = `$${currentPrice?.monthly}/month`; + const currentPlanPrice = `$${currentBasePrice?.monthly}/month`; + const currentSummary = ( + + + Current Cluster: {currentPlan?.heading} + {' '} + + {currentPlanPrice} + + + {' '} + {database.cluster_size} Node + {database.cluster_size > 1 ? 's - HA ' : ' '} + + {currentNodePrice} + + ); const isDisabledSharedTab = database.cluster_size === 2; + React.useEffect(() => { + const initialTab = determineInitialPlanCategoryTab( + displayTypes, + planSelected, + currentPlan?.heading + ); + setSelectedTab(initialTab); + + if (isDatabasesGAEnabled) { + const engineType = database.engine.split('/')[0] as Engine; + const nodePricingDetails = { + double: currentPlan?.engines[engineType]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 2 + )?.price, + multi: currentPlan?.engines[engineType]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 3 + )?.price, + single: currentPlan?.engines[engineType]?.find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 1 + )?.price, + }; + setNodePricing(nodePricingDetails); + } + }, [dbTypes, displayTypes]); + + const handleNodeChange = ( + event: React.ChangeEvent + ): void => { + const size = Number(event.currentTarget.value) as ClusterSize; + const selectedPlanTab = determineInitialPlanCategoryTab( + displayTypes, + planSelected + ); + // If 2 Nodes is selected for an incompatible plan, clear selected plan and related information + if (size === 2 && selectedPlanTab !== 0) { + setNodePricing(undefined); + setPlanSelected(undefined); + setSummaryText(undefined); + } + setClusterSize(size); + }; + + const handleTabChange = (index: number) => { + if (selectedTab === index) { + return; + } + // Clear plan and related info when when 2 nodes option is selected for incompatible plan. + if (isDatabasesGAEnabled && selectedTab === 0 && clusterSize === 2) { + setClusterSize(undefined); + setPlanSelected(undefined); + setNodePricing(undefined); + setSummaryText(undefined); + } + setSelectedTab(index); + }; + + const nodeOptions = React.useMemo(() => { + const hasDedicated = displayTypes.some( + (type) => type.class === 'dedicated' + ); + + const currentChip = ( + + ); + + const isDisabled = (nodeSize: ClusterSize) => { + return nodeSize < database.cluster_size; + }; + + const options = [ + { + label: ( + + 1 Node {` `} + {database.cluster_size === 1 && currentChip} +
+ + {`$${nodePricing?.single?.monthly || 0}/month $${ + nodePricing?.single?.hourly || 0 + }/hr`} + +
+ ), + value: 1, + }, + ]; + + if (hasDedicated && selectedTab === 0 && isDatabasesV2Enabled) { + options.push({ + label: ( + + 2 Nodes - High Availability + {database.cluster_size === 2 && currentChip} +
+ + {`$${nodePricing?.double?.monthly || 0}/month $${ + nodePricing?.double?.hourly || 0 + }/hr`} + +
+ ), + value: 2, + }); + } + + options.push({ + label: ( + + 3 Nodes - High Availability (recommended) + {database.cluster_size === 3 && currentChip} +
+ + {`$${nodePricing?.multi?.monthly || 0}/month $${ + nodePricing?.multi?.hourly || 0 + }/hr`} + +
+ ), + value: 3, + }); + + return options; + }, [selectedTab, nodePricing, displayTypes, isDatabasesV2Enabled]); + if (typesLoading) { return ; } @@ -237,12 +531,57 @@ export const DatabaseResize = ({ database, disabled = false }: Props) => { disabledTabs={isDisabledSharedTab ? ['shared'] : []} header="Choose a Plan" onSelect={(selected: string) => setPlanSelected(selected)} + handleTabChange={handleTabChange} selectedId={planSelected} tabDisabledMessage="Resizing a 2-nodes cluster is only allowed with Dedicated plans." types={displayTypes} /> + {isDatabasesGAEnabled && ( + <> + + + + Set Number of Nodes{' '} + + + We recommend 3 nodes in a database cluster to avoid downtime + during upgrades and maintenance. + + + + {nodeOptions.map((nodeOption) => ( + } + data-testid={`database-node-${nodeOption.value}`} + data-qa-radio={nodeOption.label} + key={nodeOption.value} + label={nodeOption.label} + value={nodeOption.value} + disabled={nodeOption.value < database.cluster_size} + /> + ))} + + + )} + + + ({ + marginBottom: isDatabasesGAEnabled ? theme.spacing(2) : 0, + })} + variant="h2" + > + Summary {isDatabasesGAEnabled ? database.label : ''} + + {isDatabasesGAEnabled && currentPlan ? currentSummary : null} + {resizeSummary} - {summaryPanel} { diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 7272b939185..2842a0d96a5 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -60,8 +60,12 @@ export const useIsDatabasesEnabled = () => { account?.capabilities ?? [] ); + const isDatabasesGA = + flags.dbaasV2?.enabled && flags.dbaasV2.beta === false; + return { isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled, + isDatabasesGAEnabled: isDatabasesV1Enabled && isDatabasesGA, isDatabasesV1Enabled, isDatabasesV2Beta: isDatabasesV2Enabled && flags.dbaasV2?.beta, isDatabasesV2Enabled, diff --git a/packages/manager/src/features/components/PlansPanel/types.ts b/packages/manager/src/features/components/PlansPanel/types.ts index f29ef849c0a..6e96161c110 100644 --- a/packages/manager/src/features/components/PlansPanel/types.ts +++ b/packages/manager/src/features/components/PlansPanel/types.ts @@ -1,6 +1,10 @@ -import type { BaseType, RegionPriceObject } from '@linode/api-v4'; +import type { BaseType, Engines, RegionPriceObject } from '@linode/api-v4'; import type { ExtendedType } from 'src/utilities/extendType'; +export interface PlanSelectionWithDatabaseType extends PlanSelectionType { + engines: Engines; +} + export interface PlanSelectionType extends BaseType { class: ExtendedType['class']; formattedLabel: ExtendedType['formattedLabel'];