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 });
   },
 }));
 

Reply via email to