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 6dd02d275 feat(web): surface the naming rule on the create forms and
document the model (#7354)
6dd02d275 is described below
commit 6dd02d2759e7c4bb6b7ba34f6a768628f03377f1
Author: lizhimins <[email protected]>
AuthorDate: Mon Oct 26 15:05:15 2026 +0800
feat(web): surface the naming rule on the create forms and document the
model (#7354)
Add a nameserver selector and column to the instance page, a resource model
page
with the ownership topology, and a question-mark help modal on the create
instance, topic and group forms that spells out the three creation outcomes.
The creation-decision flow diagram is deliberately left out of the page: it
read
as a backend implementation detail, so the rule now lives where creation
happens.
---
web/src/App.tsx | 2 +
web/src/api/instance.ts | 3 +
web/src/components/ResourceRuleHelp.tsx | 120 +++++++
.../components/__tests__/ResourceRuleHelp.test.tsx | 125 +++++++
web/src/i18n/translations.ts | 133 ++++++++
web/src/layouts/MainLayout.tsx | 7 +
.../pages/instance/__tests__/ConsumerPage.test.tsx | 15 +
.../pages/instance/__tests__/InstancePage.test.tsx | 40 ++-
.../pages/instance/__tests__/TopicPage.test.tsx | 12 +
web/src/pages/instance/consumer.tsx | 8 +-
web/src/pages/instance/index.tsx | 79 +++++
web/src/pages/instance/topic.tsx | 12 +-
web/src/pages/studio/ResourceModel.tsx | 370 +++++++++++++++++++++
.../pages/studio/__tests__/ResourceModel.test.tsx | 122 +++++++
14 files changed, 1042 insertions(+), 6 deletions(-)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 4e7284fe3..aaaef366a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -55,6 +55,7 @@ const ProducerPage = lazy(() =>
import('./pages/studio/Producer'));
const OpsPage = lazy(() => import('./pages/studio/Ops'));
const AlertRuleAssetsPage = lazy(() =>
import('./pages/studio/AlertRuleAssets'));
const UserManagementPage = lazy(() => import('./pages/studio/UserManagement'));
+const ResourceModelPage = lazy(() => import('./pages/studio/ResourceModel'));
type AuthGateState = 'checking' | 'allowed' | 'denied' | 'error';
@@ -236,6 +237,7 @@ function App() {
element={<Navigate to="/ops/business-alerts" replace />}
/>
<Route path="studio/users" element={<UserManagementPage />} />
+ <Route path="studio/resource-model" element={<ResourceModelPage
/>} />
<Route path="ops/alert-rule-templates"
element={<AlertRuleAssetsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 6d17aefef..90e0248c5 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -42,6 +42,8 @@ export interface Instance {
adminCredentialRef?: string;
regionId?: string;
regionName?: string;
+ nameserverId?: number | null;
+ nameserverName?: string | null;
topicCount: number;
consumerGroupCount: number;
resourceCountsAvailable?: boolean;
@@ -59,6 +61,7 @@ export interface CreateInstanceRequest {
credentialId?: number;
adminCredentialRef?: string;
regionId?: string;
+ nameserverId?: number;
}
export interface UpdateInstanceRequest {
diff --git a/web/src/components/ResourceRuleHelp.tsx
b/web/src/components/ResourceRuleHelp.tsx
new file mode 100644
index 000000000..85302aa43
--- /dev/null
+++ b/web/src/components/ResourceRuleHelp.tsx
@@ -0,0 +1,120 @@
+/*
+ * 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 { useState } from 'react';
+import { Button, Modal, Space, Tooltip, Typography, theme } from 'antd';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import { useLang } from '../i18n/LangContext';
+
+const { Text } = Typography;
+
+const FONT_SIZE = 14;
+
+/**
+ * 创建实例 / Topic / Group 表单上的规则入口:问号点开弹窗,讲清名字的唯一性边界与
+ * 创建时的三种结果。文案与「资源模型」页共用 `resourceModel.*` 一套口径。
+ */
+const ResourceRuleHelp = () => {
+ const { t } = useLang();
+ const { token } = theme.useToken();
+ const [open, setOpen] = useState(false);
+
+ const outcomes = [
+ {
+ color: token.colorSuccess,
+ title: t('resourceModel.outcomeFreeTitle'),
+ desc: t('resourceModel.outcomeFreeDesc'),
+ },
+ {
+ color: token.colorPrimary,
+ title: t('resourceModel.outcomeOwnTitle'),
+ desc: t('resourceModel.outcomeOwnDesc'),
+ },
+ {
+ color: token.colorError,
+ title: t('resourceModel.outcomeOtherTitle'),
+ desc: t('resourceModel.outcomeOtherDesc'),
+ },
+ ];
+
+ return (
+ <>
+ <Tooltip title={t('resourceModel.helpTooltip')}>
+ <span
+ role="button"
+ tabIndex={0}
+ aria-label={t('resourceModel.helpAria')}
+ onClick={(event) => {
+ // The icon sits inside a Form.Item label, whose default click
behaviour focuses the
+ // field - on a Select that would pop the dropdown open behind the
modal.
+ event.preventDefault();
+ setOpen(true);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ setOpen(true);
+ }}
+ style={{ display: 'inline-flex', cursor: 'pointer' }}
+ >
+ <QuestionCircleOutlined style={{ color: token.colorTextTertiary,
fontSize: FONT_SIZE }} />
+ </span>
+ </Tooltip>
+
+ <Modal
+ title={t('resourceModel.modalTitle')}
+ open={open}
+ width={520}
+ onCancel={() => setOpen(false)}
+ footer={<Button onClick={() =>
setOpen(false)}>{t('common.close')}</Button>}
+ >
+ <Space direction="vertical" size={14} style={{ width: '100%',
marginTop: 8 }}>
+ <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+ {t('resourceModel.modalIntro')}
+ </Text>
+ {outcomes.map((outcome) => (
+ <Space key={outcome.title} align="start" size={10}>
+ <span
+ aria-hidden
+ style={{
+ width: 8,
+ height: 8,
+ marginTop: 7,
+ borderRadius: '50%',
+ background: outcome.color,
+ flexShrink: 0,
+ }}
+ />
+ <div>
+ <Text strong style={{ fontSize: FONT_SIZE }}>
+ {outcome.title}
+ </Text>
+ <div>
+ <Text type="secondary" style={{ fontSize: FONT_SIZE,
lineHeight: 1.7 }}>
+ {outcome.desc}
+ </Text>
+ </div>
+ </div>
+ </Space>
+ ))}
+ </Space>
+ </Modal>
+ </>
+ );
+};
+
+export default ResourceRuleHelp;
diff --git a/web/src/components/__tests__/ResourceRuleHelp.test.tsx
b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
new file mode 100644
index 000000000..afbf21736
--- /dev/null
+++ b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
@@ -0,0 +1,125 @@
+/*
+ * 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 { LangProvider } from '../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../i18n/languagePreference';
+import translations from '../../i18n/translations';
+import ResourceRuleHelp from '../ResourceRuleHelp';
+
+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 renderHelp = (lang: 'zh' | 'en') => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+ return render(
+ <App>
+ <LangProvider>
+ <ResourceRuleHelp />
+ </LangProvider>
+ </App>,
+ );
+};
+
+const OUTCOME_KEYS = [
+ 'resourceModel.outcomeFreeTitle',
+ 'resourceModel.outcomeOwnTitle',
+ 'resourceModel.outcomeOtherTitle',
+] as const;
+
+describe('ResourceRuleHelp', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('keeps the rule hidden until the question mark is used', async () => {
+ const user = userEvent.setup();
+ renderHelp('zh');
+ expect(screen.queryByText('创建时的归属规则')).toBeNull();
+
+ await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+ });
+
+ it('opens from the keyboard so the form stays reachable without a mouse',
async () => {
+ const user = userEvent.setup();
+ renderHelp('zh');
+ const trigger = screen.getByRole('button', { name: '查看资源命名与归属规则' });
+ trigger.focus();
+ await user.keyboard('{Enter}');
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+ });
+
+ it('spells out the three creation outcomes', async () => {
+ const user = userEvent.setup();
+ renderHelp('zh');
+ await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+
+ OUTCOME_KEYS.forEach((key) => {
+ expect(screen.getByText(translations[key].zh)).toBeInTheDocument();
+ });
+
expect(screen.getByText(translations['resourceModel.outcomeOtherDesc'].zh)).toBeInTheDocument();
+ });
+
+ it.each(['zh', 'en'] as const)(
+ 'resolves every %s label without leaking i18n keys',
+ async (lang) => {
+ const user = userEvent.setup();
+ const { baseElement } = renderHelp(lang);
+ await user.click(
+ screen.getByRole('button', { name:
translations['resourceModel.helpAria'][lang] }),
+ );
+
+ expect(
+ await
screen.findByText(translations['resourceModel.modalTitle'][lang]),
+ ).toBeInTheDocument();
+ expect(baseElement.textContent).not.toContain('resourceModel.');
+ },
+ );
+
+ it('hands the modal back to antd to close from the footer button', async ()
=> {
+ const user = userEvent.setup();
+ renderHelp('zh');
+ await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+
+ // antd pads a two-character CJK button label with a space, so match it
loosely.
+ await user.click(screen.getByRole('button', { name: /关\s*闭/ }));
+ // jsdom never fires animationend, so a closed antd modal stays mounted
mid-leave instead of
+ // leaving the accessibility tree. The leave class on the mask is the
observable proof that
+ // `open` flipped back to false.
+ await waitFor(() =>
+
expect(document.querySelector('.ant-modal-mask')?.className).toContain('ant-fade-leave'),
+ );
+ });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index de81f7b05..dc4d861b4 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -40,6 +40,7 @@ const translations: Record<string, Record<Lang, string>> = {
'nav.grafanaDashboards': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
'nav.ai': { zh: 'AI 交互', en: 'AI Chat' },
'nav.settings': { zh: '设置', en: 'Settings' },
+ 'nav.resourceModel': { zh: '资源模型', en: 'Resource Model' },
// ─── Common ───
'common.search': { zh: '搜索...', en: 'Search...' },
@@ -463,6 +464,23 @@ const translations: Record<string, Record<Lang, string>> =
{
en: 'No {vendor} credentials. ',
},
'instance.addInSettings': { zh: '去设置中添加', en: 'Add in settings' },
+ 'instance.nameserver': { zh: 'nameserver 集群', en: 'NameServer cluster' },
+ 'instance.nameserverRequired': {
+ zh: '请选择 nameserver 集群',
+ en: 'Select a NameServer cluster',
+ },
+ 'instance.nameserverExtra': {
+ zh: '实例的名字唯一性以该集群为边界,创建后不可更改',
+ en: 'Name uniqueness is scoped to this cluster and the binding cannot
change later',
+ },
+ 'instance.nameserverImmutable': {
+ zh: '创建时绑定,不可更改',
+ en: 'Bound at creation, cannot be changed',
+ },
+ 'instance.selectNameserver': { zh: '选择 nameserver 集群', en: 'Select a
NameServer cluster' },
+ 'instance.noNameserver': { zh: '还没有注册 nameserver,', en: 'No NameServer
registered yet, ' },
+ 'instance.addInClusterPage': { zh: '去集群管理注册', en: 'register one in Cluster
Management' },
+ 'instance.nameserverColumn': { zh: 'nameserver 集群', en: 'NameServer cluster'
},
'instance.regionRequired': { zh: '请选择地域', en: 'Select a region' },
'instance.selectRegion': { zh: '选择地域', en: 'Select a region' },
'instance.cloudInstance': { zh: '云上实例', en: 'Cloud Instance' },
@@ -3980,6 +3998,121 @@ const translations: Record<string, Record<Lang,
string>> = {
zh: 'Claude / Titan / Llama (AWS)',
en: 'Claude / Titan / Llama (AWS)',
},
+ // ─── ResourceModel(资源命名与归属:说明页 + 创建表单的规则弹窗)───
+ 'resourceModel.title': { zh: '资源模型', en: 'Resource Model' },
+ 'resourceModel.banner': {
+ zh:
+ 'Studio 把每一组 nameserver 看作一套独立的环境,例如生产与预发各用一组。Topic 与 Group 的名字' +
+ '在同一套环境里的同一个 broker 集群上唯一;换一套环境、或换同一套环境里的另一个 broker 集群,' +
+ '都可以继续使用相同的名字。',
+ en:
+ 'Studio treats each set of NameServers as one independent environment,
for example one for production and ' +
+ 'one for staging. A topic or group name is unique on one broker cluster
within one environment; another ' +
+ 'environment, or another broker cluster in the same environment, may use
the same name again.',
+ },
+
+ 'resourceModel.archTitle': { zh: '命名与归属', en: 'Naming and ownership' },
+ 'resourceModel.svgTopologyAria': {
+ zh: '生产与预发两套 nameserver 环境下 Topic 名字的归属示意',
+ en: 'Topic ownership across the production and staging NameServer
environments',
+ },
+ 'resourceModel.svgDomainProd': {
+ zh: 'nameserver 集群「生产」',
+ en: 'NameServer cluster "production"',
+ },
+ 'resourceModel.svgDomainStaging': {
+ zh: 'nameserver 集群「预发」',
+ en: 'NameServer cluster "staging"',
+ },
+ 'resourceModel.svgChipOrdersTrade': { zh: 'orders · 交易', en: 'orders ·
trade' },
+ 'resourceModel.svgChipPaymentsTrade': { zh: 'payments · 交易', en: 'payments ·
trade' },
+ 'resourceModel.svgChipOrdersLogistics': { zh: 'orders · 物流', en: 'orders ·
logistics' },
+ 'resourceModel.svgChipOrdersStaging': { zh: 'orders · 预发', en: 'orders ·
staging' },
+ 'resourceModel.svgMergeNote1': {
+ zh: '同一个名字用在生产的两个 broker 集群上时,路由会合并:',
+ en: 'One name on both production clusters merges routes:',
+ },
+ 'resourceModel.svgMergeNote2': {
+ zh: '客户端的消息会在两个 broker 集群之间负载均衡',
+ en: 'clients load-balance messages across both clusters',
+ },
+ 'resourceModel.svgInstTrade': { zh: '实例「交易」', en: 'Instance "trade"' },
+ 'resourceModel.svgInstLogistics': { zh: '实例「物流」', en: 'Instance "logistics"'
},
+ 'resourceModel.svgInstStaging': { zh: '实例「预发」', en: 'Instance "staging"' },
+ 'resourceModel.svgBoundNote': { zh: '创建时绑定,不可更改', en: 'fixed at creation' },
+ 'resourceModel.svgConflictLine1': {
+ zh: '实例「物流」在 broker-a 上创建 orders',
+ en: 'Instance "logistics" creates orders on broker-a',
+ },
+ 'resourceModel.svgConflictLine2': {
+ zh: 'Studio 会提示这个名字已经属于实例「交易」',
+ en: 'Studio reports the name belongs to Instance "trade"',
+ },
+ 'resourceModel.svgCrossDomainNote': {
+ zh: '另一套环境,名字可以重复',
+ en: 'Names may repeat here',
+ },
+ 'resourceModel.svgCrossDomainNote2': {
+ zh: '路由各自独立,不会合并',
+ en: 'routes stay separate, never merged',
+ },
+ 'resourceModel.svgCrossLine1': {
+ zh: '预发的 orders 与生产的 orders 同名',
+ en: 'staging orders shares the name',
+ },
+ 'resourceModel.svgCrossLine2': { zh: '两者各自独立,互不影响', en: 'The two stay
independent' },
+
+ 'resourceModel.rulesTitle': { zh: '归属规则', en: 'Ownership rules' },
+ 'resourceModel.rule1': {
+ zh: '· 一个名字在同一个 broker 集群上只属于一个实例。如果它已经属于别的实例,创建时会告诉你它属于谁。',
+ en: '· A name belongs to one instance per broker cluster. If another
instance already uses it, the create form tells you which one.',
+ },
+ 'resourceModel.rule2': {
+ zh: '· 同一个实例再次创建同名资源,相当于按这次填写的队列数等参数更新它。',
+ en: '· Creating the same name again from the owning instance updates it
with the queue counts and other settings you submit.',
+ },
+ 'resourceModel.rule3': {
+ zh: '· 实例在创建时选定 nameserver 集群,之后不能更改;直连模式的接入地址必须始终指向该集群。',
+ en: '· An instance picks its NameServer cluster at creation and keeps it
forever; a direct endpoint must always address that same cluster.',
+ },
+ 'resourceModel.rule4': {
+ zh: '· 云实例(阿里云、腾讯云)各自是一套独立的环境,与开源实例之间不会出现名字冲突。',
+ en: '· Cloud instances (Alibaba Cloud, Tencent Cloud) are each an
environment of their own and never collide with open-source instances.',
+ },
+
+ 'resourceModel.helpTooltip': { zh: '资源命名与归属规则', en: 'Naming and ownership
rules' },
+ 'resourceModel.helpAria': {
+ zh: '查看资源命名与归属规则',
+ en: 'Show the naming and ownership rules',
+ },
+ 'resourceModel.modalTitle': { zh: '创建时的归属规则', en: 'Ownership rules when you
create' },
+ 'resourceModel.modalIntro': {
+ zh:
+ '一个名字在「nameserver 集群 + broker 集群」这个范围内唯一。实例在创建时选定 nameserver ' +
+ '集群,之后不能更改;云实例各自是一套独立的环境。',
+ en:
+ 'A name is unique within one NameServer cluster plus one broker cluster.
An instance picks its ' +
+ 'NameServer cluster at creation and keeps it forever; each cloud
instance is an environment of its own.',
+ },
+ 'resourceModel.outcomeFreeTitle': { zh: '这个名字还没有人使用', en: 'The name is not
used yet' },
+ 'resourceModel.outcomeFreeDesc': { zh: '正常创建。', en: 'It is created.' },
+ 'resourceModel.outcomeOwnTitle': {
+ zh: '这个名字已经属于当前实例',
+ en: 'The name already belongs to this instance',
+ },
+ 'resourceModel.outcomeOwnDesc': {
+ zh: '相当于按这次填写的队列数等参数更新它。',
+ en: 'It is updated with the queue counts and other settings you submit.',
+ },
+ 'resourceModel.outcomeOtherTitle': {
+ zh: '这个名字已经属于其他实例',
+ en: 'The name already belongs to another instance',
+ },
+ 'resourceModel.outcomeOtherDesc': {
+ zh: 'Studio 会告诉你它属于哪个实例,并停止本次创建。',
+ en: 'Studio tells you which instance owns it and stops this creation.',
+ },
+
// ─── BrokerCluster ───
};
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 5a2344998..7d26943c1 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -40,6 +40,7 @@ import {
PaperPlaneTilt,
Notebook,
Warning,
+ TreeStructure,
} from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
import { useTheme } from '../theme/useTheme';
@@ -233,6 +234,11 @@ const MainLayout = () => {
label: t('nav.alertDeliveries'),
},
{ key: '/ops/dashboard', icon: <ChartBar size={16} />, label:
t('nav.dashboard') },
+ {
+ key: '/studio/resource-model',
+ icon: <TreeStructure size={16} />,
+ label: t('nav.resourceModel'),
+ },
],
},
{
@@ -274,6 +280,7 @@ const MainLayout = () => {
'/ai': t('nav.ai'),
'/settings': t('nav.settings'),
'/studio/users': t('userMgmt.title'),
+ '/studio/resource-model': t('nav.resourceModel'),
}),
[t],
);
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index b7e49403d..c48281ad0 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -543,6 +543,21 @@ describe('Consumer page', () => {
confirmSpy.mockRestore();
});
+ // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+ it('explains the naming rule from a question mark on the create form', async
() => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderWithProviders(<ConsumerPage />);
+
+ await screen.findByText('remote-cg');
+ const createButton = screen.getByRole('button', { name: '创建 Group' });
+ await waitFor(() => expect(createButton).toBeEnabled());
+ await user.click(createButton);
+ const dialog = await screen.findByRole('dialog');
+
+ await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则'
}));
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+ });
+
it('submits the canonical global delivery order type', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 2f07260e4..22ae808e3 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import * as aliyunCatalogApi from '../../../api/aliyunCatalog';
+import * as clusterApi from '../../../api/cluster';
import * as cloudCredentialApi from '../../../api/cloudCredential';
import * as tencentCatalogApi from '../../../api/tencentCatalog';
import type { CloudCredential, CloudCredentialPage } from
'../../../api/cloudCredential';
@@ -39,6 +40,9 @@ vi.mock('../../../api/cloudCredential', () => ({
listCloudCredentials: vi.fn(),
}));
+vi.mock('../../../api/cluster', () => ({
+ listNameserverRegistry: vi.fn(),
+}));
vi.mock('../../../api/tencentCatalog', () => ({
listTencentInstances: vi.fn(),
listTencentRegions: vi.fn(),
@@ -123,6 +127,19 @@ describe('InstancePage', () => {
vi.mocked(aliyunCatalogApi.listAliyunInstances).mockResolvedValue([]);
vi.mocked(tencentCatalogApi.listTencentRegions).mockResolvedValue([]);
vi.mocked(tencentCatalogApi.listTencentInstances).mockResolvedValue([]);
+ vi.mocked(clusterApi.listNameserverRegistry).mockResolvedValue([
+ {
+ id: 7,
+ name: 'ns-test',
+ namesrvAddr: 'ns-test:9876',
+ k8sNamespace: null,
+ k8sId: null,
+ status: 'healthy',
+ description: null,
+ gmtCreate: null,
+ gmtModified: null,
+ },
+ ]);
vi.mocked(instanceService.listInstances).mockResolvedValue([
instance(1, 'production-proxy'),
instance(2, 'development-direct', 'DIRECT'),
@@ -351,6 +368,19 @@ describe('InstancePage', () => {
expect(await screen.findByText('云凭据列表加载失败')).toBeInTheDocument();
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalledTimes(1);
});
+
+ // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+ it('offersTheNamingRuleFromTheCreateInstanceFormTest', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('production-proxy');
+ await user.click(screen.getByRole('button', { name: /添加实例/ }));
+ const dialog = await screen.findByRole('dialog');
+
+ await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则'
}));
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+ });
+
it('loads server-filtered results when the search or type changes', async ()
=> {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderPage();
@@ -496,7 +526,7 @@ describe('InstancePage', () => {
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
- const createTypeSelect = within(dialog).getByRole('combobox');
+ const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
fireEvent.mouseDown(createTypeSelect.parentElement!);
const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
selector: '.ant-select-item-option-content',
@@ -532,7 +562,7 @@ describe('InstancePage', () => {
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
- const createTypeSelect = within(dialog).getByRole('combobox');
+ const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
fireEvent.mouseDown(createTypeSelect.parentElement!);
const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
selector: '.ant-select-item-option-content',
@@ -546,6 +576,7 @@ describe('InstancePage', () => {
name: 'new-proxy',
type: 'PROXY_CLUSTER',
endpoint: 'proxy-new:8080',
+ nameserverId: 7,
}),
);
expect(instanceService.listInstances).toHaveBeenLastCalledWith({
@@ -565,7 +596,7 @@ describe('InstancePage', () => {
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
await user.type(within(dialog).getByLabelText('实例 ID'), 'local-proxy');
- const createTypeSelect = within(dialog).getByRole('combobox');
+ const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
fireEvent.mouseDown(createTypeSelect.parentElement!);
await user.click(
await screen.findByText('Proxy Local 模式', {
@@ -580,6 +611,7 @@ describe('InstancePage', () => {
name: 'local-proxy',
type: 'PROXY_LOCAL',
endpoint: 'broker-proxy:8080',
+ nameserverId: 7,
}),
);
});
@@ -697,7 +729,7 @@ describe('InstancePage', () => {
await user.click(screen.getByRole('button', { name: /添加实例/ }));
const dialog = await screen.findByRole('dialog');
await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
- const createTypeSelect = within(dialog).getByRole('combobox');
+ const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
fireEvent.mouseDown(createTypeSelect.parentElement!);
const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
selector: '.ant-select-item-option-content',
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index e3344974e..512d85264 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -300,6 +300,18 @@ describe('TopicPage', () => {
expect(within(dialog).getAllByText(/每个 Broker 节点写/).length).toBe(1);
});
+ // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+ it('explains the naming rule from a question mark on the create form', async
() => {
+ renderWithProviders('/instance/instance-proxy-1/topic');
+ await screen.findByText('topic-01');
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+
+ await user.click(screen.getByRole('button', { name: /创建 Topic/ }));
+ const dialog = await screen.findByRole('dialog', { name: /创建 Topic/ });
+ await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则'
}));
+ expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+ });
+
it('gives each of the four row actions its own color', async () => {
renderWithProviders('/instance/instance-proxy-1/topic');
await screen.findByText('topic-01');
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 1864c020c..febc23a3a 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -67,6 +67,7 @@ import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { InstanceSelect } from '../../components/InstanceSelect';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
import { formatDateTime, formatDelay, formatUtcDateTime } from
'../../utils/format';
@@ -2469,7 +2470,12 @@ const ConsumerPageContent = ({
}}
>
<Form.Item
- label="Group 名称"
+ label={
+ <Space size={4}>
+ Group 名称
+ <ResourceRuleHelp />
+ </Space>
+ }
name="name"
rules={[
{ required: true, message: '请输入 Group 名称' },
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 85d997688..66f830b24 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -40,6 +40,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { SortOrder } from 'antd/es/table/interface';
import type { Instance, InstanceQuery } from '../../api/instance';
import { listCloudCredentials, type CloudCredential } from
'../../api/cloudCredential';
+import { listNameserverRegistry, type NameserverRegistryEntry } from
'../../api/cluster';
import {
listAliyunInstances,
listAliyunRegions,
@@ -50,6 +51,7 @@ import { listTencentInstances, listTencentRegions } from
'../../api/tencentCatal
import { describeApiError } from '../../utils/apiError';
import { formatDateTime } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
import {
createInstance,
deleteInstance,
@@ -140,6 +142,8 @@ const InstancePage = () => {
const [cloudInstances, setCloudInstances] =
useState<CloudInstanceOption[]>([]);
const [cloudInstancesLoading, setCloudInstancesLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
+ const [nameservers, setNameservers] =
useState<NameserverRegistryEntry[]>([]);
+ const [nameserversLoading, setNameserversLoading] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance |
null>(null);
const [editForm] = Form.useForm();
const editInstanceType = Form.useWatch<Instance['type'] | undefined>('type',
editForm);
@@ -193,6 +197,38 @@ const InstancePage = () => {
const cloudVendor = vendor === 'ALIYUN' || vendor === 'TENCENT';
+ useEffect(() => {
+ if (nameservers.length === 1 && addForm.getFieldValue('nameserverId') ==
null) {
+ addForm.setFieldsValue({ nameserverId: nameservers[0].id });
+ }
+ }, [nameservers, addForm]);
+
+ useEffect(() => {
+ if (!(addModalOpen || editModalOpen) || cloudVendor) {
+ return;
+ }
+ let active = true;
+ // Deferred like the credential loader: a synchronous setState here trips
the
+ // react-hooks lint rule about cascading renders inside effect bodies.
+ const timer = window.setTimeout(() => {
+ setNameserversLoading(true);
+ listNameserverRegistry()
+ .then((entries) => {
+ if (active) setNameservers(entries);
+ })
+ .catch(() => {
+ if (active) setNameservers([]);
+ })
+ .finally(() => {
+ if (active) setNameserversLoading(false);
+ });
+ }, 0);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
+ }, [addModalOpen, editModalOpen, cloudVendor]);
+
useEffect(() => {
if (!cloudVendor || !addModalOpen) {
const timer = window.setTimeout(() => setCredentialsLoading(false), 0);
@@ -619,6 +655,14 @@ const InstancePage = () => {
return <Tag color={config.color}>{typeLabel[type] ? t(config.labelKey)
: type}</Tag>;
},
},
+ {
+ title: t('instance.nameserverColumn'),
+ dataIndex: 'nameserverName',
+ key: 'nameserverName',
+ width: 140,
+ render: (name: string | null, record: Instance) =>
+ record.vendor === 'ALIYUN' || record.vendor === 'TENCENT' ? '-' :
(name ?? '-'),
+ },
{
title: 'Topic',
dataIndex: 'topicCount',
@@ -964,6 +1008,36 @@ const InstancePage = () => {
>
<Input placeholder={t('instance.namePlaceholder')} />
</Form.Item>
+ <Form.Item
+ label={
+ <Space size={4}>
+ {t('instance.nameserver')}
+ <ResourceRuleHelp />
+ </Space>
+ }
+ name="nameserverId"
+ rules={[{ required: true, message:
t('instance.nameserverRequired') }]}
+ extra={t('instance.nameserverExtra')}
+ >
+ <Select
+ placeholder={t('instance.selectNameserver')}
+ loading={nameserversLoading}
+ notFoundContent={
+ nameserversLoading ? (
+ t('instance.loading')
+ ) : (
+ <span>
+ {t('instance.noNameserver')}
+ <Link
to="/cluster">{t('instance.addInClusterPage')}</Link>
+ </span>
+ )
+ }
+ options={nameservers.map((entry) => ({
+ value: entry.id,
+ label: `${entry.name}(${entry.namesrvAddr})`,
+ }))}
+ />
+ </Form.Item>
<Form.Item
label={t('instance.accessType')}
name="type"
@@ -1027,6 +1101,11 @@ const InstancePage = () => {
<Form.Item label={t('instance.instanceName')}>
<Input value={editingInstance?.name} disabled />
</Form.Item>
+ {editingInstance?.vendor !== 'ALIYUN' && editingInstance?.vendor !==
'TENCENT' && (
+ <Form.Item label={t('instance.nameserver')}
extra={t('instance.nameserverImmutable')}>
+ <Input value={editingInstance?.nameserverName ?? '-'} disabled />
+ </Form.Item>
+ )}
<Form.Item
label={t('instance.accessType')}
name="type"
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 76b6dd88e..ad75998a3 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -59,6 +59,7 @@ import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
import TopicConfigComparisonModal from
'../../components/TopicConfigComparisonModal';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, CLUSTER_TYPE_MAP } from '../../constants/theme';
import type { Topic, BrokerRoute, ConsumerGroupInfo, TopicConsumerPage } from
'../../api/metadata';
@@ -1526,7 +1527,16 @@ const TopicPageContent = ({
style={{ marginTop: 16 }}
>
<Form.Item
- label="Topic 名称"
+ label={
+ editingTopic ? (
+ 'Topic 名称'
+ ) : (
+ <Space size={4}>
+ Topic 名称
+ <ResourceRuleHelp />
+ </Space>
+ )
+ }
name="name"
rules={[
{ required: true, message: '请输入 Topic 名称' },
diff --git a/web/src/pages/studio/ResourceModel.tsx
b/web/src/pages/studio/ResourceModel.tsx
new file mode 100644
index 000000000..963dc5791
--- /dev/null
+++ b/web/src/pages/studio/ResourceModel.tsx
@@ -0,0 +1,370 @@
+/*
+ * 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 React from 'react';
+import { Card, Space, theme, Typography } from 'antd';
+import { TreeStructure } from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+import { useTheme } from '../../theme/useTheme';
+import InfoBanner from '../../components/InfoBanner';
+
+const { Text, Title } = Typography;
+
+const FONT_SIZE = 14;
+
+/**
+ * 页面专用色板(用户指定 2026-10-23)。颜色只用于描边、浅色填充、圆点与标题下划线,
+ * 正文文字仍走 antd token 保证对比度;深色模式换一组提亮变体。
+ */
+const PALETTE = {
+ light: {
+ red: '#D4042D',
+ blue: '#5A92E5',
+ olive: '#5E8034',
+ teal: '#41B5C2',
+ purple: '#722169',
+ },
+ dark: {
+ red: '#FF7A8C',
+ blue: '#8FB9F2',
+ olive: '#A9C47E',
+ teal: '#7FD4DE',
+ purple: '#C993C0',
+ },
+};
+
+type PaletteKey = keyof typeof PALETTE.light;
+
+interface SvgBoxProps {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ stroke: string;
+ fill?: string;
+ radius?: number;
+}
+
+const SvgBox: React.FC<SvgBoxProps> = ({ x, y, width, height, stroke, fill,
radius = 10 }) => (
+ <rect
+ x={x}
+ y={y}
+ width={width}
+ height={height}
+ rx={radius}
+ fill={fill ?? 'transparent'}
+ stroke={stroke}
+ strokeWidth={1}
+ />
+);
+
+interface SvgLabelProps {
+ x: number;
+ y: number;
+ fill: string;
+ children: React.ReactNode;
+ bold?: boolean;
+ muted?: boolean;
+ anchor?: 'start' | 'middle';
+}
+
+const SvgLabel: React.FC<SvgLabelProps> = ({
+ x,
+ y,
+ fill,
+ children,
+ bold,
+ muted,
+ anchor = 'start',
+}) => (
+ <text
+ x={x}
+ y={y}
+ fill={fill}
+ fontSize={FONT_SIZE}
+ fontWeight={bold ? 600 : 400}
+ fontStyle={muted ? 'italic' : undefined}
+ textAnchor={anchor}
+ >
+ {children}
+ </text>
+);
+
+const SvgDot: React.FC<{ cx: number; cy: number; fill: string }> = ({ cx, cy,
fill }) => (
+ <circle cx={cx} cy={cy} r={3.5} fill={fill} />
+);
+
+/**
+ * 面向用户的资源命名与归属说明:以 nameserver 集群为唯一性边界,
+ * 同一个名字在同一个 broker 集群上只属于一个实例。
+ */
+const ResourceModelPage: React.FC = () => {
+ const { t } = useLang();
+ const { darkMode } = useTheme();
+ const { token } = theme.useToken();
+ const c = darkMode ? PALETTE.dark : PALETTE.light;
+ const tint = (key: PaletteKey, alpha: string) => `${c[key]}${alpha}`;
+
+ return (
+ <div style={{ padding: '0 0 24px' }}>
+ <Card
+ size="small"
+ title={
+ <Space>
+ <TreeStructure size={18} />
+ <span>{t('resourceModel.title')}</span>
+ </Space>
+ }
+ >
+ <InfoBanner description={t('resourceModel.banner')}
data-testid="resource-model-banner" />
+
+ <Title level={5}>{t('resourceModel.archTitle')}</Title>
+ <svg
+ role="img"
+ aria-label={t('resourceModel.svgTopologyAria')}
+ viewBox="0 0 760 332"
+ style={{
+ width: '100%',
+ maxWidth: 760,
+ height: 'auto',
+ display: 'block',
+ margin: '0 auto',
+ }}
+ data-testid="resource-model-topology"
+ >
+ <SvgBox
+ x={8}
+ y={8}
+ width={430}
+ height={316}
+ radius={14}
+ stroke={token.colorBorderSecondary}
+ fill={token.colorFillQuaternary}
+ />
+ <SvgLabel x={24} y={34} fill={token.colorText} bold>
+ {t('resourceModel.svgDomainProd')}
+ </SvgLabel>
+ <rect x={24} y={42} width={48} height={3} rx={1.5} fill={c.blue} />
+ <SvgLabel x={24} y={62} fill={token.colorTextSecondary} muted>
+ 10.0.0.1:9876
+ </SvgLabel>
+
+ <SvgBox
+ x={24}
+ y={74}
+ width={193}
+ height={96}
+ stroke={tint('blue', '59')}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={40} cy={96} fill={c.blue} />
+ <SvgLabel x={50} y={101} fill={token.colorText} bold>
+ broker-a
+ </SvgLabel>
+ <SvgBox
+ x={36}
+ y={110}
+ width={169}
+ height={26}
+ radius={6}
+ stroke={tint('olive', '73')}
+ fill={tint('olive', '1F')}
+ />
+ <SvgLabel x={46} y={128} fill={token.colorText}>
+ {t('resourceModel.svgChipOrdersTrade')}
+ </SvgLabel>
+ <SvgBox
+ x={36}
+ y={142}
+ width={169}
+ height={26}
+ radius={6}
+ stroke={tint('olive', '73')}
+ fill={tint('olive', '1F')}
+ />
+ <SvgLabel x={46} y={160} fill={token.colorText}>
+ {t('resourceModel.svgChipPaymentsTrade')}
+ </SvgLabel>
+
+ <SvgBox
+ x={229}
+ y={74}
+ width={193}
+ height={96}
+ stroke={tint('blue', '59')}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={245} cy={96} fill={c.blue} />
+ <SvgLabel x={255} y={101} fill={token.colorText} bold>
+ broker-b
+ </SvgLabel>
+ <SvgBox
+ x={241}
+ y={110}
+ width={169}
+ height={26}
+ radius={6}
+ stroke={tint('olive', '73')}
+ fill={tint('olive', '1F')}
+ />
+ <SvgLabel x={251} y={128} fill={token.colorText}>
+ {t('resourceModel.svgChipOrdersLogistics')}
+ </SvgLabel>
+
+ <SvgLabel x={24} y={190} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgMergeNote1')}
+ </SvgLabel>
+ <SvgLabel x={24} y={208} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgMergeNote2')}
+ </SvgLabel>
+
+ <SvgBox
+ x={24}
+ y={220}
+ width={193}
+ height={44}
+ stroke={token.colorBorder}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={40} cy={236} fill={c.teal} />
+ <SvgLabel x={50} y={241} fill={token.colorText} bold>
+ {t('resourceModel.svgInstTrade')}
+ </SvgLabel>
+ <SvgLabel x={50} y={257} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgBoundNote')}
+ </SvgLabel>
+
+ <SvgBox
+ x={229}
+ y={220}
+ width={193}
+ height={44}
+ stroke={token.colorBorder}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={245} cy={236} fill={c.teal} />
+ <SvgLabel x={255} y={241} fill={token.colorText} bold>
+ {t('resourceModel.svgInstLogistics')}
+ </SvgLabel>
+ <SvgLabel x={255} y={257} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgBoundNote')}
+ </SvgLabel>
+
+ <SvgBox
+ x={24}
+ y={276}
+ width={398}
+ height={40}
+ stroke={tint('red', '66')}
+ fill={tint('red', '14')}
+ />
+ <SvgLabel x={38} y={293} fill={c.red} bold>
+ {t('resourceModel.svgConflictLine1')}
+ </SvgLabel>
+ <SvgLabel x={38} y={309} fill={token.colorText}>
+ {t('resourceModel.svgConflictLine2')}
+ </SvgLabel>
+
+ <SvgBox
+ x={454}
+ y={8}
+ width={298}
+ height={316}
+ radius={14}
+ stroke={token.colorBorderSecondary}
+ fill={token.colorFillQuaternary}
+ />
+ <SvgLabel x={470} y={34} fill={token.colorText} bold>
+ {t('resourceModel.svgDomainStaging')}
+ </SvgLabel>
+ <rect x={470} y={42} width={48} height={3} rx={1.5} fill={c.purple}
/>
+ <SvgLabel x={470} y={62} fill={token.colorTextSecondary} muted>
+ 10.9.0.1:9876
+ </SvgLabel>
+
+ <SvgBox
+ x={470}
+ y={74}
+ width={266}
+ height={96}
+ stroke={tint('purple', '59')}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={486} cy={96} fill={c.purple} />
+ <SvgLabel x={496} y={101} fill={token.colorText} bold>
+ broker-s
+ </SvgLabel>
+ <SvgBox
+ x={482}
+ y={110}
+ width={242}
+ height={26}
+ radius={6}
+ stroke={tint('olive', '73')}
+ fill={tint('olive', '1F')}
+ />
+ <SvgLabel x={492} y={128} fill={token.colorText}>
+ {t('resourceModel.svgChipOrdersStaging')}
+ </SvgLabel>
+
+ <SvgLabel x={470} y={190} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgCrossDomainNote')}
+ </SvgLabel>
+ <SvgLabel x={470} y={208} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgCrossDomainNote2')}
+ </SvgLabel>
+
+ <SvgBox
+ x={470}
+ y={220}
+ width={266}
+ height={44}
+ stroke={token.colorBorder}
+ fill={token.colorBgContainer}
+ />
+ <SvgDot cx={486} cy={236} fill={c.teal} />
+ <SvgLabel x={496} y={241} fill={token.colorText} bold>
+ {t('resourceModel.svgInstStaging')}
+ </SvgLabel>
+ <SvgLabel x={496} y={257} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgBoundNote')}
+ </SvgLabel>
+
+ <SvgBox x={470} y={276} width={266} height={40}
stroke={token.colorBorderSecondary} />
+ <SvgLabel x={484} y={293} fill={token.colorText}>
+ {t('resourceModel.svgCrossLine1')}
+ </SvgLabel>
+ <SvgLabel x={484} y={309} fill={token.colorTextSecondary} muted>
+ {t('resourceModel.svgCrossLine2')}
+ </SvgLabel>
+ </svg>
+
+ <Title level={5} style={{ marginTop: 24 }}>
+ {t('resourceModel.rulesTitle')}
+ </Title>
+ <Space direction="vertical" size={6}>
+ <Text>{t('resourceModel.rule1')}</Text>
+ <Text>{t('resourceModel.rule2')}</Text>
+ <Text>{t('resourceModel.rule3')}</Text>
+ <Text>{t('resourceModel.rule4')}</Text>
+ </Space>
+ </Card>
+ </div>
+ );
+};
+
+export default ResourceModelPage;
diff --git a/web/src/pages/studio/__tests__/ResourceModel.test.tsx
b/web/src/pages/studio/__tests__/ResourceModel.test.tsx
new file mode 100644
index 000000000..6968464de
--- /dev/null
+++ b/web/src/pages/studio/__tests__/ResourceModel.test.tsx
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import translations from '../../../i18n/translations';
+import ResourceModelPage from '../ResourceModel';
+
+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 renderPage = (lang: 'zh' | 'en') => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+ return render(
+ <App>
+ <LangProvider>
+ <ResourceModelPage />
+ </LangProvider>
+ </App>,
+ );
+};
+
+describe('ResourceModel page', () => {
+ it('renders the topology diagram and the summary banner', () => {
+ const { getByTestId } = renderPage('zh');
+ expect(getByTestId('resource-model-topology').tagName).toBe('svg');
+ expect(getByTestId('resource-model-banner')).toBeInTheDocument();
+ });
+
+ // The creation-time decision flow was a backend implementation detail; the
rule now lives in
+ // the ResourceRuleHelp modal on the create forms, so the page must not
carry the diagram back.
+ it('leaves the creation-decision flow to the create forms', () => {
+ const { queryByTestId, container } = renderPage('zh');
+ expect(queryByTestId('resource-model-flow')).toBeNull();
+ expect(container.textContent).not.toContain('创建时的判定');
+ });
+
+ // An untranslated label falls back to the raw key, so a missing entry shows
up as
+ // "resourceModel.xxx" in the rendered text.
+ it.each(['zh', 'en'] as const)('resolves every %s label without leaking i18n
keys', (lang) => {
+ const { container } = renderPage(lang);
+ expect(container.textContent).not.toContain('resourceModel.');
+ expect(container.textContent?.length).toBeGreaterThan(500);
+ });
+
+ it('keeps the whole resourceModel catalogue filled in both languages', () =>
{
+ const keys = Object.keys(translations).filter((key) =>
key.startsWith('resourceModel.'));
+ expect(keys.length).toBeGreaterThan(30);
+ keys.forEach((key) => {
+ expect(translations[key].zh.trim(), key).not.toBe('');
+ expect(translations[key].en.trim(), key).not.toBe('');
+ });
+ });
+
+ // jsdom has no layout, so the SVG boxes cannot be measured here. The
budgets come from a
+ // browser measurement of the 14px UI font (~7px per Latin char, ~14px per
CJK char) against
+ // the usable width of each hand-drawn box; a label that grows past it would
be clipped.
+ const WIDE_CHAR = /[\u2E80-\u9FFF\u3000-\u303F\uF900-\uFAFF\uFF00-\uFF60]/;
+ const estimatedWidth = (text: string) =>
+ [...text].reduce((sum, ch) => sum + (WIDE_CHAR.test(ch) ? 14 : 7), 0);
+
+ const SVG_LABEL_BUDGETS: Array<[string, number]> = [
+ // topology: resource chips (169 / 242 wide, text starts 10 inside)
+ ['resourceModel.svgChipOrdersTrade', 150],
+ ['resourceModel.svgChipPaymentsTrade', 150],
+ ['resourceModel.svgChipOrdersLogistics', 150],
+ ['resourceModel.svgChipOrdersStaging', 224],
+ // domain titles and body notes
+ ['resourceModel.svgDomainProd', 398],
+ ['resourceModel.svgDomainStaging', 242],
+ ['resourceModel.svgMergeNote1', 398],
+ ['resourceModel.svgMergeNote2', 398],
+ ['resourceModel.svgCrossDomainNote', 242],
+ ['resourceModel.svgCrossDomainNote2', 242],
+ ['resourceModel.svgCrossLine1', 242],
+ ['resourceModel.svgCrossLine2', 242],
+ ['resourceModel.svgConflictLine1', 374],
+ ['resourceModel.svgConflictLine2', 374],
+ // instance boxes (193 / 266 wide, text starts 26 inside)
+ ['resourceModel.svgInstTrade', 155],
+ ['resourceModel.svgInstLogistics', 155],
+ ['resourceModel.svgInstStaging', 228],
+ ['resourceModel.svgBoundNote', 155],
+ ];
+
+ it.each(SVG_LABEL_BUDGETS)('keeps %s inside its drawn box', (key, budget) =>
{
+ (['zh', 'en'] as const).forEach((lang) => {
+ const width = estimatedWidth(translations[key][lang]);
+ expect(width, `${key}.${lang} =
"${translations[key][lang]}"`).toBeLessThanOrEqual(budget);
+ });
+ });
+});