diff --git a/packages/api-v4/.changeset/pr-13504-added-1775602581034.md b/packages/api-v4/.changeset/pr-13504-added-1775602581034.md new file mode 100644 index 00000000000..11cd67a7fe9 --- /dev/null +++ b/packages/api-v4/.changeset/pr-13504-added-1775602581034.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +SubnetAssignedDatabaseData interface and update to Subnet to include databases property ([#13504](https://github.com/linode/manager/pull/13504)) diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index 8bd11c29d41..33d0c486f35 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -42,6 +42,7 @@ export interface CreateSubnetPayload { export interface Subnet extends CreateSubnetPayload { created: string; + databases: SubnetAssignedDatabaseData[]; id: number; linodes: SubnetAssignedLinodeData[]; nodebalancers: SubnetAssignedNodeBalancerData[]; @@ -68,6 +69,12 @@ export interface SubnetAssignedNodeBalancerData { ipv4_range: string; } +export interface SubnetAssignedDatabaseData { + id: number; + ipv4_range: string; + ipv6_ranges: null | { range: string }[]; +} + export interface VPCIP { active: boolean; address: null | string; diff --git a/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md new file mode 100644 index 00000000000..d1e65099f62 --- /dev/null +++ b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS resource counts and databases resource table in VPC UI ([#13504](https://github.com/linode/manager/pull/13504)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index a77562d06ba..0db33ce2d62 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -94,6 +94,7 @@ const options: { flag: keyof Flags; label: string }[] = [ label: 'Object Storage Contextual Metrics', }, { flag: 'objSummaryPage', label: 'OBJ Summary Page' }, + { flag: 'vpcDbaasResources', label: 'VPC DBaaS Resources' }, { flag: 'vpcIpv6', label: 'VPC IPv6' }, { flag: 'reserveIp', label: 'Reserve IP' }, { flag: 'marketplaceV2GlobalBanner', label: 'Marketplace V2 Global Banner' }, diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 2d47993e8f1..9fffbc8da76 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -199,6 +199,8 @@ export const databaseInstanceFactory = label: Factory.each((i) => `example.com-database-${i}`), members: { '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', }, platform: 'rdbms-default', region: Factory.each((i) => possibleRegions[i % possibleRegions.length]), @@ -268,6 +270,8 @@ export const databaseFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `database-${i}`), members: { '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', }, oldest_restore_time: '2024-09-15T17:15:12', platform: 'rdbms-default', diff --git a/packages/manager/src/factories/subnets.ts b/packages/manager/src/factories/subnets.ts index d81bf06a94c..4c460a5dc15 100644 --- a/packages/manager/src/factories/subnets.ts +++ b/packages/manager/src/factories/subnets.ts @@ -2,6 +2,7 @@ import { Factory } from '@linode/utilities'; import type { Subnet, + SubnetAssignedDatabaseData, SubnetAssignedLinodeData, SubnetAssignedNodeBalancerData, } from '@linode/api-v4/lib/vpcs/types'; @@ -27,6 +28,23 @@ export const subnetAssignedNodebalancerDataFactory = ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), }); +export const subnetAssignedDatabaseDataFactory = + Factory.Sync.makeFactory({ + id: Factory.each((i) => i), + ipv4_range: Factory.each((i) => `192.168.${i}.0/30`), + ipv6_ranges: Factory.each((i) => [ + { + range: `2600:3c11:e41c:${i}::/64`, + }, + { + range: `2600:3c11:e41c:${i}::/64`, + }, + { + range: `2600:3c11:e41c:${i}::/64`, + }, + ]), + }); + export const subnetFactory = Factory.Sync.makeFactory({ created: '2023-07-12T16:08:53', id: Factory.each((i) => i), @@ -46,5 +64,12 @@ export const subnetFactory = Factory.Sync.makeFactory({ }) ) ), + databases: Factory.each((i) => + Array.from({ length: 3 }, (_, arrIdx) => + subnetAssignedDatabaseDataFactory.build({ + id: i * 10 + arrIdx, + }) + ) + ), updated: '2023-07-12T16:08:53', }); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 3e59d492994..76760ed7774 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -294,6 +294,7 @@ export interface Flags { udp: boolean; vmHostMaintenance: VMHostMaintenanceFlag; volumeSummaryPage: boolean; + vpcDbaasResources: boolean; vpcIpv6: boolean; } diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index d033335b735..be746f7a77a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -68,7 +68,8 @@ export const DatabaseLanding = () => { page_size: newDatabasesPagination.pageSize, }, databasesFilter, - isDefaultEnabled // TODO (UIE-8634): Determine if check if still necessary + isDefaultEnabled, // TODO (UIE-8634): Determine if check is still necessary + 20000 ); if (databasesError) { diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index decaf48b49a..1e28d08f156 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -12,7 +12,10 @@ import { Link } from 'src/components/Link'; import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/DatabaseStatusDisplay'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { DatabaseActionMenu } from 'src/features/Databases/DatabaseLanding/DatabaseActionMenu'; -import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; +import { + getIsLinkInactive, + useIsDatabasesEnabled, +} from 'src/features/Databases/utilities'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; @@ -64,11 +67,6 @@ export const DatabaseRow = ({ const plan = types?.find((t: DatabaseType) => t.id === type); const formattedPlan = plan && formatStorageUnits(plan.label); const actualRegion = regions?.find((r) => r.id === region); - const isLinkInactive = - status === 'suspended' || - status === 'suspending' || - status === 'resuming' || - status === 'migrated'; const { isDatabasesV2GA } = useIsDatabasesEnabled(); const configuration = @@ -97,7 +95,7 @@ export const DatabaseRow = ({ flex: '0 1 20.5%', }} > - {isDatabasesV2GA && isLinkInactive ? ( + {isDatabasesV2GA && getIsLinkInactive(status) ? ( label ) : ( {label} diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 8760f8d392c..2248b19eca1 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -9,6 +9,7 @@ import type { DatabaseEngine, DatabaseFork, DatabaseInstance, + DatabaseStatus, Engine, PendingUpdates, } from '@linode/api-v4'; @@ -256,3 +257,6 @@ export const convertPrivateToPublicHostname = (host: string) => { const baseHostName = host.slice(privateStrIndex + 1); return `public-${baseHostName}`; }; + +export const getIsLinkInactive = (status: DatabaseStatus) => + ['migrated', 'resuming', 'suspended', 'suspending'].includes(status); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx index c459d13cdab..874a824c33e 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.test.tsx @@ -52,6 +52,7 @@ const props = { label: 'subnet-1', linodes: [], nodebalancers: [], + databases: [], created: '', updated: '', } as Subnet, diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx new file mode 100644 index 00000000000..d11fa0c02a8 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; + +import { + databaseInstanceFactory, + subnetAssignedDatabaseDataFactory, +} from 'src/factories'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { SubnetDatabaseRow } from './SubnetDatabaseRow'; + +import type { DatabaseInstance } from '@linode/api-v4'; + +const mockIpv6Range = '0000:db1::/32'; +const databaseLabel = 'test-database-1'; +const mockDatabase = databaseInstanceFactory.build({ + id: 1, + label: databaseLabel, +}); + +const mockAssignedDatabase = subnetAssignedDatabaseDataFactory.build({ + id: 1, + ipv4_range: '1.1.1.1/32', + ipv6_ranges: [{ range: mockIpv6Range }], +}); + +describe('SubnetDatabaseRow', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render SubnetDatabaseRow', () => { + const dbWithPrimary: DatabaseInstance = { + ...mockDatabase, + members: { '2.2.2.2': 'primary' }, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('2.2.2.2'); + }); + + it('should render SubnetDatabaseRow with multiple failover IPs', () => { + const dbWithFailovers: DatabaseInstance = { + ...mockDatabase, + members: { + '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', + }, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('2.2.2.2, 2.2.2.3, 2.2.2.4'); + }); + + it('should render SubnetDatabaseRow with no members', () => { + const dbWithNoMembers = { + ...mockDatabase, + members: {}, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText(mockIpv6Range); + getByText('—'); + }); + + it('should render SubnetDatabaseRow with no ipv6 ranges', () => { + const assignedDatabaseWithNoIpv6 = { + ...mockAssignedDatabase, + ipv6_ranges: null, + }; + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText(mockAssignedDatabase.ipv4_range); + getByText('—'); + }); + + it('should render SubnetDatabaseRow with HA cluster', () => { + const haDatabase = databaseInstanceFactory.build({ + id: 1, + label: databaseLabel, + cluster_size: 3, + }); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + getByText(databaseLabel); + getByText('HA'); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx new file mode 100644 index 00000000000..b4844d22940 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx @@ -0,0 +1,97 @@ +import { Box, Chip } from '@linode/ui'; +import * as React from 'react'; + +import { Link } from 'src/components/Link'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { getIsLinkInactive } from 'src/features/Databases/utilities'; +import { determineNoneSingleOrMultipleWithChip } from 'src/utilities/noneSingleOrMultipleWithChip'; + +import type { + DatabaseInstance, + SubnetAssignedDatabaseData, +} from '@linode/api-v4'; + +interface Props { + assignedDatabase: SubnetAssignedDatabaseData; + database: DatabaseInstance; +} + +export const SubnetDatabaseRow = ({ assignedDatabase, database }: Props) => { + const ipv6Ranges = + assignedDatabase?.ipv6_ranges + ?.map((rangeObj) => rangeObj.range) + .filter((range) => range !== undefined) ?? []; + + const ipv6RangeContent = assignedDatabase?.ipv6_ranges + ? determineNoneSingleOrMultipleWithChip(ipv6Ranges) + : '—'; + + // For IPv4 addresses column, we display the primary and failover IPs for the database instance. + const getIPv4AddressesContent = () => { + const memberKeys = Object.keys(database.members); + + if (memberKeys.length === 0) { + return '—'; + } + // If there's only one key in members, it only contains the primary IPv4 which should be returned. + if (memberKeys.length === 1) { + return memberKeys[0]; + } + + // Retrieve primary and failover IPv4 addresses since there can be up to 2 failover IPv4 addresses for multi-node HA clusters. + const primaryIPv4 = memberKeys.find( + (key) => database.members[key] === 'primary' + ); + const failoverIPv4s = memberKeys.filter( + (key) => database.members[key] === 'failover' + ); + + return [primaryIPv4, ...failoverIPv4s].join(', '); + }; + + return ( + + + ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacingFunction(8), + })} + > + {getIsLinkInactive(database.status) ? ( + database?.label + ) : ( + + {database?.label} + + )} + {database.cluster_size > 1 && ( + ({ borderColor: theme.color.green, mx: 0, my: 0 })} + variant="outlined" + /> + )} + + + {getIPv4AddressesContent()} + {assignedDatabase?.ipv4_range} + {ipv6RangeContent} + + ); +}; + +export const SubnetDatabasesTableRowHead = ( + + Database Cluster + IPv4 Address(es) + VPC IPv4 Range + VPC IPv6 Range + +); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx new file mode 100644 index 00000000000..a58575783f1 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; + +import { + databaseInstanceFactory, + subnetAssignedDatabaseDataFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SubnetDatabasesTable } from './SubnetDatabasesTable'; + +const queryMocks = vi.hoisted(() => ({ + useDatabasesQuery: vi.fn().mockReturnValue({ + data: [], + }), +})); + +const mockSubnetDatabasesData = [ + subnetAssignedDatabaseDataFactory.build({ id: 1 }), +]; + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useDatabasesQuery: queryMocks.useDatabasesQuery, + }; +}); + +describe('SubnetDatabasesTable', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render table for SubnetDatabasesTable when there are assigned databases', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([ + databaseInstanceFactory.build({ id: 1, label: 'test-database-1' }), + ]), + isLoading: false, + error: null, + }); + const { getByText } = renderWithTheme( + + ); + getByText('test-database-1'); + getByText('Database Cluster'); + }); + + it('should render loading state for SubnetDatabasesTable', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: true, + error: null, + }); + + const { getByTestId } = renderWithTheme( + + ); + getByTestId('circle-progress'); + }); + + it('should render empty state for SubnetDatabasesTable when no databases are returned', async () => { + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + error: null, + }); + + const { getByTestId } = renderWithTheme( + + ); + getByTestId('table-row-empty'); + }); + + it('should render error state for SubnetDatabasesTable', async () => { + const expectedErrorMessage = 'Failed to fetch databases'; + queryMocks.useDatabasesQuery.mockReturnValue({ + data: makeResourcePage([]), + isLoading: false, + error: [{ reason: expectedErrorMessage }], + }); + + const { getByText } = renderWithTheme( + + ); + getByText(expectedErrorMessage); + }); +}); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx new file mode 100644 index 00000000000..2a3272408d4 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -0,0 +1,134 @@ +import { useDatabasesQuery } from '@linode/queries'; +import { CircleProgress } from '@linode/ui'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { + SubnetDatabaseRow, + SubnetDatabasesTableRowHead, +} from './SubnetDatabaseRow'; + +import type { SubnetAssignedDatabaseData } from '@linode/api-v4'; +interface Props { + subnetDatabasesData: SubnetAssignedDatabaseData[]; +} + +export const SubnetDatabasesTable = ({ subnetDatabasesData }: Props) => { + const theme = useTheme(); + + const [pageSize, setPageSize] = React.useState(25); + const [page, setPage] = React.useState(1); + + const assignedSubnetDatabasesMap: Record = + {}; // Store assigned databases in map for easy lookup when rendering subnet database rows + const databaseIDsToFilter = subnetDatabasesData.map((database) => { + assignedSubnetDatabasesMap[database.id] = database; + return { + id: database.id, + }; + }); + + const { + data: databases, + error: databasesError, + isLoading, + } = useDatabasesQuery( + { + page_size: pageSize, + page, + }, + { + '+or': databaseIDsToFilter, + }, + true + ); + + const DatabasesTableWrapper = ({ + children, + }: { + children: React.ReactNode; + }) => ( + + + {SubnetDatabasesTableRowHead} + + {children} +
+ ); + + if (isLoading) { + return ( + + + + + + + + ); + } + + if (databasesError) { + return ( + + + + ); + } + + if (databases && databases.data.length === 0) { + return ( + + + + ); + } + + return ( + <> + + {databases?.data.map((database) => ( + + ))} + + setPage(page)} + handleSizeChange={(pageSize: number) => setPageSize(pageSize)} + page={page} + pageSize={pageSize} + sx={{ + border: 'none', + borderBottom: `1px solid ${theme.tokens.component.Table.Row.Border}`, + borderTop: `1px solid ${theme.tokens.component.Table.Row.Border}`, + }} + /> + + ); +}; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx index 92e34e6e2a4..8d2eb2fd512 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.test.tsx @@ -114,14 +114,14 @@ describe('VPC Detail Summary section', () => { }); const { getByText } = renderWithTheme(, { - flags: { nodebalancerVpc: true }, + flags: { nodebalancerVpc: true, vpcDbaasResources: true }, }); - // there is 1 subnet with 8 resources (5 Linodes, 3 nbs) + // there is 1 subnet with 11 resources (5 Linodes, 3 nbs, 3 dbs) expect(getByText('Subnets')).toBeVisible(); expect(getByText('1')).toBeVisible(); expect(getByText('Resources')).toBeVisible(); - expect(getByText('8')).toBeVisible(); + expect(getByText('11')).toBeVisible(); expect(getByText('Region')).toBeVisible(); expect(getByText('US, Newark, NJ')).toBeVisible(); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index db6c3f55310..98deb622af5 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -19,6 +19,7 @@ import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { LKE_ENTERPRISE_AUTOGEN_VPC_WARNING } from 'src/features/Kubernetes/constants'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { getIsVPCLKEEnterpriseCluster, @@ -51,7 +52,9 @@ const VPCDetail = () => { isLoading, } = useVPCQuery(Number(vpcId) || -1, Boolean(vpcId)); - const flags = useIsNodebalancerVPCEnabled(); + const flags = useFlags(); + + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const { data: regions } = useRegionsQuery(); @@ -104,8 +107,11 @@ const VPCDetail = () => { const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? vpc.region; - const numResources = flags.isNodebalancerVPCEnabled - ? getUniqueResourcesFromSubnets(vpc.subnets) + const numResources = isNodebalancerVPCEnabled + ? getUniqueResourcesFromSubnets( + vpc.subnets, + Boolean(flags.vpcDbaasResources) + ) : getUniqueLinodesFromSubnets(vpc.subnets); const summaryData = [ @@ -115,7 +121,7 @@ const VPCDetail = () => { value: vpc.subnets.length, }, { - label: flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes', + label: isNodebalancerVPCEnabled ? 'Resources' : 'Linodes', value: numResources, }, ], diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index d885bc3bb61..2de1b993b04 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -122,7 +122,7 @@ describe('VPC Subnets table', () => { vpcRegion="" />, { - flags: { nodebalancerVpc: true }, + flags: { nodebalancerVpc: true, vpcDbaasResources: true }, } ); @@ -137,7 +137,11 @@ describe('VPC Subnets table', () => { expect(getByText('Resources')).toBeVisible(); expect( - getByText(subnet.linodes.length + subnet.nodebalancers.length) + getByText( + subnet.linodes.length + + subnet.nodebalancers.length + + subnet.databases.length + ) ).toBeVisible(); const actionMenuButton = getByLabelText( @@ -294,6 +298,37 @@ describe('VPC Subnets table', () => { } ); + it( + 'should show Databases table head data when table is expanded', + { timeout: 15_000 }, + async () => { + const subnet = subnetFactory.build(); + + queryMocks.useSubnetsQuery.mockReturnValue({ + data: { + data: [subnet], + }, + }); + + const { getByLabelText, findByText } = renderWithTheme( + , + { flags: { nodebalancerVpc: true, vpcDbaasResources: true } } + ); + + const expandTableButton = getByLabelText(`expand ${subnet.label} row`); + await userEvent.click(expandTableButton); + + await findByText('Database Cluster'); + await findByText('IPv4 Address(es)'); + await findByText('VPC IPv4 Range'); + await findByText('VPC IPv6 Range'); + } + ); + it('should disable "Create Subnet" button when user does not have create_vpc_subnet permission', async () => { queryMocks.userPermissions.mockReturnValue({ data: { diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 37fea394caa..e2bf7fddf95 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -29,6 +29,7 @@ import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDrawer'; import { useIsNodebalancerVPCEnabled } from 'src/features/NodeBalancers/utils'; import { SubnetActionMenu } from 'src/features/VPCs/VPCDetail/SubnetActionMenu'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useVPCDualStack } from 'src/hooks/useVPCDualStack'; @@ -37,6 +38,7 @@ import { SUBNET_ACTION_PATH } from '../constants'; import { VPC_DETAILS_ROUTE } from '../constants'; import { SubnetAssignLinodesDrawer } from './SubnetAssignLinodesDrawer'; import { SubnetCreateDrawer } from './SubnetCreateDrawer'; +import { SubnetDatabasesTable } from './SubnetDatabasesTable'; import { SubnetDeleteDialog } from './SubnetDeleteDialog'; import { SubnetEditDrawer } from './SubnetEditDrawer'; import { SubnetLinodeRow, SubnetLinodeTableRowHead } from './SubnetLinodeRow'; @@ -90,6 +92,7 @@ export const VPCSubnetsTable = (props: Props) => { const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); const { isDualStackEnabled } = useVPCDualStack(); + const flags = useFlags(); const { data: permissions } = usePermissions( 'vpc', @@ -333,7 +336,7 @@ export const VPCSubnetsTable = (props: Props) => { )} - {`${isNodebalancerVPCEnabled ? subnet.linodes.length + uniqueNodebalancers.length : subnet.linodes.length}`} + {`${isNodebalancerVPCEnabled ? subnet.linodes.length + uniqueNodebalancers.length + (flags.vpcDbaasResources ? subnet.databases.length : 0) : subnet.linodes.length}`} @@ -403,6 +406,9 @@ export const VPCSubnetsTable = (props: Props) => { )} + {flags.vpcDbaasResources && subnet.databases?.length > 0 && ( + + )} ); diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx index df02ebdebef..79837dd642c 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -21,6 +21,7 @@ import { VPC_LANDING_TABLE_PREFERENCE_KEY, } from 'src/features/VPCs/constants'; import { VPC_DOCS_LINK, VPC_LABEL } from 'src/features/VPCs/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -98,7 +99,8 @@ const VPCLanding = () => { error: selectedVPCError, } = useVPCQuery(params.vpcId ?? -1, !!params.vpcId); - const flags = useIsNodebalancerVPCEnabled(); + const flags = useFlags(); + const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); if (error) { return ( @@ -168,7 +170,7 @@ const VPCLanding = () => { Subnets - {`${flags.isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`} + {`${isNodebalancerVPCEnabled ? 'Resources' : 'Linodes'}`} @@ -176,9 +178,10 @@ const VPCLanding = () => { {vpcs?.data.map((vpc: VPC) => ( handleDeleteVPC(vpc)} handleEditVPC={() => handleEditVPC(vpc)} - isNodebalancerVPCEnabled={flags.isNodebalancerVPCEnabled} + isNodebalancerVPCEnabled={isNodebalancerVPCEnabled} key={vpc.id} vpc={vpc} /> diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx index 32f2f31e0cd..98d0d9f47ab 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.test.tsx @@ -32,6 +32,7 @@ describe('VPC Table Row', () => { const { getByText, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( { const { getByTestId, getByLabelText } = renderWithTheme( wrapWithTableBody( void; handleEditVPC: () => void; isNodebalancerVPCEnabled: boolean; @@ -27,6 +28,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, isNodebalancerVPCEnabled, + displayVPCDBaaSResources, vpc, }: Props) => { const { id, label, subnets } = vpc; @@ -36,7 +38,7 @@ export const VPCRow = ({ const regionLabel = regions?.find((r) => r.id === vpc.region)?.label ?? ''; const numResources = isNodebalancerVPCEnabled - ? getUniqueResourcesFromSubnets(vpc.subnets) + ? getUniqueResourcesFromSubnets(vpc.subnets, displayVPCDBaaSResources) : getUniqueLinodesFromSubnets(vpc.subnets); const { data: permissions, isLoading } = usePermissions( diff --git a/packages/manager/src/features/VPCs/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index 0d6682a0337..b81b674eccb 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -6,6 +6,7 @@ import { import { linodeConfigFactory } from 'src/factories/linodeConfigs'; import { + subnetAssignedDatabaseDataFactory, subnetAssignedLinodeDataFactory, subnetAssignedNodebalancerDataFactory, subnetFactory, @@ -36,14 +37,23 @@ const subnetNodeBalancerInfoId1 = subnetAssignedNodebalancerDataFactory.build({ const subnetNodeBalancerInfoId3 = subnetAssignedNodebalancerDataFactory.build({ id: 3, }); +const subnetdatabaseInfoId1 = subnetAssignedDatabaseDataFactory.build({ + id: 1, +}); +const subnetdatabaseInfoId2 = subnetAssignedDatabaseDataFactory.build({ + id: 2, +}); describe('getUniqueResourcesFromSubnets', () => { - it(`returns the number of unique linodes and nodeBalancers within a VPC's subnets`, () => { - const subnets0 = [subnetFactory.build({ linodes: [], nodebalancers: [] })]; + it(`returns the number of unique linodes, nodeBalancers, and databases within a VPC's subnets`, () => { + const subnets0 = [ + subnetFactory.build({ linodes: [], nodebalancers: [], databases: [] }), + ]; const subnets1 = [ subnetFactory.build({ linodes: subnetLinodeInfoList1, nodebalancers: subnetNodeBalancerInfoList1, + databases: [], }), ]; const subnets2 = [ @@ -60,17 +70,20 @@ describe('getUniqueResourcesFromSubnets', () => { subnetNodeBalancerInfoId3, subnetNodeBalancerInfoId3, ], + databases: [], }), ]; const subnets3 = [ subnetFactory.build({ linodes: subnetLinodeInfoList1, nodebalancers: subnetNodeBalancerInfoList1, + databases: [], }), - subnetFactory.build({ linodes: [], nodebalancers: [] }), + subnetFactory.build({ linodes: [], nodebalancers: [], databases: [] }), subnetFactory.build({ linodes: [subnetLinodeInfoId3], nodebalancers: [subnetNodeBalancerInfoId3], + databases: [], }), subnetFactory.build({ linodes: [ @@ -87,14 +100,31 @@ describe('getUniqueResourcesFromSubnets', () => { subnetAssignedNodebalancerDataFactory.build({ id: 9 }), subnetNodeBalancerInfoId1, ], + databases: [], + }), + ]; + + const subnets4 = [ + ...subnets3, + subnetFactory.build({ + databases: [subnetdatabaseInfoId1], + linodes: [], + nodebalancers: [], + }), + subnetFactory.build({ + databases: [subnetdatabaseInfoId2], + linodes: [], + nodebalancers: [], }), ]; - expect(getUniqueResourcesFromSubnets(subnets0)).toBe(0); - expect(getUniqueResourcesFromSubnets(subnets1)).toBe(8); - expect(getUniqueResourcesFromSubnets(subnets2)).toBe(4); + expect(getUniqueResourcesFromSubnets(subnets0, false)).toBe(0); + expect(getUniqueResourcesFromSubnets(subnets1, false)).toBe(8); + expect(getUniqueResourcesFromSubnets(subnets2, false)).toBe(4); // updated factory for generating linode ids, so unique linodes will be different - expect(getUniqueResourcesFromSubnets(subnets3)).toBe(16); + expect(getUniqueResourcesFromSubnets(subnets3, false)).toBe(16); + // Test databases count when getUniqueLinodesFromSubnets countDatabases param is true + expect(getUniqueResourcesFromSubnets(subnets4, true)).toBe(18); }); }); diff --git a/packages/manager/src/features/VPCs/utils.ts b/packages/manager/src/features/VPCs/utils.ts index 5e96fb4388e..3bdc3fa8807 100644 --- a/packages/manager/src/features/VPCs/utils.ts +++ b/packages/manager/src/features/VPCs/utils.ts @@ -23,9 +23,13 @@ export const getUniqueLinodesFromSubnets = (subnets: Subnet[]) => { return linodes.length; }; -export const getUniqueResourcesFromSubnets = (subnets: Subnet[]) => { +export const getUniqueResourcesFromSubnets = ( + subnets: Subnet[], + countDatabases: boolean +) => { const linodes: number[] = []; const nodeBalancer: number[] = []; + const databases: number[] = []; for (const subnet of subnets) { subnet.linodes.forEach((linodeInfo) => { if (!linodes.includes(linodeInfo.id)) { @@ -37,8 +41,15 @@ export const getUniqueResourcesFromSubnets = (subnets: Subnet[]) => { nodeBalancer.push(nodeBalancerInfo.id); } }); + if (countDatabases) { + subnet.databases.forEach((databaseInfo) => { + if (!databases.includes(databaseInfo.id)) { + databases.push(databaseInfo.id); + } + }); + } } - return linodes.length + nodeBalancer.length; + return linodes.length + nodeBalancer.length + databases.length; }; // Linode Interfaces: show unrecommended notice if (active) VPC interface has an IPv4 nat_1_1 address but isn't the default IPv4 route diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index dd7c919570b..3e922548ced 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -368,33 +368,14 @@ const entityTransfers = [ const databases = [ http.get('*/databases/instances', () => { - const database1 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 1, - label: 'database-instance-1', - }); - const database2 = databaseInstanceFactory.build({ - cluster_size: 2, - id: 2, - label: 'database-instance-2', - }); - const database3 = databaseInstanceFactory.build({ - cluster_size: 3, - id: 3, - label: 'database-instance-3', - }); - const database4 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 4, - label: 'database-instance-4', - }); - const database5 = databaseInstanceFactory.build({ - cluster_size: 1, - id: 5, - label: 'database-instance-5', - }); + const ids = Array.from({ length: 5 }, (_, i) => i + 1); // Update length to change the number of databases - const databases = [database1, database2, database3, database4, database5]; + const databases = ids.map((id) => { + return databaseInstanceFactory.build({ + id, + label: `databases-instance-${id}`, + }); + }); return HttpResponse.json(makeResourcePage(databases)); }), @@ -613,6 +594,15 @@ const vpc = [ ); }), http.get('*/v4beta/vpcs/:vpcId/subnets', () => { + /* Uncomment to the code below to mock a subnet with assignedDatabases that can be found in the GET database instances call */ + // const ids = Array.from({ length: 5 }, (_, i) => i + 1); // Update length to change the number of assigned databases + // const assignedDatabases = ids.map((id) => { + // return subnetAssignedDatabaseDataFactory.build({ + // id, + // }); + // }); + // const mockSubnet = subnetFactory.build({ databases: assignedDatabases }); + // return HttpResponse.json(makeResourcePage([mockSubnet])); return HttpResponse.json(makeResourcePage(subnetFactory.buildList(30))); }), http.delete('*/v4beta/vpcs/:vpcId/subnets/:subnetId', () => { diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 7357985e112..2bf52b17452 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -56,13 +56,14 @@ export const useDatabasesQuery = ( params: Params, filter: Filter, isEnabled: boolean | undefined, + refetchInterval?: number, ) => useQuery, APIError[]>({ ...databaseQueries.databases._ctx.paginated(params, filter), enabled: isEnabled, placeholderData: keepPreviousData, // @TODO Consider removing polling - refetchInterval: 20000, + refetchInterval, }); export const useDatabasesInfiniteQuery = (filter: Filter, enabled: boolean) => {