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 e74d368b9 [ISSUE #3071] feat(topic): add cross-instance configuration
comparison (#3072)
e74d368b9 is described below
commit e74d368b9cc2b6e507d3512c7d22ff839879695a
Author: shown <[email protected]>
AuthorDate: Fri Sep 4 14:26:38 2026 +0800
[ISSUE #3071] feat(topic): add cross-instance configuration comparison
(#3072)
---
web/src/components/TopicConfigComparisonDrawer.tsx | 319 +++++++++++++++++++++
.../__tests__/TopicConfigComparisonDrawer.test.tsx | 228 +++++++++++++++
web/src/i18n/translations.ts | 47 +++
web/src/pages/instance/topic.tsx | 20 ++
web/src/utils/topicConfigComparison.test.ts | 176 ++++++++++++
web/src/utils/topicConfigComparison.ts | 142 +++++++++
6 files changed, 932 insertions(+)
diff --git a/web/src/components/TopicConfigComparisonDrawer.tsx
b/web/src/components/TopicConfigComparisonDrawer.tsx
new file mode 100644
index 000000000..72e1c5de3
--- /dev/null
+++ b/web/src/components/TopicConfigComparisonDrawer.tsx
@@ -0,0 +1,319 @@
+/*
+ * 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 { useMemo, useRef, useState } from 'react';
+import {
+ Button,
+ Card,
+ Drawer,
+ Empty,
+ Flex,
+ Input,
+ Select,
+ Space,
+ Statistic,
+ Table,
+ Tag,
+ Typography,
+ message,
+} from 'antd';
+import type { TableColumnsType } from 'antd';
+import { DownloadOutlined, SwapOutlined } from '@ant-design/icons';
+import type { Instance } from '../api/instance';
+import { listAllTopics } from '../services/topicService';
+import { useLang } from '../i18n/LangContext';
+import { buildCsv, downloadCsv, type CsvColumn } from '../utils/download';
+import {
+ compareTopicInventories,
+ filterTopicComparisonRows,
+ formatTopicDifferences,
+ type TopicComparisonResult,
+ type TopicComparisonRow,
+ type TopicComparisonStatus,
+ type TopicConfigField,
+} from '../utils/topicConfigComparison';
+
+interface TopicConfigComparisonDrawerProps {
+ open: boolean;
+ instances: Instance[];
+ currentInstanceId?: string;
+ onClose: () => void;
+}
+
+const STATUS_COLORS: Record<TopicComparisonStatus, string> = {
+ MATCH: 'success',
+ DRIFT: 'warning',
+ ONLY_SOURCE: 'blue',
+ ONLY_TARGET: 'purple',
+};
+
+const STATUS_LABELS: Record<TopicComparisonStatus, string> = {
+ MATCH: 'topicCompare.statusMatch',
+ DRIFT: 'topicCompare.statusDrift',
+ ONLY_SOURCE: 'topicCompare.statusOnlySource',
+ ONLY_TARGET: 'topicCompare.statusOnlyTarget',
+};
+
+const FIELD_LABELS: Record<TopicConfigField, string> = {
+ type: 'topicCompare.fieldType',
+ namespace: 'topicCompare.fieldNamespace',
+ writeQueues: 'topicCompare.fieldWriteQueues',
+ readQueues: 'topicCompare.fieldReadQueues',
+ perm: 'topicCompare.fieldPermission',
+};
+
+const CSV_COLUMNS: CsvColumn<TopicComparisonRow>[] = [
+ { header: 'Topic', value: (row) => row.topicName },
+ { header: 'Status', value: (row) => row.status },
+ { header: 'Differences', value: (row) =>
formatTopicDifferences(row.differences) },
+ { header: 'Source Type', value: (row) => row.source?.type },
+ { header: 'Target Type', value: (row) => row.target?.type },
+ { header: 'Source Write Queues', value: (row) => row.source?.writeQueues },
+ { header: 'Target Write Queues', value: (row) => row.target?.writeQueues },
+ { header: 'Source Read Queues', value: (row) => row.source?.readQueues },
+ { header: 'Target Read Queues', value: (row) => row.target?.readQueues },
+ { header: 'Source Permission', value: (row) => row.source?.perm },
+ { header: 'Target Permission', value: (row) => row.target?.perm },
+];
+
+const TopicConfigComparisonDrawer = ({
+ open,
+ instances,
+ currentInstanceId,
+ onClose,
+}: TopicConfigComparisonDrawerProps) => {
+ const { t } = useLang();
+ const initialSource =
+ currentInstanceId && instances.some((instance) => instance.name ===
currentInstanceId)
+ ? currentInstanceId
+ : instances[0]?.name;
+ const [sourceInstanceId, setSourceInstanceId] = useState<string |
undefined>(initialSource);
+ const [targetInstanceId, setTargetInstanceId] = useState<string | undefined>(
+ instances.find((instance) => instance.name !== initialSource)?.name,
+ );
+ const [result, setResult] = useState<TopicComparisonResult | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [statusFilter, setStatusFilter] = useState<TopicComparisonStatus |
'ALL'>('ALL');
+ const [search, setSearch] = useState('');
+ const requestIdRef = useRef(0);
+
+ const options = instances.map((instance) => ({
+ value: instance.name,
+ label: instance.name,
+ }));
+ const visibleRows = useMemo(
+ () => filterTopicComparisonRows(result?.rows ?? [], statusFilter, search),
+ [result, search, statusFilter],
+ );
+
+ const runComparison = async () => {
+ if (!sourceInstanceId || !targetInstanceId || sourceInstanceId ===
targetInstanceId) {
+ message.warning(t('topicCompare.selectDifferentInstances'));
+ return;
+ }
+ const requestId = ++requestIdRef.current;
+ setLoading(true);
+ try {
+ const [sourceTopics, targetTopics] = await Promise.all([
+ listAllTopics({ instanceId: sourceInstanceId }),
+ listAllTopics({ instanceId: targetInstanceId }),
+ ]);
+ if (requestId === requestIdRef.current) {
+ setResult(compareTopicInventories(sourceTopics, targetTopics));
+ setStatusFilter('ALL');
+ setSearch('');
+ }
+ } catch {
+ if (requestId === requestIdRef.current)
message.error(t('topicCompare.loadFailed'));
+ } finally {
+ if (requestId === requestIdRef.current) setLoading(false);
+ }
+ };
+
+ const swapInstances = () => {
+ setSourceInstanceId(targetInstanceId);
+ setTargetInstanceId(sourceInstanceId);
+ setResult(null);
+ };
+
+ const exportComparison = () => {
+ if (!result || !sourceInstanceId || !targetInstanceId) return;
+ downloadCsv(
+ `rocketmq-topic-config-${sourceInstanceId}-vs-${targetInstanceId}.csv`,
+ buildCsv(CSV_COLUMNS, visibleRows),
+ );
+ message.success(t('topicCompare.exported', { count: visibleRows.length }));
+ };
+
+ const columns: TableColumnsType<TopicComparisonRow> = [
+ {
+ title: t('topicCompare.topic'),
+ dataIndex: 'topicName',
+ key: 'topicName',
+ sorter: (left, right) => left.topicName.localeCompare(right.topicName),
+ },
+ {
+ title: t('topicCompare.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 150,
+ render: (status: TopicComparisonStatus) => (
+ <Tag color={STATUS_COLORS[status]}>{t(STATUS_LABELS[status])}</Tag>
+ ),
+ },
+ {
+ title: t('topicCompare.differenceCount'),
+ key: 'differenceCount',
+ width: 150,
+ render: (_, row) => row.differences.length,
+ },
+ ];
+
+ return (
+ <Drawer
+ title={t('topicCompare.title')}
+ open={open}
+ onClose={onClose}
+ width={960}
+ destroyOnHidden
+ >
+ <Flex vertical gap={16}>
+ <Typography.Paragraph type="secondary" style={{ margin: 0 }}>
+ {t('topicCompare.description')}
+ </Typography.Paragraph>
+ <Flex gap={8} align="end" wrap="wrap">
+ <label style={{ flex: 1, minWidth: 220 }}>
+
<Typography.Text>{t('topicCompare.sourceInstance')}</Typography.Text>
+ <Select
+ aria-label={t('topicCompare.sourceInstance')}
+ value={sourceInstanceId}
+ options={options}
+ style={{ width: '100%', marginTop: 4 }}
+ onChange={(value) => {
+ setSourceInstanceId(value);
+ setResult(null);
+ }}
+ />
+ </label>
+ <Button
+ aria-label={t('topicCompare.swap')}
+ icon={<SwapOutlined />}
+ onClick={swapInstances}
+ />
+ <label style={{ flex: 1, minWidth: 220 }}>
+
<Typography.Text>{t('topicCompare.targetInstance')}</Typography.Text>
+ <Select
+ aria-label={t('topicCompare.targetInstance')}
+ value={targetInstanceId}
+ options={options}
+ style={{ width: '100%', marginTop: 4 }}
+ onChange={(value) => {
+ setTargetInstanceId(value);
+ setResult(null);
+ }}
+ />
+ </label>
+ <Button
+ type="primary"
+ loading={loading}
+ disabled={instances.length < 2}
+ onClick={() => void runComparison()}
+ >
+ {t('topicCompare.compare')}
+ </Button>
+ </Flex>
+
+ {instances.length < 2 && <Empty
description={t('topicCompare.needTwoInstances')} />}
+
+ {result && (
+ <>
+ <Flex gap={12} wrap="wrap">
+ <Card size="small" style={{ flex: 1, minWidth: 140 }}>
+ <Statistic title={t('topicCompare.matches')}
value={result.summary.matches} />
+ </Card>
+ <Card size="small" style={{ flex: 1, minWidth: 140 }}>
+ <Statistic title={t('topicCompare.drifted')}
value={result.summary.drifted} />
+ </Card>
+ <Card size="small" style={{ flex: 1, minWidth: 140 }}>
+ <Statistic title={t('topicCompare.onlySource')}
value={result.summary.onlySource} />
+ </Card>
+ <Card size="small" style={{ flex: 1, minWidth: 140 }}>
+ <Statistic title={t('topicCompare.onlyTarget')}
value={result.summary.onlyTarget} />
+ </Card>
+ </Flex>
+ <Flex justify="space-between" gap={8} wrap="wrap">
+ <Space wrap>
+ <Input.Search
+ allowClear
+ aria-label={t('topicCompare.search')}
+ placeholder={t('topicCompare.search')}
+ value={search}
+ onChange={(event) => setSearch(event.target.value)}
+ style={{ width: 240 }}
+ />
+ <Select
+ aria-label={t('topicCompare.statusFilter')}
+ value={statusFilter}
+ onChange={setStatusFilter}
+ style={{ width: 180 }}
+ options={[
+ { value: 'ALL', label: t('topicCompare.statusAll') },
+ ...Object.keys(STATUS_LABELS).map((status) => ({
+ value: status,
+ label: t(STATUS_LABELS[status as TopicComparisonStatus]),
+ })),
+ ]}
+ />
+ </Space>
+ <Button icon={<DownloadOutlined />} onClick={exportComparison}>
+ {t('topicCompare.export')}
+ </Button>
+ </Flex>
+ <Table<TopicComparisonRow>
+ rowKey="key"
+ columns={columns}
+ dataSource={visibleRows}
+ pagination={{ pageSize: 20, showSizeChanger: false }}
+ expandable={{
+ rowExpandable: (row) => row.differences.length > 0,
+ expandedRowRender: (row) => (
+ <Table
+ rowKey="field"
+ size="small"
+ pagination={false}
+ dataSource={row.differences}
+ columns={[
+ {
+ title: t('topicCompare.field'),
+ dataIndex: 'field',
+ render: (field: TopicConfigField) =>
t(FIELD_LABELS[field]),
+ },
+ { title: sourceInstanceId, dataIndex: 'sourceValue' },
+ { title: targetInstanceId, dataIndex: 'targetValue' },
+ ]}
+ />
+ ),
+ }}
+ />
+ </>
+ )}
+ </Flex>
+ </Drawer>
+ );
+};
+
+export default TopicConfigComparisonDrawer;
diff --git a/web/src/components/__tests__/TopicConfigComparisonDrawer.test.tsx
b/web/src/components/__tests__/TopicConfigComparisonDrawer.test.tsx
new file mode 100644
index 000000000..93ecbe2f5
--- /dev/null
+++ b/web/src/components/__tests__/TopicConfigComparisonDrawer.test.tsx
@@ -0,0 +1,228 @@
+/*
+ * 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, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import type { Instance } from '../../api/instance';
+import type { Topic } from '../../api/metadata';
+import { LangProvider } from '../../i18n/LangContext';
+import TopicConfigComparisonDrawer from '../TopicConfigComparisonDrawer';
+import { downloadCsv } from '../../utils/download';
+
+const topicServiceMocks = vi.hoisted(() => ({
+ listAllTopics: vi.fn(),
+}));
+
+vi.mock('../../services/topicService', () => topicServiceMocks);
+vi.mock('../../utils/download', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('../../utils/download')>();
+ return { ...actual, downloadCsv: vi.fn() };
+});
+
+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 instances: Instance[] = [
+ {
+ id: 1,
+ name: 'production',
+ remark: null,
+ type: 'DIRECT',
+ endpoint: 'nameserver-a:9876',
+ vendor: 'APACHE',
+ topicCount: 2,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-09-01 00:00:00',
+ gmtModified: '2026-09-01 00:00:00',
+ },
+ {
+ id: 2,
+ name: 'staging',
+ remark: null,
+ type: 'DIRECT',
+ endpoint: 'nameserver-b:9876',
+ vendor: 'APACHE',
+ topicCount: 2,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-09-01 00:00:00',
+ gmtModified: '2026-09-01 00:00:00',
+ },
+];
+
+const topic = (name: string, instanceId: string, overrides: Partial<Topic> =
{}): Topic => ({
+ name,
+ namespace: 'default',
+ type: 'NORMAL',
+ clusterId: `${instanceId}-cluster`,
+ instanceId,
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: 0,
+ tps: 0,
+ consumerGroupCount: 0,
+ remark: '',
+ gmtCreate: '2026-09-01 00:00:00',
+ gmtModified: '2026-09-01 00:00:00',
+ ...overrides,
+});
+
+const productionTopics = [
+ topic('matching-topic', 'production'),
+ topic('drifted-topic', 'production'),
+ topic('source-only-topic', 'production'),
+];
+const stagingTopics = [
+ topic('matching-topic', 'staging'),
+ topic('drifted-topic', 'staging', { writeQueues: 16 }),
+ topic('target-only-topic', 'staging'),
+];
+
+const renderDrawer = (
+ overrides: Partial<React.ComponentProps<typeof TopicConfigComparisonDrawer>>
= {},
+) =>
+ render(
+ <App>
+ <LangProvider>
+ <TopicConfigComparisonDrawer
+ open
+ instances={instances}
+ currentInstanceId="production"
+ onClose={vi.fn()}
+ {...overrides}
+ />
+ </LangProvider>
+ </App>,
+ );
+
+describe('TopicConfigComparisonDrawer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ topicServiceMocks.listAllTopics.mockImplementation(({ instanceId }: {
instanceId: string }) =>
+ Promise.resolve(instanceId === 'production' ? productionTopics :
stagingTopics),
+ );
+ });
+
+ it('loads both complete inventories and summarizes all comparison states',
async () => {
+ const user = userEvent.setup();
+ renderDrawer();
+
+ await user.click(screen.getByRole('button', { name: '开始对比' }));
+
+ await waitFor(() => {
+ expect(topicServiceMocks.listAllTopics).toHaveBeenCalledWith({
instanceId: 'production' });
+ expect(topicServiceMocks.listAllTopics).toHaveBeenCalledWith({
instanceId: 'staging' });
+ });
+ expect(await screen.findByText('matching-topic')).toBeInTheDocument();
+ expect(screen.getByText('drifted-topic')).toBeInTheDocument();
+ expect(screen.getByText('source-only-topic')).toBeInTheDocument();
+ expect(screen.getByText('target-only-topic')).toBeInTheDocument();
+ for (const label of ['配置一致', '配置漂移', '仅源实例', '仅目标实例']) {
+ expect(
+ screen.getByText(label, { selector: '.ant-statistic-title'
}).parentElement,
+ ).toHaveTextContent('1');
+ }
+ });
+
+ it('shows field-level values when a drifted row is expanded', async () => {
+ const user = userEvent.setup();
+ renderDrawer();
+ await user.click(screen.getByRole('button', { name: '开始对比' }));
+ await screen.findByText('drifted-topic');
+
+ const expandButtons = screen.getAllByRole('button', { name: 'Expand row'
});
+ await user.click(expandButtons[0]);
+
+ expect(await screen.findByText('写队列数')).toBeInTheDocument();
+ expect(screen.getByText('8')).toBeInTheDocument();
+ expect(screen.getByText('16')).toBeInTheDocument();
+ });
+
+ it('filters exported rows by topic search', async () => {
+ const user = userEvent.setup();
+ renderDrawer();
+ await user.click(screen.getByRole('button', { name: '开始对比' }));
+ await screen.findByText('source-only-topic');
+
+ await user.type(screen.getByLabelText('搜索 Topic 名称'), 'source-only');
+ await user.click(screen.getByRole('button', { name: /导出结果/ }));
+
+ expect(downloadCsv).toHaveBeenCalledTimes(1);
+ const [filename, csv] = vi.mocked(downloadCsv).mock.calls[0];
+ expect(filename).toBe('rocketmq-topic-config-production-vs-staging.csv');
+ expect(csv).toContain('source-only-topic');
+ expect(csv).not.toContain('target-only-topic');
+ });
+
+ it('swaps source and target before comparison', async () => {
+ const user = userEvent.setup();
+ renderDrawer();
+
+ await user.click(screen.getByRole('button', { name: '交换源实例和目标实例' }));
+ await user.click(screen.getByRole('button', { name: '开始对比' }));
+
+ await waitFor(() => {
+ expect(topicServiceMocks.listAllTopics.mock.calls[0][0]).toEqual({
instanceId: 'staging' });
+ expect(topicServiceMocks.listAllTopics.mock.calls[1][0]).toEqual({
+ instanceId: 'production',
+ });
+ });
+ });
+
+ it('shows an actionable empty state when only one instance exists', () => {
+ renderDrawer({ instances: [instances[0]] });
+
+ expect(screen.getByText('至少需要两个实例才能进行配置对比')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '开始对比' })).toBeDisabled();
+ });
+
+ it('keeps the previous result empty when inventory loading fails', async ()
=> {
+ topicServiceMocks.listAllTopics.mockRejectedValue(new Error('offline'));
+ const user = userEvent.setup();
+ renderDrawer();
+
+ await user.click(screen.getByRole('button', { name: '开始对比' }));
+
+ await waitFor(() =>
expect(topicServiceMocks.listAllTopics).toHaveBeenCalledTimes(2));
+ expect(screen.queryByText('配置一致')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose from the drawer close control', async () => {
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+ renderDrawer({ onClose });
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 802092b92..0a5c6c881 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1082,6 +1082,53 @@ const translations: Record<string, Record<Lang, string>>
= {
'topic.properties': { zh: '属性', en: 'Properties' },
'topic.topicConfig': { zh: 'Topic 配置', en: 'Topic Config' },
'topic.queueCount': { zh: '队列数', en: 'Queue Count' },
+ 'topicCompare.open': { zh: '配置对比', en: 'Compare Config' },
+ 'topicCompare.title': { zh: '跨实例 Topic 配置对比', en: 'Cross-instance Topic
Comparison' },
+ 'topicCompare.description': {
+ zh: '对比两个实例中的 Topic 类型、命名空间、队列数和权限;运行时指标与时间戳不参与对比。',
+ en: 'Compare Topic type, namespace, queue counts, and permissions. Runtime
metrics and timestamps are excluded.',
+ },
+ 'topicCompare.sourceInstance': { zh: '源实例', en: 'Source instance' },
+ 'topicCompare.targetInstance': { zh: '目标实例', en: 'Target instance' },
+ 'topicCompare.swap': { zh: '交换源实例和目标实例', en: 'Swap source and target' },
+ 'topicCompare.compare': { zh: '开始对比', en: 'Compare' },
+ 'topicCompare.needTwoInstances': {
+ zh: '至少需要两个实例才能进行配置对比',
+ en: 'At least two instances are required for comparison.',
+ },
+ 'topicCompare.selectDifferentInstances': {
+ zh: '请选择两个不同的实例',
+ en: 'Select two different instances.',
+ },
+ 'topicCompare.loadFailed': {
+ zh: 'Topic 配置加载失败,请稍后重试',
+ en: 'Failed to load Topic configuration. Please try again.',
+ },
+ 'topicCompare.exported': {
+ zh: '已导出 {count} 条对比结果',
+ en: 'Exported {count} comparison rows.',
+ },
+ 'topicCompare.matches': { zh: '配置一致', en: 'Matching' },
+ 'topicCompare.drifted': { zh: '配置漂移', en: 'Drifted' },
+ 'topicCompare.onlySource': { zh: '仅源实例', en: 'Source only' },
+ 'topicCompare.onlyTarget': { zh: '仅目标实例', en: 'Target only' },
+ 'topicCompare.topic': { zh: 'Topic', en: 'Topic' },
+ 'topicCompare.status': { zh: '对比状态', en: 'Status' },
+ 'topicCompare.differenceCount': { zh: '差异字段数', en: 'Differences' },
+ 'topicCompare.field': { zh: '配置字段', en: 'Configuration field' },
+ 'topicCompare.fieldType': { zh: '消息类型', en: 'Message type' },
+ 'topicCompare.fieldNamespace': { zh: '命名空间', en: 'Namespace' },
+ 'topicCompare.fieldWriteQueues': { zh: '写队列数', en: 'Write queues' },
+ 'topicCompare.fieldReadQueues': { zh: '读队列数', en: 'Read queues' },
+ 'topicCompare.fieldPermission': { zh: '权限', en: 'Permission' },
+ 'topicCompare.search': { zh: '搜索 Topic 名称', en: 'Search Topic name' },
+ 'topicCompare.statusFilter': { zh: '对比状态筛选', en: 'Filter comparison status'
},
+ 'topicCompare.statusAll': { zh: '全部状态', en: 'All statuses' },
+ 'topicCompare.statusMatch': { zh: '一致', en: 'Match' },
+ 'topicCompare.statusDrift': { zh: '漂移', en: 'Drift' },
+ 'topicCompare.statusOnlySource': { zh: '仅源实例', en: 'Source only' },
+ 'topicCompare.statusOnlyTarget': { zh: '仅目标实例', en: 'Target only' },
+ 'topicCompare.export': { zh: '导出结果', en: 'Export results' },
// ─── Consumer Page ───
'consumer.name': { zh: 'Group 名称', en: 'Group Name' },
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index c45d78188..12d696ee7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -56,10 +56,12 @@ import {
CheckCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
+ DiffOutlined,
} from '@ant-design/icons';
import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
+import TopicConfigComparisonDrawer from
'../../components/TopicConfigComparisonDrawer';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, CLUSTER_TYPE_MAP } from '../../constants/theme';
import type { Topic, BrokerRoute, ConsumerGroupInfo, TopicConsumerPage } from
'../../api/metadata';
@@ -307,6 +309,7 @@ const TopicPage = () => {
selectInstance,
instanceOptions,
instancesLoading,
+ instances,
} = useInstanceFilter();
const isCloudInstance =
selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
@@ -349,6 +352,7 @@ const TopicPage = () => {
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importing, setImporting] = useState(false);
const [exporting, setExporting] = useState(false);
+ const [comparisonOpen, setComparisonOpen] = useState(false);
const topicRequestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
@@ -1378,6 +1382,13 @@ const TopicPage = () => {
<Button icon={<ExportOutlined />} loading={exporting} onClick={() =>
void handleExport()}>
导出
</Button>
+ <Button
+ icon={<DiffOutlined />}
+ disabled={instances.length < 2}
+ onClick={() => setComparisonOpen(true)}
+ >
+ {t('topicCompare.open')}
+ </Button>
{!isCloudInstance && (
<Button
icon={<SyncOutlined />}
@@ -1429,6 +1440,15 @@ const TopicPage = () => {
/>
</Card>
+ {comparisonOpen && (
+ <TopicConfigComparisonDrawer
+ open
+ instances={instances}
+ currentInstanceId={selectedInstanceId}
+ onClose={() => setComparisonOpen(false)}
+ />
+ )}
+
{/* ── Detail Modal ──────────────────────────────────────── */}
<Modal
title={selectedTopic?.name}
diff --git a/web/src/utils/topicConfigComparison.test.ts
b/web/src/utils/topicConfigComparison.test.ts
new file mode 100644
index 000000000..c66e7845a
--- /dev/null
+++ b/web/src/utils/topicConfigComparison.test.ts
@@ -0,0 +1,176 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import type { Topic } from '../api/metadata';
+import {
+ compareTopicInventories,
+ filterTopicComparisonRows,
+ formatTopicDifferences,
+ TOPIC_CONFIG_FIELDS,
+} from './topicConfigComparison';
+
+const topic = (name: string, overrides: Partial<Topic> = {}): Topic => ({
+ name,
+ namespace: 'orders',
+ type: 'NORMAL',
+ clusterId: 'cluster-a',
+ instanceId: 'source',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: 100,
+ tps: 12,
+ consumerGroupCount: 2,
+ remark: '',
+ gmtCreate: '2026-09-01 00:00:00',
+ gmtModified: '2026-09-01 00:00:00',
+ ...overrides,
+});
+
+describe('compareTopicInventories', () => {
+ it('reports matching stable configuration', () => {
+ const result = compareTopicInventories(
+ [topic('orders-created')],
+ [topic('orders-created', { instanceId: 'target', clusterId: 'cluster-b'
})],
+ );
+
+ expect(result.rows).toEqual([
+ expect.objectContaining({
+ topicName: 'orders-created',
+ status: 'MATCH',
+ differences: [],
+ }),
+ ]);
+ expect(result.summary).toEqual({
+ total: 1,
+ matches: 1,
+ drifted: 0,
+ onlySource: 0,
+ onlyTarget: 0,
+ });
+ });
+
+ it('compares every declared configuration field', () => {
+ const source = topic('orders-created');
+ const target = topic('orders-created', {
+ type: 'FIFO',
+ namespace: 'payments',
+ writeQueues: 16,
+ readQueues: 4,
+ perm: 'RO',
+ });
+
+ const result = compareTopicInventories([source], [target]);
+
+ expect(result.rows[0].status).toBe('DRIFT');
+ expect(result.rows[0].differences.map((difference) =>
difference.field)).toEqual(
+ TOPIC_CONFIG_FIELDS,
+ );
+ expect(result.summary.drifted).toBe(1);
+ });
+
+ it('ignores runtime values, timestamps, remarks, instance and cluster
identity', () => {
+ const source = topic('orders-created');
+ const target = topic('orders-created', {
+ clusterId: 'cluster-b',
+ instanceId: 'target',
+ messageCount: 9_999,
+ tps: 999,
+ consumerGroupCount: 20,
+ remark: 'different operational note',
+ gmtCreate: '2025-01-01 00:00:00',
+ gmtModified: '2026-09-04 00:00:00',
+ });
+
+ expect(compareTopicInventories([source],
[target]).rows[0].status).toBe('MATCH');
+ });
+
+ it('trims string configuration returned by heterogeneous providers', () => {
+ const source = topic('orders-created', { namespace: ' orders ', perm: ' RW
' });
+ const target = topic('orders-created', { namespace: 'orders', perm: 'RW'
});
+
+ expect(compareTopicInventories([source],
[target]).rows[0].status).toBe('MATCH');
+ });
+
+ it('reports source-only and target-only topics', () => {
+ const result = compareTopicInventories(
+ [topic('source-only'), topic('shared')],
+ [topic('target-only'), topic('shared')],
+ );
+
+ expect(result.rows.map(({ topicName, status }) => ({ topicName, status
}))).toEqual([
+ { topicName: 'shared', status: 'MATCH' },
+ { topicName: 'source-only', status: 'ONLY_SOURCE' },
+ { topicName: 'target-only', status: 'ONLY_TARGET' },
+ ]);
+ expect(result.summary).toEqual({
+ total: 3,
+ matches: 1,
+ drifted: 0,
+ onlySource: 1,
+ onlyTarget: 1,
+ });
+ });
+
+ it('returns deterministic name ordering regardless of API order', () => {
+ const result = compareTopicInventories(
+ [topic('z-topic'), topic('a-topic')],
+ [topic('m-topic'), topic('z-topic')],
+ );
+
+ expect(result.rows.map((row) => row.topicName)).toEqual(['a-topic',
'm-topic', 'z-topic']);
+ });
+
+ it('handles empty inventories', () => {
+ expect(compareTopicInventories([], [])).toEqual({
+ rows: [],
+ summary: { total: 0, matches: 0, drifted: 0, onlySource: 0, onlyTarget:
0 },
+ });
+ });
+});
+
+describe('comparison row helpers', () => {
+ const rows = compareTopicInventories(
+ [topic('Orders.Created'), topic('payments-settled')],
+ [topic('Orders.Created', { writeQueues: 16 }), topic('target-only')],
+ ).rows;
+
+ it('filters by status', () => {
+ expect(filterTopicComparisonRows(rows, 'DRIFT', '').map((row) =>
row.topicName)).toEqual([
+ 'Orders.Created',
+ ]);
+ expect(filterTopicComparisonRows(rows, 'ONLY_TARGET', '').map((row) =>
row.topicName)).toEqual([
+ 'target-only',
+ ]);
+ });
+
+ it('searches topic names case-insensitively and trims input', () => {
+ expect(filterTopicComparisonRows(rows, 'ALL', ' orders.
')).toHaveLength(1);
+ });
+
+ it('combines status and search filters', () => {
+ expect(filterTopicComparisonRows(rows, 'ONLY_SOURCE',
'payments')).toHaveLength(1);
+ expect(filterTopicComparisonRows(rows, 'DRIFT',
'payments')).toHaveLength(0);
+ });
+
+ it('formats field differences for CSV and text views', () => {
+ const drift = rows.find((row) => row.status === 'DRIFT')!;
+ expect(formatTopicDifferences(drift.differences)).toBe('writeQueues: 8 ->
16');
+ expect(formatTopicDifferences([])).toBe('');
+ });
+});
diff --git a/web/src/utils/topicConfigComparison.ts
b/web/src/utils/topicConfigComparison.ts
new file mode 100644
index 000000000..2189db128
--- /dev/null
+++ b/web/src/utils/topicConfigComparison.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 type { Topic } from '../api/metadata';
+
+export type TopicComparisonStatus = 'MATCH' | 'DRIFT' | 'ONLY_SOURCE' |
'ONLY_TARGET';
+export type TopicConfigField = 'type' | 'namespace' | 'writeQueues' |
'readQueues' | 'perm';
+
+export interface TopicFieldDifference {
+ field: TopicConfigField;
+ sourceValue: string | number;
+ targetValue: string | number;
+}
+
+export interface TopicComparisonRow {
+ key: string;
+ topicName: string;
+ status: TopicComparisonStatus;
+ source?: Topic;
+ target?: Topic;
+ differences: TopicFieldDifference[];
+}
+
+export interface TopicComparisonSummary {
+ total: number;
+ matches: number;
+ drifted: number;
+ onlySource: number;
+ onlyTarget: number;
+}
+
+export interface TopicComparisonResult {
+ rows: TopicComparisonRow[];
+ summary: TopicComparisonSummary;
+}
+
+export const TOPIC_CONFIG_FIELDS: TopicConfigField[] = [
+ 'type',
+ 'namespace',
+ 'writeQueues',
+ 'readQueues',
+ 'perm',
+];
+
+const valueOf = (topic: Topic, field: TopicConfigField): string | number => {
+ const value = topic[field];
+ return typeof value === 'string' ? value.trim() : value;
+};
+
+const differencesBetween = (source: Topic, target: Topic):
TopicFieldDifference[] =>
+ TOPIC_CONFIG_FIELDS.flatMap((field) => {
+ const sourceValue = valueOf(source, field);
+ const targetValue = valueOf(target, field);
+ return sourceValue === targetValue ? [] : [{ field, sourceValue,
targetValue }];
+ });
+
+/** Compares stable Topic configuration and intentionally excludes runtime
counters and timestamps. */
+export const compareTopicInventories = (
+ sourceTopics: Topic[],
+ targetTopics: Topic[],
+): TopicComparisonResult => {
+ const sourceByName = new Map(sourceTopics.map((topic) => [topic.name,
topic]));
+ const targetByName = new Map(targetTopics.map((topic) => [topic.name,
topic]));
+ const names = [...new Set([...sourceByName.keys(),
...targetByName.keys()])].sort((left, right) =>
+ left.localeCompare(right),
+ );
+
+ const rows = names.map((topicName): TopicComparisonRow => {
+ const source = sourceByName.get(topicName);
+ const target = targetByName.get(topicName);
+ if (!source) {
+ return {
+ key: topicName,
+ topicName,
+ status: 'ONLY_TARGET',
+ target,
+ differences: [],
+ };
+ }
+ if (!target) {
+ return {
+ key: topicName,
+ topicName,
+ status: 'ONLY_SOURCE',
+ source,
+ differences: [],
+ };
+ }
+ const differences = differencesBetween(source, target);
+ return {
+ key: topicName,
+ topicName,
+ status: differences.length === 0 ? 'MATCH' : 'DRIFT',
+ source,
+ target,
+ differences,
+ };
+ });
+
+ return {
+ rows,
+ summary: {
+ total: rows.length,
+ matches: rows.filter((row) => row.status === 'MATCH').length,
+ drifted: rows.filter((row) => row.status === 'DRIFT').length,
+ onlySource: rows.filter((row) => row.status === 'ONLY_SOURCE').length,
+ onlyTarget: rows.filter((row) => row.status === 'ONLY_TARGET').length,
+ },
+ };
+};
+
+export const filterTopicComparisonRows = (
+ rows: TopicComparisonRow[],
+ status: TopicComparisonStatus | 'ALL',
+ search: string,
+) => {
+ const normalizedSearch = search.trim().toLocaleLowerCase();
+ return rows.filter(
+ (row) =>
+ (status === 'ALL' || row.status === status) &&
+ (!normalizedSearch ||
row.topicName.toLocaleLowerCase().includes(normalizedSearch)),
+ );
+};
+
+export const formatTopicDifferences = (differences: TopicFieldDifference[]) =>
+ differences
+ .map(({ field, sourceValue, targetValue }) => `${field}: ${sourceValue} ->
${targetValue}`)
+ .join('; ');