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.

Reply via email to