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'));
+});

Reply via email to