Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Added PG Bouncer ServiceURI component ([#13182](https://github.com/linode/manager/pull/13182))
7 changes: 3 additions & 4 deletions packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DatabaseConnectionPools database={mockDatabase} />);
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(<DatabaseConnectionPools database={mockDatabase} />);
const serviceURIText = screen.queryByText('Service URI');
expect(serviceURIText).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,9 @@ export const DatabaseConnectionPools = ({ database }: Props) => {
Add Pool
</Button>
</div>
{connectionPools && connectionPools.data.length > 0 && (
<ServiceURI database={database} />
)}
<div style={{ overflowX: 'auto', width: '100%' }}>
<Table
aria-label={'List of Connection pools'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ import { DatabaseConnectionPools } from './DatabaseConnectionPools';
import { DatabaseManageNetworking } from './DatabaseManageNetworking';

export const DatabaseNetworking = () => {
const flags = useFlags();
const navigate = useNavigate();
const { database, disabled, engine, isVPCEnabled } =
useDatabaseDetailContext();

const flags = useFlags();

const accessControlCopy = (
<Typography>{ACCESS_CONTROLS_IN_SETTINGS_TEXT}</Typography>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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',

Check warning on line 20 in packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Review this potentially hard-coded password. Raw Output: {"ruleId":"sonarjs/no-hardcoded-passwords","severity":1,"message":"Review this potentially hard-coded password.","line":20,"column":13,"nodeType":"Literal","messageId":"reviewPassword","endLine":20,"endColumn":26}
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(
<ServiceURI database={mockDatabase} />
);

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]');

Check warning on line 59 in packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Avoid direct Node access. Prefer using the methods from Testing Library. Raw Output: {"ruleId":"testing-library/no-node-access","severity":1,"message":"Avoid direct Node access. Prefer using the methods from Testing Library.","line":59,"column":34,"nodeType":"MemberExpression","messageId":"noNodeAccess"}

Check warning on line 59 in packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Avoid direct Node access. Prefer using the methods from Testing Library. Raw Output: {"ruleId":"testing-library/no-node-access","severity":1,"message":"Avoid direct Node access. Prefer using the methods from Testing Library.","line":59,"column":34,"nodeType":"MemberExpression","messageId":"noNodeAccess"}
expect(copyButton).toBeInTheDocument();
});

it('should reveal password after clicking reveal button', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
refetch: vi.fn(),
});
renderWithTheme(<ServiceURI database={mockDatabase} />);

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(<ServiceURI database={mockDatabase} />);

const errorRetryBtn = screen.getByRole('button', {
name: '{error. click to retry}',
});
expect(errorRetryBtn).toBeInTheDocument();
});
});
152 changes: 152 additions & 0 deletions packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Copy link
Copy Markdown
Contributor

@smans-akamai smans-akamai Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there will be a general case for the ServiceURI component outside of PgBouncer with UIE-9327, we should consider making this component reusable for both scenarios (Connection Pool URI and General Service URI)

Since we haven't confirmed the general format yet, this could be taken up as part of the next UIE ticket that adds to the Service URI to the Summary tab. But I just wanted to bring that up here for consideration.


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`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the assumption here that, after copying, they'll know to add the connection pool port and connection pool label before ?sslmode=require in the service URI string?

When they copy this, those will be missing and it looks like we're excluding the placeholders from the copy output for it. This looks like the intention, so I assume this behavior was already agreed upon.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ I have this question as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just added ?sslmode=require to the copied string

If the credentials call is successful, the pool port and label will be present in the copied string because we are copying again in https://github.com/hana-akamai/manager/blob/UIE-9380-service-uri-pg-bouncer/packages/manager/src/features/Databases/DatabaseDetail/ServiceURI.tsx#L43


// hide loading state if the user clicks on the copy icon
const showBtnLoading =
!isCopying && (credentialsLoading || credentialsFetching);

return (
<StyledGridContainer display="flex">
<Grid
size={{
md: 1.5,
xs: 3,
}}
>
<StyledLabelTypography>Service URI</StyledLabelTypography>
</Grid>
<Grid display="contents">
<StyledValueGrid
data-testid="service-uri"
size="grow"
sx={{ overflowX: 'auto', overflowY: 'hidden' }}
whiteSpace="pre"
>
postgres://
{credentialsError ? (
<Button
loading={showBtnLoading}
onClick={() => getDatabaseCredentials()}
sx={(theme) => ({
p: 0,
color: theme.tokens.alias.Content.Text.Negative,
'&:hover, &:focus': {
color: theme.tokens.alias.Content.Text.Negative,
},
})}
>
{`{error. click to retry}`}
</Button>
) : hidePassword || (!credentialsError && !credentials) ? (
<Button
loading={showBtnLoading}
onClick={() => {
setHidePassword(false);
getDatabaseCredentials();
}}
sx={{ p: 0 }}
>
{`{click to reveal password}`}
</Button>
) : (
`${credentials?.username}:${credentials?.password}`
)}
@{database.hosts?.primary}:
<StyledCode>{'{connection pool port}'}</StyledCode>/
<StyledCode>{'{connection pool label}'}</StyledCode>?sslmode=require
</StyledValueGrid>
{isCopying ? (
<Button loading sx={{ paddingLeft: 2 }}>
{' '}
</Button>
) : (
<Grid alignContent="center" size="auto">
<StyledCopyTooltip onClickCallback={handleCopy} text={serviceURI} />
</Grid>
)}
</Grid>
</StyledGridContainer>
);
};

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)}`,
}));
5 changes: 5 additions & 0 deletions packages/manager/src/mocks/serverHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down