From 2e41109e9f0ef6544d81e6568ceff9914c08989a Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Thu, 19 Feb 2026 15:39:18 -0500 Subject: [PATCH 01/10] fix: [UIE-9487] - Networking/DBaaS - Display DBaaS resource count and table in the VPC UI --- packages/api-v4/src/vpcs/types.ts | 7 + .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/factories/databases.ts | 4 + packages/manager/src/factories/subnets.ts | 25 +++ packages/manager/src/featureFlags.ts | 1 + .../DatabaseLanding/DatabaseLanding.tsx | 3 +- .../Databases/DatabaseLanding/DatabaseRow.tsx | 12 +- .../src/features/Databases/utilities.ts | 7 + .../SubnetAssignLinodesDrawer.test.tsx | 1 + .../VPCs/VPCDetail/SubnetDatabaseRow.tsx | 99 ++++++++++++ .../VPCs/VPCDetail/SubnetDatabasesTable.tsx | 151 ++++++++++++++++++ .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 14 +- .../VPCs/VPCDetail/VPCSubnetsTable.tsx | 8 +- .../features/VPCs/VPCLanding/VPCLanding.tsx | 9 +- .../features/VPCs/VPCLanding/VPCRow.test.tsx | 5 + .../src/features/VPCs/VPCLanding/VPCRow.tsx | 4 +- .../manager/src/features/VPCs/utils.test.ts | 8 +- packages/manager/src/features/VPCs/utils.ts | 15 +- packages/manager/src/mocks/serverHandlers.ts | 42 ++--- packages/queries/src/databases/databases.ts | 10 +- 20 files changed, 375 insertions(+), 51 deletions(-) create mode 100644 packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx create mode 100644 packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx 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/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..78eaec904f2 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 if 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..de978709a02 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,9 @@ export const convertPrivateToPublicHostname = (host: string) => { const baseHostName = host.slice(privateStrIndex + 1); return `public-${baseHostName}`; }; + +export const getIsLinkInactive = (status: DatabaseStatus) => + status === 'suspended' || + status === 'suspending' || + status === 'resuming' || + status === 'migrated'; 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.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx new file mode 100644 index 00000000000..e07f2dbc66b --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx @@ -0,0 +1,99 @@ +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 noneSingleOrMultipleWithChipIPV6 = + determineNoneSingleOrMultipleWithChip(ipv6Ranges); + const ipv6RangeContent = assignedDatabase?.ipv6_ranges + ? noneSingleOrMultipleWithChipIPV6 + : '—'; + + // 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 '—'; + } + + const primaryIPv4 = memberKeys.find( + (key) => database.members[key] === 'primary' + ); + // Retrieve failover IPv4 addresses as there can be multiple + const failoverIPv4s = memberKeys.filter( + (key) => database.members[key] === 'failover' + ); + + if (failoverIPv4s.length === 0) { + return primaryIPv4; + } + + 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(s) + VPC IPv4 Range + VPC IPv6 Range + +); 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..7787ec02649 --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -0,0 +1,151 @@ +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 { + databasesData: SubnetAssignedDatabaseData[]; +} + +export const SubnetDatabasesTable = ({ databasesData }: Props) => { + const theme = useTheme(); + + const [pageSize, setPageSize] = React.useState(25); + const [page, setPage] = React.useState(1); + + const assignedDatabasesMap = () => { + const databaseMap: Record = {}; + databasesData.forEach((assignedDatabase) => { + databaseMap[assignedDatabase.id] = assignedDatabase; + }); + return databaseMap; + }; + + // Create filter using unique database IDs from the assigned databases + const makeDatabaseIDsFilter = () => { + const uniqueIds = Object.values(assignedDatabasesMap()).map((db) => { + return { id: db.id }; + }); + + return { + '+or': uniqueIds, + }; + }; + + const { + data: databases, + error: databasesError, + isLoading, + } = useDatabasesQuery( + { + page_size: pageSize, + page, + }, + makeDatabaseIDsFilter(), + true, + false + ); + + const DatabasesTable = ({ children }: { children: React.ReactNode }) => ( + <> + + + {SubnetDatabasesTableRowHead} + + {children} +
+ 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}`, + }} + /> + + ); + + const LoadingState = () => ( + + + + + + ); + + const TableErrorState = () => ( + + ); + + const EmptyState = () => ( + + ); + + if (isLoading) { + return ( + + + + ); + } + + if (databasesError) { + // TODO: Fix the error state styling + return ( + + + + ); + } + + if (databases && databases.data.length === 0) { + return ( + + + + ); + } + + const databaseRows = () => + databases?.data.map((database) => ( + + )); + + return {databaseRows()}; +}; diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index db6c3f55310..664631ab414 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, + flags.vpcDbaasResources ?? false + ) : 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.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 37fea394caa..4e951dd8069 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..cbff5093e95 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..08f6b4d3dfd 100644 --- a/packages/manager/src/features/VPCs/utils.test.ts +++ b/packages/manager/src/features/VPCs/utils.test.ts @@ -90,11 +90,11 @@ describe('getUniqueResourcesFromSubnets', () => { }), ]; - 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); }); }); 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..f582f651af5 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -42,9 +42,14 @@ import type { UpdateDatabasePayload, } from '@linode/api-v4'; -export const useDatabaseQuery = (engine: Engine, id: number) => +export const useDatabaseQuery = ( + engine: Engine, + id: number, + isEnabled = true, +) => useQuery({ ...databaseQueries.database(engine, id), + enabled: isEnabled, // @TODO Consider removing polling // The refetchInterval will poll the API for this Database. We will do this // to ensure we have up to date information. We do this polling because the events @@ -56,13 +61,14 @@ export const useDatabasesQuery = ( params: Params, filter: Filter, isEnabled: boolean | undefined, + refetchInterval: false | 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) => { From 74a27397f7fe3db01ab7f101d5c8300d3522161a Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Mon, 16 Mar 2026 17:48:04 -0400 Subject: [PATCH 02/10] Making enhancements to the SubnetDatabasesTable and cleaning up --- .../VPCs/VPCDetail/SubnetDatabasesTable.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx index 7787ec02649..b7c4354e05e 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -29,24 +29,24 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { const [pageSize, setPageSize] = React.useState(25); const [page, setPage] = React.useState(1); - const assignedDatabasesMap = () => { + const assignedDatabasesMap = React.useMemo(() => { const databaseMap: Record = {}; databasesData.forEach((assignedDatabase) => { databaseMap[assignedDatabase.id] = assignedDatabase; }); return databaseMap; - }; + }, [databasesData]); // Create filter using unique database IDs from the assigned databases - const makeDatabaseIDsFilter = () => { - const uniqueIds = Object.values(assignedDatabasesMap()).map((db) => { + const makeDatabaseIDsFilter = React.useMemo(() => { + const uniqueIds = Object.values(assignedDatabasesMap).map((db) => { return { id: db.id }; }); return { '+or': uniqueIds, }; - }; + }, [assignedDatabasesMap]); const { data: databases, @@ -57,12 +57,16 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { page_size: pageSize, page, }, - makeDatabaseIDsFilter(), + makeDatabaseIDsFilter, true, false ); - const DatabasesTable = ({ children }: { children: React.ReactNode }) => ( + const DatabasesTableWrapper = ({ + children, + }: { + children: React.ReactNode; + }) => ( <> { const LoadingState = () => ( - + @@ -99,7 +103,7 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { const TableErrorState = () => ( { ); const EmptyState = () => ( - + ); if (isLoading) { return ( - + - + ); } if (databasesError) { - // TODO: Fix the error state styling return ( - + - + ); } if (databases && databases.data.length === 0) { return ( - + - + ); } const databaseRows = () => databases?.data.map((database) => ( )); - return {databaseRows()}; + return {databaseRows()}; }; From d9b58d59e61f39a2d31b8c59e50c986ccda8aa32 Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Tue, 17 Mar 2026 11:48:12 -0400 Subject: [PATCH 03/10] Moving paginator in SubnetDatabasesTable --- .../VPCs/VPCDetail/SubnetDatabasesTable.tsx | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx index b7c4354e05e..821c0dfa466 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -67,30 +67,16 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { }: { children: React.ReactNode; }) => ( - <> -
- - {SubnetDatabasesTableRowHead} - - {children} -
- 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}`, + + - + > + {SubnetDatabasesTableRowHead} + + {children} +
); const LoadingState = () => ( @@ -150,5 +136,21 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { /> )); - return {databaseRows()}; + return ( + <> + {databaseRows()} + 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}`, + }} + /> + + ); }; From 7068c61e43013c448ae45bd252fa5132eddcf670 Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Mon, 30 Mar 2026 15:35:16 -0400 Subject: [PATCH 04/10] Applying initial feedback --- .../DatabaseLanding/DatabaseLanding.tsx | 2 +- .../src/features/Databases/utilities.ts | 5 +- .../VPCs/VPCDetail/SubnetDatabaseRow.tsx | 14 ++- .../VPCs/VPCDetail/SubnetDatabasesTable.tsx | 89 +++++++------------ packages/queries/src/databases/databases.ts | 2 +- 5 files changed, 42 insertions(+), 70 deletions(-) diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 78eaec904f2..be746f7a77a 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -68,7 +68,7 @@ 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 ); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index de978709a02..2248b19eca1 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -259,7 +259,4 @@ export const convertPrivateToPublicHostname = (host: string) => { }; export const getIsLinkInactive = (status: DatabaseStatus) => - status === 'suspended' || - status === 'suspending' || - status === 'resuming' || - status === 'migrated'; + ['migrated', 'resuming', 'suspended', 'suspending'].includes(status); diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx index e07f2dbc66b..42f6f475903 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx @@ -23,10 +23,8 @@ export const SubnetDatabaseRow = ({ assignedDatabase, database }: Props) => { ?.map((rangeObj) => rangeObj.range) .filter((range) => range !== undefined) ?? []; - const noneSingleOrMultipleWithChipIPV6 = - determineNoneSingleOrMultipleWithChip(ipv6Ranges); const ipv6RangeContent = assignedDatabase?.ipv6_ranges - ? noneSingleOrMultipleWithChipIPV6 + ? determineNoneSingleOrMultipleWithChip(ipv6Ranges) : '—'; // For IPv4 addresses column, we display the primary and failover IPs for the database instance. @@ -36,19 +34,19 @@ export const SubnetDatabaseRow = ({ assignedDatabase, database }: Props) => { 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' ); - // Retrieve failover IPv4 addresses as there can be multiple const failoverIPv4s = memberKeys.filter( (key) => database.members[key] === 'failover' ); - if (failoverIPv4s.length === 0) { - return primaryIPv4; - } - return [primaryIPv4, ...failoverIPv4s].join(', '); }; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx index 821c0dfa466..caf1bc22603 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -29,24 +29,13 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { const [pageSize, setPageSize] = React.useState(25); const [page, setPage] = React.useState(1); - const assignedDatabasesMap = React.useMemo(() => { - const databaseMap: Record = {}; - databasesData.forEach((assignedDatabase) => { - databaseMap[assignedDatabase.id] = assignedDatabase; - }); - return databaseMap; - }, [databasesData]); - - // Create filter using unique database IDs from the assigned databases - const makeDatabaseIDsFilter = React.useMemo(() => { - const uniqueIds = Object.values(assignedDatabasesMap).map((db) => { - return { id: db.id }; - }); - + const assignedDatabasesMap: Record = {}; // Store assigned databases in map for easy lookup when rendering subnet database rows + const databaseIDsToFilter = databasesData.map((database) => { + assignedDatabasesMap[database.id] = database; return { - '+or': uniqueIds, + id: database.id, }; - }, [assignedDatabasesMap]); + }); const { data: databases, @@ -57,9 +46,10 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { page_size: pageSize, page, }, - makeDatabaseIDsFilter, - true, - false + { + '+or': databaseIDsToFilter, + }, + true ); const DatabasesTableWrapper = ({ @@ -79,34 +69,14 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { ); - const LoadingState = () => ( - - - - - - ); - - const TableErrorState = () => ( - - ); - - const EmptyState = () => ( - - ); - if (isLoading) { return ( - + + + + + ); } @@ -114,7 +84,15 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { if (databasesError) { return ( - + ); } @@ -122,23 +100,22 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { if (databases && databases.data.length === 0) { return ( - + ); } - const databaseRows = () => - databases?.data.map((database) => ( - - )); - return ( <> - {databaseRows()} + + {databases?.data.map((database) => ( + + ))} + setPage(page)} diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index f582f651af5..3b25b92360c 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -61,7 +61,7 @@ export const useDatabasesQuery = ( params: Params, filter: Filter, isEnabled: boolean | undefined, - refetchInterval: false | number, + refetchInterval?: number, ) => useQuery, APIError[]>({ ...databaseQueries.databases._ctx.paginated(params, filter), From d53ca808e9aa0e2edd2efcce3d8d888e0afc415b Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Mon, 30 Mar 2026 15:51:23 -0400 Subject: [PATCH 05/10] Removing leftover change from old implementation --- packages/queries/src/databases/databases.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/queries/src/databases/databases.ts b/packages/queries/src/databases/databases.ts index 3b25b92360c..2bf52b17452 100644 --- a/packages/queries/src/databases/databases.ts +++ b/packages/queries/src/databases/databases.ts @@ -42,14 +42,9 @@ import type { UpdateDatabasePayload, } from '@linode/api-v4'; -export const useDatabaseQuery = ( - engine: Engine, - id: number, - isEnabled = true, -) => +export const useDatabaseQuery = (engine: Engine, id: number) => useQuery({ ...databaseQueries.database(engine, id), - enabled: isEnabled, // @TODO Consider removing polling // The refetchInterval will poll the API for this Database. We will do this // to ensure we have up to date information. We do this polling because the events From 15ffa37e28d4b84c2fc9e91e1095c0ffc2d6dc76 Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Tue, 7 Apr 2026 18:49:13 -0400 Subject: [PATCH 06/10] Adding unit tests for various changes related to dbaas resource count and table feature --- .../VPCs/VPCDetail/SubnetDatabaseRow.test.tsx | 132 ++++++++++++++++++ .../VPCDetail/SubnetDatabasesTable.test.tsx | 87 ++++++++++++ .../VPCs/VPCDetail/VPCDetail.test.tsx | 6 +- .../VPCs/VPCDetail/VPCSubnetsTable.test.tsx | 39 +++++- .../manager/src/features/VPCs/utils.test.ts | 36 ++++- 5 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx create mode 100644 packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx 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..5f3dbd8d17d --- /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 = { + ...mockDatabase, + members: { '2.2.2.2': 'primary' }, + } as DatabaseInstance; + + 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 = { + ...mockDatabase, + members: { + '2.2.2.2': 'primary', + '2.2.2.3': 'failover', + '2.2.2.4': 'failover', + }, + } as DatabaseInstance; + + 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: {}, + } as DatabaseInstance; + + 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/SubnetDatabasesTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx new file mode 100644 index 00000000000..0fa53cf301b --- /dev/null +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx @@ -0,0 +1,87 @@ +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 mockDatabasesData = [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/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/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index d885bc3bb61..c7d29968955 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(s)'); + 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/utils.test.ts b/packages/manager/src/features/VPCs/utils.test.ts index 08f6b4d3dfd..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,6 +100,21 @@ describe('getUniqueResourcesFromSubnets', () => { subnetAssignedNodebalancerDataFactory.build({ id: 9 }), subnetNodeBalancerInfoId1, ], + databases: [], + }), + ]; + + const subnets4 = [ + ...subnets3, + subnetFactory.build({ + databases: [subnetdatabaseInfoId1], + linodes: [], + nodebalancers: [], + }), + subnetFactory.build({ + databases: [subnetdatabaseInfoId2], + linodes: [], + nodebalancers: [], }), ]; @@ -95,6 +123,8 @@ describe('getUniqueResourcesFromSubnets', () => { expect(getUniqueResourcesFromSubnets(subnets2, false)).toBe(4); // updated factory for generating linode ids, so unique linodes will be different expect(getUniqueResourcesFromSubnets(subnets3, false)).toBe(16); + // Test databases count when getUniqueLinodesFromSubnets countDatabases param is true + expect(getUniqueResourcesFromSubnets(subnets4, true)).toBe(18); }); }); From 7d5948ef945bb5c0638bc97d3c34b5fc861f61b1 Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Tue, 7 Apr 2026 18:59:40 -0400 Subject: [PATCH 07/10] adding changesets --- packages/api-v4/.changeset/pr-13504-added-1775602581034.md | 5 +++++ packages/manager/.changeset/pr-13504-fixed-1775602514569.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-13504-added-1775602581034.md create mode 100644 packages/manager/.changeset/pr-13504-fixed-1775602514569.md 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/manager/.changeset/pr-13504-fixed-1775602514569.md b/packages/manager/.changeset/pr-13504-fixed-1775602514569.md new file mode 100644 index 00000000000..809014b674b --- /dev/null +++ b/packages/manager/.changeset/pr-13504-fixed-1775602514569.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Fixed +--- + +DBaaS resource counts and databases resource table in VPC UI ([#13504](https://github.com/linode/manager/pull/13504)) From 367fdec52530d5e0eb1d1d0d8cb67eb5660b851a Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Wed, 8 Apr 2026 14:12:00 -0400 Subject: [PATCH 08/10] Applying secondary feedback --- .../VPCs/VPCDetail/SubnetDatabasesTable.test.tsx | 12 +++++++----- .../VPCs/VPCDetail/SubnetDatabasesTable.tsx | 13 +++++++------ .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 2 +- .../src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx | 2 +- .../src/features/VPCs/VPCLanding/VPCLanding.tsx | 2 +- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx index 0fa53cf301b..a58575783f1 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.test.tsx @@ -15,7 +15,9 @@ const queryMocks = vi.hoisted(() => ({ }), })); -const mockDatabasesData = [subnetAssignedDatabaseDataFactory.build({ id: 1 })]; +const mockSubnetDatabasesData = [ + subnetAssignedDatabaseDataFactory.build({ id: 1 }), +]; vi.mock('@linode/queries', async () => { const actual = await vi.importActual('@linode/queries'); @@ -39,7 +41,7 @@ describe('SubnetDatabasesTable', () => { error: null, }); const { getByText } = renderWithTheme( - + ); getByText('test-database-1'); getByText('Database Cluster'); @@ -53,7 +55,7 @@ describe('SubnetDatabasesTable', () => { }); const { getByTestId } = renderWithTheme( - + ); getByTestId('circle-progress'); }); @@ -66,7 +68,7 @@ describe('SubnetDatabasesTable', () => { }); const { getByTestId } = renderWithTheme( - + ); getByTestId('table-row-empty'); }); @@ -80,7 +82,7 @@ describe('SubnetDatabasesTable', () => { }); 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 index caf1bc22603..2a3272408d4 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabasesTable.tsx @@ -20,18 +20,19 @@ import { import type { SubnetAssignedDatabaseData } from '@linode/api-v4'; interface Props { - databasesData: SubnetAssignedDatabaseData[]; + subnetDatabasesData: SubnetAssignedDatabaseData[]; } -export const SubnetDatabasesTable = ({ databasesData }: Props) => { +export const SubnetDatabasesTable = ({ subnetDatabasesData }: Props) => { const theme = useTheme(); const [pageSize, setPageSize] = React.useState(25); const [page, setPage] = React.useState(1); - const assignedDatabasesMap: Record = {}; // Store assigned databases in map for easy lookup when rendering subnet database rows - const databaseIDsToFilter = databasesData.map((database) => { - assignedDatabasesMap[database.id] = database; + 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, }; @@ -110,7 +111,7 @@ export const SubnetDatabasesTable = ({ databasesData }: Props) => { {databases?.data.map((database) => ( diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx index 664631ab414..98deb622af5 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCDetail.tsx @@ -110,7 +110,7 @@ const VPCDetail = () => { const numResources = isNodebalancerVPCEnabled ? getUniqueResourcesFromSubnets( vpc.subnets, - flags.vpcDbaasResources ?? false + Boolean(flags.vpcDbaasResources) ) : getUniqueLinodesFromSubnets(vpc.subnets); diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx index 4e951dd8069..e2bf7fddf95 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.tsx @@ -407,7 +407,7 @@ 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 cbff5093e95..79837dd642c 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCLanding.tsx @@ -178,7 +178,7 @@ const VPCLanding = () => { {vpcs?.data.map((vpc: VPC) => ( handleDeleteVPC(vpc)} handleEditVPC={() => handleEditVPC(vpc)} isNodebalancerVPCEnabled={isNodebalancerVPCEnabled} From 06e17b47993e37a69a263470a559beefe2b3219b Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Wed, 8 Apr 2026 16:21:18 -0400 Subject: [PATCH 09/10] Updating changeset and unit tests based on additional feedback --- ....md => pr-13504-upcoming-features-1775679549160.md} | 2 +- .../features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename packages/manager/.changeset/{pr-13504-fixed-1775602514569.md => pr-13504-upcoming-features-1775679549160.md} (77%) diff --git a/packages/manager/.changeset/pr-13504-fixed-1775602514569.md b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md similarity index 77% rename from packages/manager/.changeset/pr-13504-fixed-1775602514569.md rename to packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md index 809014b674b..d1e65099f62 100644 --- a/packages/manager/.changeset/pr-13504-fixed-1775602514569.md +++ b/packages/manager/.changeset/pr-13504-upcoming-features-1775679549160.md @@ -1,5 +1,5 @@ --- -'@linode/manager': Fixed +"@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/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx index 5f3dbd8d17d..d11fa0c02a8 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.test.tsx @@ -29,10 +29,10 @@ describe('SubnetDatabaseRow', () => { }); it('should render SubnetDatabaseRow', () => { - const dbWithPrimary = { + const dbWithPrimary: DatabaseInstance = { ...mockDatabase, members: { '2.2.2.2': 'primary' }, - } as DatabaseInstance; + }; const { getByText } = renderWithTheme( wrapWithTableBody( @@ -49,14 +49,14 @@ describe('SubnetDatabaseRow', () => { }); it('should render SubnetDatabaseRow with multiple failover IPs', () => { - const dbWithFailovers = { + const dbWithFailovers: DatabaseInstance = { ...mockDatabase, members: { '2.2.2.2': 'primary', '2.2.2.3': 'failover', '2.2.2.4': 'failover', }, - } as DatabaseInstance; + }; const { getByText } = renderWithTheme( wrapWithTableBody( @@ -76,7 +76,7 @@ describe('SubnetDatabaseRow', () => { const dbWithNoMembers = { ...mockDatabase, members: {}, - } as DatabaseInstance; + }; const { getByText } = renderWithTheme( wrapWithTableBody( From 1dc80e92b4e1c45e3dc8fc2a5cc9e35ae459b9a5 Mon Sep 17 00:00:00 2001 From: Sam Mans Date: Thu, 9 Apr 2026 13:30:31 -0400 Subject: [PATCH 10/10] Applying feedback to fix text for database table IPV4 Address header --- .../manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx | 2 +- .../src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx index 42f6f475903..b4844d22940 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDatabaseRow.tsx @@ -90,7 +90,7 @@ export const SubnetDatabaseRow = ({ assignedDatabase, database }: Props) => { export const SubnetDatabasesTableRowHead = ( Database Cluster - IPv4 Address(s) + IPv4 Address(es) VPC IPv4 Range VPC IPv6 Range diff --git a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx index c7d29968955..2de1b993b04 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/VPCSubnetsTable.test.tsx @@ -323,7 +323,7 @@ describe('VPC Subnets table', () => { await userEvent.click(expandTableButton); await findByText('Database Cluster'); - await findByText('IPv4 Address(s)'); + await findByText('IPv4 Address(es)'); await findByText('VPC IPv4 Range'); await findByText('VPC IPv6 Range'); }