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') {