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 fb6dfea9 feat: add resource change plan preview (#1269)
fb6dfea9 is described below

commit fb6dfea9c3b13e4a4d0bf892bcf9897516f35a4f
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 20:12:33 2026 +0800

    feat: add resource change plan preview (#1269)
---
 web/src/App.tsx                                    |   3 +
 web/src/hooks/useInstanceFilter.test.tsx           |  28 ++
 web/src/hooks/useInstanceFilter.ts                 |   5 +-
 web/src/i18n/translations.ts                       |   1 +
 web/src/layouts/MainLayout.tsx                     |   8 +-
 .../instance/__tests__/ResourcePlanPage.test.tsx   | 147 ++++++++
 web/src/pages/instance/resourcePlan.tsx            | 265 ++++++++++++++
 web/src/services/resourcePlanService.test.ts       | 118 ++++++
 web/src/services/resourcePlanService.ts            | 395 +++++++++++++++++++++
 9 files changed, 967 insertions(+), 3 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 96e20230..949fbbe2 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -32,6 +32,7 @@ const ConsumerPage = lazy(() => 
import('./pages/instance/consumer'));
 const MessagePage = lazy(() => import('./pages/instance/message'));
 const AclPage = lazy(() => import('./pages/instance/acl'));
 const DlqPage = lazy(() => import('./pages/instance/dlq'));
+const ResourcePlanPage = lazy(() => import('./pages/instance/resourcePlan'));
 const ClusterPage = lazy(() => import('./pages/cluster'));
 const K8sCertsPage = lazy(() => import('./pages/cluster/certs'));
 const ClientsPage = lazy(() => import('./pages/cluster/clients'));
@@ -157,6 +158,8 @@ function App() {
             <Route path="instance/:instanceId/acl" element={<AclPage />} />
             <Route path="instance/dlq" element={<DlqPage />} />
             <Route path="instance/:instanceId/dlq" element={<DlqPage />} />
+            <Route path="instance/resource-plan" element={<ResourcePlanPage 
/>} />
+            <Route path="instance/:instanceId/resource-plan" 
element={<ResourcePlanPage />} />
             <Route path="cluster" element={<ClusterPage />} />
             <Route path="cluster/certs" element={<K8sCertsPage />} />
             <Route path="cluster/clients" element={<ClientsPage />} />
diff --git a/web/src/hooks/useInstanceFilter.test.tsx 
b/web/src/hooks/useInstanceFilter.test.tsx
index 919bd9cf..2dc4cbda 100644
--- a/web/src/hooks/useInstanceFilter.test.tsx
+++ b/web/src/hooks/useInstanceFilter.test.tsx
@@ -60,4 +60,32 @@ describe('useInstanceFilter', () => {
       
expect(screen.getByText('/instance/instance-a/topic|instance-a')).toBeInTheDocument();
     });
   });
+
+  it('keeps the resource-plan section when normalizing instance scoped 
routes', async () => {
+    instanceServiceMocks.listInstances.mockResolvedValue([
+      {
+        id: 'instance-a',
+        name: 'Instance A',
+        remark: '',
+        type: 'PROXY',
+        endpoint: '127.0.0.1:8080',
+        topicCount: 0,
+        consumerGroupCount: 0,
+        createdAt: '2026-01-01T00:00:00Z',
+        updatedAt: '2026-01-01T00:00:00Z',
+      },
+    ]);
+
+    render(
+      <MemoryRouter initialEntries={['/instance/missing/resource-plan']}>
+        <Routes>
+          <Route path="/instance/:instanceId/resource-plan" 
element={<InstanceRouteProbe />} />
+        </Routes>
+      </MemoryRouter>,
+    );
+
+    await waitFor(() => {
+      
expect(screen.getByText('/instance/instance-a/resource-plan|instance-a')).toBeInTheDocument();
+    });
+  });
 });
diff --git a/web/src/hooks/useInstanceFilter.ts 
b/web/src/hooks/useInstanceFilter.ts
index ee98d817..4d42045a 100644
--- a/web/src/hooks/useInstanceFilter.ts
+++ b/web/src/hooks/useInstanceFilter.ts
@@ -20,8 +20,9 @@ import { useLocation, useNavigate } from 'react-router-dom';
 import { listInstances } from '../services/instanceService';
 import type { Instance } from '../api/instance';
 
-const INSTANCE_SCOPED_PATH = 
/^\/instance\/([^/]+)\/(topic|consumer|message|acl|dlq)$/;
-const STATIC_SECTION_PATH = /^\/instance\/(topic|consumer|message|acl|dlq)$/;
+const INSTANCE_SCOPED_PATH =
+  /^\/instance\/([^/]+)\/(topic|consumer|message|acl|dlq|resource-plan)$/;
+const STATIC_SECTION_PATH = 
/^\/instance\/(topic|consumer|message|acl|dlq|resource-plan)$/;
 
 /**
  * 实例维度页面的公共筛选逻辑:从 /instance/:instanceId/<section> 路由解析当前实例,
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index eb18a06e..ec89bf46 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -28,6 +28,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'nav.acl': { zh: 'ACL 管理', en: 'ACL Management' },
   'nav.message': { zh: '消息查询', en: 'Message Search' },
   'nav.dlq': { zh: '死信队列', en: 'Dead Letter Queue' },
+  'nav.resourcePlan': { zh: '资源变更计划', en: 'Resource Plan' },
   'nav.clusterOps': { zh: '集群运维', en: 'Cluster & Ops' },
   'nav.certs': { zh: 'K8s 证书管理', en: 'K8s Certificates' },
   'nav.rocketmqCluster': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' },
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 168710b7..d86ada04 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -101,7 +101,7 @@ const MainLayout = () => {
   }, []);
 
   const instanceScopedMatch = location.pathname.match(
-    /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq)$/,
+    /^\/instance\/[^/]+\/(topic|consumer|message|acl|dlq|resource-plan)$/,
   );
   const selectedMenuKey = instanceScopedMatch
     ? `/instance/${instanceScopedMatch[1]}`
@@ -120,6 +120,11 @@ const MainLayout = () => {
         { key: '/instance/acl', icon: <Key size={16} />, label: t('nav.acl') },
         { key: '/instance/message', icon: <MagnifyingGlass size={16} />, 
label: t('nav.message') },
         { key: '/instance/dlq', icon: <TrashSimple size={16} />, label: 
t('nav.dlq') },
+        {
+          key: '/instance/resource-plan',
+          icon: <Notebook size={16} />,
+          label: t('nav.resourcePlan'),
+        },
       ],
     },
     {
@@ -164,6 +169,7 @@ const MainLayout = () => {
     '/instance/message': t('nav.message'),
     '/instance/acl': t('nav.acl'),
     '/instance/dlq': t('nav.dlq'),
+    '/instance/resource-plan': t('nav.resourcePlan'),
     '/cluster': t('nav.rocketmqCluster'),
     '/cluster/certs': t('nav.certs'),
     '/cluster/clients': t('nav.clients'),
diff --git a/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx 
b/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
new file mode 100644
index 00000000..b6541200
--- /dev/null
+++ b/web/src/pages/instance/__tests__/ResourcePlanPage.test.tsx
@@ -0,0 +1,147 @@
+/*
+ * 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 { App } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { LangProvider } from '../../../i18n/LangContext';
+import ResourcePlanPage from '../resourcePlan';
+
+const resourcePlanServiceMocks = vi.hoisted(() => ({
+  RESOURCE_PLAN_SAMPLE: JSON.stringify({
+    topics: [{ name: 'orders', type: 'NORMAL', writeQueues: 8, readQueues: 8, 
perm: 'RW' }],
+    consumerGroups: [{ name: 'cg-orders', consumeType: 'CLUSTERING' }],
+  }),
+  parseResourceBundle: vi.fn(),
+  previewResourcePlan: vi.fn(),
+}));
+
+const instanceServiceMocks = vi.hoisted(() => ({
+  listInstances: vi.fn(),
+}));
+
+vi.mock('../../../services/resourcePlanService', () => 
resourcePlanServiceMocks);
+vi.mock('../../../services/instanceService', () => instanceServiceMocks);
+
+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 renderWithProviders = () =>
+  render(
+    <App>
+      <LangProvider>
+        <MemoryRouter 
initialEntries={['/instance/instance-proxy-1/resource-plan']}>
+          <Routes>
+            <Route path="/instance/:instanceId/resource-plan" 
element={<ResourcePlanPage />} />
+          </Routes>
+        </MemoryRouter>
+      </LangProvider>
+    </App>,
+  );
+
+describe('ResourcePlanPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    instanceServiceMocks.listInstances.mockResolvedValue([
+      {
+        id: 'instance-proxy-1',
+        name: 'Instance Proxy 1',
+        remark: '',
+        type: 'PROXY',
+        endpoint: '10.0.0.1:8080',
+        topicCount: 0,
+        consumerGroupCount: 0,
+        createdAt: '2026-01-01T00:00:00Z',
+        updatedAt: '2026-01-01T00:00:00Z',
+      },
+    ]);
+    resourcePlanServiceMocks.parseResourceBundle.mockReturnValue({
+      topics: [{ name: 'orders', type: 'NORMAL', writeQueues: 8, readQueues: 
8, perm: 'RW' }],
+      consumerGroups: [{ name: 'cg-orders', consumeType: 'CLUSTERING' }],
+    });
+    resourcePlanServiceMocks.previewResourcePlan.mockResolvedValue({
+      instanceId: 'instance-proxy-1',
+      summary: {
+        total: 2,
+        creates: 1,
+        updates: 1,
+        skips: 0,
+        conflicts: 0,
+        invalids: 0,
+        applicable: 2,
+      },
+      entries: [
+        {
+          resourceType: 'TOPIC',
+          name: 'orders',
+          rowIndex: 1,
+          action: 'UPDATE',
+          applicable: true,
+          reason: 'Topic exists with different configuration',
+          changes: [{ field: 'writeQueues', currentValue: '16', desiredValue: 
'8' }],
+        },
+        {
+          resourceType: 'CONSUMER_GROUP',
+          name: 'cg-orders',
+          rowIndex: 1,
+          action: 'CREATE',
+          applicable: true,
+          reason: 'Consumer group does not exist in the selected instance',
+          changes: [],
+        },
+      ],
+    });
+  });
+
+  it('previews the pasted resource bundle for the selected instance', async () 
=> {
+    const user = userEvent.setup();
+    renderWithProviders();
+
+    expect(await screen.findByText('资源变更计划')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /生成计划/ }));
+
+    await waitFor(() =>
+      
expect(resourcePlanServiceMocks.previewResourcePlan).toHaveBeenCalledWith({
+        instanceId: 'instance-proxy-1',
+        topics: [{ name: 'orders', type: 'NORMAL', writeQueues: 8, readQueues: 
8, perm: 'RW' }],
+        consumerGroups: [{ name: 'cg-orders', consumeType: 'CLUSTERING' }],
+      }),
+    );
+
+    expect(screen.getByText('orders')).toBeInTheDocument();
+    expect(screen.getByText('cg-orders')).toBeInTheDocument();
+    expect(
+      within(screen.getByText('总资源').closest('.ant-card')!).getByText('2'),
+    ).toBeInTheDocument();
+    expect(screen.getByText('writeQueues: 16 → 8')).toBeInTheDocument();
+  });
+});
diff --git a/web/src/pages/instance/resourcePlan.tsx 
b/web/src/pages/instance/resourcePlan.tsx
new file mode 100644
index 00000000..3a79da25
--- /dev/null
+++ b/web/src/pages/instance/resourcePlan.tsx
@@ -0,0 +1,265 @@
+/*
+ * 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, useState } from 'react';
+import {
+  Alert,
+  App,
+  Button,
+  Card,
+  Col,
+  Input,
+  Row,
+  Select,
+  Space,
+  Statistic,
+  Table,
+  Tag,
+  Typography,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { PlayCircleOutlined } from '@ant-design/icons';
+import PageHeader from '../../components/PageHeader';
+import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import type { ResourcePlanEntry } from '../../services/resourcePlanService';
+import {
+  RESOURCE_PLAN_SAMPLE,
+  parseResourceBundle,
+  previewResourcePlan,
+} from '../../services/resourcePlanService';
+
+const { Text, Paragraph } = Typography;
+const { TextArea } = Input;
+
+const ACTION_COLOR: Record<string, string> = {
+  CREATE: 'green',
+  UPDATE: 'blue',
+  SKIP: 'default',
+  CONFLICT: 'orange',
+  INVALID: 'red',
+};
+
+const RESOURCE_LABEL: Record<string, string> = {
+  TOPIC: 'Topic',
+  CONSUMER_GROUP: 'Consumer Group',
+};
+
+const ResourcePlanPage = () => {
+  const { message } = App.useApp();
+  const { selectedInstanceId, selectedInstance, selectInstance, 
instanceOptions } =
+    useInstanceFilter();
+  const [bundleText, setBundleText] = useState(RESOURCE_PLAN_SAMPLE);
+  const [previewLoading, setPreviewLoading] = useState(false);
+  const [plan, setPlan] = useState<Awaited<ReturnType<typeof 
previewResourcePlan>> | null>(null);
+
+  const columns = useMemo<ColumnsType<ResourcePlanEntry>>(
+    () => [
+      {
+        title: '资源类型',
+        dataIndex: 'resourceType',
+        width: 150,
+        render: (value: ResourcePlanEntry['resourceType']) => 
RESOURCE_LABEL[value] ?? value,
+      },
+      {
+        title: '名称',
+        dataIndex: 'name',
+        width: 220,
+        render: (value: string) => value || <Text type="secondary">未命名</Text>,
+      },
+      {
+        title: '行号',
+        dataIndex: 'rowIndex',
+        width: 80,
+      },
+      {
+        title: '动作',
+        dataIndex: 'action',
+        width: 110,
+        render: (action: ResourcePlanEntry['action']) => (
+          <Tag color={ACTION_COLOR[action]}>{action}</Tag>
+        ),
+      },
+      {
+        title: '可应用',
+        dataIndex: 'applicable',
+        width: 100,
+        render: (applicable: boolean) =>
+          applicable ? <Tag color="green">是</Tag> : <Tag 
color="default">否</Tag>,
+      },
+      {
+        title: '原因',
+        dataIndex: 'reason',
+        width: 260,
+      },
+      {
+        title: '差异',
+        dataIndex: 'changes',
+        render: (changes: ResourcePlanEntry['changes']) =>
+          changes.length ? (
+            <Space direction="vertical" size={2}>
+              {changes.map((change) => (
+                <Text code 
key={`${change.field}-${change.currentValue}-${change.desiredValue}`}>
+                  {change.field}: {change.currentValue ?? '∅'} → 
{change.desiredValue ?? '∅'}
+                </Text>
+              ))}
+            </Space>
+          ) : (
+            <Text type="secondary">无差异</Text>
+          ),
+      },
+    ],
+    [],
+  );
+
+  const runPreview = async () => {
+    if (!selectedInstanceId) {
+      message.warning('请先选择实例');
+      return;
+    }
+    setPreviewLoading(true);
+    try {
+      const bundle = parseResourceBundle(bundleText);
+      const request = { instanceId: selectedInstanceId, ...bundle };
+      const nextPlan = await previewResourcePlan(request);
+      setPlan(nextPlan);
+      message.success('资源变更计划已生成');
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '资源变更计划生成失败');
+    } finally {
+      setPreviewLoading(false);
+    }
+  };
+
+  return (
+    <div style={{ padding: 24 }}>
+      <PageHeader
+        title="资源变更计划"
+        subtitle="导入前只读预检 Topic 与 Consumer Group 配置,先看差异再决定是否手动调整"
+        extra={
+          <Space>
+            <Select
+              value={selectedInstanceId || undefined}
+              placeholder="选择实例"
+              style={{ width: 220 }}
+              options={instanceOptions}
+              onChange={selectInstance}
+            />
+            <Button onClick={() => 
setBundleText(RESOURCE_PLAN_SAMPLE)}>填充示例</Button>
+            <Button
+              type="primary"
+              icon={<PlayCircleOutlined />}
+              loading={previewLoading}
+              onClick={runPreview}
+            >
+              生成计划
+            </Button>
+          </Space>
+        }
+      />
+
+      <Alert
+        type="info"
+        showIcon
+        style={{ marginBottom: 16 }}
+        message="资源包预检不会直接修改集群"
+        description={
+          <Paragraph style={{ marginBottom: 0 }}>
+            Topic 已存在且配置不同会标记为 UPDATE;Consumer Group
+            当前没有更新接口,已存在但配置不同会标记为
+            CONFLICT。页面只生成计划,不会创建、更新或删除任何资源。
+          </Paragraph>
+        }
+      />
+
+      <Row gutter={[16, 16]}>
+        <Col xs={24} lg={9}>
+          <Card
+            title="资源包 JSON"
+            extra={
+              selectedInstance ? (
+                <Text type="secondary">当前实例:{selectedInstance.name}</Text>
+              ) : (
+                <Text type="secondary">未选择实例</Text>
+              )
+            }
+          >
+            <TextArea
+              value={bundleText}
+              onChange={(event) => setBundleText(event.target.value)}
+              autoSize={{ minRows: 20, maxRows: 30 }}
+              spellCheck={false}
+              style={{ fontFamily: 'Menlo, Monaco, Consolas, monospace', 
fontSize: 12 }}
+            />
+          </Card>
+        </Col>
+        <Col xs={24} lg={15}>
+          {plan && (
+            <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
+              <Col xs={12} md={6}>
+                <Card size="small">
+                  <Statistic title="总资源" value={plan.summary.total} />
+                </Card>
+              </Col>
+              <Col xs={12} md={6}>
+                <Card size="small">
+                  <Statistic
+                    title="可应用"
+                    value={plan.summary.applicable}
+                    valueStyle={{ color: '#389e0d' }}
+                  />
+                </Card>
+              </Col>
+              <Col xs={12} md={6}>
+                <Card size="small">
+                  <Statistic
+                    title="冲突"
+                    value={plan.summary.conflicts}
+                    valueStyle={{ color: '#d46b08' }}
+                  />
+                </Card>
+              </Col>
+              <Col xs={12} md={6}>
+                <Card size="small">
+                  <Statistic
+                    title="无效"
+                    value={plan.summary.invalids}
+                    valueStyle={{ color: '#cf1322' }}
+                  />
+                </Card>
+              </Col>
+            </Row>
+          )}
+          <Card title="预检结果">
+            <Table
+              rowKey={(record) => 
`${record.resourceType}-${record.name}-${record.rowIndex}`}
+              loading={previewLoading}
+              columns={columns}
+              dataSource={plan?.entries ?? []}
+              pagination={{ pageSize: 10, showSizeChanger: true }}
+              scroll={{ x: 1080 }}
+              locale={{
+                emptyText: '粘贴资源包后点击“生成计划”',
+              }}
+            />
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  );
+};
+
+export default ResourcePlanPage;
diff --git a/web/src/services/resourcePlanService.test.ts 
b/web/src/services/resourcePlanService.test.ts
new file mode 100644
index 00000000..1a2fdfe2
--- /dev/null
+++ b/web/src/services/resourcePlanService.test.ts
@@ -0,0 +1,118 @@
+/*
+ * 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, vi } from 'vitest';
+import type { ConsumerGroup, Topic } from '../api/metadata';
+import {
+  parseResourceBundle,
+  previewResourcePlan,
+  type ResourceBundle,
+} from './resourcePlanService';
+
+const topicServiceMocks = vi.hoisted(() => ({
+  listTopics: vi.fn(),
+}));
+const consumerServiceMocks = vi.hoisted(() => ({
+  listConsumerGroups: vi.fn(),
+}));
+
+vi.mock('./topicService', () => topicServiceMocks);
+vi.mock('./consumerService', () => consumerServiceMocks);
+
+const existingTopic: Topic = {
+  name: 'order-create',
+  namespace: 'trade',
+  type: 'NORMAL',
+  clusterId: 'rmq-cn-v5-prod-01',
+  instanceId: 'instance-proxy-1',
+  writeQueues: 16,
+  readQueues: 16,
+  perm: 'RW',
+  messageCount: 0,
+  tps: 0,
+  consumerGroupCount: 1,
+  remark: 'old',
+  createdAt: '2026-01-01T00:00:00Z',
+  updatedAt: '2026-01-01T00:00:00Z',
+};
+
+const existingGroup: ConsumerGroup = {
+  name: 'cg-order-notify',
+  namespace: 'trade',
+  clusterId: 'hz-prod',
+  instanceId: 'instance-proxy-1',
+  subscriptionMode: 'Push',
+  consumeType: 'CLUSTERING',
+  onlineInstances: 1,
+  totalLag: 0,
+  subscribedTopics: ['order-create'],
+  subscriptionDataType: 'NORMAL',
+  retryMaxTimes: 16,
+  createdAt: '2026-01-01T00:00:00Z',
+  updatedAt: '2026-01-01T00:00:00Z',
+  delaySeconds: 0,
+  instances: [],
+};
+
+describe('resource plan service', () => {
+  it('parses JSON resource bundles and rejects malformed shapes', () => {
+    const bundle = 
parseResourceBundle('{"topics":[{"name":"orders"}],"consumerGroups":[]}');
+    expect(bundle).toEqual({ topics: [{ name: 'orders' }], consumerGroups: [] 
});
+
+    expect(() => parseResourceBundle('{')).toThrow('Resource bundle must be 
valid JSON');
+    expect(() => parseResourceBundle('[]')).toThrow('Resource bundle must be a 
JSON object');
+    expect(() => parseResourceBundle('{"topics":{}}')).toThrow('topics must be 
an array');
+  });
+
+  it('builds a mock preview with create, update, conflict and invalid 
entries', async () => {
+    topicServiceMocks.listTopics.mockResolvedValue([existingTopic]);
+    consumerServiceMocks.listConsumerGroups.mockResolvedValue([existingGroup]);
+
+    const bundle: ResourceBundle = {
+      topics: [
+        { name: 'order-create', namespace: 'trade', writeQueues: 32 },
+        { name: 'new-topic', type: 'NORMAL', writeQueues: 8, readQueues: 8, 
perm: 'RW' },
+        { name: 'new-topic', type: 'NORMAL' },
+      ],
+      consumerGroups: [
+        { name: 'cg-order-notify', consumeType: 'BROADCASTING' },
+        { name: 'cg-new', consumeType: 'CLUSTERING', retryMaxTimes: 16 },
+      ],
+    };
+
+    const plan = await previewResourcePlan({ instanceId: 'instance-proxy-1', 
...bundle });
+
+    expect(plan.summary).toMatchObject({
+      total: 5,
+      creates: 2,
+      updates: 1,
+      conflicts: 1,
+      invalids: 1,
+      applicable: 3,
+    });
+    expect(plan.entries.map((entry) => entry.action)).toEqual([
+      'UPDATE',
+      'CREATE',
+      'INVALID',
+      'CONFLICT',
+      'CREATE',
+    ]);
+    expect(plan.entries[0].changes).toEqual([
+      { field: 'writeQueues', currentValue: '16', desiredValue: '32' },
+    ]);
+  });
+});
diff --git a/web/src/services/resourcePlanService.ts 
b/web/src/services/resourcePlanService.ts
new file mode 100644
index 00000000..92904b49
--- /dev/null
+++ b/web/src/services/resourcePlanService.ts
@@ -0,0 +1,395 @@
+/*
+ * 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 { listConsumerGroups } from './consumerService';
+import { listTopics } from './topicService';
+import type { ConsumerGroup, Topic } from '../api/metadata';
+
+export type ResourcePlanResourceType = 'TOPIC' | 'CONSUMER_GROUP';
+export type ResourcePlanAction = 'CREATE' | 'UPDATE' | 'SKIP' | 'CONFLICT' | 
'INVALID';
+
+export interface ResourcePlanTopicSpec {
+  name: string;
+  namespace?: string;
+  clusterId?: string;
+  type?: string;
+  writeQueues?: number;
+  readQueues?: number;
+  perm?: string;
+  remark?: string;
+}
+
+export interface ResourcePlanConsumerGroupSpec {
+  name: string;
+  namespace?: string;
+  clusterId?: string;
+  subscriptionMode?: string;
+  consumeType?: string;
+  subscribedTopics?: string[];
+  subscriptionDataType?: string;
+  deliveryOrderType?: string;
+  retryMaxTimes?: number;
+  delaySeconds?: number;
+}
+
+export interface ResourcePlanRequest {
+  instanceId: string;
+  topics?: ResourcePlanTopicSpec[];
+  consumerGroups?: ResourcePlanConsumerGroupSpec[];
+}
+
+export interface ResourcePlanChange {
+  field: string;
+  currentValue?: string | null;
+  desiredValue?: string | null;
+}
+
+export interface ResourcePlanEntry {
+  resourceType: ResourcePlanResourceType;
+  name: string;
+  rowIndex: number;
+  action: ResourcePlanAction;
+  applicable: boolean;
+  reason: string;
+  changes: ResourcePlanChange[];
+}
+
+export interface ResourcePlanSummary {
+  total: number;
+  creates: number;
+  updates: number;
+  skips: number;
+  conflicts: number;
+  invalids: number;
+  applicable: number;
+}
+
+export interface ResourcePlan {
+  instanceId: string;
+  summary: ResourcePlanSummary;
+  entries: ResourcePlanEntry[];
+}
+
+export interface ResourceBundle {
+  topics?: ResourcePlanTopicSpec[];
+  consumerGroups?: ResourcePlanConsumerGroupSpec[];
+}
+
+export const RESOURCE_PLAN_SAMPLE = JSON.stringify(
+  {
+    topics: [
+      {
+        name: 'order-status-change',
+        namespace: 'trade',
+        type: 'NORMAL',
+        writeQueues: 8,
+        readQueues: 8,
+        perm: 'RW',
+        remark: 'Order status events',
+      },
+      {
+        name: 'payment-callback',
+        namespace: 'trade',
+        type: 'FIFO',
+        writeQueues: 4,
+        readQueues: 4,
+        perm: 'RW',
+        remark: 'Payment callbacks with FIFO order',
+      },
+    ],
+    consumerGroups: [
+      {
+        name: 'cg-order-status-sync',
+        namespace: 'trade',
+        subscriptionMode: 'Push',
+        consumeType: 'CLUSTERING',
+        subscribedTopics: ['order-status-change'],
+        subscriptionDataType: 'NORMAL',
+        retryMaxTimes: 16,
+        delaySeconds: 0,
+      },
+    ],
+  },
+  null,
+  2,
+);
+
+export function parseResourceBundle(text: string): ResourceBundle {
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(text);
+  } catch {
+    throw new Error('Resource bundle must be valid JSON');
+  }
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    throw new Error('Resource bundle must be a JSON object');
+  }
+  const bundle = parsed as ResourceBundle;
+  if (bundle.topics !== undefined && !Array.isArray(bundle.topics)) {
+    throw new Error('topics must be an array');
+  }
+  if (bundle.consumerGroups !== undefined && 
!Array.isArray(bundle.consumerGroups)) {
+    throw new Error('consumerGroups must be an array');
+  }
+  return {
+    topics: bundle.topics ?? [],
+    consumerGroups: bundle.consumerGroups ?? [],
+  };
+}
+
+export async function previewResourcePlan(request: ResourcePlanRequest): 
Promise<ResourcePlan> {
+  if (!request.instanceId?.trim()) throw new Error('instanceId is required');
+  const desiredTopics = request.topics ?? [];
+  const desiredGroups = request.consumerGroups ?? [];
+  const total = desiredTopics.length + desiredGroups.length;
+  if (total === 0) throw new Error('At least one topic or consumer group is 
required');
+  if (total > 200) throw new Error('Resource plan supports at most 200 
resources');
+
+  const [topics, groups] = await Promise.all([
+    listTopics({ instanceId: request.instanceId }),
+    listConsumerGroups({ instanceId: request.instanceId }),
+  ]);
+  const existingTopics = new Map(topics.map((topic) => 
[normalizeName(topic.name), topic]));
+  const existingGroups = new Map(groups.map((group) => 
[normalizeName(group.name), group]));
+  const entries = [
+    ...planTopicEntries(desiredTopics, existingTopics),
+    ...planConsumerGroupEntries(desiredGroups, existingGroups),
+  ];
+
+  return {
+    instanceId: request.instanceId.trim(),
+    summary: summarizeEntries(entries),
+    entries,
+  };
+}
+
+function planTopicEntries(
+  desiredTopics: ResourcePlanTopicSpec[],
+  existingTopics: Map<string, Topic>,
+): ResourcePlanEntry[] {
+  const seen = new Set<string>();
+  return desiredTopics.map((topic, index) => {
+    const name = normalizeName(topic?.name);
+    if (!name) return invalidEntry('TOPIC', name, index, 'Topic name is 
required');
+    if (seen.has(name))
+      return invalidEntry('TOPIC', name, index, 'Duplicate topic in resource 
plan');
+    seen.add(name);
+    if (topic.writeQueues !== undefined && topic.writeQueues < 0) {
+      return invalidEntry('TOPIC', name, index, 'Topic writeQueues must be 
zero or positive');
+    }
+    if (topic.readQueues !== undefined && topic.readQueues < 0) {
+      return invalidEntry('TOPIC', name, index, 'Topic readQueues must be zero 
or positive');
+    }
+
+    const existing = existingTopics.get(name);
+    if (!existing) {
+      return entry(
+        'TOPIC',
+        name,
+        index,
+        'CREATE',
+        true,
+        'Topic does not exist in the selected instance',
+      );
+    }
+    const changes = collectChanges([
+      ['namespace', existing.namespace, topic.namespace],
+      ['clusterId', existing.clusterId, topic.clusterId],
+      ['type', existing.type, topic.type],
+      ['writeQueues', existing.writeQueues, topic.writeQueues],
+      ['readQueues', existing.readQueues, topic.readQueues],
+      ['perm', existing.perm, topic.perm],
+      ['remark', existing.remark, topic.remark],
+    ]);
+    if (changes.length === 0) {
+      return entry(
+        'TOPIC',
+        name,
+        index,
+        'SKIP',
+        false,
+        'Topic already matches the desired configuration',
+      );
+    }
+    return entry(
+      'TOPIC',
+      name,
+      index,
+      'UPDATE',
+      true,
+      'Topic exists with different configuration',
+      changes,
+    );
+  });
+}
+
+function planConsumerGroupEntries(
+  desiredGroups: ResourcePlanConsumerGroupSpec[],
+  existingGroups: Map<string, ConsumerGroup>,
+): ResourcePlanEntry[] {
+  const seen = new Set<string>();
+  return desiredGroups.map((group, index) => {
+    const name = normalizeName(group?.name);
+    if (!name)
+      return invalidEntry('CONSUMER_GROUP', name, index, 'Consumer group name 
is required');
+    if (seen.has(name)) {
+      return invalidEntry(
+        'CONSUMER_GROUP',
+        name,
+        index,
+        'Duplicate consumer group in resource plan',
+      );
+    }
+    seen.add(name);
+    if (group.retryMaxTimes !== undefined && group.retryMaxTimes < 0) {
+      return invalidEntry(
+        'CONSUMER_GROUP',
+        name,
+        index,
+        'Consumer group retryMaxTimes must be zero or positive',
+      );
+    }
+    if (group.delaySeconds !== undefined && group.delaySeconds < 0) {
+      return invalidEntry(
+        'CONSUMER_GROUP',
+        name,
+        index,
+        'Consumer group delaySeconds must be zero or positive',
+      );
+    }
+
+    const existing = existingGroups.get(name);
+    if (!existing) {
+      return entry(
+        'CONSUMER_GROUP',
+        name,
+        index,
+        'CREATE',
+        true,
+        'Consumer group does not exist in the selected instance',
+      );
+    }
+    const changes = collectChanges([
+      ['namespace', existing.namespace, group.namespace],
+      ['clusterId', existing.clusterId, group.clusterId],
+      ['subscriptionMode', existing.subscriptionMode, group.subscriptionMode],
+      ['consumeType', existing.consumeType, group.consumeType],
+      [
+        'subscribedTopics',
+        sortedTopics(existing.subscribedTopics),
+        sortedTopics(group.subscribedTopics),
+      ],
+      ['subscriptionDataType', existing.subscriptionDataType, 
group.subscriptionDataType],
+      ['deliveryOrderType', existing.deliveryOrderType, 
group.deliveryOrderType],
+      ['retryMaxTimes', existing.retryMaxTimes, group.retryMaxTimes],
+      ['delaySeconds', existing.delaySeconds, group.delaySeconds],
+    ]);
+    if (changes.length === 0) {
+      return entry(
+        'CONSUMER_GROUP',
+        name,
+        index,
+        'SKIP',
+        false,
+        'Consumer group already matches the desired configuration',
+      );
+    }
+    return entry(
+      'CONSUMER_GROUP',
+      name,
+      index,
+      'CONFLICT',
+      false,
+      'Consumer group exists with unsupported in-place changes',
+      changes,
+    );
+  });
+}
+
+function collectChanges(rows: Array<[string, unknown, unknown]>): 
ResourcePlanChange[] {
+  return rows
+    .filter(([, , desiredValue]) => desiredValue !== undefined)
+    .filter(
+      ([, currentValue, desiredValue]) => comparable(currentValue) !== 
comparable(desiredValue),
+    )
+    .map(([field, currentValue, desiredValue]) => ({
+      field,
+      currentValue: currentValue == null ? null : String(currentValue),
+      desiredValue: desiredValue == null ? null : String(desiredValue),
+    }));
+}
+
+function summarizeEntries(entries: ResourcePlanEntry[]): ResourcePlanSummary {
+  return entries.reduce<ResourcePlanSummary>(
+    (summary, item) => {
+      summary.total++;
+      if (item.applicable) summary.applicable++;
+      if (item.action === 'CREATE') summary.creates++;
+      if (item.action === 'UPDATE') summary.updates++;
+      if (item.action === 'SKIP') summary.skips++;
+      if (item.action === 'CONFLICT') summary.conflicts++;
+      if (item.action === 'INVALID') summary.invalids++;
+      return summary;
+    },
+    { total: 0, creates: 0, updates: 0, skips: 0, conflicts: 0, invalids: 0, 
applicable: 0 },
+  );
+}
+
+function invalidEntry(
+  resourceType: ResourcePlanResourceType,
+  name: string,
+  index: number,
+  reason: string,
+): ResourcePlanEntry {
+  return entry(resourceType, name, index, 'INVALID', false, reason);
+}
+
+function entry(
+  resourceType: ResourcePlanResourceType,
+  name: string,
+  index: number,
+  action: ResourcePlanAction,
+  applicable: boolean,
+  reason: string,
+  changes: ResourcePlanChange[] = [],
+): ResourcePlanEntry {
+  return {
+    resourceType,
+    name,
+    rowIndex: index + 1,
+    action,
+    applicable,
+    reason,
+    changes,
+  };
+}
+
+function normalizeName(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : '';
+}
+
+function comparable(value: unknown): string | null {
+  return value == null ? null : String(value).trim();
+}
+
+function sortedTopics(topics?: string[]): string | undefined {
+  return topics
+    ?.filter((topic) => topic.trim())
+    .map((topic) => topic.trim())
+    .sort()
+    .join(',');
+}

Reply via email to