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('; ');

Reply via email to