Skip to content

Commit 218ab5e

Browse files
authored
upcoming: [UIE-9380] - Service URI PG Bouncer Connection Details Section (#13182)
## Description 📝 Add reusable Service URI component for PG Bouncer and place it in the Networking -> Connection Pools section ## How to test 🧪 ### Prerequisites (How to setup test environment) - Turn on the legacy MSW and make sure you have the `Database PgBouncer` flag on ### Verification steps (How to verify changes) - [ ] With the legacy MSW & flag on, navigate to a Database cluster's details page, then Network tab - [ ] You should see a Service URI section. Test `click to reveal` password, copying, loading, error & mobile states
1 parent 3594035 commit 218ab5e

File tree

8 files changed

+286
-6
lines changed

8 files changed

+286
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Added PG Bouncer ServiceURI component ([#13182](https://github.com/linode/manager/pull/13182))

packages/manager/src/components/CopyTooltip/CopyTooltip.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,13 @@ export interface CopyTooltipProps {
3434
* @default false
3535
*/
3636
masked?: boolean;
37-
/**
38-
* Callback to be executed when the icon is clicked.
39-
*/
40-
4137
/**
4238
* 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.
4339
*/
4440
maskedTextLength?: MaskableTextLength | number;
41+
/**
42+
* Callback to be executed when the icon is clicked.
43+
*/
4544
onClickCallback?: () => void;
4645
/**
4746
* The placement of the tooltip.

packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,26 @@ describe('DatabaseManageNetworkingDrawer Component', () => {
108108
);
109109
expect(errorStateText).toBeInTheDocument();
110110
});
111+
112+
it('should render service URI component if there are connection pools', () => {
113+
queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({
114+
data: makeResourcePage([mockConnectionPool]),
115+
isLoading: false,
116+
});
117+
118+
renderWithTheme(<DatabaseConnectionPools database={mockDatabase} />);
119+
const serviceURIText = screen.getByText('Service URI');
120+
expect(serviceURIText).toBeInTheDocument();
121+
});
122+
123+
it('should not render service URI component if there are no connection pools', () => {
124+
queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({
125+
data: makeResourcePage([]),
126+
isLoading: false,
127+
});
128+
129+
renderWithTheme(<DatabaseConnectionPools database={mockDatabase} />);
130+
const serviceURIText = screen.queryByText('Service URI');
131+
expect(serviceURIText).not.toBeInTheDocument();
132+
});
111133
});

packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
makeSettingsItemStyles,
3131
StyledActionMenuWrapper,
3232
} from '../../shared.styles';
33+
import { ServiceURI } from '../ServiceURI';
3334

3435
import type { Database } from '@linode/api-v4';
3536
import type { Action } from 'src/components/ActionMenu/ActionMenu';
@@ -104,6 +105,9 @@ export const DatabaseConnectionPools = ({ database }: Props) => {
104105
Add Pool
105106
</Button>
106107
</div>
108+
{connectionPools && connectionPools.data.length > 0 && (
109+
<ServiceURI database={database} />
110+
)}
107111
<div style={{ overflowX: 'auto', width: '100%' }}>
108112
<Table
109113
aria-label={'List of Connection pools'}

packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseNetworking.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ import { DatabaseConnectionPools } from './DatabaseConnectionPools';
1111
import { DatabaseManageNetworking } from './DatabaseManageNetworking';
1212

1313
export const DatabaseNetworking = () => {
14+
const flags = useFlags();
1415
const navigate = useNavigate();
1516
const { database, disabled, engine, isVPCEnabled } =
1617
useDatabaseDetailContext();
1718

18-
const flags = useFlags();
19-
2019
const accessControlCopy = (
2120
<Typography>{ACCESS_CONTROLS_IN_SETTINGS_TEXT}</Typography>
2221
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import * as React from 'react';
4+
import { describe, it } from 'vitest';
5+
6+
import { databaseFactory } from 'src/factories/databases';
7+
import { renderWithTheme } from 'src/utilities/testHelpers';
8+
9+
import { ServiceURI } from './ServiceURI';
10+
11+
const mockDatabase = databaseFactory.build({
12+
connection_pool_port: 100,
13+
engine: 'postgresql',
14+
id: 1,
15+
platform: 'rdbms-default',
16+
private_network: null,
17+
});
18+
19+
const mockCredentials = {
20+
password: 'password123',
21+
username: 'lnroot',
22+
};
23+
24+
// Hoist query mocks
25+
const queryMocks = vi.hoisted(() => {
26+
return {
27+
useDatabaseCredentialsQuery: vi.fn(),
28+
};
29+
});
30+
31+
vi.mock('@linode/queries', async () => {
32+
const actual = await vi.importActual('@linode/queries');
33+
return {
34+
...actual,
35+
useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery,
36+
};
37+
});
38+
39+
describe('ServiceURI', () => {
40+
it('should render the service URI component and copy icon', async () => {
41+
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
42+
data: mockCredentials,
43+
});
44+
const { container } = renderWithTheme(
45+
<ServiceURI database={mockDatabase} />
46+
);
47+
48+
const revealPasswordBtn = screen.getByRole('button', {
49+
name: '{click to reveal password}',
50+
});
51+
const serviceURIText = screen.getByTestId('service-uri').textContent;
52+
53+
expect(revealPasswordBtn).toBeInTheDocument();
54+
expect(serviceURIText).toBe(
55+
`postgres://{click to reveal password}@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require`
56+
);
57+
58+
// eslint-disable-next-line testing-library/no-container
59+
const copyButton = container.querySelector('[data-qa-copy-btn]');
60+
expect(copyButton).toBeInTheDocument();
61+
});
62+
63+
it('should reveal password after clicking reveal button', async () => {
64+
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
65+
data: mockCredentials,
66+
refetch: vi.fn(),
67+
});
68+
renderWithTheme(<ServiceURI database={mockDatabase} />);
69+
70+
const revealPasswordBtn = screen.getByRole('button', {
71+
name: '{click to reveal password}',
72+
});
73+
await userEvent.click(revealPasswordBtn);
74+
75+
const serviceURIText = screen.getByTestId('service-uri').textContent;
76+
expect(revealPasswordBtn).not.toBeInTheDocument();
77+
expect(serviceURIText).toBe(
78+
`postgres://lnroot:password123@db-mysql-primary-0.b.linodeb.net:{connection pool port}/{connection pool label}?sslmode=require`
79+
);
80+
});
81+
82+
it('should render error retry button if the credentials call fails', () => {
83+
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
84+
error: new Error('Failed to fetch credentials'),
85+
});
86+
87+
renderWithTheme(<ServiceURI database={mockDatabase} />);
88+
89+
const errorRetryBtn = screen.getByRole('button', {
90+
name: '{error. click to retry}',
91+
});
92+
expect(errorRetryBtn).toBeInTheDocument();
93+
});
94+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useDatabaseCredentialsQuery } from '@linode/queries';
2+
import { Button } from '@linode/ui';
3+
import { Grid, styled } from '@mui/material';
4+
import copy from 'copy-to-clipboard';
5+
import { enqueueSnackbar } from 'notistack';
6+
import React, { useState } from 'react';
7+
8+
import { Code } from 'src/components/Code/Code';
9+
import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
10+
import {
11+
StyledGridContainer,
12+
StyledLabelTypography,
13+
StyledValueGrid,
14+
} from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style';
15+
16+
import type { Database } from '@linode/api-v4';
17+
18+
interface ServiceURIProps {
19+
database: Database;
20+
}
21+
22+
export const ServiceURI = (props: ServiceURIProps) => {
23+
const { database } = props;
24+
25+
const [hidePassword, setHidePassword] = useState(true);
26+
const [isCopying, setIsCopying] = useState(false);
27+
28+
const {
29+
data: credentials,
30+
error: credentialsError,
31+
isLoading: credentialsLoading,
32+
isFetching: credentialsFetching,
33+
refetch: getDatabaseCredentials,
34+
} = useDatabaseCredentialsQuery(database.engine, database.id, !hidePassword);
35+
36+
const handleCopy = async () => {
37+
if (!credentials) {
38+
try {
39+
setIsCopying(true);
40+
const { data } = await getDatabaseCredentials();
41+
if (data) {
42+
// copy with username/password data
43+
copy(
44+
`postgres://${data?.username}:${data?.password}@${database.hosts?.primary}?sslmode=require`
45+
);
46+
} else {
47+
enqueueSnackbar(
48+
'There was an error retrieving cluster credentials. Please try again.',
49+
{ variant: 'error' }
50+
);
51+
}
52+
setIsCopying(false);
53+
} catch {
54+
setIsCopying(false);
55+
enqueueSnackbar(
56+
'There was an error retrieving cluster credentials. Please try again.',
57+
{ variant: 'error' }
58+
);
59+
}
60+
}
61+
};
62+
63+
const serviceURI = `postgres://${credentials?.username}:${credentials?.password}@${database.hosts?.primary}?sslmode=require`;
64+
65+
// hide loading state if the user clicks on the copy icon
66+
const showBtnLoading =
67+
!isCopying && (credentialsLoading || credentialsFetching);
68+
69+
return (
70+
<StyledGridContainer display="flex">
71+
<Grid
72+
size={{
73+
md: 1.5,
74+
xs: 3,
75+
}}
76+
>
77+
<StyledLabelTypography>Service URI</StyledLabelTypography>
78+
</Grid>
79+
<Grid display="contents">
80+
<StyledValueGrid
81+
data-testid="service-uri"
82+
size="grow"
83+
sx={{ overflowX: 'auto', overflowY: 'hidden' }}
84+
whiteSpace="pre"
85+
>
86+
postgres://
87+
{credentialsError ? (
88+
<Button
89+
loading={showBtnLoading}
90+
onClick={() => getDatabaseCredentials()}
91+
sx={(theme) => ({
92+
p: 0,
93+
color: theme.tokens.alias.Content.Text.Negative,
94+
'&:hover, &:focus': {
95+
color: theme.tokens.alias.Content.Text.Negative,
96+
},
97+
})}
98+
>
99+
{`{error. click to retry}`}
100+
</Button>
101+
) : hidePassword || (!credentialsError && !credentials) ? (
102+
<Button
103+
loading={showBtnLoading}
104+
onClick={() => {
105+
setHidePassword(false);
106+
getDatabaseCredentials();
107+
}}
108+
sx={{ p: 0 }}
109+
>
110+
{`{click to reveal password}`}
111+
</Button>
112+
) : (
113+
`${credentials?.username}:${credentials?.password}`
114+
)}
115+
@{database.hosts?.primary}:
116+
<StyledCode>{'{connection pool port}'}</StyledCode>/
117+
<StyledCode>{'{connection pool label}'}</StyledCode>?sslmode=require
118+
</StyledValueGrid>
119+
{isCopying ? (
120+
<Button loading sx={{ paddingLeft: 2 }}>
121+
{' '}
122+
</Button>
123+
) : (
124+
<Grid alignContent="center" size="auto">
125+
<StyledCopyTooltip onClickCallback={handleCopy} text={serviceURI} />
126+
</Grid>
127+
)}
128+
</Grid>
129+
</StyledGridContainer>
130+
);
131+
};
132+
133+
export const StyledCode = styled(Code, {
134+
label: 'StyledCode',
135+
})(() => ({
136+
margin: 0,
137+
}));
138+
139+
export const StyledCopyTooltip = styled(CopyTooltip, {
140+
label: 'StyledCopyTooltip',
141+
})(({ theme }) => ({
142+
alignSelf: 'center',
143+
'& svg': {
144+
height: theme.spacingFunction(16),
145+
width: theme.spacingFunction(16),
146+
},
147+
'&:hover': {
148+
backgroundColor: 'transparent',
149+
},
150+
display: 'flex',
151+
margin: `0 ${theme.spacingFunction(4)}`,
152+
}));

packages/manager/src/mocks/serverHandlers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ const makeMockDatabase = (params: PathParams): Database => {
211211

212212
db.ssl_connection = true;
213213
}
214+
215+
if (db.engine === 'postgresql') {
216+
db.connection_pool_port = 100;
217+
}
218+
214219
const database = databaseFactory.build(db);
215220

216221
if (database.platform !== 'rdbms-default') {

0 commit comments

Comments
 (0)