This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit b518949dc534ea817447489eaa4495d5242fab8a Author: dark <[email protected]> AuthorDate: Fri Aug 14 12:39:02 2026 +0800 test(operations): cover topology and navigation recovery --- .../src/pages/Operations/Overview.test.js | 2 +- .../hubble-fe/src/pages/Operations/components.js | 106 +++++++-- .../src/pages/Operations/components.test.js | 19 +- .../src/pages/Operations/navigation-flow.test.js | 263 +++++++++++++++++++++ 4 files changed, 364 insertions(+), 26 deletions(-) diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js index b5636bba7..d3e0b74d7 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.test.js @@ -77,7 +77,7 @@ test('keeps unknown and partial source states explicit', async () => { renderOverview(); - expect(await screen.findByText('DEGRADED')).toBeInTheDocument(); + expect(await screen.findByText('Attention')).toBeInTheDocument(); expect(screen.getByRole('radiogroup', {name: 'Overview view'})) .toBeInTheDocument(); expect(screen.getByText('Malformed')).toBeInTheDocument(); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js index 5e6f96684..df586871a 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.js @@ -20,6 +20,7 @@ import { CheckCircleFilled, ClockCircleFilled, CloseCircleFilled, + CrownOutlined, DatabaseOutlined, DeploymentUnitOutlined, ExclamationCircleFilled, @@ -35,6 +36,7 @@ import { formatObservedAt, hasStaleMetrics, selectTierNodes, + storeLeaderCount, } from './topology'; const STATUS_ICON = { @@ -58,20 +60,43 @@ const NODE_TYPE_LABELS = { const displayNodeType = type => NODE_TYPE_LABELS[type] ?? type ?? '—'; +const displayHealthStatus = (status, t) => ( + status === 'DEGRADED' ? t('operations.status_degraded') : status +); + const HealthStatus = ({status = 'UNKNOWN', reason, stale = false, size = 'normal'}) => { const {t} = useTranslation(); const normalized = STATUS_ICON[status] ? status : 'UNKNOWN'; const Icon = STATUS_ICON[normalized]; + const details = [ + normalized === 'UNKNOWN' ? t('operations.status_unknown') : null, + normalized === 'DEGRADED' ? t('operations.status_degraded_help') : null, + reason ? formatReason(reason, t) : null, + stale ? t('operations.stale_help') : null, + ].filter(Boolean); + const icon = details.length > 0 ? ( + <Tooltip title={details.join(' · ')}> + <span + role='img' + aria-label={details.join(' · ')} + className='operations-health-info' + > + <Icon aria-hidden='true' /> + </span> + </Tooltip> + ) : <Icon aria-hidden='true' />; return ( <span className={`operations-health status-${normalized.toLowerCase()} is-${size}`}> - <Icon aria-hidden='true' /> - <span>{normalized}</span> + {icon} + <span> + {displayHealthStatus(normalized, t)} + </span> {stale && ( <span className='operations-health-stale'> <ClockCircleFilled aria-hidden='true' /> {t('operations.stale')} </span> )} - {reason && ( + {reason && size === 'large' && ( <span className='operations-health-reason'>{formatReason(reason, t)}</span> )} </span> @@ -106,7 +131,10 @@ const SourceStrip = ({sources = {}, detailed = false, {displayNodeType(name === 'stores' ? 'STORE' : name.toUpperCase())} </strong> - <HealthStatus status={source.status} /> + <HealthStatus + status={source.status} + reason={source.reason} + /> <span className='operations-source-state'> {t(`operations.availability_${( source.availability ?? 'UNSUPPORTED' @@ -164,26 +192,52 @@ const RefreshButton = ({loading = false, onClick}) => { ); }; -const TierNode = ({node}) => ( - <Link - className={[ - 'operations-topology-node', - `status-${node.status?.toLowerCase()}`, - node.type === 'PD' && node.role === 'LEADER' ? 'is-axis-node' : '', - ].filter(Boolean).join(' ')} - to={`/operations/nodes/${node.id}`} - aria-label={`${node.type} ${node.name} ${node.role ?? ''} ${node.status}`} - > - <TierIcon type={node.type} /> - <span className='operations-node-copy'> - <strong>{node.name}</strong> - <span>{node.role ?? node.version ?? '—'}</span> - </span> - <HealthStatus status={node.status} stale={hasStaleMetrics(node)} /> - </Link> -); +const nodeRoleLabel = (node, t) => { + if (node?.role) { + return node.role; + } + const leaders = storeLeaderCount(node); + return leaders === null ? '—' : t( + leaders === 1 ? 'operations.leader_shard' : 'operations.leader_shards', + {count: leaders} + ); +}; + +const TierNode = ({node, returnState}) => { + const {t} = useTranslation(); + return ( + <Link + className={[ + 'operations-topology-node', + `status-${node.status?.toLowerCase()}`, + node.type === 'PD' && node.role === 'LEADER' + ? 'is-axis-node' : '', + ].filter(Boolean).join(' ')} + to={`/operations/nodes/${node.id}`} + state={returnState} + aria-label={`${node.type} ${node.name} ${node.role ?? ''} ${ + displayHealthStatus(node.status, t)}`} + > + <TierIcon type={node.type} /> + <span className='operations-node-copy'> + <strong>{node.name}</strong> + <span> + {node.type === 'PD' && node.role === 'LEADER' && ( + <CrownOutlined + aria-label={t('operations.leader_role')} + role='img' + /> + )} + {nodeRoleLabel(node, t) === '—' + ? (node.version ?? '—') : nodeRoleLabel(node, t)} + </span> + </span> + <HealthStatus status={node.status} stale={hasStaleMetrics(node)} /> + </Link> + ); +}; -const TopologyTier = ({type, nodes}) => { +const TopologyTier = ({type, nodes, returnState}) => { const {t} = useTranslation(); const tier = selectTierNodes(nodes, type); return ( @@ -203,6 +257,7 @@ const TopologyTier = ({type, nodes}) => { <TierNode key={node.id} node={node} + returnState={returnState} index={index} /> ))} @@ -220,7 +275,7 @@ const TopologyTier = ({type, nodes}) => { ); }; -const ClusterTopology = ({nodes = []}) => { +const ClusterTopology = ({nodes = [], returnState}) => { const {t} = useTranslation(); return ( <div className='operations-topology' aria-label={t('operations.topology_label')}> @@ -229,6 +284,7 @@ const ClusterTopology = ({nodes = []}) => { key={type} type={type} nodes={Array.isArray(nodes) ? nodes : []} + returnState={returnState} /> ))} </div> @@ -237,10 +293,12 @@ const ClusterTopology = ({nodes = []}) => { export { HealthStatus, + nodeRoleLabel, SourceStrip, ClusterTopology, TierIcon, RefreshButton, formatReason, + displayHealthStatus, displayNodeType, }; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js index 510be2a35..3d79cc5fe 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/components.test.js @@ -18,7 +18,7 @@ import {render, screen, within} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; -import {ClusterTopology, SourceStrip} from './components'; +import {ClusterTopology, HealthStatus, SourceStrip} from './components'; import i18n from '../../i18n'; afterEach(() => i18n.changeLanguage('en-US')); @@ -46,6 +46,8 @@ test('localizes the standalone deployment reason code', async () => { status: 'UNKNOWN', availability: 'UNSUPPORTED', reason: 'deployment_mode_unsupported', + observed_at: 1000, + last_success_at: 500, }, }} /> @@ -53,6 +55,19 @@ test('localizes the standalone deployment reason code', async () => { expect(screen.getByText(/当前部署模式不支持/)).toBeInTheDocument(); expect(screen.queryByText(/deployment mode unsupported/)).not.toBeInTheDocument(); + const statusInfo = screen.getAllByRole('img', {name: /当前无法确认/}) + .find(element => element.getAttribute('aria-label').includes('当前部署模式不支持')); + expect(statusInfo).toHaveAccessibleName(/当前部署模式不支持/); + expect(statusInfo).not.toHaveAccessibleName(/观测时间|最近成功/); +}); + +test('uses a concise Attention label and explains the degraded state', () => { + render(<HealthStatus status='DEGRADED' reason='refresh_failed' />); + + expect(screen.getByText('Attention')).toBeInTheDocument(); + expect(screen.getByRole('img', {name: /Some sources or metrics are unhealthy/})) + .toHaveAccessibleName(/Refresh failed/); + expect(screen.queryByText('DEGRADED')).not.toBeInTheDocument(); }); test('uses semantic tier icons and keeps the PD leader on the visual axis', () => { @@ -72,6 +87,8 @@ test('uses semantic tier icons and keeps the PD leader on the visual axis', () = expect(screen.getAllByLabelText('PD icon')).toHaveLength(2); expect(screen.getByLabelText('STORE icon')).toBeInTheDocument(); expect(screen.getByText('pd-1').closest('a')).toHaveClass('is-axis-node'); + expect(within(screen.getByText('pd-1').closest('a')) + .getByLabelText('Leader role')).toBeInTheDocument(); expect(screen.getByText('pd-2').closest('a')).not.toHaveClass('is-axis-node'); expect(screen.getByRole('link', {name: 'Server tier'})).toBeInTheDocument(); expect(screen.getByRole('link', {name: 'Store tier'})).toBeInTheDocument(); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/navigation-flow.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/navigation-flow.test.js new file mode 100644 index 000000000..8e447a33e --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/navigation-flow.test.js @@ -0,0 +1,263 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, +} from 'react-router-dom'; +import Overview from './Overview'; +import Nodes from './Nodes'; +import NodeDetail from './NodeDetail'; +import {getNode, getNodes, getOverview} from '../../api/operations'; +import {getDashboard} from '../../api/auth'; +import '../../i18n'; + +jest.mock('../../api/operations'); +jest.mock('../../api/auth'); + +const OVERVIEW = { + status: 'UP', + observed_at: 1000, + sources: {}, + facts: {}, + nodes: [{id: 'store-safe', name: 'Store A', type: 'STORE', status: 'UP'}], +}; + +const DETAIL = { + node: { + id: 'store-safe', + name: 'Store A', + type: 'STORE', + status: 'UP', + version: '1.5.0', + metrics: {}, + }, + observed_at: 1000, + sources: {}, +}; + +const HistoryControls = () => { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + <button type='button' onClick={() => navigate(-1)}>browser back</button> + <button type='button' onClick={() => navigate(1)}>browser forward</button> + <output aria-label='current location'> + {location.pathname}{location.search} + </output> + </> + ); +}; + +const Journey = () => ( + <> + <HistoryControls /> + <Routes> + <Route path='/operations/overview' element={<Overview />} /> + <Route path='/operations/nodes/:nodeId' element={<NodeDetail />} /> + <Route path='/operations/nodes' element={<Nodes />} /> + </Routes> + </> +); + +beforeEach(() => { + sessionStorage.clear(); + sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + window.matchMedia = () => ({ + matches: false, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + getOverview.mockResolvedValue(OVERVIEW); + getNodes.mockResolvedValue({ + items: OVERVIEW.nodes, + total: OVERVIEW.nodes.length, + observed_at: OVERVIEW.observed_at, + stale: false, + }); + getNode.mockResolvedValue(DETAIL); + getDashboard.mockReturnValue(new Promise(() => {})); +}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); +}); + +test('keeps the overview node list across detail and browser history', async () => { + render( + <MemoryRouter + initialEntries={['/operations/overview']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + expect(await screen.findByRole('radio', {name: 'Topology'})).toBeChecked(); + fireEvent.click(screen.getByRole('radio', {name: 'Node list'})); + expect(screen.getByRole('radio', {name: 'Node list'})).toBeChecked(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/overview?view=nodes'); + + fireEvent.click(screen.getByRole('link', {name: 'Store A'})); + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'browser back'})); + expect(await screen.findByRole('radio', {name: 'Node list'})).toBeChecked(); + + fireEvent.click(screen.getByRole('button', {name: 'browser forward'})); + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'arrow-left Back'})); + expect(await screen.findByRole('radio', {name: 'Node list'})).toBeChecked(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/overview?view=nodes'); +}); + +test('returns a topology node detail to the current overview query', async () => { + render( + <MemoryRouter + initialEntries={['/operations/overview?source=topology']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + fireEvent.click(await screen.findByRole('link', {name: /STORE Store A.*UP/})); + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'arrow-left Back'})); + + expect(await screen.findByRole('radio', {name: 'Topology'})).toBeChecked(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/overview?source=topology'); +}); + +test('returns an attention detail to the overview query that opened it', async () => { + getOverview.mockResolvedValue({ + ...OVERVIEW, + status: 'DEGRADED', + nodes: [{...OVERVIEW.nodes[0], status: 'DOWN'}], + }); + + render( + <MemoryRouter + initialEntries={['/operations/overview?source=attention']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + fireEvent.click(await screen.findByRole('link', {name: 'View details'})); + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'arrow-left Back'})); + + expect(await screen.findByRole('link', {name: 'View details'})).toBeInTheDocument(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/overview?source=attention'); +}); + +test('uses the node list fallback for a direct or unsafe detail entry', async () => { + render( + <MemoryRouter + initialEntries={[{ + pathname: '/operations/nodes/store-safe', + state: {operationsReturnTo: '//example.invalid/operations/overview?view=nodes'}, + }]} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'arrow-left Back'})); + + expect(await screen.findByRole('link', {name: /Store A/})).toBeInTheDocument(); + expect(screen.getByLabelText('current location')).toHaveTextContent('/operations/nodes'); +}); + +test('recovers a failed detail to the overview node-list view', async () => { + getNode.mockRejectedValue(new Error('down')); + + render( + <MemoryRouter + initialEntries={['/operations/overview?view=nodes']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + fireEvent.click(await screen.findByRole('link', {name: 'Store A'})); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'Back to nodes'})); + + expect(await screen.findByRole('radio', {name: 'Node list'})).toBeChecked(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/overview?view=nodes'); +}); + +test('recovers a failed detail to the filtered node list', async () => { + getNode.mockRejectedValue(new Error('down')); + + render( + <MemoryRouter + initialEntries={['/operations/nodes?query=Store&page=2']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + fireEvent.click(await screen.findByRole('link', {name: /Store A/})); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'Back to nodes'})); + + expect(await screen.findByRole('link', {name: /Store A/})).toBeInTheDocument(); + expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/nodes?query=Store&page=2'); +}); + +test('preserves node-list filters when its detail returns', async () => { + render( + <MemoryRouter + initialEntries={['/operations/nodes?query=Store&page=2']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Journey /> + </MemoryRouter> + ); + + fireEvent.click(await screen.findByRole('link', {name: /Store A/})); + expect(await screen.findByRole('heading', {name: 'Store A'})).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'arrow-left Back'})); + + expect(await screen.findByRole('link', {name: /Store A/})).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText('current location')) + .toHaveTextContent('/operations/nodes?query=Store&page=2')); +});
