diff --git a/packages/manager/.changeset/pr-13182-upcoming-features-1765488986544.md b/packages/manager/.changeset/pr-13182-upcoming-features-1765488986544.md new file mode 100644 index 00000000000..0e5467ccc71 --- /dev/null +++ b/packages/manager/.changeset/pr-13182-upcoming-features-1765488986544.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Added PG Bouncer ServiceURI component ([#13182](https://github.com/linode/manager/pull/13182)) diff --git a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx index 6d57f5f21ca..6db536e4098 100644 --- a/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx +++ b/packages/manager/src/components/CopyTooltip/CopyTooltip.tsx @@ -34,14 +34,13 @@ export interface CopyTooltipProps { * @default false */ masked?: boolean; - /** - * Callback to be executed when the icon is clicked. - */ - /** * Optionally specifies the length of the masked text to depending on data type (e.g. 'ipv4', 'ipv6', 'plaintext'); if not provided, will use a default length. */ maskedTextLength?: MaskableTextLength | number; + /** + * Callback to be executed when the icon is clicked. + */ onClickCallback?: () => void; /** * The placement of the tooltip. diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx index 04202fbf78e..c9341cd3254 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx @@ -108,4 +108,26 @@ describe('DatabaseManageNetworkingDrawer Component', () => { ); expect(errorStateText).toBeInTheDocument(); }); + + it('should render service URI component if there are connection pools', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([mockConnectionPool]), + isLoading: false, + }); + + renderWithTheme(); + const serviceURIText = screen.getByText('Service URI'); + expect(serviceURIText).toBeInTheDocument(); + }); + + it('should not render service URI component if there are no connection pools', () => { + queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + }); + + renderWithTheme(); + const serviceURIText = screen.queryByText('Service URI'); + expect(serviceURIText).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx index f905b2639c2..7ff53ab74ac 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx @@ -30,6 +30,7 @@ import { makeSettingsItemStyles, StyledActionMenuWrapper, } from '../../shared.styles'; +import { ServiceURI } from '../ServiceURI'; import type { Database } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -104,6 +105,9 @@ export const DatabaseConnectionPools = ({ database }: Props) => { Add Pool + {connectionPools && connectionPools.data.length > 0 && ( + + )}
{ + const flags = useFlags(); const navigate = useNavigate(); const { database, disabled, engine, isVPCEnabled } = useDatabaseDetailContext(); - const flags = useFlags(); - const accessControlCopy = ( {ACCESS_CONTROLS_IN_SETTINGS_TEXT} ); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx new file mode 100644 index 00000000000..2b65b71c560 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx @@ -0,0 +1,94 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { describe, it } from 'vitest'; + +import { databaseFactory } from 'src/factories/databases'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ServiceURI } from './ServiceURI'; + +const mockDatabase = databaseFactory.build({ + connection_pool_port: 100, + engine: 'postgresql', + id: 1, + platform: 'rdbms-default', + private_network: null, +}); + +const mockCredentials = { + password: 'password123', + username: 'lnroot', +}; + +// Hoist query mocks +const queryMocks = vi.hoisted(() => { + return { + useDatabaseCredentialsQuery: vi.fn(), + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, + }; +}); + +describe('ServiceURI', () => { + it('should render the service URI component and copy icon', async () => { + 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:{connection pool port}/{connection pool label}?sslmode=require` + ); + + // eslint-disable-next-line testing-library/no-container + const copyButton = container.querySelector('[data-qa-copy-btn]'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should reveal 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://lnroot:password123@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require` + ); + }); + + it('should render error retry button if the credentials call fails', () => { + queryMocks.useDatabaseCredentialsQuery.mockReturnValue({ + error: new Error('Failed to fetch credentials'), + }); + + renderWithTheme(); + + const errorRetryBtn = screen.getByRole('button', { + name: '{error. click to retry}', + }); + expect(errorRetryBtn).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx new file mode 100644 index 00000000000..adcc31ed8cc --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx @@ -0,0 +1,152 @@ +import { useDatabaseCredentialsQuery } from '@linode/queries'; +import { Button } from '@linode/ui'; +import { Grid, styled } from '@mui/material'; +import copy from 'copy-to-clipboard'; +import { enqueueSnackbar } from 'notistack'; +import React, { useState } from 'react'; + +import { Code } from 'src/components/Code/Code'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { + StyledGridContainer, + StyledLabelTypography, + StyledValueGrid, +} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; + +import type { Database } from '@linode/api-v4'; + +interface ServiceURIProps { + database: Database; +} + +export const ServiceURI = (props: ServiceURIProps) => { + const { database } = props; + + const [hidePassword, setHidePassword] = useState(true); + const [isCopying, setIsCopying] = useState(false); + + const { + data: credentials, + error: credentialsError, + isLoading: credentialsLoading, + isFetching: credentialsFetching, + refetch: getDatabaseCredentials, + } = useDatabaseCredentialsQuery(database.engine, database.id, !hidePassword); + + const handleCopy = async () => { + if (!credentials) { + try { + setIsCopying(true); + const { data } = await getDatabaseCredentials(); + if (data) { + // copy with username/password data + copy( + `postgres://${data?.username}:${data?.password}@${database.hosts?.primary}?sslmode=require` + ); + } else { + enqueueSnackbar( + 'There was an error retrieving cluster credentials. Please try again.', + { variant: 'error' } + ); + } + setIsCopying(false); + } catch { + setIsCopying(false); + enqueueSnackbar( + 'There was an error retrieving cluster credentials. Please try again.', + { variant: 'error' } + ); + } + } + }; + + const serviceURI = `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}?sslmode=require`; + + // hide loading state if the user clicks on the copy icon + const showBtnLoading = + !isCopying && (credentialsLoading || credentialsFetching); + + return ( + + + Service URI + + + + postgres:// + {credentialsError ? ( + + ) : hidePassword || (!credentialsError && !credentials) ? ( + + ) : ( + `${credentials?.username}:${credentials?.password}` + )} + @{database.hosts?.primary}: + {'{connection pool port}'}/ + {'{connection pool label}'}?sslmode=require + + {isCopying ? ( + + ) : ( + + + + )} + + + ); +}; + +export const StyledCode = styled(Code, { + label: 'StyledCode', +})(() => ({ + margin: 0, +})); + +export const StyledCopyTooltip = styled(CopyTooltip, { + label: 'StyledCopyTooltip', +})(({ theme }) => ({ + alignSelf: 'center', + '& svg': { + height: theme.spacingFunction(16), + width: theme.spacingFunction(16), + }, + '&:hover': { + backgroundColor: 'transparent', + }, + display: 'flex', + margin: `0 ${theme.spacingFunction(4)}`, +})); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index a28fdd8815a..28002de6d87 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -211,6 +211,11 @@ const makeMockDatabase = (params: PathParams): Database => { db.ssl_connection = true; } + + if (db.engine === 'postgresql') { + db.connection_pool_port = 100; + } + const database = databaseFactory.build(db); if (database.platform !== 'rdbms-default') {