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 ae87f31 feat: add consumer group management page (#480)
ae87f31 is described below
commit ae87f31a9a25e7be7fd8f07bd12b59fdce53b556
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:23:19 2026 +0800
feat: add consumer group management page (#480)
Add consumer group management page.
---
web/src/App.tsx | 2 +
web/src/i18n/translations.ts | 82 ++-
web/src/pages/studio/GroupManagement.tsx | 551 +++++++++++++++++++++
.../studio/__tests__/GroupManagement.test.tsx | 97 ++++
4 files changed, 709 insertions(+), 23 deletions(-)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index f4ca334..c23820a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
import AuditPage from './pages/ops/audit';
import AiPage from './pages/ai';
import SettingsPage from './pages/settings';
+import GroupManagementPage from './pages/studio/GroupManagement';
import BrokerClusterPage from './pages/studio/BrokerCluster';
import SslSettingsPage from './pages/studio/SslSettings';
import AlertManagementPage from './pages/studio/AlertManagement';
@@ -61,6 +62,7 @@ function App() {
<Route path="ops/audit" element={<AuditPage />} />
<Route path="ai" element={<AiPage />} />
<Route path="settings" element={<SettingsPage />} />
+ <Route path="studio/group-management" element={<GroupManagementPage
/>} />
<Route path="studio/broker-cluster" element={<BrokerClusterPage />} />
<Route path="studio/ssl-settings" element={<SslSettingsPage />} />
<Route path="studio/alert-management" element={<AlertManagementPage
/>} />
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index d14c1fd..6823e5d 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -623,6 +623,65 @@ const translations: Record<string, Record<Lang, string>> =
{
'proxy.addrRequired': { zh: '请输入代理地址', en: 'Proxy address is required' },
'proxy.noConfigData': { zh: '无配置数据', en: 'No config data' },
+ // ─── Broker Cluster ───
+ 'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' },
+ 'brokerCluster.k8sCluster': { zh: 'K8s 集群', en: 'K8s Cluster' },
+ 'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' },
+ 'brokerCluster.status': { zh: '状态', en: 'Status' },
+ 'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' },
+ 'brokerCluster.statusReadonly': { zh: '只读', en: 'Read Only' },
+ 'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' },
+ 'brokerCluster.version': { zh: '版本', en: 'Version' },
+ 'brokerCluster.diskUsage': { zh: '磁盘使用', en: 'Disk Usage' },
+ 'brokerCluster.tpsIn': { zh: '入站 TPS', en: 'TPS In' },
+ 'brokerCluster.tpsOut': { zh: '出站 TPS', en: 'TPS Out' },
+ 'brokerCluster.config': { zh: '配置', en: 'Config' },
+ 'brokerCluster.restart': { zh: '重启', en: 'Restart' },
+ 'brokerCluster.manual': { zh: '手动', en: 'Manual' },
+ 'brokerCluster.createCluster': { zh: '创建集群', en: 'Create Cluster' },
+ 'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' },
+ 'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer
Management' },
+ 'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management'
},
+ 'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' },
+ 'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' },
+ 'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' },
+ 'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' },
+ 'brokerCluster.connections': { zh: '连接数', en: 'Connections' },
+
+ // ─── Group Management ───
+ 'groupMgmt.title': { zh: '消费组管理', en: 'Consumer Group Management' },
+ 'groupMgmt.groupName': { zh: '消费组名称', en: 'Group Name' },
+ 'groupMgmt.namespace': { zh: '命名空间', en: 'Namespace' },
+ 'groupMgmt.cluster': { zh: '集群', en: 'Cluster' },
+ 'groupMgmt.onlineInstances': { zh: '在线实例', en: 'Online Instances' },
+ 'groupMgmt.consumeMode': { zh: '消费模式', en: 'Consume Mode' },
+ 'groupMgmt.clustering': { zh: '集群消费', en: 'Clustering' },
+ 'groupMgmt.broadcasting': { zh: '广播消费', en: 'Broadcasting' },
+ 'groupMgmt.diff': { zh: '堆积量', en: 'Diff' },
+ 'groupMgmt.backlogAlert': { zh: '堆积告警', en: 'Backlog Alert' },
+ 'groupMgmt.stopped': { zh: '已停止', en: 'Stopped' },
+ 'groupMgmt.createGroup': { zh: '创建消费组', en: 'Create Group' },
+ 'groupMgmt.searchPlaceholder': { zh: '搜索消费组', en: 'Search group' },
+ 'groupMgmt.manual': { zh: '手动', en: 'Manual' },
+ 'groupMgmt.overview': { zh: '概览', en: 'Overview' },
+ 'groupMgmt.totalDiff': { zh: '总堆积量', en: 'Total Diff' },
+ 'groupMgmt.subscribedTopics': { zh: '订阅主题', en: 'Subscribed Topics' },
+ 'groupMgmt.online': { zh: '在线', en: 'Online' },
+ 'groupMgmt.consumeType': { zh: '消费类型', en: 'Consume Type' },
+ 'groupMgmt.consumeDelay': { zh: '消费延迟', en: 'Consume Delay' },
+ 'groupMgmt.maxRetry': { zh: '最大重试', en: 'Max Retry' },
+ 'groupMgmt.createdAt': { zh: '创建时间', en: 'Created At' },
+ 'groupMgmt.subscription': { zh: '订阅关系', en: 'Subscription' },
+ 'groupMgmt.topic': { zh: '主题', en: 'Topic' },
+ 'groupMgmt.consistency': { zh: '一致性', en: 'Consistency' },
+ 'groupMgmt.consistent': { zh: '一致', en: 'Consistent' },
+ 'groupMgmt.inconsistent': { zh: '不一致', en: 'Inconsistent' },
+ 'groupMgmt.subMode': { zh: '订阅模式', en: 'Sub Mode' },
+ 'groupMgmt.expression': { zh: '过滤表达式', en: 'Expression' },
+ 'groupMgmt.viewDistribution': { zh: '查看分布', en: 'View Distribution' },
+ 'groupMgmt.instanceId': { zh: '实例 ID', en: 'Instance ID' },
+ 'groupMgmt.consumeProgress': { zh: '消费进度', en: 'Consume Progress' },
+
// ─── SSL Settings ───
'ssl.title': { zh: 'SSL/TLS 设置', en: 'SSL/TLS Settings' },
'ssl.info': { zh: 'SSL/TLS 配置', en: 'SSL/TLS Configuration' },
@@ -877,29 +936,6 @@ const translations: Record<string, Record<Lang, string>> =
{
'common.default': { zh: '默认', en: 'Default' },
// ─── BrokerCluster ───
- 'brokerCluster.title': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' },
- 'brokerCluster.k8sCluster': { zh: 'K8s 集群名称', en: 'K8s Cluster' },
- 'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' },
- 'brokerCluster.status': { zh: '运行状态', en: 'Status' },
- 'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' },
- 'brokerCluster.statusReadonly': { zh: '只读', en: 'Readonly' },
- 'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' },
- 'brokerCluster.version': { zh: '版本', en: 'Version' },
- 'brokerCluster.diskUsage': { zh: '磁盘使用率', en: 'Disk Usage' },
- 'brokerCluster.tpsIn': { zh: 'TPS 入流量', en: 'TPS In' },
- 'brokerCluster.tpsOut': { zh: 'TPS 出流量', en: 'TPS Out' },
- 'brokerCluster.config': { zh: '配置', en: 'Config' },
- 'brokerCluster.restart': { zh: '重启', en: 'Restart' },
- 'brokerCluster.manual': { zh: '手动', en: 'Manual' },
- 'brokerCluster.createCluster': { zh: '新建集群', en: 'Create Cluster' },
- 'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' },
- 'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer
Management' },
- 'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management'
},
- 'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' },
- 'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' },
- 'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' },
- 'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' },
- 'brokerCluster.connections': { zh: '连接数', en: 'Connections' },
};
export default translations;
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
new file mode 100644
index 0000000..4c4976a
--- /dev/null
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -0,0 +1,551 @@
+/*
+ * 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 {
+ Table,
+ Button,
+ Input,
+ Tag,
+ Modal,
+ Tabs,
+ Card,
+ Row,
+ Col,
+ Descriptions,
+ Space,
+ Switch,
+} from 'antd';
+import { MagnifyingGlass, Plus, ArrowClockwise, Users, Eye } from
'@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+
+// ─── Types ──────────────────────────────────────────────────────
+interface GroupRecord {
+ key: string;
+ group: string;
+ namespace: string;
+ cluster: string;
+ count: number;
+ consumeType: string;
+ messageModel: 'CLUSTERING' | 'BROADCASTING';
+ diff: number;
+ status: 'running' | 'warning' | 'stopped';
+}
+
+interface SubscriptionRecord {
+ key: string;
+ topic: string;
+ consistency: 'consistent' | 'inconsistent';
+ subMode: string;
+ expression: string;
+}
+
+interface InstanceRecord {
+ key: string;
+ instanceId: string;
+ address: string;
+ version: string;
+ status: string;
+}
+
+interface ProgressRecord {
+ key: string;
+ topic: string;
+ queueId: string;
+ brokerOffset: string;
+ consumerOffset: string;
+ diff: number;
+}
+
+// ─── Mock Data ──────────────────────────────────────────────────
+const groupData: GroupRecord[] = [
+ {
+ key: '1',
+ group: 'order-consumer-group',
+ namespace: 'default',
+ cluster: 'cluster-production',
+ count: 4,
+ consumeType: 'PUSH',
+ messageModel: 'CLUSTERING',
+ diff: 1280,
+ status: 'running',
+ },
+ {
+ key: '2',
+ group: 'payment-consumer-group',
+ namespace: 'default',
+ cluster: 'cluster-production',
+ count: 2,
+ consumeType: 'PUSH',
+ messageModel: 'CLUSTERING',
+ diff: 0,
+ status: 'running',
+ },
+ {
+ key: '3',
+ group: 'inventory-sync-group',
+ namespace: 'ns-warehouse',
+ cluster: 'cluster-warehouse',
+ count: 3,
+ consumeType: 'PUSH',
+ messageModel: 'CLUSTERING',
+ diff: 56800,
+ status: 'warning',
+ },
+ {
+ key: '4',
+ group: 'log-processor-group',
+ namespace: 'default',
+ cluster: 'cluster-production',
+ count: 1,
+ consumeType: 'PUSH',
+ messageModel: 'BROADCASTING',
+ diff: 0,
+ status: 'running',
+ },
+ {
+ key: '5',
+ group: 'notification-group',
+ namespace: 'ns-notify',
+ cluster: 'cluster-production',
+ count: 2,
+ consumeType: 'PUSH',
+ messageModel: 'CLUSTERING',
+ diff: 3,
+ status: 'running',
+ },
+];
+
+const subscriptionData: SubscriptionRecord[] = [
+ { key: '1', topic: 'ORDER_TOPIC', consistency: 'consistent', subMode: 'TAG',
expression: '*' },
+ {
+ key: '2',
+ topic: 'PAYMENT_TOPIC',
+ consistency: 'consistent',
+ subMode: 'TAG',
+ expression: 'pay_success || pay_fail',
+ },
+ {
+ key: '3',
+ topic: 'INVENTORY_TOPIC',
+ consistency: 'inconsistent',
+ subMode: 'TAG',
+ expression: 'stock_update',
+ },
+];
+
+const instanceData: InstanceRecord[] = [
+ {
+ key: '1',
+ instanceId: 'cid-001',
+ address: '10.0.1.10:10911',
+ version: '5.3.0',
+ status: 'online',
+ },
+ {
+ key: '2',
+ instanceId: 'cid-002',
+ address: '10.0.1.11:10911',
+ version: '5.3.0',
+ status: 'online',
+ },
+ {
+ key: '3',
+ instanceId: 'cid-003',
+ address: '10.0.1.12:10911',
+ version: '5.3.0',
+ status: 'online',
+ },
+ {
+ key: '4',
+ instanceId: 'cid-004',
+ address: '10.0.1.13:10911',
+ version: '5.3.0',
+ status: 'online',
+ },
+];
+
+const progressData: ProgressRecord[] = [
+ {
+ key: '1',
+ topic: 'ORDER_TOPIC',
+ queueId: '0',
+ brokerOffset: '125,680',
+ consumerOffset: '125,400',
+ diff: 280,
+ },
+ {
+ key: '2',
+ topic: 'ORDER_TOPIC',
+ queueId: '1',
+ brokerOffset: '98,200',
+ consumerOffset: '98,200',
+ diff: 0,
+ },
+ {
+ key: '3',
+ topic: 'PAYMENT_TOPIC',
+ queueId: '0',
+ brokerOffset: '45,300',
+ consumerOffset: '45,300',
+ diff: 0,
+ },
+ {
+ key: '4',
+ topic: 'INVENTORY_TOPIC',
+ queueId: '0',
+ brokerOffset: '230,100',
+ consumerOffset: '173,300',
+ diff: 56800,
+ },
+];
+
+// ─── Component ──────────────────────────────────────────────────
+const GroupManagementPage = () => {
+ const [searchText, setSearchText] = useState('');
+ const [modalVisible, setModalVisible] = useState(false);
+ const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
+ const [autoRefresh, setAutoRefresh] = useState(false);
+ const { t } = useLang();
+
+ const handleViewDetail = (groupName: string) => {
+ setSelectedGroup(groupName);
+ setModalVisible(true);
+ };
+
+ const columns = [
+ {
+ title: t('groupMgmt.groupName'),
+ dataIndex: 'group',
+ key: 'group',
+ render: (text: string) => (
+ <a onClick={() => handleViewDetail(text)} style={{ color: '#1677ff',
fontWeight: 500 }}>
+ {text}
+ </a>
+ ),
+ },
+ { title: t('groupMgmt.namespace'), dataIndex: 'namespace', key:
'namespace' },
+ { title: t('groupMgmt.cluster'), dataIndex: 'cluster', key: 'cluster' },
+ {
+ title: t('groupMgmt.onlineInstances'),
+ dataIndex: 'count',
+ key: 'count',
+ render: (count: number) => <span style={{ fontWeight: 500
}}>{count}</span>,
+ },
+ {
+ title: t('groupMgmt.consumeMode'),
+ dataIndex: 'messageModel',
+ key: 'messageModel',
+ render: (mode: string) => (
+ <Tag color={mode === 'CLUSTERING' ? 'blue' : 'orange'}>
+ {mode === 'CLUSTERING' ? t('groupMgmt.clustering') :
t('groupMgmt.broadcasting')}
+ </Tag>
+ ),
+ },
+ {
+ title: t('groupMgmt.diff'),
+ dataIndex: 'diff',
+ key: 'diff',
+ render: (diff: number) => (
+ <span
+ style={{
+ color: diff > 10000 ? '#ff4d4f' : diff > 0 ? '#fa8c16' : '#52c41a',
+ fontWeight: 500,
+ }}
+ >
+ {diff.toLocaleString()}
+ </span>
+ ),
+ },
+ {
+ title: t('brokerCluster.status'),
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: string) => {
+ const config: Record<string, { color: string; label: string }> = {
+ running: { color: 'success', label: t('brokerCluster.statusRunning')
},
+ warning: { color: 'warning', label: t('groupMgmt.backlogAlert') },
+ stopped: { color: 'error', label: t('groupMgmt.stopped') },
+ };
+ const { color, label } = config[status] || config.running;
+ return <Tag color={color}>{label}</Tag>;
+ },
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ render: (_: unknown, record: GroupRecord) => (
+ <Space size="small">
+ <Button type="link" size="small" onClick={() =>
handleViewDetail(record.group)}>
+ {t('common.detail')}
+ </Button>
+ <Button type="link" size="small">
+ {t('brokerCluster.config')}
+ </Button>
+ </Space>
+ ),
+ },
+ ];
+
+ const subscriptionColumns = [
+ {
+ title: t('groupMgmt.topic'),
+ dataIndex: 'topic',
+ key: 'topic',
+ render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
+ },
+ {
+ title: t('groupMgmt.consistency'),
+ dataIndex: 'consistency',
+ key: 'consistency',
+ render: (consistency: string) => (
+ <Tag color={consistency === 'consistent' ? 'success' : 'warning'}>
+ {consistency === 'consistent' ? t('groupMgmt.consistent') :
t('groupMgmt.inconsistent')}
+ </Tag>
+ ),
+ },
+ { title: t('groupMgmt.subMode'), dataIndex: 'subMode', key: 'subMode' },
+ {
+ title: t('groupMgmt.expression'),
+ dataIndex: 'expression',
+ key: 'expression',
+ render: (text: string) => (
+ <code style={{ background: '#f5f5f5', padding: '2px 6px',
borderRadius: 4, fontSize: 12 }}>
+ {text}
+ </code>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ render: () => (
+ <Button type="link" size="small" icon={<Eye size={14} />}>
+ {t('groupMgmt.viewDistribution')}
+ </Button>
+ ),
+ },
+ ];
+
+ return (
+ <div style={{ padding: 0 }}>
+ <div
+ style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ }}
+ >
+ <h2
+ style={{
+ fontSize: 20,
+ fontWeight: 600,
+ margin: 0,
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+ <Users size={22} style={{ marginRight: 8, color: '#1677ff' }} />
+ {t('groupMgmt.title')}
+ </h2>
+ <Space size="middle">
+ <Input
+ placeholder={t('groupMgmt.searchPlaceholder')}
+ prefix={<MagnifyingGlass size={14} />}
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ style={{ width: 240 }}
+ allowClear
+ />
+ <Button type="primary" icon={<Plus size={14} />}>
+ {t('groupMgmt.createGroup')}
+ </Button>
+ <Switch
+ checked={autoRefresh}
+ onChange={setAutoRefresh}
+ checkedChildren={t('common.autoRefresh')}
+ unCheckedChildren={t('groupMgmt.manual')}
+ size="small"
+ />
+ <Button icon={<ArrowClockwise size={14} />} size="small">
+ {t('common.reset')}
+ </Button>
+ </Space>
+ </div>
+
+ <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px
rgba(0,0,0,0.04)' }}>
+ <Table
+ columns={columns}
+ dataSource={groupData.filter((d) => !searchText ||
d.group.includes(searchText))}
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => `${t('common.total')} ${total} Group`,
+ showSizeChanger: true,
+ }}
+ size="middle"
+ />
+ </Card>
+
+ <Modal
+ title={null}
+ open={modalVisible}
+ onCancel={() => setModalVisible(false)}
+ footer={null}
+ width={720}
+ destroyOnClose
+ >
+ <div style={{ marginBottom: 16 }}>
+ <h3 style={{ margin: 0, display: 'flex', alignItems: 'center' }}>
+ <Users size={18} style={{ marginRight: 8, color: '#1677ff' }} />
+ {selectedGroup}
+ </h3>
+ </div>
+ <Tabs
+ defaultActiveKey="overview"
+ items={[
+ {
+ key: 'overview',
+ label: t('groupMgmt.overview'),
+ children: (
+ <div>
+ <Row gutter={16} style={{ marginBottom: 20 }}>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#f6ffed' }}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.onlineInstances')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600 }}>
+ 4{' '}
+ <Tag color="success" style={{ marginLeft: 8 }}>
+ {t('groupMgmt.online')}
+ </Tag>
+ </div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#fff2f0' }}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.totalDiff')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600, color:
'#ff4d4f' }}>1,280</div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#f0f5ff' }}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.subscribedTopics')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600 }}>3</div>
+ </Card>
+ </Col>
+ </Row>
+ <Descriptions column={2} bordered size="small">
+ <Descriptions.Item label={t('groupMgmt.groupName')}>
+ order-consumer-group
+ </Descriptions.Item>
+ <Descriptions.Item
label={t('groupMgmt.namespace')}>default</Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.cluster')}>
+ cluster-production
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.consumeMode')}>
+ <Tag color="blue">{t('groupMgmt.clustering')}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item
label={t('groupMgmt.consumeType')}>PUSH</Descriptions.Item>
+ <Descriptions.Item
label={t('groupMgmt.consumeDelay')}>12ms</Descriptions.Item>
+ <Descriptions.Item
label={t('groupMgmt.maxRetry')}>16</Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.createdAt')}>
+ 2025-03-15 10:30:00
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.subscribedTopics')}
span={2}>
+ ORDER_TOPIC, PAYMENT_TOPIC, INVENTORY_TOPIC
+ </Descriptions.Item>
+ </Descriptions>
+ <h4 style={{ marginTop: 20, marginBottom: 12
}}>{t('groupMgmt.subscription')}</h4>
+ <Table
+ columns={subscriptionColumns}
+ dataSource={subscriptionData}
+ pagination={false}
+ size="small"
+ />
+ </div>
+ ),
+ },
+ {
+ key: 'instances',
+ label: t('groupMgmt.onlineInstances'),
+ children: (
+ <Table
+ columns={[
+ {
+ title: t('groupMgmt.instanceId'),
+ dataIndex: 'instanceId',
+ key: 'instanceId',
+ },
+ { title: t('common.address'), dataIndex: 'address', key:
'address' },
+ { title: t('brokerCluster.version'), dataIndex: 'version',
key: 'version' },
+ {
+ title: t('brokerCluster.status'),
+ dataIndex: 'status',
+ key: 'status',
+ render: () => <Tag
color="success">{t('groupMgmt.online')}</Tag>,
+ },
+ ]}
+ dataSource={instanceData}
+ pagination={false}
+ size="small"
+ />
+ ),
+ },
+ {
+ key: 'progress',
+ label: t('groupMgmt.consumeProgress'),
+ children: (
+ <Table
+ columns={[
+ { title: 'Topic', dataIndex: 'topic', key: 'topic' },
+ { title: 'QueueId', dataIndex: 'queueId', key: 'queueId' },
+ { title: 'Broker Offset', dataIndex: 'brokerOffset', key:
'brokerOffset' },
+ {
+ title: 'Consumer Offset',
+ dataIndex: 'consumerOffset',
+ key: 'consumerOffset',
+ },
+ {
+ title: 'Diff',
+ dataIndex: 'diff',
+ key: 'diff',
+ render: (v: number) => (
+ <span style={{ color: v > 100 ? '#ff4d4f' : '#52c41a',
fontWeight: 500 }}>
+ {v}
+ </span>
+ ),
+ },
+ ]}
+ dataSource={progressData}
+ pagination={false}
+ size="small"
+ />
+ ),
+ },
+ ]}
+ />
+ </Modal>
+ </div>
+ );
+};
+
+export default GroupManagementPage;
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
new file mode 100644
index 0000000..3ac9fbb
--- /dev/null
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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, it, expect, vi, beforeAll } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import GroupManagement from '../GroupManagement';
+
+// Mock matchMedia for antd responsive components
+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(),
+ })),
+ });
+});
+
+// Mock react-router-dom
+vi.mock('react-router-dom', () => ({
+ useNavigate: () => vi.fn(),
+ useParams: () => ({}),
+}));
+
+const renderWithProviders = (ui: React.ReactElement) => {
+ return render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+};
+
+describe('GroupManagement Page', () => {
+ it('should render the page title', () => {
+ renderWithProviders(<GroupManagement />);
+ expect(screen.getByText('消费组管理')).toBeInTheDocument();
+ });
+
+ it('should render search input with placeholder', () => {
+ renderWithProviders(<GroupManagement />);
+ expect(screen.getByPlaceholderText('搜索消费组')).toBeInTheDocument();
+ });
+
+ it('should render create group button', () => {
+ renderWithProviders(<GroupManagement />);
+ expect(screen.getByText('创建消费组')).toBeInTheDocument();
+ });
+
+ it('should render reset button', () => {
+ renderWithProviders(<GroupManagement />);
+ expect(screen.getByText('重置')).toBeInTheDocument();
+ });
+
+ it('should display mock consumer group data in table', () => {
+ renderWithProviders(<GroupManagement />);
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+ expect(screen.getByText('payment-consumer-group')).toBeInTheDocument();
+ });
+
+ it('should render detail action buttons for each row', () => {
+ renderWithProviders(<GroupManagement />);
+ const detailButtons = screen.getAllByText('详情');
+ expect(detailButtons.length).toBeGreaterThan(0);
+ });
+
+ it('should filter groups by search text', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<GroupManagement />);
+ const searchInput = screen.getByPlaceholderText('搜索消费组');
+ await user.type(searchInput, 'order');
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
+ });
+});