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 b1ea5c4b feat(web): footer build info, shared InstanceSelect and
segmented topic type picker (#2051)
b1ea5c4b is described below
commit b1ea5c4bf47f1fa36e14186618161d9fc33d39f1
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 13 11:52:09 2026 +0800
feat(web): footer build info, shared InstanceSelect and segmented topic
type picker (#2051)
Show the build commit and build time in the home page footer via the
VITE_GIT_COMMIT build arg. Extract the instance dropdown into a shared
InstanceSelect component used by useInstanceFilter and the instance
pages, and switch the create-topic modal type selector from stacked
cards to a Segmented control with the selected type description as
helper text.
---
web/Dockerfile | 2 +
web/src/components/InstanceSelect.tsx | 74 ++++++++++++++++++
web/src/hooks/useInstanceFilter.test.tsx | 8 +-
web/src/hooks/useInstanceFilter.ts | 18 +++--
web/src/i18n/translations.ts | 4 +-
web/src/index.css | 5 ++
.../pages/cluster/__tests__/ClientsPage.test.tsx | 16 ++--
.../pages/cluster/__tests__/ClusterPage.test.tsx | 8 +-
web/src/pages/cluster/clients.tsx | 4 +-
web/src/pages/cluster/index.tsx | 9 ++-
.../pages/home/__tests__/DashboardPage.test.tsx | 14 ++--
web/src/pages/home/dashboard.tsx | 5 +-
web/src/pages/home/index.tsx | 4 +
.../pages/instance/__tests__/ConsumerPage.test.tsx | 12 +--
web/src/pages/instance/__tests__/DLQPage.test.tsx | 6 +-
.../pages/instance/__tests__/InstancePage.test.tsx | 16 ++--
.../instance/__tests__/ResourcePlanPage.test.tsx | 2 +-
.../pages/instance/__tests__/TopicPage.test.tsx | 4 +-
web/src/pages/instance/acl.tsx | 5 +-
web/src/pages/instance/consumer.tsx | 5 +-
web/src/pages/instance/dlq.tsx | 6 +-
web/src/pages/instance/index.tsx | 63 ++++++++++------
web/src/pages/instance/message.tsx | 5 +-
web/src/pages/instance/resourcePlan.tsx | 9 +--
web/src/pages/instance/topic.tsx | 87 +++++++++++++++++-----
web/src/pages/ops/nameServerConfigDrift.tsx | 9 ++-
web/src/pages/settings/index.tsx | 9 ++-
web/src/pages/studio/BrokerCluster.tsx | 30 ++------
web/src/pages/studio/Producer.tsx | 7 +-
.../pages/studio/__tests__/BrokerCluster.test.tsx | 2 +-
web/src/pages/studio/__tests__/Producer.test.tsx | 8 +-
web/src/vite-env.d.ts | 3 +
web/vite.config.ts | 16 ++++
33 files changed, 325 insertions(+), 150 deletions(-)
diff --git a/web/Dockerfile b/web/Dockerfile
index a7347a1c..4385020a 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,5 +1,7 @@
# Stage 1: Build
FROM node:20-alpine AS build
+ARG VITE_GIT_COMMIT
+ENV VITE_GIT_COMMIT=${VITE_GIT_COMMIT}
WORKDIR /app
COPY package*.json ./
RUN npm ci
diff --git a/web/src/components/InstanceSelect.tsx
b/web/src/components/InstanceSelect.tsx
new file mode 100644
index 00000000..31236029
--- /dev/null
+++ b/web/src/components/InstanceSelect.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 { Select } from 'antd';
+import type { CSSProperties } from 'react';
+
+export interface InstanceOption {
+ value: string;
+ label: string;
+}
+
+interface InstanceSelectProps {
+ value?: string;
+ onChange: (value: string, option?: unknown) => void;
+ options: InstanceOption[];
+ style?: CSSProperties;
+ placeholder?: string;
+}
+
+/**
+ * 实例维度页面统一的实例选择器:支持输入并按实例 ID 筛选(showSearch),
+ * 选中后由页面通过 onChange 切换路由实例。
+ */
+export function InstanceSelect({
+ value,
+ onChange,
+ options,
+ style,
+ placeholder = '选择实例',
+}: InstanceSelectProps) {
+ return (
+ <Select
+ showSearch
+ allowClear
+ placeholder={placeholder}
+ value={value || undefined}
+ onChange={(next, option) => {
+ if (next === undefined || next === null) {
+ const first = options[0]?.value;
+ if (first) {
+ onChange(first);
+ }
+ return;
+ }
+ onChange(next, option);
+ }}
+ options={options}
+ optionFilterProp="label"
+ filterOption={(input, option) =>
+ String(option?.label ?? '')
+ .toLowerCase()
+ .includes(input.toLowerCase())
+ }
+ notFoundContent="暂无匹配实例"
+ style={style ?? { width: 220 }}
+ />
+ );
+}
+
+export default InstanceSelect;
diff --git a/web/src/hooks/useInstanceFilter.test.tsx
b/web/src/hooks/useInstanceFilter.test.tsx
index 2dc4cbda..64271952 100644
--- a/web/src/hooks/useInstanceFilter.test.tsx
+++ b/web/src/hooks/useInstanceFilter.test.tsx
@@ -36,8 +36,8 @@ describe('useInstanceFilter', () => {
it('replaces an unknown route instance with the first available instance',
async () => {
instanceServiceMocks.listInstances.mockResolvedValue([
{
- id: 'instance-a',
- name: 'Instance A',
+ id: 'uuid-a',
+ name: 'instance-a',
remark: '',
type: 'PROXY',
endpoint: '127.0.0.1:8080',
@@ -64,8 +64,8 @@ describe('useInstanceFilter', () => {
it('keeps the resource-plan section when normalizing instance scoped
routes', async () => {
instanceServiceMocks.listInstances.mockResolvedValue([
{
- id: 'instance-a',
- name: 'Instance A',
+ id: 'uuid-a',
+ name: 'instance-a',
remark: '',
type: 'PROXY',
endpoint: '127.0.0.1:8080',
diff --git a/web/src/hooks/useInstanceFilter.ts
b/web/src/hooks/useInstanceFilter.ts
index 4d42045a..059aeefc 100644
--- a/web/src/hooks/useInstanceFilter.ts
+++ b/web/src/hooks/useInstanceFilter.ts
@@ -34,7 +34,7 @@ export function useInstanceFilter() {
const scopedMatch = pathname.match(INSTANCE_SCOPED_PATH);
const staticMatch = pathname.match(STATIC_SECTION_PATH);
- const routeInstanceId = scopedMatch?.[1];
+ const routeInstanceId = scopedMatch ? decodeURIComponent(scopedMatch[1]) :
undefined;
const section = scopedMatch?.[2] ?? staticMatch?.[1] ?? 'topic';
const [instances, setInstances] = useState<Instance[]>([]);
@@ -45,9 +45,11 @@ export function useInstanceFilter() {
.then((nextInstances) => {
if (cancelled) return;
setInstances(nextInstances);
- const isKnownInstance = nextInstances.some((instance) => instance.id
=== routeInstanceId);
+ const isKnownInstance = nextInstances.some((instance) => instance.name
=== routeInstanceId);
if (nextInstances.length > 0 && !isKnownInstance) {
- navigate(`/instance/${nextInstances[0].id}/${section}`, { replace:
true });
+
navigate(`/instance/${encodeURIComponent(nextInstances[0].name)}/${section}`, {
+ replace: true,
+ });
}
})
.catch(() => {
@@ -59,17 +61,17 @@ export function useInstanceFilter() {
}, [navigate, routeInstanceId, section]);
const selectedInstanceId =
- routeInstanceId && instances.some((instance) => instance.id ===
routeInstanceId)
+ routeInstanceId && instances.some((instance) => instance.name ===
routeInstanceId)
? routeInstanceId
- : (instances[0]?.id ?? '');
- const selectedInstance = instances.find((instance) => instance.id ===
selectedInstanceId);
+ : (instances[0]?.name ?? '');
+ const selectedInstance = instances.find((instance) => instance.name ===
selectedInstanceId);
const selectInstance = (id: string) => {
- navigate(`/instance/${id}/${section}`);
+ navigate(`/instance/${encodeURIComponent(id)}/${section}`);
};
const instanceOptions = instances.map((instance) => ({
- value: instance.id,
+ value: instance.name,
label: instance.name,
}));
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 35b7308b..46104837 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -149,12 +149,12 @@ const translations: Record<string, Record<Lang, string>>
= {
'instance.title': { zh: '实例列表', en: 'Instance List' },
'instance.subtitle': { zh: '管理 RocketMQ 集群连接', en: 'Manage RocketMQ cluster
connections' },
'instance.count': { zh: '共 {n} 个实例', en: '{n} instances' },
- 'instance.searchPlaceholder': { zh: '搜索实例名称或地址', en: 'Search name or
endpoint' },
+ 'instance.searchPlaceholder': { zh: '搜索实例 ID 或地址', en: 'Search instance ID
or endpoint' },
'instance.allTypes': { zh: '全部架构', en: 'All Types' },
'instance.proxyMode': { zh: 'Proxy 模式', en: 'Proxy Mode' },
'instance.directMode': { zh: 'Direct 模式', en: 'Direct Mode' },
'instance.addInstance': { zh: '添加实例', en: 'Add Instance' },
- 'instance.instanceName': { zh: '实例名称', en: 'Instance Name' },
+ 'instance.instanceName': { zh: '实例 ID', en: 'Instance ID' },
'instance.namePlaceholder': { zh: '例:rocketmq-production', en: 'e.g.
rocketmq-production' },
'instance.accessType': { zh: '接入方式', en: 'Access Type' },
'instance.selectAccessType': { zh: '选择接入方式', en: 'Select access type' },
diff --git a/web/src/index.css b/web/src/index.css
index 1c6f07b7..7c6fc8c1 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -293,3 +293,8 @@ body {
.ant-table-cell .ant-typography {
margin-bottom: 0;
}
+
+/* Instance list: center the table header cells (body alignment stays
per-column). */
+.instance-table .ant-table-thead > tr > th {
+ text-align: center;
+}
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index c941aeda..ac96da14 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -33,7 +33,7 @@ vi.mock('../../../services/instanceService', () => ({
listInstances: vi.fn().mockResolvedValue([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
remark: '',
@@ -238,14 +238,16 @@ describe('Clients page', () => {
expect(await
screen.findByText('[email protected]:49152')).toBeInTheDocument();
expect(connectionsService.listConnections).toHaveBeenCalledTimes(2);
- expect(connectionsService.listConnections).toHaveBeenLastCalledWith({
instanceId: 'instance-1' });
+ expect(connectionsService.listConnections).toHaveBeenLastCalledWith({
+ instanceId: 'instance-1',
+ });
});
it('clears the previous instance data when the next instance connection
request fails', async () => {
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
remark: '',
@@ -256,7 +258,7 @@ describe('Clients page', () => {
},
{
id: 'instance-2',
- name: 'Instance 2',
+ name: 'instance-2',
endpoint: 'namesrv-2:9876',
type: 'DIRECT',
remark: '',
@@ -276,7 +278,9 @@ describe('Clients page', () => {
await screen.findByText('[email protected]:49152');
await user.click(screen.getByRole('combobox', { name: 'Instance' }));
- await user.click(await screen.findByText('Instance 2', { selector:
'.ant-select-item-option-content' }));
+ await user.click(
+ await screen.findByText('instance-2', { selector:
'.ant-select-item-option-content' }),
+ );
expect(await screen.findByText('Instance 2 is
unavailable')).toBeInTheDocument();
expect(screen.queryByText('[email protected]:49152')).not.toBeInTheDocument();
@@ -289,7 +293,7 @@ describe('Clients page', () => {
.mockResolvedValueOnce([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
remark: '',
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 77e6ab7b..6de6f66a 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -152,7 +152,7 @@ describe('Cluster page', () => {
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
type: 'DIRECT',
vendor: 'APACHE',
endpoint: '127.0.0.1:9876',
@@ -164,7 +164,7 @@ describe('Cluster page', () => {
},
{
id: 'instance-b',
- name: 'Instance B',
+ name: 'instance-b',
type: 'DIRECT',
vendor: 'APACHE',
endpoint: '127.0.0.2:9876',
@@ -186,7 +186,7 @@ describe('Cluster page', () => {
instanceServiceMocks.listInstances.mockReset().mockResolvedValue([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
vendor: 'APACHE',
@@ -225,7 +225,7 @@ describe('Cluster page', () => {
.mockResolvedValueOnce([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
vendor: 'APACHE',
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 98c11a38..b944ba43 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -133,7 +133,7 @@ const ClientsPage = () => {
.then((nextInstances) => {
if (cancelled) return;
setInstances(nextInstances);
- setSelectedInstanceId((current) => current || nextInstances[0]?.id ||
'');
+ setSelectedInstanceId((current) => current || nextInstances[0]?.name
|| '');
setLoadError(null);
})
.catch((error) => {
@@ -421,7 +421,7 @@ const ClientsPage = () => {
onChange={handleInstanceChange}
placeholder="Select instance"
style={{ width: 180 }}
- options={instances.map((instance) => ({ value: instance.id, label:
instance.name }))}
+ options={instances.map((instance) => ({ value: instance.name,
label: instance.name }))}
/>
<Select
aria-label={t('clients.cluster')}
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index be1a6090..d6bc2076 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -168,10 +168,10 @@ const ClusterPage = () => {
const apacheInstances = nextInstances.filter((instance) =>
instance.vendor === 'APACHE');
setInstances(apacheInstances);
const initialInstanceId = apacheInstances.some(
- (instance) => instance.id === requestedInstanceId,
+ (instance) => instance.name === requestedInstanceId,
)
? requestedInstanceId
- : (apacheInstances[0]?.id ?? '');
+ : (apacheInstances[0]?.name ?? '');
selectedInstanceIdRef.current = initialInstanceId;
setSelectedInstanceId(initialInstanceId);
setInstanceLoadError(null);
@@ -791,7 +791,10 @@ const ClusterPage = () => {
}}
placeholder="Select instance"
style={{ width: 180 }}
- options={instances.map((instance) => ({ value: instance.id,
label: instance.name }))}
+ options={instances.map((instance) => ({
+ value: instance.name,
+ label: instance.name,
+ }))}
/>
<Input.Search
placeholder={t('cluster.searchNs')}
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 37942ba3..96d9ff65 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -95,7 +95,7 @@ beforeEach(() => {
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
endpoint: 'a:9876',
type: 'DIRECT',
remark: '',
@@ -106,7 +106,7 @@ beforeEach(() => {
},
{
id: 'instance-b',
- name: 'Instance B',
+ name: 'instance-b',
endpoint: 'b:9876',
type: 'DIRECT',
remark: '',
@@ -130,7 +130,9 @@ describe('DashboardPage', () => {
await screen.findByText('initial-cluster');
const selector = screen.getByRole('combobox', { name: 'Dashboard instance'
});
await user.click(selector);
- await user.click(await screen.findByText('Instance A', { selector:
'.ant-select-item-option-content' }));
+ await user.click(
+ await screen.findByText('instance-a', { selector:
'.ant-select-item-option-content' }),
+ );
await waitFor(() =>
expect(dashboardService.getDashboard).toHaveBeenCalledWith('instance-a'));
expect(screen.queryByText('initial-cluster')).not.toBeInTheDocument();
@@ -152,11 +154,11 @@ describe('DashboardPage', () => {
const selector = screen.getByRole('combobox', { name: 'Dashboard instance'
});
await user.click(selector);
await user.click(
- await screen.findByText('Instance A', { selector:
'.ant-select-item-option-content' }),
+ await screen.findByText('instance-a', { selector:
'.ant-select-item-option-content' }),
);
await user.click(selector);
await user.click(
- await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }),
+ await screen.findByText('instance-b', { selector:
'.ant-select-item-option-content' }),
);
instanceB.resolve(dashboard('instance-b-cluster'));
@@ -183,7 +185,7 @@ describe('DashboardPage', () => {
const selector = screen.getByRole('combobox', { name: 'Dashboard instance'
});
await user.click(selector);
await user.click(
- await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }),
+ await screen.findByText('instance-b', { selector:
'.ant-select-item-option-content' }),
);
await user.click(screen.getByText('查看全部'));
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index bb172e16..50f0fbe2 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -82,8 +82,7 @@ const DashboardPage = () => {
void Promise.resolve().then(loadDashboard);
}, [loadDashboard]);
- const visibleDashboard =
- dashboardInstanceId === selectedInstanceId ? dashboard : null;
+ const visibleDashboard = dashboardInstanceId === selectedInstanceId ?
dashboard : null;
const dashboardHeader = (
<PageHeader
@@ -97,7 +96,7 @@ const DashboardPage = () => {
placeholder="All configured instances"
value={selectedInstanceId}
onChange={setSelectedInstanceId}
- options={instances.map((instance) => ({ value: instance.id, label:
instance.name }))}
+ options={instances.map((instance) => ({ value: instance.name,
label: instance.name }))}
style={{ width: 220 }}
/>
<Button onClick={() => void loadDashboard()} loading={loading}>
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 7a6da0dc..4da5ae43 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -515,6 +515,10 @@ const HomePage = () => {
</a>
<span style={{ margin: '0 4px' }}>|</span>
<span>RocketMQ Studio 出品</span>
+ <span style={{ margin: '0 4px' }}>|</span>
+ <span>
+ 当前版本 {__BUILD_TIME__} build({__BUILD_COMMIT__})
+ </span>
</span>
</footer>
</div>
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 433b6193..3a5c0750 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -251,7 +251,7 @@ describe('Consumer page', () => {
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.1:9876',
@@ -324,7 +324,7 @@ describe('Consumer page', () => {
vi.mocked(instanceService.listInstances).mockResolvedValue([
{
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.1:9876',
@@ -335,7 +335,7 @@ describe('Consumer page', () => {
},
{
id: 'instance-b',
- name: 'Instance B',
+ name: 'instance-b',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.2:9876',
@@ -359,8 +359,10 @@ describe('Consumer page', () => {
),
);
- await user.click(screen.getByText('Instance A'));
- await user.click(await screen.findByText('Instance B'));
+ await user.click(screen.getByText('instance-a'));
+ await user.click(
+ await screen.findByText('instance-b', { selector:
'.ant-select-item-option-content' }),
+ );
await waitFor(() =>
expect(consumerService.listConsumerGroups).toHaveBeenCalledWith({
instanceId: 'instance-b' }),
);
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 3bbc7891..b43bb333 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -34,7 +34,7 @@ vi.mock('../../../services/instanceService', () => ({
listInstances: vi.fn().mockResolvedValue([
{
id: 'instance-1',
- name: 'Instance 1',
+ name: 'instance-1',
endpoint: 'namesrv-1:9876',
type: 'DIRECT',
remark: '',
@@ -45,7 +45,7 @@ vi.mock('../../../services/instanceService', () => ({
},
{
id: 'instance-2',
- name: 'Instance 2',
+ name: 'instance-2',
endpoint: 'namesrv-2:9876',
type: 'DIRECT',
remark: '',
@@ -336,7 +336,7 @@ describe('DLQ page', () => {
await user.click(screen.getAllByRole('combobox')[0]);
await user.click(
- await screen.findByText('Instance 2', { selector:
'.ant-select-item-option-content' }),
+ await screen.findByText('instance-2', { selector:
'.ant-select-item-option-content' }),
);
await waitFor(() => {
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 2ad44b25..2338d8f8 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -121,7 +121,7 @@ describe('InstancePage', () => {
expect(await screen.findByText('production-proxy')).toBeInTheDocument();
expect(instanceService.listInstances).toHaveBeenCalledWith({});
- fireEvent.change(screen.getByPlaceholderText('搜索实例名称或地址'), {
+ fireEvent.change(screen.getByPlaceholderText('搜索实例 ID 或地址'), {
target: { value: 'proxy-hz' },
});
await waitFor(
@@ -129,7 +129,7 @@ describe('InstancePage', () => {
{ timeout: 1000 },
);
- fireEvent.change(screen.getByPlaceholderText('搜索实例名称或地址'), {
+ fireEvent.change(screen.getByPlaceholderText('搜索实例 ID 或地址'), {
target: { value: '' },
});
await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenLastCalledWith({}), {
@@ -149,7 +149,11 @@ describe('InstancePage', () => {
it('keeps unavailable resource counts after available values in both sort
directions', async () => {
vi.mocked(instanceService.listInstances).mockResolvedValue([
- { ...instance('unavailable', 'unavailable-instance'), topicCount: 0,
resourceCountsAvailable: false },
+ {
+ ...instance('unavailable', 'unavailable-instance'),
+ topicCount: 0,
+ resourceCountsAvailable: false,
+ },
{ ...instance('zero', 'zero-instance'), topicCount: 0 },
{ ...instance('many', 'many-instance'), topicCount: 10 },
]);
@@ -189,7 +193,7 @@ describe('InstancePage', () => {
renderPage();
expect(await screen.findByText('initial-instance')).toBeInTheDocument();
- const searchInput = screen.getByPlaceholderText('搜索实例名称或地址');
+ const searchInput = screen.getByPlaceholderText('搜索实例 ID 或地址');
fireEvent.change(searchInput, { target: { value: 'old' } });
await waitFor(
() => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'old' }),
@@ -220,7 +224,7 @@ describe('InstancePage', () => {
expect(await screen.findByText('production-proxy')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
- await user.type(within(dialog).getByLabelText('实例名称'), 'new-proxy');
+ await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
const createTypeSelect = within(dialog).getByRole('combobox');
fireEvent.mouseDown(createTypeSelect.parentElement!);
const proxyOptions = await screen.findAllByText('Proxy 模式', {
@@ -253,7 +257,7 @@ describe('InstancePage', () => {
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
- await user.type(within(dialog).getByLabelText('实例名称'), 'new-proxy');
+ await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
const createTypeSelect = within(dialog).getByRole('combobox');
fireEvent.mouseDown(createTypeSelect.parentElement!);
const proxyOptions = await screen.findAllByText('Proxy 模式', {
diff --git a/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
b/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
index b6541200..b6b0ef74 100644
--- a/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
@@ -74,7 +74,7 @@ describe('ResourcePlanPage', () => {
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 'instance-proxy-1',
- name: 'Instance Proxy 1',
+ name: 'instance-proxy-1',
remark: '',
type: 'PROXY',
endpoint: '10.0.0.1:8080',
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 544655a1..4adc1375 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -227,7 +227,7 @@ describe('TopicPage', () => {
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
type: 'DIRECT',
endpoint: '127.0.0.1:9876',
remark: '',
@@ -277,7 +277,7 @@ describe('TopicPage', () => {
{
...selectedInstance,
id: 'instance-a',
- name: 'Instance A',
+ name: 'instance-a',
type: 'DIRECT',
},
]);
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 1459bf38..56423a31 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -48,6 +48,7 @@ import {
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import {
createAclRule,
@@ -901,13 +902,11 @@ const AclPage = () => {
flexWrap: 'wrap',
}}
>
- <Select
- placeholder="选择实例"
+ <InstanceSelect
value={selectedInstanceId || undefined}
onChange={selectInstance}
options={instanceOptions}
style={{ width: 220 }}
- notFoundContent="暂无实例"
/>
<Input.Search
placeholder={t('acl.searchPrincipal')}
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index f9601a31..dc9d683f 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -59,6 +59,7 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
import { formatDateTime } from '../../utils/format';
@@ -829,13 +830,11 @@ const ConsumerPageContent = ({
{/* ─── Filter Bar ─── */}
<Flex justify="space-between" align="center" style={{ marginBottom: 16
}}>
<Space size={12} wrap>
- <Select
- placeholder="选择实例"
+ <InstanceSelect
value={selectedInstanceId || undefined}
onChange={selectInstance}
options={instanceOptions}
style={{ width: 220 }}
- notFoundContent="暂无实例"
/>
<Input.Search
placeholder="搜索 Group 名称或 Topic"
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 94a95b87..51f17b78 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -27,7 +27,6 @@ import {
Modal,
DatePicker,
Typography,
- Select,
message,
} from 'antd';
import { MagnifyingGlass, Eye, ArrowsCounterClockwise, Download } from
'@phosphor-icons/react';
@@ -35,6 +34,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import type { DLQGroup } from '../../api/message';
import { listDLQGroups, resendDLQ } from '../../services/messageService';
@@ -362,13 +362,11 @@ const DLQPage = () => {
{/* ── Filter Bar ── */}
<Flex justify="space-between" align="center" style={{ marginBottom: 16
}}>
<Space size={12} wrap>
- <Select
- placeholder="选择实例"
+ <InstanceSelect
value={selectedInstanceId || undefined}
onChange={selectInstance}
options={instanceOptions}
style={{ width: 220 }}
- notFoundContent="暂无实例"
/>
<Input.Search
placeholder="搜索 Group 名称或 DLQ Topic"
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index bee86e14..ef466a45 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -69,6 +69,12 @@ const typeLabel: Record<string, { text: string; color:
string }> = {
DIRECT: { text: 'Direct 模式', color: 'orange' },
};
+function describeApiError(error: unknown, fallback: string): string {
+ const serverMessage = (error as { response?: { data?: { message?: unknown }
} })?.response?.data
+ ?.message;
+ return typeof serverMessage === 'string' && serverMessage.trim() ?
serverMessage : fallback;
+}
+
type InstanceTypeFilter = 'ALL' | Instance['type'];
function compareResourceCounts(
@@ -217,9 +223,9 @@ const InstancePage = () => {
}
}
})
- .catch(() => {
+ .catch((error) => {
if (active) {
- message.error('云地域列表加载失败');
+ message.error(describeApiError(error, '云地域列表加载失败'));
}
})
.finally(() => {
@@ -252,9 +258,9 @@ const InstancePage = () => {
setCloudInstances(items);
}
})
- .catch(() => {
+ .catch((error) => {
if (active) {
- message.error('云实例列表加载失败');
+ message.error(describeApiError(error, '云实例列表加载失败'));
}
})
.finally(() => {
@@ -368,7 +374,7 @@ const InstancePage = () => {
const columns: ColumnsType<Instance> = [
{
- title: '实例名称',
+ title: '实例 ID',
dataIndex: 'name',
key: 'name',
width: 180,
@@ -436,8 +442,7 @@ const InstancePage = () => {
key: 'consumerGroupCount',
width: 80,
align: 'center' as const,
- sorter: (a, b, sortOrder) =>
- compareResourceCounts(a, b, 'consumerGroupCount', sortOrder),
+ sorter: (a, b, sortOrder) => compareResourceCounts(a, b,
'consumerGroupCount', sortOrder),
render: (count: number, record: Instance) =>
record.resourceCountsAvailable === false ? '不可用' : count,
},
@@ -490,15 +495,18 @@ const InstancePage = () => {
size="small"
icon={<DeleteOutlined />}
style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
- onClick={() =>
+ onClick={() => {
+ const isCloudInstance = record.vendor === 'ALIYUN' ||
record.vendor === 'TENCENT';
Modal.confirm({
title: `确认删除 "${record.name}"?`,
- content: '此操作不可恢复。',
+ content: isCloudInstance
+ ? '仅从 Studio 移除该实例记录,不会释放云上的 RocketMQ 实例。'
+ : '此操作不可恢复。',
okText: '删除',
okButtonProps: { danger: true },
onOk: () => handleDelete(record),
- })
- }
+ });
+ }}
>
删除
</Button>
@@ -512,9 +520,9 @@ const InstancePage = () => {
{/* Header */}
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600
}}>{t('instance.title')}</h2>
- <span style={{ fontSize: 13, color: '#9CA3AF' }}>
- 管理 RocketMQ 集群连接,当前显示 {instances.length} 个实例
- </span>
+ <div style={{ marginTop: 6, fontSize: 13, color: '#9CA3AF' }}>
+ 接入并管理 RocketMQ 实例(开源自建 / 阿里云 / 腾讯云),当前显示 {instances.length} 个实例
+ </div>
</div>
{/* Filter bar */}
@@ -527,7 +535,7 @@ const InstancePage = () => {
>
<Space size={12} wrap>
<Input
- placeholder="搜索实例名称或地址"
+ placeholder="搜索实例 ID 或地址"
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -557,6 +565,7 @@ const InstancePage = () => {
{/* Table */}
<Card bodyStyle={{ padding: 0 }}>
<Table
+ className="instance-table"
columns={columns}
dataSource={sortedInstances}
loading={loading}
@@ -565,7 +574,7 @@ const InstancePage = () => {
size="small"
onRow={(record) => ({
style: { cursor: 'pointer' },
- onClick: () => navigate(`/instance/${record.id}/topic`),
+ onClick: () =>
navigate(`/instance/${encodeURIComponent(record.name)}/topic`),
})}
/>
</Card>
@@ -657,18 +666,21 @@ const InstancePage = () => {
}))}
onChange={(value) => {
const selected = cloudInstances.find((item) =>
item.instanceId === value);
- if (selected?.instanceName) {
- addForm.setFieldsValue({ name: selected.instanceName });
+ if (selected) {
+ addForm.setFieldsValue({ name: selected.instanceId });
}
}}
/>
</Form.Item>
<Form.Item
- label="实例名称"
+ label="实例 ID"
name="name"
- rules={[{ required: true, message: '请输入实例名称' }]}
+ rules={[
+ { required: true, message: '请输入实例 ID' },
+ { max: 64, message: '实例 ID 不能超过 64 个字符' },
+ ]}
>
- <Input placeholder="默认取云上实例名称" />
+ <Input placeholder="默认取云上实例 ID" />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={2} placeholder="可选,描述实例用途" />
@@ -677,9 +689,12 @@ const InstancePage = () => {
) : (
<Form form={addForm} layout="vertical">
<Form.Item
- label="实例名称"
+ label="实例 ID"
name="name"
- rules={[{ required: true, message: '请输入实例名称' }]}
+ rules={[
+ { required: true, message: '请输入实例 ID' },
+ { max: 64, message: '实例 ID 不能超过 64 个字符' },
+ ]}
>
<Input placeholder="例:rocketmq-production" />
</Form.Item>
@@ -752,7 +767,7 @@ const InstancePage = () => {
width={520}
>
<Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
- <Form.Item label="实例名称">
+ <Form.Item label="实例 ID">
<Input value={editingInstance?.name} disabled />
</Form.Item>
<Form.Item label="接入方式">
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index cc2eaf5e..9f03fcf5 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -52,6 +52,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import type { MessageQuery, MessageRecord, TraceRecord } from
'../../api/message';
import { getMessageTrace, queryMessages } from '../../services/messageService';
@@ -751,13 +752,11 @@ const MessagePageContent = ({
<Card style={{ marginBottom: 16 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space size={12}>
- <Select
- placeholder="选择实例"
+ <InstanceSelect
value={selectedInstanceId || undefined}
onChange={selectInstance}
options={instanceOptions}
style={{ width: 220 }}
- notFoundContent="暂无实例"
/>
<Segmented
options={QUERY_OPTIONS}
diff --git a/web/src/pages/instance/resourcePlan.tsx
b/web/src/pages/instance/resourcePlan.tsx
index 3a79da25..61c037d2 100644
--- a/web/src/pages/instance/resourcePlan.tsx
+++ b/web/src/pages/instance/resourcePlan.tsx
@@ -24,7 +24,6 @@ import {
Col,
Input,
Row,
- Select,
Space,
Statistic,
Table,
@@ -34,6 +33,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { PlayCircleOutlined } from '@ant-design/icons';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import type { ResourcePlanEntry } from '../../services/resourcePlanService';
import {
@@ -151,12 +151,11 @@ const ResourcePlanPage = () => {
subtitle="导入前只读预检 Topic 与 Consumer Group 配置,先看差异再决定是否手动调整"
extra={
<Space>
- <Select
+ <InstanceSelect
value={selectedInstanceId || undefined}
- placeholder="选择实例"
- style={{ width: 220 }}
- options={instanceOptions}
onChange={selectInstance}
+ options={instanceOptions}
+ style={{ width: 220 }}
/>
<Button onClick={() =>
setBundleText(RESOURCE_PLAN_SAMPLE)}>填充示例</Button>
<Button
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index d5540c42..898460e2 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -53,6 +53,7 @@ import {
MinusCircleOutlined,
} from '@ant-design/icons';
import PageHeader from '../../components/PageHeader';
+import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, CLUSTER_TYPE_MAP } from '../../constants/theme';
import type { Topic, BrokerRoute, ConsumerGroupInfo, TopicConsumerPage } from
'../../api/metadata';
@@ -89,6 +90,23 @@ const TYPE_OPTIONS = [
{ label: 'LiteTopic', value: 'LITE' },
];
+// Topic 类型选项(描述参考阿里云 RocketMQ 消息类型语义),创建弹窗用 Segmented 展示
+const TOPIC_TYPE_CARDS = [
+ { value: 'NORMAL', label: '普通消息', desc: '适用于无特殊顺序要求的常规消息收发场景。' },
+ { value: 'FIFO', label: '顺序消息', desc: '严格按照消息发送顺序消费,适用于顺序敏感的业务。' },
+ { value: 'DELAY', label: '延迟消息', desc: '消息在指定的延迟时间或定时后才投递给消费者。' },
+ {
+ value: 'TRANSACTION',
+ label: '事务消息',
+ desc: '支持分布式事务,保证本地事务与消息发送的最终一致性。',
+ },
+ {
+ value: 'LITE',
+ label: 'LiteTopic',
+ desc: '轻量级主题,资源开销更低,适用于大规模轻量消息场景。',
+ },
+];
+
// ─── Perm label ───────────────────────────────────────────────────
const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
@@ -293,6 +311,7 @@ const TopicPage = () => {
const [modalOpen, setModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form] = Form.useForm();
+ const createTopicType = Form.useWatch('type', form);
const [sendModalOpen, setSendModalOpen] = useState(false);
const [sendTopic, setSendTopic] = useState<Topic | null>(null);
const [sending, setSending] = useState(false);
@@ -868,19 +887,46 @@ const TopicPage = () => {
{/* ── Header ────────────────────────────────────────────── */}
<PageHeader title={t('topic.title')} subtitle={`共
${filteredTopics.length} 个 Topic`} />
- {/* ── Endpoint hint ─────────────────────────────────────── */}
+ {/* ── Current instance banner ───────────────────────────── */}
{selectedInstance && (
- <div style={{ marginBottom: 16, fontSize: 13, lineHeight: 1.8 }}>
- <Space wrap size={8}>
- <Text strong>
- 当前实例:{selectedInstance.name}(
- {selectedInstance.type === 'DIRECT' ? 'Direct 模式' : 'Proxy 模式'})
- </Text>
- <Text code copyable>
- {selectedInstance.endpoint}
- </Text>
- </Space>
- <div style={{ color: '#8c8c8c' }}>
+ <div
+ style={{
+ marginBottom: 16,
+ padding: '12px 16px',
+ borderRadius: 8,
+ border: '1px solid var(--bolt-elements-border-color, #f0f0f0)',
+ background: 'var(--bolt-elements-bg-depth-2, #fafafa)',
+ }}
+ >
+ <Flex align="center" wrap="wrap" gap="8px 28px" style={{ fontSize:
14 }}>
+ <span>
+ <span style={{ color: '#8c8c8c', marginRight: 6 }}>当前实例</span>
+ <span>{selectedInstance.name}</span>
+ </span>
+ <span>
+ <span style={{ color: '#8c8c8c', marginRight: 6 }}>接入模式</span>
+ <span>{selectedInstance.type === 'DIRECT' ? 'Direct 模式' : 'Proxy
模式'}</span>
+ </span>
+ {selectedInstance.vendor === 'ALIYUN' && (
+ <span>
+ <span style={{ color: '#8c8c8c', marginRight: 6 }}>厂商</span>
+ <span>阿里云</span>
+ </span>
+ )}
+ {selectedInstance.vendor === 'TENCENT' && (
+ <span>
+ <span style={{ color: '#8c8c8c', marginRight: 6 }}>厂商</span>
+ <span>腾讯云</span>
+ </span>
+ )}
+ <span>
+ <span style={{ color: '#8c8c8c', marginRight: 6 }}>接入点</span>
+ <Text code copyable style={{ fontSize: 16 }}>
+ {selectedInstance.endpoint}
+ </Text>
+ </span>
+ </Flex>
+ <div style={{ marginTop: 10, fontSize: 14, lineHeight: 1.6, color:
'#8c8c8c' }}>
{selectedInstance.type === 'DIRECT'
? '接入点为 NameServer SLB 地址(K8s 场景下一般为 NameServer Service
地址),Direct 模式客户端通过该地址发现 Broker。若客户端环境无法解析该地址,请自行配置 DNS 解析或在客户端 hosts 中映射。'
: '接入点为 Proxy SLB 内网地址,gRPC/Remoting
客户端直接连接该地址收发消息。若客户端环境无法解析该地址,请自行配置 DNS 解析或在客户端 hosts 中映射。'}
@@ -897,8 +943,7 @@ const TopicPage = () => {
justify="space-between"
>
<Space size={12} wrap>
- <Select
- placeholder="选择实例"
+ <InstanceSelect
value={selectedInstanceId || undefined}
onChange={(value) => {
resetTablePage();
@@ -906,7 +951,6 @@ const TopicPage = () => {
}}
options={instanceOptions}
style={{ width: 220 }}
- notFoundContent="暂无实例"
/>
<Input.Search
placeholder="搜索 Topic 名称"
@@ -1183,10 +1227,15 @@ const TopicPage = () => {
<Input placeholder="请输入 Topic 名称" />
</Form.Item>
- <Form.Item label="类型" name="type" rules={[{ required: true }]}>
- <Select
- options={TYPE_OPTIONS.filter(
- (o) => o.value && (!isCloudInstance || o.value !== 'LITE'),
+ <Form.Item
+ label="类型"
+ name="type"
+ rules={[{ required: true }]}
+ extra={TOPIC_TYPE_CARDS.find((c) => c.value ===
createTopicType)?.desc}
+ >
+ <Segmented
+ options={TOPIC_TYPE_CARDS.filter((c) => !isCloudInstance ||
c.value !== 'LITE').map(
+ ({ value, label }) => ({ value, label }),
)}
/>
</Form.Item>
diff --git a/web/src/pages/ops/nameServerConfigDrift.tsx
b/web/src/pages/ops/nameServerConfigDrift.tsx
index 4e19c61f..6c750c8c 100644
--- a/web/src/pages/ops/nameServerConfigDrift.tsx
+++ b/web/src/pages/ops/nameServerConfigDrift.tsx
@@ -110,7 +110,8 @@ const NameServerConfigDriftPage = () => {
setSelectedClusterId(firstClusterId);
if (firstClusterId) void runCheck(firstClusterId, instanceId);
} catch {
- if (sequence === requestSequence.current)
message.error(t('nameServerDrift.loadClustersFailed'));
+ if (sequence === requestSequence.current)
+ message.error(t('nameServerDrift.loadClustersFailed'));
} finally {
if (sequence === requestSequence.current) setClustersLoading(false);
}
@@ -199,7 +200,7 @@ const NameServerConfigDriftPage = () => {
value={selectedInstanceId}
onChange={(instanceId) => void selectInstance(instanceId)}
placeholder={t('common.selectInstance')}
- options={instances.map((instance) => ({ label: instance.name, value:
instance.id }))}
+ options={instances.map((instance) => ({ label: instance.name, value:
instance.name }))}
style={{ width: 'min(100%, 280px)' }}
/>
<Select
@@ -221,7 +222,9 @@ const NameServerConfigDriftPage = () => {
loading={checking}
disabled={!selectedClusterId || !selectedInstanceId}
onClick={() =>
- selectedClusterId && selectedInstanceId && void
runCheck(selectedClusterId, selectedInstanceId)
+ selectedClusterId &&
+ selectedInstanceId &&
+ void runCheck(selectedClusterId, selectedInstanceId)
}
/>
</Tooltip>
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index eb1384d4..11219d1b 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -418,7 +418,9 @@ export const DataSourceTab = () => {
return instanceIds
.map(
(instanceId) =>
- instances.find((instance) => instance.id === instanceId)?.name
?? instanceId,
+ instances.find(
+ (instance) => instance.name === instanceId || instance.id ===
instanceId,
+ )?.name ?? instanceId,
)
.join('、');
},
@@ -530,7 +532,10 @@ export const DataSourceTab = () => {
mode="multiple"
allowClear
placeholder="选择此数据源对应的实例"
- options={instances.map((instance) => ({ value: instance.id,
label: instance.name }))}
+ options={instances.map((instance) => ({
+ value: instance.name,
+ label: instance.name,
+ }))}
/>
</Form.Item>
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index 6bddb01f..961b6b43 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -16,25 +16,8 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
-import {
- Table,
- Button,
- Tag,
- Tabs,
- Card,
- Space,
- Switch,
- Progress,
- Spin,
- App,
- Select,
-} from 'antd';
-import {
- ArrowClockwise,
- Cloud,
- ChartBar,
- PlugsConnected,
-} from '@phosphor-icons/react';
+import { Table, Button, Tag, Tabs, Card, Space, Switch, Progress, Spin, App,
Select } from 'antd';
+import { ArrowClockwise, Cloud, ChartBar, PlugsConnected } from
'@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
import { listClusters } from '../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
@@ -207,7 +190,7 @@ const BrokerClusterPage = () => {
.then((nextInstances) => {
if (!active) return;
setInstances(nextInstances);
- setSelectedInstanceId(nextInstances[0]?.id ?? '');
+ setSelectedInstanceId(nextInstances[0]?.name ?? '');
})
.catch(() => {
if (!active) return;
@@ -220,9 +203,12 @@ const BrokerClusterPage = () => {
}, [clearData, message, t]);
useEffect(() => {
+ const requestId = loadRequestId.current;
+ // The state updates are performed by the asynchronous cluster API
request, not by this effect itself.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
void loadData();
return () => {
- ++loadRequestId.current;
+ loadRequestId.current = requestId + 1;
};
}, [loadData]);
@@ -474,7 +460,7 @@ const BrokerClusterPage = () => {
onChange={setSelectedInstanceId}
placeholder="选择实例"
style={{ minWidth: 180 }}
- options={instances.map((instance) => ({ value: instance.id, label:
instance.name }))}
+ options={instances.map((instance) => ({ value: instance.name,
label: instance.name }))}
/>
<Switch
checked={autoRefresh}
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index 02250437..764f7546 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -72,7 +72,7 @@ const ProducerPage = () => {
.then((nextInstances) => {
if (cancelled) return;
setInstances(nextInstances);
- setSelectedInstanceId((current) => current || nextInstances[0]?.id ||
'');
+ setSelectedInstanceId((current) => current || nextInstances[0]?.name
|| '');
})
.catch(() => {
if (!cancelled) {
@@ -246,7 +246,10 @@ const ProducerPage = () => {
onChange={handleInstanceChange}
placeholder="Select instance"
style={{ width: 220 }}
- options={instances.map((instance) => ({ value: instance.id,
label: instance.name }))}
+ options={instances.map((instance) => ({
+ value: instance.name,
+ label: instance.name,
+ }))}
/>
</Form.Item>
<Form.Item
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 1c54da3a..19a325ac 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -135,7 +135,7 @@ describe('BrokerCluster Page', () => {
vi.mocked(listInstances).mockResolvedValue([
{
id: 'instance-1',
- name: 'prod-cn',
+ name: 'instance-1',
remark: '',
type: 'DIRECT',
endpoint: '10.0.1.20:9876',
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 22236aa8..d574b2ad 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -86,7 +86,7 @@ describe('ProducerPage', () => {
vi.mocked(listInstances).mockResolvedValue([
{
id: 'instance-1',
- name: 'Primary instance',
+ name: 'instance-1',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.1:9876',
@@ -262,7 +262,7 @@ describe('ProducerPage', () => {
vi.mocked(listInstances).mockResolvedValue([
{
id: 'instance-1',
- name: 'Primary instance',
+ name: 'instance-1',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.1:9876',
@@ -273,7 +273,7 @@ describe('ProducerPage', () => {
},
{
id: 'instance-2',
- name: 'Secondary instance',
+ name: 'instance-2',
remark: '',
type: 'DIRECT',
endpoint: '127.0.0.2:9876',
@@ -309,7 +309,7 @@ describe('ProducerPage', () => {
fireEvent.mouseDown(instanceSelect.parentElement!);
await user.click(
- await screen.findByText('Secondary instance', {
+ await screen.findByText('instance-2', {
selector: '.ant-select-item-option-content',
}),
);
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
index 0949d93a..6d4fbd6e 100644
--- a/web/src/vite-env.d.ts
+++ b/web/src/vite-env.d.ts
@@ -16,3 +16,6 @@
*/
/// <reference types="vite/client" />
+
+declare const __BUILD_COMMIT__: string;
+declare const __BUILD_TIME__: string;
diff --git a/web/vite.config.ts b/web/vite.config.ts
index ec30743b..8cc69c61 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -2,10 +2,26 @@ import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
+function formatBuildTime(date: Date): string {
+ // Build runs in a UTC container; render the timestamp in UTC+8.
+ const utc8 = new Date(date.getTime() + 8 * 3600 * 1000);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${utc8.getUTCFullYear()}-${pad(utc8.getUTCMonth() +
1)}-${pad(utc8.getUTCDate())} ${pad(
+ utc8.getUTCHours(),
+ )}:${pad(utc8.getUTCMinutes())}`;
+}
+
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
+ // Build commit is injected as a Docker build arg (VITE_GIT_COMMIT) so the
footer can show it.
+ const buildCommit = env.VITE_GIT_COMMIT || 'dev';
+ const buildTime = formatBuildTime(new Date());
return {
plugins: [react()],
+ define: {
+ __BUILD_COMMIT__: JSON.stringify(buildCommit),
+ __BUILD_TIME__: JSON.stringify(buildTime),
+ },
build: {
// Ant Design is shared by the application shell and most route
components. Keep it
// cacheable as one vendor chunk rather than splitting its cyclic
internals.