diff --git a/packages/manager/.changeset/pr-12939-added-1759348628476.md b/packages/manager/.changeset/pr-12939-added-1759348628476.md new file mode 100644 index 00000000000..d72e33a9b22 --- /dev/null +++ b/packages/manager/.changeset/pr-12939-added-1759348628476.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +ConnectionDetailsRow and ConnectionDetailsHostRows components to manage connection details table content ([#12939](https://github.com/linode/manager/pull/12939)) diff --git a/packages/manager/.changeset/pr-12939-changed-1759348579491.md b/packages/manager/.changeset/pr-12939-changed-1759348579491.md new file mode 100644 index 00000000000..8619873bdc9 --- /dev/null +++ b/packages/manager/.changeset/pr-12939-changed-1759348579491.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +DBaaS - Host field in connection details table renders based on VPC configuration and host fields are synced between Details and Networking tabs ([#12939](https://github.com/linode/manager/pull/12939)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx new file mode 100644 index 00000000000..712a21a2651 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; + +import { databaseFactory } from 'src/factories/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ConnectionDetailsHostRows } from './ConnectionDetailsHostRows'; + +import type { Database } from '@linode/api-v4/lib/databases'; + +const DEFAULT_PRIMARY = 'private-db-mysql-default-primary.net'; +const DEFAULT_STANDBY = 'db-mysql-default-standby.net'; + +const LEGACY_PRIMARY = 'db-mysql-legacy-primary.net'; +const LEGACY_SECONDARY = 'db-mysql-legacy-secondary.net'; + +describe('ConnectionDetailsHostRows', () => { + it('should display correctly for default database', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: DEFAULT_STANDBY, + }, + platform: 'rdbms-default', + private_network: null, // Added to test that Host field renders + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('Host')).toHaveLength(1); + expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1); + + expect(queryAllByText('Read-only Host')).toHaveLength(1); + }); + + it('should display N/A for default DB with blank read-only Host field', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: undefined, + }, + platform: 'rdbms-default', + }); + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('N/A')).toHaveLength(1); + }); + + it('should display Host rows correctly for legacy db', () => { + const database = databaseFactory.build({ + hosts: { + primary: LEGACY_PRIMARY, + secondary: LEGACY_SECONDARY, + standby: undefined, + }, + id: 22, + platform: 'rdbms-legacy', + port: 3306, + ssl_connection: true, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('Host')).toHaveLength(1); + expect(queryAllByText(LEGACY_PRIMARY)).toHaveLength(1); + + expect(queryAllByText('Private Network Host')).toHaveLength(1); + expect(queryAllByText(LEGACY_SECONDARY)).toHaveLength(1); + }); + + it('should display provisioning text when hosts are not available', () => { + const database = databaseFactory.build({ + hosts: undefined, + platform: 'rdbms-default', + }) as Database; + + const { getByText } = renderWithTheme( + + ); + + const hostNameProvisioningText = getByText( + 'Your hostname will appear here once it is available.' + ); + + expect(hostNameProvisioningText).toBeInTheDocument(); + }); + + it('should display Host when VPC is not configured', () => { + const privateStrIndex = DEFAULT_PRIMARY.indexOf('-'); + const baseHostName = DEFAULT_PRIMARY.slice(privateStrIndex + 1); + + const database = databaseFactory.build({ + hosts: { + primary: baseHostName, + }, + platform: 'rdbms-default', + private_network: null, // VPC not configured + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('Host')).toHaveLength(1); + expect(queryAllByText(baseHostName)).toHaveLength(1); + }); + + it('should display Private Host field when VPC is configured with public access as false', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: undefined, + }, + platform: 'rdbms-default', + private_network: { + public_access: false, + subnet_id: 1, + vpc_id: 123, + }, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + expect(queryAllByText('Private Host')).toHaveLength(1); + expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1); + }); + + it('should display both Private Host and Public Host fields when VPC is configured with public access as true', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: undefined, + }, + platform: 'rdbms-default', + private_network: { + public_access: true, + subnet_id: 1, + vpc_id: 123, + }, + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + // Verify that both Private Host and Public Host fields are rendered + expect(queryAllByText('Private Host')).toHaveLength(1); + expect(queryAllByText('Public Host')).toHaveLength(1); + + // Verify that the Private hostname is rendered correctly + expect(queryAllByText(DEFAULT_PRIMARY)).toHaveLength(1); + // Verify that the Public hostname is rendered correctly + const privateStrIndex = DEFAULT_PRIMARY.indexOf('-'); + const baseHostName = DEFAULT_PRIMARY.slice(privateStrIndex + 1); + const expectedPublicHostname = `public-${baseHostName}`; + expect(queryAllByText(expectedPublicHostname)).toHaveLength(1); + }); + + it('should display Read-only Host when read-only host is available', () => { + const database = databaseFactory.build({ + hosts: { + primary: DEFAULT_PRIMARY, + secondary: undefined, + standby: DEFAULT_STANDBY, + }, + platform: 'rdbms-default', + }) as Database; + + const { queryAllByText } = renderWithTheme( + + ); + + expect(queryAllByText('Read-only Host')).toHaveLength(1); + expect(queryAllByText(DEFAULT_STANDBY)).toHaveLength(1); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx new file mode 100644 index 00000000000..ca9e7ed6298 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx @@ -0,0 +1,138 @@ +import { TooltipIcon, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; + +import { + SUMMARY_HOST_TOOLTIP_COPY, + SUMMARY_PRIVATE_HOST_COPY, + SUMMARY_PRIVATE_HOST_LEGACY_COPY, +} from '../constants'; +import { getReadOnlyHost, isLegacyDatabase } from '../utilities'; +import { ConnectionDetailsRow } from './ConnectionDetailsRow'; +import { useStyles } from './DatabaseSummary/DatabaseSummaryConnectionDetails.style'; + +import type { Database } from '@linode/api-v4/lib/databases/types'; + +interface ConnectionDetailsHostRowsProps { + database: Database; +} + +/** + * This component is responsible for conditionally rendering the Private Host, Public Host, and Read-only Host rows that get displayed in + * the Connection Details tables that appear in the Database Summary and Networking tabs */ +export const ConnectionDetailsHostRows = ( + props: ConnectionDetailsHostRowsProps +) => { + const { database } = props; + const { classes } = useStyles(); + + const sxTooltipIcon = { + marginLeft: '4px', + padding: '0px', + }; + + const hostTooltipComponentProps = { + tooltip: { + style: { + minWidth: 285, + }, + }, + }; + + const isLegacy = isLegacyDatabase(database); + const hasVPC = Boolean(database?.private_network?.vpc_id); + const hasPublicVPC = hasVPC && database?.private_network?.public_access; + + const getHostContent = ( + mode: 'default' | 'private' | 'public' = 'default' + ) => { + let primaryHostName = database.hosts?.primary; + + if (mode === 'public' && primaryHostName) { + // Remove 'private-' substring at the beginning of the hostname and replace it with 'public-' + const privateStrIndex = database.hosts.primary.indexOf('-'); + const baseHostName = database.hosts.primary.slice(privateStrIndex + 1); + primaryHostName = `public-${baseHostName}`; + } + + if (primaryHostName) { + return ( + <> + {primaryHostName} + + {!isLegacy && ( + + )} + + ); + } + + return ( + + + Your hostname will appear here once it is available. + + + ); + }; + + const getReadOnlyHostContent = () => { + const defaultValue = isLegacy ? '-' : 'N/A'; + const value = getReadOnlyHost(database) || defaultValue; + const hasHost = value !== '-' && value !== 'N/A'; + return ( + <> + {value} + {value && hasHost && ( + + )} + {isLegacy && ( + + )} + {!isLegacy && hasHost && ( + + )} + + ); + }; + + return ( + <> + + {getHostContent(hasVPC ? 'private' : 'default')} + + {hasPublicVPC && ( + + {getHostContent('public')} + + )} + + {getReadOnlyHostContent()} + + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx new file mode 100644 index 00000000000..3fa87196cf3 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ConnectionDetailsRow } from './ConnectionDetailsRow'; + +describe('ConnectionDetailsRow', () => { + it('should render provided label and children', async () => { + const { getByText } = renderWithTheme( + +

Test Children Prop

+
+ ); + const testLabel = getByText('Test Label'); + const testChildrenProp = getByText('Test Children Prop'); + + expect(testLabel).toBeInTheDocument(); + expect(testChildrenProp).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx new file mode 100644 index 00000000000..cebfd7593c7 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx @@ -0,0 +1,29 @@ +import { Grid } from '@mui/material'; +import * as React from 'react'; + +import { + StyledLabelTypography, + StyledValueGrid, +} from './DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; + +interface ConnectionDetailsRowProps { + children: React.ReactNode; + label: string; +} + +export const ConnectionDetailsRow = (props: ConnectionDetailsRowProps) => { + const { children, label } = props; + return ( + <> + + {label} + + {children} + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx index 624bef08e12..3e35fa76bbb 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseManageNetworking.tsx @@ -6,7 +6,6 @@ import { ErrorState, Typography, } from '@linode/ui'; -import { Grid } from '@mui/material'; import React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -14,12 +13,9 @@ import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; import { MANAGE_NETWORKING_LEARN_MORE_LINK } from '../../constants'; -import { getReadOnlyHost } from '../../utilities'; -import { - StyledGridContainer, - StyledLabelTypography, - StyledValueGrid, -} from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; +import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; +import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; +import { StyledGridContainer } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; import DatabaseManageNetworkingDrawer from './DatabaseManageNetworkingDrawer'; import { DatabaseNetworkingUnassignVPCDialog } from './DatabaseNetworkingUnassignVPCDialog'; @@ -64,10 +60,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => { flexDirection: 'column', }, }, - provisioningText: { - font: theme.font.normal, - fontStyle: 'italic', - }, })); const flags = useFlags(); @@ -80,8 +72,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => { const vpcId = Number(database.private_network?.vpc_id); const hasVPCConfigured = Boolean(vpcId); const gridContainerSize = { lg: 7, md: 10 }; - const gridValueSize = { md: 8, xs: 9 }; - const gridLabelSize = { md: 4, xs: 3 }; const { data: vpcs, @@ -99,12 +89,6 @@ export const DatabaseManageNetworking = ({ database }: Props) => { ); const hasVPCs = Boolean(vpcs && vpcs.length > 0); - const readOnlyHost = () => { - const defaultValue = 'N/A'; - const value = getReadOnlyHost(database) || defaultValue; - return {value}; - }; - const onManageAccess = () => { setIsManageNetworkingDrawerOpen(true); }; @@ -158,54 +142,26 @@ export const DatabaseManageNetworking = ({ database }: Props) => { - - Connection Type - - + {hasVPCConfigured ? 'VPC' : 'Public'} - + + {hasVPCConfigured && ( <> - - VPC - - + {currentVPC?.label} - - - Subnet - - + + {`${currentSubnet?.label} (${currentSubnet?.ipv4})`} - + )} - - Host - - - {database.hosts?.primary ? ( - database.hosts?.primary - ) : ( - - Your hostname will appear here once it is available. - - )} - - - Read-only Host - - {readOnlyHost()} + {hasVPCConfigured && ( - <> - - Public Access - - - {database?.private_network?.public_access ? 'Yes' : 'No'} - - + + {database?.private_network?.public_access ? 'Yes' : 'No'} + )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 89bc3c80c51..a725b87d4fc 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -2,7 +2,6 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; import { useDatabaseCredentialsQuery } from '@linode/queries'; import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; import { downloadFile } from '@linode/utilities'; -import Grid from '@mui/material/Grid'; import { Button } from 'akamai-cds-react-components'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -14,12 +13,10 @@ import { DB_ROOT_USERNAME } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import { getReadOnlyHost, isDefaultDatabase } from '../../utilities'; -import { - StyledGridContainer, - StyledLabelTypography, - StyledValueGrid, -} from './DatabaseSummaryClusterConfiguration.style'; +import { isDefaultDatabase } from '../../utilities'; +import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; +import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; +import { StyledGridContainer } from './DatabaseSummaryClusterConfiguration.style'; import { useStyles } from './DatabaseSummaryConnectionDetails.style'; import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; @@ -34,15 +31,13 @@ const sxTooltipIcon = { padding: '0px', }; -const privateHostCopy = - 'A private network host and a private IP can only be used to access a Database Cluster from Linodes in the same data center and will not incur transfer costs.'; - export const DatabaseSummaryConnectionDetails = (props: Props) => { const { database } = props; const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); const flags = useFlags(); const isLegacy = database.platform !== 'rdbms-default'; + const hasVPC = Boolean(database?.private_network?.vpc_id); const displayConnectionType = flags.databaseVpc && isDefaultDatabase(database); @@ -67,16 +62,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { const password = showCredentials && credentials ? credentials?.password : '••••••••••'; - const hostTooltipComponentProps = { - tooltip: { - style: { - minWidth: 285, - }, - }, - }; - const HOST_TOOLTIP_COPY = - 'Use the IPv6 address (AAAA record) for this hostname to avoid network transfer charges when connecting to this database from Linodes within the same region.'; - const handleShowPasswordClick = () => { setShowPassword((showCredentials) => !showCredentials); }; @@ -117,35 +102,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { const disableShowBtn = ['failed', 'provisioning'].includes(database.status); const disableDownloadCACertificateBtn = database.status === 'provisioning'; - const readOnlyHost = () => { - const defaultValue = isLegacy ? '-' : 'N/A'; - const value = getReadOnlyHost(database) || defaultValue; - const hasHost = value !== '-' && value !== 'N/A'; - return ( - <> - {value} - {value && hasHost && ( - - )} - {isLegacy && ( - - )} - {!isLegacy && hasHost && ( - - )} - - ); - }; - const credentialsBtn = (handleClick: () => void, btnText: string) => { return (