diff --git a/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md b/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md new file mode 100644 index 00000000000..9145c9aec6b --- /dev/null +++ b/packages/manager/.changeset/pr-13261-upcoming-features-1768256606077.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add service URIs to Database summary tab ([#13261](https://github.com/linode/manager/pull/13261)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx index 8aa01b3b5a4..0cc591d1eac 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsHostRows.tsx @@ -20,6 +20,7 @@ import type { Database } from '@linode/api-v4/lib/databases/types'; interface ConnectionDetailsHostRowsProps { database: Database; + isSummaryTab?: boolean; } type HostContentMode = 'default' | 'private' | 'public'; @@ -30,7 +31,7 @@ type HostContentMode = 'default' | 'private' | 'public'; export const ConnectionDetailsHostRows = ( props: ConnectionDetailsHostRowsProps ) => { - const { database } = props; + const { database, isSummaryTab } = props; const { classes } = useStyles(); const sxTooltipIcon = { @@ -136,21 +137,28 @@ export const ConnectionDetailsHostRows = ( return ( <> - + {getHostContent(hasVPC ? 'private' : 'default')} {hasPublicVPC && ( - + {getHostContent('public')} )} {getReadOnlyHostContent(hasVPC ? 'private' : 'default')} {hasPublicVPC && ( - + {getReadOnlyHostContent('public')} )} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx index cebfd7593c7..93c3f9be1d6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ConnectionDetailsRow.tsx @@ -8,22 +8,25 @@ import { interface ConnectionDetailsRowProps { children: React.ReactNode; + isSummaryTab?: boolean; label: string; } export const ConnectionDetailsRow = (props: ConnectionDetailsRowProps) => { - const { children, label } = props; + const { children, label, isSummaryTab } = props; return ( <> {label} - {children} + + {children} + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx new file mode 100644 index 00000000000..9d8e840467c --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseCaCert.tsx @@ -0,0 +1,107 @@ +import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; +import { TooltipIcon } from '@linode/ui'; +import { downloadFile } from '@linode/utilities'; +import { styled } from '@mui/material/styles'; +import { Button } from 'akamai-cds-react-components'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import DownloadIcon from 'src/assets/icons/lke-download.svg'; +import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; + +import { sxTooltipIcon } from './DatabaseSummaryConnectionDetails'; + +import type { Database, SSLFields } from '@linode/api-v4'; + +interface Props { + database: Database; +} + +export const DatabaseCaCert = (props: Props) => { + const { database } = props; + const { enqueueSnackbar } = useSnackbar(); + const [isCACertDownloading, setIsCACertDownloading] = + React.useState(false); + + const handleDownloadCACertificate = () => { + setIsCACertDownloading(true); + getSSLFields(database.engine, database.id) + .then((response: SSLFields) => { + // Convert to utf-8 from base64 + try { + const decodedFile = window.atob(response.ca_certificate); + downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); + setIsCACertDownloading(false); + } catch { + enqueueSnackbar('Error parsing your CA Certificate file', { + variant: 'error', + }); + setIsCACertDownloading(false); + return; + } + }) + .catch((errorResponse: any) => { + const error = getErrorStringOrDefault( + errorResponse, + 'Unable to download your CA Certificate' + ); + setIsCACertDownloading(false); + enqueueSnackbar(error, { variant: 'error' }); + }); + }; + + const disableDownloadCACertificateBtn = database.status === 'provisioning'; + + return ( + <> + + + Download CA Certificate + + {disableDownloadCACertificateBtn && ( + + + + )} + + ); +}; + +export const StyledCaCertButton = styled(Button, { + label: 'StyledCaCertButton', +})(({ theme }) => ({ + '&:hover': { + backgroundColor: 'transparent', + opacity: 0.7, + }, + '&[disabled]': { + '& g': { + stroke: theme.tokens.color.Neutrals[30], + }, + '&:hover': { + backgroundColor: 'inherit', + textDecoration: 'none', + }, + // Override disabled background color defined for dark mode + backgroundColor: 'transparent', + color: theme.tokens.color.Neutrals[30], + cursor: 'default', + }, + color: theme.palette.primary.main, + font: theme.font.bold, + fontSize: '0.875rem', + lineHeight: '1.125rem', + minHeight: 'auto', + minWidth: 'auto', + padding: 0, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index d04a3767284..79f7cdfae6c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,14 +1,32 @@ -import { Paper } from '@linode/ui'; +import { useDatabaseConnectionPoolsQuery } from '@linode/queries'; +import { Paper, Typography } from '@linode/ui'; import Grid from '@mui/material/Grid'; +import { styled } from '@mui/material/styles'; import * as React from 'react'; import ClusterConfiguration from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration'; import ConnectionDetails from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails'; +import { useFlags } from 'src/hooks/useFlags'; import { useDatabaseDetailContext } from '../DatabaseDetailContext'; +import { ServiceURI } from '../ServiceURI'; +import { DatabaseCaCert } from './DatabaseCaCert'; export const DatabaseSummary = () => { const { database } = useDatabaseDetailContext(); + const flags = useFlags(); + + const { data: connectionPools } = useDatabaseConnectionPoolsQuery( + database.id, + flags.databasePgBouncer, + {} + ); + + const showPgBouncerConnectionDetails = + flags.databasePgBouncer && + database.engine === 'postgresql' && + connectionPools && + connectionPools.data.length > 0; return ( @@ -29,7 +47,34 @@ export const DatabaseSummary = () => { > + {showPgBouncerConnectionDetails && ( + + + PgBouncer Connection Details + + + + )} + {database.ssl_connection && ( + + + + )} ); }; + +export const StyledButtonCtn = styled('div', { + label: 'StyledButtonCtn', +})(({ theme }) => ({ + display: 'flex', + justifyContent: 'flex-end', + marginTop: '10px', + padding: `${theme.spacingFunction(8)} 0`, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts index cef7b59a304..556f28b7b1c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts @@ -3,45 +3,6 @@ import { makeStyles } from 'tss-react/mui'; import type { Theme } from '@mui/material/styles'; export const useStyles = makeStyles()((theme: Theme) => ({ - actionBtnsCtn: { - display: 'flex', - justifyContent: 'flex-end', - marginTop: '10px', - padding: `${theme.spacing(1)} 0`, - }, - caCertBtn: { - '& svg': { - marginRight: theme.spacing(), - }, - '&:hover': { - backgroundColor: 'transparent', - opacity: 0.7, - }, - '&[disabled]': { - '& g': { - stroke: theme.tokens.color.Neutrals[30], - }, - '&:hover': { - backgroundColor: 'inherit', - textDecoration: 'none', - }, - // Override disabled background color defined for dark mode - backgroundColor: 'transparent', - color: theme.tokens.color.Neutrals[30], - cursor: 'default', - }, - color: theme.palette.primary.main, - font: theme.font.bold, - fontSize: '0.875rem', - lineHeight: '1.125rem', - marginLeft: theme.spacing(), - minHeight: 'auto', - minWidth: 'auto', - padding: 0, - }, - tooltipIcon: { - alignContent: 'center', - }, connectionDetailsCtn: { '& p': { lineHeight: '1.5rem', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index a725b87d4fc..5329032f66c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,32 +1,28 @@ -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 { Button } from 'akamai-cds-react-components'; -import { useSnackbar } from 'notistack'; import * as React from 'react'; -import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { isDefaultDatabase } from '../../utilities'; import { ConnectionDetailsHostRows } from '../ConnectionDetailsHostRows'; import { ConnectionDetailsRow } from '../ConnectionDetailsRow'; +import { ServiceURI } from '../ServiceURI'; import { StyledGridContainer } from './DatabaseSummaryClusterConfiguration.style'; import { useStyles } from './DatabaseSummaryConnectionDetails.style'; -import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; +import type { Database } from '@linode/api-v4/lib/databases/types'; import type { Theme } from '@mui/material/styles'; interface Props { database: Database; } -const sxTooltipIcon = { +export const sxTooltipIcon = { marginLeft: '4px', padding: '0px', }; @@ -34,7 +30,6 @@ const sxTooltipIcon = { 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); @@ -42,8 +37,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { flags.databaseVpc && isDefaultDatabase(database); const [showCredentials, setShowPassword] = React.useState(false); - const [isCACertDownloading, setIsCACertDownloading] = - React.useState(false); const { data: credentials, @@ -72,35 +65,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { } }, [credentials, getDatabaseCredentials, showCredentials]); - const handleDownloadCACertificate = () => { - setIsCACertDownloading(true); - getSSLFields(database.engine, database.id) - .then((response: SSLFields) => { - // Convert to utf-8 from base64 - try { - const decodedFile = window.atob(response.ca_certificate); - downloadFile(`${database.label}-ca-certificate.crt`, decodedFile); - setIsCACertDownloading(false); - } catch (e) { - enqueueSnackbar('Error parsing your CA Certificate file', { - variant: 'error', - }); - setIsCACertDownloading(false); - return; - } - }) - .catch((errorResponse: any) => { - const error = getErrorStringOrDefault( - errorResponse, - 'Unable to download your CA Certificate' - ); - setIsCACertDownloading(false); - enqueueSnackbar(error, { variant: 'error' }); - }); - }; - const disableShowBtn = ['failed', 'provisioning'].includes(database.status); - const disableDownloadCACertificateBtn = database.status === 'provisioning'; const credentialsBtn = (handleClick: () => void, btnText: string) => { return ( @@ -116,31 +81,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { ); }; - const caCertificateJSX = ( - <> - - {disableDownloadCACertificateBtn && ( - - - - )} - - ); - const CredentialsContent = ( <> {password} @@ -181,23 +121,30 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { Connection Details - - {username} - + + {flags.databasePgBouncer && ( + + + + )} + + {username} + + {CredentialsContent} - + {isLegacy ? database.engine : 'defaultdb'} - - + + {database.port} - + {database.ssl_connection ? 'ENABLED' : 'DISABLED'} {displayConnectionType && ( - + ({ marginRight: theme.spacingFunction(20), @@ -213,9 +160,6 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { )} -
- {database.ssl_connection ? caCertificateJSX : null} -
); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx index 2b65b71c560..b9379d57746 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -52,7 +52,7 @@ describe('ServiceURI', () => { expect(revealPasswordBtn).toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` ); // eslint-disable-next-line testing-library/no-container @@ -75,7 +75,7 @@ describe('ServiceURI', () => { const serviceURIText = screen.getByTestId('service-uri').textContent; expect(revealPasswordBtn).not.toBeInTheDocument(); expect(serviceURIText).toBe( - `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + `postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:100/{connection pool label}?sslmode=require` ); }); @@ -91,4 +91,46 @@ describe('ServiceURI', () => { }); expect(errorRetryBtn).toBeInTheDocument(); }); + + it('should render general service URI if isGeneralServiceURI is true', () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + }); + const { container } = renderWithTheme( + + ); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + const serviceURIText = screen.getByTestId('service-uri').textContent; + + expect(revealPasswordBtn).toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + ); + + // eslint-disable-next-line testing-library/no-container + const copyButton = container.querySelector('[data-qa-copy-btn]'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should reveal general service URI password after clicking reveal button', async () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + data: mockCredentials, + refetch: vi.fn(), + }); + renderWithTheme(); + + const revealPasswordBtn = screen.getByRole('button', { + name: '{click to reveal password}', + }); + await userEvent.click(revealPasswordBtn); + + const serviceURIText = screen.getByTestId('service-uri').textContent; + expect(revealPasswordBtn).not.toBeInTheDocument(); + expect(serviceURIText).toBe( + `postgres://password123@db-mysql-primary-0.b.linodeb.net:3306/defaultdb?sslmode=require` + ); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx index adcc31ed8cc..0cfe994211d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx @@ -13,17 +13,20 @@ import { StyledValueGrid, } from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; -import type { Database } from '@linode/api-v4'; +import type { Database, DatabaseCredentials } from '@linode/api-v4'; interface ServiceURIProps { database: Database; + isGeneralServiceURI?: boolean; } export const ServiceURI = (props: ServiceURIProps) => { - const { database } = props; + const { database, isGeneralServiceURI = false } = props; const [hidePassword, setHidePassword] = useState(true); const [isCopying, setIsCopying] = useState(false); + const engine = + database.engine === 'postgresql' ? 'postgres' : database.engine; const { data: credentials, @@ -39,10 +42,8 @@ export const ServiceURI = (props: ServiceURIProps) => { setIsCopying(true); const { data } = await getDatabaseCredentials(); if (data) { - // copy with username/password data - copy( - `postgres://${data?.username}:${data?.password}@${database.hosts?.primary}?sslmode=require` - ); + // copy with revealed credentials + copy(getServiceURIText(isGeneralServiceURI, data)); } else { enqueueSnackbar( 'There was an error retrieving cluster credentials. Please try again.', @@ -60,12 +61,105 @@ export const ServiceURI = (props: ServiceURIProps) => { } }; - const serviceURI = `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}?sslmode=require`; + const getServiceURIText = ( + isGeneralServiceURI: boolean, + credentials: DatabaseCredentials | undefined + ) => { + if (isGeneralServiceURI) { + return `${engine}://${credentials?.password}@${database.hosts?.primary}:${database.port}/defaultdb?sslmode=require`; + } + return `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}:${database.connection_pool_port}/{connection pool label}?sslmode=require`; + }; + + const getCredentials = (isGeneralServiceURI: boolean) => { + return !isGeneralServiceURI + ? `${credentials?.username}:${credentials?.password}` + : credentials?.password; + }; // hide loading state if the user clicks on the copy icon const showBtnLoading = !isCopying && (credentialsLoading || credentialsFetching); + const ErrorButton = ( + + ); + + const RevealPasswordButton = ( + + ); + + const ServiceURIJSX = (isGeneralServiceURI: boolean) => ( + + + {engine}:// + {credentialsError + ? ErrorButton + : hidePassword || (!credentialsError && !credentials) + ? RevealPasswordButton + : getCredentials(isGeneralServiceURI)} + {!isGeneralServiceURI ? ( + <> + @{database.hosts?.primary}:{database.connection_pool_port}/ + {'{connection pool label}'} + ?sslmode=require + + ) : ( + <> + @{database.hosts?.primary}: + {`${database.port}/defaultdb?sslmode=require`} + + )} + + {isCopying ? ( + + ) : ( + + + + )} + + ); + + if (isGeneralServiceURI) { + return ServiceURIJSX(isGeneralServiceURI); + } + return ( { > Service URI - - - postgres:// - {credentialsError ? ( - - ) : hidePassword || (!credentialsError && !credentials) ? ( - - ) : ( - `${credentials?.username}:${credentials?.password}` - )} - @{database.hosts?.primary}: - {'{connection pool port}'}/ - {'{connection pool label}'}?sslmode=require - - {isCopying ? ( - - ) : ( - - - - )} - + {ServiceURIJSX(isGeneralServiceURI)} ); }; -export const StyledCode = styled(Code, { +const StyledCode = styled(Code, { label: 'StyledCode', })(() => ({ margin: 0, })); -export const StyledCopyTooltip = styled(CopyTooltip, { +const StyledCopyTooltip = styled(CopyTooltip, { label: 'StyledCopyTooltip', })(({ theme }) => ({ alignSelf: 'center',