This is an automated email from the ASF dual-hosted git repository. lizhimins pushed a commit to branch rocketmq-studio in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
commit 7927a09e4cee2f13f2f678695b4463ab382d99f7 Author: zhaohai <[email protected]> AuthorDate: Wed Jul 22 19:22:36 2026 +0800 feat: add broker cluster overview page (#479) Add Broker Cluster overview page with tab-based NameServer/Broker/Proxy management and cluster stats. --- web/src/App.tsx | 2 + web/src/i18n/translations.ts | 25 + web/src/pages/studio/BrokerCluster.tsx | 551 +++++++++++++++++++++ .../pages/studio/__tests__/BrokerCluster.test.tsx | 112 +++++ 4 files changed, 690 insertions(+) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5ace3a4e..f4ca3341 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts'; import AuditPage from './pages/ops/audit'; import AiPage from './pages/ai'; import SettingsPage from './pages/settings'; +import BrokerClusterPage from './pages/studio/BrokerCluster'; import SslSettingsPage from './pages/studio/SslSettings'; import AlertManagementPage from './pages/studio/AlertManagement'; import ProducerPage from './pages/studio/Producer'; @@ -60,6 +61,7 @@ function App() { <Route path="ops/audit" element={<AuditPage />} /> <Route path="ai" element={<AiPage />} /> <Route path="settings" element={<SettingsPage />} /> + <Route path="studio/broker-cluster" element={<BrokerClusterPage />} /> <Route path="studio/ssl-settings" element={<SslSettingsPage />} /> <Route path="studio/alert-management" element={<AlertManagementPage />} /> <Route path="studio/producer" element={<ProducerPage />} /> diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 7928b720..d14c1fd1 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -875,6 +875,31 @@ const translations: Record<string, Record<Lang, string>> = { 'common.warning': { zh: '警告', en: 'Warning' }, 'common.na': { zh: 'N/A', en: 'N/A' }, 'common.default': { zh: '默认', en: 'Default' }, + + // ─── BrokerCluster ─── + 'brokerCluster.title': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' }, + 'brokerCluster.k8sCluster': { zh: 'K8s 集群名称', en: 'K8s Cluster' }, + 'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' }, + 'brokerCluster.status': { zh: '运行状态', en: 'Status' }, + 'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' }, + 'brokerCluster.statusReadonly': { zh: '只读', en: 'Readonly' }, + 'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' }, + 'brokerCluster.version': { zh: '版本', en: 'Version' }, + 'brokerCluster.diskUsage': { zh: '磁盘使用率', en: 'Disk Usage' }, + 'brokerCluster.tpsIn': { zh: 'TPS 入流量', en: 'TPS In' }, + 'brokerCluster.tpsOut': { zh: 'TPS 出流量', en: 'TPS Out' }, + 'brokerCluster.config': { zh: '配置', en: 'Config' }, + 'brokerCluster.restart': { zh: '重启', en: 'Restart' }, + 'brokerCluster.manual': { zh: '手动', en: 'Manual' }, + 'brokerCluster.createCluster': { zh: '新建集群', en: 'Create Cluster' }, + 'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' }, + 'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer Management' }, + 'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management' }, + 'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' }, + 'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' }, + 'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' }, + 'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' }, + 'brokerCluster.connections': { zh: '连接数', en: 'Connections' }, }; export default translations; diff --git a/web/src/pages/studio/BrokerCluster.tsx b/web/src/pages/studio/BrokerCluster.tsx new file mode 100644 index 00000000..ab21b02c --- /dev/null +++ b/web/src/pages/studio/BrokerCluster.tsx @@ -0,0 +1,551 @@ +/* + * 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 { useState } from 'react'; +import { Table, Button, Tag, Tabs, Card, Space, Switch, Progress, Tooltip } from 'antd'; +import { + Plus, + ArrowClockwise, + GearSix, + ArrowsClockwise, + Cloud, + ChartBar, + PlugsConnected, +} from '@phosphor-icons/react'; +import { useLang } from '../../i18n/LangContext'; + +// ─── Types ────────────────────────────────────────────────────── +interface BrokerRecord { + key: string; + k8sCluster: string; + brokerName: string; + status: 'running' | 'readonly' | 'maintenance'; + version: string; + diskUsage: number; + address: string; + tpsIn: string; + tpsOut: string; +} + +interface NameServerRecord { + key: string; + k8sCluster: string; + name: string; + status: 'running' | 'readonly' | 'maintenance'; + version: string; + address: string; + connections: number; +} + +interface ProxyRecord { + key: string; + k8sCluster: string; + name: string; + status: 'running' | 'readonly' | 'maintenance'; + version: string; + address: string; + grpcPort: string; + connections: number; +} + +// ─── Mock Data ────────────────────────────────────────────────── +const brokerData: BrokerRecord[] = [ + { + key: '1', + k8sCluster: 'prod-cn-east-1', + brokerName: 'broker-a', + status: 'running', + version: '5.3.0', + diskUsage: 62, + address: '10.0.1.10:10911', + tpsIn: '12,580', + tpsOut: '8,340', + }, + { + key: '2', + k8sCluster: 'prod-cn-east-1', + brokerName: 'broker-b', + status: 'readonly', + version: '5.3.0', + diskUsage: 89, + address: '10.0.1.11:10911', + tpsIn: '0', + tpsOut: '3,120', + }, + { + key: '3', + k8sCluster: 'prod-cn-east-1', + brokerName: 'broker-c', + status: 'running', + version: '5.2.0', + diskUsage: 45, + address: '10.0.1.12:10911', + tpsIn: '9,750', + tpsOut: '6,280', + }, + { + key: '4', + k8sCluster: 'prod-cn-south-1', + brokerName: 'broker-d', + status: 'maintenance', + version: '5.3.0', + diskUsage: 33, + address: '10.0.2.10:10911', + tpsIn: '0', + tpsOut: '0', + }, + { + key: '5', + k8sCluster: 'prod-cn-south-1', + brokerName: 'broker-e', + status: 'running', + version: '5.3.0', + diskUsage: 51, + address: '10.0.2.11:10911', + tpsIn: '7,890', + tpsOut: '5,430', + }, + { + key: '6', + k8sCluster: 'staging-cn-east-1', + brokerName: 'broker-staging-a', + status: 'running', + version: '5.3.1', + diskUsage: 28, + address: '10.0.10.10:10911', + tpsIn: '1,230', + tpsOut: '980', + }, +]; + +const nameServerData: NameServerRecord[] = [ + { + key: '1', + k8sCluster: 'prod-cn-east-1', + name: 'nameserver-a', + status: 'running', + version: '5.3.0', + address: '10.0.1.20:9876', + connections: 156, + }, + { + key: '2', + k8sCluster: 'prod-cn-east-1', + name: 'nameserver-b', + status: 'running', + version: '5.3.0', + address: '10.0.1.21:9876', + connections: 148, + }, + { + key: '3', + k8sCluster: 'prod-cn-south-1', + name: 'nameserver-c', + status: 'running', + version: '5.3.0', + address: '10.0.2.20:9876', + connections: 92, + }, +]; + +const proxyData: ProxyRecord[] = [ + { + key: '1', + k8sCluster: 'prod-cn-east-1', + name: 'proxy-a', + status: 'running', + version: '5.3.0', + address: '10.0.1.30:8080', + grpcPort: '10.0.1.30:8081', + connections: 2340, + }, + { + key: '2', + k8sCluster: 'prod-cn-south-1', + name: 'proxy-b', + status: 'running', + version: '5.3.0', + address: '10.0.2.30:8080', + grpcPort: '10.0.2.30:8081', + connections: 1560, + }, +]; + +// ─── Component ────────────────────────────────────────────────── +const BrokerClusterPage = () => { + const [autoRefresh, setAutoRefresh] = useState(false); + const [activeTab, setActiveTab] = useState('broker'); + const { t } = useLang(); + + const renderStatus = (status: string) => { + const config: Record<string, { color: string; label: string }> = { + running: { color: 'success', label: t('brokerCluster.statusRunning') }, + readonly: { color: 'warning', label: t('brokerCluster.statusReadonly') }, + maintenance: { + color: 'error', + label: t('brokerCluster.statusMaintenance'), + }, + }; + const { color, label } = config[status] || config.running; + return <Tag color={color}>{label}</Tag>; + }; + + const renderDiskUsage = (percent: number) => { + let status: 'normal' | 'active' | 'exception' = 'normal'; + let color = '#52c41a'; + if (percent > 85) { + status = 'exception'; + color = '#ff4d4f'; + } else if (percent > 70) { + status = 'active'; + color = '#fa8c16'; + } + return ( + <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> + <Progress + percent={percent} + size="small" + status={status} + style={{ width: 80, margin: 0 }} + strokeColor={color} + /> + <span style={{ fontSize: 12, color, fontWeight: 500 }}>{percent}%</span> + </div> + ); + }; + + const brokerColumns = [ + { + title: t('brokerCluster.k8sCluster'), + dataIndex: 'k8sCluster', + key: 'k8sCluster', + render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.brokerName'), + dataIndex: 'brokerName', + key: 'brokerName', + render: (text: string) => <span style={{ color: '#1677ff', fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.status'), + dataIndex: 'status', + key: 'status', + render: renderStatus, + }, + { title: t('brokerCluster.version'), dataIndex: 'version', key: 'version' }, + { + title: t('brokerCluster.diskUsage'), + dataIndex: 'diskUsage', + key: 'diskUsage', + render: renderDiskUsage, + width: 160, + }, + { + title: t('common.address'), + dataIndex: 'address', + key: 'address', + render: (text: string) => ( + <code + style={{ + fontSize: 12, + background: '#f5f5f5', + padding: '2px 6px', + borderRadius: 4, + }} + > + {text} + </code> + ), + }, + { + title: t('brokerCluster.tpsIn'), + dataIndex: 'tpsIn', + key: 'tpsIn', + render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>, + sorter: (a: BrokerRecord, b: BrokerRecord) => + parseFloat(a.tpsIn.replace(/,/g, '')) - parseFloat(b.tpsIn.replace(/,/g, '')), + }, + { + title: t('brokerCluster.tpsOut'), + dataIndex: 'tpsOut', + key: 'tpsOut', + render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>, + sorter: (a: BrokerRecord, b: BrokerRecord) => + parseFloat(a.tpsOut.replace(/,/g, '')) - parseFloat(b.tpsOut.replace(/,/g, '')), + }, + { + title: t('common.actions'), + key: 'action', + render: () => ( + <Space size="small"> + <Tooltip title={t('brokerCluster.config')}> + <Button type="link" size="small" icon={<GearSix size={14} />}> + {t('brokerCluster.config')} + </Button> + </Tooltip> + <Tooltip title={t('brokerCluster.restart')}> + <Button type="link" size="small" icon={<ArrowsClockwise size={14} />}> + {t('brokerCluster.restart')} + </Button> + </Tooltip> + </Space> + ), + }, + ]; + + const nsColumns = [ + { + title: t('brokerCluster.k8sCluster'), + dataIndex: 'k8sCluster', + key: 'k8sCluster', + render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.nsName'), + dataIndex: 'name', + key: 'name', + render: (text: string) => <span style={{ color: '#1677ff', fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.status'), + dataIndex: 'status', + key: 'status', + render: renderStatus, + }, + { title: t('brokerCluster.version'), dataIndex: 'version', key: 'version' }, + { + title: t('common.address'), + dataIndex: 'address', + key: 'address', + render: (text: string) => ( + <code + style={{ + fontSize: 12, + background: '#f5f5f5', + padding: '2px 6px', + borderRadius: 4, + }} + > + {text} + </code> + ), + }, + { + title: t('brokerCluster.connections'), + dataIndex: 'connections', + key: 'connections', + render: (text: number) => <span style={{ fontWeight: 500 }}>{text}</span>, + }, + { + title: t('common.actions'), + key: 'action', + render: () => ( + <Space size="small"> + <Button type="link" size="small" icon={<GearSix size={14} />}> + {t('brokerCluster.config')} + </Button> + <Button type="link" size="small" icon={<ArrowsClockwise size={14} />}> + {t('brokerCluster.restart')} + </Button> + </Space> + ), + }, + ]; + + const proxyColumns = [ + { + title: t('brokerCluster.k8sCluster'), + dataIndex: 'k8sCluster', + key: 'k8sCluster', + render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.proxyName'), + dataIndex: 'name', + key: 'name', + render: (text: string) => <span style={{ color: '#1677ff', fontWeight: 500 }}>{text}</span>, + }, + { + title: t('brokerCluster.status'), + dataIndex: 'status', + key: 'status', + render: renderStatus, + }, + { title: t('brokerCluster.version'), dataIndex: 'version', key: 'version' }, + { + title: t('brokerCluster.httpAddr'), + dataIndex: 'address', + key: 'address', + render: (text: string) => ( + <code + style={{ + fontSize: 12, + background: '#f5f5f5', + padding: '2px 6px', + borderRadius: 4, + }} + > + {text} + </code> + ), + }, + { + title: t('brokerCluster.grpcAddr'), + dataIndex: 'grpcPort', + key: 'grpcPort', + render: (text: string) => ( + <code + style={{ + fontSize: 12, + background: '#f5f5f5', + padding: '2px 6px', + borderRadius: 4, + }} + > + {text} + </code> + ), + }, + { + title: t('brokerCluster.connections'), + dataIndex: 'connections', + key: 'connections', + render: (text: number) => <span style={{ fontWeight: 500 }}>{text}</span>, + }, + { + title: t('common.actions'), + key: 'action', + render: () => ( + <Space size="small"> + <Button type="link" size="small" icon={<GearSix size={14} />}> + {t('brokerCluster.config')} + </Button> + <Button type="link" size="small" icon={<ArrowsClockwise size={14} />}> + {t('brokerCluster.restart')} + </Button> + </Space> + ), + }, + ]; + + return ( + <div style={{ padding: 0 }}> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }} + > + <h2 + style={{ + fontSize: 20, + fontWeight: 600, + margin: 0, + display: 'flex', + alignItems: 'center', + }} + > + <Cloud size={22} style={{ marginRight: 8, color: '#1677ff' }} /> + {t('brokerCluster.title')} + </h2> + <Space size="middle"> + <Switch + checked={autoRefresh} + onChange={setAutoRefresh} + checkedChildren={t('common.liveRefresh')} + unCheckedChildren={t('brokerCluster.manual')} + size="small" + /> + <Button icon={<ArrowClockwise size={14} />} size="small"> + {t('common.reset')} + </Button> + <Button type="primary" icon={<Plus size={14} />}> + {t('brokerCluster.createCluster')} + </Button> + </Space> + </div> + + <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }}> + <Tabs + activeKey={activeTab} + onChange={setActiveTab} + items={[ + { + key: 'nameserver', + label: ( + <span> + <ChartBar size={16} style={{ marginRight: 4, verticalAlign: 'middle' }} /> + {t('brokerCluster.nsManagement')} + </span> + ), + children: ( + <Table + columns={nsColumns} + dataSource={nameServerData} + pagination={false} + size="middle" + /> + ), + }, + { + key: 'broker', + label: ( + <span> + <Cloud size={16} style={{ marginRight: 4, verticalAlign: 'middle' }} /> + {t('brokerCluster.brokerManagement')} + </span> + ), + children: ( + <Table + columns={brokerColumns} + dataSource={brokerData} + pagination={{ + pageSize: 10, + showTotal: (total) => `${t('common.total')} ${total} Broker`, + }} + size="middle" + /> + ), + }, + { + key: 'proxy', + label: ( + <span> + <PlugsConnected size={16} style={{ marginRight: 4, verticalAlign: 'middle' }} /> + {t('brokerCluster.proxyManagement')} + </span> + ), + children: ( + <Table + columns={proxyColumns} + dataSource={proxyData} + pagination={false} + size="middle" + /> + ), + }, + ]} + /> + </Card> + </div> + ); +}; + +export default BrokerClusterPage; diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx new file mode 100644 index 00000000..618014cc --- /dev/null +++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { App } from 'antd'; +import { LangProvider } from '../../../i18n/LangContext'; +import BrokerCluster from '../BrokerCluster'; + +// Mock matchMedia for antd responsive components +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +// Mock react-router-dom +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({}), +})); + +const renderWithProviders = (ui: React.ReactElement) => { + return render( + <App> + <LangProvider>{ui}</LangProvider> + </App>, + ); +}; + +describe('BrokerCluster Page', () => { + it('should render the page title', () => { + renderWithProviders(<BrokerCluster />); + expect(screen.getByText('RocketMQ 集群')).toBeInTheDocument(); + }); + + it('should render create cluster button', () => { + renderWithProviders(<BrokerCluster />); + expect(screen.getByText('新建集群')).toBeInTheDocument(); + }); + + it('should render reset button', () => { + renderWithProviders(<BrokerCluster />); + expect(screen.getByText('重置')).toBeInTheDocument(); + }); + + it('should display broker tab with mock data', () => { + renderWithProviders(<BrokerCluster />); + // Default tab is broker - check for mock broker data + expect(screen.getByText('broker-a')).toBeInTheDocument(); + expect(screen.getByText('broker-b')).toBeInTheDocument(); + }); + + it('should display broker status tags', () => { + renderWithProviders(<BrokerCluster />); + const runningTags = screen.getAllByText('运行中'); + expect(runningTags.length).toBeGreaterThan(0); + const readonlyTags = screen.getAllByText('只读'); + expect(readonlyTags.length).toBeGreaterThan(0); + }); + + it('should switch to NameServer tab on click', async () => { + const user = userEvent.setup(); + renderWithProviders(<BrokerCluster />); + const nsTab = screen.getByText('NameServer 管理'); + await user.click(nsTab); + // After clicking, NameServer data should be visible + expect(screen.getByText('nameserver-a')).toBeInTheDocument(); + }); + + it('should switch to Proxy tab on click', async () => { + const user = userEvent.setup(); + renderWithProviders(<BrokerCluster />); + const proxyTab = screen.getByText('Proxy 管理'); + await user.click(proxyTab); + // After clicking, Proxy data should be visible + expect(screen.getByText('proxy-a')).toBeInTheDocument(); + }); + + it('should render config and restart action buttons', () => { + renderWithProviders(<BrokerCluster />); + const configButtons = screen.getAllByText('配置'); + expect(configButtons.length).toBeGreaterThan(0); + const restartButtons = screen.getAllByText('重启'); + expect(restartButtons.length).toBeGreaterThan(0); + }); +});
