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 545e81b92d55049f184bb6227da6905c195ee90a
Author: dark <[email protected]>
AuthorDate: Fri Aug 14 12:39:02 2026 +0800

    fix(operations): localize node health states
---
 .../hubble-fe/src/pages/Operations/Nodes.js        |  64 ++++++++--
 .../hubble-fe/src/pages/Operations/Nodes.test.js   | 133 +++++++++++++++++++++
 .../hubble-fe/src/pages/Operations/Overview.js     |  42 +++++--
 3 files changed, 219 insertions(+), 20 deletions(-)

diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
index a9b35ee9d..b57b7a40d 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.js
@@ -19,12 +19,20 @@
 import {Alert, Button, Input, message, Select, Space, Table, Tag, Tooltip} 
from 'antd';
 import {CopyOutlined, CrownOutlined, SearchOutlined} from '@ant-design/icons';
 import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {Link, useNavigate, useSearchParams} from 'react-router-dom';
+import {Link, useLocation, useNavigate, useSearchParams} from 
'react-router-dom';
 import {useTranslation} from 'react-i18next';
 import {getNodes} from '../../api/operations';
 import {isPdEnabled} from '../../utils/config';
-import {displayNodeType, HealthStatus, RefreshButton, TierIcon} from 
'./components';
+import {
+    displayNodeType,
+    displayHealthStatus,
+    HealthStatus,
+    nodeRoleLabel,
+    RefreshButton,
+    TierIcon,
+} from './components';
 import {formatObservedAt, hasStaleMetrics} from './topology';
+import {operationsReturnState} from './navigation';
 import './operations.scss';
 
 const stopRowNavigation = event => event.stopPropagation();
@@ -34,7 +42,7 @@ const shortNodeId = id => {
     return normalized.length > 12 ? `…${normalized.slice(-12)}` : normalized;
 };
 
-const NodeIdentityCell = ({record, unavailable, t}) => {
+const NodeIdentityCell = ({record, returnState, unavailable, t}) => {
     const name = record.name ?? unavailable;
     const copyId = useCallback(async event => {
         stopRowNavigation(event);
@@ -52,6 +60,7 @@ const NodeIdentityCell = ({record, unavailable, t}) => {
         <span className='operations-node-identity-cell' 
aria-label={identityLabel}>
             <Link
                 to={`/operations/nodes/${record.id}`}
+                state={returnState}
                 onClick={stopRowNavigation}
                 aria-label={t('operations.view_node_details', {name})}
             >
@@ -67,13 +76,14 @@ const NodeIdentityCell = ({record, unavailable, t}) => {
                     </span>
                 </span>
             </Link>
-            {record.role && (
+            {(record.role || record.type === 'STORE') && (
                 <Tag
                     className={leader ? 'operations-node-role is-leader' : 
'operations-node-role'}
                     icon={leader ? <CrownOutlined aria-hidden='true' /> : null}
-                    aria-label={leader ? t('operations.leader_role') : 
record.role}
+                    aria-label={leader
+                        ? t('operations.leader_role') : nodeRoleLabel(record, 
t)}
                 >
-                    {record.role}
+                    {record.role ?? nodeRoleLabel(record, t)}
                 </Tag>
             )}
             <Tooltip title={record.id}>
@@ -96,6 +106,7 @@ const Nodes = () => {
     const {t, i18n} = useTranslation();
     const pdMode = isPdEnabled();
     const navigate = useNavigate();
+    const location = useLocation();
     const [searchParams, setSearchParams] = useSearchParams();
     const [data, setData] = useState({items: [], total: 0, observed_at: null, 
stale: false});
     const [loading, setLoading] = useState(true);
@@ -172,6 +183,18 @@ const Nodes = () => {
     const changeStatus = useCallback(value => update({status: value}), 
[update]);
     const search = useCallback(() => update({query: searchValue}), 
[searchValue, update]);
     const clearSearch = useCallback(() => update({query: undefined}), 
[update]);
+    const hasUserFilters = Boolean(
+        (pdMode && params.type) || params.status || params.query
+    );
+    const clearFilters = useCallback(() => {
+        setSearchValue('');
+        update({
+            type: undefined,
+            status: undefined,
+            query: undefined,
+            page: '1',
+        });
+    }, [update]);
     const changeSearch = useCallback(event => {
         const value = event.currentTarget.value;
         setSearchValue(value);
@@ -181,14 +204,20 @@ const Nodes = () => {
     }, [clearSearch]);
     const row = useCallback(record => ({
         tabIndex: 0,
-        onClick: () => navigate(`/operations/nodes/${record.id}`),
+        onClick: () => navigate(
+            `/operations/nodes/${record.id}`,
+            {state: operationsReturnState(location)}
+        ),
         onKeyDown: event => {
             if (event.key === 'Enter' || event.key === ' ') {
                 event.preventDefault();
-                navigate(`/operations/nodes/${record.id}`);
+                navigate(
+                    `/operations/nodes/${record.id}`,
+                    {state: operationsReturnState(location)}
+                );
             }
         },
-    }), [navigate]);
+    }), [location, navigate]);
     const changeTable = useCallback((pagination, filters, sorter) => update({
         page: String(pagination.current),
         page_size: String(pagination.pageSize),
@@ -209,7 +238,12 @@ const Nodes = () => {
         {title: t('operations.node'), dataIndex: 'name', key: 'name', sorter: 
true,
             width: 330,
             sortOrder: sortOrder('name'), render: (_, record) => (
-                <NodeIdentityCell record={record} unavailable={unavailable} 
t={t} />
+                <NodeIdentityCell
+                    record={record}
+                    returnState={operationsReturnState(location)}
+                    unavailable={unavailable}
+                    t={t}
+                />
             )},
         {title: t('operations.type'), dataIndex: 'type', key: 'type', width: 
86,
             sorter: true, sortOrder: sortOrder('type'), render: 
displayNodeType},
@@ -264,7 +298,10 @@ const Nodes = () => {
                             aria-label={t('operations.node_status_filter')}
                             onChange={changeStatus}
                             options={['UP', 'DEGRADED', 'DOWN', 'UNKNOWN']
-                                .map(value => ({value}))}
+                                .map(value => ({
+                                    value,
+                                    label: displayHealthStatus(value, t),
+                                }))}
                         />
                         <Input
                             allowClear
@@ -275,6 +312,11 @@ const Nodes = () => {
                             onPressEnter={search}
                             onChange={changeSearch}
                         />
+                        {hasUserFilters && (
+                            <Button onClick={clearFilters}>
+                                {t('operations.clear_filters')}
+                            </Button>
+                        )}
                     </Space>
                     <strong className='operations-result-count'>
                         {t('operations.result_count', {count: 
Number(data.total)})}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.test.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.test.js
index c8da10847..86f44e105 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Nodes.test.js
@@ -22,6 +22,8 @@ import {MemoryRouter, useLocation, useNavigate} from 
'react-router-dom';
 import Nodes from './Nodes';
 import {getNodes} from '../../api/operations';
 import '../../i18n';
+import enPages from '../../i18n/resources/en-US/modules/pages.json';
+import zhPages from '../../i18n/resources/zh-CN/modules/pages.json';
 
 jest.mock('../../api/operations');
 
@@ -70,6 +72,8 @@ test('limits standalone node details to Server nodes and 
filters', async () => {
     expect(screen.getByRole('link', {name: /Server A/})).toBeInTheDocument();
     expect(screen.queryByRole('link', {name: /PD A/})).not.toBeInTheDocument();
     expect(screen.queryByRole('link', {name: /Store 
A/})).not.toBeInTheDocument();
+    await waitFor(() => expect(screen.getByRole('button', {name: /Refresh/}))
+        .not.toHaveClass('ant-btn-loading'));
 });
 
 afterEach(() => {
@@ -91,6 +95,106 @@ const HistoryControls = () => {
     );
 };
 
+const FilterLocationProbe = () => {
+    const location = useLocation();
+    return <output aria-label='filter location'>{location.search}</output>;
+};
+
+test('clears all user filters while preserving paging size and sort 
preferences', async () => {
+    getNodes.mockResolvedValue({items: [], total: 0, observed_at: 1000, stale: 
false});
+
+    render(
+        <MemoryRouter
+            initialEntries={[
+                '/operations/nodes?type=PD&status=DOWN&query=node&page=4'
+                + '&page_size=50&sort=status&order=desc',
+            ]}
+            future={{v7_startTransition: true, v7_relativeSplatPath: true}}
+        >
+            <FilterLocationProbe />
+            <Nodes />
+        </MemoryRouter>
+    );
+
+    const clearFilters = await screen.findByRole('button', {name: 'Clear 
filters'});
+    expect(screen.getByRole('textbox', {name: /search 
node/i})).toHaveValue('node');
+    fireEvent.click(clearFilters);
+
+    await waitFor(() => {
+        const params = new URLSearchParams(
+            screen.getByLabelText('filter location').textContent
+        );
+        expect(params.has('type')).toBe(false);
+        expect(params.has('status')).toBe(false);
+        expect(params.has('query')).toBe(false);
+        expect(params.get('page')).toBe('1');
+        expect(params.get('page_size')).toBe('50');
+        expect(params.get('sort')).toBe('status');
+        expect(params.get('order')).toBe('desc');
+    });
+    expect(screen.getByRole('textbox', {name: /search 
node/i})).toHaveValue('');
+    await waitFor(() => expect(getNodes).toHaveBeenLastCalledWith({
+        type: undefined,
+        status: undefined,
+        query: undefined,
+        page: 1,
+        page_size: 50,
+        sort: 'status',
+        order: 'desc',
+    }));
+    await waitFor(() => expect(screen.getByRole('button', {name: /Refresh/}))
+        .not.toHaveClass('ant-btn-loading'));
+    expect(screen.queryByRole('button', {name: 'Clear filters'}))
+        .not.toBeInTheDocument();
+});
+
+test('hides clear filters when no user-controlled filter is active', async () 
=> {
+    getNodes.mockResolvedValue({items: [], total: 0, observed_at: 1000, stale: 
false});
+
+    render(
+        <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: 
true}}>
+            <Nodes />
+        </MemoryRouter>
+    );
+
+    expect(await screen.findByText('0 nodes')).toBeInTheDocument();
+    await waitFor(() => expect(screen.getByRole('button', {name: /Refresh/}))
+        .not.toHaveClass('ant-btn-loading'));
+    expect(screen.queryByRole('button', {name: 'Clear filters'}))
+        .not.toBeInTheDocument();
+});
+
+test('does not treat the standalone fixed SERVER type as a clearable filter', 
async () => {
+    sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: 
false}));
+    getNodes.mockResolvedValue({
+        items: [{id: 'server-1', name: 'Server A', type: 'SERVER', status: 
'UP'}],
+        total: 1,
+        observed_at: 1000,
+        stale: false,
+    });
+
+    render(
+        <MemoryRouter
+            initialEntries={['/operations/nodes?type=STORE']}
+            future={{v7_startTransition: true, v7_relativeSplatPath: true}}
+        >
+            <Nodes />
+        </MemoryRouter>
+    );
+
+    expect(await screen.findByRole('link', {name: /Server 
A/})).toBeInTheDocument();
+    await waitFor(() => expect(screen.getByRole('button', {name: /Refresh/}))
+        .not.toHaveClass('ant-btn-loading'));
+    expect(screen.queryByRole('button', {name: 'Clear filters'}))
+        .not.toBeInTheDocument();
+    expect(getNodes).toHaveBeenCalledWith(expect.objectContaining({type: 
'SERVER'}));
+});
+
+test('ships the clear-filters action in Chinese and English', () => {
+    expect(zhPages.operations.clear_filters).toBe('清空筛选');
+    expect(enPages.operations.clear_filters).toBe('Clear filters');
+});
+
 test('shows stale observation metadata and exposes real detail links', async 
() => {
     getNodes.mockResolvedValue({
         items: [
@@ -113,6 +217,9 @@ test('shows stale observation metadata and exposes real 
detail links', async ()
     expect(screen.getByText(/Stale/i)).toBeInTheDocument();
     expect(screen.getAllByText(/Observed/i).length).toBeGreaterThan(0);
     expect(screen.getByRole('combobox', {name: /node 
type/i})).toBeInTheDocument();
+    fireEvent.mouseDown(screen.getByRole('combobox', {name: /node status/i}));
+    expect(await screen.findByRole('option', {name: 
'Attention'})).toBeInTheDocument();
+    expect(screen.queryByRole('option', {name: 
'DEGRADED'})).not.toBeInTheDocument();
     expect(screen.getByRole('textbox', {name: /search 
node/i})).toBeInTheDocument();
     expect(screen.getByText('Browse, filter and inspect every discovered 
service node'))
         .toBeInTheDocument();
@@ -276,3 +383,29 @@ test('merges role into node identity and keeps the full ID 
explainable and copya
     fireEvent.click(within(identity).getByRole('button', {name: 'Copy full 
node ID'}));
     expect(writeText).toHaveBeenCalledWith('store-c410c1adb107-full-id');
 });
+
+test('renders a Store leader-shard count without an explicit node role', async 
() => {
+    getNodes.mockResolvedValue({
+        items: [{
+            id: 'store-shards',
+            name: 'store-shards',
+            type: 'STORE',
+            role: null,
+            status: 'UP',
+            metrics: {backend: {leaders: 3}},
+        }],
+        total: 1,
+        observed_at: 1000,
+        stale: false,
+    });
+
+    render(
+        <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: 
true}}>
+            <Nodes />
+        </MemoryRouter>
+    );
+
+    const identity = (await screen.findByText('store-shards'))
+        .closest('.operations-node-identity-cell');
+    expect(within(identity).getByText('3 leader shards')).toBeInTheDocument();
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js 
b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js
index 2ce575fa7..2fbfc9e56 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/Overview.js
@@ -28,7 +28,7 @@ import {
 } from '@ant-design/icons';
 import {useCallback, useEffect, useRef, useState} from 'react';
 import {useTranslation} from 'react-i18next';
-import {Link} from 'react-router-dom';
+import {Link, useLocation, useSearchParams} from 'react-router-dom';
 import {getDashboard} from '../../api/auth';
 import {getOverview} from '../../api/operations';
 import {normalizeDashboardUrl} from 
'../../modules/navigation/ConsoleItem/dashboard';
@@ -36,6 +36,7 @@ import {
     ClusterTopology,
     displayNodeType,
     HealthStatus,
+    nodeRoleLabel,
     RefreshButton,
     SourceStrip,
 } from './components';
@@ -46,18 +47,21 @@ import {
     hasStaleMetrics,
     selectAttentionNodes,
 } from './topology';
-import './operations.scss';
 import {TopbarPageContextSlot} from '../../components/Topbar/PageContextSlot';
+import {operationsReturnState} from './navigation';
+import './operations.scss';
 
 const Overview = () => {
     const {t, i18n} = useTranslation();
+    const location = useLocation();
+    const [searchParams, setSearchParams] = useSearchParams();
     const [data, setData] = useState(null);
     const [loading, setLoading] = useState(true);
     const [refreshing, setRefreshing] = useState(false);
     const [error, setError] = useState(null);
-    const [view, setView] = useState('topology');
     const [dashboard, setDashboard] = useState({status: 'checking', url: ''});
     const requestSequence = useRef(0);
+    const view = searchParams.get('view') === 'nodes' ? 'nodes' : 'topology';
 
     const load = useCallback(async refresh => {
         const request = ++requestSequence.current;
@@ -129,7 +133,12 @@ const Overview = () => {
     }, []);
 
     const refresh = useCallback(() => load(true), [load]);
-    const changeView = useCallback(event => setView(event.target.value), []);
+    const changeView = useCallback(event => {
+        const next = new URLSearchParams(searchParams);
+        event.target.value === 'nodes'
+            ? next.set('view', 'nodes') : next.delete('view');
+        setSearchParams(next, {replace: true});
+    }, [searchParams, setSearchParams]);
     const openDashboard = useCallback(() => {
         const popup = window.open(
             `${dashboard.url}/monitor/machine`,
@@ -199,11 +208,17 @@ const Overview = () => {
             title: t('operations.node'),
             dataIndex: 'name',
             render: (name, node) => (
-                <Link to={`/operations/nodes/${node.id}`}>{name ?? 
unavailable}</Link>
+                <Link
+                    to={`/operations/nodes/${node.id}`}
+                    state={operationsReturnState(location)}
+                >
+                    {name ?? unavailable}
+                </Link>
             ),
         },
         {title: t('operations.tier_header'), dataIndex: 'type', render: 
displayNodeType},
-        {title: t('operations.role'), dataIndex: 'role', render: value => 
value ?? '—'},
+        {title: t('operations.role'), dataIndex: 'role',
+            render: (value, node) => nodeRoleLabel(node, t)},
         {
             title: t('operations.status'),
             dataIndex: 'status',
@@ -219,7 +234,8 @@ const Overview = () => {
             render: name => <strong>{name ?? unavailable}</strong>,
         },
         {title: t('operations.tier_header'), dataIndex: 'type', render: 
displayNodeType},
-        {title: t('operations.role'), dataIndex: 'role', render: value => 
value ?? '—'},
+        {title: t('operations.role'), dataIndex: 'role',
+            render: (value, node) => nodeRoleLabel(node, t)},
         {
             title: t('operations.status'),
             dataIndex: 'status',
@@ -238,7 +254,10 @@ const Overview = () => {
             title: t('operations.action'),
             key: 'action',
             render: (_, node) => (
-                <Link to={`/operations/nodes/${node.id}`}>
+                <Link
+                    to={`/operations/nodes/${node.id}`}
+                    state={operationsReturnState(location)}
+                >
                     {t('operations.view_details')}
                 </Link>
             ),
@@ -370,7 +389,12 @@ const Overview = () => {
                                 {nodes.length === 0
                                     ? <Empty 
description={t('operations.empty_cluster')} />
                                     : view === 'topology'
-                                        ? <ClusterTopology nodes={nodes} />
+                                        ? (
+                                            <ClusterTopology
+                                                nodes={nodes}
+                                                
returnState={operationsReturnState(location)}
+                                            />
+                                        )
                                         : (
                                             <Table
                                                 components={{

Reply via email to