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 11aed1d352b7ba0c43566884a39e85c17426d3b8
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 a75edbbf..faba2783 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 00000000..573a351a
--- /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 8f199962..28ab04ea 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 c07151f6..47ba4549 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 00000000..bbda058c
--- /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;

Reply via email to