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