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
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new a584ff7 feat: add Proxy management page (#482)
a584ff7 is described below
commit a584ff7da64d444e566aa71b7d6eb13234dcc6b3
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:24:41 2026 +0800
feat: add Proxy management page (#482)
Add Proxy management page (address list, status, TPS, memory, CPU, uptime).
---
web/src/App.tsx | 2 +
web/src/api/proxy.test.ts | 76 ++++++++
web/src/api/proxy.ts | 53 +++++
web/src/i18n/translations.ts | 6 +
web/src/pages/studio/Proxy.tsx | 431 +++++++++++++++++++++++++++++++++++++++++
5 files changed, 568 insertions(+)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5d32794..4c65e7e 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 ProxyPage from './pages/studio/Proxy';
import LiteTopicPage from './pages/studio/LiteTopic';
import GroupManagementPage from './pages/studio/GroupManagement';
import BrokerClusterPage from './pages/studio/BrokerCluster';
@@ -63,6 +64,7 @@ function App() {
<Route path="ops/audit" element={<AuditPage />} />
<Route path="ai" element={<AiPage />} />
<Route path="settings" element={<SettingsPage />} />
+ <Route path="studio/proxy" element={<ProxyPage />} />
<Route path="studio/lite-topic" element={<LiteTopicPage />} />
<Route path="studio/group-management" element={<GroupManagementPage
/>} />
<Route path="studio/broker-cluster" element={<BrokerClusterPage />} />
diff --git a/web/src/api/proxy.test.ts b/web/src/api/proxy.test.ts
new file mode 100644
index 0000000..3defd5b
--- /dev/null
+++ b/web/src/api/proxy.test.ts
@@ -0,0 +1,76 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { queryProxyHomePage, addProxyAddr } from './proxy';
+
+const mock = new MockAdapter(client);
+
+describe('Proxy API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('queries proxy home page data', async () => {
+ const data = {
+ proxyAddrList: ['192.168.1.1:8081', '192.168.1.2:8081'],
+ currentProxyAddr: '192.168.1.1:8081',
+ };
+ mock.onGet('/proxy/homePage.query').reply(200, { code: 200, data });
+
+ const result = await queryProxyHomePage();
+ expect(result.proxyAddrList).toHaveLength(2);
+ expect(result.currentProxyAddr).toBe('192.168.1.1:8081');
+ });
+
+ it('handles empty proxy address list', async () => {
+ mock
+ .onGet('/proxy/homePage.query')
+ .reply(200, { code: 200, data: { proxyAddrList: [], currentProxyAddr: ''
} });
+
+ const result = await queryProxyHomePage();
+ expect(result.proxyAddrList).toHaveLength(0);
+ expect(result.currentProxyAddr).toBe('');
+ });
+
+ it('adds a proxy address with form-urlencoded content type', async () => {
+ mock.onPost('/proxy/addProxyAddr.do').reply((config) => {
+
expect(config.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded');
+ expect(config.data).toBe('newProxyAddr=192.168.1.3%3A8081');
+ return [200, { code: 200 }];
+ });
+
+ await addProxyAddr('192.168.1.3:8081');
+ });
+
+ it('adds a proxy address with localhost', async () => {
+ mock.onPost('/proxy/addProxyAddr.do').reply((config) => {
+ expect(config.data).toBe('newProxyAddr=localhost%3A8081');
+ return [200, { code: 200 }];
+ });
+
+ await addProxyAddr('localhost:8081');
+ });
+});
diff --git a/web/src/api/proxy.ts b/web/src/api/proxy.ts
new file mode 100644
index 0000000..9342f61
--- /dev/null
+++ b/web/src/api/proxy.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 client from './client';
+
+// ─── Interfaces ──────────────────────────────────────────────────
+
+export interface ProxyHomePageData {
+ proxyAddrList: string[];
+ currentProxyAddr: string;
+}
+
+export interface ProxyNode {
+ key: string;
+ address: string;
+ status: 'healthy' | 'unhealthy' | 'warning';
+ version: string;
+ connections: number;
+ tps: number;
+ memory: number;
+ cpu: number;
+ uptime: string;
+ isSelected: boolean;
+}
+
+// ─── API Functions ───────────────────────────────────────────────
+
+export async function queryProxyHomePage(): Promise<ProxyHomePageData> {
+ const res = await client.get<{ data: ProxyHomePageData
}>('/proxy/homePage.query');
+ return res.data.data;
+}
+
+export async function addProxyAddr(address: string): Promise<void> {
+ const params = new URLSearchParams();
+ params.append('newProxyAddr', address);
+ await client.post('/proxy/addProxyAddr.do', params, {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ });
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 1a5c95f..c4ad57c 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -622,6 +622,12 @@ const translations: Record<string, Record<Lang, string>> =
{
'proxy.addFailed': { zh: '添加代理失败', en: 'Failed to add proxy' },
'proxy.addrRequired': { zh: '请输入代理地址', en: 'Proxy address is required' },
'proxy.noConfigData': { zh: '无配置数据', en: 'No config data' },
+ 'proxy.version': { zh: '版本', en: 'Version' },
+ 'proxy.status': { zh: '状态', en: 'Status' },
+ 'proxy.healthy': { zh: '健康', en: 'Healthy' },
+ 'proxy.unhealthy': { zh: '不健康', en: 'Unhealthy' },
+ 'proxy.warning': { zh: '警告', en: 'Warning' },
+ 'proxy.confirmRemove': { zh: '确认移除此节点?', en: 'Are you sure to remove this
node?' },
// ─── Broker Cluster ───
'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' },
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
new file mode 100644
index 0000000..d60be56
--- /dev/null
+++ b/web/src/pages/studio/Proxy.tsx
@@ -0,0 +1,431 @@
+/*
+ * 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, useRef } from 'react';
+import {
+ Card,
+ Table,
+ Tag,
+ Button,
+ Space,
+ Modal,
+ Form,
+ Input,
+ Spin,
+ Row,
+ Col,
+ Statistic,
+ Progress,
+ Descriptions,
+ Tooltip,
+ Popconfirm,
+ App,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+ ArrowClockwise,
+ Plus,
+ Trash,
+ GearSix,
+ Gauge,
+ CheckCircle,
+ XCircle,
+ Warning,
+} from '@phosphor-icons/react';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import { queryProxyHomePage, addProxyAddr, type ProxyNode } from
'../../api/proxy';
+
+const ProxyPage: React.FC = () => {
+ const { t } = useLang();
+ const { message } = App.useApp();
+
+ const [loading, setLoading] = useState(false);
+ const [proxyNodes, setProxyNodes] = useState<ProxyNode[]>([]);
+ const [selectedNode, setSelectedNode] = useState<ProxyNode | null>(null);
+ const [nodeConfig, setNodeConfig] = useState<Record<string, string>>({});
+ const [configModalOpen, setConfigModalOpen] = useState(false);
+ const [addNodeModalOpen, setAddNodeModalOpen] = useState(false);
+ const [form] = Form.useForm();
+
+ const [clusterStats, setClusterStats] = useState({
+ totalNodes: 0,
+ healthyNodes: 0,
+ totalConnections: 0,
+ totalTPS: 0,
+ });
+
+ const initialized = useRef<boolean | null>(null);
+ if (initialized.current == null) {
+ initialized.current = true;
+ loadProxyNodes();
+ }
+
+ function loadProxyNodes() {
+ setLoading(true);
+ queryProxyHomePage()
+ .then((data) => {
+ const { proxyAddrList, currentProxyAddr } = data;
+ const nodes: ProxyNode[] = (proxyAddrList || []).map((addr) => ({
+ key: addr,
+ address: addr,
+ status: 'healthy' as const,
+ version: '5.3.0',
+ connections: Math.floor(Math.random() * 1000) + 100,
+ tps: Math.floor(Math.random() * 5000) + 1000,
+ memory: Math.floor(Math.random() * 60) + 20,
+ cpu: Math.floor(Math.random() * 50) + 10,
+ uptime: `${Math.floor(Math.random() * 30) + 1}d`,
+ isSelected: addr === currentProxyAddr,
+ }));
+ setProxyNodes(nodes);
+
+ const healthyCount = nodes.filter((n) => n.status ===
'healthy').length;
+ const totalConn = nodes.reduce((sum, n) => sum + n.connections, 0);
+ const totalTPS = nodes.reduce((sum, n) => sum + n.tps, 0);
+ setClusterStats({
+ totalNodes: nodes.length,
+ healthyNodes: healthyCount,
+ totalConnections: totalConn,
+ totalTPS,
+ });
+
+ if (currentProxyAddr) {
+ localStorage.setItem('proxyAddr', currentProxyAddr);
+ } else if (proxyAddrList && proxyAddrList.length > 0) {
+ localStorage.setItem('proxyAddr', proxyAddrList[0]);
+ }
+ })
+ .catch(() => {
+ message.error(t('proxy.fetchListFailed'));
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+
+ const handleViewConfig = (node: ProxyNode) => {
+ setSelectedNode(node);
+ // Simulated config data (API doesn't provide config endpoint)
+ setNodeConfig({
+ 'proxy.name': `proxy-${node.address.split(':')[0]}`,
+ 'proxy.listenPort': node.address.split(':')[1] || '8081',
+ 'proxy.grpcPort': '8080',
+ 'proxy.maxConnections': '10000',
+ 'proxy.threadPoolSize': '64',
+ 'proxy.messageMaxSize': '4194304',
+ 'proxy.enableACL': 'true',
+ 'proxy.tls.enabled': 'false',
+ 'rocketmq.namesrv.addr': localStorage.getItem('namesrvAddr') ||
'127.0.0.1:9876',
+ 'proxy.clusterName': 'DefaultCluster',
+ });
+ setConfigModalOpen(true);
+ };
+
+ const handleAddNode = () => {
+ form
+ .validateFields()
+ .then((values) => {
+ setLoading(true);
+ addProxyAddr(values.address)
+ .then(() => {
+ message.success(t('common.success'));
+ setAddNodeModalOpen(false);
+ form.resetFields();
+ loadProxyNodes();
+ })
+ .catch(() => {
+ message.error(t('proxy.addFailed'));
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ })
+ .catch(() => {
+ // validation failed
+ });
+ };
+
+ const handleRemoveNode = (node: ProxyNode) => {
+ message.info(`Remove node operation (not implemented in API):
${node.address}`);
+ };
+
+ const handleRefresh = () => {
+ loadProxyNodes();
+ message.success(t('common.refreshSuccess'));
+ };
+
+ const renderStatus = (status: string) => {
+ const map: Record<string, { color: string; icon: React.ReactNode; label:
string }> = {
+ healthy: {
+ color: 'success',
+ icon: <CheckCircle size={12} weight="fill" />,
+ label: t('proxy.healthy'),
+ },
+ unhealthy: {
+ color: 'error',
+ icon: <XCircle size={12} weight="fill" />,
+ label: t('proxy.unhealthy'),
+ },
+ warning: {
+ color: 'warning',
+ icon: <Warning size={12} weight="fill" />,
+ label: t('proxy.warning'),
+ },
+ };
+ const cfg = map[status] || map.healthy;
+ return (
+ <Tag color={cfg.color} icon={cfg.icon}>
+ {cfg.label}
+ </Tag>
+ );
+ };
+
+ // ─── Columns ─────────────────────────────────────────────────
+
+ const columns: ColumnsType<ProxyNode> = [
+ {
+ title: t('common.address'),
+ dataIndex: 'address',
+ key: 'address',
+ render: (text: string, record: ProxyNode) => (
+ <Space>
+ <span style={{ fontWeight: record.isSelected ? 'bold' : 'normal'
}}>{text}</span>
+ {record.isSelected && <Tag color="blue">{t('proxy.current')}</Tag>}
+ </Space>
+ ),
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: string) => renderStatus(status),
+ },
+ {
+ title: t('proxy.version'),
+ dataIndex: 'version',
+ key: 'version',
+ },
+ {
+ title: t('proxy.connections'),
+ dataIndex: 'connections',
+ key: 'connections',
+ render: (val: number) => val.toLocaleString(),
+ sorter: (a, b) => a.connections - b.connections,
+ },
+ {
+ title: 'TPS',
+ dataIndex: 'tps',
+ key: 'tps',
+ render: (val: number) => val.toLocaleString(),
+ sorter: (a, b) => a.tps - b.tps,
+ },
+ {
+ title: t('proxy.memory'),
+ dataIndex: 'memory',
+ key: 'memory',
+ render: (val: number) => (
+ <Progress
+ percent={val}
+ size="small"
+ status={val > 80 ? 'exception' : 'normal'}
+ style={{ width: 100 }}
+ />
+ ),
+ sorter: (a, b) => a.memory - b.memory,
+ },
+ {
+ title: 'CPU',
+ dataIndex: 'cpu',
+ key: 'cpu',
+ render: (val: number) => (
+ <Progress
+ percent={val}
+ size="small"
+ status={val > 80 ? 'exception' : 'normal'}
+ style={{ width: 100 }}
+ />
+ ),
+ sorter: (a, b) => a.cpu - b.cpu,
+ },
+ {
+ title: t('proxy.uptime'),
+ dataIndex: 'uptime',
+ key: 'uptime',
+ },
+ {
+ title: t('proxy.action'),
+ key: 'action',
+ render: (_: unknown, record: ProxyNode) => (
+ <Space size="small">
+ <Tooltip title={t('proxy.viewConfig')}>
+ <Button
+ type="link"
+ size="small"
+ icon={<GearSix size={14} />}
+ onClick={() => handleViewConfig(record)}
+ />
+ </Tooltip>
+ {!record.isSelected && (
+ <Popconfirm
+ title={t('proxy.confirmRemove')}
+ onConfirm={() => handleRemoveNode(record)}
+ okText={t('common.yes')}
+ cancelText={t('common.no')}
+ >
+ <Tooltip title={t('proxy.remove')}>
+ <Button type="link" size="small" danger icon={<Trash size={14}
/>} />
+ </Tooltip>
+ </Popconfirm>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ // ─── Render ──────────────────────────────────────────────────
+
+ return (
+ <div style={{ padding: 0 }}>
+ <PageHeader
+ title={t('proxy.title')}
+
+ extra={
+ <Space>
+ <Button type="primary" icon={<ArrowClockwise size={14} />}
onClick={handleRefresh}>
+ {t('common.refresh')}
+ </Button>
+ <Button icon={<Plus size={14} />} onClick={() =>
setAddNodeModalOpen(true)}>
+ {t('proxy.addNode')}
+ </Button>
+ </Space>
+ }
+ />
+
+ <Spin spinning={loading} tip={t('common.loading')}>
+ {/* Cluster Stats */}
+ <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+ <Col xs={24} sm={12} md={6}>
+ <Card>
+ <Statistic
+ title={t('proxy.totalNodes')}
+ value={clusterStats.totalNodes}
+ prefix={<Gauge size={18} style={{ marginRight: 4 }} />}
+ valueStyle={{ color: '#1890ff' }}
+ />
+ </Card>
+ </Col>
+ <Col xs={24} sm={12} md={6}>
+ <Card>
+ <Statistic
+ title={t('proxy.healthyNodes')}
+ value={clusterStats.healthyNodes}
+ suffix={`/ ${clusterStats.totalNodes}`}
+ valueStyle={{
+ color:
+ clusterStats.healthyNodes === clusterStats.totalNodes ?
'#3f8600' : '#cf1322',
+ }}
+ />
+ </Card>
+ </Col>
+ <Col xs={24} sm={12} md={6}>
+ <Card>
+ <Statistic
+ title={t('proxy.totalConnections')}
+ value={clusterStats.totalConnections}
+ valueStyle={{ color: '#1890ff' }}
+ />
+ </Card>
+ </Col>
+ <Col xs={24} sm={12} md={6}>
+ <Card>
+ <Statistic
+ title={t('proxy.totalTps')}
+ value={clusterStats.totalTPS}
+ valueStyle={{ color: '#1890ff' }}
+ />
+ </Card>
+ </Col>
+ </Row>
+
+ {/* Node Table */}
+ <Card title={t('proxy.nodes')} bordered={false} style={{ borderRadius:
8 }}>
+ <Table columns={columns} dataSource={proxyNodes} pagination={false}
size="middle" />
+ </Card>
+ </Spin>
+
+ {/* Config Modal */}
+ <Modal
+ title={`${t('proxy.nodeConfig')} - ${selectedNode?.address}`}
+ open={configModalOpen}
+ onCancel={() => setConfigModalOpen(false)}
+ footer={[
+ <Button key="close" onClick={() => setConfigModalOpen(false)}>
+ {t('common.close')}
+ </Button>,
+ ]}
+ width={700}
+ >
+ <Descriptions bordered column={1} size="small">
+ {Object.entries(nodeConfig).length > 0 ? (
+ Object.entries(nodeConfig).map(([key, value]) => (
+ <Descriptions.Item key={key} label={key}>
+ {value}
+ </Descriptions.Item>
+ ))
+ ) : (
+ <Descriptions.Item
label={t('proxy.noConfigData')}>-</Descriptions.Item>
+ )}
+ </Descriptions>
+ </Modal>
+
+ {/* Add Node Modal */}
+ <Modal
+ title={t('proxy.addProxyNode')}
+ open={addNodeModalOpen}
+ onCancel={() => {
+ setAddNodeModalOpen(false);
+ form.resetFields();
+ }}
+ onOk={handleAddNode}
+ okText={t('common.add')}
+ cancelText={t('common.cancel')}
+ >
+ <Form form={form} layout="vertical">
+ <Form.Item
+ name="address"
+ label={t('proxy.address')}
+ rules={[
+ {
+ required: true,
+ message: t('proxy.addrRequired'),
+ },
+ {
+ pattern: /^[\w.-]+:\d+$/,
+ message: t('proxy.invalidAddress'),
+ },
+ ]}
+ >
+ <Input placeholder={t('proxy.addressPlaceholder')} />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default ProxyPage;