From 14e6d00d2a476075ef4565fb2fbbf18051be7b27 Mon Sep 17 00:00:00 2001 From: grevanak-akamai <145482092+grevanak-akamai@users.noreply.github.com> Date: Thu, 11 Jan 2024 20:23:06 +0530 Subject: [PATCH] feat: [UIE-6937] - Add the ability to scale up DBaaS (#9869) * feat: [UIE-6937] - Add the ability to scale DBaaS * Added changeset: Add the ability to scale up DBaaS * Addressed review comments * Show confirmation popup message conditionally based on node size of cluster * Addressed review comments * Fixed issue with unit test case and addressed review comments * Update logic to disable smaller plans in plan selection table * UIE-7092: Added feature flag, disable tabs other than active one and addressed review comments * Addressed review comments * Addressed review comments * Fix unit test --------- Co-authored-by: Banks Nussman --- .../.changeset/pr-9869-added-1703002185530.md | 5 + packages/api-v4/src/account/types.ts | 1 + packages/api-v4/src/databases/databases.ts | 2 +- packages/api-v4/src/databases/types.ts | 1 + .../.changeset/pr-9869-added-1699013625860.md | 5 + .../components/TabbedPanel/TabbedPanel.tsx | 23 +- packages/manager/src/featureFlags.ts | 1 + .../DatabaseScaleUp/DatabaseScaleUp.style.ts | 41 +++ .../DatabaseScaleUp/DatabaseScaleUp.test.tsx | 141 +++++++++ .../DatabaseScaleUp/DatabaseScaleUp.tsx | 289 ++++++++++++++++++ ...tabaseScaleUpCurrentConfiguration.style.ts | 54 ++++ ...tabaseScaleUpCurrentConfiguration.test.tsx | 82 +++++ .../DatabaseScaleUpCurrentConfiguration.tsx | 153 ++++++++++ .../Databases/DatabaseDetail/index.tsx | 19 ++ .../manager/src/features/Events/constants.ts | 1 + .../features/Events/eventMessageGenerator.ts | 13 + .../components/PlansPanel/PlanContainer.tsx | 7 +- .../components/PlansPanel/PlanSelection.tsx | 21 +- .../components/PlansPanel/PlansPanel.tsx | 4 + .../pr-9869-changed-1703002209433.md | 5 + packages/validation/src/databases.schema.ts | 3 +- 21 files changed, 862 insertions(+), 9 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-9869-added-1703002185530.md create mode 100644 packages/manager/.changeset/pr-9869-added-1699013625860.md create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.style.ts create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.style.ts create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.test.tsx create mode 100644 packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.tsx create mode 100644 packages/validation/.changeset/pr-9869-changed-1703002209433.md diff --git a/packages/api-v4/.changeset/pr-9869-added-1703002185530.md b/packages/api-v4/.changeset/pr-9869-added-1703002185530.md new file mode 100644 index 00000000000..a8a8f54549b --- /dev/null +++ b/packages/api-v4/.changeset/pr-9869-added-1703002185530.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Ability to scale up Database instances ([#9869](https://github.com/linode/manager/pull/9869)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index a08f586db08..20fff74a7e2 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -285,6 +285,7 @@ export type EventAction = | 'community_question_reply' | 'credit_card_updated' | 'database_low_disk_space' + | 'database_scale' | 'database_backup_restore' | 'database_create' | 'database_credentials_reset' diff --git a/packages/api-v4/src/databases/databases.ts b/packages/api-v4/src/databases/databases.ts index 8e93a666a42..255d05138c7 100644 --- a/packages/api-v4/src/databases/databases.ts +++ b/packages/api-v4/src/databases/databases.ts @@ -144,7 +144,7 @@ export const getEngineDatabase = (engine: Engine, databaseID: number) => /** * updateDatabase * - * Update the label or allowed IPs of an + * Update the label or allowed IPs or plan of an * existing database * */ diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index e764162e8dd..3dd34f63984 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -179,6 +179,7 @@ export interface UpdateDatabasePayload { label?: string; allow_list?: string[]; updates?: UpdatesSchedule; + type?: string; } export interface UpdateDatabaseResponse { diff --git a/packages/manager/.changeset/pr-9869-added-1699013625860.md b/packages/manager/.changeset/pr-9869-added-1699013625860.md new file mode 100644 index 00000000000..a884babacbe --- /dev/null +++ b/packages/manager/.changeset/pr-9869-added-1699013625860.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Ability to scale up Database instances ([#9869](https://github.com/linode/manager/pull/9869)) diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 9b670945804..23bc8782e0a 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -1,3 +1,4 @@ +import HelpOutline from '@mui/icons-material/HelpOutline'; import { styled } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import React, { useEffect, useState } from 'react'; @@ -9,11 +10,13 @@ import { TabList } from 'src/components/Tabs/TabList'; import { TabPanel } from 'src/components/Tabs/TabPanel'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { Box } from '../Box'; export interface Tab { + disabled?: boolean; render: (props: any) => JSX.Element | null; title: string; } @@ -31,6 +34,7 @@ interface TabbedPanelProps { noPadding?: boolean; rootClass?: string; sx?: SxProps; + tabDisabledMessage?: string; tabs: Tab[]; value?: number; } @@ -52,6 +56,13 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { const [tabIndex, setTabIndex] = useState(initTab); + const sxHelpIcon = { + height: 20, + m: 0.5, + verticalAlign: 'sub', + width: 20, + }; + const tabChangeHandler = (index: number) => { setTabIndex(index); if (handleTabChange) { @@ -89,8 +100,18 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( - + {tab.title} + {tab.disabled && props.tabDisabledMessage && ( + + + + + + )} ))} diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 4ba80881a03..323768e414a 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -44,6 +44,7 @@ export interface Flags { aglbFullCreateFlow: boolean; apiMaintenance: APIMaintenance; databaseBeta: boolean; + databaseScaleUp: boolean; databases: boolean; dcGetWell: boolean; firewallNodebalancer: boolean; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.style.ts new file mode 100644 index 00000000000..1ffa8cd9ff5 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.style.ts @@ -0,0 +1,41 @@ +import Grid from '@mui/material/Unstable_Grid2'; +import { styled } from '@mui/material/styles'; + +import { Button } from 'src/components/Button/Button'; +import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; + +export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( + ({ theme }) => ({ + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + marginTop: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + alignItems: 'flex-end', + flexDirection: 'column', + marginTop: theme.spacing(), + }, + }) +); + +export const StyledScaleUpButton = styled(Button, { + label: 'StyledScaleUpButton', +})(({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginRight: theme.spacing(), + }, + whiteSpace: 'nowrap', +})); + +export const StyledPlansPanel = styled(PlansPanel, { + label: 'StyledPlansPanel', +})(() => ({ + margin: 0, + padding: 0, +})); + +export const StyledPlanSummarySpan = styled('span', { + label: 'StyledPlanSummarySpan', +})(({ theme }) => ({ + fontFamily: theme.font.bold, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.test.tsx new file mode 100644 index 00000000000..20a93af8c8e --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.test.tsx @@ -0,0 +1,141 @@ +import { + fireEvent, + queryByAttribute, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import * as React from 'react'; +import { QueryClient } from 'react-query'; +import { Router } from 'react-router-dom'; + +import { databaseFactory, databaseTypeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseScaleUp } from './DatabaseScaleUp'; + +const queryClient = new QueryClient(); +const loadingTestId = 'circle-progress'; + +beforeAll(() => mockMatchMedia()); +afterEach(() => { + queryClient.clear(); +}); + +describe('database scale up', () => { + const database = databaseFactory.build(); + const dedicatedTypes = databaseTypeFactory.buildList(7, { + class: 'dedicated', + }); + + it('should render a loading state', async () => { + const { getByTestId } = renderWithTheme( + , + { + queryClient, + } + ); + + // Should render a loading state + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + }); + + it('should render configuration, summary sections and input field to choose a plan', async () => { + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-standard-0', + label: `Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + server.use( + rest.get('*/databases/types', (req, res, ctx) => { + return res( + ctx.json(makeResourcePage([...standardTypes, ...dedicatedTypes])) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + , + { + queryClient, + } + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText('Current Configuration'); + getByText('Choose a Plan'); + getByText('Summary'); + }); + + describe('On rendering of page', () => { + const examplePlanType = 'g6-standard-60'; + const dedicatedTypes = databaseTypeFactory.buildList(7, { + class: 'dedicated', + }); + const database = databaseFactory.build(); + beforeEach(() => { + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-standard-0', + label: `Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + server.use( + rest.get('*/databases/types', (req, res, ctx) => { + return res( + ctx.json(makeResourcePage([...standardTypes, ...dedicatedTypes])) + ); + }) + ); + }); + + it('scale up button should be disabled when no input is provided in the form', async () => { + const { getByTestId, getByText } = renderWithTheme( + , + { + queryClient, + } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + getByText(/Scale Up Database Cluster/i).closest('button') + ).toHaveAttribute('aria-disabled', 'true'); + }); + + it('when a plan is selected, scale up button should be enabled and on click of it, it should show a confirmation dialog', async () => { + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB scale up flow. + const history = createMemoryHistory(); + history.push(`databases/${database.engine}/${database.id}/scale-up`); + const { container, getByTestId, getByText } = renderWithTheme( + + + , + { + queryClient, + } + ); + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + const getById = queryByAttribute.bind(null, 'id'); + fireEvent.click(getById(container, examplePlanType)); + const scaleUpButton = getByText(/Scale Up Database Cluster/i); + expect(scaleUpButton.closest('button')).toHaveAttribute( + 'aria-disabled', + 'false' + ); + fireEvent.click(scaleUpButton); + getByText(`Scale up ${database.label}?`); + }); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.tsx new file mode 100644 index 00000000000..a6cee534055 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUp.tsx @@ -0,0 +1,289 @@ +import { LinodeTypeClass } from '@linode/api-v4'; +import { + Database, + DatabaseClusterSizeObject, + DatabasePriceObject, + Engine, +} from '@linode/api-v4/lib/databases/types'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; +import { typeLabelDetails } from 'src/features/Linodes/presentation'; +import { PlanSelectionType } from 'src/features/components/PlansPanel/types'; +import { getPlanSelectionsByPlanType } from 'src/features/components/PlansPanel/utils'; +import { useDatabaseTypesQuery } from 'src/queries/databases'; +import { useDatabaseMutation } from 'src/queries/databases'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; + +import { + StyledGrid, + StyledPlanSummarySpan, + StyledPlansPanel, + StyledScaleUpButton, +} from './DatabaseScaleUp.style'; +import { DatabaseScaleUpCurrentConfiguration } from './DatabaseScaleUpCurrentConfiguration'; + +interface Props { + database: Database; +} + +export const DatabaseScaleUp = ({ database }: Props) => { + const history = useHistory(); + + const [planSelected, setPlanSelected] = React.useState(); + const [summaryText, setSummaryText] = React.useState<{ + numberOfNodes: number; + plan: string; + price: string; + }>(); + // This will be set to `false` once one of the configuration is selected from available plan. This is used to disable the + // "Scale up" button unless there have been changes to the form. + const [ + shouldSubmitBeDisabled, + setShouldSubmitBeDisabled, + ] = React.useState(true); + + const [ + isScaleUpConfirmationDialogOpen, + setIsScaleUpConfirmationDialogOpen, + ] = React.useState(false); + + const { + error: scaleUpError, + isLoading: submitInProgress, + mutateAsync: updateDatabase, + } = useDatabaseMutation(database.engine, database.id); + + const { + data: dbTypes, + error: typesError, + isLoading: typesLoading, + } = useDatabaseTypesQuery(); + + const { enqueueSnackbar } = useSnackbar(); + + const onScaleUp = () => { + updateDatabase({ + type: planSelected, + }).then(() => { + enqueueSnackbar( + `Your database cluster ${database.label} is being scaled up.`, + { + variant: 'info', + } + ); + history.push(`/databases/${database.engine}/${database.id}`); + }); + }; + + const scaleUpDescription = ( + <> + Scaling up a Database Cluster + + Adapt the cluster to your needs by scaling it up. Clusters cannot be + scaled down. + + + ); + + const summaryPanel = ( + <> + Summary + ({ + marginTop: theme.spacing(2), + })} + data-testid="summary" + > + {summaryText ? ( + <> + {summaryText.plan}{' '} + {summaryText.numberOfNodes} Node + {summaryText.numberOfNodes > 1 ? 's' : ''}: {summaryText.price} + + ) : ( + 'Please select a plan.' + )} + + + ); + + const confirmationDialogActions = ( + setIsScaleUpConfirmationDialogOpen(false), + }} + /> + ); + + const costSummary = ( + + {`The cost of the scaled-up database is ${summaryText?.price}.`} + + ); + const confirmationPopUpMessage = + database.cluster_size === 1 ? ( + <> + {costSummary} + + {`Warning: This operation will cause downtime for your upscaled node cluster.`} + + + ) : ( + <> + {costSummary} + + {`Operation can take up to 2 hours and will incur a failover.`} + + + ); + + React.useEffect(() => { + if (!planSelected || !dbTypes) { + return; + } + + const selectedPlanType = dbTypes.find((type) => type.id === planSelected); + if (!selectedPlanType) { + setPlanSelected(undefined); + 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 + )?.price as DatabasePriceObject; + + setShouldSubmitBeDisabled(false); + + setSummaryText({ + numberOfNodes: database.cluster_size, + plan: formatStorageUnits(selectedPlanType.label), + price: `$${price?.monthly}/month or $${price?.hourly}/hour`, + }); + }, [ + dbTypes, + database.engine, + database.type, + planSelected, + database.cluster_size, + ]); + + const selectedEngine = database.engine.split('/')[0] as Engine; + + const displayTypes: PlanSelectionType[] = React.useMemo(() => { + if (!dbTypes) { + return []; + } + return dbTypes.map((type) => { + const { label } = type; + const formattedLabel = formatStorageUnits(label); + const nodePricing = type.engines[selectedEngine].find( + (cluster) => cluster.quantity === database.cluster_size + ); + const price = nodePricing?.price ?? { + hourly: null, + monthly: null, + }; + const subHeadings = [ + `$${price.monthly}/mo ($${price.hourly}/hr)`, + typeLabelDetails(type.memory, type.disk, type.vcpus), + ]; + return { + ...type, + formattedLabel, + heading: formattedLabel, + price, + subHeadings, + }; + }); + }, [database.cluster_size, dbTypes, selectedEngine]); + + const currentPlan = displayTypes?.find((type) => type.id === database.type); + // create an array of different class of types. + const typeClasses: LinodeTypeClass[] = Object.keys( + getPlanSelectionsByPlanType(displayTypes) + ).map((plan) => (plan === 'shared' ? 'standard' : (plan as LinodeTypeClass))); + const currentPlanClass = currentPlan?.class ?? 'dedicated'; + // We don't have a "Nanodes" tab anymore, so use `shared` + const selectedTypeClass = + currentPlanClass === 'nanode' ? 'standard' : currentPlanClass; + // User cannot switch to different plan type apart from current plan while scaling up a DB cluster. So disable rest of the tabs. + const tabsToBeDisabled = typeClasses + .filter((typeClass) => typeClass !== selectedTypeClass) + .map((plan) => (plan === 'standard' ? 'shared' : plan)); + if (typesLoading) { + return ; + } + + if (typesError) { + return ; + } + return ( + <> + + {scaleUpDescription} + + + + + + setPlanSelected(selected)} + selectedDiskSize={currentPlan?.disk} + selectedId={planSelected} + types={displayTypes} + /> + + {summaryPanel} + + { + setIsScaleUpConfirmationDialogOpen(true); + }} + buttonType="primary" + disabled={shouldSubmitBeDisabled} + type="submit" + > + Scale Up Database Cluster + + + setIsScaleUpConfirmationDialogOpen(false)} + open={isScaleUpConfirmationDialogOpen} + title={`Scale up ${database.label}?`} + > + {confirmationPopUpMessage} + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.style.ts new file mode 100644 index 00000000000..0cda918f77e --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.style.ts @@ -0,0 +1,54 @@ +import { styled } from '@mui/material/styles'; + +import { Box } from 'src/components/Box'; +import { Typography } from 'src/components/Typography'; + +export const StyledSummaryBox = styled(Box, { + label: 'StyledSummaryBox', +})(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { + display: 'grid', + gridTemplateColumns: '50% 2fr', + }, + [theme.breakpoints.down('sm')]: { + display: 'flex', + flexDirection: 'column', + }, +})); + +export const StyledSummaryTextTypography = styled(Typography, { + label: 'StyledSummaryTextTypography', +})(({ theme }) => ({ + '& strong': { + paddingRight: theme.spacing(1), + }, + paddingBottom: theme.spacing(2), + whiteSpace: 'nowrap', +})); + +export const StyledSummaryTextBox = styled(Box, { + label: 'StyledSummaryTextBox', +})(({ theme }) => ({ + '& strong': { + paddingRight: theme.spacing(1), + }, + '&:first-of-type': { + paddingBottom: theme.spacing(2), + }, + [theme.breakpoints.down('sm')]: { + paddingBottom: theme.spacing(2), + }, + whiteSpace: 'nowrap', +})); + +export const StyledTitleTypography = styled(Typography, { + label: 'StyledCurrentConfigurationTypography', +})(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); + +export const StyledStatusSpan = styled('span', { label: 'StyledStatusSpan' })( + () => ({ + textTransform: 'capitalize', + }) +); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.test.tsx new file mode 100644 index 00000000000..89f705623d8 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.test.tsx @@ -0,0 +1,82 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import * as React from 'react'; +import { QueryClient } from 'react-query'; + +import { databaseFactory, databaseTypeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import { DatabaseScaleUpCurrentConfiguration } from './DatabaseScaleUpCurrentConfiguration'; + +const queryClient = new QueryClient(); +const loadingTestId = 'circle-progress'; + +beforeAll(() => mockMatchMedia()); +afterEach(() => { + queryClient.clear(); +}); + +describe('database current configuration section', () => { + const database = databaseFactory.build(); + it('should render a loading state', async () => { + const { getByTestId } = renderWithTheme( + , + { + queryClient, + } + ); + + // Should render a loading state + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + }); + + it('should display number of status, version, nodes, region, RAM, CPUs and total disk size', async () => { + // Mock database types + const standardTypes = [ + databaseTypeFactory.build({ + class: 'nanode', + id: 'g6-standard-0', + label: `Nanode 1 GB`, + memory: 1024, + }), + ...databaseTypeFactory.buildList(7, { class: 'standard' }), + ]; + const dedicatedTypes = databaseTypeFactory.buildList(7, { + class: 'dedicated', + }); + server.use( + rest.get('*/databases/types', (req, res, ctx) => { + return res( + ctx.json(makeResourcePage([...standardTypes, ...dedicatedTypes])) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + , + { + queryClient, + } + ); + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText('Status'); + getByText('Version'); + getByText('Nodes'); + + getByText('Region'); + getByText('Newark, NJ'); + + getByText('RAM'); + getByText('1 GB'); + + getByText('CPUs'); + getByText('2'); + + getByText('Total Disk Size'); + getByText('15 GB'); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.tsx new file mode 100644 index 00000000000..1fea3ed69b2 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseScaleUp/DatabaseScaleUpCurrentConfiguration.tsx @@ -0,0 +1,153 @@ +import { Database, DatabaseInstance } from '@linode/api-v4/lib/databases/types'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { useDatabaseTypesQuery } from 'src/queries/databases'; +import { useRegionsQuery } from 'src/queries/regions'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; +import { convertMegabytesTo } from 'src/utilities/unitConversions'; + +import { + databaseEngineMap, + databaseStatusMap, +} from '../../DatabaseLanding/DatabaseRow'; +import { + StyledStatusSpan, + StyledSummaryBox, + StyledSummaryTextBox, + StyledSummaryTextTypography, + StyledTitleTypography, +} from './DatabaseScaleUpCurrentConfiguration.style'; + +interface Props { + database: Database; +} + +export const getDatabaseVersionNumber = ( + version: DatabaseInstance['version'] +) => version.split('/')[1]; + +export const DatabaseScaleUpCurrentConfiguration = ({ database }: Props) => { + const { + data: types, + error: typesError, + isLoading: typesLoading, + } = useDatabaseTypesQuery(); + const theme = useTheme(); + const { data: regions } = useRegionsQuery(); + + const region = regions?.find((r) => r.id === database.region); + + const type = types?.find((type) => type.id === database?.type); + + if (typesLoading) { + return ; + } + + if (typesError) { + return ; + } + + if (!database || !type) { + return null; + } + + const configuration = + database.cluster_size === 1 + ? 'Primary' + : `Primary +${database.cluster_size - 1} replicas`; + + const sxTooltipIcon = { + marginLeft: 0.5, + padding: 0, + }; + + const STORAGE_COPY = + 'The total disk size is smaller than the selected plan capacity due to the OS overhead.'; + + return ( + <> + + Current Configuration + + + + + Status{' '} + + + {database.status} + + + + Version{' '} + {databaseEngineMap[database.engine]} v{database.version} + + + Nodes{' '} + {configuration} + + + + + Region{' '} + {region?.label ?? database.region} + + + Plan{' '} + {formatStorageUnits(type.label)} + + + + + + RAM{' '} + {type.memory / 1024} GB + + + CPUs{' '} + {type.vcpus} + + + + {database.total_disk_size_gb ? ( + <> + + + Total Disk Size + {' '} + {database.total_disk_size_gb} GB + + + + Used{' '} + {database.used_disk_size_gb} GB + + + ) : ( + + Storage{' '} + {convertMegabytesTo(type.disk, true)} + + )} + + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index 108aa22432c..449002010a0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -12,6 +12,7 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useEditableLabelState } from 'src/hooks/useEditableLabelState'; +import { useFlags } from 'src/hooks/useFlags'; import { useDatabaseMutation, useDatabaseQuery, @@ -22,9 +23,15 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; const DatabaseSummary = React.lazy(() => import('./DatabaseSummary')); const DatabaseBackups = React.lazy(() => import('./DatabaseBackups')); const DatabaseSettings = React.lazy(() => import('./DatabaseSettings')); +const DatabaseScaleUp = React.lazy(() => + import('./DatabaseScaleUp/DatabaseScaleUp').then(({ DatabaseScaleUp }) => ({ + default: DatabaseScaleUp, + })) +); export const DatabaseDetail = () => { const history = useHistory(); + const flags = useFlags(); const { databaseId, engine } = useParams<{ databaseId: string; @@ -77,6 +84,13 @@ export const DatabaseDetail = () => { }, ]; + if (flags.databaseScaleUp) { + tabs.push({ + routeName: `/databases/${engine}/${id}/scale-up`, + title: 'Scale Up', + }); + } + const getTabIndex = () => { const tabChoice = tabs.findIndex((tab) => Boolean(matchPath(tab.routeName, { path: location.pathname })) @@ -150,6 +164,11 @@ export const DatabaseDetail = () => { + {flags.databaseScaleUp ? ( + + + + ) : null} diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index 1bd5c89efd9..800a6bb2786 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -16,6 +16,7 @@ export const EVENT_ACTIONS: Event['action'][] = [ 'database_delete', 'database_update_failed', 'database_update', + 'database_scale', 'disk_create', 'disk_delete', 'disk_duplicate', diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 0060e867a59..4a34e6c0444 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -108,14 +108,27 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { notification: (e) => `Database ${e.entity!.label}'s credentials have been reset.`, }, + database_degraded: { + notification: (e) => `Database ${e.entity!.label} has been degraded.`, + }, database_delete: { notification: (e) => `Database ${e.entity!.label} has been deleted.`, }, + database_failed: { + notification: (e) => `Database ${e.entity!.label} failed to update.`, + }, database_low_disk_space: { finished: (e) => `Low disk space alert for database ${e.entity!.label} has cleared.`, notification: (e) => `Database ${e.entity!.label} has low disk space.`, }, + database_scale: { + failed: (e) => `Database ${e.entity!.label} could not be scaled up.`, + finished: (e) => `Database ${e.entity!.label} has been scaled up.`, + scheduled: (e) => + `Database ${e.entity!.label} is scheduled for scaling up.`, + started: (e) => `Database ${e.entity!.label} is scaling up.`, + }, database_update: { finished: (e) => `Database ${e.entity!.label} has been updated.`, }, diff --git a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx index 2426bc76752..d5dc3b34a96 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanContainer.tsx @@ -82,10 +82,13 @@ export const PlanContainer = (props: Props) => { const shouldShowNetwork = showTransfer && plans.some((plan: ExtendedType) => plan.network_out); - // DC Dynamic price logic - DB creation flow is currently out of scope + // DC Dynamic price logic - DB creation and DB scale up flows are currently out of scope const isDatabaseCreateFlow = location.pathname.includes('/databases/create'); + const isDatabaseScaleUpFlow = + location.pathname.match(/\/databases\/.*\/(\d+\/scale-up)/)?.[0] === + location.pathname; const shouldDisplayNoRegionSelectedMessage = - !selectedRegionId && !isDatabaseCreateFlow; + !selectedRegionId && !isDatabaseCreateFlow && !isDatabaseScaleUpFlow; const renderPlanSelection = React.useCallback(() => { return plans.map((plan, id) => { diff --git a/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx b/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx index 6595151decd..8a9c072ce91 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanSelection.tsx @@ -89,9 +89,9 @@ export const PlanSelection = (props: PlanSelectionProps) => { ? `${type.formattedLabel} this plan is too small for resize` : type.formattedLabel; - // DC Dynamic price logic - DB creation flow is out of scope - const isDatabaseCreateFlow = location.pathname.includes('/databases/create'); - const price: PriceObject | undefined = !isDatabaseCreateFlow + // DC Dynamic price logic - DB creation and DB scale up flows are currently out of scope + const isDatabaseFlow = location.pathname.includes('/databases'); + const price: PriceObject | undefined = !isDatabaseFlow ? getLinodeRegionPrice(type, selectedRegionId) : type.price; type.subHeadings[0] = `$${renderMonthlyPriceToCorrectDecimalPlace( @@ -110,7 +110,11 @@ export const PlanSelection = (props: PlanSelectionProps) => { isSamePlan || planTooSmall || isPlanSoldOut || isDisabledClass } onClick={() => - !isSamePlan && !disabled && !isPlanSoldOut && !isDisabledClass + !isSamePlan && + !disabled && + !isPlanSoldOut && + !isDisabledClass && + !planTooSmall ? onSelect(type.id) : undefined } @@ -229,6 +233,15 @@ export const PlanSelection = (props: PlanSelectionProps) => { isPlanSoldOut || isDisabledClass } + headingDecoration={ + isSamePlan || type.id === selectedLinodePlanType ? ( + + ) : undefined + } subheadings={[ ...type.subHeadings, isPlanSoldOut ? : '', diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 8c2ac6e4299..c0d43e6989e 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -20,6 +20,7 @@ interface Props { currentPlanHeading?: string; disabled?: boolean; disabledClasses?: LinodeTypeClass[]; + disabledTabs?: string[]; docsLink?: JSX.Element; error?: string; header?: string; @@ -31,6 +32,7 @@ interface Props { selectedId?: string; selectedRegionID?: string; showTransfer?: boolean; + tabDisabledMessage?: string; tabbedPanelInnerClass?: string; types: PlanSelectionType[]; } @@ -68,6 +70,7 @@ export const PlansPanel = (props: Props) => { const tabs = Object.keys(plans).map((plan: LinodeTypeClass) => { return { + disabled: props.disabledTabs ? props.disabledTabs?.includes(plan) : false, render: () => { return ( <> @@ -117,6 +120,7 @@ export const PlansPanel = (props: Props) => { innerClass={props.tabbedPanelInnerClass} rootClass={`${className} tabbedPanel`} sx={{ marginTop: theme.spacing(3), width: '100%' }} + tabDisabledMessage={props.tabDisabledMessage} tabs={tabs} /> ); diff --git a/packages/validation/.changeset/pr-9869-changed-1703002209433.md b/packages/validation/.changeset/pr-9869-changed-1703002209433.md new file mode 100644 index 00000000000..bcd653b4590 --- /dev/null +++ b/packages/validation/.changeset/pr-9869-changed-1703002209433.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Made `allow_list` not required for updateDatabaseSchema and added optional `type` property ([#9869](https://github.com/linode/manager/pull/9869)) diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 2b45b3ee87c..1fc6d8684f7 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -49,7 +49,7 @@ export const createDatabaseSchema = object({ export const updateDatabaseSchema = object({ label: string().notRequired().min(3, LABEL_MESSAGE).max(32, LABEL_MESSAGE), - allow_list: array().of(string()).required('An IPv4 address is required'), + allow_list: array().of(string()).notRequired(), updates: object() .notRequired() .shape({ @@ -60,4 +60,5 @@ export const updateDatabaseSchema = object({ week_of_month: number().nullable(true), }) .nullable(true), + type: string().notRequired(), });