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 604d8bec refactor: harden mock service data handling (#631)
604d8bec is described below

commit 604d8bec07668f5a6ae05db604a7cbfcf85062f8
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 06:02:10 2026 -0700

    refactor: harden mock service data handling (#631)
    
    * [Studio] Harden mock service data handling
    
    * [Studio] Route consumer page through service layer
    
    * [Studio] Route cluster page through service layer
---
 web/src/pages/cluster/index.tsx             | 151 ++++++++++++++++++++++------
 web/src/pages/instance/consumer.tsx         | 129 ++++++++++++++++++++----
 web/src/services/aclService.test.ts         | 102 +++++++++++++++++++
 web/src/services/aclService.ts              |  32 ++++--
 web/src/services/clusterService.test.ts     |  64 ++++++++++++
 web/src/services/clusterService.ts          |  44 ++++----
 web/src/services/connectionsService.test.ts |  44 ++++++++
 web/src/services/connectionsService.ts      |   6 +-
 web/src/services/consumerService.test.ts    |  83 +++++++++++++++
 web/src/services/consumerService.ts         |  40 ++++++--
 web/src/services/dashboardService.test.ts   |  48 +++++++++
 web/src/services/dashboardService.ts        |  11 +-
 web/src/services/instanceService.test.ts    |  71 +++++++++++++
 web/src/services/instanceService.ts         |  10 +-
 web/src/services/messageService.test.ts     |  67 ++++++++++++
 web/src/services/messageService.ts          |  21 +++-
 web/src/services/opsService.test.ts         | 122 ++++++++++++++++++++++
 web/src/services/opsService.ts              |  42 ++++++--
 web/src/services/topicService.test.ts       |  71 +++++++++++++
 web/src/services/topicService.ts            |  19 ++--
 20 files changed, 1064 insertions(+), 113 deletions(-)

diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 5fdcf63e..daa241f1 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -46,20 +46,51 @@ import {
 import { Cpu, HardDrives, Globe } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
-import clusters, {
-  type BrokerInfo,
-  type ProxyInfo,
-  type NameServerInfo,
-  type ClusterConfig,
-  type ClusterInfo,
-} from '../../mock/clusters';
+import type {
+  BrokerInfo,
+  ProxyInfo,
+  NameServerInfo,
+  ClusterConfig,
+  ClusterInfo,
+} from '../../api/cluster';
+import {
+  createNameServer,
+  listClusters,
+  restartProxy,
+  updateClusterConfig,
+  updateNameServer,
+} from '../../services/clusterService';
 
 const { Text } = Typography;
 
+const buildBrokerTpsMap = (
+  clusters: ClusterInfo[],
+): Record<string, { tpsIn: number; tpsOut: number }> => {
+  const result: Record<string, { tpsIn: number; tpsOut: number }> = {};
+  clusters.forEach((cluster) =>
+    cluster.brokers.forEach((broker) => {
+      result[broker.addr] = { tpsIn: broker.tpsIn, tpsOut: broker.tpsOut };
+    }),
+  );
+  return result;
+};
+
+const buildProxyConnMap = (clusters: ClusterInfo[]): Record<string, number> => 
{
+  const result: Record<string, number> = {};
+  clusters.forEach((cluster) =>
+    cluster.proxies.forEach((proxy) => {
+      result[proxy.addr] = proxy.connections;
+    }),
+  );
+  return result;
+};
+
 // ─── Page 
─────────────────────────────────────────────────────────────────────
 
 const ClusterPage = () => {
   const { t } = useLang();
+  const [clusters, setClusters] = useState<ClusterInfo[]>([]);
+  const [loading, setLoading] = useState(true);
   const [nsSearch, setNsSearch] = useState('');
   const [brokerSearch, setBrokerSearch] = useState('');
   const [brokerNsClusterFilter, setBrokerNsClusterFilter] = 
useState<string>('');
@@ -74,28 +105,51 @@ const ClusterPage = () => {
 
   // ─── Auto-refresh TPS / connections every 2s ──────────────────────────────
   const [autoRefresh, setAutoRefresh] = useState(true);
-  // Initialize from mock base values
-  const initBrokerTps = (): Record<string, { tpsIn: number; tpsOut: number }> 
=> {
-    const m: Record<string, { tpsIn: number; tpsOut: number }> = {};
-    clusters.forEach((c) =>
-      c.brokers.forEach((b) => {
-        m[b.addr] = { tpsIn: b.tpsIn, tpsOut: b.tpsOut };
-      }),
-    );
-    return m;
+  const [brokerTpsMap, setBrokerTpsMap] = useState<
+    Record<string, { tpsIn: number; tpsOut: number }>
+  >({});
+  const [proxyConnMap, setProxyConnMap] = useState<Record<string, number>>({});
+
+  const applyClusters = (nextClusters: ClusterInfo[]) => {
+    setClusters(nextClusters);
+    setBrokerTpsMap(buildBrokerTpsMap(nextClusters));
+    setProxyConnMap(buildProxyConnMap(nextClusters));
   };
-  const initProxyConn = (): Record<string, number> => {
-    const m: Record<string, number> = {};
-    clusters.forEach((c) =>
-      c.proxies.forEach((p) => {
-        m[p.addr] = p.connections;
-      }),
-    );
-    return m;
+
+  const refreshClusters = async (showLoading = false) => {
+    if (showLoading) setLoading(true);
+    try {
+      applyClusters(await listClusters());
+    } catch {
+      message.error(t('common.fetchDataFailed'));
+    } finally {
+      if (showLoading) setLoading(false);
+    }
   };
 
-  const [brokerTpsMap, setBrokerTpsMap] = useState(initBrokerTps);
-  const [proxyConnMap, setProxyConnMap] = useState(initProxyConn);
+  useEffect(() => {
+    let cancelled = false;
+
+    const fetchClusters = async () => {
+      try {
+        const nextClusters = await listClusters();
+        if (!cancelled) {
+          setClusters(nextClusters);
+          setBrokerTpsMap(buildBrokerTpsMap(nextClusters));
+          setProxyConnMap(buildProxyConnMap(nextClusters));
+        }
+      } catch {
+        if (!cancelled) message.error(t('common.fetchDataFailed'));
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    };
+
+    void fetchClusters();
+    return () => {
+      cancelled = true;
+    };
+  }, [t]);
 
   useEffect(() => {
     if (!autoRefresh) return;
@@ -133,7 +187,7 @@ const ClusterPage = () => {
     }, 2000);
 
     return () => clearInterval(timer);
-  }, [autoRefresh]);
+  }, [autoRefresh, clusters]);
 
   // Broker config handler
   const handleConfigOpen = (cluster: ClusterInfo) => {
@@ -372,6 +426,7 @@ const ClusterPage = () => {
           <Table
             columns={brokerColumns}
             dataSource={allBrokers}
+            loading={loading}
             rowKey="addr"
             pagination={{ pageSize: 20 }}
             size="small"
@@ -384,7 +439,26 @@ const ClusterPage = () => {
             open={configModalOpen}
             onCancel={() => setConfigModalOpen(false)}
             onOk={() => {
-              configForm.validateFields().then(() => {
+              configForm.validateFields().then(async (values) => {
+                if (!selectedCluster) return;
+                const { maxMessageSizeMB, ...configValues } = values;
+                const nextConfig: ClusterConfig = {
+                  ...selectedCluster.config,
+                  ...configValues,
+                  maxMessageSize: maxMessageSizeMB * 1048576,
+                };
+                await updateClusterConfig({
+                  id: selectedCluster.id,
+                  ...nextConfig,
+                });
+                setClusters((prev) =>
+                  prev.map((cluster) =>
+                    cluster.id === selectedCluster.id
+                      ? { ...cluster, config: nextConfig }
+                      : cluster,
+                  ),
+                );
+                setSelectedCluster((prev) => (prev ? { ...prev, config: 
nextConfig } : prev));
                 message.success(t('cluster.configUpdated'));
                 setConfigModalOpen(false);
               });
@@ -583,6 +657,7 @@ const ClusterPage = () => {
           <Table
             columns={clusterColumns}
             dataSource={filteredClusters}
+            loading={loading}
             rowKey="id"
             pagination={{ pageSize: 20 }}
             size="small"
@@ -608,7 +683,7 @@ const ClusterPage = () => {
   // ─── Tab 3: Proxy 管理 (flat table) ────────────────────────────────────────
 
   function renderProxyTab() {
-    type ProxyRow = ProxyInfo & { clusterName: string; nsClusterName: string };
+    type ProxyRow = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
 
     const allProxies: ProxyRow[] = clusters
       .filter((c) => c.proxies.length > 0)
@@ -622,6 +697,7 @@ const ClusterPage = () => {
           .map((p) => ({
             ...p,
             connections: proxyConnMap[p.addr] ?? p.connections,
+            clusterId: c.id,
             clusterName: c.name,
             nsClusterName: c.nsClusterName,
           })),
@@ -718,8 +794,11 @@ const ClusterPage = () => {
                   content: t('cluster.restartProxyConfirm', { addr: 
record.addr }),
                   okText: t('common.confirm'),
                   cancelText: t('common.cancel'),
-                  onOk: () =>
-                    message.success(t('cluster.restartProxySubmitted', { addr: 
record.addr })),
+                  onOk: async () => {
+                    await restartProxy({ clusterId: record.clusterId, addr: 
record.addr });
+                    await refreshClusters();
+                    message.success(t('cluster.restartProxySubmitted', { addr: 
record.addr }));
+                  },
                 });
               }}
             >
@@ -754,6 +833,7 @@ const ClusterPage = () => {
           <Table
             columns={proxyColumns}
             dataSource={allProxies}
+            loading={loading}
             rowKey={(r) => `${r.clusterName}-${r.addr}`}
             pagination={{ pageSize: 20 }}
             size="small"
@@ -811,14 +891,21 @@ const ClusterPage = () => {
         open={nsModalOpen}
         onCancel={() => setNsModalOpen(false)}
         onOk={() => {
-          nsForm.validateFields().then((values: Record<string, string>) => {
+          nsForm.validateFields().then(async (values: Record<string, string>) 
=> {
             if (nsModalMode === 'create') {
+              await createNameServer({ clusterId: values.clusterId, addr: 
values.addr });
               message.success(`${t('cluster.nsCreated')}: ${values.addr}`);
             } else {
+              await updateNameServer({
+                clusterId: values.clusterId,
+                addr: values.addr,
+                newAddr: values.newAddr,
+              });
               message.success(
                 `${t('cluster.nsUpdated')}: ${values.addr}${values.newAddr ? ` 
→ ${values.newAddr}` : ''}`,
               );
             }
+            await refreshClusters();
             setNsModalOpen(false);
           });
         }}
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index d7cec904..51908399 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import {
   Table,
   Card,
@@ -59,13 +59,21 @@ import type { Dayjs } from 'dayjs';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
 import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
-import { mockConsumerGroups, mockQueueProgress, mockSubscriptions } from 
'../../mock/consumers';
 import type {
   ConsumerGroup,
   ConsumerInstance,
   QueueProgress,
   SubscriptionEntry,
-} from '../../mock/consumers';
+} from '../../api/metadata';
+import {
+  batchDeleteConsumerGroups,
+  createConsumerGroup,
+  deleteConsumerGroup,
+  getConsumerProgress,
+  getConsumerSubscriptions,
+  listConsumerGroups,
+  resetConsumerOffset,
+} from '../../services/consumerService';
 
 const { Text } = Typography;
 
@@ -112,7 +120,8 @@ const formatDateTime = (dateStr: string): string => {
    ═══════════════════════════════════════════ */
 const ConsumerPage = () => {
   const { t } = useLang();
-  const [groups, setGroups] = useState<ConsumerGroup[]>(mockConsumerGroups);
+  const [groups, setGroups] = useState<ConsumerGroup[]>([]);
+  const [loading, setLoading] = useState(true);
   const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
   const [search, setSearch] = useState('');
   const [modeFilter, setModeFilter] = useState<string>('ALL');
@@ -125,6 +134,56 @@ const ConsumerPage = () => {
   const [resetModalOpen, setResetModalOpen] = useState(false);
   const [resetGroup, setResetGroup] = useState<ConsumerGroup | null>(null);
   const [resetTime, setResetTime] = useState<Dayjs>(dayjs().subtract(3, 
'hour'));
+  const [subscriptionsByGroup, setSubscriptionsByGroup] = useState<
+    Record<string, SubscriptionEntry[]>
+  >({});
+  const [progressByGroup, setProgressByGroup] = useState<Record<string, 
QueueProgress[]>>({});
+
+  useEffect(() => {
+    let cancelled = false;
+
+    const fetchGroups = async () => {
+      try {
+        const nextGroups = await listConsumerGroups();
+        if (!cancelled) setGroups(nextGroups);
+      } catch {
+        if (!cancelled) message.error('消费组列表加载失败,请稍后重试');
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    };
+
+    void fetchGroups();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const loadSubscriptions = useCallback(
+    async (groupName: string) => {
+      if (subscriptionsByGroup[groupName]) return;
+      try {
+        const subscriptions = await getConsumerSubscriptions(groupName);
+        setSubscriptionsByGroup((prev) => ({ ...prev, [groupName]: 
subscriptions }));
+      } catch {
+        message.error(`消费组 ${groupName} 订阅关系加载失败`);
+      }
+    },
+    [subscriptionsByGroup],
+  );
+
+  const loadProgress = useCallback(
+    async (groupName: string) => {
+      if (progressByGroup[groupName]) return;
+      try {
+        const progress = await getConsumerProgress(groupName);
+        setProgressByGroup((prev) => ({ ...prev, [groupName]: progress }));
+      } catch {
+        message.error(`消费组 ${groupName} 消费进度加载失败`);
+      }
+    },
+    [progressByGroup],
+  );
 
   /* ─── Filtered & sorted data ─── */
   const filtered = useMemo(() => {
@@ -152,8 +211,15 @@ const ConsumerPage = () => {
   const openModal = (group: ConsumerGroup) => {
     setSelectedGroup(group);
     setModalOpen(true);
+    void loadSubscriptions(group.name);
+    void loadProgress(group.name);
   };
 
+  const selectedSubscriptions = selectedGroup
+    ? (subscriptionsByGroup[selectedGroup.name] ?? [])
+    : [];
+  const selectedProgress = selectedGroup ? 
(progressByGroup[selectedGroup.name] ?? []) : [];
+
   /* ═══════════════════════════════════════════
      Main Table Columns
      ═══════════════════════════════════════════ */
@@ -279,7 +345,12 @@ const ConsumerPage = () => {
                 okText: '删除',
                 okButtonProps: { danger: true },
                 cancelText: '取消',
-                onOk: () => message.success(`消费组 ${record.name} 已删除`),
+                onOk: async () => {
+                  await deleteConsumerGroup(record.name);
+                  setGroups((prev) => prev.filter((group) => group.name !== 
record.name));
+                  setSelectedRowKeys((prev) => prev.filter((key) => key !== 
record.name));
+                  message.success(`消费组 ${record.name} 已删除`);
+                },
               });
             }}
           >
@@ -517,8 +588,10 @@ const ConsumerPage = () => {
                   okText: '删除',
                   okButtonProps: { danger: true },
                   cancelText: '取消',
-                  onOk: () => {
-                    setGroups((prev) => prev.filter((g) => 
!selectedRowKeys.includes(g.name)));
+                  onOk: async () => {
+                    const names = selectedRowKeys.map(String);
+                    await batchDeleteConsumerGroups(names);
+                    setGroups((prev) => prev.filter((g) => 
!names.includes(g.name)));
                     message.success(`已删除 ${selectedRowKeys.length} 个 Group`);
                     setSelectedRowKeys([]);
                   },
@@ -552,6 +625,7 @@ const ConsumerPage = () => {
         <Table
           columns={columns}
           dataSource={filtered}
+          loading={loading}
           rowKey="name"
           rowSelection={{
             selectedRowKeys,
@@ -564,11 +638,14 @@ const ConsumerPage = () => {
           }}
           size="small"
           expandable={{
+            onExpand: (expanded, record) => {
+              if (expanded) void loadSubscriptions(record.name);
+            },
             expandedRowRender: (record) => (
               <div style={{ padding: '8px 0' }}>
                 <Table
                   columns={subscriptionSubColumns}
-                  dataSource={mockSubscriptions[record.name] || []}
+                  dataSource={subscriptionsByGroup[record.name] ?? []}
                   rowKey="topic"
                   pagination={false}
                   size="small"
@@ -748,7 +825,7 @@ const ConsumerPage = () => {
                       </Flex>
                       <Table
                         columns={subscriptionSubColumns}
-                        dataSource={mockSubscriptions[selectedGroup.name] || 
[]}
+                        dataSource={selectedSubscriptions}
                         rowKey="topic"
                         pagination={false}
                         size="small"
@@ -800,17 +877,11 @@ const ConsumerPage = () => {
                       <Space size={24}>
                         <Space size={4}>
                           <Text type="secondary">总 Broker 数:</Text>
-                          <Text strong>
-                            {
-                              new Set(
-                                (mockQueueProgress[selectedGroup.name] || 
[]).map((q) => q.broker),
-                              ).size
-                            }
-                          </Text>
+                          <Text strong>{new Set(selectedProgress.map((q) => 
q.broker)).size}</Text>
                         </Space>
                         <Space size={4}>
                           <Text type="secondary">总 Queue 数:</Text>
-                          <Text strong>{(mockQueueProgress[selectedGroup.name] 
|| []).length}</Text>
+                          <Text strong>{selectedProgress.length}</Text>
                         </Space>
                         <Space size={4}>
                           <Text type="secondary">总堆积:</Text>
@@ -828,7 +899,7 @@ const ConsumerPage = () => {
 
                     <Table
                       columns={queueColumns}
-                      dataSource={mockQueueProgress[selectedGroup.name] || []}
+                      dataSource={selectedProgress}
                       rowKey={(r) => `${r.broker}-${r.queueId}`}
                       pagination={false}
                       size="small"
@@ -867,7 +938,21 @@ const ConsumerPage = () => {
                 content: `将创建消费组 "${values.name}",命名空间: ${values.namespace || 
'default'}`,
                 okText: '确认创建',
                 cancelText: '取消',
-                onOk: () => {
+                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();
@@ -976,8 +1061,12 @@ const ConsumerPage = () => {
           setResetModalOpen(false);
           setResetGroup(null);
         }}
-        onOk={() => {
+        onOk={async () => {
           if (resetGroup) {
+            await resetConsumerOffset({
+              name: resetGroup.name,
+              timestamp: resetTime.valueOf(),
+            });
             message.success(
               `${resetGroup.name} 消费位点已重置到 ${resetTime.format('YYYY-MM-DD 
HH:mm:ss')}`,
             );
diff --git a/web/src/services/aclService.test.ts 
b/web/src/services/aclService.test.ts
new file mode 100644
index 00000000..dea835f5
--- /dev/null
+++ b/web/src/services/aclService.test.ts
@@ -0,0 +1,102 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+import {
+  createAclRule,
+  createAclUser,
+  listAclRules,
+  listAclUsers,
+  updateAclRule,
+  updateAclUser,
+} from './aclService';
+
+vi.mock('../config', () => ({
+  API_BASE_URL: '/api',
+  USE_MOCK: true,
+}));
+
+describe('ACL service mock data', () => {
+  it('returns copied ACL rule rows', async () => {
+    const first = await listAclRules({ principal: 'user-admin' });
+    expect(first[0].principal).toBe('user-admin');
+
+    first[0].principal = 'mutated-principal';
+    first[0].actions.push('MUTATED');
+
+    const second = await listAclRules({ principal: 'user-admin' });
+    expect(second[0].principal).toBe('user-admin');
+    expect(second[0].actions).toEqual(['ALL']);
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('copies ACL rule arrays on create and update', async () => {
+    const actions = ['PUB'];
+    const created = await createAclRule({
+      principal: 'user-created-copy-test',
+      resource: 'created-topic',
+      actions,
+    });
+    actions.push('SUB');
+    created.actions.push('MUTATED');
+
+    const afterCreate = await listAclRules({ principal: 
'user-created-copy-test' });
+    expect(afterCreate[0].actions).toEqual(['PUB']);
+
+    const updateActions = ['SUB'];
+    const updated = await updateAclRule({ id: created.id, actions: 
updateActions });
+    updateActions.push('PUB');
+    updated.actions.push('MUTATED');
+
+    const afterUpdate = await listAclRules({ principal: 
'user-created-copy-test' });
+    expect(afterUpdate[0].actions).toEqual(['SUB']);
+  });
+
+  it('returns copied ACL user rows', async () => {
+    const first = await listAclUsers({ keyword: 'user-admin' });
+    expect(first[0].username).toBe('user-admin');
+
+    first[0].username = 'mutated-user';
+    first[0].clusters.push('mutated-cluster');
+
+    const second = await listAclUsers({ keyword: 'user-admin' });
+    expect(second[0].username).toBe('user-admin');
+    expect(second[0].clusters).not.toContain('mutated-cluster');
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('copies ACL user arrays on create and update', async () => {
+    const clusters = ['rmq-created'];
+    const created = await createAclUser({
+      username: 'user-created-copy-test',
+      clusters,
+    });
+    clusters.push('rmq-mutated');
+    created.clusters.push('rmq-mutated-return');
+
+    const afterCreate = await listAclUsers({ keyword: 'user-created-copy-test' 
});
+    expect(afterCreate[0].clusters).toEqual(['rmq-created']);
+
+    const updateClusters = ['rmq-updated'];
+    const updated = await updateAclUser({ id: created.id, clusters: 
updateClusters });
+    updateClusters.push('rmq-mutated');
+    updated.clusters.push('rmq-mutated-return');
+
+    const afterUpdate = await listAclUsers({ keyword: 'user-created-copy-test' 
});
+    expect(afterUpdate[0].clusters).toEqual(['rmq-updated']);
+  });
+});
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index 3ef7b6e0..df7361a7 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -6,6 +6,20 @@ import { aclRules as mockRules, aclUsers as mockUsers } from 
'../mock/acl';
 const aclRulesState = mockRules as unknown as AclRule[];
 const aclUsersState = mockUsers as unknown as AclUser[];
 
+function copyAclRule(rule: AclRule): AclRule {
+  return {
+    ...rule,
+    actions: [...rule.actions],
+  };
+}
+
+function copyAclUser(user: AclUser): AclUser {
+  return {
+    ...user,
+    clusters: [...user.clusters],
+  };
+}
+
 export async function listAclRules(params?: AclRuleQuery): Promise<AclRule[]> {
   if (USE_MOCK) {
     let result = [...aclRulesState];
@@ -13,7 +27,7 @@ export async function listAclRules(params?: AclRuleQuery): 
Promise<AclRule[]> {
       const principal = params.principal.toLowerCase();
       result = result.filter((rule) => 
rule.principal.toLowerCase().includes(principal));
     }
-    return result;
+    return result.map(copyAclRule);
   }
   return aclApi.listAclRules(params);
 }
@@ -25,7 +39,7 @@ export async function listAclUsers(params?: { keyword?: 
string }): Promise<AclUs
       const kw = params.keyword.toLowerCase();
       result = result.filter((u) => u.username.toLowerCase().includes(kw));
     }
-    return result;
+    return result.map(copyAclUser);
   }
   return aclApi.listAclUsers(params);
 }
@@ -38,15 +52,15 @@ export async function createAclRule(data: 
Partial<AclRule>): Promise<AclRule> {
       resource: '',
       resourceType: '',
       resourcePattern: '',
-      actions: [],
       decision: '',
       scope: '',
       aclVersion: 2,
       createdAt: new Date().toISOString(),
       ...data,
+      actions: [...(data.actions ?? [])],
     };
     aclRulesState.push(rule);
-    return rule;
+    return copyAclRule(rule);
   }
   return aclApi.createAclRule(data);
 }
@@ -58,8 +72,9 @@ export async function updateAclRule(data: Partial<AclRule>): 
Promise<AclRule> {
     aclRulesState[idx] = {
       ...aclRulesState[idx],
       ...data,
+      actions: data.actions ? [...data.actions] : 
[...aclRulesState[idx].actions],
     };
-    return aclRulesState[idx];
+    return copyAclRule(aclRulesState[idx]);
   }
   return aclApi.updateAclRule(data);
 }
@@ -81,12 +96,12 @@ export async function createAclUser(data: 
Partial<AclUser>): Promise<AclUser> {
       accessKey: '',
       secretKey: '',
       admin: false,
-      clusters: [],
       createdAt: new Date().toISOString(),
       ...data,
+      clusters: [...(data.clusters ?? [])],
     };
     aclUsersState.push(user);
-    return user;
+    return copyAclUser(user);
   }
   return aclApi.createAclUser(data);
 }
@@ -98,8 +113,9 @@ export async function updateAclUser(data: Partial<AclUser>): 
Promise<AclUser> {
     aclUsersState[idx] = {
       ...aclUsersState[idx],
       ...data,
+      clusters: data.clusters ? [...data.clusters] : 
[...aclUsersState[idx].clusters],
     };
-    return aclUsersState[idx];
+    return copyAclUser(aclUsersState[idx]);
   }
   return aclApi.updateAclUser(data);
 }
diff --git a/web/src/services/clusterService.test.ts 
b/web/src/services/clusterService.test.ts
new file mode 100644
index 00000000..a858a727
--- /dev/null
+++ b/web/src/services/clusterService.test.ts
@@ -0,0 +1,64 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+
+vi.mock('../config', () => ({
+  USE_MOCK: true,
+  API_BASE_URL: '/api',
+}));
+
+import { getCluster, listClusters } from './clusterService';
+
+describe('clusterService mock clusters', () => {
+  it('returns defensive copies from cluster detail reads', async () => {
+    const cluster = await getCluster('cluster-prod');
+    const originalBrokerStatus = cluster.brokers[0].status;
+    const originalProxyConnections = cluster.proxies[0].connections;
+    const originalNameServerAddr = cluster.nameServers[0].addr;
+    const originalFlushDiskType = cluster.config.flushDiskType;
+    const originalFirstTps = cluster.tpsHistory[0];
+
+    cluster.brokers[0].status = 'offline';
+    cluster.proxies[0].connections = 0;
+    cluster.nameServers[0].addr = '127.0.0.1:9876';
+    cluster.config.flushDiskType = 'ASYNC_FLUSH';
+    cluster.tpsHistory[0] = 0;
+
+    const fresh = await getCluster('cluster-prod');
+
+    expect(fresh.brokers[0].status).toBe(originalBrokerStatus);
+    expect(fresh.proxies[0].connections).toBe(originalProxyConnections);
+    expect(fresh.nameServers[0].addr).toBe(originalNameServerAddr);
+    expect(fresh.config.flushDiskType).toBe(originalFlushDiskType);
+    expect(fresh.tpsHistory[0]).toBe(originalFirstTps);
+  });
+
+  it('does not share nested references between list and detail reads', async 
() => {
+    const [listed] = await listClusters();
+    const detail = await getCluster(listed.id);
+
+    expect(detail).toEqual(listed);
+    expect(detail).not.toBe(listed);
+    expect(detail.brokers).not.toBe(listed.brokers);
+    expect(detail.brokers[0]).not.toBe(listed.brokers[0]);
+    expect(detail.proxies).not.toBe(listed.proxies);
+    expect(detail.nameServers).not.toBe(listed.nameServers);
+    expect(detail.config).not.toBe(listed.config);
+    expect(detail.tpsHistory).not.toBe(listed.tpsHistory);
+  });
+});
diff --git a/web/src/services/clusterService.ts 
b/web/src/services/clusterService.ts
index d758dc6a..fd9fcc75 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -8,33 +8,37 @@ const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) 
=> ({
   san: [...cert.san],
 }));
 
+function copyCluster(cluster: ClusterInfo): ClusterInfo {
+  return {
+    id: cluster.id,
+    name: cluster.name,
+    nsClusterName: cluster.nsClusterName,
+    type: cluster.type,
+    endpoint: cluster.endpoint,
+    status: cluster.status,
+    version: cluster.version,
+    brokers: cluster.brokers.map((broker) => ({ ...broker })),
+    proxies: cluster.proxies.map((proxy) => ({ ...proxy })),
+    nameServers: cluster.nameServers.map((nameServer) => ({ ...nameServer })),
+    config: { ...cluster.config },
+    topicCount: cluster.topicCount,
+    groupCount: cluster.groupCount,
+    tpsHistory: [...cluster.tpsHistory],
+  };
+}
+
 export async function listClusters(): Promise<ClusterInfo[]> {
   if (USE_MOCK) {
-    return clusters.map((c) => ({
-      id: c.id,
-      name: c.name,
-      nsClusterName: c.nsClusterName,
-      type: c.type,
-      endpoint: c.endpoint,
-      status: c.status,
-      version: c.version,
-      brokers: c.brokers.map((broker) => ({ ...broker })),
-      proxies: c.proxies.map((proxy) => ({ ...proxy })),
-      nameServers: c.nameServers.map((nameServer) => ({ ...nameServer })),
-      config: { ...c.config },
-      topicCount: c.topicCount,
-      groupCount: c.groupCount,
-      tpsHistory: [...c.tpsHistory],
-    }));
+    return clusters.map(copyCluster);
   }
   return clusterApi.listClusters();
 }
 
-export async function getCluster(id: string) {
+export async function getCluster(id: string): Promise<ClusterInfo> {
   if (USE_MOCK) {
-    const c = clusters.find((c) => c.id === id);
-    if (!c) throw new Error('Cluster not found');
-    return c;
+    const cluster = clusters.find((item) => item.id === id);
+    if (!cluster) throw new Error('Cluster not found');
+    return copyCluster(cluster);
   }
   return clusterApi.getCluster(id);
 }
diff --git a/web/src/services/connectionsService.test.ts 
b/web/src/services/connectionsService.test.ts
new file mode 100644
index 00000000..5461a0a0
--- /dev/null
+++ b/web/src/services/connectionsService.test.ts
@@ -0,0 +1,44 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+
+vi.mock('../config', () => ({
+  USE_MOCK: true,
+  API_BASE_URL: '/api',
+}));
+
+import { listConnections } from './connectionsService';
+
+describe('connectionsService mock connections', () => {
+  it('returns defensive copies after applying filters', async () => {
+    const connections = await listConnections({ clusterId: 'ns-prod', type: 
'Consumer' });
+    const originalClientId = connections[0].clientId;
+    const originalAddress = connections[0].address;
+
+    connections[0].clientId = 'mutated-client';
+    connections[0].address = '127.0.0.1:8081';
+
+    const fresh = await listConnections({ clusterId: 'ns-prod', type: 
'Consumer' });
+
+    expect(fresh[0].clientId).toBe(originalClientId);
+    expect(fresh[0].address).toBe(originalAddress);
+    expect(fresh[0]).not.toBe(connections[0]);
+    expect(fresh.every((connection) => connection.clusterName === 
'ns-prod')).toBe(true);
+    expect(fresh.every((connection) => connection.type === 
'Consumer')).toBe(true);
+  });
+});
diff --git a/web/src/services/connectionsService.ts 
b/web/src/services/connectionsService.ts
index b773cea9..d29b58a0 100644
--- a/web/src/services/connectionsService.ts
+++ b/web/src/services/connectionsService.ts
@@ -3,13 +3,17 @@ import * as connApi from '../api/connections';
 import type { ClientConnection, ClientConnectionQuery } from 
'../api/connections';
 import { mockClients } from '../mock/clients';
 
+function copyConnection(connection: ClientConnection): ClientConnection {
+  return { ...connection };
+}
+
 export async function listConnections(params?: ClientConnectionQuery): 
Promise<ClientConnection[]> {
   if (USE_MOCK) {
     let result = [...mockClients];
     if (params?.clusterId)
       result = result.filter((connection) => connection.clusterName === 
params.clusterId);
     if (params?.type) result = result.filter((c) => c.type === params.type);
-    return result as unknown as ClientConnection[];
+    return (result as unknown as ClientConnection[]).map(copyConnection);
   }
   return connApi.listConnections(params);
 }
diff --git a/web/src/services/consumerService.test.ts 
b/web/src/services/consumerService.test.ts
new file mode 100644
index 00000000..603861d1
--- /dev/null
+++ b/web/src/services/consumerService.test.ts
@@ -0,0 +1,83 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+import {
+  createConsumerGroup,
+  getConsumerGroup,
+  getConsumerProgress,
+  getConsumerSubscriptions,
+  listConsumerGroups,
+} from './consumerService';
+
+vi.mock('../config', () => ({
+  API_BASE_URL: '/api',
+  USE_MOCK: true,
+}));
+
+describe('consumer service mock data', () => {
+  it('returns copied consumer group rows', async () => {
+    const first = await listConsumerGroups({ search: 'cg-order-notify' });
+    expect(first[0].name).toBe('cg-order-notify');
+
+    first[0].name = 'mutated-group';
+    first[0].subscribedTopics.push('mutated-topic');
+    first[0].instances[0].topicLag['order-create'] = 999999;
+
+    const second = await listConsumerGroups({ search: 'cg-order-notify' });
+    expect(second[0].name).toBe('cg-order-notify');
+    expect(second[0].subscribedTopics).not.toContain('mutated-topic');
+    expect(second[0].instances[0].topicLag['order-create']).toBe(180);
+    expect(second[0]).not.toBe(first[0]);
+    expect(second[0].instances[0]).not.toBe(first[0].instances[0]);
+  });
+
+  it('returns copied consumer group details', async () => {
+    const first = await getConsumerGroup('cg-order-notify');
+    first.instances[0].subscribedTopics.push('mutated-topic');
+
+    const second = await getConsumerGroup('cg-order-notify');
+    
expect(second.instances[0].subscribedTopics).not.toContain('mutated-topic');
+    expect(second.instances[0]).not.toBe(first.instances[0]);
+  });
+
+  it('returns copied progress and subscription rows', async () => {
+    const firstProgress = await getConsumerProgress('cg-order-notify');
+    const firstSubscriptions = await 
getConsumerSubscriptions('cg-order-notify');
+    firstProgress[0].broker = 'mutated-broker';
+    firstSubscriptions[0].topic = 'mutated-topic';
+
+    const secondProgress = await getConsumerProgress('cg-order-notify');
+    const secondSubscriptions = await 
getConsumerSubscriptions('cg-order-notify');
+    expect(secondProgress[0].broker).not.toBe('mutated-broker');
+    expect(secondSubscriptions[0].topic).not.toBe('mutated-topic');
+    expect(secondProgress[0]).not.toBe(firstProgress[0]);
+    expect(secondSubscriptions[0]).not.toBe(firstSubscriptions[0]);
+  });
+
+  it('returns a copy after creating consumer groups', async () => {
+    const created = await createConsumerGroup({
+      name: 'cg-created-copy-test',
+      subscribedTopics: ['created-topic'],
+    });
+    created.subscribedTopics.push('mutated-topic');
+
+    const detail = await getConsumerGroup('cg-created-copy-test');
+    expect(detail.subscribedTopics).toEqual(['created-topic']);
+    expect(detail).not.toBe(created);
+  });
+});
diff --git a/web/src/services/consumerService.ts 
b/web/src/services/consumerService.ts
index 34bee234..c1859dce 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -12,6 +12,30 @@ import { mockConsumerGroups, mockQueueProgress, 
mockSubscriptions } from '../moc
 
 const consumerGroupsState = mockConsumerGroups as unknown as ConsumerGroup[];
 
+function copyConsumerInstance(instance: ConsumerGroup['instances'][number]): 
ConsumerGroup['instances'][number] {
+  return {
+    ...instance,
+    subscribedTopics: [...instance.subscribedTopics],
+    topicLag: { ...instance.topicLag },
+  };
+}
+
+function copyConsumerGroup(group: ConsumerGroup): ConsumerGroup {
+  return {
+    ...group,
+    subscribedTopics: [...group.subscribedTopics],
+    instances: group.instances.map(copyConsumerInstance),
+  };
+}
+
+function copyQueueProgress(progress: QueueProgress): QueueProgress {
+  return { ...progress };
+}
+
+function copySubscription(subscription: SubscriptionEntry): SubscriptionEntry {
+  return { ...subscription };
+}
+
 export async function listConsumerGroups(params?: ConsumerGroupQuery): 
Promise<ConsumerGroup[]> {
   if (USE_MOCK) {
     let result = [...consumerGroupsState];
@@ -20,13 +44,15 @@ export async function listConsumerGroups(params?: 
ConsumerGroupQuery): Promise<C
       const kw = params.search.toLowerCase();
       result = result.filter((g) => g.name.toLowerCase().includes(kw));
     }
-    return result;
+    return result.map(copyConsumerGroup);
   }
   return metadataApi.listConsumerGroups(params);
 }
 
 export async function getConsumerProgress(name: string): 
Promise<QueueProgress[]> {
-  if (USE_MOCK) return (mockQueueProgress[name] as unknown as QueueProgress[]) 
?? [];
+  if (USE_MOCK) {
+    return ((mockQueueProgress[name] as unknown as QueueProgress[]) ?? 
[]).map(copyQueueProgress);
+  }
   return metadataApi.getConsumerProgress(name);
 }
 
@@ -34,13 +60,15 @@ export async function getConsumerGroup(name: string): 
Promise<ConsumerGroupDetai
   if (USE_MOCK) {
     const group = mockConsumerGroups.find((item) => item.name === name);
     if (!group) throw new Error(`Consumer group not found: ${name}`);
-    return group as unknown as ConsumerGroupDetail;
+    return copyConsumerGroup(group as unknown as ConsumerGroupDetail) as 
ConsumerGroupDetail;
   }
   return metadataApi.getConsumerGroup(name);
 }
 
 export async function getConsumerSubscriptions(name: string): 
Promise<SubscriptionEntry[]> {
-  if (USE_MOCK) return (mockSubscriptions[name] as unknown as 
SubscriptionEntry[]) ?? [];
+  if (USE_MOCK) {
+    return ((mockSubscriptions[name] as unknown as SubscriptionEntry[]) ?? 
[]).map(copySubscription);
+  }
   return metadataApi.getConsumerSubscriptions(name);
 }
 
@@ -62,9 +90,9 @@ export async function createConsumerGroup(data: 
Partial<ConsumerGroup>): Promise
       updatedAt: now,
       delaySeconds: 0,
       instances: [],
-    };
+    } as ConsumerGroup;
     mockConsumerGroups.unshift(group as never);
-    return group as ConsumerGroup;
+    return copyConsumerGroup(group);
   }
   return metadataApi.createConsumerGroup(data);
 }
diff --git a/web/src/services/dashboardService.test.ts 
b/web/src/services/dashboardService.test.ts
new file mode 100644
index 00000000..f2bfc39b
--- /dev/null
+++ b/web/src/services/dashboardService.test.ts
@@ -0,0 +1,48 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+
+vi.mock('../config', () => ({
+  USE_MOCK: true,
+  API_BASE_URL: '/api',
+}));
+
+import { getDashboard } from './dashboardService';
+
+describe('dashboardService mock dashboard', () => {
+  it('returns defensive copies for overview stats and cluster throughput', 
async () => {
+    const dashboard = await getDashboard();
+    const originalTotalClusters = dashboard.stats.totalClusters;
+    const originalClusterName = dashboard.clusters[0].name;
+    const originalThroughput = dashboard.clusters[0].throughput[0];
+
+    dashboard.stats.totalClusters = 0;
+    dashboard.clusters[0].name = 'mutated-cluster';
+    dashboard.clusters[0].throughput[0] = 0;
+
+    const fresh = await getDashboard();
+
+    expect(fresh.stats.totalClusters).toBe(originalTotalClusters);
+    expect(fresh.clusters[0].name).toBe(originalClusterName);
+    expect(fresh.clusters[0].throughput[0]).toBe(originalThroughput);
+    expect(fresh.stats).not.toBe(dashboard.stats);
+    expect(fresh.clusters).not.toBe(dashboard.clusters);
+    expect(fresh.clusters[0]).not.toBe(dashboard.clusters[0]);
+    
expect(fresh.clusters[0].throughput).not.toBe(dashboard.clusters[0].throughput);
+  });
+});
diff --git a/web/src/services/dashboardService.ts 
b/web/src/services/dashboardService.ts
index ad9ce343..0d396eff 100644
--- a/web/src/services/dashboardService.ts
+++ b/web/src/services/dashboardService.ts
@@ -3,11 +3,18 @@ import * as metricsApi from '../api/metrics';
 import { dashboardStats, clusterOverview } from '../mock/dashboard';
 import type { DashboardData } from '../api/metrics';
 
+function copyClusterOverview(cluster: DashboardData['clusters'][number]) {
+  return {
+    ...cluster,
+    throughput: [...cluster.throughput],
+  };
+}
+
 export async function getDashboard(): Promise<DashboardData> {
   if (USE_MOCK) {
     return {
-      stats: dashboardStats,
-      clusters: clusterOverview as unknown as DashboardData['clusters'],
+      stats: { ...dashboardStats },
+      clusters: (clusterOverview as 
DashboardData['clusters']).map(copyClusterOverview),
     };
   }
   return metricsApi.getDashboard();
diff --git a/web/src/services/instanceService.test.ts 
b/web/src/services/instanceService.test.ts
new file mode 100644
index 00000000..e7f394b6
--- /dev/null
+++ b/web/src/services/instanceService.test.ts
@@ -0,0 +1,71 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+
+vi.mock('../config', () => ({
+  USE_MOCK: true,
+  API_BASE_URL: '/api',
+}));
+
+import { createInstance, listInstances, updateInstance } from 
'./instanceService';
+
+describe('instanceService mock instances', () => {
+  it('returns defensive copies from list reads', async () => {
+    const instances = await listInstances();
+    const originalName = instances[0].name;
+    const originalRemark = instances[0].remark;
+
+    instances[0].name = 'mutated-name';
+    instances[0].remark = 'mutated-remark';
+
+    const fresh = await listInstances();
+
+    expect(fresh[0].name).toBe(originalName);
+    expect(fresh[0].remark).toBe(originalRemark);
+    expect(fresh[0]).not.toBe(instances[0]);
+  });
+
+  it('does not expose created or updated store records by reference', async () 
=> {
+    const created = await createInstance({
+      name: 'rocketmq-copy-test',
+      type: 'PROXY',
+      endpoint: 'proxy-copy-test:8080',
+      remark: 'created',
+    });
+
+    created.name = 'mutated-created';
+    created.remark = 'mutated-created-remark';
+
+    const afterCreate = await listInstances();
+    const storedCreated = afterCreate.find((instance) => instance.id === 
created.id);
+    expect(storedCreated).toMatchObject({
+      name: 'rocketmq-copy-test',
+      remark: 'created',
+    });
+
+    const updated = await updateInstance({
+      id: created.id,
+      remark: 'updated',
+    });
+    updated.remark = 'mutated-updated';
+
+    const afterUpdate = await listInstances();
+    const storedUpdated = afterUpdate.find((instance) => instance.id === 
created.id);
+    expect(storedUpdated?.remark).toBe('updated');
+  });
+});
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 81d6de1f..5cadff45 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -6,8 +6,12 @@ import { mockInstances } from '../mock/instances';
 // Compile-time switch: mock or real API
 // Vite replaces USE_MOCK with a literal at build time → tree-shaking removes 
unused branch
 
+function copyInstance(instance: Instance): Instance {
+  return { ...instance };
+}
+
 export async function listInstances(): Promise<Instance[]> {
-  if (USE_MOCK) return mockInstances;
+  if (USE_MOCK) return mockInstances.map(copyInstance);
   return instanceApi.listInstances();
 }
 
@@ -23,7 +27,7 @@ export async function createInstance(data: 
CreateInstanceRequest): Promise<Insta
       updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
     };
     mockInstances.push(instance);
-    return instance;
+    return copyInstance(instance);
   }
   return instanceApi.createInstance(data);
 }
@@ -35,7 +39,7 @@ export async function updateInstance(data: 
UpdateInstanceRequest): Promise<Insta
       Object.assign(mockInstances[idx], data, {
         updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
       });
-      return mockInstances[idx];
+      return copyInstance(mockInstances[idx]);
     }
     throw new Error('Instance not found');
   }
diff --git a/web/src/services/messageService.test.ts 
b/web/src/services/messageService.test.ts
new file mode 100644
index 00000000..42b0c926
--- /dev/null
+++ b/web/src/services/messageService.test.ts
@@ -0,0 +1,67 @@
+/*
+ * 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, expect, it, vi } from 'vitest';
+import { getMessageTrace, listDLQGroups, queryMessages } from 
'./messageService';
+
+vi.mock('../config', () => ({
+  API_BASE_URL: '/api',
+  USE_MOCK: true,
+}));
+
+describe('message service mock data', () => {
+  it('returns copied message rows and properties', async () => {
+    const first = await queryMessages({ msgId: 
'AC1E0A6400002A9F0000000001A3F2B1' });
+    expect(first[0].topic).toBe('order-create');
+    expect(first[0].properties.KEYS).toBe('order-12345');
+
+    first[0].topic = 'mutated-topic';
+    first[0].properties.KEYS = 'mutated-key';
+
+    const second = await queryMessages({ msgId: 
'AC1E0A6400002A9F0000000001A3F2B1' });
+    expect(second[0].topic).toBe('order-create');
+    expect(second[0].properties.KEYS).toBe('order-12345');
+    expect(second[0]).not.toBe(first[0]);
+    expect(second[0].properties).not.toBe(first[0].properties);
+  });
+
+  it('returns copied message trace rows', async () => {
+    const first = await getMessageTrace('AC1E0A6400002A9F0000000001A3F2B1');
+    expect(first?.nodes[0].title).toBe('Producer 发送');
+    expect(first?.consumerStatus[0].group).toBe('cg-order-processor');
+
+    first!.nodes[0].title = 'mutated-node';
+    first!.consumerStatus[0].group = 'mutated-group';
+
+    const second = await getMessageTrace('AC1E0A6400002A9F0000000001A3F2B1');
+    expect(second?.nodes[0].title).toBe('Producer 发送');
+    expect(second?.consumerStatus[0].group).toBe('cg-order-processor');
+    expect(second?.nodes[0]).not.toBe(first?.nodes[0]);
+    expect(second?.consumerStatus[0]).not.toBe(first?.consumerStatus[0]);
+  });
+
+  it('returns copied DLQ group rows', async () => {
+    const first = await listDLQGroups();
+    expect(first[0].groupName).toBe('cg-order-processor');
+
+    first[0].groupName = 'mutated-group';
+
+    const second = await listDLQGroups();
+    expect(second[0].groupName).toBe('cg-order-processor');
+    expect(second[0]).not.toBe(first[0]);
+  });
+});
diff --git a/web/src/services/messageService.ts 
b/web/src/services/messageService.ts
index 852b40d1..750afc21 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -5,6 +5,18 @@ import type { MessageQuery, MessageRecord, TraceRecord, 
DLQGroup } from '../api/
 import { mockMessages, mockMessageTraces } from '../mock/messages';
 import { mockDLQGroups } from '../mock/dlq';
 
+const cloneMessage = (message: MessageRecord): MessageRecord => ({
+  ...message,
+  properties: { ...message.properties },
+});
+
+const cloneTrace = (trace: TraceRecord): TraceRecord => ({
+  nodes: trace.nodes.map((node) => ({ ...node })),
+  consumerStatus: trace.consumerStatus.map((status) => ({ ...status })),
+});
+
+const cloneDLQGroup = (group: DLQGroup): DLQGroup => ({ ...group });
+
 export async function queryMessages(params: MessageQuery): 
Promise<MessageRecord[]> {
   if (USE_MOCK) {
     let result = [...mockMessages];
@@ -12,18 +24,21 @@ export async function queryMessages(params: MessageQuery): 
Promise<MessageRecord
     if (params.tag) result = result.filter((m) => m.tag === params.tag);
     if (params.key) result = result.filter((m) => m.key.includes(params.key!));
     if (params.msgId) result = result.filter((m) => m.msgId === params.msgId);
-    return sortMessagesByStoreTimeDesc(result as unknown as MessageRecord[]);
+    return sortMessagesByStoreTimeDesc((result as unknown as 
MessageRecord[]).map(cloneMessage));
   }
   return messageApi.queryMessages(params);
 }
 
 export async function getMessageTrace(msgId: string): Promise<TraceRecord | 
null> {
-  if (USE_MOCK) return (mockMessageTraces[msgId] as unknown as TraceRecord) ?? 
null;
+  if (USE_MOCK) {
+    const trace = mockMessageTraces[msgId] as unknown as TraceRecord | 
undefined;
+    return trace ? cloneTrace(trace) : null;
+  }
   return messageApi.getMessageTrace(msgId);
 }
 
 export async function listDLQGroups(): Promise<DLQGroup[]> {
-  if (USE_MOCK) return mockDLQGroups as unknown as DLQGroup[];
+  if (USE_MOCK) return (mockDLQGroups as unknown as 
DLQGroup[]).map(cloneDLQGroup);
   return messageApi.listDLQGroups();
 }
 
diff --git a/web/src/services/opsService.test.ts 
b/web/src/services/opsService.test.ts
new file mode 100644
index 00000000..eafa7468
--- /dev/null
+++ b/web/src/services/opsService.test.ts
@@ -0,0 +1,122 @@
+/*
+ * 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 { afterEach, describe, expect, it, vi } from 'vitest';
+import type { AuditRecord } from '../api/ops';
+import { mockAuditRecords } from '../mock/audit';
+import {
+  createAlertRule,
+  listAlertRules,
+  listAuditRecords,
+  listSystemAlerts,
+  toggleAlertRule,
+  updateAlertRule,
+} from './opsService';
+
+vi.mock('../config', () => ({
+  API_BASE_URL: '/api',
+  USE_MOCK: true,
+}));
+
+describe('ops service mock data', () => {
+  const auditRecords = mockAuditRecords as unknown as AuditRecord[];
+  const insertedRecords: AuditRecord[] = [];
+
+  afterEach(() => {
+    for (const record of insertedRecords.splice(0)) {
+      const index = auditRecords.findIndex((item) => item.id === record.id);
+      if (index >= 0) auditRecords.splice(index, 1);
+    }
+  });
+
+  it('returns copied alert rule rows', async () => {
+    const first = await listAlertRules();
+    const originalName = first[0].name;
+    first[0].name = 'mutated-rule';
+    first[0].channels.push('mutated-channel');
+
+    const second = await listAlertRules();
+    expect(second[0].name).toBe(originalName);
+    expect(second[0].channels).not.toContain('mutated-channel');
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('copies alert rule channels on create, update, and toggle', async () => {
+    const channels = ['email'];
+    const created = await createAlertRule({
+      name: 'created-copy-test',
+      channels,
+    });
+    channels.push('sms');
+    created.channels.push('mutated-return');
+
+    const afterCreate = (await listAlertRules()).find((rule) => rule.id === 
created.id);
+    expect(afterCreate?.channels).toEqual(['email']);
+
+    const updated = await updateAlertRule({
+      ...created,
+      channels: ['webhook'],
+    });
+    updated.channels.push('mutated-return');
+
+    const toggled = await toggleAlertRule(created.id, false);
+    toggled.channels.push('mutated-toggle');
+
+    const afterUpdate = (await listAlertRules()).find((rule) => rule.id === 
created.id);
+    expect(afterUpdate?.enabled).toBe(false);
+    expect(afterUpdate?.channels).toEqual(['webhook']);
+  });
+
+  it('returns copied system alert rows', async () => {
+    const first = await listSystemAlerts();
+    const originalTitle = first[0].title;
+    first[0].title = 'mutated-alert';
+
+    const second = await listSystemAlerts();
+    expect(second[0].title).toBe(originalTitle);
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('returns copied audit records', async () => {
+    const first = await listAuditRecords({ page: 1, pageSize: 1 });
+    const originalOperator = first.items[0].operator;
+    first.items[0].operator = 'mutated-operator';
+
+    const second = await listAuditRecords({ page: 1, pageSize: 1 });
+    expect(second.items[0].operator).toBe(originalOperator);
+    expect(second.items[0]).not.toBe(first.items[0]);
+  });
+
+  it('searches records safely when optional text fields are missing', async () 
=> {
+    const record = {
+      id: 'audit-null-safe',
+      timestamp: '2026-07-26 10:00:00',
+      operator: null,
+      operationType: 'DIAGNOSE',
+      target: null,
+      detail: 'Describe gRPC client connection',
+      ipAddress: '127.0.0.1',
+      result: 'success',
+    } as unknown as AuditRecord;
+    insertedRecords.push(record);
+    auditRecords.push(record);
+
+    const result = await listAuditRecords({ search: 'grpc client', pageSize: 
100 });
+
+    expect(result.items.map((item) => item.id)).toContain('audit-null-safe');
+  });
+});
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 4563d72e..68d087eb 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -8,8 +8,27 @@ import { systemAlerts as mockSystemAlerts } from 
'../mock/dashboard';
 let auditRecordsState = mockAuditRecords as unknown as AuditRecord[];
 const alertRulesState = mockAlertRules as unknown as AlertRule[];
 
+function copyAlertRule(rule: AlertRule): AlertRule {
+  return {
+    ...rule,
+    channels: [...rule.channels],
+  };
+}
+
+function copySystemAlert(alert: SystemAlert): SystemAlert {
+  return { ...alert };
+}
+
+function copyAuditRecord(record: AuditRecord): AuditRecord {
+  return { ...record };
+}
+
+function includesIgnoreCase(value: string | null | undefined, search: string): 
boolean {
+  return (value ?? '').toLowerCase().includes(search);
+}
+
 export async function listAlertRules(): Promise<AlertRule[]> {
-  if (USE_MOCK) return alertRulesState;
+  if (USE_MOCK) return alertRulesState.map(copyAlertRule);
   return opsApi.listAlertRules();
 }
 
@@ -23,14 +42,14 @@ export async function createAlertRule(data: 
Partial<AlertRule>): Promise<AlertRu
       threshold: 0,
       thresholdUnit: '',
       duration: '',
-      channels: [],
       enabled: true,
       lastTriggered: null,
       description: '',
       ...data,
+      channels: [...(data.channels ?? [])],
     };
     alertRulesState.push(rule);
-    return rule;
+    return copyAlertRule(rule);
   }
   return opsApi.createAlertRule(data);
 }
@@ -38,8 +57,9 @@ export async function createAlertRule(data: 
Partial<AlertRule>): Promise<AlertRu
 export async function updateAlertRule(data: AlertRule): Promise<AlertRule> {
   if (USE_MOCK) {
     const index = alertRulesState.findIndex((rule) => rule.id === data.id);
-    if (index >= 0) alertRulesState[index] = data;
-    return data;
+    const rule = copyAlertRule(data);
+    if (index >= 0) alertRulesState[index] = rule;
+    return copyAlertRule(rule);
   }
   return opsApi.updateAlertRule(data);
 }
@@ -49,7 +69,7 @@ export async function toggleAlertRule(id: string, enabled: 
boolean): Promise<Ale
     const rule = alertRulesState.find((item) => item.id === id);
     if (!rule) throw new Error(`Alert rule not found: ${id}`);
     rule.enabled = enabled;
-    return rule;
+    return copyAlertRule(rule);
   }
   return opsApi.toggleAlertRule(id, enabled);
 }
@@ -64,7 +84,7 @@ export async function deleteAlertRule(id: string): 
Promise<void> {
 }
 
 export async function listSystemAlerts(): Promise<SystemAlert[]> {
-  if (USE_MOCK) return mockSystemAlerts as unknown as SystemAlert[];
+  if (USE_MOCK) return (mockSystemAlerts as unknown as 
SystemAlert[]).map(copySystemAlert);
   return opsApi.listSystemAlerts();
 }
 
@@ -97,9 +117,9 @@ export async function listAuditRecords(params: AuditQuery = 
{}): Promise<PageRes
     const search = params.search?.trim().toLowerCase();
     if (
       search &&
-      !record.operator.toLowerCase().includes(search) &&
-      !record.target.toLowerCase().includes(search) &&
-      !record.detail.toLowerCase().includes(search)
+      !includesIgnoreCase(record.operator, search) &&
+      !includesIgnoreCase(record.target, search) &&
+      !includesIgnoreCase(record.detail, search)
     ) {
       return false;
     }
@@ -110,7 +130,7 @@ export async function listAuditRecords(params: AuditQuery = 
{}): Promise<PageRes
   });
   const from = (page - 1) * pageSize;
   return {
-    items: records.slice(from, from + pageSize),
+    items: records.slice(from, from + pageSize).map(copyAuditRecord),
     total: records.length,
     page,
     size: pageSize,
diff --git a/web/src/services/topicService.test.ts 
b/web/src/services/topicService.test.ts
new file mode 100644
index 00000000..ff832977
--- /dev/null
+++ b/web/src/services/topicService.test.ts
@@ -0,0 +1,71 @@
+/*
+ * 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 { getTopicConsumers, getTopicRoutes, listTopics } from './topicService';
+
+vi.mock('../config', () => ({
+  API_BASE_URL: '/api',
+  USE_MOCK: true,
+}));
+
+describe('topic service mock data', () => {
+  it('returns copied topic rows', async () => {
+    const first = await listTopics({ search: 'order-create' });
+    expect(first[0].name).toBe('order-create');
+
+    first[0].name = 'mutated-topic';
+
+    const second = await listTopics({ search: 'order-create' });
+    expect(second[0].name).toBe('order-create');
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('returns copied topic route rows', async () => {
+    const first = await getTopicRoutes('order-create');
+    expect(first[0].brokerName).toBe('broker-a-0');
+
+    first[0].brokerName = 'mutated-broker';
+
+    const second = await getTopicRoutes('order-create');
+    expect(second[0].brokerName).toBe('broker-a-0');
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('returns copied topic consumer rows', async () => {
+    const first = await getTopicConsumers('order-create');
+    expect(first[0].group).toBe('GID_order_service');
+
+    first[0].group = 'mutated-group';
+
+    const second = await getTopicConsumers('order-create');
+    expect(second[0].group).toBe('GID_order_service');
+    expect(second[0]).not.toBe(first[0]);
+  });
+
+  it('trims search text before filtering topic names', async () => {
+    const topics = await listTopics({ search: '  ORDER-CREATE  ' });
+
+    expect(topics.map((topic) => topic.name)).toEqual(['order-create']);
+  });
+
+  it('ignores blank search text', async () => {
+    const allTopics = await listTopics();
+    const blankSearchTopics = await listTopics({ search: '   ' });
+
+    expect(blankSearchTopics).toHaveLength(allTopics.length);
+  });
+});
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 39d25cc1..1390fda3 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -10,16 +10,21 @@ import type {
 } from '../api/metadata';
 import { topics as mockTopics, topicRoutes, topicConsumers } from 
'../mock/topics';
 
+const cloneTopic = (topic: Topic): Topic => ({ ...topic });
+const cloneRoutes = (routes: BrokerRoute[]): BrokerRoute[] => 
routes.map((route) => ({ ...route }));
+const cloneConsumers = (consumers: ConsumerGroupInfo[]): ConsumerGroupInfo[] =>
+  consumers.map((consumer) => ({ ...consumer }));
+
 export async function listTopics(params?: TopicQuery): Promise<Topic[]> {
   if (USE_MOCK) {
     let result = [...mockTopics];
     if (params?.search) {
-      const kw = params.search.toLowerCase();
-      result = result.filter((t) => t.name.toLowerCase().includes(kw));
+      const keyword = params.search.trim().toLowerCase();
+      if (keyword) result = result.filter((topic) => 
topic.name.toLowerCase().includes(keyword));
     }
     if (params?.type) result = result.filter((t) => t.type === params.type);
     if (params?.clusterId) result = result.filter((t) => t.clusterId === 
params.clusterId);
-    return result as unknown as Topic[];
+    return (result as unknown as Topic[]).map(cloneTopic);
   }
   return metadataApi.listTopics(params);
 }
@@ -35,7 +40,7 @@ export async function createTopic(data: Partial<Topic>): 
Promise<Topic> {
       consumerGroupCount: 0,
     } as unknown as Topic;
     mockTopics.unshift(topic as never);
-    return topic;
+    return cloneTopic(topic);
   }
   return metadataApi.createTopic(data);
 }
@@ -45,7 +50,7 @@ export async function updateTopic(data: Partial<Topic>): 
Promise<Topic> {
     const idx = mockTopics.findIndex((t) => t.name === data.name);
     if (idx < 0) throw new Error(`Topic not found: ${data.name}`);
     Object.assign(mockTopics[idx], data, { updatedAt: new Date().toISOString() 
});
-    return mockTopics[idx] as unknown as Topic;
+    return cloneTopic(mockTopics[idx] as unknown as Topic);
   }
   return metadataApi.updateTopic(data);
 }
@@ -67,12 +72,12 @@ export async function batchDeleteTopics(names: string[]): 
Promise<void> {
 }
 
 export async function getTopicRoutes(name: string): Promise<BrokerRoute[]> {
-  if (USE_MOCK) return (topicRoutes[name] as unknown as BrokerRoute[]) ?? [];
+  if (USE_MOCK) return cloneRoutes((topicRoutes[name] as unknown as 
BrokerRoute[]) ?? []);
   return metadataApi.getTopicRoutes(name);
 }
 
 export async function getTopicConsumers(name: string): 
Promise<ConsumerGroupInfo[]> {
-  if (USE_MOCK) return (topicConsumers[name] as unknown as 
ConsumerGroupInfo[]) ?? [];
+  if (USE_MOCK) return cloneConsumers((topicConsumers[name] as unknown as 
ConsumerGroupInfo[]) ?? []);
   return metadataApi.getTopicConsumers(name);
 }
 

Reply via email to