Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Removed
---

Old `failed` property from `DatabaseStatus` ([#13505](https://github.com/linode/manager/pull/13505))
1 change: 0 additions & 1 deletion packages/api-v4/src/databases/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export interface DatabaseEngine {
export type DatabaseStatus =
| 'active'
| 'degraded'
| 'failed'
| 'migrated'
| 'migrating'
| 'provisioning'
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13505-fixed-1773782546945.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Disable Database credential buttons for resuming state ([#13505](https://github.com/linode/manager/pull/13505))
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('CopyTooltip', () => {
expect(getByText(mockText)).toBeVisible();
});

it('should disable the tooltip text with the disable property', async () => {
it('should disable the tooltip with the disable property', async () => {
const { getByLabelText } = renderWithTheme(
<CopyTooltip {...defaultProps} disabled />
);
Expand All @@ -53,6 +53,23 @@ describe('CopyTooltip', () => {
expect(copyIconButton).toBeDisabled();
});

it('should display tooltip reason with the disabledReason property', async () => {
const { getByLabelText, findByRole } = renderWithTheme(
<CopyTooltip
{...defaultProps}
disabled
disabledReason="Tooltip disabled"
/>
);

const copyIconButton = getByLabelText(`Copy ${mockText} to clipboard`);

await userEvent.hover(copyIconButton);
const copiedTooltip = await findByRole('tooltip');
expect(copiedTooltip).toBeInTheDocument();
expect(copiedTooltip).toHaveTextContent('Tooltip disabled');
});

it('should mask and toggle visibility of tooltip text with the masked property', async () => {
const { getByLabelText, getByTestId, getByText, queryByText } =
renderWithTheme(
Expand Down
20 changes: 20 additions & 0 deletions packages/manager/src/components/CopyTooltip/CopyTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface CopyTooltipProps {
* @default false
*/
disabled?: boolean;
/**
* Optionally display disabled reason as tooltip
*/
disabledReason?: string;
/**
* If true, the component is in controlled mode for text masking, meaning the parent component handles the visibility toggle.
* @default false
Expand Down Expand Up @@ -63,6 +67,7 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
className,
copyableText,
disabled,
disabledReason,
isMaskingControlled,
masked,
maskedTextLength,
Expand Down Expand Up @@ -105,6 +110,20 @@ export const CopyTooltip = (props: CopyTooltipProps) => {
</StyledIconButton>
);

if (disabled && disabledReason) {
return (
<Tooltip
className="copy-tooltip"
data-qa-copied
disableInteractive
placement={placement ?? 'top'}
title={disabledReason}
>
<span>{CopyButton}</span>
</Tooltip>
);
}

if (disabled) {
return CopyButton;
}
Expand Down Expand Up @@ -134,6 +153,7 @@ export const StyledIconButton = styled('button', {
label: 'StyledIconButton',
shouldForwardProp: omittedProps([
'copyableText',
'disabledReason',
'text',
'onClickCallback',
'masked',
Expand Down
1 change: 0 additions & 1 deletion packages/manager/src/factories/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { Factory } from '@linode/utilities';
export const possibleStatuses: DatabaseStatus[] = [
'active',
'degraded',
'failed',
'migrating',
'migrated',
'provisioning',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import type { Status } from 'src/components/StatusIcon/StatusIcon';
export const databaseStatusMap: Record<DatabaseStatus, Status> = {
active: 'active',
degraded: 'inactive',
failed: 'error',
migrated: 'inactive',
migrating: 'other',
provisioning: 'other',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DB_ROOT_USERNAME } from 'src/constants';
import {
CLUSTER_PROVISIONING_TEXT,
CREDENTIALS_ERROR_TEXT,
DISABLE_CREDENTIAL_STATES,
DISABLED_PASSWORD_BUTTON_TEXT,
} from 'src/features/Databases/constants';
import { useFlags } from 'src/hooks/useFlags';
Expand Down Expand Up @@ -74,9 +75,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => {
}
}, [showCredentials, credentialsError]);

const disableShowBtn = ['failed', 'provisioning', 'suspended'].includes(
database.status
);
const disableShowBtn = DISABLE_CREDENTIAL_STATES.includes(database.status);

Copy link
Copy Markdown
Contributor

@smans-akamai smans-akamai Mar 18, 2026

Choose a reason for hiding this comment

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

We had a similar change before in #13487 where failed, provisioning, and suspended statuses would throw this error for credentials. Have we confirmed that this the full list of statuses that can cause this with the backend?

We may want to check to make sure there aren't any others that can cause this such as degraded, migrating, resizing, suspending, restoring.

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.

It looks like the backend throws an error for anything that isn't active, but it may become more permissive in the future. For example, Aiven does support reading the password for powered-off services such as suspending/suspended/resuming.

Removed failed since it's apparently no longer used by DBaaS and added suspending to the list.

The other states are very ephemeral (I wasn't able to catch the in-between resizing/restoring state), so I don't think it's a huge issue if the button isn't disabled. I think being too restrictive on the disabled state would introduce more potential issues (such as not being able to reveal if the API eventually matches Aiven's more permissive states) than the minor friction of an error

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 saw the discussion and it sounds like this could also change in the future. The different statuses you have listed for DISABLE_CREDENTIAL_STATES look good to me and we can also adjust it if we need to. This change looks good to me, thanks for looking into it!

const credentialsBtn = (handleClick: () => void, btnText: string) => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import { ServiceURI } from './ServiceURI';

import type { DatabaseStatus } from '@linode/api-v4';

const mockCredentials = {
password: 'password123',
username: 'lnroot',
Expand Down Expand Up @@ -163,11 +165,12 @@
});

describe('ServiceURI', () => {
it('should render the service URI component and copy icon', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
refetch: vi.fn(),
});

it('should render the service URI component and copy icon', async () => {
const { container } = renderWithTheme(
<ServiceURI database={databaseWithNoVPC} />
);
Expand All @@ -188,11 +191,6 @@
});

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

renderWithTheme(<ServiceURI database={databaseWithNoVPC} />);

const revealPasswordBtn = screen.getByRole('button', {
Expand All @@ -208,10 +206,6 @@
});

it('should render general service URI if isGeneralServiceURI is true', () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(
<ServiceURI database={databaseWithNoVPC} isGeneralServiceURI />
);
Expand All @@ -228,10 +222,6 @@
});

it('should reveal general service URI password after clicking reveal button', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
refetch: vi.fn(),
});
renderWithTheme(
<ServiceURI database={databaseWithNoVPC} isGeneralServiceURI />
);
Expand All @@ -249,10 +239,6 @@
});

it('should render private service URI component if there is a private-only VPC', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(<ServiceURI database={databaseWithPrivateVPC} />);

const revealPasswordBtn = screen.getByRole('button', {
Expand All @@ -267,10 +253,6 @@
});

it('should render private general service URI component if there is a private-only VPC', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(
<ServiceURI database={databaseWithPrivateVPC} isGeneralServiceURI />
);
Expand All @@ -287,10 +269,6 @@
});

it('should render public service URI component if there is a VPC with public access', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(<ServiceURI database={databaseWithPublicVPC} />);

const revealPasswordBtn = screen.getByRole('button', {
Expand All @@ -305,10 +283,6 @@
});

it('should render private service URI component if there is a VPC with public access and showPrivateVPC is true', async () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(
<ServiceURI database={databaseWithPublicVPC} showPrivateVPC />
);
Expand All @@ -325,10 +299,6 @@
});

it('should render general private service URI if there is a VPC with public access, isGeneralServiceURI is true, and showPrivateVPC is true', () => {
queryMocks.useDatabaseCredentialsQuery.mockReturnValue({
data: mockCredentials,
});

renderWithTheme(
<ServiceURI
database={databaseWithPublicVPC}
Expand All @@ -347,4 +317,23 @@
`postgres://{click to reveal password}@${PRIVATE_PRIMARY}:3306/defaultdb?sslmode=require`
);
});

it('should disable the reveal password and copy icon if the Database is suspended', async () => {
const mockDatabase = {
...databaseWithNoVPC,
status: 'suspended' as DatabaseStatus,
};

const { container } = renderWithTheme(
<ServiceURI database={mockDatabase} />
);

const revealPasswordBtn = screen.getByRole('button', {
name: '{click to reveal password}',
});
// eslint-disable-next-line testing-library/no-container
const copyButton = container.querySelector('[data-qa-copy-btn]');

Check warning on line 335 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":335,"column":34,"nodeType":"MemberExpression","messageId":"noNodeAccess"}

Check warning on line 335 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":335,"column":34,"nodeType":"MemberExpression","messageId":"noNodeAccess"}
expect(revealPasswordBtn).toBeDisabled();
expect(copyButton).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip';
import {
CLUSTER_PROVISIONING_TEXT,
CREDENTIALS_ERROR_TEXT,
DISABLE_CREDENTIAL_STATES,
DISABLED_PASSWORD_BUTTON_TEXT,
} from 'src/features/Databases/constants';
import { StyledValueGrid } from 'src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style';
Expand Down Expand Up @@ -101,9 +102,10 @@ export const ServiceURI = (props: ServiceURIProps) => {
const showBtnLoading =
!hidePassword && !isCopying && (credentialsLoading || credentialsFetching);

const disablePasswordBtn = ['failed', 'provisioning', 'suspended'].includes(
const disablePasswordBtn = DISABLE_CREDENTIAL_STATES.includes(
database.status
);

const disabledPasswordTooltipText =
database.status === 'provisioning'
? CLUSTER_PROVISIONING_TEXT
Expand Down Expand Up @@ -177,6 +179,8 @@ export const ServiceURI = (props: ServiceURIProps) => {
) : (
<Grid alignContent="center" size="auto">
<StyledCopyTooltip
disabled={disablePasswordBtn}
disabledReason={disabledPasswordTooltipText}
onClickCallback={handleCopy}
text={getServiceURIText(credentials, isGeneralServiceURI)}
/>
Expand Down
8 changes: 7 additions & 1 deletion packages/manager/src/features/Databases/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const CREDENTIALS_ERROR_TEXT =
'There was an error retrieving cluster credentials. Please try again.';

export const DISABLED_PASSWORD_BUTTON_TEXT =
'Your root password is unavailable when your Database Cluster is in a failed or suspended state.';
'Your root password is unavailable when your Database Cluster is in a suspended or resuming state.';

export const CLUSTER_PROVISIONING_TEXT =
'Your Database Cluster is currently provisioning.';
Expand Down Expand Up @@ -103,3 +103,9 @@ export const usernameOptions = [
]; // Currently the only options for the username field

export const DEFAULT_PAGE_SIZES = [25, 50, 75, 100];
export const DISABLE_CREDENTIAL_STATES = [
'provisioning',
'resuming',
'suspending',
'suspended',
];
Loading