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 d1be7319 fix: harden page data loading and state (#638)
d1be7319 is described below
commit d1be7319188df4930f0b441287c22ee849162043
Author: aias00 <[email protected]>
AuthorDate: Fri Jul 31 00:36:01 2026 -0700
fix: harden page data loading and state (#638)
* [Studio] Load page data after mount
* Preserve topic table pagination
* [Studio] Wire consumer page to group APIs
* [Studio] Use auth admin flag for Ops writes
---
web/src/i18n/translations.ts | 14 +++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 125 +++++++++++++++++++++
.../pages/instance/__tests__/TopicPage.test.tsx | 123 ++++++++++++++++++++
web/src/pages/instance/consumer.tsx | 82 +++++++++-----
web/src/pages/instance/topic.tsx | 36 +++++-
web/src/pages/login/index.tsx | 2 +-
web/src/pages/studio/AlertManagement.tsx | 42 +++++--
web/src/pages/studio/Ops.tsx | 41 ++++---
web/src/pages/studio/Producer.tsx | 27 +++--
.../studio/__tests__/AlertManagement.test.tsx | 86 ++++++++++++++
web/src/pages/studio/__tests__/Ops.test.tsx | 110 ++++++++++++++++++
web/src/pages/studio/__tests__/Producer.test.tsx | 81 +++++++++++++
web/src/stores/authStorage.test.ts | 18 ++-
web/src/stores/authStorage.ts | 10 +-
web/src/stores/authStore.ts | 12 +-
15 files changed, 724 insertions(+), 85 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 80164b5f..54625d24 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -414,6 +414,20 @@ const translations: Record<string, Record<Lang, string>> =
{
'consumer.viewDistribution': { zh: '查看分布', en: 'View Distribution' },
'consumer.resetToTime': { zh: '重置到指定时间', en: 'Reset to Time' },
'consumer.resetSuccess': { zh: '位点重置成功', en: 'Offset reset successfully' },
+ 'consumer.fetchListFailed': {
+ zh: '消费组列表加载失败,请稍后重试',
+ en: 'Failed to load consumer groups. Please try again later.',
+ },
+ 'consumer.fetchSubscriptionsFailed': {
+ zh: '消费组 {name} 订阅关系加载失败',
+ en: 'Failed to load subscriptions for consumer group {name}',
+ },
+ 'consumer.fetchProgressFailed': {
+ zh: '消费组 {name} 消费进度加载失败',
+ en: 'Failed to load consume progress for consumer group {name}',
+ },
+ 'consumer.createFailed': { zh: '消费组创建失败', en: 'Failed to create consumer
group' },
+ 'consumer.resetFailed': { zh: '消费位点重置失败', en: 'Failed to reset consume
offset' },
// ─── Home Page (additional) ───
'home.banner': {
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
new file mode 100644
index 00000000..9d413f97
--- /dev/null
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -0,0 +1,125 @@
+/*
+ * 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 { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ConsumerGroup } from '../../../api/metadata';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as consumerService from '../../../services/consumerService';
+import ConsumerPage from '../consumer';
+
+vi.mock('../../../services/consumerService', () => ({
+ batchDeleteConsumerGroups: vi.fn(),
+ createConsumerGroup: vi.fn(),
+ deleteConsumerGroup: vi.fn(),
+ getConsumerGroup: vi.fn(),
+ getConsumerProgress: vi.fn(),
+ getConsumerSubscriptions: vi.fn(),
+ listConsumerGroups: vi.fn(),
+ resetConsumerOffset: vi.fn(),
+}));
+
+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(),
+ })),
+ });
+});
+
+const group: ConsumerGroup = {
+ name: 'remote-cg',
+ namespace: 'remote-ns',
+ clusterId: 'cluster-a',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 1,
+ totalLag: 10,
+ subscribedTopics: ['remote-topic'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ createdAt: '2026-07-23T00:00:00Z',
+ updatedAt: '2026-07-23T00:00:00Z',
+ delaySeconds: 3,
+ instances: [],
+};
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+
+describe('Consumer page', () => {
+ beforeEach(() => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([group]);
+ vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([
+ {
+ broker: 'broker-a',
+ queueId: 0,
+ brokerOffset: 100,
+ consumerOffset: 90,
+ diffTotal: 10,
+ },
+ ]);
+ vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([
+ {
+ topic: 'remote-topic',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ ]);
+ });
+
+ it('loads consumer groups through the service layer', async () => {
+ renderWithProviders(<ConsumerPage />);
+
+ expect(await screen.findByText('remote-cg')).toBeInTheDocument();
+ expect(screen.getByText('Push')).toBeInTheDocument();
+ expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
+ });
+
+ it('loads subscriptions and progress when opening a group', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ await user.click(await screen.findByRole('button', { name: /详情/ }));
+
+ await waitFor(() =>
+
expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith('remote-cg'),
+ );
+ await waitFor(() =>
+
expect(consumerService.getConsumerProgress).toHaveBeenCalledWith('remote-cg'),
+ );
+ expect(consumerService.getConsumerGroup).not.toHaveBeenCalled();
+ await waitFor(() =>
expect(screen.getAllByText('remote-topic').length).toBeGreaterThan(0));
+ });
+});
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
new file mode 100644
index 00000000..701afd10
--- /dev/null
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -0,0 +1,123 @@
+/*
+ * 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, beforeEach, afterEach } from
'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import type { Topic } from '../../../api/metadata';
+import TopicPage from '../topic';
+
+const topicServiceMocks = vi.hoisted(() => ({
+ batchDeleteTopics: vi.fn(),
+ createTopic: vi.fn(),
+ deleteTopic: vi.fn(),
+ getTopicConsumers: vi.fn(),
+ getTopicRoutes: vi.fn(),
+ listTopics: vi.fn(),
+ sendTopicMessage: vi.fn(),
+}));
+
+vi.mock('../../../services/topicService', () => topicServiceMocks);
+
+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(),
+ })),
+ });
+});
+
+const buildTopics = (count: number): Topic[] =>
+ Array.from({ length: count }, (_, index) => {
+ const suffix = String(index + 1).padStart(2, '0');
+ return {
+ name: `topic-${suffix}`,
+ namespace: 'default',
+ type: 'NORMAL',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: index,
+ tps: index,
+ consumerGroupCount: 0,
+ remark: `Topic ${suffix}`,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ };
+ });
+
+const renderWithProviders = () =>
+ render(
+ <App>
+ <LangProvider>
+ <TopicPage />
+ </LangProvider>
+ </App>,
+ );
+
+const getTableBody = () => {
+ const tableBody = document.querySelector('.ant-table-tbody');
+ expect(tableBody).not.toBeNull();
+ return tableBody as HTMLElement;
+};
+
+describe('TopicPage', () => {
+ beforeEach(() => {
+ topicServiceMocks.listTopics.mockResolvedValue(buildTopics(25));
+ topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
+ topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('keeps the current table page after opening and closing topic details',
async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+
+ const secondPage = document.querySelector('.ant-pagination-item-2');
+ expect(secondPage).not.toBeNull();
+ await user.click(secondPage as HTMLElement);
+
+ await waitFor(() =>
expect(within(getTableBody()).getByText('topic-21')).toBeInTheDocument());
+
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
+
+ await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
+ await waitFor(() =>
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-21'));
+
+ const closeButton = document.querySelector('.ant-modal-close');
+ expect(closeButton).not.toBeNull();
+ await user.click(closeButton as HTMLElement);
+
+ expect(within(getTableBody()).getByText('topic-21')).toBeInTheDocument();
+
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 51908399..ccf0f46d 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -122,6 +122,8 @@ const ConsumerPage = () => {
const { t } = useLang();
const [groups, setGroups] = useState<ConsumerGroup[]>([]);
const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [resetSubmitting, setResetSubmitting] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [search, setSearch] = useState('');
const [modeFilter, setModeFilter] = useState<string>('ALL');
@@ -147,7 +149,7 @@ const ConsumerPage = () => {
const nextGroups = await listConsumerGroups();
if (!cancelled) setGroups(nextGroups);
} catch {
- if (!cancelled) message.error('消费组列表加载失败,请稍后重试');
+ if (!cancelled) message.error(t('consumer.fetchListFailed'));
} finally {
if (!cancelled) setLoading(false);
}
@@ -157,7 +159,7 @@ const ConsumerPage = () => {
return () => {
cancelled = true;
};
- }, []);
+ }, [t]);
const loadSubscriptions = useCallback(
async (groupName: string) => {
@@ -166,10 +168,10 @@ const ConsumerPage = () => {
const subscriptions = await getConsumerSubscriptions(groupName);
setSubscriptionsByGroup((prev) => ({ ...prev, [groupName]:
subscriptions }));
} catch {
- message.error(`消费组 ${groupName} 订阅关系加载失败`);
+ message.error(t('consumer.fetchSubscriptionsFailed', { name: groupName
}));
}
},
- [subscriptionsByGroup],
+ [subscriptionsByGroup, t],
);
const loadProgress = useCallback(
@@ -179,10 +181,10 @@ const ConsumerPage = () => {
const progress = await getConsumerProgress(groupName);
setProgressByGroup((prev) => ({ ...prev, [groupName]: progress }));
} catch {
- message.error(`消费组 ${groupName} 消费进度加载失败`);
+ message.error(t('consumer.fetchProgressFailed', { name: groupName }));
}
},
- [progressByGroup],
+ [progressByGroup, t],
);
/* ─── Filtered & sorted data ─── */
@@ -939,29 +941,38 @@ const ConsumerPage = () => {
okText: '确认创建',
cancelText: '取消',
onOk: async () => {
- const created = await createConsumerGroup({
- name: values.name,
- namespace: values.namespace || 'default',
- subscriptionMode: values.subscriptionMode,
- consumeType: values.consumeType,
- retryMaxTimes: values.retryMaxTimes,
- subscriptionDataType: values.dataType || 'NORMAL',
- deliveryOrderType: values.deliveryOrderType,
- subscribedTopics: [],
- });
- setGroups((prev) => [
- created,
- ...prev.filter((group) => group.name !== created.name),
- ]);
- message.success(`消费组 ${values.name} 创建成功`);
- setCreateModalOpen(false);
- form.resetFields();
- setDataTypeValue(undefined);
+ setSubmitting(true);
+ try {
+ const created = await createConsumerGroup({
+ name: values.name,
+ namespace: values.namespace || 'default',
+ subscriptionMode: values.subscriptionMode,
+ consumeType: values.consumeType,
+ retryMaxTimes: values.retryMaxTimes,
+ subscriptionDataType: values.dataType || 'NORMAL',
+ deliveryOrderType: values.deliveryOrderType,
+ subscribedTopics: [],
+ });
+ setGroups((prev) => [
+ created,
+ ...prev.filter((group) => group.name !== created.name),
+ ]);
+ message.success(`消费组 ${values.name} 创建成功`);
+ setCreateModalOpen(false);
+ form.resetFields();
+ setDataTypeValue(undefined);
+ } catch {
+ message.error(t('consumer.createFailed'));
+ throw new Error(t('consumer.createFailed'));
+ } finally {
+ setSubmitting(false);
+ }
},
});
})
.catch(() => {});
}}
+ confirmLoading={submitting}
okText="创建"
cancelText="取消"
width={560}
@@ -1063,17 +1074,26 @@ const ConsumerPage = () => {
}}
onOk={async () => {
if (resetGroup) {
- await resetConsumerOffset({
- name: resetGroup.name,
- timestamp: resetTime.valueOf(),
- });
- message.success(
- `${resetGroup.name} 消费位点已重置到 ${resetTime.format('YYYY-MM-DD
HH:mm:ss')}`,
- );
+ setResetSubmitting(true);
+ try {
+ await resetConsumerOffset({
+ name: resetGroup.name,
+ timestamp: resetTime.valueOf(),
+ });
+ message.success(
+ `${resetGroup.name} 消费位点已重置到 ${resetTime.format('YYYY-MM-DD
HH:mm:ss')}`,
+ );
+ } catch {
+ message.error(t('consumer.resetFailed'));
+ return;
+ } finally {
+ setResetSubmitting(false);
+ }
}
setResetModalOpen(false);
setResetGroup(null);
}}
+ confirmLoading={resetSubmitting}
okText="确认重置"
cancelText="取消"
width={480}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 4569bfac..b3d9cfeb 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -223,6 +223,8 @@ const TopicPage = () => {
const [searchText, setSearchText] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [nsFilter, setNsFilter] = useState('');
+ const [tablePage, setTablePage] = useState(1);
+ const [tablePageSize, setTablePageSize] = useState(20);
const [viewMode, setViewMode] = useState<string>('列表');
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
@@ -266,6 +268,13 @@ const TopicPage = () => {
[topics, searchText, typeFilter, nsFilter],
);
+ const maxTablePage = Math.max(1, Math.ceil(filteredTopics.length /
tablePageSize));
+ const currentTablePage = Math.min(tablePage, maxTablePage);
+
+ const resetTablePage = () => {
+ setTablePage(1);
+ };
+
// ─── Open detail modal ────────────────────────────────────────
const openDetail = async (topic: Topic) => {
setSelectedTopic(topic);
@@ -621,22 +630,34 @@ const TopicPage = () => {
placeholder="搜索 Topic 名称"
allowClear
style={{ width: 260 }}
- onSearch={setSearchText}
+ onSearch={(value) => {
+ setSearchText(value);
+ resetTablePage();
+ }}
onChange={(e) => {
- if (!e.target.value) setSearchText('');
+ if (!e.target.value) {
+ setSearchText('');
+ resetTablePage();
+ }
}}
/>
<Select
placeholder="类型筛选"
value={typeFilter}
- onChange={setTypeFilter}
+ onChange={(value) => {
+ setTypeFilter(value);
+ resetTablePage();
+ }}
options={TYPE_OPTIONS}
style={{ width: 140 }}
/>
<Select
placeholder="命名空间"
value={nsFilter}
- onChange={setNsFilter}
+ onChange={(value) => {
+ setNsFilter(value);
+ resetTablePage();
+ }}
options={NAMESPACE_OPTIONS}
style={{ width: 140 }}
/>
@@ -708,9 +729,14 @@ const TopicPage = () => {
onChange: (keys) => setSelectedRowKeys(keys),
}}
pagination={{
- pageSize: 20,
+ current: currentTablePage,
+ pageSize: tablePageSize,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
+ onChange: (page, pageSize) => {
+ setTablePage(page);
+ setTablePageSize(pageSize);
+ },
}}
size="small"
onRow={(record) => ({
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
index 37ceca17..cec0353b 100644
--- a/web/src/pages/login/index.tsx
+++ b/web/src/pages/login/index.tsx
@@ -41,7 +41,7 @@ const LoginPage = () => {
setLoading(true);
try {
const data = await loginApi(values.username, values.password);
- authLogin(data.token, data.user.username);
+ authLogin(data.token, data.user.username, data.user.admin);
message.success(t('login.success'));
navigate('/', { replace: true });
} catch (err: unknown) {
diff --git a/web/src/pages/studio/AlertManagement.tsx
b/web/src/pages/studio/AlertManagement.tsx
index dbdd6df3..a166cd83 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import React, { useMemo, useRef, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
App,
Badge,
@@ -145,6 +145,7 @@ const AlertManagementPage: React.FC = () => {
const { t } = useLang();
const { message } = App.useApp();
const [form] = Form.useForm();
+ const fetchFailedMessage = t('alertMgmt.fetchFailed');
const [alertRules, setAlertRules] = useState<AlertRule[]>([]);
const [loading, setLoading] = useState(false);
@@ -162,32 +163,49 @@ const AlertManagementPage: React.FC = () => {
return {};
}
});
+ const disabledRulesRef = useRef(disabledRules);
+
+ useEffect(() => {
+ disabledRulesRef.current = disabledRules;
+ }, [disabledRules]);
+
+ useEffect(() => {
+ let cancelled = false;
- // One-time initialization (ESLint-compliant)
- const initialized = useRef<boolean | null>(null);
- if (initialized.current == null) {
- initialized.current = true;
const loadRules = async () => {
- setLoading(true);
+ if (!cancelled) {
+ setLoading(true);
+ }
try {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
- setAlertRules(parseYamlRules(yamlStr, disabledRules));
+ if (!cancelled) {
+ setAlertRules(parseYamlRules(yamlStr, disabledRulesRef.current));
+ }
} catch {
- message.error(t('alertMgmt.fetchFailed'));
+ if (!cancelled) {
+ message.error(fetchFailedMessage);
+ }
} finally {
- setLoading(false);
+ if (!cancelled) {
+ setLoading(false);
+ }
}
};
- loadRules();
- }
+
+ void loadRules();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchFailedMessage, message]);
const fetchAlertRules = async () => {
setLoading(true);
try {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
- setAlertRules(parseYamlRules(yamlStr, disabledRules));
+ setAlertRules(parseYamlRules(yamlStr, disabledRulesRef.current));
} catch {
message.error(t('alertMgmt.fetchFailed'));
} finally {
diff --git a/web/src/pages/studio/Ops.tsx b/web/src/pages/studio/Ops.tsx
index bbda058c..60e08ff0 100644
--- a/web/src/pages/studio/Ops.tsx
+++ b/web/src/pages/studio/Ops.tsx
@@ -15,10 +15,11 @@
* limitations under the License.
*/
-import React, { useRef, useState } from 'react';
+import React, { useEffect, 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 useAuthStore from '../../stores/authStore';
import {
addNameSvrAddr,
queryOpsHomePage,
@@ -30,34 +31,42 @@ import {
const OpsPage: React.FC = () => {
const { t } = useLang();
const { message } = App.useApp();
+ const fetchFailedMessage = t('ops.fetchFailed');
+ const token = useAuthStore((state) => state.token);
+ const admin = useAuthStore((state) => state.admin);
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);
+ const writeOperationEnabled = !token || admin === true;
+
+ useEffect(() => {
+ let cancelled = false;
- // 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);
+ if (!cancelled) {
+ setNamesrvAddrList(data.namesvrAddrList);
+ setUseVIPChannel(data.useVIPChannel);
+ setUseTLS(data.useTLS);
+ setSelectedNamesrv(data.currentNamesrv);
+ }
} catch {
- message.error(t('ops.fetchFailed'));
+ if (!cancelled) {
+ message.error(fetchFailedMessage);
+ }
}
};
- loadOpsData();
- }
+
+ void loadOpsData();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchFailedMessage, message]);
const handleUpdateNameSvrAddr = async () => {
if (!selectedNamesrv) {
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index 241adffc..f88b8197 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState, useRef } from 'react';
+import { useEffect, useState } from 'react';
import { Button, Form, Input, Select, Table, Card, App } from 'antd';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
@@ -32,21 +32,30 @@ const ProducerPage = () => {
const [loading, setLoading] = useState(false);
const { t } = useLang();
const { message } = App.useApp();
+ const fetchTopicFailedMessage = t('producer.fetchTopicFailed');
+
+ useEffect(() => {
+ let cancelled = false;
- // Load topic list on mount (once)
- const initialized = useRef<boolean | null>(null);
- if (initialized.current == null) {
- initialized.current = true;
const loadTopics = async () => {
try {
const topics = await fetchTopicList();
- setTopicList(topics);
+ if (!cancelled) {
+ setTopicList(topics);
+ }
} catch {
- message.error(t('producer.fetchTopicFailed'));
+ if (!cancelled) {
+ message.error(fetchTopicFailedMessage);
+ }
}
};
- loadTopics();
- }
+
+ void loadTopics();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchTopicFailedMessage, message]);
const onFinish = async (values: { selectedTopic: string; producerGroup:
string }) => {
setLoading(true);
diff --git a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
new file mode 100644
index 00000000..a62aaa84
--- /dev/null
+++ b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 type { ReactElement } from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import AlertManagementPage from '../AlertManagement';
+import { queryAlertRules } from '../../../api/alertManagement';
+
+vi.mock('../../../api/alertManagement', () => ({
+ queryAlertRules: vi.fn(),
+}));
+
+const rulesYaml = `
+groups:
+- name: rocketmq-broker.rules
+ rules:
+ # Rule 1:
+ - alert: BrokerDown
+ expr: up{job="rocketmq-broker"} == 0
+ for: 5m
+ labels:
+ severity: critical
+ team: broker
+ annotations:
+ summary: "Broker unavailable"
+ description: "Broker has been unavailable for five minutes"
+`;
+
+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(),
+ })),
+ });
+});
+
+const renderWithProviders = (ui: ReactElement) => {
+ return render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+};
+
+describe('AlertManagementPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ vi.mocked(queryAlertRules).mockResolvedValue({ rules: rulesYaml });
+ });
+
+ it('loads alert rules after mount', async () => {
+ renderWithProviders(<AlertManagementPage />);
+
+ await waitFor(() => {
+ expect(queryAlertRules).toHaveBeenCalledTimes(1);
+ });
+
+ expect(await screen.findByText('BrokerDown')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/studio/__tests__/Ops.test.tsx
b/web/src/pages/studio/__tests__/Ops.test.tsx
new file mode 100644
index 00000000..1e41136a
--- /dev/null
+++ b/web/src/pages/studio/__tests__/Ops.test.tsx
@@ -0,0 +1,110 @@
+/*
+ * 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 type { ReactElement } from 'react';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import OpsPage from '../Ops';
+import { queryOpsHomePage } from '../../../api/ops';
+import useAuthStore from '../../../stores/authStore';
+
+vi.mock('../../../api/ops', () => ({
+ addNameSvrAddr: vi.fn(),
+ queryOpsHomePage: vi.fn(),
+ updateIsVIPChannel: vi.fn(),
+ updateNameSvrAddr: vi.fn(),
+ updateUseTLS: vi.fn(),
+}));
+
+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(),
+ })),
+ });
+});
+
+const renderWithProviders = (ui: ReactElement) => {
+ return render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+};
+
+describe('OpsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ sessionStorage.clear();
+ useAuthStore.setState({ token: null, user: null, admin: null });
+ vi.mocked(queryOpsHomePage).mockResolvedValue({
+ namesvrAddrList: ['127.0.0.1:9876', '127.0.0.2:9876'],
+ useVIPChannel: true,
+ useTLS: false,
+ currentNamesrv: '127.0.0.1:9876',
+ });
+ });
+
+ it('loads NameServer and channel settings after mount', async () => {
+ renderWithProviders(<OpsPage />);
+
+ await waitFor(() => {
+ expect(queryOpsHomePage).toHaveBeenCalledTimes(1);
+ });
+
+ expect(await screen.findByText('127.0.0.1:9876')).toBeInTheDocument();
+ expect(screen.getAllByRole('switch')[0]).toBeChecked();
+ expect(screen.getAllByRole('switch')[1]).not.toBeChecked();
+ });
+
+ it('hides write controls for read-only users', async () => {
+ useAuthStore.setState({ token: 'token-reader', user: 'reader', admin:
false });
+
+ renderWithProviders(<OpsPage />);
+
+ await waitFor(() => {
+ expect(queryOpsHomePage).toHaveBeenCalledTimes(1);
+ });
+
+
expect(screen.queryByPlaceholderText('NamesrvAddr')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /新增|添加/
})).not.toBeInTheDocument();
+ });
+
+ it('keeps write controls visible for admin users', async () => {
+ useAuthStore.setState({ token: 'token-admin', user: 'admin', admin: true
});
+
+ renderWithProviders(<OpsPage />);
+
+ await waitFor(() => {
+ expect(queryOpsHomePage).toHaveBeenCalledTimes(1);
+ });
+
+ expect(await
screen.findByPlaceholderText('NamesrvAddr')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /新增|添加/ })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
new file mode 100644
index 00000000..b4dbb091
--- /dev/null
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -0,0 +1,81 @@
+/*
+ * 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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import ProducerPage from '../Producer';
+import { fetchTopicList } from '../../../api/producer';
+
+vi.mock('../../../api/producer', () => ({
+ fetchTopicList: vi.fn(),
+ queryProducerConnection: vi.fn(),
+}));
+
+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(),
+ })),
+ });
+});
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+};
+
+describe('ProducerPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(fetchTopicList).mockResolvedValue(['order-events',
'payment-events']);
+ });
+
+ it('loads topic options after mount', async () => {
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => {
+ expect(fetchTopicList).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('renders topic options loaded from the API', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => {
+ expect(fetchTopicList).toHaveBeenCalledTimes(1);
+ });
+
+ await user.click(screen.getByRole('combobox'));
+ await screen.findByRole('option', { name: 'order-events' });
+ expect(await screen.findByRole('option', { name: 'payment-events'
})).toBeInTheDocument();
+ });
+});
diff --git a/web/src/stores/authStorage.test.ts
b/web/src/stores/authStorage.test.ts
index 9348dbed..445ca89e 100644
--- a/web/src/stores/authStorage.test.ts
+++ b/web/src/stores/authStorage.test.ts
@@ -21,6 +21,7 @@ import {
persistAuthSession,
readAuthSession,
TOKEN_STORAGE_KEY,
+ USER_ADMIN_STORAGE_KEY,
USER_STORAGE_KEY,
} from './authStorage';
@@ -30,22 +31,31 @@ describe('auth session storage', () => {
});
it('persists the token and user together', () => {
- persistAuthSession('token-1', 'studio-admin');
+ persistAuthSession('token-1', 'studio-admin', true);
- expect(readAuthSession()).toEqual({ token: 'token-1', user: 'studio-admin'
});
+ expect(readAuthSession()).toEqual({ token: 'token-1', user:
'studio-admin', admin: true });
});
it('does not restore an orphaned user without a token', () => {
localStorage.setItem(USER_STORAGE_KEY, 'studio-admin');
+ localStorage.setItem(USER_ADMIN_STORAGE_KEY, 'true');
- expect(readAuthSession()).toEqual({ token: null, user: null });
+ expect(readAuthSession()).toEqual({ token: null, user: null, admin: null
});
+ });
+
+ it('does not infer admin permissions from legacy sessions', () => {
+ localStorage.setItem(TOKEN_STORAGE_KEY, 'token-1');
+ localStorage.setItem(USER_STORAGE_KEY, 'studio-admin');
+
+ expect(readAuthSession()).toEqual({ token: 'token-1', user:
'studio-admin', admin: null });
});
it('clears every persisted session key', () => {
- persistAuthSession('token-1', 'studio-admin');
+ persistAuthSession('token-1', 'studio-admin', true);
clearAuthSession();
expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull();
expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull();
+ expect(localStorage.getItem(USER_ADMIN_STORAGE_KEY)).toBeNull();
});
});
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
index 8aa22d57..17cdcefc 100644
--- a/web/src/stores/authStorage.ts
+++ b/web/src/stores/authStorage.ts
@@ -17,28 +17,33 @@
export const TOKEN_STORAGE_KEY = 'token';
export const USER_STORAGE_KEY = 'rocketmq-studio-user';
+export const USER_ADMIN_STORAGE_KEY = 'rocketmq-studio-user-admin';
export interface AuthSession {
token: string | null;
user: string | null;
+ admin: boolean | null;
}
export function readAuthSession(): AuthSession {
try {
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+ const admin = localStorage.getItem(USER_ADMIN_STORAGE_KEY);
return {
token,
user: token ? localStorage.getItem(USER_STORAGE_KEY) : null,
+ admin: token && admin != null ? admin === 'true' : null,
};
} catch {
- return { token: null, user: null };
+ return { token: null, user: null, admin: null };
}
}
-export function persistAuthSession(token: string, user: string): void {
+export function persistAuthSession(token: string, user: string, admin:
boolean): void {
try {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
localStorage.setItem(USER_STORAGE_KEY, user);
+ localStorage.setItem(USER_ADMIN_STORAGE_KEY, String(admin));
} catch {
// The in-memory store remains usable when browser storage is unavailable.
}
@@ -48,6 +53,7 @@ export function clearAuthSession(): void {
try {
localStorage.removeItem(TOKEN_STORAGE_KEY);
localStorage.removeItem(USER_STORAGE_KEY);
+ localStorage.removeItem(USER_ADMIN_STORAGE_KEY);
} catch {
// The caller still clears the in-memory store.
}
diff --git a/web/src/stores/authStore.ts b/web/src/stores/authStore.ts
index 9804208f..7416a40a 100644
--- a/web/src/stores/authStore.ts
+++ b/web/src/stores/authStore.ts
@@ -21,7 +21,8 @@ import { clearAuthSession, persistAuthSession,
readAuthSession } from './authSto
interface AuthState {
user: string | null;
token: string | null;
- login: (token: string, user: string) => void;
+ admin: boolean | null;
+ login: (token: string, user: string, admin: boolean) => void;
logout: () => void;
}
@@ -30,13 +31,14 @@ const initialSession = readAuthSession();
const useAuthStore = create<AuthState>((set) => ({
user: initialSession.user,
token: initialSession.token,
- login: (token: string, user: string) => {
- persistAuthSession(token, user);
- set({ token, user });
+ admin: initialSession.admin,
+ login: (token: string, user: string, admin: boolean) => {
+ persistAuthSession(token, user, admin);
+ set({ token, user, admin });
},
logout: () => {
clearAuthSession();
- set({ token: null, user: null });
+ set({ token: null, user: null, admin: null });
},
}));