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;
