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