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 7b7ce9bf feat: add vendor tabs and cloud instance flows to the
frontend (#1149)
7b7ce9bf is described below
commit 7b7ce9bf7fd738b40d90f03abdb79cb6bb46f845
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 6 20:48:23 2026 +0800
feat: add vendor tabs and cloud instance flows to the frontend (#1149)
---
web/src/api/aliyunCatalog.test.ts | 70 +++++
web/src/api/aliyunCatalog.ts | 35 +++
web/src/api/cloudCredential.test.ts | 56 ++++
web/src/api/cloudCredential.ts | 22 ++
web/src/api/instance.ts | 16 +-
web/src/api/metadata.ts | 26 +-
web/src/assets/logos/alibabacloud.svg | 1 +
web/src/assets/logos/apache-feather.svg | 29 ++
web/src/assets/logos/tencentcloud.svg | 1 +
.../pages/instance/__tests__/ConsumerPage.test.tsx | 23 +-
.../pages/instance/__tests__/InstancePage.test.tsx | 17 ++
.../pages/instance/__tests__/TopicPage.test.tsx | 19 +-
web/src/pages/instance/consumer.tsx | 106 ++++---
web/src/pages/instance/index.tsx | 309 +++++++++++++++++----
web/src/pages/instance/topic.tsx | 210 ++++++++------
web/src/pages/instance/vendorOptions.ts | 52 ++++
web/src/services/consumerService.ts | 18 +-
web/src/services/instanceService.ts | 4 +
web/src/services/topicService.ts | 15 +-
web/src/utils/format.ts | 6 +-
20 files changed, 818 insertions(+), 217 deletions(-)
diff --git a/web/src/api/aliyunCatalog.test.ts
b/web/src/api/aliyunCatalog.test.ts
new file mode 100644
index 00000000..4272d134
--- /dev/null
+++ b/web/src/api/aliyunCatalog.test.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { listAliyunInstances, listAliyunRegions } from './aliyunCatalog';
+
+const mock = new MockAdapter(client);
+
+describe('aliyunCatalog API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('lists regions for a credential', async () => {
+ mock.onGet('/cloud/aliyun/regions').reply(200, {
+ code: 200,
+ data: [{ regionId: 'cn-hangzhou', regionName: '华东1(杭州)' }],
+ });
+
+ const regions = await listAliyunRegions('cred-1');
+
+ expect(regions[0].regionId).toBe('cn-hangzhou');
+ });
+
+ it('lists cloud instances with credential and region params', async () => {
+ mock.onGet('/cloud/aliyun/instances').reply((config) => {
+ expect(config.params).toMatchObject({ credentialId: 'cred-1', regionId:
'cn-hangzhou' });
+ return [
+ 200,
+ {
+ code: 200,
+ data: [
+ {
+ instanceId: 'rmq-cn-xxx',
+ instanceName: 'prod-mq',
+ status: 'RUNNING',
+ regionId: 'cn-hangzhou',
+ },
+ ],
+ },
+ ];
+ });
+
+ const instances = await listAliyunInstances('cred-1', 'cn-hangzhou');
+
+ expect(instances[0].instanceId).toBe('rmq-cn-xxx');
+ });
+});
diff --git a/web/src/api/aliyunCatalog.ts b/web/src/api/aliyunCatalog.ts
new file mode 100644
index 00000000..fac4679e
--- /dev/null
+++ b/web/src/api/aliyunCatalog.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+
+export interface CloudRegion {
+ regionId: string;
+ regionName: string;
+}
+
+export interface CloudInstanceOption {
+ instanceId: string;
+ instanceName: string;
+ status: string;
+ regionId: string;
+ topicCount?: number;
+ groupCount?: number;
+ remark?: string;
+}
+
+export async function listAliyunRegions(credentialId: string) {
+ const res = await client.get<{ data: CloudRegion[]
}>('/cloud/aliyun/regions', {
+ params: { credentialId },
+ });
+ return res.data.data;
+}
+
+export async function listAliyunInstances(credentialId: string, regionId:
string, search?: string) {
+ const res = await client.get<{ data: CloudInstanceOption[]
}>('/cloud/aliyun/instances', {
+ params: { credentialId, regionId, ...(search ? { search } : {}) },
+ });
+ return res.data.data;
+}
diff --git a/web/src/api/cloudCredential.test.ts
b/web/src/api/cloudCredential.test.ts
new file mode 100644
index 00000000..77502914
--- /dev/null
+++ b/web/src/api/cloudCredential.test.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { listCloudCredentials } from './cloudCredential';
+
+const mock = new MockAdapter(client);
+
+describe('cloudCredential API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('returns masked credentials from the backend', async () => {
+ mock.onGet('/cloud-credentials').reply(200, {
+ code: 200,
+ data: [
+ {
+ id: 'cred-1',
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI****0001',
+ createdAt: '2026-08-06T00:00:00Z',
+ },
+ ],
+ });
+
+ const credentials = await listCloudCredentials();
+
+ expect(credentials).toHaveLength(1);
+ expect(credentials[0].vendor).toBe('ALIYUN');
+ expect(credentials[0].secretKey).toBeUndefined();
+ });
+});
diff --git a/web/src/api/cloudCredential.ts b/web/src/api/cloudCredential.ts
new file mode 100644
index 00000000..ef0524b5
--- /dev/null
+++ b/web/src/api/cloudCredential.ts
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+import type { InstanceVendor } from './instance';
+
+export interface CloudCredential {
+ id: string;
+ name: string;
+ vendor: InstanceVendor;
+ accessKey: string;
+ secretKey?: string;
+ remark?: string;
+ createdAt: string;
+}
+
+export async function listCloudCredentials() {
+ const res = await client.get<{ data: CloudCredential[]
}>('/cloud-credentials');
+ return res.data.data;
+}
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 98b0609d..76344f6e 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -18,12 +18,18 @@
import client from './client';
// ─── Types ──────────────────────────────────────────────────────
+export type InstanceVendor = 'APACHE' | 'ALIYUN' | 'TENCENT';
+
export interface Instance {
id: string;
name: string;
remark: string;
type: 'PROXY' | 'DIRECT';
endpoint: string;
+ vendor?: InstanceVendor;
+ cloudInstanceId?: string;
+ credentialId?: string;
+ regionId?: string;
topicCount: number;
consumerGroupCount: number;
createdAt: string;
@@ -31,10 +37,14 @@ export interface Instance {
}
export interface CreateInstanceRequest {
- name: string;
- type: 'PROXY' | 'DIRECT';
- endpoint: string;
+ name?: string;
+ type?: 'PROXY' | 'DIRECT';
+ endpoint?: string;
remark?: string;
+ vendor?: InstanceVendor;
+ cloudInstanceId?: string;
+ credentialId?: string;
+ regionId?: string;
}
export interface UpdateInstanceRequest {
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index f3897b18..7d63cccc 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -96,6 +96,7 @@ export interface SubscriptionEntry {
}
export interface ConsumerGroupQuery {
+ instanceId?: string;
clusterId?: string;
search?: string;
}
@@ -122,20 +123,22 @@ export async function updateTopic(data: Partial<Topic>) {
return res.data.data;
}
-export async function deleteTopic(name: string) {
- await client.post('/topics/delete', { name });
+export async function deleteTopic(name: string, instanceId?: string) {
+ await client.post('/topics/delete', { name, ...(instanceId ? { instanceId }
: {}) });
}
-export async function getTopicRoutes(name: string) {
+export async function getTopicRoutes(name: string, instanceId?: string) {
const res = await client.get<{ data: BrokerRoute[] }>(
`/topics/${encodeURIComponent(name)}/routes`,
+ { params: instanceId ? { instanceId } : {} },
);
return res.data.data;
}
-export async function getTopicConsumers(name: string) {
+export async function getTopicConsumers(name: string, instanceId?: string) {
const res = await client.get<{ data: ConsumerGroupInfo[] }>(
`/topics/${encodeURIComponent(name)}/consumers`,
+ { params: instanceId ? { instanceId } : {} },
);
return res.data.data;
}
@@ -146,6 +149,7 @@ export interface SendTopicMessageRequest {
key?: string;
body: string;
properties?: Record<string, string>;
+ instanceId?: string;
}
export interface SendTopicMessageResult {
@@ -165,23 +169,26 @@ export async function listConsumerGroups(params?:
ConsumerGroupQuery) {
return res.data.data;
}
-export async function getConsumerGroup(name: string) {
+export async function getConsumerGroup(name: string, instanceId?: string) {
const res = await client.get<{ data: ConsumerGroupDetail }>(
`/groups/${encodeURIComponent(name)}`,
+ { params: instanceId ? { instanceId } : {} },
);
return res.data.data;
}
-export async function getConsumerProgress(name: string) {
+export async function getConsumerProgress(name: string, instanceId?: string) {
const res = await client.get<{ data: QueueProgress[] }>(
`/groups/${encodeURIComponent(name)}/progress`,
+ { params: instanceId ? { instanceId } : {} },
);
return res.data.data;
}
-export async function getConsumerSubscriptions(name: string) {
+export async function getConsumerSubscriptions(name: string, instanceId?:
string) {
const res = await client.get<{ data: SubscriptionEntry[] }>(
`/groups/${encodeURIComponent(name)}/subscriptions`,
+ { params: instanceId ? { instanceId } : {} },
);
return res.data.data;
}
@@ -191,14 +198,15 @@ export async function createConsumerGroup(data:
Partial<ConsumerGroup>) {
return res.data.data;
}
-export async function deleteConsumerGroup(name: string) {
- await client.post('/groups/delete', { name });
+export async function deleteConsumerGroup(name: string, instanceId?: string) {
+ await client.post('/groups/delete', { name, ...(instanceId ? { instanceId }
: {}) });
}
export interface ResetConsumerOffsetRequest {
name: string;
timestamp: number;
topic?: string;
+ instanceId?: string;
}
export async function resetConsumerOffset(data: ResetConsumerOffsetRequest) {
diff --git a/web/src/assets/logos/alibabacloud.svg
b/web/src/assets/logos/alibabacloud.svg
new file mode 100644
index 00000000..c1d8e4ff
--- /dev/null
+++ b/web/src/assets/logos/alibabacloud.svg
@@ -0,0 +1 @@
+<svg fill="#FF6A00" role="img" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"><title>Alibaba Cloud</title><path d="M3.996
4.517h5.291L8.01 6.324 4.153 7.506a1.668 1.668 0 0 0-1.165 1.601v5.786a1.668
1.668 0 0 0 1.165 1.6l3.857 1.183 1.277 1.807H3.996A3.996 3.996 0 0 1 0
15.487V8.513a3.996 3.996 0 0 1 3.996-3.996m16.008 0h-5.291l1.277 1.807 3.857
1.182c.715.227 1.17.889 1.165 1.601v5.786a1.668 1.668 0 0 1-1.165 1.6l-3.857
1.183-1.277 1.807h5.291A3.996 3.996 0 0 0 24 15.487V8.513a3 [...]
\ No newline at end of file
diff --git a/web/src/assets/logos/apache-feather.svg
b/web/src/assets/logos/apache-feather.svg
new file mode 100644
index 00000000..261459fa
--- /dev/null
+++ b/web/src/assets/logos/apache-feather.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 650 1000">
+ <defs>
+ <style>
+ .cls-1 {
+ fill: #7c297d;
+ }
+
+ .cls-2 {
+ fill: #f79a23;
+ }
+
+ .cls-3 {
+ fill: #dd552c;
+ }
+
+ .cls-4 {
+ fill: #d22128;
+ }
+ </style>
+ </defs>
+ <path class="cls-3"
d="M276.7092915,398.1515795c25.5279479-63.1242453,54.0110775-126.1246793,84.5729347-181.9636035-45.6212286-33.8852148-89.4276433-106.9534674-107.4259055-139.3268803-6.4642564,7.3439687-10.6608099,15.8022396-12.563595,22.6835448-16.9556402,61.2214602,43.4023987,135.1498759-5.21311,108.1394499-40.5058645-22.5076023-131.7157397-71.797557-166.5067324-22.8073561,38.9647388,50.0654049,140.8777805,176.0271745,207.1364082,213.2748452Z"/>
+ <path class="cls-2"
d="M361.2822261,216.187976c29.6137228-54.1055651,61.1725873-101.4927347,93.8913687-135.6320886,0,0-32.6340684,47.2372927-79.2457879,141.6662634,28.2289905,7.7740502,108.6249208,23.7261667,220.6090393-5.2000772,2.7531737-20.350678-10.9279818-42.734469-79.1056856-50.2283145-44.5101845-4.8872906,53.4246026-106.2822795-17.5225659-154.2363748-2.2905102-1.5509002-4.5419221-2.9193416-6.7477192-4.1444224-2.3784814-.8536468-4.8905488-1.6356133-7.5720422-2.3328667-82.8591248-
[...]
+ <path class="cls-4"
d="M210.0661969,580.0239535c18.7052902-56.0344158,41.2063761-118.989235,66.6430946-181.872374-66.2586277-37.2476707-168.1716694-163.2094403-207.1364082-213.2748452-6.9562436,9.787614-11.7099483,23.4394457-13.2934304,42.1088958-8.4973692,100.2806866,94.9567981,174.521889,74.3324318,188.0824913-27.2808561,17.9396147-81.5786546-43.0928703-102.978471-4.3138485,31.0180043,39.8477093,94.2008971,111.8472744,182.4327833,169.2696807Z"/>
+ <path class="cls-3"
d="M496.7155649,363.7515701c-52.3819806-18.6824828,54.7376547-68.786986,89.5221309-121.9411586,4.4506926-6.7965921,9.0512622-15.5806824,10.2991504-24.7883379-111.9841185,28.9262439-192.3800488,12.9741274-220.6090393,5.2000772-24.1301828,48.8924551-51.9942555,110.5048986-81.0540854,184.7917156,30.2653616,12.9415455,153.8421334,60.7457639,328.3900879,60.9933866,29.3465509-76.4372249-76.8347245-86.5311091-126.5482445-104.255683Z"/>
+ <path class="cls-4"
d="M230.3060964,590.1113213c30.7801562,9.5921223,132.7681363,38.244678,241.0835287,33.9308295,14.5510932-39.3980786-39.8509675-43.2427472-44.282111-74.84071-3.430878-24.4494858,143.1682907,20.5461697,190.3371613-68.3569045,2.3849978-4.4963073,4.2454264-8.7384756,5.819134-12.8372833-174.5479545-.2476227-298.1247263-48.0518411-328.3900879-60.9933866-21.2369067,54.2880239-43.0830957,115.2162467-64.5676252,183.0974549Z"/>
+ <path class="cls-1"
d="M230.3060964,590.1113213c-13.8310324,43.6923779-27.4763477,90.3692613-40.7209052,139.6983144-4.6983154,17.4899839-9.3412414,35.3057873-13.9190036,53.5125738,102.8057868,33.9373459,197.4726056.0781966,200.6819264-41.8873386.0260655-.3323358-.0358401-.5799585-.016291-.8992615,2.4469035-44.4482789-64.1733837-19.8098179-62.5964179-46.5335229,1.5834822-26.9191966,116.3077416-.1563933,151.7862131-57.872037,2.7205918-4.4246271,4.4930492-8.3540087,5.8680069-12.0878987-10
[...]
+ <path class="cls-1"
d="M27.6334136,410.7542728c-1.4987691,2.7173336-2.8509195,5.8582323-4.0043201,9.6116715-19.9238547,64.7533422,120.9604422,151.7405984,101.7924885,170.7032859-17.2782014,17.0859679-39.7955782-21.9602257-67.5619052-5.8321668-3.0431529,1.7724574-6.1319206,4.0075783-9.3021431,7.2592556-31.4024712,32.1714049-.4919873,124.8539837,88.6033203,174.3263973-20.7905342,69.8100589-41.489839,147.8047004-61.7525458,229.3703222,7.3504851-2.573973,16.1476081-5.1544625,18.3371143-12.
[...]
+</svg>
\ No newline at end of file
diff --git a/web/src/assets/logos/tencentcloud.svg
b/web/src/assets/logos/tencentcloud.svg
new file mode 100644
index 00000000..53ce4ad1
--- /dev/null
+++ b/web/src/assets/logos/tencentcloud.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" viewBox="-1 0
24 22"><path fill="#0052d9" d="M94.753 5.145c1.529 0 3.031.561 4.067
1.494v1.409a.08.08 0 0 1-.137.056c-.918-.954-2.454-1.659-3.928-1.659-2.534
0-4.11 1.784-4.11 4.517.001 2.733 1.653 4.5 4.21 4.5 1.722 0 3.08-.832
3.972-1.817.048-.055.138-.02.138.053l.002.002v1.545c-1.039.96-2.557 1.535-4.112
1.535-3.26 0-5.45-2.338-5.45-5.818 0-1.71.502-3.166 1.45-4.209.958-1.052
2.37-1.608 3.898-1.608M59.316 8.196c1.18 0 2.32 [...]
\ No newline at end of file
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index fd191da3..c7758803 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -37,7 +37,19 @@ vi.mock('../../../services/consumerService', () => ({
resetConsumerOffset: vi.fn(),
}));
vi.mock('../../../services/instanceService', () => ({
- listInstances: vi.fn().mockResolvedValue([]),
+ listInstances: vi.fn().mockResolvedValue([
+ {
+ id: 'instance-1',
+ name: 'instance-1',
+ remark: '',
+ type: 'PROXY',
+ endpoint: '10.0.0.1:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ]),
}));
beforeAll(() => {
@@ -68,6 +80,7 @@ const group: ConsumerGroup = {
name: 'remote-cg',
namespace: 'remote-ns',
clusterId: 'cluster-a',
+ instanceId: 'instance-1',
subscriptionMode: 'Push',
consumeType: 'CLUSTERING',
onlineInstances: 1,
@@ -189,10 +202,13 @@ describe('Consumer page', () => {
await user.click(await screen.findByRole('button', { name: /详情/ }));
await waitFor(() =>
-
expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith('remote-cg'),
+ expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith(
+ 'remote-cg',
+ 'instance-1',
+ ),
);
await waitFor(() =>
-
expect(consumerService.getConsumerProgress).toHaveBeenCalledWith('remote-cg'),
+
expect(consumerService.getConsumerProgress).toHaveBeenCalledWith('remote-cg',
'instance-1'),
);
expect(consumerService.getConsumerGroup).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getAllByText('remote-topic').length).toBeGreaterThan(0));
@@ -329,6 +345,7 @@ describe('Consumer page', () => {
retryMaxTimes: 16,
subscriptionDataType: 'NORMAL',
subscribedTopics: [],
+ instanceId: 'instance-1',
});
expect(await screen.findByText('已导入 1 个 Group,1 个失败')).toBeInTheDocument();
expect(screen.getByText('broker rejected group')).toBeInTheDocument();
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 48d81511..6a628f9f 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -188,4 +188,21 @@ describe('InstancePage', () => {
);
expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
});
+
+ it('shows vendor tabs in the add instance modal and switches description',
async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+
+ expect(within(dialog).getByRole('tab', { name: /开源版/
})).toBeInTheDocument();
+ expect(within(dialog).getByRole('tab', { name: /Aliyun 版/
})).toBeInTheDocument();
+ expect(within(dialog).getByRole('tab', { name: /Tencent 版/
})).toBeInTheDocument();
+ expect(within(dialog).getByText(/自建 Apache RocketMQ
开源集群/)).toBeInTheDocument();
+
+ await user.click(within(dialog).getByRole('tab', { name: /Aliyun 版/ }));
+ expect(within(dialog).getByText(/云凭据与云上实例完成接入/)).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 05ae8715..9fb9cc26 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -73,6 +73,7 @@ const buildTopics = (count: number): Topic[] =>
namespace: 'default',
type: 'NORMAL',
clusterId: 'rmq-cn-v5-prod-01',
+ instanceId: 'instance-proxy-1',
writeQueues: 8,
readQueues: 8,
perm: 'RW',
@@ -122,7 +123,19 @@ describe('TopicPage', () => {
}));
topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
- instanceServiceMocks.listInstances.mockResolvedValue([]);
+ instanceServiceMocks.listInstances.mockResolvedValue([
+ {
+ id: 'instance-proxy-1',
+ name: 'instance-proxy-1',
+ remark: '',
+ type: 'PROXY',
+ endpoint: '10.0.2.21:8080',
+ topicCount: 1,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
});
afterEach(() => {
@@ -186,7 +199,9 @@ describe('TopicPage', () => {
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
- await waitFor(() =>
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-21'));
+ await waitFor(() =>
+
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-21',
'instance-proxy-1'),
+ );
const closeButton = document.querySelector('.ant-modal-close');
expect(closeButton).not.toBeNull();
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 742e0d45..3bc792eb 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -130,7 +130,7 @@ const GROUP_EXPORT_COLUMNS: Array<{ header: string; value:
(group: ConsumerGroup
{ header: 'Subscription Data Type', value: (group) =>
group.subscriptionDataType },
{ header: 'Delivery Order Type', value: (group) => group.deliveryOrderType },
{ header: 'Retry Max Times', value: (group) => group.retryMaxTimes },
- { header: 'Subscribed Topics', value: (group) =>
group.subscribedTopics.join(';') },
+ { header: 'Subscribed Topics', value: (group) => (group.subscribedTopics ??
[]).join(';') },
{ header: 'Created At', value: (group) => group.createdAt },
{ header: 'Updated At', value: (group) => group.updatedAt },
];
@@ -181,7 +181,10 @@ const isInconsistentSubscription = (subscription:
SubscriptionEntry): boolean =>
═══════════════════════════════════════════ */
const ConsumerPage = () => {
const { t } = useLang();
- const { selectedInstanceId, selectInstance, instanceOptions } =
useInstanceFilter();
+ const { selectedInstanceId, selectedInstance, selectInstance,
instanceOptions } =
+ useInstanceFilter();
+ const isCloudInstance =
+ selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
const [groups, setGroups] = useState<ConsumerGroup[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -216,25 +219,30 @@ const ConsumerPage = () => {
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
- useEffect(() => {
- let cancelled = false;
-
- const fetchGroups = async () => {
- try {
- const nextGroups = await listConsumerGroups();
- if (!cancelled) setGroups(nextGroups);
- } catch {
- if (!cancelled) message.error(t('consumer.fetchListFailed'));
- } finally {
- if (!cancelled) setLoading(false);
- }
- };
+ const groupRequestIdRef = useRef(0);
- void fetchGroups();
+ useEffect(() => {
+ if (!selectedInstanceId) {
+ return;
+ }
+ const requestId = ++groupRequestIdRef.current;
+ const timer = window.setTimeout(() => {
+ setLoading(true);
+ void listConsumerGroups({ instanceId: selectedInstanceId })
+ .then((nextGroups) => {
+ if (requestId === groupRequestIdRef.current) setGroups(nextGroups);
+ })
+ .catch(() => {
+ if (requestId === groupRequestIdRef.current)
message.error(t('consumer.fetchListFailed'));
+ })
+ .finally(() => {
+ if (requestId === groupRequestIdRef.current) setLoading(false);
+ });
+ }, 0);
return () => {
- cancelled = true;
+ window.clearTimeout(timer);
};
- }, [t]);
+ }, [t, selectedInstanceId]);
const loadSubscriptions = useCallback(
async (groupName: string, force = false) => {
@@ -242,7 +250,10 @@ const ConsumerPage = () => {
setSubscriptionLoadingByGroup((prev) => ({ ...prev, [groupName]: true
}));
setSubscriptionErrorByGroup((prev) => ({ ...prev, [groupName]: false }));
try {
- const subscriptions = await getConsumerSubscriptions(groupName);
+ const subscriptions = await getConsumerSubscriptions(
+ groupName,
+ selectedInstanceId || undefined,
+ );
setSubscriptionsByGroup((prev) => ({ ...prev, [groupName]:
subscriptions }));
} catch {
setSubscriptionErrorByGroup((prev) => ({ ...prev, [groupName]: true
}));
@@ -251,26 +262,26 @@ const ConsumerPage = () => {
setSubscriptionLoadingByGroup((prev) => ({ ...prev, [groupName]: false
}));
}
},
- [subscriptionsByGroup, t],
+ [subscriptionsByGroup, t, selectedInstanceId],
);
const loadProgress = useCallback(
async (groupName: string) => {
if (progressByGroup[groupName]) return;
try {
- const progress = await getConsumerProgress(groupName);
+ const progress = await getConsumerProgress(groupName,
selectedInstanceId || undefined);
setProgressByGroup((prev) => ({ ...prev, [groupName]: progress }));
} catch {
message.error(t('consumer.fetchProgressFailed', { name: groupName }));
}
},
- [progressByGroup, t],
+ [progressByGroup, t, selectedInstanceId],
);
/* ─── Filtered & sorted data ─── */
const filtered = useMemo(() => {
let data = groups.filter(
- (g) => g.name.includes(search) || g.subscribedTopics.some((t) =>
t.includes(search)),
+ (g) => g.name.includes(search) || (g.subscribedTopics ?? []).some((t) =>
t.includes(search)),
);
if (selectedInstanceId) {
@@ -421,7 +432,7 @@ const ConsumerPage = () => {
dataIndex: 'subscriptionDataType',
key: 'subscriptionDataType',
width: 110,
- sorter: (a, b) =>
a.subscriptionDataType.localeCompare(b.subscriptionDataType),
+ sorter: (a, b) => (a.subscriptionDataType ??
'').localeCompare(b.subscriptionDataType ?? ''),
render: (type: string) => {
const config = TOPIC_TYPE_MAP[type] || { labelKey: type, color:
'default' };
return <Tag color={config.color}>{t(config.labelKey)}</Tag>;
@@ -432,7 +443,7 @@ const ConsumerPage = () => {
dataIndex: 'subscriptionMode',
key: 'subscriptionMode',
width: 90,
- sorter: (a, b) => a.subscriptionMode.localeCompare(b.subscriptionMode),
+ sorter: (a, b) => (a.subscriptionMode ??
'').localeCompare(b.subscriptionMode ?? ''),
render: (mode: string) => <Tag color={mode === 'Push' ? 'blue' :
'green'}>{mode}</Tag>,
},
{
@@ -441,30 +452,30 @@ const ConsumerPage = () => {
key: 'onlineInstances',
width: 130,
align: 'center',
- sorter: (a, b) => a.onlineInstances - b.onlineInstances,
+ sorter: (a, b) => (a.onlineInstances ?? 0) - (b.onlineInstances ?? 0),
},
{
title: '总堆积量',
dataIndex: 'totalLag',
key: 'totalLag',
width: 120,
- sorter: (a, b) => a.totalLag - b.totalLag,
- render: (lag: number) => lag.toLocaleString(),
+ sorter: (a, b) => (a.totalLag ?? 0) - (b.totalLag ?? 0),
+ render: (lag: number) => (lag ?? 0).toLocaleString(),
},
{
title: '消费延迟',
dataIndex: 'delaySeconds',
key: 'delaySeconds',
width: 160,
- sorter: (a, b) => a.delaySeconds - b.delaySeconds,
- render: (seconds: number) => formatDelay(seconds),
+ sorter: (a, b) => (a.delaySeconds ?? 0) - (b.delaySeconds ?? 0),
+ render: (seconds: number) => formatDelay(seconds ?? 0),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
- sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+ sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
{formatDateTime(d)}
@@ -476,7 +487,7 @@ const ConsumerPage = () => {
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 170,
- sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
+ sorter: (a, b) => (a.updatedAt ?? '').localeCompare(b.updatedAt ?? ''),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
{formatDateTime(d)}
@@ -526,7 +537,7 @@ const ConsumerPage = () => {
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
- await deleteConsumerGroup(record.name);
+ await deleteConsumerGroup(record.name, selectedInstanceId ||
undefined);
setGroups((prev) => prev.filter((group) => group.name !==
record.name));
setSelectedRowKeys((prev) => prev.filter((key) => key !==
record.name));
message.success(`消费组 ${record.name} 已删除`);
@@ -1264,19 +1275,23 @@ const ConsumerPage = () => {
<Input placeholder="例:cg-order-notify" />
</Form.Item>
- <Form.Item label="订阅模式" name="subscriptionMode">
- <Radio.Group>
- <Radio.Button value="Push">Push</Radio.Button>
- <Radio.Button value="Pop">Pop</Radio.Button>
- </Radio.Group>
- </Form.Item>
+ {!isCloudInstance && (
+ <Form.Item label="订阅模式" name="subscriptionMode">
+ <Radio.Group>
+ <Radio.Button value="Push">Push</Radio.Button>
+ <Radio.Button value="Pop">Pop</Radio.Button>
+ </Radio.Group>
+ </Form.Item>
+ )}
- <Form.Item label="消费类型" name="consumeType">
- <Radio.Group>
- <Radio.Button value="CLUSTERING">集群消费</Radio.Button>
- <Radio.Button value="BROADCASTING">广播消费</Radio.Button>
- </Radio.Group>
- </Form.Item>
+ {!isCloudInstance && (
+ <Form.Item label="消费类型" name="consumeType">
+ <Radio.Group>
+ <Radio.Button value="CLUSTERING">集群消费</Radio.Button>
+ <Radio.Button value="BROADCASTING">广播消费</Radio.Button>
+ </Radio.Group>
+ </Form.Item>
+ )}
<Form.Item label="最大重试次数" name="retryMaxTimes">
<InputNumber min={0} max={128} style={{ width: '100%' }} />
@@ -1393,6 +1408,7 @@ const ConsumerPage = () => {
await resetConsumerOffset({
name: resetGroup.name,
timestamp: resetTime.valueOf(),
+ instanceId: selectedInstanceId || undefined,
});
message.success(
`${resetGroup.name} 消费位点已重置到 ${resetTime.format('YYYY-MM-DD
HH:mm:ss')}`,
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index aec233b2..8c8f6664 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -28,6 +28,7 @@ import {
Modal,
Form,
Flex,
+ Tabs,
Typography,
Alert,
message,
@@ -37,6 +38,13 @@ import { Plus, MagnifyingGlass } from
'@phosphor-icons/react';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { Instance, InstanceQuery } from '../../api/instance';
+import { listCloudCredentials, type CloudCredential } from
'../../api/cloudCredential';
+import {
+ listAliyunInstances,
+ listAliyunRegions,
+ type CloudInstanceOption,
+ type CloudRegion,
+} from '../../api/aliyunCatalog';
import { formatDateTime } from '../../utils/format';
import {
createInstance,
@@ -44,9 +52,12 @@ import {
listInstances,
updateInstance,
} from '../../services/instanceService';
+import { DEFAULT_VENDOR, VENDOR_OPTIONS, type InstanceVendor } from
'./vendorOptions';
const { Text } = Typography;
+const DEFAULT_ALIYUN_REGION_ID = 'cn-hangzhou';
+
/* ─── Helpers ─── */
const typeLabel: Record<string, { text: string; color: string }> = {
PROXY: { text: 'Proxy 模式', color: 'blue' },
@@ -67,8 +78,17 @@ const InstancePage = () => {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<InstanceTypeFilter>('ALL');
const [addModalOpen, setAddModalOpen] = useState(false);
+ const [vendor, setVendor] = useState<InstanceVendor>(DEFAULT_VENDOR);
const [addForm] = Form.useForm();
const addInstanceType = Form.useWatch<'PROXY' | 'DIRECT' |
undefined>('type', addForm);
+ const addCredentialId = Form.useWatch<string | undefined>('credentialId',
addForm);
+ const addRegionId = Form.useWatch<string | undefined>('regionId', addForm);
+ const [credentials, setCredentials] = useState<CloudCredential[]>([]);
+ const [credentialsLoading, setCredentialsLoading] = useState(false);
+ const [regions, setRegions] = useState<CloudRegion[]>([]);
+ const [regionsLoading, setRegionsLoading] = useState(false);
+ const [cloudInstances, setCloudInstances] =
useState<CloudInstanceOption[]>([]);
+ const [cloudInstancesLoading, setCloudInstancesLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance |
null>(null);
const [editForm] = Form.useForm();
@@ -113,15 +133,88 @@ const InstancePage = () => {
};
}, [loadInstances]);
+ useEffect(() => {
+ if (vendor !== 'ALIYUN' || !addModalOpen) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ setCredentialsLoading(true);
+ listCloudCredentials()
+ .then((items) => setCredentials(items.filter((item) => item.vendor ===
'ALIYUN')))
+ .catch(() => message.error('云凭据列表加载失败'))
+ .finally(() => setCredentialsLoading(false));
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [vendor, addModalOpen]);
+
+ useEffect(() => {
+ if (vendor !== 'ALIYUN' || !addCredentialId) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ setRegionsLoading(true);
+ listAliyunRegions(addCredentialId)
+ .then((items) => {
+ setRegions(items);
+ if (!addForm.getFieldValue('regionId')) {
+ const preferred = items.find((region) => region.regionId ===
DEFAULT_ALIYUN_REGION_ID);
+ if (preferred) {
+ addForm.setFieldsValue({ regionId: preferred.regionId });
+ }
+ }
+ })
+ .catch(() => message.error('云地域列表加载失败'))
+ .finally(() => setRegionsLoading(false));
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [vendor, addCredentialId, addForm]);
+
+ useEffect(() => {
+ if (vendor !== 'ALIYUN' || !addCredentialId || !addRegionId) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ setCloudInstancesLoading(true);
+ listAliyunInstances(addCredentialId, addRegionId)
+ .then(setCloudInstances)
+ .catch(() => message.error('云实例列表加载失败'))
+ .finally(() => setCloudInstancesLoading(false));
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [vendor, addCredentialId, addRegionId]);
+
+ const handleCredentialChange = () => {
+ setRegions([]);
+ setCloudInstances([]);
+ addForm.setFieldsValue({ regionId: undefined, cloudInstanceId: undefined
});
+ };
+
+ const handleRegionChange = () => {
+ setCloudInstances([]);
+ addForm.setFieldsValue({ cloudInstanceId: undefined });
+ };
+
const handleCreate = async () => {
try {
const values = await addForm.validateFields();
setSubmitting(true);
- const created = await createInstance(values);
+ const payload =
+ vendor === 'ALIYUN'
+ ? {
+ name: values.name,
+ vendor: 'ALIYUN' as const,
+ credentialId: values.credentialId,
+ cloudInstanceId: values.cloudInstanceId,
+ regionId: values.regionId,
+ remark: values.remark,
+ }
+ : values;
+ const created = await createInstance(payload);
await loadInstances();
message.success(`实例「${created.name}」添加成功`);
setAddModalOpen(false);
addForm.resetFields();
+ setVendor(DEFAULT_VENDOR);
} catch {
message.error('添加实例失败,请稍后重试');
} finally {
@@ -183,6 +276,24 @@ const InstancePage = () => {
</Text>
),
},
+ {
+ title: '厂商',
+ dataIndex: 'vendor',
+ key: 'vendor',
+ width: 140,
+ render: (value?: string) => {
+ const option = VENDOR_OPTIONS.find((item) => item.key === (value ||
'APACHE'));
+ if (!option) {
+ return <Text type="secondary">{value || '-'}</Text>;
+ }
+ return (
+ <Space size={6}>
+ <img src={option.logo} alt={option.label} style={{ height: 16 }} />
+ <Text style={{ fontSize: 13 }}>{option.label}</Text>
+ </Space>
+ );
+ },
+ },
{
title: '类型',
dataIndex: 'type',
@@ -343,65 +454,165 @@ const InstancePage = () => {
onCancel={() => {
setAddModalOpen(false);
addForm.resetFields();
+ setVendor(DEFAULT_VENDOR);
+ setRegions([]);
+ setCloudInstances([]);
}}
onOk={() => void handleCreate()}
confirmLoading={submitting}
+ okButtonProps={{ disabled: vendor === 'TENCENT' }}
okText="连接"
cancelText="取消"
width={520}
>
- <Form form={addForm} layout="vertical" style={{ marginTop: 16 }}>
- <Form.Item
- label="实例名称"
- name="name"
- rules={[{ required: true, message: '请输入实例名称' }]}
- >
- <Input placeholder="例:rocketmq-production" />
- </Form.Item>
- <Form.Item
- label="接入方式"
- name="type"
- rules={[{ required: true, message: '请选择接入方式' }]}
- >
- <Select
- placeholder="选择接入方式"
- options={[
- { value: 'PROXY', label: 'Proxy 模式' },
- { value: 'DIRECT', label: 'Direct 模式' },
- ]}
- />
- </Form.Item>
- <Form.Item
- label="接入地址"
- name="endpoint"
- rules={[{ required: true, message: '请输入接入地址' }]}
- extra={
- addInstanceType === 'DIRECT'
- ? 'Direct 模式请填写 NameServer SLB 地址(K8s 场景下一般为 NameServer
Service 地址,如 namesrv.mq.svc:9876)'
- : addInstanceType === 'PROXY'
- ? 'Proxy 模式请填写 Proxy SLB 内网地址(如 proxy.mq.svc:8080)'
- : '请先选择接入方式'
- }
- >
- <Input
- placeholder={
- addInstanceType === 'DIRECT'
- ? '例:namesrv.mq.svc.cluster.local:9876'
- : '例:proxy.mq.svc.cluster.local:8080'
- }
- />
- </Form.Item>
+ <Tabs
+ type="card"
+ activeKey={vendor}
+ onChange={(key) => setVendor(key as InstanceVendor)}
+ style={{ marginTop: 8, marginBottom: 4 }}
+ items={VENDOR_OPTIONS.map((option) => ({
+ key: option.key,
+ label: (
+ <span style={{ display: 'inline-flex', alignItems: 'center',
gap: 8 }}>
+ <img src={option.logo} alt={option.label} style={{ height: 18,
maxWidth: 80 }} />
+ {option.label}
+ </span>
+ ),
+ }))}
+ />
+ <Text type="secondary" style={{ display: 'block', fontSize: 12,
marginBottom: 12 }}>
+ {VENDOR_OPTIONS.find((option) => option.key === vendor)?.description}
+ </Text>
+ {vendor === 'ALIYUN' ? (
+ <Form form={addForm} layout="vertical">
+ <Form.Item
+ label="云凭据"
+ name="credentialId"
+ rules={[{ required: true, message: '请选择云凭据' }]}
+ extra="凭据为阿里云账号的 AK/SK,在云凭据管理中录入"
+ >
+ <Select
+ placeholder="选择已录入的 AK/SK 凭据"
+ loading={credentialsLoading}
+ onChange={handleCredentialChange}
+ options={credentials.map((item) => ({
+ value: item.id,
+ label: `${item.name}(${item.accessKey})`,
+ }))}
+ />
+ </Form.Item>
+ <Form.Item
+ label="地域"
+ name="regionId"
+ rules={[{ required: true, message: '请选择地域' }]}
+ >
+ <Select
+ placeholder={addCredentialId ? '选择地域' : '请先选择云凭据'}
+ disabled={!addCredentialId}
+ loading={regionsLoading}
+ onChange={handleRegionChange}
+ options={regions.map((region) => ({
+ value: region.regionId,
+ label: `${region.regionName}(${region.regionId})`,
+ }))}
+ />
+ </Form.Item>
+ <Form.Item
+ label="云上实例"
+ name="cloudInstanceId"
+ rules={[{ required: true, message: '请选择云上实例' }]}
+ extra="商业版实例来自云端目录,无法手工创建"
+ >
+ <Select
+ showSearch
+ optionFilterProp="label"
+ placeholder={addRegionId ? '选择云上实例' : '请先选择地域'}
+ disabled={!addRegionId}
+ loading={cloudInstancesLoading}
+ options={cloudInstances.map((item) => ({
+ value: item.instanceId,
+ label: `${item.instanceName ||
item.instanceId}(${item.instanceId})`,
+ }))}
+ onChange={(value) => {
+ const selected = cloudInstances.find((item) =>
item.instanceId === value);
+ if (selected?.instanceName) {
+ addForm.setFieldsValue({ name: selected.instanceName });
+ }
+ }}
+ />
+ </Form.Item>
+ <Form.Item
+ label="实例名称"
+ name="name"
+ rules={[{ required: true, message: '请输入实例名称' }]}
+ >
+ <Input placeholder="默认取云上实例名称" />
+ </Form.Item>
+ <Form.Item label="备注" name="remark">
+ <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
+ </Form.Item>
+ </Form>
+ ) : vendor === 'TENCENT' ? (
<Alert
type="info"
showIcon
- style={{ marginBottom: 16 }}
- message="接入地址为客户端访问入口"
- description="接入地址会展示在 Topic 等页面供客户端配置使用。若客户端环境无法解析该地址(如 K8s 内部
Service 域名),可自行配置 DNS 解析或在客户端 hosts 中映射。"
+ message="Tencent 版接入开发中"
+ description="腾讯云 TDMQ RocketMQ 版接入正在开发中,敬请期待。"
/>
- <Form.Item label="备注" name="remark">
- <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
- </Form.Item>
- </Form>
+ ) : (
+ <Form form={addForm} layout="vertical">
+ <Form.Item
+ label="实例名称"
+ name="name"
+ rules={[{ required: true, message: '请输入实例名称' }]}
+ >
+ <Input placeholder="例:rocketmq-production" />
+ </Form.Item>
+ <Form.Item
+ label="接入方式"
+ name="type"
+ rules={[{ required: true, message: '请选择接入方式' }]}
+ >
+ <Select
+ placeholder="选择接入方式"
+ options={[
+ { value: 'PROXY', label: 'Proxy 模式' },
+ { value: 'DIRECT', label: 'Direct 模式' },
+ ]}
+ />
+ </Form.Item>
+ <Form.Item
+ label="接入地址"
+ name="endpoint"
+ rules={[{ required: true, message: '请输入接入地址' }]}
+ extra={
+ addInstanceType === 'DIRECT'
+ ? 'Direct 模式请填写 NameServer SLB 地址(K8s 场景下一般为 NameServer
Service 地址,如 namesrv.mq.svc:9876)'
+ : addInstanceType === 'PROXY'
+ ? 'Proxy 模式请填写 Proxy SLB 内网地址(如 proxy.mq.svc:8080)'
+ : '请先选择接入方式'
+ }
+ >
+ <Input
+ placeholder={
+ addInstanceType === 'DIRECT'
+ ? '例:namesrv.mq.svc.cluster.local:9876'
+ : '例:proxy.mq.svc.cluster.local:8080'
+ }
+ />
+ </Form.Item>
+ <Alert
+ type="info"
+ showIcon
+ style={{ marginBottom: 16 }}
+ message="接入地址为客户端访问入口"
+ description="接入地址会展示在 Topic 等页面供客户端配置使用。若客户端环境无法解析该地址(如 K8s 内部
Service 域名),可自行配置 DNS 解析或在客户端 hosts 中映射。"
+ />
+ <Form.Item label="备注" name="remark">
+ <Input.TextArea rows={2} placeholder="可选,描述实例用途" />
+ </Form.Item>
+ </Form>
+ )}
</Modal>
{/* Edit Instance Modal */}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 4dbe5120..ea6d0e05 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -269,6 +269,8 @@ const TopicPage = () => {
const { t } = useLang();
const { selectedInstanceId, selectedInstance, selectInstance,
instanceOptions } =
useInstanceFilter();
+ const isCloudInstance =
+ selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
// ─── State ─────────────────────────────────────────────────────
const [topics, setTopics] = useState<Topic[]>([]);
@@ -300,23 +302,32 @@ const TopicPage = () => {
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
+ const topicRequestIdRef = useRef(0);
+
useEffect(() => {
- let cancelled = false;
- void listTopics()
- .then((nextTopics) => {
- if (!cancelled) setTopics(nextTopics);
- })
- .catch(() => {
- if (!cancelled) message.error('Topic 列表加载失败,请稍后重试');
- })
- .finally(() => {
- if (!cancelled) setLoading(false);
- });
+ if (!selectedInstanceId) {
+ return;
+ }
+ const requestId = ++topicRequestIdRef.current;
+ const timer = window.setTimeout(() => {
+ setLoading(true);
+ void listTopics({ instanceId: selectedInstanceId })
+ .then((nextTopics) => {
+ if (requestId === topicRequestIdRef.current) setTopics(nextTopics);
+ })
+ .catch(() => {
+ if (requestId === topicRequestIdRef.current)
+ message.error('Topic 列表加载失败,请稍后重试');
+ })
+ .finally(() => {
+ if (requestId === topicRequestIdRef.current) setLoading(false);
+ });
+ }, 0);
return () => {
- cancelled = true;
+ window.clearTimeout(timer);
};
- }, []);
+ }, [selectedInstanceId]);
// ─── Filtered data ─────────────────────────────────────────────
const filteredTopics = useMemo(
@@ -345,11 +356,11 @@ const TopicPage = () => {
setDetailModalOpen(true);
setDetailLoading(true);
try {
- const [routes, consumers] = await Promise.all([
- getTopicRoutes(topic.name),
- getTopicConsumers(topic.name),
- ]);
- setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes }));
+ const consumers = await getTopicConsumers(topic.name, selectedInstanceId
|| undefined);
+ if (!isCloudInstance) {
+ const routes = await getTopicRoutes(topic.name, selectedInstanceId ||
undefined);
+ setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes
}));
+ }
setConsumersByTopic((previous) => ({ ...previous, [topic.name]:
consumers }));
} catch {
message.error('Topic 详情加载失败,请稍后重试');
@@ -401,7 +412,7 @@ const TopicPage = () => {
cancelText: '取消',
onOk: async () => {
try {
- await deleteTopic(topic.name);
+ await deleteTopic(topic.name, selectedInstanceId || undefined);
setTopics((previous) => previous.filter((item) => item.name !==
topic.name));
message.success(`Topic「${topic.name}」已删除`);
} catch {
@@ -431,7 +442,7 @@ const TopicPage = () => {
dataIndex: 'remark',
key: 'remark',
width: 200,
- sorter: (a, b) => a.remark.localeCompare(b.remark),
+ sorter: (a, b) => (a.remark ?? '').localeCompare(b.remark ?? ''),
render: (remark: string) => (
<Text
type="secondary"
@@ -447,7 +458,7 @@ const TopicPage = () => {
dataIndex: 'type',
key: 'type',
width: 100,
- sorter: (a, b) => a.type.localeCompare(b.type),
+ sorter: (a, b) => (a.type ?? '').localeCompare(b.type ?? ''),
render: (type: string) => {
const cfg = TOPIC_TYPE_MAP[type];
return cfg ? <Tag color={cfg.color}>{t(cfg.labelKey)}</Tag> :
<Tag>{type}</Tag>;
@@ -464,7 +475,7 @@ const TopicPage = () => {
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
- sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+ sorter: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
render: (d: string) => <Text type="secondary">{formatDateTime(d)}</Text>,
},
{
@@ -472,7 +483,7 @@ const TopicPage = () => {
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 170,
- sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
+ sorter: (a, b) => (a.updatedAt ?? '').localeCompare(b.updatedAt ?? ''),
render: (d: string) => <Text type="secondary">{formatDateTime(d)}</Text>,
},
{
@@ -489,14 +500,16 @@ const TopicPage = () => {
>
详情
</Button>
- <Button
- size="small"
- icon={<SendOutlined />}
- style={{ borderColor: '#52c41a', color: '#52c41a' }}
- onClick={() => handleAction('send', record)}
- >
- 发送
- </Button>
+ {!isCloudInstance && (
+ <Button
+ size="small"
+ icon={<SendOutlined />}
+ style={{ borderColor: '#52c41a', color: '#52c41a' }}
+ onClick={() => handleAction('send', record)}
+ >
+ 发送
+ </Button>
+ )}
<Button
size="small"
icon={<DeleteOutlined />}
@@ -785,6 +798,7 @@ const TopicPage = () => {
key: values.key || undefined,
body: values.body,
properties: props,
+ instanceId: selectedInstanceId || undefined,
});
// Keep the modal open for consecutive sends
message.success(`消息发送成功!MsgId: ${result.msgId}`);
@@ -1006,39 +1020,43 @@ const TopicPage = () => {
</Text>
{renderDetailTab(selectedTopic)}
- <Divider style={{ margin: '20px 0 16px' }} />
+ {!isCloudInstance && (
+ <>
+ <Divider style={{ margin: '20px 0 16px' }} />
- {/* Section 2: 路由信息 */}
- <Text strong style={{ fontSize: 14, display: 'block',
marginBottom: 12 }}>
- 路由信息
- </Text>
- {!detailLoading && getRoutes(selectedTopic.name).length === 0 && (
- <Alert
- type="warning"
- showIcon
- style={{ marginBottom: 12 }}
- message="Broker 上没有该 Topic 的路由"
- description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker
上创建或已被删除。可按库中记录的队列数重建。"
- action={
- <Button
- size="small"
- type="primary"
- loading={rebuilding}
- onClick={() => void rebuildTopic(selectedTopic)}
- >
- 在 Broker 上重建
- </Button>
- }
- />
+ {/* Section 2: 路由信息 */}
+ <Text strong style={{ fontSize: 14, display: 'block',
marginBottom: 12 }}>
+ 路由信息
+ </Text>
+ {!detailLoading && getRoutes(selectedTopic.name).length === 0
&& (
+ <Alert
+ type="warning"
+ showIcon
+ style={{ marginBottom: 12 }}
+ message="Broker 上没有该 Topic 的路由"
+ description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker
上创建或已被删除。可按库中记录的队列数重建。"
+ action={
+ <Button
+ size="small"
+ type="primary"
+ loading={rebuilding}
+ onClick={() => void rebuildTopic(selectedTopic)}
+ >
+ 在 Broker 上重建
+ </Button>
+ }
+ />
+ )}
+ <Table<BrokerRoute>
+ columns={routeColumns}
+ dataSource={getRoutes(selectedTopic.name)}
+ rowKey="brokerName"
+ pagination={false}
+ size="small"
+ loading={detailLoading}
+ />
+ </>
)}
- <Table<BrokerRoute>
- columns={routeColumns}
- dataSource={getRoutes(selectedTopic.name)}
- rowKey="brokerName"
- pagination={false}
- size="small"
- loading={detailLoading}
- />
<Divider style={{ margin: '20px 0 16px' }} />
@@ -1097,39 +1115,47 @@ const TopicPage = () => {
</Form.Item>
<Form.Item label="类型" name="type" rules={[{ required: true }]}>
- <Select options={TYPE_OPTIONS.filter((o) => o.value)} />
+ <Select
+ options={TYPE_OPTIONS.filter(
+ (o) => o.value && (!isCloudInstance || o.value !== 'LITE'),
+ )}
+ />
</Form.Item>
- <Row gutter={16}>
- <Col span={12}>
- <Form.Item
- label="写队列数"
- name="writeQueues"
- rules={[{ required: true }]}
- extra="每个 Broker 节点 8 个队列"
- >
- <InputNumber min={1} max={256} style={{ width: '100%' }} />
- </Form.Item>
- </Col>
- <Col span={12}>
- <Form.Item
- label="读队列数"
- name="readQueues"
- rules={[{ required: true }]}
- extra="每个 Broker 节点 8 个队列"
- >
- <InputNumber min={1} max={256} style={{ width: '100%' }} />
- </Form.Item>
- </Col>
- </Row>
+ {!isCloudInstance && (
+ <Row gutter={16}>
+ <Col span={12}>
+ <Form.Item
+ label="写队列数"
+ name="writeQueues"
+ rules={[{ required: true }]}
+ extra="每个 Broker 节点 8 个队列"
+ >
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ <Col span={12}>
+ <Form.Item
+ label="读队列数"
+ name="readQueues"
+ rules={[{ required: true }]}
+ extra="每个 Broker 节点 8 个队列"
+ >
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ </Col>
+ </Row>
+ )}
- <Form.Item label="权限" name="perm" rules={[{ required: true }]}>
- <Radio.Group>
- <Radio.Button value="RW">读写</Radio.Button>
- <Radio.Button value="RO">只读</Radio.Button>
- <Radio.Button value="WO">只写</Radio.Button>
- </Radio.Group>
- </Form.Item>
+ {!isCloudInstance && (
+ <Form.Item label="权限" name="perm" rules={[{ required: true }]}>
+ <Radio.Group>
+ <Radio.Button value="RW">读写</Radio.Button>
+ <Radio.Button value="RO">只读</Radio.Button>
+ <Radio.Button value="WO">只写</Radio.Button>
+ </Radio.Group>
+ </Form.Item>
+ )}
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} placeholder="可选,描述 Topic 用途" />
diff --git a/web/src/pages/instance/vendorOptions.ts
b/web/src/pages/instance/vendorOptions.ts
new file mode 100644
index 00000000..5da6130d
--- /dev/null
+++ b/web/src/pages/instance/vendorOptions.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 apacheFeatherLogo from '../../assets/logos/apache-feather.svg';
+import alibabaCloudLogo from '../../assets/logos/alibabacloud.svg';
+import tencentCloudLogo from '../../assets/logos/tencentcloud.svg';
+
+export type InstanceVendor = 'APACHE' | 'ALIYUN' | 'TENCENT';
+
+export interface VendorOption {
+ key: InstanceVendor;
+ label: string;
+ logo: string;
+ description: string;
+}
+
+export const VENDOR_OPTIONS: VendorOption[] = [
+ {
+ key: 'APACHE',
+ label: '开源版',
+ logo: apacheFeatherLogo,
+ description: '接入自建 Apache RocketMQ 开源集群,支持 Proxy / Direct 两种接入方式',
+ },
+ {
+ key: 'ALIYUN',
+ label: 'Aliyun 版',
+ logo: alibabaCloudLogo,
+ description: '选择已录入的云凭据与云上实例完成接入,接入点自动解析',
+ },
+ {
+ key: 'TENCENT',
+ label: 'Tencent 版',
+ logo: tencentCloudLogo,
+ description: '接入腾讯云 TDMQ RocketMQ 版实例,接入地址填写实例的接入点',
+ },
+];
+
+export const DEFAULT_VENDOR: InstanceVendor = 'APACHE';
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index 256c1b0c..c46d5362 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -51,11 +51,14 @@ export async function listConsumerGroups(params?:
ConsumerGroupQuery): Promise<C
return metadataApi.listConsumerGroups(params);
}
-export async function getConsumerProgress(name: string):
Promise<QueueProgress[]> {
+export async function getConsumerProgress(
+ name: string,
+ instanceId?: string,
+): Promise<QueueProgress[]> {
if (isMockMode()) {
return ((mockQueueProgress[name] as unknown as QueueProgress[]) ??
[]).map(copyQueueProgress);
}
- return metadataApi.getConsumerProgress(name);
+ return metadataApi.getConsumerProgress(name, instanceId);
}
export async function getConsumerGroup(name: string):
Promise<ConsumerGroupDetail> {
@@ -67,13 +70,16 @@ export async function getConsumerGroup(name: string):
Promise<ConsumerGroupDetai
return metadataApi.getConsumerGroup(name);
}
-export async function getConsumerSubscriptions(name: string):
Promise<SubscriptionEntry[]> {
+export async function getConsumerSubscriptions(
+ name: string,
+ instanceId?: string,
+): Promise<SubscriptionEntry[]> {
if (isMockMode()) {
return ((mockSubscriptions[name] as unknown as SubscriptionEntry[]) ??
[]).map(
copySubscription,
);
}
- return metadataApi.getConsumerSubscriptions(name);
+ return metadataApi.getConsumerSubscriptions(name, instanceId);
}
export async function createConsumerGroup(data: Partial<ConsumerGroup>):
Promise<ConsumerGroup> {
@@ -101,13 +107,13 @@ export async function createConsumerGroup(data:
Partial<ConsumerGroup>): Promise
return metadataApi.createConsumerGroup(data);
}
-export async function deleteConsumerGroup(name: string): Promise<void> {
+export async function deleteConsumerGroup(name: string, instanceId?: string):
Promise<void> {
if (isMockMode()) {
const idx = consumerGroupsState.findIndex((group) => group.name === name);
if (idx >= 0) consumerGroupsState.splice(idx, 1);
return;
}
- return metadataApi.deleteConsumerGroup(name);
+ return metadataApi.deleteConsumerGroup(name, instanceId);
}
export async function resetConsumerOffset(data: ResetConsumerOffsetRequest):
Promise<void> {
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 54121765..1f7687f6 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -36,6 +36,10 @@ export async function createInstance(data:
CreateInstanceRequest): Promise<Insta
const instance: Instance = {
id: String(Date.now()),
...data,
+ name: data.name || '',
+ type: data.type || 'PROXY',
+ endpoint: data.endpoint || '',
+ vendor: data.vendor || 'APACHE',
remark: data.remark || '',
topicCount: 0,
consumerGroupCount: 0,
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 90617928..86074bf4 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -60,13 +60,13 @@ export async function updateTopic(data: Partial<Topic>):
Promise<Topic> {
return metadataApi.updateTopic(data);
}
-export async function deleteTopic(name: string): Promise<void> {
+export async function deleteTopic(name: string, instanceId?: string):
Promise<void> {
if (isMockMode()) {
const idx = mockTopics.findIndex((t) => t.name === name);
if (idx >= 0) mockTopics.splice(idx, 1);
return;
}
- return metadataApi.deleteTopic(name);
+ return metadataApi.deleteTopic(name, instanceId);
}
export interface BatchDeleteTopicsResult {
@@ -88,15 +88,18 @@ export async function batchDeleteTopics(names: string[]):
Promise<BatchDeleteTop
return result;
}
-export async function getTopicRoutes(name: string): Promise<BrokerRoute[]> {
+export async function getTopicRoutes(name: string, instanceId?: string):
Promise<BrokerRoute[]> {
if (isMockMode()) return cloneRoutes((topicRoutes[name] as unknown as
BrokerRoute[]) ?? []);
- return metadataApi.getTopicRoutes(name);
+ return metadataApi.getTopicRoutes(name, instanceId);
}
-export async function getTopicConsumers(name: string):
Promise<ConsumerGroupInfo[]> {
+export async function getTopicConsumers(
+ name: string,
+ instanceId?: string,
+): Promise<ConsumerGroupInfo[]> {
if (isMockMode())
return cloneConsumers((topicConsumers[name] as unknown as
ConsumerGroupInfo[]) ?? []);
- return metadataApi.getTopicConsumers(name);
+ return metadataApi.getTopicConsumers(name, instanceId);
}
export async function sendTopicMessage(
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 4df61936..b9a150e4 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -20,7 +20,8 @@ const pad = (n: number, width = 2): string =>
String(n).padStart(width, '0');
/**
* Format a date string or Date object to 'YYYY-MM-DD HH:mm:ss'.
*/
-export function formatDateTime(date: string | Date): string {
+export function formatDateTime(date: string | Date | null | undefined): string
{
+ if (date === null || date === undefined) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return String(date);
return (
@@ -32,7 +33,8 @@ export function formatDateTime(date: string | Date): string {
/**
* Format a date string or Date object to 'YYYY-MM-DD'.
*/
-export function formatDate(date: string | Date): string {
+export function formatDate(date: string | Date | null | undefined): string {
+ if (date === null || date === undefined) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return String(date);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;