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 bb0594ffa fix(web): consolidate frontend display guards and mock 
alignment fixes (#2627)
bb0594ffa is described below

commit bb0594ffa4cd59dc46c5e96b705a1a0c3c93b9c2
Author: yyqdbngt <[email protected]>
AuthorDate: Mon Aug 31 19:47:49 2026 +0800

    fix(web): consolidate frontend display guards and mock alignment fixes 
(#2627)
    
    * fix(web): handle unavailable ACL credentials
    
    * fix(alerts): terminate exports on empty pages
    
    * fix(message): preserve epoch queue timestamps
    
    * fix(dlq): guard invalid message timestamps
    
    * fix(ai): enforce history limit in UTF-8 bytes
    
    * fix(lite-topic): guard invalid activity timestamps
    
    * fix(mock): normalize connection filters consistently
    
    * fix(mock): validate message pagination consistently
    
    * fix(mock): validate ACL user pagination
    
    * fix(mock): isolate Grafana dashboard state
    
    ---------
    
    Co-authored-by: Yue Wang <[email protected]>
---
 web/src/api/acl.ts                                 |  4 +-
 web/src/components/QueueBrowser.tsx                |  6 +--
 web/src/components/__tests__/QueueBrowser.test.tsx | 15 +++++-
 web/src/pages/instance/__tests__/AclPage.test.tsx  | 27 +++++++++++
 web/src/pages/instance/__tests__/DLQPage.test.tsx  |  8 +++-
 web/src/pages/instance/acl.tsx                     | 29 ++++++------
 web/src/pages/instance/dlq.tsx                     | 32 ++++++++-----
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  | 53 ++++++++++++++++++++++
 web/src/pages/ops/systemAlerts.tsx                 | 25 ++++++++--
 web/src/pages/studio/LiteTopic.tsx                 |  7 +--
 web/src/pages/studio/__tests__/LiteTopic.test.tsx  | 10 +++-
 web/src/services/aclService.test.ts                |  8 ++++
 web/src/services/aclService.ts                     |  3 ++
 web/src/services/connectionsService.test.ts        | 18 ++++++++
 web/src/services/connectionsService.ts             |  7 +--
 web/src/services/grafanaService.test.ts            | 12 +++++
 web/src/services/grafanaService.ts                 |  4 +-
 web/src/services/messageService.test.ts            | 10 ++++
 web/src/services/messageService.ts                 |  3 ++
 web/src/stores/aiChatHistoryStore.test.ts          | 19 ++++++++
 web/src/stores/aiChatHistoryStore.ts               |  4 +-
 21 files changed, 260 insertions(+), 44 deletions(-)

diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
index a4ac9331d..003a86922 100644
--- a/web/src/api/acl.ts
+++ b/web/src/api/acl.ts
@@ -50,8 +50,8 @@ export interface AclUserPage {
 export interface AclUser {
   id: AclEntityId;
   username: string;
-  accessKey: string;
-  secretKey: string;
+  accessKey?: string | null;
+  secretKey?: string | null;
   admin: boolean;
   clusters: string[];
   permRead?: boolean;
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index cd0e1024c..9b9c5b005 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -42,9 +42,9 @@ export interface TopicOption {
   value: string;
 }
 
-const formatTimeMs = (value: number | string) => {
-  const ts = typeof value === 'string' ? new Date(value).getTime() : value;
-  if (!ts || Number.isNaN(ts)) return '-';
+export const formatTimeMs = (value: number | string) => {
+  const ts = typeof value === 'string' ? Date.parse(value) : value;
+  if (!Number.isFinite(ts)) return '-';
   return new Date(ts).toLocaleString('zh-CN', { hour12: false });
 };
 
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index f0aa7a908..261a47411 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import type { MessageRecord, QueueOffset } from '../../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
-import { useQueueBrowser } from '../QueueBrowser';
+import { formatTimeMs, useQueueBrowser } from '../QueueBrowser';
 
 vi.mock('../../api/message', () => ({
   getQueueOffsets: vi.fn(),
@@ -89,6 +89,19 @@ function QueueBrowserProbe({ instanceId = 'instance-a' }: { 
instanceId?: string
   );
 }
 
+describe('formatTimeMs', () => {
+  it('preserves the Unix epoch timestamp', () => {
+    expect(formatTimeMs(0)).not.toBe('-');
+  });
+
+  it.each(['not-a-date', Number.NaN, Number.POSITIVE_INFINITY])(
+    'returns a placeholder for invalid timestamp %s',
+    (value) => {
+      expect(formatTimeMs(value)).toBe('-');
+    },
+  );
+});
+
 describe('QueueBrowser request ownership', () => {
   beforeEach(() => {
     vi.clearAllMocks();
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx 
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index cf5befec8..f279794e4 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -131,6 +131,33 @@ describe('ACL page', () => {
     expect(aclService.pageAclUsers).toHaveBeenCalledTimes(1);
   });
 
+  it('shows a non-copyable placeholder when an ACL user has no access key', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(aclService.pageAclUsers).mockResolvedValue({
+      items: [
+        {
+          id: 12,
+          username: 'cloud-role',
+          accessKey: null,
+          secretKey: null,
+          admin: false,
+          clusters: ['cluster-a'],
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    renderWithProviders(<AclPage />);
+
+    await user.click(await screen.findByText('用户管理'));
+    const row = await screen.findByRole('row', { name: /cloud-role/ });
+    const accessKeyCell = row.querySelectorAll('td')[1];
+
+    expect(within(accessKeyCell).getByText('-')).toBeInTheDocument();
+    
expect(within(accessKeyCell).queryByRole('button')).not.toBeInTheDocument();
+  });
+
   it('closes an ACL rule dialog when switching to another instance', async () 
=> {
     const user = userEvent.setup();
     vi.mocked(instanceService.listInstances).mockResolvedValue([
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index df86adf25..57aa6aba0 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -24,7 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, 
it, vi } from 'vite
 import type { DLQGroup, DLQGroupPage, DLQMessagePage, DLQResendResult } from 
'../../../api/message';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as messageService from '../../../services/messageService';
-import DLQPage from '../dlq';
+import DLQPage, { formatDateTime } from '../dlq';
 
 vi.mock('../../../services/messageService', () => ({
   listDLQGroups: vi.fn(),
@@ -155,6 +155,12 @@ describe('DLQ page', () => {
     vi.clearAllMocks();
   });
 
+  it('renders invalid message timestamps as unavailable without throwing', () 
=> {
+    expect(formatDateTime(Number.NaN)).toBe('-');
+    expect(formatDateTime(Number.POSITIVE_INFINITY)).toBe('-');
+    expect(formatDateTime(0)).not.toBe('-');
+  });
+
   it('loads DLQ groups through the service layer', async () => {
     renderWithProviders(<DLQPage />);
 
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index d06bf5416..bb08518bf 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -90,7 +90,9 @@ const normalizeRule = (rule: AclRule): AclRule => ({
   gmtCreate: rule.gmtCreate ?? null,
 });
 
-const normalizeUser = (user: AclUser): AclUser => ({
+type NormalizedAclUser = AclUser & { accessKey: string; secretKey: string };
+
+const normalizeUser = (user: AclUser): NormalizedAclUser => ({
   ...user,
   id: user.id ?? user.username,
   username: user.username ?? '',
@@ -125,7 +127,7 @@ const AclPageContent = ({
 
   /* ─── State ─── */
   const [rules, setRules] = useState<AclRule[]>([]);
-  const [users, setUsers] = useState<AclUser[]>([]);
+  const [users, setUsers] = useState<NormalizedAclUser[]>([]);
   const [rulesLoading, setRulesLoading] = useState(hasSelectedInstance);
   const [usersLoading, setUsersLoading] = useState(hasSelectedInstance);
   const [userPage, setUserPage] = useState(1);
@@ -154,7 +156,7 @@ const AclPageContent = ({
 
   // User modal
   const [userModalOpen, setUserModalOpen] = useState(false);
-  const [editingUser, setEditingUser] = useState<AclUser | null>(null);
+  const [editingUser, setEditingUser] = useState<NormalizedAclUser | 
null>(null);
   const [userForm] = Form.useForm();
 
   // Secret key reveal
@@ -361,8 +363,8 @@ const AclPageContent = ({
       setCredentialsByUser((prev) => ({
         ...prev,
         [userKey]: {
-          accessKey: credentials.accessKey,
-          secretKey: credentials.secretKey,
+          accessKey: credentials.accessKey ?? '',
+          secretKey: credentials.secretKey ?? '',
         },
       }));
     } catch {
@@ -383,7 +385,7 @@ const AclPageContent = ({
     setUserModalOpen(true);
   };
 
-  const openEditUserModal = (user: AclUser) => {
+  const openEditUserModal = (user: NormalizedAclUser) => {
     setEditingUser(user);
     userForm.setFieldsValue({
       username: user.username,
@@ -696,14 +698,14 @@ const AclPageContent = ({
   /* ═══════════════════════════════════════════
      Users Table
      ═══════════════════════════════════════════ */
-  const userColumns: ColumnsType<AclUser> = [
+  const userColumns: ColumnsType<NormalizedAclUser> = [
     {
       title: t('acl.username'),
       dataIndex: 'username',
       key: 'username',
       width: 200,
       sorter: (a, b) => a.username.localeCompare(b.username),
-      render: (text: string, record: AclUser) => (
+      render: (text: string, record: NormalizedAclUser) => (
         <Space size={6}>
           <User size={14} color="#8c8c8c" weight="fill" />
           <span style={{ fontWeight: 500 }}>{text}</span>
@@ -722,16 +724,17 @@ const AclPageContent = ({
       key: 'accessKey',
       width: 220,
       sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
-      render: (text: string, record: AclUser) => {
+      render: (text: string, record: NormalizedAclUser) => {
         const revealed = revealedKeys.has(record.id);
         const fullAccessKey = credentialsByUser[String(record.id)]?.accessKey 
?? text;
+        const displayedAccessKey = revealed && fullAccessKey ? fullAccessKey : 
text || '-';
         return (
           <Space size={8}>
             <Typography.Text
-              copyable={{ text: fullAccessKey }}
+              copyable={fullAccessKey ? { text: fullAccessKey } : false}
               style={{ fontFamily: 'monospace', fontSize: 14 }}
             >
-              {revealed ? fullAccessKey : text}
+              {displayedAccessKey}
             </Typography.Text>
           </Space>
         );
@@ -742,7 +745,7 @@ const AclPageContent = ({
       dataIndex: 'secretKey',
       key: 'secretKey',
       width: 240,
-      render: (_: string, record: AclUser) => {
+      render: (_: string, record: NormalizedAclUser) => {
         const revealed = revealedKeys.has(record.id);
         const secret = credentialsByUser[String(record.id)]?.secretKey;
         return (
@@ -809,7 +812,7 @@ const AclPageContent = ({
       title: t('common.actions'),
       key: 'userActions',
       width: 160,
-      render: (_: unknown, record: AclUser) => (
+      render: (_: unknown, record: NormalizedAclUser) => (
         <Flex gap={6}>
           <Button
             size="small"
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 5cb14544e..f97ebda50 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -78,9 +78,9 @@ const getErrorMessage = (error: unknown, fallback: string): 
string => {
   return fallback;
 };
 
-const formatDateTime = (iso?: string | null): string => {
-  if (!iso) return '-';
-  const d = new Date(iso);
+export const formatDateTime = (value?: string | number | null): string => {
+  if (value === undefined || value === null || value === '') return '-';
+  const d = new Date(value);
   if (Number.isNaN(d.getTime())) return '-';
   const pad = (n: number) => String(n).padStart(2, '0');
   return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} 
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
@@ -534,9 +534,7 @@ const DLQPage = () => {
       key: 'storeTime',
       width: 160,
       render: (storeTime: number) => (
-        <Text style={{ fontFamily: 'monospace', fontSize: 14 }}>
-          {formatDateTime(new Date(storeTime).toISOString())}
-        </Text>
+        <Text style={{ fontFamily: 'monospace', fontSize: 14 
}}>{formatDateTime(storeTime)}</Text>
       ),
     },
     {
@@ -555,7 +553,7 @@ const DLQPage = () => {
       ellipsis: true,
       render: (body: string | null) => (
         <Text type="secondary" style={{ fontSize: 14 }}>
-          {body && body.length > 80 ? `${body.slice(0, 80)}…` : body ?? '-'}
+          {body && body.length > 80 ? `${body.slice(0, 80)}…` : (body ?? '-')}
         </Text>
       ),
     },
@@ -791,7 +789,10 @@ const DLQPage = () => {
             >
               <Space size={24} wrap>
                 <div>
-                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                  <Text
+                    type="secondary"
+                    style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+                  >
                     DLQ Topic
                   </Text>
                   <Text copyable style={{ fontFamily: 'monospace' }}>
@@ -799,17 +800,26 @@ const DLQPage = () => {
                   </Text>
                 </div>
                 <div>
-                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                  <Text
+                    type="secondary"
+                    style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+                  >
                     死信数量
                   </Text>
-                  <Text strong style={{ color: detailGroup.messageCount > 0 ? 
'#fa8c16' : undefined }}>
+                  <Text
+                    strong
+                    style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' : 
undefined }}
+                  >
                     {detailGroup.statsAvailable === false
                       ? '不可用'
                       : detailGroup.messageCount.toLocaleString()}
                   </Text>
                 </div>
                 <div>
-                  <Text type="secondary" style={{ fontSize: 14, display: 
'block', marginBottom: 4 }}>
+                  <Text
+                    type="secondary"
+                    style={{ fontSize: 14, display: 'block', marginBottom: 4 }}
+                  >
                     最近入队时间
                   </Text>
                   <Text style={{ fontFamily: 'monospace' }}>
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 41cd1fdb2..9ae4fe41a 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -10,7 +10,9 @@ import { fireEvent, render, screen, waitFor } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
 import { formatUtcDateTime } from '../../../utils/format';
+import { downloadCsv } from '../../../utils/download';
 import {
   acknowledgeAlert,
   createAlertSilence,
@@ -35,6 +37,12 @@ vi.mock('../../../services/opsService', () => ({
   deleteAlertSilence: vi.fn(),
 }));
 
+vi.mock('../../../utils/download', async () => {
+  const actual =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return { ...actual, downloadCsv: vi.fn() };
+});
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -89,6 +97,51 @@ describe('SystemAlertsPage', () => {
     vi.mocked(listAlertSilences).mockResolvedValue([]);
   });
 
+  it('finishes an export when a later page is empty after the result set 
shrinks', async () => {
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+    vi.mocked(listSystemAlertsPage)
+      .mockResolvedValueOnce({
+        items: [
+          {
+            id: 1,
+            level: 'error',
+            title: 'Broker unavailable',
+            description: 'broker a',
+            time: '2026-08-10 01:00',
+            acknowledged: false,
+          },
+        ],
+        total: 1,
+        page: 1,
+        size: 20,
+      })
+      .mockResolvedValueOnce({
+        items: [
+          {
+            id: 1,
+            level: 'error',
+            title: 'Broker unavailable',
+            description: 'broker a',
+            time: '2026-08-10 01:00',
+            acknowledged: false,
+          },
+        ],
+        total: 200,
+        page: 1,
+        size: 100,
+      })
+      .mockResolvedValueOnce({ items: [], total: 200, page: 2, size: 100 });
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText('Broker unavailable');
+
+    await user.click(screen.getByRole('button', { name: 'Export CSV' }));
+
+    await waitFor(() => expect(downloadCsv).toHaveBeenCalledTimes(1));
+    expect(listSystemAlertsPage).toHaveBeenCalledTimes(3);
+    expect(listSystemAlertsPage).toHaveBeenLastCalledWith({ page: 2, pageSize: 
100 });
+  });
+
   it('renders an alert with an unknown backend level', async () => {
     vi.mocked(listSystemAlertsPage).mockReset();
     vi.mocked(listSystemAlertsPage).mockResolvedValue({
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index 7438485b0..513b50d81 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -60,6 +60,9 @@ import { buildCsv, downloadCsv, type CsvColumn } from 
'../../utils/download';
 
 const { Text } = Typography;
 
+const ALERT_EXPORT_PAGE_SIZE = 100;
+const ALERT_EXPORT_MAX_PAGES = 10_000;
+
 const normalizeAlertLevel = (level?: string | null) => (level ?? 
'').toLowerCase();
 const formatAlertTransition = (
   transition: SystemAlert['transition'] | undefined,
@@ -256,11 +259,27 @@ const SystemAlertsPage = () => {
     setExporting(true);
     try {
       const query = currentQuery();
-      const first = await listSystemAlertsPage({ ...query, page: 1, pageSize: 
100 });
+      const first = await listSystemAlertsPage({
+        ...query,
+        page: 1,
+        pageSize: ALERT_EXPORT_PAGE_SIZE,
+      });
       const rows = [...first.items];
-      for (let currentPage = 2; rows.length < first.total; currentPage += 1) {
-        const result = await listSystemAlertsPage({ ...query, page: 
currentPage, pageSize: 100 });
+      let expectedTotal = first.total;
+      let currentPage = 2;
+      while (rows.length < expectedTotal) {
+        if (currentPage > ALERT_EXPORT_MAX_PAGES) {
+          throw new Error('System alert export exceeded the pagination limit');
+        }
+        const result = await listSystemAlertsPage({
+          ...query,
+          page: currentPage,
+          pageSize: ALERT_EXPORT_PAGE_SIZE,
+        });
+        if (result.items.length === 0) break;
         rows.push(...result.items);
+        expectedTotal = Math.min(expectedTotal, result.total);
+        currentPage += 1;
       }
       downloadCsv(
         `rocketmq-system-alerts-${new Date().toISOString().slice(0, 10)}.csv`,
diff --git a/web/src/pages/studio/LiteTopic.tsx 
b/web/src/pages/studio/LiteTopic.tsx
index 652d3b48f..b00a4b947 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -69,9 +69,10 @@ const formatDuration = (ms: number | undefined | null): 
string => {
   return `${(ms / 3600000).toFixed(1)}h`;
 };
 
-const formatTime = (timestamp: number | undefined | null): string => {
-  if (!timestamp) return '-';
-  return new Date(timestamp).toLocaleString();
+export const formatTime = (timestamp: number | undefined | null): string => {
+  if (timestamp == null || !Number.isFinite(timestamp)) return '-';
+  const date = new Date(timestamp);
+  return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
 };
 
 const getProgressStatus = (percent: number): 'exception' | 'active' | 'normal' 
=> {
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx 
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index eac0b4abe..0dc12a554 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -22,7 +22,7 @@ import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
 import { downloadCsv } from '../../../utils/download';
-import LiteTopic from '../LiteTopic';
+import LiteTopic, { formatTime } from '../LiteTopic';
 
 const apiMocks = vi.hoisted(() => ({
   queryLiteTopicCapability: vi.fn(),
@@ -78,6 +78,14 @@ const createDeferred = <T,>() => {
   return { promise, resolve, reject };
 };
 
+describe('LiteTopic time formatting', () => {
+  it('preserves epoch timestamps and rejects invalid provider values', () => {
+    expect(formatTime(0)).toBe(new Date(0).toLocaleString());
+    expect(formatTime(Number.POSITIVE_INFINITY)).toBe('-');
+    expect(formatTime(Number.MAX_VALUE)).toBe('-');
+  });
+});
+
 const createQuota = (currentTopicCount: number): LiteTopicQuota => ({
   currentTopicCount,
   maxTopicCount: 100,
diff --git a/web/src/services/aclService.test.ts 
b/web/src/services/aclService.test.ts
index 98d180c5f..6aae08c4a 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -23,6 +23,7 @@ import {
   examineBrokerClusterAclConfig,
   listAclRules,
   listAclUsers,
+  pageAclUsers,
   updateAclRule,
   updateAclUser,
 } from './aclService';
@@ -81,6 +82,13 @@ describe('ACL service mock data', () => {
     expect(second[0]).not.toBe(first[0]);
   });
 
+  it('validates ACL user pagination like the backend', async () => {
+    await expect(pageAclUsers({ page: 0, pageSize: 20 
})).rejects.toThrow('page must be >= 1');
+    await expect(pageAclUsers({ page: 1, pageSize: 101 })).rejects.toThrow(
+      'pageSize must be between 1 and 100',
+    );
+  });
+
   it('copies ACL user arrays on create and update', async () => {
     const clusters = ['rmq-created'];
     const created = await createAclUser({
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index ad1042519..877f6c182 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -96,6 +96,9 @@ export async function pageAclUsers(params: {
   pageSize: number;
 }): Promise<AclUserPage> {
   if (isMockMode()) {
+    if (params.page < 1 || params.pageSize < 1 || params.pageSize > 100) {
+      throw new Error('page must be >= 1 and pageSize must be between 1 and 
100');
+    }
     const users = await listAclUsers(params);
     const from = (params.page - 1) * params.pageSize;
     return {
diff --git a/web/src/services/connectionsService.test.ts 
b/web/src/services/connectionsService.test.ts
index 625eb02d7..7e703d645 100644
--- a/web/src/services/connectionsService.test.ts
+++ b/web/src/services/connectionsService.test.ts
@@ -77,4 +77,22 @@ describe('connectionsService mock connections', () => {
       listConnections({ namesrvAddr: '10.101.2.1:9876', clusterId: 'ns-pre' }),
     ).resolves.toEqual([]);
   });
+
+  it('normalizes optional filters like the real endpoint', async () => {
+    const padded = await listConnections({
+      namesrvAddr: '10.101.2.1:9876',
+      clusterId: ' ns-prod ',
+      type: ' Consumer ',
+    });
+    const blank = await listConnections({
+      namesrvAddr: '10.101.2.1:9876',
+      clusterId: '  ',
+      type: '  ',
+    });
+
+    expect(padded).not.toHaveLength(0);
+    expect(padded.every((connection) => connection.clusterName === 
'ns-prod')).toBe(true);
+    expect(padded.every((connection) => connection.type === 
'Consumer')).toBe(true);
+    expect(blank).not.toHaveLength(0);
+  });
 });
diff --git a/web/src/services/connectionsService.ts 
b/web/src/services/connectionsService.ts
index 44eba5a96..9630fe79d 100644
--- a/web/src/services/connectionsService.ts
+++ b/web/src/services/connectionsService.ts
@@ -16,10 +16,11 @@ export async function listConnections(params?: 
ClientConnectionQuery): Promise<C
     const instanceCluster = mockClientClusterByNamesrvAddr[namesrvAddr];
     if (!instanceCluster) return [];
 
+    const clusterId = params?.clusterId?.trim();
+    const type = params?.type?.trim();
     let result = mockClients.filter((connection) => connection.clusterName === 
instanceCluster);
-    if (params?.clusterId)
-      result = result.filter((connection) => connection.clusterName === 
params.clusterId);
-    if (params?.type) result = result.filter((c) => c.type === params.type);
+    if (clusterId) result = result.filter((connection) => 
connection.clusterName === clusterId);
+    if (type) result = result.filter((connection) => connection.type === type);
     return (result as unknown as ClientConnection[]).map(copyConnection);
   }
   return connApi.listConnections(params);
diff --git a/web/src/services/grafanaService.test.ts 
b/web/src/services/grafanaService.test.ts
index 8bf35b7d8..ab39689c3 100644
--- a/web/src/services/grafanaService.test.ts
+++ b/web/src/services/grafanaService.test.ts
@@ -35,6 +35,18 @@ describe('grafanaService', () => {
     expect(model.uid).toBe('rocketmq-overview');
   });
 
+  it('does not expose mutable mock dashboard state', async () => {
+    const dashboards = await listGrafanaDashboards();
+    dashboards[0].tags.push('mutated');
+    const model = await getGrafanaDashboard('rocketmq-overview');
+    (model.panels as Array<Record<string, unknown>>)[0].title = 'mutated';
+
+    const freshDashboards = await listGrafanaDashboards();
+    const freshModel = await getGrafanaDashboard('rocketmq-overview');
+    expect(freshDashboards[0].tags).not.toContain('mutated');
+    expect((freshModel.panels as Array<Record<string, 
unknown>>)[0].title).toBe('Messages In TPS');
+  });
+
   it('throws for an unknown dashboard uid in mock mode', async () => {
     await expect(getGrafanaDashboard('nope')).rejects.toThrow();
   });
diff --git a/web/src/services/grafanaService.ts 
b/web/src/services/grafanaService.ts
index 43120defb..7d2080a41 100644
--- a/web/src/services/grafanaService.ts
+++ b/web/src/services/grafanaService.ts
@@ -12,7 +12,7 @@ export async function listGrafanaDashboards(): 
Promise<GrafanaDashboardInfo[]> {
       uid,
       title,
       description,
-      tags,
+      tags: [...tags],
     }));
   }
   return metricsApi.listGrafanaDashboards();
@@ -24,7 +24,7 @@ export async function getGrafanaDashboard(uid: string): 
Promise<Record<string, u
     if (!found) {
       throw new Error(`Grafana dashboard not found: ${uid}`);
     }
-    return found.model;
+    return structuredClone(found.model);
   }
   return metricsApi.getGrafanaDashboard(uid);
 }
diff --git a/web/src/services/messageService.test.ts 
b/web/src/services/messageService.test.ts
index ff1113d6a..716392f7f 100644
--- a/web/src/services/messageService.test.ts
+++ b/web/src/services/messageService.test.ts
@@ -20,6 +20,7 @@ import {
   consumeMessageDirectly,
   getMessageTrace,
   listDLQGroups,
+  queryMessagePage,
   queryMessages,
 } from './messageService';
 
@@ -54,6 +55,15 @@ describe('message service mock data', () => {
     expect(messages.map((message) => 
message.msgId)).toEqual(['AC1E0A6400002A9F0000000001A3F7C2']);
   });
 
+  it('validates mock message pagination like the real endpoint', async () => {
+    await expect(queryMessagePage({ topic: 'order-create', page: 0 
})).rejects.toThrow(
+      'page must be positive',
+    );
+    await expect(
+      queryMessagePage({ topic: 'order-create', page: 1, pageSize: 201 }),
+    ).rejects.toThrow('pageSize must be between 1 and 200');
+  });
+
   it('returns copied message trace rows', async () => {
     const first = await getMessageTrace('AC1E0A6400002A9F0000000001A3F2B1');
     expect(first?.nodes[0].title).toBe('Producer 发送');
diff --git a/web/src/services/messageService.ts 
b/web/src/services/messageService.ts
index 837aeebda..ebfba8b54 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -61,6 +61,9 @@ export async function queryMessagePage(
     const items = await queryMessages(params);
     const page = params.page ?? 1;
     const pageSize = params.pageSize ?? 50;
+    if (page < 1 || pageSize < 1 || pageSize > 200) {
+      throw new Error('page must be positive and pageSize must be between 1 
and 200');
+    }
     const from = Math.min((page - 1) * pageSize, items.length);
     return {
       items: items.slice(from, from + pageSize),
diff --git a/web/src/stores/aiChatHistoryStore.test.ts 
b/web/src/stores/aiChatHistoryStore.test.ts
index 656f7bf58..1deffbcea 100644
--- a/web/src/stores/aiChatHistoryStore.test.ts
+++ b/web/src/stores/aiChatHistoryStore.test.ts
@@ -160,6 +160,25 @@ describe('aiChatHistoryStore', () => {
     );
   });
 
+  it('measures the persisted history limit in UTF-8 bytes', async () => {
+    const { MAX_AI_CHAT_HISTORY_BYTES } = await import('./aiChatHistoryStore');
+    const store = await loadStore();
+    for (let index = 0; index < 20; index += 1) {
+      store
+        .getState()
+        .setMessages('real', `conversation-${index}`, [
+          { id: `message-${index}`, role: 'user', text: '界'.repeat(16 * 1024) 
},
+        ]);
+    }
+
+    const serialized = JSON.stringify({
+      conversations: store.getState().histories.real.conversations,
+    });
+    expect(new 
TextEncoder().encode(serialized).byteLength).toBeLessThanOrEqual(
+      MAX_AI_CHAT_HISTORY_BYTES,
+    );
+  });
+
   it('clears pending throttled persistence when histories are cleared', async 
() => {
     vi.useFakeTimers();
     const store = await loadStore();
diff --git a/web/src/stores/aiChatHistoryStore.ts 
b/web/src/stores/aiChatHistoryStore.ts
index d9a6e60be..4e0056252 100644
--- a/web/src/stores/aiChatHistoryStore.ts
+++ b/web/src/stores/aiChatHistoryStore.ts
@@ -83,6 +83,8 @@ const boundMessageFields = (messages: AiChatMessage[]): 
AiChatMessage[] =>
     thinking: truncateMessageField(message.thinking),
   }));
 
+const utf8ByteLength = (value: string): number => new 
TextEncoder().encode(value).byteLength;
+
 const restoreMessages = (messages: unknown): AiChatMessage[] =>
   (Array.isArray(messages) ? messages : [])
     .filter(isRecord)
@@ -110,7 +112,7 @@ const limitHistorySize = (history: AiChatHistory): 
AiChatHistory => {
   }));
   while (
     conversations.length > 0 &&
-    JSON.stringify({ conversations }).length > MAX_AI_CHAT_HISTORY_BYTES
+    utf8ByteLength(JSON.stringify({ conversations })) > 
MAX_AI_CHAT_HISTORY_BYTES
   ) {
     const oldestIndex = conversations.length - 1;
     const oldest = conversations[oldestIndex];

Reply via email to