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={{
