diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index f8eeb143115..9797e66c4fc 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -26,6 +26,10 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'apl', label: 'Akamai App Platform' }, { flag: 'aplGeneralAvailability', label: 'Akamai App Platform GA' }, { flag: 'aplLkeE', label: 'Akamai App Platform LKE-E' }, + { + flag: 'aclpNbMetricsIntegration', + label: 'ACLP NodeBalancer Metrics Integration', + }, { flag: 'blockStorageEncryption', label: 'Block Storage Encryption (BSE)' }, { flag: 'blockStorageVolumeLimit', label: 'Block Storage Volume Limit' }, { flag: 'cloudNat', label: 'Cloud NAT' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 02e26be0afc..36fe667cfb0 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -223,6 +223,7 @@ export interface Flags { aclpAlerting: AclpAlerting; aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; aclpLogs: AclpLogsFlag; + aclpNbMetricsIntegration: boolean; aclpReadEndpoint: string; aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[]; aclpServices: Partial; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index c9124cb79f7..12e3455b1f2 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -2,7 +2,7 @@ import { useNodeBalancerQuery, useNodebalancerUpdateMutation, } from '@linode/queries'; -import { CircleProgress, ErrorState, Notice } from '@linode/ui'; +import { CircleProgress, ErrorState, NewFeatureChip, Notice } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import React from 'react'; @@ -13,12 +13,14 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useFlags } from 'src/hooks/useFlags'; import { useTabs } from 'src/hooks/useTabs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { NodeBalancerConfigurationsWrapper } from './NodeBalancerConfigurationsWrapper'; import { NodeBalancerSettings } from './NodeBalancerSettings'; import { NodeBalancerSummary } from './NodeBalancerSummary/NodeBalancerSummary'; +import { NodeBalancerSummaryv2 } from './NodeBalancerSummaryv2/NodeBalancerSummaryv2'; export const NodeBalancerDetail = () => { const { id } = useParams({ @@ -31,6 +33,8 @@ export const NodeBalancerDetail = () => { reset, } = useNodebalancerUpdateMutation(Number(id)); + const { aclpNbMetricsIntegration } = useFlags(); + const { data: nodebalancer, error, @@ -43,7 +47,7 @@ export const NodeBalancerDetail = () => { nodebalancer?.id ); - const { handleTabChange, tabIndex, tabs } = useTabs([ + const { getTabIndex, handleTabChange, tabIndex, tabs } = useTabs([ { title: 'Summary', to: '/nodebalancers/$id/summary', @@ -52,6 +56,12 @@ export const NodeBalancerDetail = () => { title: 'Configurations', to: '/nodebalancers/$id/configurations', }, + { + title: 'Metrics', + to: '/nodebalancers/$id/metrics', + hide: !aclpNbMetricsIntegration, + chip: , + }, { title: 'Settings', to: '/nodebalancers/$id/settings', @@ -75,6 +85,9 @@ export const NodeBalancerDetail = () => { const errorMap = getErrorMap(['label'], updateError); const labelError = errorMap.label; + const metricsTabIndex = getTabIndex('/nodebalancers/$id/metrics'); + const settingsTabIndex = getTabIndex('/nodebalancers/$id/settings'); + return ( { }> - + {!aclpNbMetricsIntegration ? ( + + ) : ( + + )} - - - + {metricsTabIndex !== null && ( + + + + )} + + {settingsTabIndex !== null && ( + + + + )} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.test.tsx new file mode 100644 index 00000000000..f2575e9866d --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.test.tsx @@ -0,0 +1,141 @@ +import { + convertMegabytesTo, + nodeBalancerConfigFactory, + nodeBalancerFactory, +} from '@linode/utilities'; +import React from 'react'; + +import { firewallFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerDetailBody } from './NodeBalancerDetailBody'; + +const queryMocks = vi.hoisted(() => ({ + useAllNodeBalancerConfigsQuery: vi.fn().mockReturnValue({ data: [] }), + useNodeBalancersFirewallsQuery: vi + .fn() + .mockReturnValue({ data: { data: [] } }), + useParams: vi.fn().mockReturnValue({ id: 1 }), + useRegionsQuery: vi.fn().mockReturnValue({ data: [] }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllNodeBalancerConfigsQuery: queryMocks.useAllNodeBalancerConfigsQuery, + useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +describe('NodeBalancerDetailBody', () => { + const nodebalancer = nodeBalancerFactory.build({ + hostname: 'example.com', + id: 1, + region: 'us-east', + tags: ['tag-1'], + type: 'common', + }); + + beforeEach(() => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: [ + nodeBalancerConfigFactory.build({ id: 101, port: 80 }), + nodeBalancerConfigFactory.build({ id: 102, port: 443 }), + ], + }); + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { + data: [firewallFactory.build({ id: 44, label: 'mock-firewall-1' })], + }, + }); + queryMocks.useParams.mockReturnValue({ id: 1 }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: [{ id: 'us-east', label: 'Newark, NJ' }], + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the nodebalancer details, config links, and firewall link', () => { + const { getByRole, getByText } = renderWithTheme( + + ); + + expect(getByText('Type')).toBeVisible(); + expect(getByText('Basic')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + expect(getByText('Newark, NJ')).toBeVisible(); + expect(getByText('NodeBalancer ID')).toBeVisible(); + expect(getByText(String(nodebalancer.id))).toBeVisible(); + + expect(getByText('Configuration Ports')).toBeVisible(); + + const port80Link = getByRole('link', { name: 'Port 80' }); + expect(port80Link).toHaveAttribute( + 'href', + `/nodebalancers/${nodebalancer.id}/configurations/101` + ); + + const port443Link = getByRole('link', { name: 'Port 443' }); + expect(port443Link).toHaveAttribute( + 'href', + `/nodebalancers/${nodebalancer.id}/configurations/102` + ); + + expect(getByText('Hostname')).toBeVisible(); + expect(getByText(nodebalancer.hostname)).toBeVisible(); + expect(getByText('Transferred')).toBeVisible(); + expect( + getByText(convertMegabytesTo(nodebalancer.transfer.total)) + ).toBeVisible(); + + expect(getByText('Firewall')).toBeVisible(); + const firewallLink = getByRole('link', { + name: 'Firewall mock-firewall-1', + }); + expect(firewallLink).toHaveAttribute('href', '/firewalls/44'); + }); + + it('renders None when there are no configuration ports and hides the firewall section when there is no firewall', () => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: [], + }); + queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({ + data: { + data: [], + }, + }); + + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText('Configuration Ports')).toBeVisible(); + expect(getByText('None')).toBeVisible(); + expect(queryByText('Firewall')).not.toBeInTheDocument(); + }); + + it('falls back to the raw region id when the region lookup is unavailable', () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: [], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText(nodebalancer.region)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.tsx new file mode 100644 index 00000000000..8f3b2113643 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailBody.tsx @@ -0,0 +1,148 @@ +import { + useAllNodeBalancerConfigsQuery, + useNodeBalancersFirewallsQuery, + useRegionsQuery, +} from '@linode/queries'; +import { Box, Typography } from '@linode/ui'; +import { convertMegabytesTo } from '@linode/utilities'; +import Grid from '@mui/material/Grid'; +import { useTheme } from '@mui/material/styles'; +import { useParams } from '@tanstack/react-router'; +import React from 'react'; + +import { Link } from 'src/components/Link'; + +import type { NodeBalancer } from '@linode/api-v4'; + +interface Props { + nodebalancer: NodeBalancer; +} + +export const NodeBalancerDetailBody = ({ nodebalancer }: Props) => { + const theme = useTheme(); + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: configs } = useAllNodeBalancerConfigsQuery(Number(id)); + const { data: regions } = useRegionsQuery(); + const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery( + Number(id) + ); + + const configPorts = configs?.reduce((acc, config) => { + return [...acc, { configId: config.id, port: config.port }]; + }, []); + + const regionLabel = + regions?.find((region) => region.id === nodebalancer.region)?.label ?? + nodebalancer.region; + const linkText = attachedFirewallData?.data[0]?.label; + const linkID = attachedFirewallData?.data[0]?.id; + const displayFirewallLink = !!attachedFirewallData?.data?.length; + + return ( + + + + ({ font: theme.font.bold })}> + Type + + + {nodebalancer.type === 'common' && 'Basic'} + {nodebalancer.type === 'premium' && 'Premium'} + {nodebalancer.type === 'premium_40gb' && 'Enterprise'} + + + + ({ font: theme.font.bold })}> + Region + + {regionLabel} + + + ({ font: theme.font.bold })}> + NodeBalancer ID + + {nodebalancer.id} + + + + + + ({ font: theme.font.bold })}> + Configuration Ports + + + {configPorts?.length === 0 && 'None'} + {configPorts?.map(({ configId, port }, i) => ( + + + {port} + + {i < configPorts?.length - 1 ? ', ' : ''} + + ))} + + + + ({ font: theme.font.bold })}> + Hostname + + {nodebalancer.hostname} + + + ({ font: theme.font.bold })}> + Transferred + + + {convertMegabytesTo(nodebalancer.transfer.total)} + + + + + {displayFirewallLink && ( + + + ({ font: theme.font.bold })}> + Firewall + + + + {linkText} + + + + + )} + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.test.tsx new file mode 100644 index 00000000000..91f8df884de --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.test.tsx @@ -0,0 +1,117 @@ +import { nodeBalancerFactory } from '@linode/utilities'; +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerDetailFooter } from './NodeBalancerDetailFooter'; + +type MockTagCellProps = { + disabled?: boolean; + entity?: string; + tags: string[]; + updateTags: (tags: string[]) => Promise; + view: 'inline' | 'panel'; +}; + +const queryMocks = vi.hoisted(() => ({ + mockTagCell: vi.fn(), + updateNodeBalancer: vi.fn().mockResolvedValue({}), + useNodebalancerUpdateMutation: vi.fn(), + usePermissions: vi.fn(), +})); + +vi.mock('src/components/TagCell/TagCell', () => ({ + TagCell: (props: MockTagCellProps) => { + queryMocks.mockTagCell(props); + + return ( +
+
{props.entity}
+ + +
{props.tags.join(', ')}
+
{props.view}
+
+ ); + }, +})); + +vi.mock('src/features/IAM/hooks/usePermissions', () => ({ + usePermissions: queryMocks.usePermissions, +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useNodebalancerUpdateMutation: queryMocks.useNodebalancerUpdateMutation, + }; +}); + +describe('NodeBalancerDetailFooter', () => { + const nodebalancer = nodeBalancerFactory.build({ + id: 1, + tags: ['very-long-tag'], + }); + + beforeEach(() => { + queryMocks.mockTagCell.mockReset(); + queryMocks.updateNodeBalancer.mockReset(); + queryMocks.updateNodeBalancer.mockResolvedValue({}); + queryMocks.useNodebalancerUpdateMutation.mockReturnValue({ + mutateAsync: queryMocks.updateNodeBalancer, + }); + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: false, + }, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('disables Add a tag when the user is not an account admin', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Add a tag')).toHaveAttribute('aria-disabled', 'true'); + expect(queryMocks.mockTagCell).toHaveBeenCalledWith( + expect.objectContaining({ + disabled: true, + entity: 'NodeBalancer', + tags: nodebalancer.tags, + view: 'inline', + }) + ); + }); + + it('enables Add a tag and updates nodebalancer tags when the user has permission', async () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: true, + }, + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Add a tag')).not.toHaveAttribute('aria-disabled', 'true'); + + fireEvent.click(getByText('Save tags')); + + await waitFor(() => { + expect(queryMocks.updateNodeBalancer).toHaveBeenCalledWith({ + tags: ['updated-tag'], + }); + }); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.tsx new file mode 100644 index 00000000000..16240da78e9 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailFooter.tsx @@ -0,0 +1,50 @@ +import { useNodebalancerUpdateMutation } from '@linode/queries'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import { TagCell } from 'src/components/TagCell/TagCell'; +import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { NodeBalancer } from '@linode/api-v4'; + +interface Props { + nodebalancer: NodeBalancer; +} + +export const NodeBalancerDetailFooter = ({ nodebalancer }: Props) => { + const { enqueueSnackbar } = useSnackbar(); + const { mutateAsync: updateNodeBalancer } = useNodebalancerUpdateMutation( + nodebalancer.id + ); + const { data: accountPermissions } = usePermissions('account', [ + 'is_account_admin', + ]); + + const updateTags = React.useCallback( + async (tags: string[]) => { + return updateNodeBalancer({ tags }).catch((e) => + enqueueSnackbar( + getAPIErrorOrDefault(e, 'Error updating tags')[0].reason, + { + variant: 'error', + } + ) + ); + }, + [updateNodeBalancer, enqueueSnackbar] + ); + + return ( + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.test.tsx new file mode 100644 index 00000000000..f9080d7bc95 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.test.tsx @@ -0,0 +1,116 @@ +import { nodeBalancerConfigFactory } from '@linode/utilities'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerDetailHeader } from './NodeBalancerDetailHeader'; + +const queryMocks = vi.hoisted(() => ({ + useAllNodeBalancerConfigsQuery: vi.fn().mockReturnValue({ data: [] }), + useParams: vi.fn().mockReturnValue({ id: 1 }), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllNodeBalancerConfigsQuery: queryMocks.useAllNodeBalancerConfigsQuery, + }; +}); + +describe('NodeBalancerDetailHeader', () => { + beforeEach(() => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: nodeBalancerConfigFactory.buildList(2, { + nodes_status: { + down: 1, + up: 0, + }, + }), + isLoading: false, + }); + queryMocks.useParams.mockReturnValue({ id: 1 }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders a loading state while config data is loading', () => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('Backend Status')).toBeVisible(); + expect(getByText('Loading')).toBeVisible(); + expect(getByLabelText('Status is inactive')).toBeVisible(); + }); + + it('renders an error status when all backends are down', () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('0 Up - 2 Down')).toBeVisible(); + expect(getByLabelText('Status is error')).toBeVisible(); + }); + + it('renders an active status when all backends are up', () => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: nodeBalancerConfigFactory.buildList(2, { + nodes_status: { + down: 0, + up: 1, + }, + }), + isLoading: false, + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('2 Up - 0 Down')).toBeVisible(); + expect(getByLabelText('Status is active')).toBeVisible(); + }); + + it('renders a warning status when backend state is mixed', () => { + queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({ + data: [ + nodeBalancerConfigFactory.build({ + nodes_status: { + down: 0, + up: 1, + }, + }), + nodeBalancerConfigFactory.build({ + nodes_status: { + down: 1, + up: 0, + }, + }), + ], + isLoading: false, + }); + + const { getByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('1 Up - 1 Down')).toBeVisible(); + expect(getByLabelText('Status is other')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.tsx new file mode 100644 index 00000000000..bd1f2e105e1 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader.tsx @@ -0,0 +1,71 @@ +import { useAllNodeBalancerConfigsQuery } from '@linode/queries'; +import { Box, Typography } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import React from 'react'; + +import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; + +export const getStatusColorMap = ( + isLoading: boolean, + up?: number, + down?: number +) => { + if (isLoading) { + return 'inactive'; + } + + if (down === 0 && up === 0) { + return 'inactive'; + } + + if (down === 0) { + return 'active'; + } + + if (up === 0) { + return 'error'; + } + + return 'other'; +}; + +export const NodeBalancerDetailHeader = () => { + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: configs, isLoading } = useAllNodeBalancerConfigsQuery( + Number(id) + ); + const down = configs?.reduce((acc: number, config) => { + return acc + config.nodes_status.down; + }, 0); // add the downtime for each config together + + const up = configs?.reduce((acc: number, config) => { + return acc + config.nodes_status.up; + }, 0); // add the uptime for each config together + + return ( + + ({ + display: 'flex', + alignItems: 'center', + padding: `${theme.spacingFunction(12)} ${theme.spacingFunction(24)}`, + })} + > + ({ font: theme.font.bold })}> + Backend Status + + ({ marginLeft: theme.spacingFunction(8) })} + /> + ({ marginLeft: theme.spacingFunction(4) })}> + {isLoading ? 'Loading' : `${up} Up - ${down} Down`} + + + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerSummaryv2.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerSummaryv2.tsx new file mode 100644 index 00000000000..e7c878b7c46 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerSummaryv2.tsx @@ -0,0 +1,24 @@ +import { useNodeBalancerQuery } from '@linode/queries'; +import Grid from '@mui/material/Grid'; +import { useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; + +import { SummaryPanel } from './SummaryPanel'; + +export const NodeBalancerSummaryv2 = () => { + const { id } = useParams({ + from: '/nodebalancers/$id/summary', + }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); + + return ( +
+ + + + +
+ ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/SummaryPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/SummaryPanel.tsx new file mode 100644 index 00000000000..34e6b9a9eb9 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummaryv2/SummaryPanel.tsx @@ -0,0 +1,29 @@ +import { useNodeBalancerQuery } from '@linode/queries'; +import { Box, Stack } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; +import * as React from 'react'; + +import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; + +import { NodeBalancerDetailBody } from './NodeBalancerDetailBody'; +import { NodeBalancerDetailFooter } from './NodeBalancerDetailFooter'; +import { NodeBalancerDetailHeader } from './NodeBalancerDetailHeader'; + +export const SummaryPanel = () => { + const { id } = useParams({ from: '/nodebalancers/$id/summary' }); + const { data: nodebalancer } = useNodeBalancerQuery(Number(id), Boolean(id)); + + if (!nodebalancer) return null; + + return ( + + + } + footer={} + header={} + /> + + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index ddb8529026c..61dd7f91535 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -1,15 +1,18 @@ import { useAllNodeBalancerConfigsQuery } from '@linode/queries'; -import { Hidden } from '@linode/ui'; +import { Box, Hidden } from '@linode/ui'; import { convertMegabytesTo } from '@linode/utilities'; import * as React from 'react'; import { Link } from 'src/components/Link'; import { Skeleton } from 'src/components/Skeleton'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { RegionIndicator } from 'src/features/Linodes/LinodesLanding/RegionIndicator'; +import { useFlags } from 'src/hooks/useFlags'; +import { getStatusColorMap } from '../NodeBalancerDetail/NodeBalancerSummaryv2/NodeBalancerDetailHeader'; import { useIsNodebalancerVPCEnabled } from '../utils'; import { NodeBalancerActionMenu } from './NodeBalancerActionMenu'; import { NodeBalancerVPC } from './NodeBalancerVPC'; @@ -19,8 +22,10 @@ import type { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; export const NodeBalancerTableRow = (props: NodeBalancer) => { const { id, ipv4, label, region, transfer } = props; const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled(); + const { aclpNbMetricsIntegration } = useFlags(); - const { data: configs } = useAllNodeBalancerConfigsQuery(id); + const { data: configs, isLoading: isConfigsLoading } = + useAllNodeBalancerConfigsQuery(id); const nodesUp = configs?.reduce((result, config) => config.nodes_status.up + result, 0) ?? @@ -38,7 +43,29 @@ export const NodeBalancerTableRow = (props: NodeBalancer) => { - {nodesUp} up - {nodesDown} down + {aclpNbMetricsIntegration ? ( + + ({ + marginLeft: theme.spacingFunction(8), + })} + /> + + {isConfigsLoading + ? 'Loading' + : `${nodesUp} Up - ${nodesDown} Down`} + + + ) : ( + {`${nodesUp} up - ${nodesDown} down`} + )} diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index 474478d4f10..f1cf8e74448 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -236,7 +236,7 @@ export const useIsNodebalancerVPCEnabled = () => { }; /** - * Returns whether or not features related to the NodeBalancer Dual Stack project + * Returns whether or not features related to the NodeBalancer IPv6 project * should be enabled. * * Currently, this just uses the `nodebalancerIPv6` feature flag as a source of truth, diff --git a/packages/manager/src/routes/nodeBalancers/index.ts b/packages/manager/src/routes/nodeBalancers/index.ts index e4a8019c001..d6475f55dcf 100644 --- a/packages/manager/src/routes/nodeBalancers/index.ts +++ b/packages/manager/src/routes/nodeBalancers/index.ts @@ -71,6 +71,24 @@ const nodeBalancerDetailConfigurationRoute = createRoute({ ).then((m) => m.nodeBalancerDetailLazyRoute) ); +const nodeBalancerDetailMetricsRoute = createRoute({ + getParentRoute: () => nodeBalancersRoute, + path: '$id/metrics', + beforeLoad: async ({ context, params }) => { + if (!context.flags.aclpNbMetricsIntegration) { + throw redirect({ + to: '/nodebalancers/$id/summary', + params: { id: params.id }, + replace: true, + }); + } + }, +}).lazy(() => + import( + 'src/features/NodeBalancers/NodeBalancerDetail/nodeBalancersDetailLazyRoute' + ).then((m) => m.nodeBalancerDetailLazyRoute) +); + const nodeBalancerDetailSettingsRoute = createRoute({ getParentRoute: () => nodeBalancersRoute, path: '$id/settings', @@ -124,6 +142,7 @@ export const nodeBalancersRouteTree = nodeBalancersRoute.addChildren([ nodeBalancerDetailConfigurationsRoute.addChildren([ nodeBalancerDetailConfigurationRoute, ]), + nodeBalancerDetailMetricsRoute, nodeBalancerDetailSettingsRoute.addChildren([ nodeBalancerDetailSettingsDeleteRoute, nodeBalancerDetailSettingsAddFirewallRoute,