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 ( { > ); + const CredentialsContent = ( + <> + {password} + {showCredentials && credentialsLoading ? ( + + + + ) : credentialsError ? ( + <> + Error retrieving credentials. + {credentialsBtn(() => getDatabaseCredentials(), 'Retry')} + > + ) : ( + credentialsBtn( + handleShowPasswordClick, + showCredentials && credentials ? 'Hide' : 'Show' + ) + )} + {disableShowBtn && ( + + )} + {showCredentials && credentials && ( + + )} + > + ); + return ( <> Connection Details - - Username - - {username} - - Password - - - {password} - {showCredentials && credentialsLoading ? ( - - - - ) : credentialsError ? ( - <> - - Error retrieving credentials. - - {credentialsBtn(() => getDatabaseCredentials(), 'Retry')} - > - ) : ( - credentialsBtn( - handleShowPasswordClick, - showCredentials && credentials ? 'Hide' : 'Show' - ) - )} - {disableShowBtn && ( - - )} - {showCredentials && credentials && ( - - )} - - - Database name - - + {username} + + {CredentialsContent} + + {isLegacy ? database.engine : 'defaultdb'} - - - Host - - - {database.hosts?.primary ? ( - <> - {database.hosts?.primary} - - {!isLegacy && ( - - )} - > - ) : ( - - - Your hostname will appear here once it is available. - - - )} - - - - {isLegacy ? 'Private Network Host' : 'Read-only Host'} - - - - {readOnlyHost()} - - - Port - - + + + {database.port} - - - SSL - - + + {database.ssl_connection ? 'ENABLED' : 'DISABLED'} - + {displayConnectionType && ( - <> - + ({ + marginRight: theme.spacingFunction(20), + })} + > + {hasVPC ? 'VPC' : 'Public'} + + - Connection Type - - - ({ - marginRight: theme.spacingFunction(20), - })} - > - {database?.private_network?.vpc_id ? 'VPC' : 'Public'} - - - View Details - - - > + View Details + + )} diff --git a/packages/manager/src/features/Databases/constants.ts b/packages/manager/src/features/Databases/constants.ts index f6b47b107d3..c09afb4d38c 100644 --- a/packages/manager/src/features/Databases/constants.ts +++ b/packages/manager/src/features/Databases/constants.ts @@ -53,6 +53,15 @@ export const BACKUPS_INVALID_TIME_VALIDATON_TEXT = export const BACKUPS_UNABLE_TO_RESTORE_TEXT = 'You can restore a backup after the first backup is completed.'; +export const SUMMARY_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.'; + +export const SUMMARY_PRIVATE_HOST_COPY = + "The private hostname resolves to an internal IP address and can only be used to access the database cluster from other Linode instances within the same VPC. This connection is secured and doesn't incur transfer costs."; + +export const SUMMARY_PRIVATE_HOST_LEGACY_COPY = + '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.'; + // Links export const LEARN_MORE_LINK_LEGACY = 'https://techdocs.akamai.com/cloud-computing/docs/manage-access-controls'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index b067c432890..f7871b31f79 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -195,9 +195,17 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } const database = databaseFactory.build(db); + if (database.platform !== 'rdbms-default') { delete database.private_network; } + + if (database.platform === 'rdbms-default' && !!database.private_network) { + // When a database is configured with a VPC, the primary host is prepended with 'private-' + const privateHost = `private-${database.hosts.primary}`; + database.hosts.primary = privateHost; + } + return database; };
Test Children Prop