From 256aea2d12b0f1a5cb86c04b3792cafd34431143 Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Wed, 12 Nov 2025 15:09:53 +0100 Subject: [PATCH 1/5] failover history table Signed-off-by: Adhitya Mamallan --- ...ge-failovers-table-active-active.config.ts | 16 ++++++ .../domain-page-failovers-table.config.ts | 41 +++++++++++++++ .../domain-page-failovers.tsx | 52 ++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts create mode 100644 src/views/domain-page/config/domain-page-failovers-table.config.ts diff --git a/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts b/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts new file mode 100644 index 000000000..984c8df86 --- /dev/null +++ b/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts @@ -0,0 +1,16 @@ +import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; + +import domainPageFailoversTableConfig from './domain-page-failovers-table.config'; + +const domainPageFailoversTableActiveActiveConfig = [ + ...domainPageFailoversTableConfig.slice(0, 3), + { + name: 'Failover Information AA', + id: 'failoverInfo', + width: '40%', + renderCell: (event: FailoverEvent) => + JSON.stringify(event.clusterFailovers), + }, +]; + +export default domainPageFailoversTableActiveActiveConfig; diff --git a/src/views/domain-page/config/domain-page-failovers-table.config.ts b/src/views/domain-page/config/domain-page-failovers-table.config.ts new file mode 100644 index 000000000..fcdb53794 --- /dev/null +++ b/src/views/domain-page/config/domain-page-failovers-table.config.ts @@ -0,0 +1,41 @@ +import { createElement } from 'react'; + +import FormattedDate from '@/components/formatted-date/formatted-date'; +import { type TableConfig } from '@/components/table/table.types'; +import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; +import parseGrpcTimestamp from '@/utils/datetime/parse-grpc-timestamp'; + +const domainPageFailoversTableConfig = [ + { + name: 'Failover ID', + id: 'failoverId', + width: '25%', + renderCell: (event: FailoverEvent) => event.id, + }, + { + name: 'Time', + id: 'time', + width: '15%', + renderCell: (event: FailoverEvent) => + createElement(FormattedDate, { + timestampMs: event.createdTime + ? parseGrpcTimestamp(event.createdTime) + : null, + }), + }, + { + name: 'Type', + id: 'type', + width: '10%', + renderCell: (event: FailoverEvent) => event.failoverType, + }, + { + name: 'Failover Information', + id: 'failoverInfo', + width: '50%', + renderCell: (event: FailoverEvent) => + JSON.stringify(event.clusterFailovers), + }, +] as const satisfies TableConfig; + +export default domainPageFailoversTableConfig; diff --git a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx index 019602f20..ee27e7ef0 100644 --- a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx +++ b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx @@ -1,8 +1,56 @@ 'use client'; import React from 'react'; +import Table from '@/components/table/table'; +import isActiveActiveDomain from '@/views/shared/active-active/helpers/is-active-active-domain'; +import useSuspenseDomainDescription from '@/views/shared/hooks/use-domain-description/use-suspense-domain-description'; + +import domainPageFailoversTableActiveActiveConfig from '../config/domain-page-failovers-table-active-active.config'; +import domainPageFailoversTableConfig from '../config/domain-page-failovers-table.config'; import { type DomainPageTabContentProps } from '../domain-page-content/domain-page-content.types'; +import useDomainFailoverHistory from '../hooks/use-domain-failover-history/use-domain-failover-history'; + +export default function DomainPageFailovers({ + domain, + cluster, +}: DomainPageTabContentProps) { + const { data: domainDescription } = useSuspenseDomainDescription({ + domain, + cluster, + }); + + const { + filteredFailoverEvents, + allFailoverEvents, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useDomainFailoverHistory({ + domainName: domain, + domainId: domainDescription.id, + cluster, + // TODO: set and pass filters here, but only for AA domains + }); -export default function DomainPageFailovers(_: DomainPageTabContentProps) { - return
WIP: Domain Page Failovers Tab
; + return ( + 0} + endMessageProps={{ + kind: 'infinite-scroll', + hasData: allFailoverEvents.length > 0, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }} + columns={ + isActiveActiveDomain(domainDescription) + ? domainPageFailoversTableActiveActiveConfig + : domainPageFailoversTableConfig + } + /> + ); } From 18f01b38ea09d104c78a2c9e56cc10baddc8daea Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Thu, 13 Nov 2025 12:56:11 +0100 Subject: [PATCH 2/5] Add more changes Signed-off-by: Adhitya Mamallan --- ...ge-failovers-table-active-active.config.ts | 10 +-- .../domain-page-failovers-table.config.ts | 15 ++-- .../config/domain-page-query-params.config.ts | 11 +++ ...main-page-failover-active-active.styles.ts | 23 ++++++ .../domain-page-failover-active-active.tsx | 71 +++++++++++++++++++ ...omain-page-failover-active-active.types.ts | 5 ++ ...ain-page-failover-single-cluster.styles.ts | 17 +++++ .../domain-page-failover-single-cluster.tsx | 23 ++++++ ...main-page-failover-single-cluster.types.ts | 4 ++ .../__tests__/domain-page-failovers.test.tsx | 0 .../domain-page-failovers.styles.ts | 7 ++ .../domain-page-failovers.tsx | 53 +++++++++----- 12 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts create mode 100644 src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx create mode 100644 src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.types.ts create mode 100644 src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.styles.ts create mode 100644 src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.tsx create mode 100644 src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.types.ts create mode 100644 src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx create mode 100644 src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts diff --git a/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts b/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts index 984c8df86..8562a614a 100644 --- a/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts +++ b/src/views/domain-page/config/domain-page-failovers-table-active-active.config.ts @@ -1,15 +1,17 @@ +import { createElement } from 'react'; + import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; +import DomainPageFailoverActiveActive from '../domain-page-failover-active-active/domain-page-failover-active-active'; + import domainPageFailoversTableConfig from './domain-page-failovers-table.config'; const domainPageFailoversTableActiveActiveConfig = [ ...domainPageFailoversTableConfig.slice(0, 3), { - name: 'Failover Information AA', - id: 'failoverInfo', - width: '40%', + ...domainPageFailoversTableConfig[3], renderCell: (event: FailoverEvent) => - JSON.stringify(event.clusterFailovers), + createElement(DomainPageFailoverActiveActive, { failoverEvent: event }), }, ]; diff --git a/src/views/domain-page/config/domain-page-failovers-table.config.ts b/src/views/domain-page/config/domain-page-failovers-table.config.ts index fcdb53794..c5bc0aa54 100644 --- a/src/views/domain-page/config/domain-page-failovers-table.config.ts +++ b/src/views/domain-page/config/domain-page-failovers-table.config.ts @@ -5,11 +5,14 @@ import { type TableConfig } from '@/components/table/table.types'; import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; import parseGrpcTimestamp from '@/utils/datetime/parse-grpc-timestamp'; +import DomainPageFailoverSingleCluster from '../domain-page-failover-single-cluster/domain-page-failover-single-cluster'; +import { FAILOVER_TYPE_LABEL_MAP } from '../domain-page-failovers/domain-page-failovers.constants'; + const domainPageFailoversTableConfig = [ { name: 'Failover ID', id: 'failoverId', - width: '25%', + width: '35%', renderCell: (event: FailoverEvent) => event.id, }, { @@ -27,14 +30,18 @@ const domainPageFailoversTableConfig = [ name: 'Type', id: 'type', width: '10%', - renderCell: (event: FailoverEvent) => event.failoverType, + renderCell: (event: FailoverEvent) => + FAILOVER_TYPE_LABEL_MAP[event.failoverType], }, { name: 'Failover Information', id: 'failoverInfo', - width: '50%', + width: '40%', renderCell: (event: FailoverEvent) => - JSON.stringify(event.clusterFailovers), + createElement(DomainPageFailoverSingleCluster, { + fromCluster: event.clusterFailovers[0].fromCluster?.activeClusterName, + toCluster: event.clusterFailovers[0].toCluster?.activeClusterName, + }), }, ] as const satisfies TableConfig; diff --git a/src/views/domain-page/config/domain-page-query-params.config.ts b/src/views/domain-page/config/domain-page-query-params.config.ts index 645fdb45f..48d551c51 100644 --- a/src/views/domain-page/config/domain-page-query-params.config.ts +++ b/src/views/domain-page/config/domain-page-query-params.config.ts @@ -41,6 +41,9 @@ const domainPageQueryParamsConfig: [ PageQueryParam<'sortColumnArchival', string>, PageQueryParam<'sortOrderArchival', SortOrder>, PageQueryParam<'queryArchival', string>, + // Failovers Tab query params + PageQueryParam<'clusterAttributeScope', string | undefined>, + PageQueryParam<'clusterAttributeValue', string | undefined>, ] = [ { key: 'inputType', @@ -163,6 +166,14 @@ const domainPageQueryParamsConfig: [ queryParamKey: 'aquery', defaultValue: '', }, + { + key: 'clusterAttributeScope', + queryParamKey: 'cs', + }, + { + key: 'clusterAttributeValue', + queryParamKey: 'cv', + }, ] as const; export default domainPageQueryParamsConfig; diff --git a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts new file mode 100644 index 000000000..9ee2404a4 --- /dev/null +++ b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts @@ -0,0 +1,23 @@ +import { styled as createStyled, type Theme } from 'baseui'; + +export const styled = { + FailoverContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + gap: $theme.sizing.scale600, + alignItems: 'baseline', + })), + ClusterFailoverContainer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + alignItems: 'baseline', + gap: $theme.sizing.scale300, + }) + ), + ClusterAttributeLabel: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + ...$theme.typography.LabelSmall, + }) + ), +}; diff --git a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx new file mode 100644 index 000000000..9717da649 --- /dev/null +++ b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; + +import { Button } from 'baseui/button'; +import { MdVisibility } from 'react-icons/md'; + +import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; + +import domainPageQueryParamsConfig from '../config/domain-page-query-params.config'; +import DomainPageFailoverSingleCluster from '../domain-page-failover-single-cluster/domain-page-failover-single-cluster'; +import { PRIMARY_CLUSTER_SCOPE } from '../domain-page-failovers/domain-page-failovers.constants'; +import clusterFailoverMatchesAttribute from '../helpers/cluster-failover-matches-attribute'; + +import { styled } from './domain-page-failover-active-active.styles'; +import { type Props } from './domain-page-failover-active-active.types'; + +export default function DomainPageFailoverActiveActive({ + failoverEvent, +}: Props) { + const [{ clusterAttributeScope, clusterAttributeValue }] = usePageQueryParams( + domainPageQueryParamsConfig + ); + + const clusterFailoverForMaybeSelectedAttribute = useMemo( + () => + failoverEvent.clusterFailovers.find((clusterFailover) => + clusterFailoverMatchesAttribute( + clusterFailover, + clusterAttributeScope, + clusterAttributeValue + ) + ), + [ + clusterAttributeScope, + clusterAttributeValue, + failoverEvent.clusterFailovers, + ] + ); + + return ( + + {clusterFailoverForMaybeSelectedAttribute && ( + + + {clusterAttributeScope === PRIMARY_CLUSTER_SCOPE + ? 'Primary:' + : `${clusterAttributeScope} (${clusterAttributeValue}):`} + + + + )} + + + ); +} diff --git a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.types.ts b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.types.ts new file mode 100644 index 000000000..300e642c9 --- /dev/null +++ b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.types.ts @@ -0,0 +1,5 @@ +import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; + +export type Props = { + failoverEvent: FailoverEvent; +}; diff --git a/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.styles.ts b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.styles.ts new file mode 100644 index 000000000..b6ba98489 --- /dev/null +++ b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.styles.ts @@ -0,0 +1,17 @@ +import { type Theme } from 'baseui'; + +import type { + StyletronCSSObject, + StyletronCSSObjectOf, +} from '@/hooks/use-styletron-classes'; + +const cssStylesObj = { + failoverContainer: (theme: Theme) => ({ + display: 'flex', + gap: theme.sizing.scale400, + alignItems: 'center', + }), +} satisfies StyletronCSSObject; + +export const cssStyles: StyletronCSSObjectOf = + cssStylesObj; diff --git a/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.tsx b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.tsx new file mode 100644 index 000000000..3466dc29e --- /dev/null +++ b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.tsx @@ -0,0 +1,23 @@ +import { MdArrowForward } from 'react-icons/md'; + +import useStyletronClasses from '@/hooks/use-styletron-classes'; + +import { cssStyles } from './domain-page-failover-single-cluster.styles'; +import { type Props } from './domain-page-failover-single-cluster.types'; + +export default function DomainPageFailoverSingleCluster({ + fromCluster, + toCluster, +}: Props) { + const { cls, theme } = useStyletronClasses(cssStyles); + + if (!fromCluster || !toCluster) return null; + + return ( +
+ {fromCluster} + + {toCluster} +
+ ); +} diff --git a/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.types.ts b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.types.ts new file mode 100644 index 000000000..c5714aeae --- /dev/null +++ b/src/views/domain-page/domain-page-failover-single-cluster/domain-page-failover-single-cluster.types.ts @@ -0,0 +1,4 @@ +export type Props = { + fromCluster?: string; + toCluster?: string; +}; diff --git a/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx b/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts b/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts new file mode 100644 index 000000000..8aa3aa059 --- /dev/null +++ b/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts @@ -0,0 +1,7 @@ +import { styled as createStyled, type Theme } from 'baseui'; + +export const styled = { + FailoversContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + paddingTop: $theme.sizing.scale950, + })), +}; diff --git a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx index ee27e7ef0..95c5b4523 100644 --- a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx +++ b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx @@ -2,14 +2,18 @@ import React from 'react'; import Table from '@/components/table/table'; +import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; import isActiveActiveDomain from '@/views/shared/active-active/helpers/is-active-active-domain'; import useSuspenseDomainDescription from '@/views/shared/hooks/use-domain-description/use-suspense-domain-description'; import domainPageFailoversTableActiveActiveConfig from '../config/domain-page-failovers-table-active-active.config'; import domainPageFailoversTableConfig from '../config/domain-page-failovers-table.config'; +import domainPageQueryParamsConfig from '../config/domain-page-query-params.config'; import { type DomainPageTabContentProps } from '../domain-page-content/domain-page-content.types'; import useDomainFailoverHistory from '../hooks/use-domain-failover-history/use-domain-failover-history'; +import { styled } from './domain-page-failovers.styles'; + export default function DomainPageFailovers({ domain, cluster, @@ -19,6 +23,12 @@ export default function DomainPageFailovers({ cluster, }); + const isActiveActive = isActiveActiveDomain(domainDescription); + + const [{ clusterAttributeScope, clusterAttributeValue }] = usePageQueryParams( + domainPageQueryParamsConfig + ); + const { filteredFailoverEvents, allFailoverEvents, @@ -31,26 +41,33 @@ export default function DomainPageFailovers({ domainName: domain, domainId: domainDescription.id, cluster, - // TODO: set and pass filters here, but only for AA domains + ...(isActiveActive + ? { + clusterAttributeScope, + clusterAttributeValue, + } + : {}), }); return ( -
0} - endMessageProps={{ - kind: 'infinite-scroll', - hasData: allFailoverEvents.length > 0, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - }} - columns={ - isActiveActiveDomain(domainDescription) - ? domainPageFailoversTableActiveActiveConfig - : domainPageFailoversTableConfig - } - /> + +
0} + endMessageProps={{ + kind: 'infinite-scroll', + hasData: allFailoverEvents.length > 0, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }} + columns={ + isActiveActive + ? domainPageFailoversTableActiveActiveConfig + : domainPageFailoversTableConfig + } + /> + ); } From 3a34dd1dd9f39402184f92404a12d3e9df2a6b8f Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Fri, 14 Nov 2025 14:23:59 +0100 Subject: [PATCH 3/5] update fixtures Signed-off-by: Adhitya Mamallan --- src/views/domain-page/__fixtures__/domain-page-query-params.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/views/domain-page/__fixtures__/domain-page-query-params.ts b/src/views/domain-page/__fixtures__/domain-page-query-params.ts index 993a1ad07..bc65c4601 100644 --- a/src/views/domain-page/__fixtures__/domain-page-query-params.ts +++ b/src/views/domain-page/__fixtures__/domain-page-query-params.ts @@ -23,4 +23,6 @@ export const mockDomainPageQueryParamsValues = { sortColumnArchival: 'startTime', sortOrderArchival: 'DESC', queryArchival: '', + clusterAttributeScope: undefined, + clusterAttributeValue: undefined, } as const satisfies PageQueryParamValues; From 2dd7727902490b9285560f1db61a69fd4eab7132 Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Fri, 14 Nov 2025 14:34:15 +0100 Subject: [PATCH 4/5] new changes Signed-off-by: Adhitya Mamallan --- ...omain-page-failover-active-active.test.tsx | 311 ++++++++++++++++++ ...main-page-failover-active-active.styles.ts | 13 +- .../domain-page-failover-active-active.tsx | 39 ++- ...main-page-failover-single-cluster.test.tsx | 28 ++ .../__tests__/domain-page-failovers.test.tsx | 227 +++++++++++++ .../domain-page-failovers.styles.ts | 9 +- .../domain-page-failovers.tsx | 4 +- 7 files changed, 604 insertions(+), 27 deletions(-) create mode 100644 src/views/domain-page/domain-page-failover-active-active/__tests__/domain-page-failover-active-active.test.tsx create mode 100644 src/views/domain-page/domain-page-failover-single-cluster/__tests__/domain-page-failover-single-cluster.test.tsx diff --git a/src/views/domain-page/domain-page-failover-active-active/__tests__/domain-page-failover-active-active.test.tsx b/src/views/domain-page/domain-page-failover-active-active/__tests__/domain-page-failover-active-active.test.tsx new file mode 100644 index 000000000..42691611a --- /dev/null +++ b/src/views/domain-page/domain-page-failover-active-active/__tests__/domain-page-failover-active-active.test.tsx @@ -0,0 +1,311 @@ +import { render, screen } from '@/test-utils/rtl'; + +import * as usePageQueryParamsModule from '@/hooks/use-page-query-params/use-page-query-params'; +import { type FailoverEvent } from '@/route-handlers/list-failover-history/list-failover-history.types'; +import { mockDomainPageQueryParamsValues } from '@/views/domain-page/__fixtures__/domain-page-query-params'; +import { PRIMARY_CLUSTER_SCOPE } from '@/views/domain-page/domain-page-failovers/domain-page-failovers.constants'; + +import DomainPageFailoverActiveActive from '../domain-page-failover-active-active'; + +const mockSetQueryParams = jest.fn(); +jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => + jest.fn(() => [mockDomainPageQueryParamsValues, mockSetQueryParams]) +); + +jest.mock( + '../../domain-page-failover-single-cluster/domain-page-failover-single-cluster', + () => + jest.fn((props: { fromCluster?: string; toCluster?: string }) => ( +
+ {`${props.fromCluster} -> ${props.toCluster}`} +
+ )) +); + +describe(DomainPageFailoverActiveActive.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders cluster failover when matching primary cluster failover is found', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: null, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: PRIMARY_CLUSTER_SCOPE, + }); + + expect(screen.getByText('Primary:')).toBeInTheDocument(); + expect( + screen.getByTestId('mock-single-cluster-failover') + ).toBeInTheDocument(); + expect(screen.getByText('cluster-1 -> cluster-2')).toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('renders cluster failover when matching non-primary cluster failover is found', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: { + scope: 'city', + name: 'new_york', + }, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: 'city', + clusterAttributeValue: 'new_york', + }); + + expect(screen.getByText('city (new_york):')).toBeInTheDocument(); + expect( + screen.getByTestId('mock-single-cluster-failover') + ).toBeInTheDocument(); + expect(screen.getByText('cluster-1 -> cluster-2')).toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('does not render cluster failover section when clusterAttributeScope is set but clusterAttributeValue is undefined for non-primary scope', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: { + scope: 'region', + name: 'us-east', + }, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: 'region', + }); + + expect( + screen.queryByTestId('mock-single-cluster-failover') + ).not.toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('does not render cluster failover section when no matching cluster failover is found', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: { + scope: 'city', + name: 'new_york', + }, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: 'city', + clusterAttributeValue: 'los_angeles', + }); + + expect(screen.queryByText('city (los_angeles):')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-single-cluster-failover') + ).not.toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('does not render cluster failover section when clusterAttributeScope is undefined', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: null, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: undefined, + }); + + expect( + screen.queryByTestId('mock-single-cluster-failover') + ).not.toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('renders "See more" button even when no matching cluster failover is found', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [], + }; + + setup({ + failoverEvent, + clusterAttributeScope: PRIMARY_CLUSTER_SCOPE, + }); + + expect( + screen.queryByTestId('mock-single-cluster-failover') + ).not.toBeInTheDocument(); + expect(screen.getByText('See more')).toBeInTheDocument(); + }); + + it('selects the correct cluster failover when multiple cluster failovers exist', () => { + const failoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: { + scope: 'city', + name: 'new_york', + }, + }, + { + fromCluster: { + activeClusterName: 'cluster-3', + failoverVersion: '3', + }, + toCluster: { + activeClusterName: 'cluster-4', + failoverVersion: '4', + }, + clusterAttribute: { + scope: 'region', + name: 'us-east', + }, + }, + ], + }; + + setup({ + failoverEvent, + clusterAttributeScope: 'region', + clusterAttributeValue: 'us-east', + }); + + expect(screen.getByText('region (us-east):')).toBeInTheDocument(); + expect( + screen.getByTestId('mock-single-cluster-failover') + ).toBeInTheDocument(); + expect(screen.getByText('cluster-3 -> cluster-4')).toBeInTheDocument(); + }); +}); + +function setup({ + failoverEvent, + clusterAttributeScope, + clusterAttributeValue, +}: { + failoverEvent: FailoverEvent; + clusterAttributeScope?: string; + clusterAttributeValue?: string; +}) { + jest.spyOn(usePageQueryParamsModule, 'default').mockReturnValue([ + { + ...mockDomainPageQueryParamsValues, + clusterAttributeScope, + clusterAttributeValue, + } as typeof mockDomainPageQueryParamsValues, + mockSetQueryParams, + ]); + + render(); +} diff --git a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts index 9ee2404a4..37628c859 100644 --- a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts +++ b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.styles.ts @@ -1,11 +1,14 @@ import { styled as createStyled, type Theme } from 'baseui'; export const styled = { - FailoverContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ - display: 'flex', - gap: $theme.sizing.scale600, - alignItems: 'baseline', - })), + FailoverEventContainer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + gap: $theme.sizing.scale600, + alignItems: 'baseline', + }) + ), ClusterFailoverContainer: createStyled( 'div', ({ $theme }: { $theme: Theme }) => ({ diff --git a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx index 9717da649..c8d5ccc07 100644 --- a/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx +++ b/src/views/domain-page/domain-page-failover-active-active/domain-page-failover-active-active.tsx @@ -20,24 +20,29 @@ export default function DomainPageFailoverActiveActive({ domainPageQueryParamsConfig ); - const clusterFailoverForMaybeSelectedAttribute = useMemo( - () => - failoverEvent.clusterFailovers.find((clusterFailover) => - clusterFailoverMatchesAttribute( - clusterFailover, - clusterAttributeScope, - clusterAttributeValue - ) - ), - [ - clusterAttributeScope, - clusterAttributeValue, - failoverEvent.clusterFailovers, - ] - ); + const clusterFailoverForMaybeSelectedAttribute = useMemo(() => { + if ( + !clusterAttributeScope || + (clusterAttributeScope !== PRIMARY_CLUSTER_SCOPE && + !clusterAttributeValue) + ) + return undefined; + + return failoverEvent.clusterFailovers.find((clusterFailover) => + clusterFailoverMatchesAttribute( + clusterFailover, + clusterAttributeScope, + clusterAttributeValue + ) + ); + }, [ + clusterAttributeScope, + clusterAttributeValue, + failoverEvent.clusterFailovers, + ]); return ( - + {clusterFailoverForMaybeSelectedAttribute && ( @@ -66,6 +71,6 @@ export default function DomainPageFailoverActiveActive({ > See more - + ); } diff --git a/src/views/domain-page/domain-page-failover-single-cluster/__tests__/domain-page-failover-single-cluster.test.tsx b/src/views/domain-page/domain-page-failover-single-cluster/__tests__/domain-page-failover-single-cluster.test.tsx new file mode 100644 index 000000000..849cb6b0f --- /dev/null +++ b/src/views/domain-page/domain-page-failover-single-cluster/__tests__/domain-page-failover-single-cluster.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@/test-utils/rtl'; + +import DomainPageFailoverSingleCluster from '../domain-page-failover-single-cluster'; + +describe(DomainPageFailoverSingleCluster.name, () => { + it('renders fromCluster and toCluster with arrow', () => { + setup({ fromCluster: 'cluster-1', toCluster: 'cluster-2' }); + + expect(screen.getByText(/cluster-1/)).toBeInTheDocument(); + expect(screen.getByText(/cluster-2/)).toBeInTheDocument(); + }); + + it('returns null when fromCluster is missing', () => { + setup({ toCluster: 'cluster-2' }); + + expect(screen.queryByText(/cluster-2/)).not.toBeInTheDocument(); + }); + + it('returns null when toCluster is missing', () => { + setup({ fromCluster: 'cluster-1' }); + + expect(screen.queryByText(/cluster-1/)).not.toBeInTheDocument(); + }); +}); + +function setup(props: { fromCluster?: string; toCluster?: string }) { + render(); +} diff --git a/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx b/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx index e69de29bb..5868c44e2 100644 --- a/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx +++ b/src/views/domain-page/domain-page-failovers/__tests__/domain-page-failovers.test.tsx @@ -0,0 +1,227 @@ +import React, { Suspense } from 'react'; + +import { HttpResponse } from 'msw'; + +import { render, screen } from '@/test-utils/rtl'; + +import { type Props as LoaderProps } from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader.types'; +import { type DescribeDomainResponse } from '@/route-handlers/describe-domain/describe-domain.types'; +import { + type FailoverEvent, + type ListFailoverHistoryResponse, +} from '@/route-handlers/list-failover-history/list-failover-history.types'; +import { mockDomainDescription } from '@/views/domain-page/__fixtures__/domain-description'; +import { mockDomainPageQueryParamsValues } from '@/views/domain-page/__fixtures__/domain-page-query-params'; +import { mockActiveActiveDomain } from '@/views/shared/active-active/__fixtures__/active-active-domain'; + +import DomainPageFailovers from '../domain-page-failovers'; + +const mockSetQueryParams = jest.fn(); +jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => + jest.fn(() => [mockDomainPageQueryParamsValues, mockSetQueryParams]) +); + +jest.mock( + '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader', + () => + jest.fn((props: LoaderProps) => ( + + )) +); + +jest.mock('../../config/domain-page-failovers-table.config', () => [ + { + name: 'Failover ID', + id: 'failoverId', + width: '35%', + renderCell: (event: FailoverEvent) =>
{event.id}
, + }, + { + name: 'Time', + id: 'time', + width: '15%', + renderCell: (event: FailoverEvent) => ( +
{event.createdTime?.seconds || 'No date'}
+ ), + }, + { + name: 'Type', + id: 'type', + width: '10%', + renderCell: (event: FailoverEvent) =>
{event.failoverType}
, + }, + { + name: 'Failover Information', + id: 'failoverInfo', + width: '40%', + renderCell: (event: FailoverEvent) => ( +
+ {event.clusterFailovers[0]?.fromCluster?.activeClusterName} + {` -> `} + {event.clusterFailovers[0]?.toCluster?.activeClusterName} +
+ ), + }, +]); + +jest.mock( + '../../config/domain-page-failovers-table-active-active.config', + () => [ + { + name: 'Failover ID', + id: 'failoverId', + width: '35%', + renderCell: (event: FailoverEvent) =>
{event.id}
, + }, + { + name: 'Time', + id: 'time', + width: '15%', + renderCell: (event: FailoverEvent) => ( +
{event.createdTime?.seconds || 'No date'}
+ ), + }, + { + name: 'Type', + id: 'type', + width: '10%', + renderCell: (event: FailoverEvent) =>
{event.failoverType}
, + }, + { + name: 'Failover Information', + id: 'failoverInfo', + width: '40%', + renderCell: (event: FailoverEvent) => ( +
Active Active: {event.id}
+ ), + }, + ] +); + +const mockFailoverEvent: FailoverEvent = { + id: 'failover-1', + createdTime: { + seconds: '1700000000', + nanos: 0, + }, + failoverType: 'FAILOVER_TYPE_GRACEFUL', + clusterFailovers: [ + { + fromCluster: { + activeClusterName: 'cluster-1', + failoverVersion: '1', + }, + toCluster: { + activeClusterName: 'cluster-2', + failoverVersion: '2', + }, + clusterAttribute: null, + }, + ], +}; + +describe(DomainPageFailovers.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table with column headers', async () => { + await setup({}); + + expect(await screen.findByText('Failover ID')).toBeInTheDocument(); + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Failover Information')).toBeInTheDocument(); + }); + + it('renders failover events in table', async () => { + await setup({ + failoverResponse: { + failoverEvents: [mockFailoverEvent], + nextPageToken: '', + }, + }); + + expect(await screen.findByText('failover-1')).toBeInTheDocument(); + expect(screen.getByText('FAILOVER_TYPE_GRACEFUL')).toBeInTheDocument(); + }); + + it('does not render data rows when no failover events', async () => { + await setup({ + failoverResponse: { + failoverEvents: [], + nextPageToken: '', + }, + }); + + await screen.findByText('Failover ID'); + expect(screen.queryByText('failover-1')).not.toBeInTheDocument(); + }); + + it('renders table with active-active config when domain is active-active', async () => { + await setup({ + domainDescription: mockActiveActiveDomain, + failoverResponse: { + failoverEvents: [mockFailoverEvent], + nextPageToken: '', + }, + }); + + expect(await screen.findByText('failover-1')).toBeInTheDocument(); + }); +}); + +async function setup({ + domain = 'mock-domain', + cluster = 'mock-cluster', + domainDescription = mockDomainDescription, + failoverResponse = { + failoverEvents: [], + nextPageToken: '', + }, + failoverError = false, +}: { + domain?: string; + cluster?: string; + domainDescription?: typeof mockDomainDescription; + failoverResponse?: ListFailoverHistoryResponse; + failoverError?: boolean; +}) { + render( + Loading...}> + + , + { + endpointsMocks: [ + { + path: '/api/domains/:domain/:cluster', + httpMethod: 'GET', + mockOnce: false, + jsonResponse: domainDescription satisfies DescribeDomainResponse, + }, + { + path: '/api/domains/:domain/:cluster/failovers', + httpMethod: 'GET', + mockOnce: false, + ...(failoverError + ? { + httpResolver: () => { + return HttpResponse.json( + { message: 'Failed to fetch failover history' }, + { status: 500 } + ); + }, + } + : { + jsonResponse: + failoverResponse satisfies ListFailoverHistoryResponse, + }), + }, + ], + } + ); + + await screen.findByText('Failover ID'); +} diff --git a/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts b/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts index 8aa3aa059..54216b184 100644 --- a/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts +++ b/src/views/domain-page/domain-page-failovers/domain-page-failovers.styles.ts @@ -1,7 +1,10 @@ import { styled as createStyled, type Theme } from 'baseui'; export const styled = { - FailoversContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ - paddingTop: $theme.sizing.scale950, - })), + FailoversTableContainer: createStyled( + 'div', + ({ $theme }: { $theme: Theme }) => ({ + paddingTop: $theme.sizing.scale950, + }) + ), }; diff --git a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx index 95c5b4523..868c38ac3 100644 --- a/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx +++ b/src/views/domain-page/domain-page-failovers/domain-page-failovers.tsx @@ -50,7 +50,7 @@ export default function DomainPageFailovers({ }); return ( - +
0} @@ -68,6 +68,6 @@ export default function DomainPageFailovers({ : domainPageFailoversTableConfig } /> - + ); } From 6f402ee22e05f413c9eaba5dba2ac62edefa0d1c Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Fri, 14 Nov 2025 14:51:00 +0100 Subject: [PATCH 5/5] Update src/views/domain-page/config/domain-page-failovers-table.config.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../domain-page/config/domain-page-failovers-table.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/domain-page/config/domain-page-failovers-table.config.ts b/src/views/domain-page/config/domain-page-failovers-table.config.ts index c5bc0aa54..f3cf99ef7 100644 --- a/src/views/domain-page/config/domain-page-failovers-table.config.ts +++ b/src/views/domain-page/config/domain-page-failovers-table.config.ts @@ -39,8 +39,8 @@ const domainPageFailoversTableConfig = [ width: '40%', renderCell: (event: FailoverEvent) => createElement(DomainPageFailoverSingleCluster, { - fromCluster: event.clusterFailovers[0].fromCluster?.activeClusterName, - toCluster: event.clusterFailovers[0].toCluster?.activeClusterName, + fromCluster: event.clusterFailovers[0]?.fromCluster?.activeClusterName, + toCluster: event.clusterFailovers[0]?.toCluster?.activeClusterName, }), }, ] as const satisfies TableConfig;