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 13bef2d feat: add Ops page (NameServer management, VIPChannel, TLS)
(#474)
13bef2d is described below
commit 13bef2d11a29c10887aef8ed1a329b65afcf7217
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:12:59 2026 +0800
feat: add Ops page (NameServer management, VIPChannel, TLS) (#474)
Add Ops management page for NameServer address management, VIP channel
toggle, and TLS switch.
---
web/src/App.tsx | 2 +
web/src/api/ops.test.ts | 248 +++++++++++++++++++++++++++++++++++++++++++
web/src/api/ops.ts | 29 +++++
web/src/i18n/translations.ts | 9 ++
web/src/pages/studio/Ops.tsx | 199 ++++++++++++++++++++++++++++++++++
5 files changed, 487 insertions(+)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index a75edbb..faba278 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 OpsPage from './pages/studio/Ops';
import LoginPage from './pages/login';
function App() {
@@ -56,6 +57,7 @@ function App() {
<Route path="ops/audit" element={<AuditPage />} />
<Route path="ai" element={<AiPage />} />
<Route path="settings" element={<SettingsPage />} />
+ <Route path="studio/ops" element={<OpsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts
new file mode 100644
index 0000000..573a351
--- /dev/null
+++ b/web/src/api/ops.test.ts
@@ -0,0 +1,248 @@
+/*
+ * 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 {
+ queryOpsHomePage,
+ updateNameSvrAddr,
+ addNameSvrAddr,
+ updateIsVIPChannel,
+ updateUseTLS,
+ listAlertRules,
+ createAlertRule,
+ updateAlertRule,
+ toggleAlertRule,
+ deleteAlertRule,
+ listSystemAlerts,
+ acknowledgeAlert,
+ clearAcknowledgedAlerts,
+ listAuditRecords,
+ cleanupAuditLogs,
+} from './ops';
+
+const mock = new MockAdapter(client);
+
+describe('Ops API - NameServer operations', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('queries ops home page data', async () => {
+ const data = {
+ namesvrAddrList: ['127.0.0.1:9876', '127.0.0.1:9877'],
+ useVIPChannel: true,
+ useTLS: false,
+ currentNamesrv: '127.0.0.1:9876',
+ };
+ mock.onGet('/ops/homePage').reply(200, { code: 200, data });
+
+ const result = await queryOpsHomePage();
+ expect(result.namesvrAddrList).toHaveLength(2);
+ expect(result.useVIPChannel).toBe(true);
+ expect(result.useTLS).toBe(false);
+ });
+
+ it('updates NameServer address', async () => {
+ mock.onPost('/ops/updateNameSvrAddr').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.namesrvAddr).toBe('10.0.0.1:9876');
+ return [200, { code: 200 }];
+ });
+
+ await updateNameSvrAddr('10.0.0.1:9876');
+ });
+
+ it('adds a NameServer address', async () => {
+ mock.onPost('/ops/addNameSvrAddr').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.namesrvAddr).toBe('10.0.0.2:9876');
+ return [200, { code: 200 }];
+ });
+
+ await addNameSvrAddr('10.0.0.2:9876');
+ });
+
+ it('updates VIP channel setting', async () => {
+ mock.onPost('/ops/updateIsVIPChannel').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.useVIPChannel).toBe(false);
+ return [200, { code: 200 }];
+ });
+
+ await updateIsVIPChannel(false);
+ });
+
+ it('updates TLS setting', async () => {
+ mock.onPost('/ops/updateUseTLS').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.useTLS).toBe(true);
+ return [200, { code: 200 }];
+ });
+
+ await updateUseTLS(true);
+ });
+});
+
+describe('Ops API - Alert Rules', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('lists alert rules', async () => {
+ const rules = [
+ {
+ id: '1',
+ name: 'HighCPU',
+ metric: 'cpu',
+ operator: '>',
+ threshold: 80,
+ thresholdUnit: '%',
+ duration: '5m',
+ channels: ['email'],
+ enabled: true,
+ lastTriggered: null,
+ description: 'CPU alert',
+ },
+ ];
+ mock.onGet('/alert-rules').reply(200, { code: 200, data: rules });
+
+ const result = await listAlertRules();
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('HighCPU');
+ });
+
+ it('creates an alert rule', async () => {
+ mock.onPost('/alert-rules/create').reply(200, { code: 200 });
+ await createAlertRule({ name: 'TestAlert', metric: 'memory', operator:
'>', threshold: 90 });
+ });
+
+ it('updates an alert rule', async () => {
+ mock.onPost('/alert-rules/update').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.id).toBe('1');
+ expect(body.threshold).toBe(95);
+ return [200, { code: 200 }];
+ });
+ await updateAlertRule({ id: '1', threshold: 95 });
+ });
+
+ it('toggles an alert rule', async () => {
+ mock.onPost('/alert-rules/toggle').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.id).toBe('1');
+ expect(body.enabled).toBe(false);
+ return [200, { code: 200 }];
+ });
+ await toggleAlertRule('1', false);
+ });
+
+ it('deletes an alert rule', async () => {
+ mock.onPost('/alert-rules/delete').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.id).toBe('1');
+ return [200, { code: 200 }];
+ });
+ await deleteAlertRule('1');
+ });
+});
+
+describe('Ops API - System Alerts & Audit', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('lists system alerts', async () => {
+ mock.onGet('/system-alerts').reply(200, {
+ code: 200,
+ data: [
+ {
+ id: 'a1',
+ level: 'critical',
+ title: 'Disk Full',
+ description: 'Disk usage > 95%',
+ time: '2026-01-01',
+ acknowledged: false,
+ },
+ ],
+ });
+ const result = await listSystemAlerts();
+ expect(result[0].level).toBe('critical');
+ });
+
+ it('acknowledges an alert', async () => {
+ mock.onPost('/system-alerts/acknowledge').reply((config) => {
+ expect(JSON.parse(config.data).id).toBe('a1');
+ return [200, { code: 200 }];
+ });
+ await acknowledgeAlert('a1');
+ });
+
+ it('clears acknowledged alerts', async () => {
+ mock.onPost('/system-alerts/clear-acknowledged').reply(200, { code: 200 });
+ await clearAcknowledgedAlerts();
+ });
+
+ it('lists audit records with params', async () => {
+ mock.onGet('/audit-logs').reply(200, {
+ code: 200,
+ data: {
+ list: [
+ {
+ id: 'r1',
+ timestamp: '2026-01-01',
+ operator: 'admin',
+ operationType: 'CREATE',
+ target: 'topic',
+ detail: 'Created topic',
+ ipAddress: '127.0.0.1',
+ result: 'SUCCESS',
+ },
+ ],
+ total: 1,
+ },
+ });
+ const result = await listAuditRecords({ page: 1 });
+ expect(result.list).toHaveLength(1);
+ expect(result.total).toBe(1);
+ });
+
+ it('cleans up audit logs', async () => {
+ mock.onPost('/audit-logs/cleanup').reply((config) => {
+ expect(JSON.parse(config.data).beforeDays).toBe(30);
+ return [200, { code: 200 }];
+ });
+ await cleanupAuditLogs(30);
+ });
+});
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 8f19996..28ab04e 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -84,3 +84,32 @@ export async function listAuditRecords(params?:
Record<string, unknown>) {
export async function cleanupAuditLogs(beforeDays: number) {
await client.post('/audit-logs/cleanup', { beforeDays });
}
+
+// ─── NameServer Operations ──────────────────────────────────────
+export interface OpsHomeData {
+ namesvrAddrList: string[];
+ useVIPChannel: boolean;
+ useTLS: boolean;
+ currentNamesrv: string;
+}
+
+export async function queryOpsHomePage(): Promise<OpsHomeData> {
+ const res = await client.get<{ data: OpsHomeData }>('/ops/homePage');
+ return res.data.data;
+}
+
+export async function updateNameSvrAddr(namesrvAddr: string): Promise<void> {
+ await client.post('/ops/updateNameSvrAddr', { namesrvAddr });
+}
+
+export async function addNameSvrAddr(namesrvAddr: string): Promise<void> {
+ await client.post('/ops/addNameSvrAddr', { namesrvAddr });
+}
+
+export async function updateIsVIPChannel(useVIPChannel: boolean):
Promise<void> {
+ await client.post('/ops/updateIsVIPChannel', { useVIPChannel });
+}
+
+export async function updateUseTLS(useTLS: boolean): Promise<void> {
+ await client.post('/ops/updateUseTLS', { useTLS });
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index c07151f..47ba454 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -357,6 +357,15 @@ const translations: Record<string, Record<Lang, string>> =
{
'ops.name': { zh: '名称', en: 'Name' },
'ops.value': { zh: '值', en: 'Value' },
'ops.description': { zh: '描述', en: 'Description' },
+ 'ops.nameServerAddressList': { zh: 'NameServer 地址列表', en: 'NameServer
Address List' },
+ 'ops.isUseVIPChannel': { zh: '是否使用 VIP 通道', en: 'Is Use VIP Channel' },
+ 'ops.useTLS': { zh: '使用 TLS', en: 'Use TLS' },
+ 'ops.selectNamesrv': { zh: '请选择 NameServer 地址', en: 'Please select a
NameServer address' },
+ 'ops.inputNamesrvAddr': {
+ zh: '请输入新的 NameServer 地址',
+ en: 'Please input a new NameServer address',
+ },
+ 'ops.fetchFailed': { zh: '获取运维数据失败', en: 'Failed to fetch ops data' },
// ─── Topic (detailed) ───
'topic.subtitle': {
diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
new file mode 100644
index 0000000..bbda058
--- /dev/null
+++ b/web/src/pages/studio/Ops.tsx
@@ -0,0 +1,199 @@
+/*
+ * 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 React, { useRef, useState } from 'react';
+import { App, Button, Input, Select, Space, Switch, Typography } from 'antd';
+import { FloppyDisk, Plus } from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+import {
+ addNameSvrAddr,
+ queryOpsHomePage,
+ updateIsVIPChannel,
+ updateNameSvrAddr,
+ updateUseTLS,
+} from '../../api/ops';
+
+const OpsPage: React.FC = () => {
+ const { t } = useLang();
+ const { message } = App.useApp();
+
+ const [namesrvAddrList, setNamesrvAddrList] = useState<string[]>([]);
+ const [selectedNamesrv, setSelectedNamesrv] = useState('');
+ const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
+ const [useVIPChannel, setUseVIPChannel] = useState(false);
+ const [useTLS, setUseTLS] = useState(false);
+ const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
+
+ // One-time initialization (ESLint-compliant: no useEffect+setState)
+ const initialized = useRef<boolean | null>(null);
+ if (initialized.current == null) {
+ initialized.current = true;
+ const loadOpsData = async () => {
+ try {
+ const userRole = sessionStorage.getItem('userrole');
+ setWriteOperationEnabled(userRole === null || userRole === '1');
+
+ const data = await queryOpsHomePage();
+ setNamesrvAddrList(data.namesvrAddrList);
+ setUseVIPChannel(data.useVIPChannel);
+ setUseTLS(data.useTLS);
+ setSelectedNamesrv(data.currentNamesrv);
+ } catch {
+ message.error(t('ops.fetchFailed'));
+ }
+ };
+ loadOpsData();
+ }
+
+ const handleUpdateNameSvrAddr = async () => {
+ if (!selectedNamesrv) {
+ message.warning(t('ops.selectNamesrv'));
+ return;
+ }
+ try {
+ await updateNameSvrAddr(selectedNamesrv);
+ message.success(t('common.success'));
+ } catch {
+ message.error(t('common.failure'));
+ }
+ };
+
+ const handleAddNameSvrAddr = async () => {
+ const addr = newNamesrvAddr.trim();
+ if (!addr) {
+ message.warning(t('ops.inputNamesrvAddr'));
+ return;
+ }
+ try {
+ await addNameSvrAddr(addr);
+ if (!namesrvAddrList.includes(addr)) {
+ setNamesrvAddrList([...namesrvAddrList, addr]);
+ }
+ setNewNamesrvAddr('');
+ message.success(t('common.success'));
+ } catch {
+ message.error(t('common.failure'));
+ }
+ };
+
+ const handleUpdateIsVIPChannel = async (checked: boolean) => {
+ setUseVIPChannel(checked);
+ try {
+ await updateIsVIPChannel(checked);
+ message.success(t('common.success'));
+ } catch {
+ message.error(t('common.failure'));
+ setUseVIPChannel(!checked);
+ }
+ };
+
+ const handleUpdateUseTLS = async (checked: boolean) => {
+ setUseTLS(checked);
+ try {
+ await updateUseTLS(checked);
+ message.success(t('common.success'));
+ } catch {
+ message.error(t('common.failure'));
+ setUseTLS(!checked);
+ }
+ };
+
+ return (
+ <div style={{ padding: 24 }}>
+ {/* NameServer Address List */}
+ <div style={{ marginBottom: 24 }}>
+ <Typography.Title
level={4}>{t('ops.nameServerAddressList')}</Typography.Title>
+ <Space wrap align="start">
+ <Select
+ style={{ minWidth: 400, maxWidth: 500 }}
+ value={selectedNamesrv || undefined}
+ onChange={setSelectedNamesrv}
+ disabled={!writeOperationEnabled}
+ placeholder={t('ops.selectNamesrv')}
+ options={namesrvAddrList.map((addr) => ({ label: addr, value: addr
}))}
+ />
+ {writeOperationEnabled && (
+ <Button
+ type="primary"
+ icon={<FloppyDisk size={16} />}
+ onClick={handleUpdateNameSvrAddr}
+ >
+ {t('common.update')}
+ </Button>
+ )}
+ {writeOperationEnabled && (
+ <Space.Compact>
+ <Input
+ style={{ width: 300 }}
+ placeholder="NamesrvAddr"
+ value={newNamesrvAddr}
+ onChange={(e) => setNewNamesrvAddr(e.target.value)}
+ />
+ <Button type="primary" icon={<Plus size={16} />}
onClick={handleAddNameSvrAddr}>
+ {t('common.add')}
+ </Button>
+ </Space.Compact>
+ )}
+ </Space>
+ </div>
+
+ {/* VIP Channel */}
+ <div style={{ marginBottom: 24 }}>
+ <Typography.Title
level={4}>{t('ops.isUseVIPChannel')}</Typography.Title>
+ <Space align="center">
+ <Switch
+ checked={useVIPChannel}
+ onChange={handleUpdateIsVIPChannel}
+ disabled={!writeOperationEnabled}
+ />
+ {writeOperationEnabled && (
+ <Button
+ type="primary"
+ icon={<FloppyDisk size={16} />}
+ onClick={() => handleUpdateIsVIPChannel(useVIPChannel)}
+ >
+ {t('common.update')}
+ </Button>
+ )}
+ </Space>
+ </div>
+
+ {/* Use TLS */}
+ <div style={{ marginBottom: 24 }}>
+ <Typography.Title level={4}>{t('ops.useTLS')}</Typography.Title>
+ <Space align="center">
+ <Switch
+ checked={useTLS}
+ onChange={handleUpdateUseTLS}
+ disabled={!writeOperationEnabled}
+ />
+ {writeOperationEnabled && (
+ <Button
+ type="primary"
+ icon={<FloppyDisk size={16} />}
+ onClick={() => handleUpdateUseTLS(useTLS)}
+ >
+ {t('common.update')}
+ </Button>
+ )}
+ </Space>
+ </div>
+ </div>
+ );
+};
+
+export default OpsPage;