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 38a06f259 feat(studio): NameServer config drift, Tencent import, 
inventory exports, catalog filters and request guards (#2630)
38a06f259 is described below

commit 38a06f259b2381c4e2b1825b5378495a65e67586
Author: coder999o <[email protected]>
AuthorDate: Thu Aug 27 14:39:27 2026 +0800

    feat(studio): NameServer config drift, Tencent import, inventory exports, 
catalog filters and request guards (#2630)
    
    * feat: surface NameServer config drift
    
    * fix: preserve Settings query parameters when switching tabs
    
    * fix: ignore stale Queue browser requests
    
    * feat: filter observability asset catalogs
    
    * feat: export Broker cluster topology
    
    * feat: export studio users
    
    * feat: filter proxy nodes
    
    * feat: export LiteTopic list
    
    * feat: export data sources
    
    * fix: reset metrics data source when instance scope changes
    
    * feat: enable Tencent cloud one-click instance import
    
    * fix: replace placeholder home footer links
---
 web/src/api/instance.test.ts                       |  13 ++
 web/src/api/instance.ts                            |   5 +-
 web/src/api/settings.test.ts                       |  35 +++
 web/src/api/settings.ts                            |  24 +++
 web/src/api/studioUsers.test.ts                    |  34 ++-
 web/src/api/studioUsers.ts                         |  27 ++-
 web/src/components/AlertRuleAssetList.tsx          |  53 ++++-
 web/src/components/GrafanaDashboardList.tsx        |  50 ++++-
 web/src/components/MetricsExplorer.tsx             |  16 +-
 web/src/components/QueueBrowser.tsx                |  31 ++-
 .../__tests__/AlertRuleAssetList.test.tsx          |  24 +++
 .../__tests__/GrafanaDashboardList.test.tsx        |  34 ++-
 .../components/__tests__/MetricsExplorer.test.tsx  |  54 +++++
 web/src/components/__tests__/QueueBrowser.test.tsx | 170 +++++++++++++++
 web/src/i18n/translations.ts                       |  58 +++++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  71 +++++++
 web/src/pages/cluster/index.tsx                    | 236 +++++++++++++++++++--
 web/src/pages/home/__tests__/HomePage.test.tsx     |  16 ++
 web/src/pages/home/index.tsx                       |  17 +-
 .../pages/instance/__tests__/InstancePage.test.tsx |  57 +++++
 web/src/pages/instance/index.tsx                   |   2 +-
 web/src/pages/settings/DataSourceTab.tsx           |  91 ++++++--
 .../settings/__tests__/DataSourceTab.test.tsx      |  79 ++++++-
 .../pages/settings/__tests__/SettingsPage.test.tsx |  94 ++++++++
 web/src/pages/settings/index.tsx                   |   8 +-
 web/src/pages/studio/BrokerCluster.tsx             |  78 ++++++-
 web/src/pages/studio/LiteTopic.tsx                 |  60 +++++-
 web/src/pages/studio/Proxy.tsx                     |  54 ++++-
 web/src/pages/studio/UserManagement.tsx            |  52 ++++-
 .../pages/studio/__tests__/BrokerCluster.test.tsx  |  33 +++
 web/src/pages/studio/__tests__/LiteTopic.test.tsx  |  48 +++++
 web/src/pages/studio/__tests__/Proxy.test.tsx      |  45 ++++
 .../pages/studio/__tests__/UserManagement.test.tsx |  61 ++++--
 33 files changed, 1641 insertions(+), 89 deletions(-)

diff --git a/web/src/api/instance.test.ts b/web/src/api/instance.test.ts
index b06d902c3..9d7db2276 100644
--- a/web/src/api/instance.test.ts
+++ b/web/src/api/instance.test.ts
@@ -22,6 +22,7 @@ import {
   createInstance,
   deleteInstance,
   getInstanceCapabilities,
+  importCloudInstances,
   listInstances,
   supportsApacheRuntime,
   updateInstance,
@@ -109,6 +110,18 @@ describe('instance API', () => {
     await 
expect(getInstanceCapabilities('instance/proxy')).resolves.toEqual(capabilities);
   });
 
+  it('posts cloud import requests for cloud vendors', async () => {
+    const result = { discovered: 4, imported: 1, skipped: 3, failed: [] };
+    mock.onPost('/instances/import-cloud').reply((config) => {
+      expect(JSON.parse(config.data)).toEqual({ vendor: 'TENCENT', 
credentialId: 201 });
+      return [200, { code: 200, data: result }];
+    });
+
+    await expect(importCloudInstances({ vendor: 'TENCENT', credentialId: 201 
})).resolves.toEqual(
+      result,
+    );
+  });
+
   it('identifies instances supported by Apache MQAdmin runtime APIs', () => {
     expect(supportsApacheRuntime({ vendor: 'APACHE' })).toBe(true);
     expect(supportsApacheRuntime({})).toBe(true);
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index f071f4883..33e749b57 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -134,7 +134,10 @@ export async function deleteInstancesBatch(ids: string[]) {
   return res.data.data;
 }
 
-export async function importCloudInstances(data: { vendor: InstanceVendor; 
credentialId: number }) {
+export async function importCloudInstances(data: {
+  vendor: Exclude<InstanceVendor, 'APACHE'>;
+  credentialId: number;
+}) {
   const res = await client.post<{ data: CloudImportResult 
}>('/instances/import-cloud', data);
   return res.data.data;
 }
diff --git a/web/src/api/settings.test.ts b/web/src/api/settings.test.ts
index 60a119ada..7267db136 100644
--- a/web/src/api/settings.test.ts
+++ b/web/src/api/settings.test.ts
@@ -21,6 +21,7 @@ import client from './client';
 import {
   createDataSource,
   deleteDataSource,
+  listAllDataSources,
   listDataSources,
   testDataSource,
   updateDataSource,
@@ -58,6 +59,40 @@ describe('data sources API', () => {
     await expect(updateDataSource(source)).resolves.toEqual(source);
   });
 
+  it('loads all data source export pages with the maximum supported page 
size', async () => {
+    mock.onGet('/settings/datasources/page').reply((config) => {
+      const page = config.params.page;
+      expect(config.params.search).toBe('prom');
+      expect(config.params.type).toBe('Prometheus');
+      expect(config.params.pageSize).toBe(100);
+      return [
+        200,
+        {
+          code: 200,
+          data: {
+            items:
+              page === 1
+                ? [source]
+                : [
+                    {
+                      ...source,
+                      key: 'source-2',
+                      name: 'Prometheus backup',
+                    },
+                  ],
+            total: 2,
+            page,
+            size: 100,
+          },
+        },
+      ];
+    });
+
+    await expect(listAllDataSources({ search: 'prom', type: 'Prometheus' 
})).resolves.toHaveLength(
+      2,
+    );
+  });
+
   it('uses a key query parameter for deletion and sends test auth details', 
async () => {
     mock.onPost('/settings/datasources/delete').reply((config) => {
       expect(config.params).toEqual({ key: source.key });
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index 5052d95fe..0b944e820 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -64,6 +64,9 @@ export interface DataSourcePage {
   size: number;
 }
 
+const DATA_SOURCE_EXPORT_PAGE_SIZE = 100;
+const DATA_SOURCE_MAX_EXPORT_PAGES = 100;
+
 // ─── General Settings ───────────────────────────────────────────
 export async function getGeneralSettings() {
   const res = await client.get<{ data: GeneralSettings }>('/settings/general');
@@ -103,6 +106,27 @@ export async function listDataSourcesPage(params: {
   return res.data.data;
 }
 
+export async function listAllDataSources(params: { search?: string; type?: 
string } = {}) {
+  const allDataSources: DataSource[] = [];
+  let page = 1;
+
+  while (page <= DATA_SOURCE_MAX_EXPORT_PAGES) {
+    const result = await listDataSourcesPage({
+      ...params,
+      page,
+      pageSize: DATA_SOURCE_EXPORT_PAGE_SIZE,
+    });
+    allDataSources.push(...result.items);
+    const total = result.total ?? allDataSources.length;
+    if (result.items.length === 0 || allDataSources.length >= total) {
+      return allDataSources;
+    }
+    page += 1;
+  }
+
+  throw new Error(`Data source export exceeded ${DATA_SOURCE_MAX_EXPORT_PAGES} 
pages`);
+}
+
 export async function createDataSource(data: Partial<DataSource>) {
   const res = await client.post<{ data: DataSource 
}>('/settings/datasources/create', data);
   return res.data.data;
diff --git a/web/src/api/studioUsers.test.ts b/web/src/api/studioUsers.test.ts
index 66662f88d..041b7c2a6 100644
--- a/web/src/api/studioUsers.test.ts
+++ b/web/src/api/studioUsers.test.ts
@@ -18,10 +18,11 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
 import client from './client';
-import { listStudioUsers } from './studioUsers';
+import { listAllStudioUsers as loadStudioUsersForExport, listStudioUsers } 
from './studioUsers';
 
 const mock = new MockAdapter(client);
-
+const exportQuery = { search: 'op', admin: false };
+const exportRequestParams = [1, 2].map((page) => ({ ...exportQuery, page, 
pageSize: 100 }));
 describe('studio users API', () => {
   beforeEach(() => mock.reset());
   afterEach(() => mock.reset());
@@ -59,4 +60,33 @@ describe('studio users API', () => {
       pageSize: 20,
     });
   });
+
+  it('loads all export pages with the maximum supported page size', async () 
=> {
+    mock.onGet('/studio-users').reply((config) => {
+      const currentPage = config.params?.page;
+      const pageItems =
+        currentPage === 1
+          ? [{ id: 7, username: 'operator', admin: false, enabled: true }]
+          : [{ id: 8, username: 'admin', admin: true, enabled: false }];
+      return [
+        200,
+        {
+          code: 200,
+          data: {
+            items: pageItems,
+            total: 2,
+            page: currentPage,
+            size: 100,
+          },
+        },
+      ];
+    });
+    const exportedUsers = await loadStudioUsersForExport(exportQuery);
+    expect(exportedUsers.map((user) => user.username)).toEqual(['operator', 
'admin']);
+    expect(mock.history.get).toHaveLength(2);
+    expect(mock.history.get.map((request) => request.params)).toEqual([
+      exportRequestParams[0],
+      exportRequestParams[1],
+    ]);
+  });
 });
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index 8d6534e47..bf644a801 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -16,6 +16,8 @@
  */
 import client from './client';
 
+const STUDIO_USER_EXPORT_PAGE_SIZE = 100;
+const STUDIO_USER_MAX_EXPORT_PAGES = 100;
 export interface StudioUser {
   id: number;
   username: string;
@@ -41,6 +43,8 @@ export interface StudioUserQuery {
   pageSize?: number;
 }
 
+type StudioUserExportQuery = Omit<StudioUserQuery, 'page' | 'pageSize'>;
+type CreateStudioUserRequest = Pick<StudioUser, 'username' | 'admin'> & { 
password: string };
 export async function listStudioUsers(query: StudioUserQuery = {}) {
   const response = await client.get<{ data: StudioUserPage }>('/studio-users', 
{
     params: query,
@@ -48,13 +52,32 @@ export async function listStudioUsers(query: 
StudioUserQuery = {}) {
   return response.data.data;
 }
 
-export async function createStudioUser(request: { username: string; password: 
string; admin: boolean }) {
+export const listAllStudioUsers = async (
+  query: StudioUserExportQuery = {},
+): Promise<StudioUser[]> => {
+  const allUsers: StudioUser[] = [];
+  let page = 1;
+  while (page <= STUDIO_USER_MAX_EXPORT_PAGES) {
+    const result = await listStudioUsers({
+      ...query,
+      page,
+      pageSize: STUDIO_USER_EXPORT_PAGE_SIZE,
+    });
+    allUsers.push(...result.items);
+    const total = result.total ?? allUsers.length;
+    if (result.items.length === 0 || allUsers.length >= total) return allUsers;
+    page += 1;
+  }
+  throw new Error(`Studio user export exceeded ${STUDIO_USER_MAX_EXPORT_PAGES} 
pages`);
+};
+export async function createStudioUser(request: CreateStudioUserRequest) {
   const response = await client.post<{ data: StudioUser }>('/studio-users', 
request);
   return response.data.data;
 }
 
 export async function setStudioUserEnabled(userId: number, enabled: boolean) {
-  const response = await client.post<{ data: StudioUser 
}>(`/studio-users/${userId}/status`, { enabled });
+  const statusPath = `/studio-users/${userId}/status`;
+  const response = await client.post<{ data: StudioUser }>(statusPath, { 
enabled });
   return response.data.data;
 }
 
diff --git a/web/src/components/AlertRuleAssetList.tsx 
b/web/src/components/AlertRuleAssetList.tsx
index 330f16ddf..b27db3076 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from 
'antd';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Alert, App, Button, Input, Modal, Select, Space, Table, Tag, 
Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
 import { useLang } from '../i18n/LangContext';
@@ -40,6 +40,8 @@ export const AlertRuleAssetList: React.FC = () => {
   const { t } = useLang();
   const { message } = App.useApp();
   const [assets, setAssets] = useState<AlertRuleAssetInfo[]>([]);
+  const [searchText, setSearchText] = useState('');
+  const [selectedSeverities, setSelectedSeverities] = useState<string[]>([]);
   const [loading, setLoading] = useState(true);
   const [loadError, setLoadError] = useState(false);
   const [viewing, setViewing] = useState<AlertRuleAssetInfo | null>(null);
@@ -50,6 +52,30 @@ export const AlertRuleAssetList: React.FC = () => {
   const viewRequestId = useRef(0);
   const [exportingNames, setExportingNames] = useState<Set<string>>(() => new 
Set());
 
+  const severityOptions = useMemo(
+    () =>
+      Array.from(new Set(assets.flatMap((asset) => asset.severities || [])))
+        .sort((a, b) => a.localeCompare(b))
+        .map((severity) => ({ label: severity.toUpperCase(), value: severity 
})),
+    [assets],
+  );
+
+  const filteredAssets = useMemo(() => {
+    const normalizedSearch = searchText.trim().toLowerCase();
+    return assets.filter((asset) => {
+      const matchesSearch =
+        !normalizedSearch ||
+        [asset.name, asset.group]
+          .filter(Boolean)
+          .some((value) => value.toLowerCase().includes(normalizedSearch));
+      const matchesSeverity =
+        selectedSeverities.length === 0 ||
+        selectedSeverities.some((severity) => (asset.severities || 
[]).includes(severity));
+
+      return matchesSearch && matchesSeverity;
+    });
+  }, [assets, searchText, selectedSeverities]);
+
   const loadAssets = useCallback(async () => {
     const requestId = ++listRequestId.current;
     setLoading(true);
@@ -182,6 +208,27 @@ export const AlertRuleAssetList: React.FC = () => {
 
   return (
     <div>
+      <Space style={{ marginBottom: 12 }}>
+        <Input.Search
+          allowClear
+          placeholder={t('alertAssets.searchPlaceholder')}
+          value={searchText}
+          onChange={(event) => setSearchText(event.target.value)}
+          onSearch={setSearchText}
+          style={{ width: 280 }}
+        />
+        <Select
+          allowClear
+          mode="multiple"
+          maxTagCount="responsive"
+          options={severityOptions}
+          placeholder={t('alertAssets.allSeverities')}
+          value={selectedSeverities}
+          onChange={setSelectedSeverities}
+          style={{ minWidth: 220 }}
+        />
+      </Space>
+
       {loadError && (
         <Alert
           showIcon
@@ -202,7 +249,7 @@ export const AlertRuleAssetList: React.FC = () => {
 
       <Table
         columns={columns}
-        dataSource={assets}
+        dataSource={filteredAssets}
         loading={loading}
         rowKey="name"
         pagination={false}
diff --git a/web/src/components/GrafanaDashboardList.tsx 
b/web/src/components/GrafanaDashboardList.tsx
index 01850690e..b76284e86 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from 
'antd';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Alert, App, Button, Input, Modal, Select, Space, Table, Tag, 
Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
 import { useLang } from '../i18n/LangContext';
@@ -35,6 +35,8 @@ export const GrafanaDashboardList: React.FC = () => {
   const { t } = useLang();
   const { message } = App.useApp();
   const [dashboards, setDashboards] = useState<GrafanaDashboardInfo[]>([]);
+  const [searchText, setSearchText] = useState('');
+  const [selectedTags, setSelectedTags] = useState<string[]>([]);
   const [loading, setLoading] = useState(true);
   const [loadError, setLoadError] = useState(false);
   const [viewing, setViewing] = useState<GrafanaDashboardInfo | null>(null);
@@ -46,6 +48,30 @@ export const GrafanaDashboardList: React.FC = () => {
   const [exportingUids, setExportingUids] = useState<Set<string>>(() => new 
Set());
   const [exportingAll, setExportingAll] = useState(false);
 
+  const tagOptions = useMemo(
+    () =>
+      Array.from(new Set(dashboards.flatMap((dashboard) => dashboard.tags || 
[])))
+        .sort((a, b) => a.localeCompare(b))
+        .map((tag) => ({ label: tag, value: tag })),
+    [dashboards],
+  );
+
+  const filteredDashboards = useMemo(() => {
+    const normalizedSearch = searchText.trim().toLowerCase();
+    return dashboards.filter((dashboard) => {
+      const matchesSearch =
+        !normalizedSearch ||
+        [dashboard.uid, dashboard.title, dashboard.description, 
...(dashboard.tags || [])]
+          .filter(Boolean)
+          .some((value) => value.toLowerCase().includes(normalizedSearch));
+      const matchesTags =
+        selectedTags.length === 0 ||
+        selectedTags.every((tag) => (dashboard.tags || []).includes(tag));
+
+      return matchesSearch && matchesTags;
+    });
+  }, [dashboards, searchText, selectedTags]);
+
   const loadDashboards = useCallback(async () => {
     const requestId = ++listRequestId.current;
     setLoading(true);
@@ -186,6 +212,24 @@ export const GrafanaDashboardList: React.FC = () => {
   return (
     <div>
       <Space style={{ marginBottom: 12 }}>
+        <Input.Search
+          allowClear
+          placeholder={t('grafana.searchPlaceholder')}
+          value={searchText}
+          onChange={(event) => setSearchText(event.target.value)}
+          onSearch={setSearchText}
+          style={{ width: 280 }}
+        />
+        <Select
+          allowClear
+          mode="multiple"
+          maxTagCount="responsive"
+          options={tagOptions}
+          placeholder={t('grafana.allTags')}
+          value={selectedTags}
+          onChange={setSelectedTags}
+          style={{ minWidth: 220 }}
+        />
         <Button
           icon={<DownloadSimple size={16} />}
           loading={exportingAll}
@@ -216,7 +260,7 @@ export const GrafanaDashboardList: React.FC = () => {
 
       <Table
         columns={columns}
-        dataSource={dashboards}
+        dataSource={filteredDashboards}
         loading={loading}
         rowKey="uid"
         pagination={false}
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index d4df753ab..d85c1b979 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -402,6 +402,11 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       ),
     [dataSources, instanceId],
   );
+  const availableDataSourceKeysRef = useRef<Set<string>>(new Set());
+
+  useEffect(() => {
+    availableDataSourceKeysRef.current = new 
Set(availableDataSources.map((source) => source.key));
+  }, [availableDataSources]);
 
   const loadMetrics = useCallback(
     async (metric: MetricMapping | undefined, range: (typeof 
RANGE_OPTIONS)[number]) => {
@@ -417,7 +422,11 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       setQueryLoading(true);
       setQueryError(null);
       try {
-        const currentDataSourceKey = dataSourceKeyRef.current;
+        const selectedDataSourceKey = dataSourceKeyRef.current;
+        const currentDataSourceKey =
+          selectedDataSourceKey && 
availableDataSourceKeysRef.current.has(selectedDataSourceKey)
+            ? selectedDataSourceKey
+            : '';
         const credentials =
           dataSourceCredentialsRef.current?.key === currentDataSourceKey
             ? dataSourceCredentialsRef.current
@@ -554,12 +563,15 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       window.setTimeout(() => {
         // Keep the ref in sync with the state; queries read the ref, so a 
stale key would
         // keep hitting the de-registered data source while the UI shows the 
default.
+        dataSourceCredentialsRef.current = null;
         dataSourceKeyRef.current = '';
         setDataSourceKey('');
         setData(null);
+        setPendingDataSource(null);
+        void loadMetrics(selectedMetric, selectedRange);
       }, 0);
     }
-  }, [availableDataSources, dataSourceKey]);
+  }, [availableDataSources, dataSourceKey, loadMetrics, selectedMetric, 
selectedRange]);
 
   const pendingAuthMode = pendingDataSource
     ? getDataSourceAuthMode(pendingDataSource.auth)
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 367817670..cd0e1024c 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { useCallback, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Button,
   Card,
@@ -61,15 +61,30 @@ export const useQueueBrowser = (instanceId?: string) => {
   const [offsets, setOffsets] = useState<Record<string, number>>({});
   const [pulling, setPulling] = useState<string | null>(null);
   const [entries, setEntries] = useState<PulledEntry[]>([]);
+  const requestSeqRef = useRef(0);
+
+  useEffect(() => {
+    const requestId = ++requestSeqRef.current;
+    void Promise.resolve().then(() => {
+      if (requestId !== requestSeqRef.current) return;
+      setQueues([]);
+      setOffsets({});
+      setEntries([]);
+      setLoading(false);
+      setPulling(null);
+    });
+  }, [instanceId, topic]);
 
   const loadQueues = useCallback(async () => {
     if (!instanceId || !topic) return;
+    const requestId = ++requestSeqRef.current;
     setLoading(true);
     setQueues([]);
     setOffsets({});
     setEntries([]);
     try {
       const result = await getQueueOffsets({ instanceId, topic });
+      if (requestId !== requestSeqRef.current) return;
       setQueues(result);
       const initial: Record<string, number> = {};
       for (const q of result) {
@@ -78,14 +93,17 @@ export const useQueueBrowser = (instanceId?: string) => {
       }
       setOffsets(initial);
     } catch (err) {
-      message.error(err instanceof Error ? err.message : '加载队列信息失败');
+      if (requestId === requestSeqRef.current) {
+        message.error(err instanceof Error ? err.message : '加载队列信息失败');
+      }
     } finally {
-      setLoading(false);
+      if (requestId === requestSeqRef.current) setLoading(false);
     }
   }, [instanceId, topic]);
 
   const handlePull = async (queue: QueueOffset) => {
     if (!instanceId || !topic) return;
+    const requestId = requestSeqRef.current;
     const key = `${queue.brokerName}-${queue.queueId}`;
     const offset = offsets[key] ?? queue.minOffset;
     setPulling(key);
@@ -97,14 +115,17 @@ export const useQueueBrowser = (instanceId?: string) => {
         queueId: queue.queueId,
         offset,
       });
+      if (requestId !== requestSeqRef.current) return;
       setEntries((prev) => [
         ...prev.filter((entry) => entry.key !== key),
         { key, offset, message: msg },
       ]);
     } catch (err) {
-      message.error(err instanceof Error ? err.message : '拉取消息失败');
+      if (requestId === requestSeqRef.current) {
+        message.error(err instanceof Error ? err.message : '拉取消息失败');
+      }
     } finally {
-      setPulling(null);
+      if (requestId === requestSeqRef.current) setPulling(null);
     }
   };
 
diff --git a/web/src/components/__tests__/AlertRuleAssetList.test.tsx 
b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
index 8555827e9..8e9e742b7 100644
--- a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
+++ b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
@@ -17,6 +17,7 @@
 
 import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
 import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { App as AntdApp } from 'antd';
 import AlertRuleAssetList from '../AlertRuleAssetList';
 import { LangProvider } from '../../i18n/LangContext';
@@ -81,6 +82,29 @@ describe('AlertRuleAssetList', () => {
     expect(screen.getByText('rocketmq-consumer-lag-high')).toBeInTheDocument();
   });
 
+  it('filters assets by search text and severity', async () => {
+    
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    const { container } = renderWithProviders(<AlertRuleAssetList />);
+
+    expect(await 
screen.findByText('rocketmq-broker-down')).toBeInTheDocument();
+
+    await user.type(screen.getByPlaceholderText(/Search alert|搜索告警/), 
'consumer');
+
+    expect(screen.getByText('rocketmq-consumer-lag-high')).toBeInTheDocument();
+    expect(screen.queryByText('rocketmq-broker-down')).not.toBeInTheDocument();
+
+    await user.clear(screen.getByPlaceholderText(/Search alert|搜索告警/));
+    await user.click(container.querySelector('.ant-select-selector') as 
Element);
+    await user.click(
+      await screen.findByText('CRITICAL', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    expect(screen.getByText('rocketmq-broker-down')).toBeInTheDocument();
+    await waitFor(() =>
+      
expect(screen.queryByText('rocketmq-consumer-lag-high')).not.toBeInTheDocument(),
+    );
+  }, 10_000);
   it('keeps a failed list request visible and recovers when retried', async () 
=> {
     vi.mocked(alertRuleAssetService.listAlertRuleAssets)
       .mockRejectedValueOnce(new Error('temporary failure'))
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx 
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index d2248c487..400bc842e 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -41,9 +41,9 @@ const dashboards = [
     uid: 'rocketmq-overview',
     title: 'RocketMQ Cluster Overview',
     description: 'Overview',
-    tags: ['rocketmq'],
+    tags: ['overview', 'rocketmq'],
   },
-  { uid: 'rocketmq-broker', title: 'RocketMQ Broker', description: 'Broker', 
tags: ['rocketmq'] },
+  { uid: 'rocketmq-broker', title: 'RocketMQ Broker', description: 'Broker', 
tags: ['broker'] },
 ];
 
 const dashboardModel = {
@@ -53,6 +53,15 @@ const dashboardModel = {
   panels: [{ id: 1, title: 'Messages In TPS', type: 'timeseries' }],
 };
 
+const renderDashboardList = () =>
+  render(
+    <App>
+      <LangProvider>
+        <GrafanaDashboardList />
+      </LangProvider>
+    </App>,
+  );
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -100,6 +109,25 @@ describe('GrafanaDashboardList', () => {
     expect(screen.getByText('RocketMQ Broker')).toBeInTheDocument();
   });
 
+  it('filters dashboards by search text and tags', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    const { container } = renderDashboardList();
+    const overviewCell = await screen.findByText('RocketMQ Cluster Overview');
+    expect(overviewCell).toBeInTheDocument();
+    await user.type(screen.getByPlaceholderText(/Search dashboard|搜索看板/), 
'broker');
+    const brokerCell = screen.getByText('RocketMQ Broker');
+    expect(brokerCell).toBeInTheDocument();
+    expect(screen.queryByText('RocketMQ Cluster 
Overview')).not.toBeInTheDocument();
+
+    await user.clear(screen.getByPlaceholderText(/Search dashboard|搜索看板/));
+    await user.click(container.querySelector('.ant-select-selector') as 
Element);
+    await user.click(
+      await screen.findByText('overview', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    expect(screen.getByText('RocketMQ Cluster Overview')).toBeInTheDocument();
+    await waitFor(() => expect(screen.queryByText('RocketMQ 
Broker')).not.toBeInTheDocument());
+  }, 10_000);
   it('keeps a failed list request visible and recovers when retried', async () 
=> {
     vi.mocked(listGrafanaDashboards)
       .mockRejectedValueOnce(new Error('temporary failure'))
@@ -252,9 +280,11 @@ describe('GrafanaDashboardList', () => {
     );
 
     await screen.findByText('RocketMQ Cluster Overview');
+    await user.type(screen.getByPlaceholderText(/Search dashboard|搜索看板/), 
'broker');
     await user.click(screen.getByRole('button', { name: /Export all|导出全部/ }));
 
     await waitFor(() => 
expect(exportGrafanaDashboards).toHaveBeenCalledTimes(1));
+    expect(exportGrafanaDashboard).not.toHaveBeenCalled();
     expect(downloadedFilename).toBe('rocketmq-grafana-dashboards.zip');
     expect(createObjectURL).toHaveBeenCalledTimes(1);
     expect(clickSpy).toHaveBeenCalled();
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index b14a3f140..d13a85d98 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -508,4 +508,58 @@ describe('MetricsExplorer', () => {
     expect(screen.getByText('Instance A Prometheus')).toBeInTheDocument();
     expect(screen.queryByText('Instance B 
Prometheus')).not.toBeInTheDocument();
   });
+
+  it('falls back to the default source when the selected data source leaves 
the instance scope', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-instance-a',
+        name: 'Instance A Prometheus',
+        type: 'Prometheus',
+        url: '',
+        auth: 'None',
+        status: 'healthy',
+        instanceIds: ['instance-1'],
+      },
+    ]);
+
+    const view = renderWithProviders(<MetricsExplorer instanceId="instance-1" 
/>);
+
+    await screen.findByRole('combobox', { name: '数据源' });
+    await user.click(screen.getByRole('combobox', { name: '数据源' }));
+    await user.click(
+      await screen.findByText('Instance A Prometheus', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+
+    await waitFor(() =>
+      expect(queryByDataSource).toHaveBeenCalledWith(
+        expect.objectContaining({
+          key: 'ds-instance-a',
+          instanceId: 'instance-1',
+        }),
+      ),
+    );
+    const dataSourceQueriesBeforeScopeChange = 
vi.mocked(queryByDataSource).mock.calls.length;
+    const defaultQueriesBeforeScopeChange = 
vi.mocked(queryMetrics).mock.calls.length;
+
+    view.rerender(
+      <App>
+        <LangProvider>
+          <MetricsExplorer instanceId="instance-2" />
+        </LangProvider>
+      </App>,
+    );
+
+    await waitFor(() =>
+      expect(vi.mocked(queryMetrics).mock.calls.length).toBeGreaterThan(
+        defaultQueriesBeforeScopeChange,
+      ),
+    );
+    expect(vi.mocked(queryByDataSource).mock.calls).toHaveLength(
+      dataSourceQueriesBeforeScopeChange,
+    );
+    expect(screen.getAllByTitle('默认数据源').length).toBeGreaterThan(0);
+  });
 });
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
new file mode 100644
index 000000000..f0aa7a908
--- /dev/null
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -0,0 +1,170 @@
+/*
+ * 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 { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { MessageRecord, QueueOffset } from '../../api/message';
+import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
+import { useQueueBrowser } from '../QueueBrowser';
+
+vi.mock('../../api/message', () => ({
+  getQueueOffsets: vi.fn(),
+  pullMessageAtOffset: vi.fn(),
+}));
+
+const createDeferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((promiseResolve) => {
+    resolve = promiseResolve;
+  });
+  return { promise, resolve };
+};
+
+const queue = (brokerName: string): QueueOffset => ({
+  brokerName,
+  queueId: 0,
+  minOffset: 0,
+  maxOffset: 3,
+});
+
+const messageRecord = (msgId: string): MessageRecord => ({
+  msgId,
+  topic: 'topic-a',
+  tag: null,
+  key: null,
+  brokerName: 'broker-a',
+  queueId: 0,
+  queueOffset: 2,
+  body: '{}',
+  storeTime: '2026-08-25T00:00:00Z',
+  bornHost: '127.0.0.1:1000',
+  storeHost: '127.0.0.1:10911',
+  properties: {},
+  size: 2,
+});
+
+function QueueBrowserProbe({ instanceId = 'instance-a' }: { instanceId?: 
string }) {
+  const state = useQueueBrowser(instanceId);
+  const firstQueue = state.queues[0];
+  return (
+    <div>
+      <button type="button" onClick={() => state.setTopic('topic-a')}>
+        topic-a
+      </button>
+      <button type="button" onClick={() => state.setTopic('topic-b')}>
+        topic-b
+      </button>
+      <button type="button" onClick={() => void state.loadQueues()}>
+        load
+      </button>
+      <button
+        type="button"
+        disabled={!firstQueue}
+        onClick={() => firstQueue && void state.handlePull(firstQueue)}
+      >
+        pull
+      </button>
+      <output aria-label="topic">{state.topic ?? ''}</output>
+      <output aria-label="queues">{state.queues.map((item) => 
item.brokerName).join(',')}</output>
+      <output aria-label="entries">
+        {state.entries.map((entry) => entry.message?.msgId ?? 
'empty').join(',')}
+      </output>
+      <output aria-label="loading">{String(state.loading)}</output>
+    </div>
+  );
+}
+
+describe('QueueBrowser request ownership', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('keeps the newest topic queue load when an older request resolves later', 
async () => {
+    const topicA = createDeferred<QueueOffset[]>();
+    const topicB = createDeferred<QueueOffset[]>();
+    vi.mocked(getQueueOffsets)
+      .mockReturnValueOnce(topicA.promise)
+      .mockReturnValueOnce(topicB.promise);
+    const user = userEvent.setup();
+    render(<QueueBrowserProbe />);
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await waitFor(() => 
expect(screen.getByLabelText('topic')).toHaveTextContent('topic-a'));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await waitFor(() =>
+      expect(getQueueOffsets).toHaveBeenLastCalledWith({
+        instanceId: 'instance-a',
+        topic: 'topic-a',
+      }),
+    );
+
+    await user.click(screen.getByRole('button', { name: 'topic-b' }));
+    await waitFor(() => 
expect(screen.getByLabelText('topic')).toHaveTextContent('topic-b'));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await waitFor(() =>
+      expect(getQueueOffsets).toHaveBeenLastCalledWith({
+        instanceId: 'instance-a',
+        topic: 'topic-b',
+      }),
+    );
+
+    await act(async () => {
+      topicB.resolve([queue('broker-b')]);
+    });
+    expect(screen.getByLabelText('queues')).toHaveTextContent('broker-b');
+
+    await act(async () => {
+      topicA.resolve([queue('broker-a')]);
+    });
+    expect(screen.getByLabelText('queues')).toHaveTextContent('broker-b');
+    expect(screen.getByLabelText('queues')).not.toHaveTextContent('broker-a');
+  });
+
+  it('ignores a pulled message after the topic changes', async () => {
+    const pull = createDeferred<MessageRecord | null>();
+    vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+    vi.mocked(pullMessageAtOffset).mockReturnValue(pull.promise);
+    const user = userEvent.setup();
+    render(<QueueBrowserProbe />);
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await waitFor(() => 
expect(screen.getByLabelText('topic')).toHaveTextContent('topic-a'));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await waitFor(() => 
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'));
+
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    await waitFor(() =>
+      expect(pullMessageAtOffset).toHaveBeenCalledWith({
+        instanceId: 'instance-a',
+        topic: 'topic-a',
+        brokerName: 'broker-a',
+        queueId: 0,
+        offset: 2,
+      }),
+    );
+
+    await user.click(screen.getByRole('button', { name: 'topic-b' }));
+    await waitFor(() => 
expect(screen.getByLabelText('topic')).toHaveTextContent('topic-b'));
+
+    await act(async () => {
+      pull.resolve(messageRecord('stale-message'));
+    });
+    expect(screen.getByLabelText('entries')).toHaveTextContent('');
+    expect(screen.queryByText('stale-message')).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 843a3e82a..3ded5988a 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -151,6 +151,33 @@ const translations: Record<string, Record<Lang, string>> = 
{
     en: 'Delete NameServer "{name}"? This action cannot be undone.',
   },
   'cluster.nsOperationFailed': { zh: '操作失败,请稍后重试', en: 'Operation failed, 
please retry' },
+  'cluster.nsConfigDiff': { zh: '配置差异', en: 'Config Diff' },
+  'cluster.nsConfigDiffTitle': {
+    zh: 'NameServer 配置差异 - {name}',
+    en: 'NameServer Config Diff - {name}',
+  },
+  'cluster.nsConfigDiffLoading': {
+    zh: '正在检测 NameServer 配置差异',
+    en: 'Checking NameServer config diff',
+  },
+  'cluster.nsConfigDiffFailed': {
+    zh: 'NameServer 配置差异检测失败,请稍后重试',
+    en: 'Failed to check NameServer config diff, please retry',
+  },
+  'cluster.nsConfigDiffDriftDetected': {
+    zh: '检测到 NameServer 配置差异',
+    en: 'NameServer config drift detected',
+  },
+  'cluster.nsConfigDiffNoDrift': {
+    zh: '未检测到 NameServer 配置差异',
+    en: 'No NameServer config drift detected',
+  },
+  'cluster.nsConfigDiffComplete': { zh: '检测完整', en: 'Complete' },
+  'cluster.nsConfigDiffComparedKeys': { zh: '比较配置项', en: 'Compared Keys' },
+  'cluster.nsConfigDiffValues': { zh: '节点配置值', en: 'Node Values' },
+  'cluster.nsConfigDiffReachable': { zh: '可达', en: 'Reachable' },
+  'cluster.nsConfigDiffUnreachable': { zh: '不可达', en: 'Unreachable' },
+  'cluster.nsConfigDiffUnconfigured': { zh: '未配置', en: 'Unconfigured' },
   'cluster.nsClusterName': { zh: 'NameServer 集群名称', en: 'NS Cluster Name' },
   'cluster.count': { zh: '数量', en: 'Count' },
   'cluster.searchBroker': {
@@ -715,6 +742,14 @@ const translations: Record<string, Record<Lang, string>> = 
{
     zh: '删除数据源失败,请稍后重试',
     en: 'Failed to delete data source. Please try again later.',
   },
+  'settings.dataSourceExported': {
+    zh: '已导出 {total} 个数据源',
+    en: 'Exported {total} data sources',
+  },
+  'settings.dataSourceExportFailed': {
+    zh: '导出数据源失败,请稍后重试',
+    en: 'Failed to export data sources. Please try again later.',
+  },
   'settings.instances': { zh: '适用实例', en: 'Applicable instances' },
   'settings.global': { zh: '全局', en: 'Global' },
   'settings.authentication': { zh: '认证方式', en: 'Authentication' },
@@ -1154,6 +1189,11 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'grafana.title': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
   'grafana.description': { zh: '说明', en: 'Description' },
   'grafana.tags': { zh: '标签', en: 'Tags' },
+  'grafana.searchPlaceholder': {
+    zh: '搜索看板标题、UID、说明或标签',
+    en: 'Search dashboard title, UID, description or tag',
+  },
+  'grafana.allTags': { zh: '全部标签', en: 'All tags' },
   'grafana.loadFailed': { zh: '加载看板失败', en: 'Failed to load dashboards' },
   'grafana.exported': { zh: '看板已导出', en: 'Dashboard exported' },
   'grafana.exportFailed': { zh: '导出看板失败', en: 'Failed to export dashboard' },
@@ -1166,6 +1206,11 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'alertAssets.group': { zh: '规则组', en: 'Group' },
   'alertAssets.ruleCount': { zh: '规则数', en: 'Rules' },
   'alertAssets.severity': { zh: '级别', en: 'Severity' },
+  'alertAssets.searchPlaceholder': {
+    zh: '搜索告警名称或规则组',
+    en: 'Search alert name or group',
+  },
+  'alertAssets.allSeverities': { zh: '全部级别', en: 'All severities' },
   'alertAssets.loadFailed': { zh: '加载告警规则失败', en: 'Failed to load alert rules' 
},
   'alertAssets.exported': { zh: '告警规则已导出', en: 'Alert rule exported' },
   'alertAssets.exportFailed': { zh: '导出告警规则失败', en: 'Failed to export alert 
rule' },
@@ -1356,6 +1401,11 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'proxy.totalConnections': { zh: '总连接数', en: 'Total Connections' },
   'proxy.totalTps': { zh: '总 TPS', en: 'Total TPS' },
   'proxy.nodes': { zh: 'Proxy 节点', en: 'Proxy Nodes' },
+  'proxy.nodeFilter': { zh: '筛选 Proxy 节点', en: 'Filter Proxy nodes' },
+  'proxy.nodeFilterPlaceholder': {
+    zh: '筛选地址、状态或版本',
+    en: 'Filter address, status or version',
+  },
   'proxy.viewConfig': { zh: '查看配置', en: 'View Config' },
   'proxy.nodeConfig': { zh: '节点配置', en: 'Node Configuration' },
   'proxy.current': { zh: '当前', en: 'Current' },
@@ -1654,6 +1704,14 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'liteTopic.fetchSessionFailed': { zh: '获取会话详情失败', en: 'Failed to fetch 
session detail' },
   'liteTopic.extendTtlSuccess': { zh: 'TTL 延长成功', en: 'TTL extended 
successfully' },
   'liteTopic.extendTtlFailed': { zh: 'TTL 延长失败', en: 'Failed to extend TTL' },
+  'liteTopic.exportSuccess': {
+    zh: '已导出 {total} 条 LiteTopic',
+    en: 'Exported {total} LiteTopics',
+  },
+  'liteTopic.exportFailed': {
+    zh: '导出 LiteTopic 失败,请稍后重试',
+    en: 'Failed to export LiteTopics',
+  },
   'liteTopic.total': { zh: '共 {total} 条记录', en: 'Total {total} records' },
   'liteTopic.defaultTtl': { zh: '默认 TTL', en: 'Default TTL' },
   'liteTopic.maxTtl': { zh: '最大 TTL', en: 'Max TTL' },
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index be42cd09d..31cf80579 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -27,6 +27,7 @@ import { LangProvider } from '../../../i18n/LangContext';
 const clusterServiceMocks = vi.hoisted(() => ({
   createNameserverRegistry: vi.fn(),
   deleteNameserverRegistry: vi.fn(),
+  getNameServerConfigDiff: vi.fn(),
   listClusters: vi.fn(),
   listK8sCerts: vi.fn(),
   listNameserverRegistry: vi.fn(),
@@ -241,6 +242,16 @@ describe('Cluster page', () => {
       gmtModified: '',
     });
     
clusterServiceMocks.deleteNameserverRegistry.mockReset().mockResolvedValue(undefined);
+    clusterServiceMocks.getNameServerConfigDiff.mockReset().mockResolvedValue({
+      cluster: 'rocketmq1',
+      complete: true,
+      driftDetected: false,
+      nodeCount: 1,
+      reachableNodeCount: 1,
+      comparedKeys: ['serverWorkerThreads'],
+      nodes: [{ address: 'rocketmq1-nameserver:9876', reachable: true }],
+      differences: [],
+    });
     clusterServiceMocks.listNameserverRegistry.mockReset().mockResolvedValue([
       {
         id: 1,
@@ -457,6 +468,66 @@ describe('Cluster page', () => {
     confirmSpy.mockRestore();
   });
 
+  it('opens NameServer config drift details from a registry row', async () => {
+    const user = userEvent.setup();
+    clusterServiceMocks.listRegistryClusters.mockResolvedValue([
+      {
+        ...buildCluster(),
+        name: 'rocketmq1',
+        nsClusterName: 'rocketmq1',
+        endpoint: 'rocketmq1-nameserver:9876',
+        nameServers: [
+          { addr: 'rocketmq1-nameserver:9876', status: 'healthy' },
+          { addr: 'rocketmq1-nameserver-1:9876', status: 'healthy' },
+        ],
+      },
+    ]);
+    clusterServiceMocks.getNameServerConfigDiff.mockResolvedValue({
+      cluster: 'rocketmq1',
+      complete: true,
+      driftDetected: true,
+      nodeCount: 2,
+      reachableNodeCount: 2,
+      comparedKeys: ['serverWorkerThreads'],
+      nodes: [
+        { address: 'rocketmq1-nameserver:9876', reachable: true },
+        { address: 'rocketmq1-nameserver-1:9876', reachable: true },
+      ],
+      differences: [
+        {
+          key: 'serverWorkerThreads',
+          values: [
+            { address: 'rocketmq1-nameserver:9876', configured: true, value: 
'8' },
+            { address: 'rocketmq1-nameserver-1:9876', configured: true, value: 
'12' },
+          ],
+        },
+      ],
+    });
+    renderWithProviders(<ClusterPage />);
+
+    await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+    const row = await screen.findByRole('row', { name: 
/rocketmq1-nameserver:9876/ });
+    await user.click(within(row).getByRole('button', { name: /配置差异/ }));
+
+    await waitFor(() =>
+      expect(clusterServiceMocks.getNameServerConfigDiff).toHaveBeenCalledWith(
+        'cluster-prod',
+        'instance-1',
+      ),
+    );
+    const dialog = await screen.findByRole('dialog', {
+      name: /NameServer 配置差异 - rocketmq1/,
+    });
+    expect(within(dialog).getByText('检测到 NameServer 
配置差异')).toBeInTheDocument();
+    
expect(within(dialog).getAllByText('serverWorkerThreads').length).toBeGreaterThan(0);
+    expect(
+      within(dialog).getByText((content) => 
content.includes('rocketmq1-nameserver:9876: 8')),
+    ).toBeInTheDocument();
+    expect(
+      within(dialog).getByText((content) => 
content.includes('rocketmq1-nameserver-1:9876: 12')),
+    ).toBeInTheDocument();
+  });
+
   it('polls the API after two seconds and renders only returned metrics', 
async () => {
     vi.useFakeTimers();
     const randomSpy = vi.spyOn(Math, 'random');
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 58051a758..78746dbc5 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -36,6 +36,7 @@ import {
   Typography,
   Card,
   Alert,
+  Spin,
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -60,10 +61,13 @@ import type {
   ClusterConfigPreviewResult,
   ClusterInfo,
   ClusterProbeResult,
+  NameServerConfigDiffResult,
+  NameServerConfigDifference,
 } from '../../api/cluster';
 import {
   createNameserverRegistry,
   deleteNameserverRegistry,
+  getNameServerConfigDiff,
   listClusters,
   listK8sCerts,
   listNameserverRegistry,
@@ -88,6 +92,7 @@ type RefreshSource = 'initial' | 'manual' | 'operation' | 
'background';
 type ProxyDetail = ProxyInfo & { clusterId: string; clusterName: string; 
nsClusterName: string };
 type ClusterConfigFormValues = Partial<ClusterConfig> & { maxMessageSizeMB: 
number };
 type ClusterConfigRequest = { id: string; instanceId?: string } & 
Partial<ClusterConfig>;
+type NameServerConfigDiffNode = NameServerConfigDiffResult['nodes'][number];
 
 const safeText = (value: string | null | undefined) => value ?? '';
 const searchText = (value: string | null | undefined) => 
safeText(value).toLowerCase();
@@ -127,6 +132,17 @@ const ClusterPage = () => {
   const [configSubmitting, setConfigSubmitting] = useState(false);
   const [nsRegistry, setNsRegistry] = useState<NameserverRegistryEntry[]>([]);
   const [selectedProxy, setSelectedProxy] = useState<ProxyDetail | null>(null);
+  const [nsConfigDiffState, setNsConfigDiffState] = useState<{
+    open: boolean;
+    loading: boolean;
+    cluster: ClusterInfo | null;
+    result: NameServerConfigDiffResult | null;
+  }>({
+    open: false,
+    loading: false,
+    cluster: null,
+    result: null,
+  });
   const [configForm] = Form.useForm();
 
   const [k8sIdOptions, setK8sIdOptions] = useState<string[]>([]);
@@ -278,6 +294,45 @@ const ClusterPage = () => {
     [loadNsRegistry, t],
   );
 
+  const resolveNameserverRegistryCluster = useCallback(
+    (entry: NameserverRegistryEntry) => {
+      const namesrvAddr = safeText(entry.namesrvAddr);
+      const name = safeText(entry.name);
+      return registryClusters.find(
+        (cluster) =>
+          cluster.endpoint === namesrvAddr ||
+          cluster.nameServers.some((nameServer) => nameServer.addr === 
namesrvAddr) ||
+          cluster.name === name ||
+          cluster.nsClusterName === name,
+      );
+    },
+    [registryClusters],
+  );
+
+  const openNameServerConfigDiff = useCallback(
+    async (cluster: ClusterInfo) => {
+      setNsConfigDiffState({
+        open: true,
+        loading: true,
+        cluster,
+        result: null,
+      });
+      try {
+        const result = await getNameServerConfigDiff(cluster.id, 
selectedInstanceIdRef.current);
+        setNsConfigDiffState({
+          open: true,
+          loading: false,
+          cluster,
+          result,
+        });
+      } catch {
+        setNsConfigDiffState((current) => ({ ...current, loading: false }));
+        message.error(t('cluster.nsConfigDiffFailed'));
+      }
+    },
+    [t],
+  );
+
   // ─── Connection test ──────────────────────────────────────────────────────
   const [connectModalOpen, setConnectModalOpen] = useState(false);
   const [connectTesting, setConnectTesting] = useState(false);
@@ -690,6 +745,126 @@ const ClusterPage = () => {
 
   // ─── Tab 2: Broker 管理 (flat table) ────────────────────────────────────────
 
+  function renderNameServerConfigDiffModal() {
+    const { cluster, loading: diffLoading, open, result } = nsConfigDiffState;
+    const titleName = cluster?.nsClusterName ?? cluster?.name ?? 
result?.cluster ?? '-';
+    const nodeColumns: ColumnsType<NameServerConfigDiffNode> = [
+      {
+        title: t('common.address'),
+        dataIndex: 'address',
+        key: 'address',
+        render: (address: string) => <Text copyable>{address}</Text>,
+      },
+      {
+        title: t('common.status'),
+        dataIndex: 'reachable',
+        key: 'reachable',
+        width: 120,
+        render: (reachable: boolean) => (
+          <Tag color={reachable ? 'green' : 'red'}>
+            {reachable ? t('cluster.nsConfigDiffReachable') : 
t('cluster.nsConfigDiffUnreachable')}
+          </Tag>
+        ),
+      },
+    ];
+    const differenceColumns: ColumnsType<NameServerConfigDifference> = [
+      {
+        title: t('cluster.configPreviewField'),
+        dataIndex: 'key',
+        key: 'key',
+        width: 220,
+        render: (key: string) => <Text strong>{key}</Text>,
+      },
+      {
+        title: t('cluster.nsConfigDiffValues'),
+        dataIndex: 'values',
+        key: 'values',
+        render: (values: NameServerConfigDifference['values']) => (
+          <Space size={[0, 4]} wrap>
+            {values.map((value) => (
+              <Tag key={value.address} color={value.configured ? 'blue' : 
'default'}>
+                {`${value.address}: ${
+                  value.configured ? (value.value ?? '-') : 
t('cluster.nsConfigDiffUnconfigured')
+                }`}
+              </Tag>
+            ))}
+          </Space>
+        ),
+      },
+    ];
+
+    return (
+      <Modal
+        title={t('cluster.nsConfigDiffTitle', { name: titleName })}
+        open={open}
+        onCancel={() =>
+          setNsConfigDiffState({ open: false, loading: false, cluster: null, 
result: null })
+        }
+        footer={
+          <Button
+            onClick={() =>
+              setNsConfigDiffState({ open: false, loading: false, cluster: 
null, result: null })
+            }
+          >
+            {t('common.close')}
+          </Button>
+        }
+        width={920}
+        destroyOnHidden
+      >
+        <Spin spinning={diffLoading}>
+          {result ? (
+            <>
+              <Alert
+                showIcon
+                type={result.driftDetected ? 'warning' : 'success'}
+                message={
+                  result.driftDetected
+                    ? t('cluster.nsConfigDiffDriftDetected')
+                    : t('cluster.nsConfigDiffNoDrift')
+                }
+                style={{ marginBottom: 16 }}
+              />
+              <Descriptions size="small" column={2} style={{ marginBottom: 16 
}}>
+                <Descriptions.Item label={t('cluster.configPreviewTargets')}>
+                  {`${result.reachableNodeCount}/${result.nodeCount}`}
+                </Descriptions.Item>
+                <Descriptions.Item label={t('cluster.nsConfigDiffComplete')}>
+                  {result.complete ? t('common.yes') : t('common.no')}
+                </Descriptions.Item>
+                <Descriptions.Item 
label={t('cluster.nsConfigDiffComparedKeys')} span={2}>
+                  <Space size={[0, 4]} wrap>
+                    {result.comparedKeys.map((key) => (
+                      <Tag key={key}>{key}</Tag>
+                    ))}
+                  </Space>
+                </Descriptions.Item>
+              </Descriptions>
+              <Table<NameServerConfigDiffNode>
+                columns={nodeColumns}
+                dataSource={result.nodes}
+                rowKey="address"
+                pagination={false}
+                size="small"
+                style={{ marginBottom: 16 }}
+              />
+              <Table<NameServerConfigDifference>
+                columns={differenceColumns}
+                dataSource={result.differences}
+                rowKey="key"
+                pagination={false}
+                size="small"
+                locale={{ emptyText: t('cluster.configPreviewNoChanges') }}
+              />
+            </>
+          ) : (
+            <Alert showIcon type="info" 
message={t('cluster.nsConfigDiffLoading')} />
+          )}
+        </Spin>
+      </Modal>
+    );
+  }
+
   function renderBrokerTab() {
     type BrokerWithCluster = BrokerInfo & {
       clusterName: string;
@@ -1016,27 +1191,43 @@ const ClusterPage = () => {
       {
         title: t('common.actions'),
         key: 'action',
-        width: 160,
-        render: (_: unknown, record: NameserverRegistryEntry) => (
-          <Flex gap={6}>
-            <Button
-              size="small"
-              icon={<EditOutlined />}
-              style={{ borderColor: '#722ed1', color: '#722ed1' }}
-              onClick={() => openNsEditModal(record)}
-            >
-              {t('common.edit')}
-            </Button>
-            <Button
-              size="small"
-              danger
-              icon={<DeleteOutlined />}
-              onClick={() => handleNsDelete(record)}
-            >
-              {t('common.delete')}
-            </Button>
-          </Flex>
-        ),
+        width: 260,
+        render: (_: unknown, record: NameserverRegistryEntry) => {
+          const matchedCluster = resolveNameserverRegistryCluster(record);
+          return (
+            <Flex gap={6}>
+              <Button
+                size="small"
+                icon={<EyeOutlined />}
+                disabled={!matchedCluster}
+                loading={
+                  nsConfigDiffState.loading && nsConfigDiffState.cluster?.id 
=== matchedCluster?.id
+                }
+                onClick={() => {
+                  if (matchedCluster) void 
openNameServerConfigDiff(matchedCluster);
+                }}
+              >
+                {t('cluster.nsConfigDiff')}
+              </Button>
+              <Button
+                size="small"
+                icon={<EditOutlined />}
+                style={{ borderColor: '#722ed1', color: '#722ed1' }}
+                onClick={() => openNsEditModal(record)}
+              >
+                {t('common.edit')}
+              </Button>
+              <Button
+                size="small"
+                danger
+                icon={<DeleteOutlined />}
+                onClick={() => handleNsDelete(record)}
+              >
+                {t('common.delete')}
+              </Button>
+            </Flex>
+          );
+        },
       },
     ];
 
@@ -1401,6 +1592,9 @@ const ClusterPage = () => {
           </Descriptions>
         )}
       </Modal>
+
+      {renderNameServerConfigDiffModal()}
+
       <Modal
         title={t('cluster.testConnectionTitle')}
         open={connectModalOpen}
diff --git a/web/src/pages/home/__tests__/HomePage.test.tsx 
b/web/src/pages/home/__tests__/HomePage.test.tsx
index c2fe44391..0a0d3aada 100644
--- a/web/src/pages/home/__tests__/HomePage.test.tsx
+++ b/web/src/pages/home/__tests__/HomePage.test.tsx
@@ -123,3 +123,19 @@ describe('HomePage LLM models', () => {
     expect(navigateMock).not.toHaveBeenCalled();
   });
 });
+
+describe('HomePage footer', () => {
+  it('links to RocketMQ documentation and community pages', () => {
+    renderHome();
+
+    const docsLink = screen.getByRole('link', { name: '文档中心' });
+    expect(docsLink).toHaveAttribute('href', 
'https://rocketmq.apache.org/docs/');
+    expect(docsLink).toHaveAttribute('target', '_blank');
+    expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer');
+
+    const communityLink = screen.getByRole('link', { name: 'RocketMQ 社区' });
+    expect(communityLink).toHaveAttribute('href', 
'https://rocketmq.apache.org/');
+    expect(communityLink).toHaveAttribute('target', '_blank');
+    expect(communityLink).toHaveAttribute('rel', 'noopener noreferrer');
+  });
+});
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index 21b081c2c..08c192a9f 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -66,6 +66,9 @@ const ENGINE_OPTIONS = [
   { value: 'http', label: 'HTTP' },
 ];
 
+const ROCKETMQ_DOCS_URL = 'https://rocketmq.apache.org/docs/';
+const ROCKETMQ_COMMUNITY_URL = 'https://rocketmq.apache.org/';
+
 // 首页只暴露这些模型(token-plan 网关实际可对话的模型集),qwen3.8-max 为推荐项。
 const HOME_MODELS = [
   'qwen3.8-max',
@@ -566,22 +569,26 @@ const HomePage = () => {
         >
           <span className="pointer-events-auto">
             <a
-              href="#"
+              href={ROCKETMQ_DOCS_URL}
+              target="_blank"
+              rel="noopener noreferrer"
               className="transition-colors hover:text-purple-500"
               style={{ textDecoration: 'none' }}
             >
-              文档中心
+              {t('home.docs')}
             </a>
             <span style={{ margin: '0 4px' }}>|</span>
             <a
-              href="#"
+              href={ROCKETMQ_COMMUNITY_URL}
+              target="_blank"
+              rel="noopener noreferrer"
               className="transition-colors hover:text-purple-500"
               style={{ textDecoration: 'none' }}
             >
-              RocketMQ 社区
+              {t('home.community')}
             </a>
             <span style={{ margin: '0 4px' }}>|</span>
-            <span>RocketMQ Studio 出品</span>
+            <span>{t('home.brand')}</span>
             <span style={{ margin: '0 4px' }}>|</span>
             <span>
               当前版本 {__BUILD_TIME__} build({__BUILD_COMMIT__})
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 35bf481fb..e1d6adb58 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -22,6 +22,7 @@ import { MemoryRouter } from 'react-router-dom';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import * as aliyunCatalogApi from '../../../api/aliyunCatalog';
 import * as cloudCredentialApi from '../../../api/cloudCredential';
+import * as tencentCatalogApi from '../../../api/tencentCatalog';
 import type { CloudCredential, CloudCredentialPage } from 
'../../../api/cloudCredential';
 import type { Instance } from '../../../api/instance';
 import { LangProvider } from '../../../i18n/LangContext';
@@ -118,6 +119,8 @@ describe('InstancePage', () => {
     
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(cloudCredentialPage([]));
     vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([]);
     vi.mocked(aliyunCatalogApi.listAliyunInstances).mockResolvedValue([]);
+    vi.mocked(tencentCatalogApi.listTencentRegions).mockResolvedValue([]);
+    vi.mocked(tencentCatalogApi.listTencentInstances).mockResolvedValue([]);
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       instance(1, 'production-proxy'),
       instance(2, 'development-direct', 'DIRECT'),
@@ -454,6 +457,60 @@ describe('InstancePage', () => {
     ).toBeInTheDocument();
   });
 
+  it('imports every Tencent instance of the credential via one-click import', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+      cloudCredentialPage([
+        {
+          id: 201,
+          name: 'tencent-prod-account',
+          vendor: 'TENCENT',
+          accessKey: 'AKID-prod',
+          gmtCreate: '2026-01-01T00:00:00Z',
+        },
+      ]),
+    );
+    vi.mocked(instanceService.importCloudInstances).mockResolvedValue({
+      discovered: 4,
+      imported: 1,
+      skipped: 3,
+      failed: [],
+    });
+
+    renderPage();
+    expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /添加实例/ }));
+    const dialog = await screen.findByRole('dialog');
+    await user.click(within(dialog).getByRole('tab', { name: /Tencent 版/ }));
+    await waitFor(() =>
+      
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenLastCalledWith('TENCENT'),
+    );
+
+    const importButton = within(dialog).getByRole('button', { name: /一键导入/ });
+    expect(importButton).toBeDisabled();
+
+    const credentialSelect = within(dialog).getAllByRole('combobox')[0];
+    fireEvent.mouseDown(credentialSelect.parentElement!);
+    await user.click(
+      await screen.findByText(/tencent-prod-account/, {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    await waitFor(() => expect(importButton).toBeEnabled());
+
+    await user.click(importButton);
+
+    await waitFor(() =>
+      expect(instanceService.importCloudInstances).toHaveBeenCalledWith({
+        vendor: 'TENCENT',
+        credentialId: 201,
+      }),
+    );
+    expect(
+      await screen.findByText(/导入完成:共同步 4 个实例(新导入 1,已存在跳过 3)/),
+    ).toBeInTheDocument();
+  });
+
   it('ignores a stale region response after the cloud credential changes', 
async () => {
     const user = userEvent.setup();
     const oldRegions = deferred<Array<{ regionId: string; regionName: string 
}>>();
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 3f77e5293..093cabd84 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -723,7 +723,7 @@ const InstancePage = () => {
         width={520}
         footer={
           <Flex justify="flex-end" gap={8}>
-            {vendor === 'ALIYUN' && (
+            {cloudVendor && (
               <Tooltip title="遍历该凭据下全部地域,将所有云上实例导入(幂等,已存在的自动跳过),备注自动取自云上实例">
                 <Button
                   loading={importing}
diff --git a/web/src/pages/settings/DataSourceTab.tsx 
b/web/src/pages/settings/DataSourceTab.tsx
index d0e7729ed..df7140bca 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -30,7 +30,7 @@ import {
   Typography,
   message,
 } from 'antd';
-import { MagnifyingGlass } from '@phosphor-icons/react';
+import { DownloadSimple, MagnifyingGlass } from '@phosphor-icons/react';
 import { ApiOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from 
'@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
@@ -39,6 +39,7 @@ import StatusBadge from '../../components/StatusBadge';
 import {
   createDataSource,
   deleteDataSource,
+  listAllDataSources,
   listDataSourcesPage,
   testDataSource,
   updateDataSource,
@@ -47,6 +48,7 @@ import type { DataSource } from '../../api/settings';
 import { STATUS_MAP } from '../../constants/theme';
 import { listInstances } from '../../services/instanceService';
 import type { Instance } from '../../api/instance';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const { Text } = Typography;
 
@@ -74,6 +76,20 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100];
 
 type DataSourceFormValues = Partial<DataSource>;
 
+interface DataSourceExportRow extends DataSource {
+  instanceNames: string;
+  statusLabel: string;
+}
+
+const DATA_SOURCE_EXPORT_COLUMNS: CsvColumn<DataSourceExportRow>[] = [
+  { header: 'Name', value: (source) => source.name },
+  { header: 'Type', value: (source) => source.type },
+  { header: 'URL', value: (source) => source.url },
+  { header: 'Applicable Instances', value: (source) => source.instanceNames },
+  { header: 'Authentication', value: (source) => source.auth },
+  { header: 'Status', value: (source) => source.statusLabel },
+];
+
 const secretFieldNames = ['username', 'password', 'bearerToken'] as const;
 const authNeedsSecret = (auth?: string) => auth === 'Basic Auth' || auth === 
'Bearer Token';
 
@@ -108,6 +124,7 @@ export const DataSourceTab = () => {
   const authValue = Form.useWatch('auth', dsForm);
   const [testingKeys, setTestingKeys] = useState<Set<string>>(() => new Set());
   const [submitting, setSubmitting] = useState(false);
+  const [exporting, setExporting] = useState(false);
   const requestSeqRef = useRef(0);
 
   useEffect(() => {
@@ -259,6 +276,45 @@ export const DataSourceTab = () => {
     }
   };
 
+  const formatInstanceIds = (instanceIds: string[] | undefined) => {
+    if (!instanceIds?.length) return t('settings.global');
+    return instanceIds
+      .map(
+        (instanceId) =>
+          instances.find(
+            (instance) => instance.name === instanceId || String(instance.id) 
=== instanceId,
+          )?.name ?? instanceId,
+      )
+      .join('、');
+  };
+
+  const formatStatus = (status: DataSource['status']) => {
+    if (!status || !STATUS_MAP[status]) return 
t('settings.dataSourceNotTested');
+    return t(STATUS_MAP[status].labelKey);
+  };
+
+  const handleExport = async () => {
+    setExporting(true);
+    try {
+      const exported = await listAllDataSources({
+        search: debouncedSearch,
+        type: typeFilter,
+      });
+      const rows = exported.map((source) => ({
+        ...source,
+        instanceNames: formatInstanceIds(source.instanceIds),
+        statusLabel: formatStatus(source.status),
+      }));
+      const filename = `rocketmq-data-sources-${new 
Date().toISOString().slice(0, 10)}.csv`;
+      downloadCsv(filename, buildCsv(DATA_SOURCE_EXPORT_COLUMNS, rows));
+      message.success(t('settings.dataSourceExported', { total: rows.length 
}));
+    } catch {
+      message.error(t('settings.dataSourceExportFailed'));
+    } finally {
+      setExporting(false);
+    }
+  };
+
   const columns: ColumnsType<DataSource> = [
     { title: t('common.name'), dataIndex: 'name', key: 'name' },
     {
@@ -272,17 +328,7 @@ export const DataSourceTab = () => {
       title: t('settings.instances'),
       dataIndex: 'instanceIds',
       key: 'instanceIds',
-      render: (instanceIds: string[] | undefined) => {
-        if (!instanceIds?.length) return t('settings.global');
-        return instanceIds
-          .map(
-            (instanceId) =>
-              instances.find(
-                (instance) => instance.name === instanceId || 
String(instance.id) === instanceId,
-              )?.name ?? instanceId,
-          )
-          .join('、');
-      },
+      render: formatInstanceIds,
     },
     { title: t('settings.authentication'), dataIndex: 'auth', key: 'auth' },
     {
@@ -362,9 +408,24 @@ export const DataSourceTab = () => {
             options={DATA_SOURCE_TYPE_OPTIONS}
           />
         </Flex>
-        <Button type="primary" icon={<PlusOutlined />} 
onClick={openCreateModal} disabled={loading}>
-          {t('settings.addDataSource')}
-        </Button>
+        <Space>
+          <Button
+            icon={<DownloadSimple size={14} />}
+            onClick={() => void handleExport()}
+            loading={exporting}
+            disabled={loading || total === 0}
+          >
+            {t('common.export')}
+          </Button>
+          <Button
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={openCreateModal}
+            disabled={loading}
+          >
+            {t('settings.addDataSource')}
+          </Button>
+        </Space>
       </Flex>
 
       <Table<DataSource>
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx 
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index b7d886e73..ca3f1a5a8 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -20,21 +20,37 @@ import { render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import type { DataSource, DataSourcePage } from '../../../api/settings';
-import { createDataSource, listDataSourcesPage, testDataSource } from 
'../../../api/settings';
+import {
+  createDataSource,
+  listAllDataSources,
+  listDataSourcesPage,
+  testDataSource,
+} from '../../../api/settings';
 import { LangProvider } from '../../../i18n/LangContext';
 import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import { downloadCsv } from '../../../utils/download';
 import { DataSourceTab } from '../DataSourceTab';
 
 vi.mock('../../../api/settings', () => ({
   createDataSource: vi.fn(),
   deleteDataSource: vi.fn(),
   getGeneralSettings: vi.fn(),
+  listAllDataSources: vi.fn(),
   listDataSourcesPage: vi.fn(),
   saveGeneralSettings: vi.fn(),
   testDataSource: vi.fn(),
   updateDataSource: vi.fn(),
 }));
 
+vi.mock('../../../utils/download', async () => {
+  const downloadModule =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...downloadModule,
+    downloadCsv: vi.fn(),
+  };
+});
+
 const sources: DataSource[] = [
   {
     key: 'prom-prod',
@@ -91,6 +107,7 @@ describe('DataSourceTab', () => {
     vi.clearAllMocks();
     localStorage.removeItem(LANGUAGE_STORAGE_KEY);
     vi.mocked(listDataSourcesPage).mockResolvedValue(sourcePage);
+    vi.mocked(listAllDataSources).mockResolvedValue(sources);
   });
 
   it('keeps data source creation disabled until the initial list is ready', 
async () => {
@@ -218,6 +235,55 @@ describe('DataSourceTab', () => {
     });
   });
 
+  it('exports all data sources that match the active filters without secrets', 
async () => {
+    vi.mocked(listAllDataSources).mockResolvedValue([
+      {
+        ...sources[1],
+        instanceIds: ['instance-1'],
+        username: 'hidden-user',
+        password: 'hidden-password',
+        bearerToken: 'hidden-token',
+      },
+    ]);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    render(
+      <LangProvider>
+        <App>
+          <DataSourceTab />
+        </App>
+      </LangProvider>,
+    );
+
+    await screen.findByText('Prometheus prod');
+    await user.type(screen.getByPlaceholderText('搜索数据源名称'), 'prom');
+    await selectFilterOption(user, '全部类型', 'Thanos');
+    await waitFor(() =>
+      expect(listDataSourcesPage).toHaveBeenLastCalledWith({
+        search: 'prom',
+        type: 'Thanos',
+        page: 1,
+        pageSize: 20,
+      }),
+    );
+
+    await user.click(screen.getByRole('button', { name: '导出' }));
+
+    await waitFor(() =>
+      expect(listAllDataSources).toHaveBeenCalledWith({
+        search: 'prom',
+        type: 'Thanos',
+      }),
+    );
+    const [filename, csv] = vi.mocked(downloadCsv).mock.calls[0];
+    expect(filename).toMatch(/^rocketmq-data-sources-\d{4}-\d{2}-\d{2}\.csv$/);
+    expect(csv).toContain('"Name","Type","URL","Applicable 
Instances","Authentication","Status"');
+    expect(csv).toContain('"Thanos DR","Thanos","http://thanos:10902";');
+    expect(csv).toContain('"Bearer Token"');
+    expect(csv).not.toContain('hidden-user');
+    expect(csv).not.toContain('hidden-password');
+    expect(csv).not.toContain('hidden-token');
+  });
+
   it('submits basic auth credentials when testing from the modal', async () => 
{
     vi.mocked(testDataSource).mockResolvedValue({ success: true, message: 'ok' 
});
 
@@ -427,3 +493,14 @@ async function selectAntdOption(
   });
   await user.click(within(popup).getByRole('option', { name: option }));
 }
+
+async function selectFilterOption(
+  user: ReturnType<typeof userEvent.setup>,
+  placeholder: string,
+  option: string,
+) {
+  await user.click(screen.getByText(placeholder));
+  await user.click(
+    await screen.findByText(option, { selector: 
'.ant-select-item-option-content' }),
+  );
+}
diff --git a/web/src/pages/settings/__tests__/SettingsPage.test.tsx 
b/web/src/pages/settings/__tests__/SettingsPage.test.tsx
new file mode 100644
index 000000000..8316491cd
--- /dev/null
+++ b/web/src/pages/settings/__tests__/SettingsPage.test.tsx
@@ -0,0 +1,94 @@
+/*
+ * 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 { beforeAll, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { LangProvider } from '../../../i18n/LangContext';
+import SettingsPage from '../index';
+
+vi.mock('../GeneralSettingsTab', () => ({
+  GeneralSettingsTab: () => <div>general settings tab</div>,
+}));
+vi.mock('../AiAssistantTab', () => ({
+  AiAssistantTab: () => <div>ai assistant tab</div>,
+}));
+vi.mock('../CloudCredentialTab', () => ({
+  CloudCredentialTab: () => <div>cloud credential tab</div>,
+}));
+vi.mock('../DataSourceTab', () => ({
+  DataSourceTab: () => <div>data source tab</div>,
+}));
+vi.mock('../AboutTab', () => ({
+  AboutTab: () => <div>about tab</div>,
+}));
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const LocationProbe = () => {
+  const { search } = useLocation();
+  return <output aria-label="location-search">{search}</output>;
+};
+
+const renderPage = (initialEntry: string) =>
+  render(
+    <LangProvider>
+      <MemoryRouter initialEntries={[initialEntry]}>
+        <Routes>
+          <Route
+            path="/settings"
+            element={
+              <>
+                <SettingsPage />
+                <LocationProbe />
+              </>
+            }
+          />
+        </Routes>
+      </MemoryRouter>
+    </LangProvider>,
+  );
+
+describe('SettingsPage', () => {
+  it('preserves unrelated query parameters when switching tabs', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage('/settings?source=nav&tab=general&focus=llm');
+
+    await user.click(screen.getByRole('tab', { name: 'AI 助手' }));
+
+    await waitFor(() => {
+      expect(screen.getByLabelText('location-search')).toHaveTextContent(
+        '?source=nav&tab=ai&focus=llm',
+      );
+    });
+  });
+});
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 22c138ea6..ee692ad62 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -43,7 +43,13 @@ const SettingsPage = () => {
 
       <Tabs
         activeKey={activeKey}
-        onChange={(key) => setSearchParams({ tab: key })}
+        onChange={(key) =>
+          setSearchParams((current) => {
+            const next = new URLSearchParams(current);
+            next.set('tab', key);
+            return next;
+          })
+        }
         items={[
           { key: 'general', label: t('settings.tabGeneral'), children: 
<GeneralSettingsTab /> },
           { key: 'ai', label: t('settings.tabAi'), children: <AiAssistantTab 
/> },
diff --git a/web/src/pages/studio/BrokerCluster.tsx 
b/web/src/pages/studio/BrokerCluster.tsx
index 0356f4ab0..6b7ccd4a1 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -17,7 +17,13 @@
 
 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 {
+  ArrowClockwise,
+  Cloud,
+  ChartBar,
+  DownloadSimple,
+  PlugsConnected,
+} from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
 import { listClusters } from '../../services/clusterService';
 import { isMockMode } from '../../services/dataMode';
@@ -25,9 +31,11 @@ import type { ClusterInfo } from '../../api/cluster';
 import { supportsApacheRuntime, type Instance } from '../../api/instance';
 import { listInstances } from '../../services/instanceService';
 import { useVisiblePolling } from '../../hooks/useVisiblePolling';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 // ─── Types ──────────────────────────────────────────────────────
 type NodeStatus = 'running' | 'readonly' | 'maintenance' | 'unknown';
+type ClusterTabKey = 'nameserver' | 'broker' | 'proxy';
 
 const REFRESH_INTERVAL_MS = 2000;
 
@@ -65,6 +73,36 @@ interface ProxyRecord {
   connections: number;
 }
 
+const BROKER_EXPORT_COLUMNS: CsvColumn<BrokerRecord>[] = [
+  { header: 'Cluster', value: (broker) => broker.k8sCluster },
+  { header: 'Broker Name', value: (broker) => broker.brokerName },
+  { header: 'Status', value: (broker) => broker.status },
+  { header: 'Version', value: (broker) => broker.version },
+  { header: 'Disk Usage', value: (broker) => broker.diskUsage },
+  { header: 'Address', value: (broker) => broker.address },
+  { header: 'TPS In', value: (broker) => broker.tpsIn },
+  { header: 'TPS Out', value: (broker) => broker.tpsOut },
+];
+
+const NAMESERVER_EXPORT_COLUMNS: CsvColumn<NameServerRecord>[] = [
+  { header: 'Cluster', value: (nameServer) => nameServer.k8sCluster },
+  { header: 'NameServer Name', value: (nameServer) => nameServer.name },
+  { header: 'Status', value: (nameServer) => nameServer.status },
+  { header: 'Version', value: (nameServer) => nameServer.version },
+  { header: 'Address', value: (nameServer) => nameServer.address },
+  { header: 'Connections', value: (nameServer) => nameServer.connections },
+];
+
+const PROXY_EXPORT_COLUMNS: CsvColumn<ProxyRecord>[] = [
+  { header: 'Cluster', value: (proxy) => proxy.k8sCluster },
+  { header: 'Proxy Name', value: (proxy) => proxy.name },
+  { header: 'Status', value: (proxy) => proxy.status },
+  { header: 'Version', value: (proxy) => proxy.version },
+  { header: 'HTTP Address', value: (proxy) => proxy.address },
+  { header: 'gRPC Address', value: (proxy) => proxy.grpcPort },
+  { header: 'Connections', value: (proxy) => proxy.connections },
+];
+
 // ─── Helpers ────────────────────────────────────────────────────
 const normalizeStatus = (status: string): NodeStatus => {
   const value = (status || '').toLowerCase();
@@ -145,7 +183,7 @@ function mapClusters(clusters: ClusterInfo[]): {
 // ─── Component ──────────────────────────────────────────────────
 const BrokerClusterPage = () => {
   const [autoRefresh, setAutoRefresh] = useState(false);
-  const [activeTab, setActiveTab] = useState('broker');
+  const [activeTab, setActiveTab] = useState<ClusterTabKey>('broker');
   const [loading, setLoading] = useState(false);
   const [brokerData, setBrokerData] = useState<BrokerRecord[]>([]);
   const [nameServerData, setNameServerData] = useState<NameServerRecord[]>([]);
@@ -259,6 +297,32 @@ const BrokerClusterPage = () => {
     );
   };
 
+  function handleExport() {
+    const today = new Date().toISOString().slice(0, 10);
+    if (activeTab === 'nameserver') {
+      downloadCsv(
+        `rocketmq-nameserver-topology-${today}.csv`,
+        buildCsv(NAMESERVER_EXPORT_COLUMNS, nameServerData),
+      );
+      return;
+    }
+    if (activeTab === 'proxy') {
+      downloadCsv(
+        `rocketmq-proxy-topology-${today}.csv`,
+        buildCsv(PROXY_EXPORT_COLUMNS, proxyData),
+      );
+      return;
+    }
+    downloadCsv(
+      `rocketmq-broker-topology-${today}.csv`,
+      buildCsv(BROKER_EXPORT_COLUMNS, brokerData),
+    );
+  }
+  const exportDisabled =
+    (activeTab === 'nameserver' && nameServerData.length === 0) ||
+    (activeTab === 'proxy' && proxyData.length === 0) ||
+    (activeTab === 'broker' && brokerData.length === 0);
+
   const brokerColumns = [
     {
       title: t('brokerCluster.k8sCluster'),
@@ -461,6 +525,14 @@ const BrokerClusterPage = () => {
             style={{ minWidth: 180 }}
             options={instances.map((instance) => ({ value: instance.name, 
label: instance.name }))}
           />
+          <Button
+            icon={<DownloadSimple size={14} />}
+            size="small"
+            disabled={exportDisabled}
+            onClick={handleExport}
+          >
+            {t('common.export')}
+          </Button>
           <Switch
             checked={autoRefresh}
             onChange={setAutoRefresh}
@@ -481,7 +553,7 @@ const BrokerClusterPage = () => {
         >
           <Tabs
             activeKey={activeTab}
-            onChange={setActiveTab}
+            onChange={(key) => setActiveTab(key as ClusterTabKey)}
             items={[
               {
                 key: 'nameserver',
diff --git a/web/src/pages/studio/LiteTopic.tsx 
b/web/src/pages/studio/LiteTopic.tsx
index 4365045b7..652d3b48f 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -45,6 +45,7 @@ import {
   PencilSimple,
   Gauge,
   Info,
+  DownloadSimple,
 } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
@@ -58,6 +59,7 @@ import {
   type LiteTopicItem,
   type LiteTopicSession,
 } from '../../api/liteTopic';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 const formatDuration = (ms: number | undefined | null): string => {
   if (ms == null) return '-';
@@ -80,6 +82,24 @@ const getProgressStatus = (percent: number): 'exception' | 
'active' | 'normal' =
 
 const knownTTLStatuses = new Set(['ACTIVE', 'EXPIRING_SOON', 'EXPIRED']);
 
+interface LiteTopicExportRow extends LiteTopicItem {
+  ttlStatusLabel: string;
+  sessionCount: number;
+}
+
+const LITE_TOPIC_EXPORT_COLUMNS: CsvColumn<LiteTopicExportRow>[] = [
+  { header: 'Namespace', value: (item) => item.namespace },
+  { header: 'Topic Pattern', value: (item) => item.topicPattern },
+  { header: 'Topic Count', value: (item) => item.topicCount },
+  { header: 'Consumer Count', value: (item) => item.consumerCount },
+  { header: 'Total Backlog', value: (item) => item.totalBacklog },
+  { header: 'Average TTL', value: (item) => formatDuration(item.averageTTL) },
+  { header: 'TTL Status', value: (item) => item.ttlStatusLabel },
+  { header: 'Last Active Time', value: (item) => 
formatTime(item.lastActiveTime) },
+  { header: 'Session Count', value: (item) => item.sessionCount },
+  { header: 'Session IDs', value: (item) => item.sessionIds?.join(';') },
+];
+
 const collectNamespaces = (items: LiteTopicItem[]): string[] => {
   const namespaces = new Map<string, string>();
 
@@ -310,6 +330,15 @@ const LiteTopicPage: React.FC = () => {
     return <Tag color={cfg.color}>{cfg.label}</Tag>;
   };
 
+  const getTTLStatusLabel = (status: string | undefined) => {
+    const map: Record<string, string> = {
+      ACTIVE: t('liteTopic.active'),
+      EXPIRING_SOON: t('liteTopic.expiringSoon'),
+      EXPIRED: t('liteTopic.expired'),
+    };
+    return map[status || ''] || t('liteTopic.unknown');
+  };
+
   const filteredTopicList = topicList.filter((item) => {
     if (!ttlStatusFilter) return true;
     if (ttlStatusFilter === 'UNKNOWN') {
@@ -321,6 +350,21 @@ const LiteTopicPage: React.FC = () => {
   const lastPage = Math.max(1, Math.ceil(filteredTopicList.length / pageSize));
   const clampedCurrentPage = Math.min(currentPage, lastPage);
 
+  const handleExport = () => {
+    try {
+      const rows = filteredTopicList.map((item) => ({
+        ...item,
+        ttlStatusLabel: getTTLStatusLabel(item.ttlStatus),
+        sessionCount: item.sessionIds?.length ?? 0,
+      }));
+      const filename = `rocketmq-lite-topics-${new 
Date().toISOString().slice(0, 10)}.csv`;
+      downloadCsv(filename, buildCsv(LITE_TOPIC_EXPORT_COLUMNS, rows));
+      message.success(t('liteTopic.exportSuccess', { total: rows.length }));
+    } catch {
+      message.error(t('liteTopic.exportFailed'));
+    }
+  };
+
   // ─── Columns ─────────────────────────────────────────────────
 
   const columns: ColumnsType<LiteTopicItem> = [
@@ -724,9 +768,19 @@ const LiteTopicPage: React.FC = () => {
       <PageHeader
         title={t('liteTopic.title')}
         extra={
-          <Button icon={<ArrowClockwise size={14} />} size="small" 
onClick={handleRefresh}>
-            {t('common.refresh')}
-          </Button>
+          <Space>
+            <Button
+              icon={<DownloadSimple size={14} />}
+              size="small"
+              disabled={loading || filteredTopicList.length === 0}
+              onClick={handleExport}
+            >
+              {t('common.export')}
+            </Button>
+            <Button icon={<ArrowClockwise size={14} />} size="small" 
onClick={handleRefresh}>
+              {t('common.refresh')}
+            </Button>
+          </Space>
         }
       />
 
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index dee270586..db8fb05ad 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   Card,
   Table,
@@ -45,6 +45,7 @@ import {
   Warning,
   Plus,
   Trash,
+  MagnifyingGlass,
 } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
@@ -78,6 +79,7 @@ const ProxyPage: React.FC = () => {
   const [selectedNode, setSelectedNode] = useState<ProxyNode | null>(null);
   const [configModalOpen, setConfigModalOpen] = useState(false);
   const [newProxyAddress, setNewProxyAddress] = useState('');
+  const [nodeFilter, setNodeFilter] = useState('');
   const [addressMutationLoading, setAddressMutationLoading] = useState(false);
   const [removingProxyAddress, setRemovingProxyAddress] = useState<string | 
null>(null);
   const [clusterId, setClusterId] = useState<string>(
@@ -295,6 +297,38 @@ const ProxyPage: React.FC = () => {
     );
   };
 
+  const proxyStatusLabel = useCallback(
+    (status: string) => {
+      const map: Record<string, string> = {
+        healthy: t('proxy.healthy'),
+        unhealthy: t('proxy.unhealthy'),
+        warning: t('proxy.warning'),
+        error: t('proxy.statusError'),
+        offline: t('proxy.statusOffline'),
+        unknown: t('common.na'),
+      };
+      return map[status] || map.unknown;
+    },
+    [t],
+  );
+
+  const filteredProxyNodes = useMemo(() => {
+    const keyword = nodeFilter.trim().toLowerCase();
+    if (!keyword) return proxyNodes;
+    return proxyNodes.filter((node) =>
+      [
+        node.address,
+        node.status,
+        proxyStatusLabel(node.status),
+        node.version,
+        node.uptime,
+        node.isSelected ? t('proxy.current') : '',
+      ]
+        .filter((value): value is string => Boolean(value))
+        .some((value) => value.toLowerCase().includes(keyword)),
+    );
+  }, [nodeFilter, proxyNodes, proxyStatusLabel, t]);
+
   const renderUnavailable = () => <Text 
type="secondary">{t('common.na')}</Text>;
 
   const renderNumberMetric = (value: number | null) =>
@@ -516,8 +550,24 @@ const ProxyPage: React.FC = () => {
           title={t('proxy.nodes')}
           variant="borderless"
           style={{ borderRadius: 8, marginBottom: 24 }}
+          extra={
+            <Input
+              allowClear
+              aria-label={t('proxy.nodeFilter')}
+              placeholder={t('proxy.nodeFilterPlaceholder')}
+              prefix={<MagnifyingGlass size={14} />}
+              value={nodeFilter}
+              onChange={(event) => setNodeFilter(event.target.value)}
+              style={{ width: 260 }}
+            />
+          }
         >
-          <Table columns={columns} dataSource={proxyNodes} pagination={false} 
size="middle" />
+          <Table
+            columns={columns}
+            dataSource={filteredProxyNodes}
+            pagination={false}
+            size="middle"
+          />
         </Card>
       </Spin>
 
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 79113a8ce..de0de99d3 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -30,19 +30,21 @@ import {
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { Key, Plus } from '@phosphor-icons/react';
+import { DownloadSimple, Key, Plus } from '@phosphor-icons/react';
 import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
 import { changePassword } from '../../api/auth';
 import {
   createStudioUser,
+  listAllStudioUsers as exportStudioUsers,
   listStudioUsers,
   resetStudioUserPassword,
   setStudioUserEnabled,
   type StudioUser,
 } from '../../api/studioUsers';
 import useAuthStore from '../../stores/authStore';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
 
 interface CreateFormValues {
   username: string;
@@ -61,6 +63,15 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100];
 type RoleFilter = 'admin' | 'reader';
 type StatusFilter = 'enabled' | 'disabled';
 
+const STUDIO_USER_EXPORT_COLUMNS: CsvColumn<StudioUser>[] = [
+  { header: 'User ID', value: (user) => user.id },
+  { header: 'Username', value: (user) => user.username },
+  { header: 'Role', value: (user) => (user.admin ? 'Admin' : 'User') },
+  { header: 'Status', value: (user) => (user.enabled ? 'Enabled' : 'Disabled') 
},
+  { header: 'Password Changed At', value: (user) => 
dateTime(user.passwordChangedAt) },
+  { header: 'Created At', value: (user) => dateTime(user.gmtCreate) },
+  { header: 'Modified At', value: (user) => dateTime(user.gmtModified) },
+];
 const UserManagementPage = () => {
   const navigate = useNavigate();
   const admin = useAuthStore((state) => state.admin);
@@ -77,6 +88,7 @@ const UserManagementPage = () => {
   const [loading, setLoading] = useState(false);
   const [createOpen, setCreateOpen] = useState(false);
   const [passwordTarget, setPasswordTarget] = useState<StudioUser | 
null>(null);
+  const [userExporting, setUserExporting] = useState(false);
   const [createForm] = Form.useForm<CreateFormValues>();
   const [passwordForm] = Form.useForm<PasswordFormValues>();
   const requestSeqRef = useRef(0);
@@ -179,7 +191,27 @@ const UserManagementPage = () => {
       message.error('修改密码失败');
     }
   };
-
+  const openCreateUserModal = () => setCreateOpen(true);
+  const handleExportUsers = useCallback(async () => {
+    if (!admin) return;
+    setUserExporting(true);
+    try {
+      const exportedUsers = await exportStudioUsers({
+        search: search.trim() || undefined,
+        admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
+        enabled: statusFilter === undefined ? undefined : statusFilter === 
'enabled',
+      });
+      const today = new Date().toISOString().slice(0, 10);
+      downloadCsv(
+        `rocketmq-studio-users-${today}.csv`,
+        buildCsv(STUDIO_USER_EXPORT_COLUMNS, exportedUsers),
+      );
+      message.success(`已导出 ${exportedUsers.length} 个用户`);
+    } catch {
+      message.error('导出用户列表失败,请稍后重试');
+    }
+    setUserExporting(false);
+  }, [admin, roleFilter, search, statusFilter]);
   const columns: ColumnsType<StudioUser> = [
     { title: '用户名', dataIndex: 'username' },
     { title: '用户 ID', dataIndex: 'id', width: 100 },
@@ -221,9 +253,19 @@ const UserManagementPage = () => {
         subtitle="Studio 本地账号、会话与密码管理"
         extra={
           admin ? (
-            <Button type="primary" icon={<Plus size={16} />} onClick={() => 
setCreateOpen(true)}>
-              新建用户
-            </Button>
+            <Space>
+              <Button
+                icon={<DownloadSimple size={16} />}
+                disabled={loading || total === 0}
+                loading={userExporting}
+                onClick={() => void handleExportUsers()}
+              >
+                导出
+              </Button>
+              <Button type="primary" icon={<Plus size={16} />} 
onClick={openCreateUserModal}>
+                新建用户
+              </Button>
+            </Space>
           ) : undefined
         }
       />
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx 
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index d9020ffb2..4125fd8a8 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -23,6 +23,7 @@ import { LangProvider } from '../../../i18n/LangContext';
 import { listClusters } from '../../../services/clusterService';
 import { listInstances } from '../../../services/instanceService';
 import type { ClusterInfo } from '../../../api/cluster';
+import { downloadCsv } from '../../../utils/download';
 import BrokerCluster from '../BrokerCluster';
 
 vi.mock('../../../services/clusterService', () => ({
@@ -33,6 +34,15 @@ vi.mock('../../../services/instanceService', () => ({
   listInstances: vi.fn(),
 }));
 
+vi.mock('../../../utils/download', async () => {
+  const actual =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...actual,
+    downloadCsv: vi.fn(),
+  };
+});
+
 // Mock matchMedia for antd responsive components
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
@@ -173,6 +183,29 @@ describe('BrokerCluster Page', () => {
     expect(listClusters).toHaveBeenCalledWith('instance-1');
   });
 
+  it('exports only the currently selected topology tab', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<BrokerCluster />);
+    await screen.findByText('broker-api-a');
+
+    await user.click(screen.getByRole('button', { name: '导出' }));
+
+    expect(downloadCsv).toHaveBeenCalledTimes(1);
+    const [brokerFilename, brokerCsv] = vi.mocked(downloadCsv).mock.calls[0];
+    
expect(brokerFilename).toMatch(/^rocketmq-broker-topology-\d{4}-\d{2}-\d{2}\.csv$/);
+    expect(brokerCsv).toContain('"broker-api-a"');
+    expect(brokerCsv).toContain('"broker-api-b"');
+    expect(brokerCsv).not.toContain('"nameserver-api-a"');
+
+    await user.click(screen.getByText('NameServer 管理'));
+    await user.click(screen.getByRole('button', { name: '导出' }));
+
+    expect(downloadCsv).toHaveBeenCalledTimes(2);
+    const [nameServerFilename, nameServerCsv] = 
vi.mocked(downloadCsv).mock.calls[1];
+    
expect(nameServerFilename).toMatch(/^rocketmq-nameserver-topology-\d{4}-\d{2}-\d{2}\.csv$/);
+    expect(nameServerCsv).toContain('"nameserver-api-a"');
+    expect(nameServerCsv).not.toContain('"broker-api-a"');
+  }, 10_000);
   it('does not fall back to an unscoped cluster query when instance discovery 
fails', async () => {
     vi.mocked(listInstances).mockRejectedValueOnce(new Error('instance 
discovery failed'));
     renderWithProviders(<BrokerCluster />);
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx 
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index b3c0aa665..eac0b4abe 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
+import { downloadCsv } from '../../../utils/download';
 import LiteTopic from '../LiteTopic';
 
 const apiMocks = vi.hoisted(() => ({
@@ -33,6 +34,15 @@ const apiMocks = vi.hoisted(() => ({
 
 vi.mock('../../../api/liteTopic', () => apiMocks);
 
+vi.mock('../../../utils/download', async () => {
+  const downloadModule =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...downloadModule,
+    downloadCsv: vi.fn(),
+  };
+});
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -260,6 +270,44 @@ describe('LiteTopic Page', () => {
     
expect(apiMocks.queryLiteTopicList).toHaveBeenCalledTimes(initialListRequestCount);
   });
 
+  it('exports the current LiteTopic filter result', async () => {
+    apiMocks.queryLiteTopicList.mockResolvedValue([
+      {
+        namespace: 'default',
+        topicPattern: '=active-*',
+        topicCount: 3,
+        consumerCount: 2,
+        totalBacklog: 12,
+        averageTTL: 60000,
+        ttlStatus: 'ACTIVE',
+        lastActiveTime: 1893456000000,
+        sessionIds: ['session-1', 'session-2'],
+      },
+      {
+        namespace: 'default',
+        topicPattern: 'expired-*',
+        ttlStatus: 'EXPIRED',
+      },
+    ]);
+    const user = userEvent.setup();
+    renderPage();
+
+    expect(await screen.findByText('=active-*')).toBeInTheDocument();
+    await user.click(screen.getByRole('combobox', { name: '状态' }));
+    await user.click(
+      await screen.findByText('活跃', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.click(screen.getByRole('button', { name: '导出' }));
+
+    expect(downloadCsv).toHaveBeenCalledTimes(1);
+    const [filename, csv] = vi.mocked(downloadCsv).mock.calls[0];
+    expect(filename).toMatch(/^rocketmq-lite-topics-\d{4}-\d{2}-\d{2}\.csv$/);
+    expect(csv).toContain('"Namespace","Topic Pattern","Topic Count"');
+    
expect(csv).toContain('"default","\'=active-*","3","2","12","1.0min","活跃"');
+    expect(csv).toContain('"session-1;session-2"');
+    expect(csv).not.toContain('expired-*');
+  });
+
   it('keeps an early filtered display while a delayed bootstrap supplies 
namespace options', async () => {
     const capability = createDeferred<{ supported: boolean }>();
     const bootstrapList = createDeferred<LiteTopicItem[]>();
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 9cdfbafdd..2fb9551ca 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import {
   addProxyAddress,
+  getProxyTopology,
   queryProxyHomePage,
   reloadProxyConfig,
   removeProxyAddress,
@@ -30,6 +31,7 @@ import ProxyPage from '../Proxy';
 
 vi.mock('../../../api/proxy', () => ({
   addProxyAddress: vi.fn(),
+  getProxyTopology: vi.fn(),
   queryProxyHomePage: vi.fn(),
   reloadProxyConfig: vi.fn(),
   removeProxyAddress: vi.fn(),
@@ -78,6 +80,7 @@ describe('ProxyPage', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(queryProxyHomePage).mockResolvedValue(proxyHome);
+    vi.mocked(getProxyTopology).mockResolvedValue([]);
     vi.mocked(addProxyAddress).mockResolvedValue(proxyHome);
     vi.mocked(reloadProxyConfig).mockResolvedValue({
       success: true,
@@ -211,6 +214,48 @@ describe('ProxyPage', () => {
     expect(await screen.findByText('Proxy 地址已删除')).toBeInTheDocument();
   });
 
+  it('filters Proxy nodes by address and status label', async () => {
+    const user = userEvent.setup();
+    vi.mocked(queryProxyHomePage).mockResolvedValueOnce({
+      proxyAddrList: ['127.0.0.1:8081', '10.0.0.10:8081'],
+      currentProxyAddr: '127.0.0.1:8081',
+    });
+    vi.mocked(getProxyTopology).mockResolvedValueOnce([
+      {
+        proxyAddr: '127.0.0.1:8081',
+        status: 'UP',
+        grpcPort: 8081,
+        remotingPort: null,
+        grpcReachable: true,
+        remotingReachable: false,
+        latencyMs: 3,
+      },
+      {
+        proxyAddr: '10.0.0.10:8081',
+        status: 'DOWN',
+        grpcPort: 8081,
+        remotingPort: null,
+        grpcReachable: false,
+        remotingReachable: false,
+        latencyMs: 0,
+      },
+    ]);
+
+    renderPage();
+    expect(await screen.findByText('127.0.0.1:8081')).toBeInTheDocument();
+    expect(screen.getByText('10.0.0.10:8081')).toBeInTheDocument();
+
+    const filter = screen.getByRole('textbox', { name: '筛选 Proxy 节点' });
+    await user.type(filter, '10.0.0.10');
+    expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
+    expect(screen.getByText('10.0.0.10:8081')).toBeInTheDocument();
+
+    await user.clear(filter);
+    await user.type(filter, '不健康');
+    expect(screen.queryByText('127.0.0.1:8081')).not.toBeInTheDocument();
+    expect(screen.getByText('10.0.0.10:8081')).toBeInTheDocument();
+  });
+
   it('keeps the latest Proxy list when an older refresh resolves last', async 
() => {
     const older = createDeferred<typeof proxyHome>();
     const latest = createDeferred<typeof proxyHome>();
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 69d90488b..07812b13d 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -18,26 +18,38 @@
 import { App } from 'antd';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
+import userEvent, { type UserEvent } from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
-import { listStudioUsers } from '../../../api/studioUsers';
+import {
+  listAllStudioUsers as downloadStudioUsers,
+  listStudioUsers,
+} from '../../../api/studioUsers';
+import { downloadCsv } from '../../../utils/download';
 import UserManagementPage from '../UserManagement';
 
+type MockAuthState = { admin: boolean; userId: number; logout: () => void };
 vi.mock('../../../api/studioUsers', () => ({
   createStudioUser: vi.fn(),
+  listAllStudioUsers: vi.fn(),
   listStudioUsers: vi.fn(),
   resetStudioUserPassword: vi.fn(),
   setStudioUserEnabled: vi.fn(),
 }));
 
 vi.mock('../../../stores/authStore', () => ({
-  default: (
-    selector: (state: { admin: boolean; userId: number; logout: () => void }) 
=> unknown,
-  ) =>
+  default: (selector: (state: MockAuthState) => unknown) =>
     selector({ admin: true, userId: 1, logout: vi.fn() }),
 }));
 
-const page = {
+vi.mock('../../../utils/download', async () => {
+  const downloadModule =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...downloadModule,
+    downloadCsv: vi.fn(),
+  };
+});
+const studioUserPage = {
   items: [
     {
       id: 7,
@@ -63,6 +75,18 @@ const renderPage = () =>
     </MemoryRouter>,
   );
 
+const selectOption = async (user: UserEvent, comboboxName: string, optionText: 
string) => {
+  await user.click(screen.getByRole('combobox', { name: comboboxName }));
+  const option = await screen.findByText(optionText, {
+    selector: '.ant-select-item-option-content',
+  });
+  await user.click(option);
+};
+const applyAdminDisabledFilter = async (user: UserEvent, keyword = 'ops') => {
+  await user.type(screen.getByPlaceholderText('搜索用户名'), keyword);
+  await selectOption(user, '按权限筛选', '管理员');
+  await selectOption(user, '按状态筛选', '已禁用');
+};
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -82,7 +106,8 @@ beforeAll(() => {
 describe('UserManagementPage', () => {
   beforeEach(() => {
     vi.clearAllMocks();
-    vi.mocked(listStudioUsers).mockResolvedValue(page);
+    vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+    vi.mocked(downloadStudioUsers).mockResolvedValue(studioUserPage.items);
   });
 
   it('loads a bounded first page and renders the server total', async () => {
@@ -104,11 +129,7 @@ describe('UserManagementPage', () => {
     renderPage();
     await screen.findByText('operator');
 
-    await user.type(screen.getByPlaceholderText('搜索用户名'), 'ops');
-    await user.click(screen.getByRole('combobox', { name: '按权限筛选' }));
-    await user.click(await screen.findByText('管理员', { selector: 
'.ant-select-item-option-content' }));
-    await user.click(screen.getByRole('combobox', { name: '按状态筛选' }));
-    await user.click(await screen.findByText('已禁用', { selector: 
'.ant-select-item-option-content' }));
+    await applyAdminDisabledFilter(user);
 
     await waitFor(() =>
       expect(listStudioUsers).toHaveBeenLastCalledWith({
@@ -120,4 +141,20 @@ describe('UserManagementPage', () => {
       }),
     );
   });
+
+  it('exports all users that match the active filters', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+    await screen.findByText('operator');
+    await applyAdminDisabledFilter(user, 'ops');
+    await user.click(screen.getByRole('button', { name: '导出' }));
+    const expectedExportQuery = { search: 'ops', admin: true, enabled: false };
+    await waitFor(() => 
expect(downloadStudioUsers).toHaveBeenCalledWith(expectedExportQuery));
+    expect(downloadCsv).toHaveBeenCalledTimes(1);
+    const [exportFilename, exportedCsv] = vi.mocked(downloadCsv).mock.calls[0];
+    
expect(exportFilename).toMatch(/^rocketmq-studio-users-\d{4}-\d{2}-\d{2}\.csv$/);
+    expect(exportedCsv).toContain('"operator"');
+    expect(exportedCsv).toContain('"User"');
+    expect(exportedCsv).toContain('"Enabled"');
+  });
 });

Reply via email to