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"');
+ });
});