Compare commits

...

57 Commits

Author SHA1 Message Date
Crazylychee
ce8306a602 [ISSUE #359] Fix fail test in LoginControllerTest and add .env (#363)
* commit

* commit

* commit

* commit
2025-08-30 19:25:05 +08:00
Crazylychee
8037cfcf05 [ISSUE #353] fix Actuator vulnerability issues (#354)
* [ISSUE #348] fix Some interaction issues with the consumer interface

* commit

* [ISSUE #353] fix Actuator vulnerability issues

* [ISSUE #353] fix Actuator vulnerability issues

* commit
2025-08-09 16:04:52 +08:00
RongtongJin
bd9f3e6b39 [maven-release-plugin] prepare for next development iteration 2025-08-01 16:48:17 +08:00
RongtongJin
a013c8fad1 [maven-release-plugin] prepare release rocketmq-dashboard-2.1.0 2025-08-01 16:47:58 +08:00
RongtongJin
cd262da8b1 Update Notice to 2025. 2025-07-31 11:34:24 +08:00
Crazylychee
37dbd7f327 [ISSUE #348] Fix Some interaction issues with the consumer interface (#349) 2025-07-31 11:24:42 +08:00
strangelookingnerd
79556420f5 Use provided scope for lombok (#350) 2025-07-31 11:24:01 +08:00
Crazylychee
9cb185afc1 [ISSUE #344] fix maven package display errors and npm i failed (#345)
* [ISSUE #344] fix maven package display errors and npm i failed

* fix
2025-07-16 19:21:39 +08:00
Crazylychee
f60103af9b [ISSUE #346] fix request error when logging in using the configuration file (#347) 2025-07-16 19:21:25 +08:00
TianMing2018
9c2a069976 #docs fix wrong port (#343) 2025-07-16 18:54:15 +08:00
Crazylychee
8cc7d6a727 [ISSUE #341] Add url parameter transcoding (#342)
* [Enhancement] ACL can add rules in clusters and fix ISSUE #297

* rollback the yml change

* [ISSUE #341] Add url parameter transcoding

* [ISSUE #344] fix maven package display errors and npm i failed
2025-07-16 18:53:54 +08:00
Crazylychee
a4e02f472f [Enhancement] ACL can add rules in clusters (#340)
* [Enhancement] ACL can add rules in clusters and fix ISSUE #297

* rollback the yml change
2025-07-08 10:46:25 +08:00
RongtongJin
87cfa3e872 Remove useless build status 2025-07-07 11:06:41 +08:00
Crazylychee
c297d059a9 [ISSUE #337] Restrict write permissions and update the doc (#338) 2025-07-07 11:04:48 +08:00
Crazylychee
07793d8aae [ISSUE #329] Add Frontend Proxy Component Support (#336) 2025-07-05 20:53:57 +08:00
Crazylychee
4b9ed97f8f [ISSUE #332] Add configuration options for login information, supporting ACL or file storage (#333) 2025-07-05 20:51:42 +08:00
Crazylychee
ff73529a75 [ISSUE #330] Format code and update the doc (#334) 2025-07-05 20:50:36 +08:00
Crazylychee
706082c62f [ISSUE #331] Fix failing tests (#335) 2025-07-05 20:50:08 +08:00
hexueyuan
6531929124 [ISSUE #325] Unified page display format. (#326)
Co-authored-by: hexueyuan <hexueyuan@baidu.com>
2025-07-02 11:07:01 +08:00
Crazylychee
a5138eb0d8 [ISSUE #317] Removed useless topic cache 2025-06-24 15:21:57 +08:00
Crazylychee
b43c7abe52 [ISSUE #321] Fix interface permission verification 2025-06-24 15:21:25 +08:00
Crazylychee
bfd0e26737 [ISSUE #319] Store the username in localStorage 2025-06-24 15:21:10 +08:00
Crazylychee
31d8086db3 [ISSUES#323]: fix Maven packaging error 2025-06-24 14:56:12 +08:00
Crazylychee
8564296440 [GSOC][RIP-78][ISSUES#308]: delete the old ui (#314) 2025-06-16 14:34:31 +08:00
Crazylychee
e81dceb6ae [ISSUES #315]: Add acl2.0 cluster support 2025-06-16 14:31:18 +08:00
Crazylychee
bc1a05d16c [GSOC][RIP-78][ISSUES#308] Add part of refactored backend files (#313) 2025-06-16 14:04:53 +08:00
Crazylychee
3cbff604e6 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#312) 2025-06-16 13:51:18 +08:00
Crazylychee
bd94e8c4f5 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#311) 2025-06-16 13:47:34 +08:00
Crazylychee
52545ccd23 [GSOC][RIP-78][ISSUES#308] Add part of refactored front-end files (#310) 2025-06-16 13:47:14 +08:00
Crazylychee
b75ace4804 [GSOC][RIP-78][ISSUES#308]: Add part of refactored front-end files (#309) 2025-06-16 13:46:41 +08:00
Crazylychee
eb51da6ca4 Merge branch 'refactor' of github.com:apache/rocketmq-dashboard into refactor (#307)
* pref: optimize the response speed of the query api

* pref: optimize the response speed of the query api (#273)

* Fixing and Adding Unit Tests (#266) (#278)

* fix: align top navigation bar styles #279

* fix code style

---------

Co-authored-by: icenfly <87740812+icenfly@users.noreply.github.com>
2025-06-12 19:57:07 +08:00
Crazylychee
c85aa2e2a9 fix: Solve the null pointer after a single refresh of the consumer #295 (#296) 2025-06-12 10:45:38 +08:00
Xu Yichi
a450594ace fix: Add consumer global refresh and fix the problem #290 (#291) 2025-04-15 09:49:34 +08:00
Xu Yichi
bbabd1cd0d [ISSUES #281 #274 #285 #287] Speeds up topic and consumer queries, adds caching, and fixes delay/dead-letter topic mix-up (#286)
* fix: Resolved issue of query failure under a large number of topics and consumers apache#281

* fix: Expand the message ID query time range to avoid query failure

* fix: Remove duplicates from topic queries, increase system topic recognition #287
2025-04-13 19:41:24 +08:00
Xu Yichi
e76185437f fix: align top navigation bar styles #279 (#280) 2025-04-01 09:55:33 +08:00
Xu Yichi
3d13e4e2b8 fix:Failed to find messages older than 3 days using message ID #274 (#275) 2025-03-31 10:45:24 +08:00
iamgd67
1aad0cda25 front js check is v5 'false' will be true fix (#269) 2025-03-14 10:50:31 +08:00
yuz10
e57d423268 remove rocketmq-namesrv dependency (#254) 2024-11-04 15:49:02 +08:00
dependabot[bot]
0d87486d7a Bump snakeyaml from 1.30 to 1.32 (#130)
---
updated-dependencies:
- dependency-name: org.yaml:snakeyaml
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 11:36:30 +08:00
yuz10
94d7a4e418 remove rocketmq-broker dependency (#249) 2024-11-04 11:35:39 +08:00
xx
3fbaa3ab92 fix: Duplicate message in the topic tab message list in the message menu. (#202)
Co-authored-by: xx <xx@123.com>
2024-11-04 11:35:17 +08:00
RongtongJin
e6d454301f [maven-release-plugin] prepare for next development iteration 2024-09-18 09:57:37 +08:00
RongtongJin
f5c09ac287 [maven-release-plugin] prepare release rocketmq-dashboard-2.0.0 2024-09-18 09:57:04 +08:00
Guyu
e97072a3b1 style: Remove unused imports for the checkstyle. (#232)
* fix: 5.x query message detail throw: Failed to query message by Id: xxx

* style: Remove unused imports for the checkstyle.

---------

Co-authored-by: yangzengc <yangzengc@ewan.cn>
2024-09-12 16:51:34 +08:00
Evan
6d360509c0 fix: 5.x query message detail throw: Failed to query message by Id: xxx (#231)
Co-authored-by: yangzengc <yangzengc@ewan.cn>
2024-09-07 19:59:38 +08:00
Akai
464f57adf8 Support retryMaxTimes filed set for consumer group (#229)
* fix:Fixed the issue that normal messages in version v4 are not showed

* feat:support retryMaxTimes args set

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
Co-authored-by: yuanziwei.akai <yuanziwei.akai@bytedance.com>
2024-08-27 20:30:00 +08:00
Akai
5d08d3b122 Support Unspecified Topic Add & Update & Query (#223)
* fix:Fixed the issue that normal messages in version v4 are not showed

* feat:support unspecified topic

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-07-24 10:57:04 +08:00
Akai
d9fc76d3a3 fix:Fixed the issue that normal messages in version v4 are not showed (#222)
Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-07-24 10:56:47 +08:00
Akai
2bc59db340 Supplement UserGuide for RocketMQ 5.0 (#208)
* Support UserGuide for new feature

* update userGuide md

* update userGuide md

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-13 15:22:38 +08:00
Akai
d58e13da95 Proxy Support And ConsumerGroup Enhancement (#207)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

* Support proxy-module And Fix the problem of showing wrong consumerGroup-info

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-12 09:12:19 +08:00
Akai
e7cb315050 Support FIFO-Type SubGroup Add、Update and Query For V5 (#204)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

* Support subGroup FIFO Type Query and Update

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-11 10:53:36 +08:00
Akai
21dc2acfdc Support dashboard v4-v5 switch And query for v5 topic (#198)
* Support dashboard v4-v5 switch And query for v5 topic

* Modify tag name

---------

Co-authored-by: yuanziwei <yuanziwei@xiaomi.com>
2024-06-05 15:17:23 +08:00
guangdashao
823bce2b8b feat: add topic message type
add message type
2024-06-04 11:40:46 +08:00
Javen
2fb0fce0b1 perf: The new metrics of getTransferredTps for rocketmq5.x and the old metrics of getTransferedTps for rocketmq4.x (#197)
Co-authored-by: jinwei2 <jinwei2@enmonster.com>
2024-03-26 17:02:16 +08:00
Abhijeet Mishra
6456630324 [#148] Throwables.propagate in deprecated for making runtime exception more verbose (#160) 2023-04-19 20:33:17 +08:00
Abhijeet Mishra
a25ccd6337 5.1.0 rocketmq version update (#155)
* update rocketmq version to 5.1.0
2023-04-07 08:30:18 +08:00
Abhijeet Mishra
538d1c1c45 [ISSUE apache#149] updated lombok version in pom.xml because of this compilation was failing (#151) 2023-03-20 15:33:45 +08:00
1152 changed files with 34284 additions and 278283 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@
.project .project
.factorypath .factorypath
.settings/ .settings/
.vscode
htmlReport/

2
NOTICE
View File

@@ -1,5 +1,5 @@
Apache RocketMQ Apache RocketMQ
Copyright 2016-2022 The Apache Software Foundation Copyright 2016-2025 The Apache Software Foundation
This product includes software developed at This product includes software developed at
The Apache Software Foundation (http://www.apache.org/). The Apache Software Foundation (http://www.apache.org/).

View File

@@ -1,5 +1,4 @@
## [Apache RocketMQ](https://github.com/apache/rocketmq) Dashboard ## [Apache RocketMQ](https://github.com/apache/rocketmq) Dashboard
[![Build Status](https://api.travis-ci.com/apache/rocketmq-dashboard.svg?branch=master)](https://travis-ci.com/github/apache/rocketmq-dashboard) [![Coverage Status](https://coveralls.io/repos/github/apache/rocketmq-dashboard/badge.svg?branch=master)](https://coveralls.io/github/apache/rocketmq-dashboard?branch=master)
[![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[![CodeCov](https://codecov.io/gh/apache/rocketmq-dashboard/branch/master/graph/badge.svg)](https://codecov.io/gh/apache/rocketmq-dashboard) [![CodeCov](https://codecov.io/gh/apache/rocketmq-dashboard/branch/master/graph/badge.svg)](https://codecov.io/gh/apache/rocketmq-dashboard)
[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/apache/rocketmq-dashboard.svg)](http://isitmaintained.com/project/apache/rocketmq-dashboard "Average time to resolve an issue") [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/apache/rocketmq-dashboard.svg)](http://isitmaintained.com/project/apache/rocketmq-dashboard "Average time to resolve an issue")
@@ -18,7 +17,7 @@ docker pull apacherocketmq/rocketmq-dashboard:latest
#### Run it (use your own `rocketmq.namesrv.addr` and `port`) #### Run it (use your own `rocketmq.namesrv.addr` and `port`)
```shell ```shell
docker run -d --name rocketmq-dashboard -e "JAVA_OPTS=-Drocketmq.namesrv.addr=127.0.0.1:9876" -p 8080:8080 -t apacherocketmq/rocketmq-dashboard:latest docker run -d --name rocketmq-dashboard -e "JAVA_OPTS=-Drocketmq.namesrv.addr=127.0.0.1:9876" -p 8082:8082 -t apacherocketmq/rocketmq-dashboard:latest
``` ```
### Run with source code ### Run with source code
@@ -26,7 +25,7 @@ docker run -d --name rocketmq-dashboard -e "JAVA_OPTS=-Drocketmq.namesrv.addr=12
#### Prerequisite #### Prerequisite
1. 64bit OS, Linux/Unix/Mac is recommended; 1. 64bit OS, Linux/Unix/Mac is recommended;
2. 64bit JDK 1.8+; 2. 64bit JDK 17;
3. Maven 3.2.x; 3. Maven 3.2.x;
#### Maven spring-boot run #### Maven spring-boot run

View File

@@ -2,12 +2,17 @@
## 运维页面 ## 运维页面
* 你可以修改这个服务使用的namesrv的地址 * 你可以修改这个服务使用的namesrv的地址
* 你可以修改这个服务是否使用VIPChannel(如果你的mq server版本小于3.5.8,请设置不使用) * 你可以修改这个服务是否使用VIPChannel(如果你的mq server版本小于3.5.8,请设置不使用)
![image-20250706143719935](UserGuide_CN/image-20250706143719935.png)
## 驾驶舱 ## 驾驶舱
* 查看broker的消息量总量/5分钟图 * 查看broker的消息量总量/5分钟图
* 查看单一主题的消息量(总量/趋势图) * 查看单一主题的消息量(总量/趋势图)
![image-20250706143801952](UserGuide_CN/image-20250706143801952.png)
## 集群页面 ## 集群页面
* 查看集群的分布情况 * 查看集群的分布情况
* cluster与broker关系 * cluster与broker关系
@@ -15,9 +20,13 @@
* 查看broker具体信息/运行信息 * 查看broker具体信息/运行信息
* 查看broker配置信息 * 查看broker配置信息
![image-20250706143819962](UserGuide_CN/image-20250706143819962.png)
## 主题页面 ## 主题页面
* 展示所有的主题,可以通过搜索框进行过滤 * 展示所有的主题,可以通过搜索框进行过滤
* 筛选 普通/重试/死信 主题 * 筛选 普通/重试/死信 主题
* 支持延迟/顺序/事务消息的筛选
* 支持延迟/顺序/事物/普通等多种消息类型主题的新增与更新
* 添加/更新主题 * 添加/更新主题
* clusterName 创建在哪几个cluster上 * clusterName 创建在哪几个cluster上
* brokerName 创建在哪几个broker上 * brokerName 创建在哪几个broker上
@@ -33,9 +42,11 @@
* 重置消费位点(分为在线和不在线两种情况,不过都需要检查重置是否成功) * 重置消费位点(分为在线和不在线两种情况,不过都需要检查重置是否成功)
* 删除主题 会删除掉所有broker以及namesrv上的主题配置和路由信息 * 删除主题 会删除掉所有broker以及namesrv上的主题配置和路由信息
![image-20250706143900173](UserGuide_CN/image-20250706143900173.png)
## 消费者页面 ## 消费者页面
* 展示所有的消费组,可以通过搜索框进行过滤 * 展示所有的消费组,可以通过搜索框进行过滤
* 刷新页面/每隔五秒定时刷新页面 * 刷新页面
* 按照订阅组/数量/TPS/延迟 进行排序 * 按照订阅组/数量/TPS/延迟 进行排序
* 添加/更新消费组 * 添加/更新消费组
* clusterName 创建在哪几个集群上 * clusterName 创建在哪几个集群上
@@ -50,11 +61,20 @@
* 消费详情 对应消费组的消费明细查看这个消费组订阅的所有Topic的消费情况每个queue对应的消费client查看包括Retry消息 * 消费详情 对应消费组的消费明细查看这个消费组订阅的所有Topic的消费情况每个queue对应的消费client查看包括Retry消息
* 配置 查看变更消费组的配置 * 配置 查看变更消费组的配置
* 删除 在指定的broker上删除消费组 * 删除 在指定的broker上删除消费组
* 是否使用代理进行查询
* 消费页面
* 支持顺序消费类型订阅组的过滤
* 提供顺序消费类型订阅组的新增与更新如果需要开启顺序消费FIFO类型的订阅组一定需要打开consumeOrderlyEnable选项
![image-20250706143924854](UserGuide_CN/image-20250706143924854.png)
## 发布管理页面 ## 发布管理页面
* 通过Topic和Group查询在线的消息生产者客户端 * 通过Topic和Group查询在线的消息生产者客户端
* 信息包含客户端主机 版本 * 信息包含客户端主机 版本
![image-20250706144100067](UserGuide_CN/image-20250706144100067.png)
## 消息查询页面 ## 消息查询页面
* 根据Topic和时间区间查询 * 根据Topic和时间区间查询
*由于数据量大 最多只会展示2000条多的会被忽略 *由于数据量大 最多只会展示2000条多的会被忽略
@@ -63,8 +83,26 @@
* 根据消息主题和消息Id进行消息的查询 * 根据消息主题和消息Id进行消息的查询
* 消息详情可以展示这条消息的详细信息,查看消息对应到具体消费组的消费情况(如果异常,可以查看具体的异常信息)。可以向指定的消费组重发消息。 * 消息详情可以展示这条消息的详细信息,查看消息对应到具体消费组的消费情况(如果异常,可以查看具体的异常信息)。可以向指定的消费组重发消息。
![image-20250706144145077](UserGuide_CN/image-20250706144145077.png)
## 代理页面
* 代理页面RocketMQ 5.0新增)
* 支持代理节点的新增与查询
* 支持代理节点地址配置在application.yml中可对proxyAddr和proxyAddrs属性进行预配置
![image-20250706144418694](UserGuide_CN/image-20250706144418694.png)
## ACL2.0管理界面
- 支持根据集群名字或者broker地址的acl规则的查询
- acl规则的修改、新增、删除、查找
- 如果只是选取了集群名字那么查询的acl列表将会取交集如果选取了brokerName就会返回该broker的acl列表。
- 不再支持acl1.0
![image-20250706145313629](UserGuide_CN/image-20250706145313629.png)
## HTTPS 方式访问Dashboard ## HTTPS 方式访问Dashboard
* HTTPS功能实际上是使用SpringBoot提供的配置功能即可完成首先需要有一个SSL KeyStore来存放服务端证书可以使用本工程所提供的测试密钥库: * HTTPS功能实际上是使用SpringBoot提供的配置功能即可完成首先需要有一个SSL KeyStore来存放服务端证书可以使用本工程所提供的测试密钥库:
resources/rmqcngkeystore.jks, 它可以通过如下keytool命令生成 resources/rmqcngkeystore.jks, 它可以通过如下keytool命令生成
``` ```
@@ -99,7 +137,7 @@ rocketmq.config.loginRequired=true
# Dashboard文件目录登录用户配置文件所在目录 # Dashboard文件目录登录用户配置文件所在目录
rocketmq.config.dataPath=/tmp/rocketmq-console/data rocketmq.config.dataPath=/tmp/rocketmq-console/data
``` ```
* 2.确保${rocketmq.config.dataPath}定义的目录存在,并且该目录下创建登录配置文件"users.properties", 如果该目录下不存在此文件则默认使用resources/users.properties文件。 * 2.确保${rocketmq.config.dataPath}定义的目录存在,并且该目录下创建登录配置文件"users.properties", 如果该目录下不存在此文件则默认使用resources/users.properties文件。 ps: 如果rocketmq启用了acl控制台必须配置ak和sk同时application.yml中的rocketmq.config.authmode 需要为acl且登录功能需要打开才能正常使用登录后将使用acl2.0中的用户名和密码构造rpchook与broker进行通信。
users.properties文件格式为: users.properties文件格式为:
```$xslt ```$xslt
# 该文件支持热修改即添加和修改用户时不需要重新启动console # 该文件支持热修改即添加和修改用户时不需要重新启动console
@@ -114,6 +152,8 @@ user2=user2
``` ```
* 3.启动控制台则开启了登录功能 * 3.启动控制台则开启了登录功能
## 权限检验 ## 权限检验
如果用户访问console时开启了登录功能会按照登录的角色对访问的接口进行权限控制。 如果用户访问console时开启了登录功能会按照登录的角色对访问的接口进行权限控制。
* 1.在Spring配置文件resources/application.properties中修改rocketmq.config.loginRequired=true开启登录功能 * 1.在Spring配置文件resources/application.properties中修改rocketmq.config.loginRequired=true开启登录功能
@@ -137,7 +177,7 @@ role-permission.yml文件格式为:
rolePerms: rolePerms:
# 普通用户 # 普通用户
ordinary: Normal:
- /rocketmq/nsaddr - /rocketmq/nsaddr
- /ops/* - /ops/*
- /dashboard/** - /dashboard/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,149 +1,222 @@
# RocketMQ User Guide
## OPS Page # RocketMQ Usage Documentation
* You can change dashboard's namesrvAddr here
* You can change the value of useVIPChannel here (if you rocketMQ version < 3.5.8,the value of useVIPChannel should be false)
## DashBoard Page ---
* broker's message count (broker total message count/5 min trend)
* topic's message counttopic total message count/5 min trend ## Operations Page
* You can **modify the NameSrv address** used by this service.
* You can configure whether this service uses **VIPChannel** (if your MQ server version is less than 3.5.8, please set it to not use).
![image-20250706143719935](UserGuide_CN/image-20250706143719935.png)
---
## Dashboard
* View the **broker's message volume** (total/5-minute chart).
* View a **single topic's message volume** (total/trend chart).
![image-20250706143801952](UserGuide_CN/image-20250706143801952.png)
---
## Cluster Page ## Cluster Page
* Cluster Detail * View the **cluster distribution**.
* relation between cluster and broker * Cluster and broker relationships.
* broker's master / salve node * Broker information.
* broker'a detail info(runtime info) * View **specific broker information/runtime information**.
* broker's config * View **broker configuration information**.
![image-20250706143819962](UserGuide_CN/image-20250706143819962.png)
---
## Topic Page ## Topic Page
* show all the topics,you can filter topic by search bar * Display all topics, filterable via a **search box**.
* filter (Normal/retry/dead) topic * **Filter topics** by normal, retry, or dead-letter types.
* Add/Update Topic * Supports filtering for delayed, ordered, and transactional messages.
* clusterName (create on which cluster) * Supports adding and updating topics of various message types: delayed, ordered, transactional, and normal.
* brokerName (create on which broker) * **Add/Update Topic**:
* topicName * **clusterName**: Specify which clusters to create the topic on.
* writeQueueNums * **brokerName**: Specify which brokers to create the topic on.
* readQueueNums * **topicName**: The name of the topic.
* perm //2 for write 4 for read 6 for write and read * **writeQueueNums**: Number of write queues.
* STATUS look over message send status(send to which broker/which queue/how many messages) * **readQueueNums**: Number of read queues.
* ROUTER look update topic's routerthis topic send to which brokerthe broker's queue info * **perm**: // 2 for write, 4 for read, 6 for read/write.
* CONSUMER MANAGEthis topic consume by which group,how about the consume state * **Status**: Query message delivery status (which brokers/queues delivered to, quantity, etc.).
* TOPIC CONFIGcheck or change the topic's config * **Routing**: View message routing (which brokers messages for this topic will be sent to, and corresponding broker queue information).
* SEND MESSAGEsend a test message * **Consumer Management**: See which groups are consuming this topic and their consumption status.
* Reset CONSUMER OFFSET (the consumer online or not online is different,you need check the reset result) * **Topic Configuration**: View and modify the current configuration.
* DELETE will delete the topic on all broker and namesrv * **Send Message**: Send a test message to this topic.
* **Reset Consumer Offset**: Available for both online and offline scenarios, though success should always be verified.
* **Delete Topic**: Deletes all topic configurations and routing information from all brokers and NameSrvs.
![image-20250706143900173](UserGuide_CN/image-20250706143900173.png)
---
## Consumer Page ## Consumer Page
* show all the consumers,you can filter consumer by search bar * Display all **consumer groups**, filterable via a search box.
* refresh page/refresh page per 5 seconds * **Refresh** page.
* order by SubscriptionGroup/Quantity/TPS/Delay * **Sort** by subscription group, quantity, TPS, or latency.
* Add/Update Consumer * **Add/Update Consumer Group**:
* clusterName (create on which cluster) * **clusterName**: Specify on which clusters to create.
* brokerName (create on which broker) * **brokerName**: Specify on which brokers to create.
* groupName (consumer group name) * **groupName**: Consumer group name.
* consumeEnable (this group can't consume message if this is false) * **consumeEnable**: // Whether consumption is enabled. If `FALSE`, consumption will be disabled.
* consumeBroadcastEnable (can't use broadcast is this is false) * **consumeBroadcastEnable**: // Whether broadcast consumption is enabled.
* retryQueueNums * **retryQueueNums**: // Size of the retry queue.
* brokerId (consume form where when broker is normal) * **brokerId**: // Normally consume from this broker.
* whichBrokerWhenConsumeSlowly(consume form where when broker has problem) * **whichBrokerWhenConsumeSlowly**: // Which broker to consume from if issues arise.
* CLIENT (look over online consumer's client,include subscribe info and consume mode) * **Terminal**: View online consumer clients, including version, subscription information, and consumption mode.
* CONSUME DETAIL (look over this consumer's consume detail,broker offset and the consumer offset,queue consumed by which client) * **Consumption Details**: View detailed consumption information for the corresponding consumer group, including the consumption status of all subscribed topics and the consumer client for each queue (including retry messages).
* CONFIG check or change the consumer's config * **Configuration**: View and modify the consumer group's configuration.
* DELETE (delete the consumer group on selected group) * **Delete**: Delete the consumer group on the specified broker.
* **Query using Proxy**:
* **Consumption Page**:
* Supports filtering for **ordered consumption type subscription groups**.
* Provides **adding and updating for ordered consumption type subscription groups**. If ordered consumption needs to be enabled, the `consumeOrderlyEnable` option must be turned on for FIFO type subscription groups.
## Producer Page ![image-20250706143924854](UserGuide_CN/image-20250706143924854.png)
* Query online producer client by topic and group
* show client's server / version
## Message Page ---
* Query By Topic And Time
*Only Return 2000 Messagesthe message more than 2000 will be hide
* Query By Topic And Key
* Only Return 64 Messages
* Query By Topic And MessageId
* look over this message's detail info.you can see the message's consume state(each group has one line),show the exception message if has exception.
you can send this message to the group you selected
## Publishing Management Page
* Query online **message producer clients** by Topic and Group.
* Information includes client host and version.
## Access Dashboard with HTTPS ![image-20250706144100067](UserGuide_CN/image-20250706144100067.png)
* SpringBoot itself has provided the SSL configuration. You can use the project test Keystore:resources/rmqcngkeystore.jks. The store is generated with the following unix keytool commands:
``` ---
#Generate Keystore and add alias rmqcngKey
## Message Query Page
* Query by **Topic and Time Range**.
* Due to large data volume, a maximum of 2000 entries will be displayed; additional results will be ignored.
* Query by **Topic and Key**.
* A maximum of 64 entries will be displayed.
* Query messages by **message topic and message ID**.
* **Message Details** can display comprehensive information about the message and its consumption status by specific consumer groups (including specific error information if an exception occurred). Messages can be re-sent to specified consumer groups.
![image-20250706144145077](UserGuide_CN/image-20250706144145077.png)
---
## Proxy Page
* **Proxy Page** (New in RocketMQ 5.0)
* Supports adding and querying **proxy nodes**.
* Supports **proxy node address configuration**: `proxyAddr` and `proxyAddrs` properties can be pre-configured in `application.yml`.
![image-20250706144418694](UserGuide_CN/image-20250706144418694.png)
---
## ACL 2.0 Management Interface
* Supports querying **ACL rules** based on broker addresses.
* **Modification, addition, deletion, and lookup** of ACL rules.
* (ACL 1.0 is no longer supported.)
![image-20250706145313629](UserGuide_CN/image-20250706145313629.png)
---
## HTTPS Access to Dashboard
The HTTPS feature is implemented using Spring Boot's configuration capabilities. First, you'll need an **SSL KeyStore** to store the server certificate. You can use the test keystore provided by this project: `resources/rmqcngkeystore.jks`, which can be generated using the following `keytool` commands:
```bash
# Generate a keystore and add a private key with the alias 'rmqcngKey'
keytool -genkeypair -alias rmqcngKey -keyalg RSA -validity 3650 -keystore rmqcngkeystore.jks keytool -genkeypair -alias rmqcngKey -keyalg RSA -validity 3650 -keystore rmqcngkeystore.jks
# View keystore content # View keystore content
keytool -list -v -keystore rmqcngkeystore.jks keytool -list -v -keystore rmqcngkeystore.jks
#Transfer type as official # Convert keystore format
keytool -importkeystore -srckeystore rmqcngkeystore.jks -destkeystore rmqcngkeystore.jks -deststoretype pkcs12 keytool -importkeystore -srckeystore rmqcngkeystore.jks -destkeystore rmqcngkeystore.jks -deststoretype pkcs12
``` ```
* Uncomment the following SSL properties in resources/application.properties. restart Dashboard then access with HTTPS. Configure `resources/application.properties` by enabling the SSL-related options. HTTPS will be enabled after starting the dashboard.
```
#Set https port
```Properties
# Set HTTPS port
server.port=8443 server.port=8443
### SSL setting ### SSL setting
server.ssl.key-store=classpath:rmqcngkeystore.jks #server.ssl.key-store=classpath:rmqcngkeystore.jks
server.ssl.key-store-password=rocketmq #server.ssl.key-store-password=rocketmq
server.ssl.keyStoreType=PKCS12 #server.ssl.keyStoreType=PKCS12
server.ssl.keyAlias=rmqcngkey #server.ssl.keyAlias=rmqcngkey
``` ```
## Login/Logout on Dashboard ------
Access Dashboard with username and password and logout to leave the dashboardTo stage the function on, we need the steps below:
* 1.Turn on the property in resources/application.properties.
```$xslt
# open the login func ## Login and Access Dashboard
The Dashboard supports **logging in with a username and password** and logging out after operations. The following settings are required:
1. In the Spring configuration file `resources/application.properties`, modify `rocketmq.config.loginRequired=true` to **enable the login function**:
```Properties
# Enable login function
rocketmq.config.loginRequired=true rocketmq.config.loginRequired=true
# Directory of ashboard & login user configure file # Dashboard file directory, where the login user configuration file is located
rocketmq.config.dataPath=/tmp/rocketmq-console/data rocketmq.config.dataPath=/tmp/rocketmq-console/data
``` ```
* 2.Make sure the directory defined in property ${rocketmq.config.dataPath} exists and the file "users.properties" is created under it.
The dashboard system will use the resources/users.properties by default if a customized file is not found。
The format in the content of users.properties: 2. Ensure that the directory defined by `${rocketmq.config.dataPath}` exists and create a login configuration file named "**users.properties**" within it. If this file doesn't exist, `resources/users.properties` will be used by default. **Note:** If RocketMQ's ACL is enabled, the console must configure AK and SK. Additionally, `rocketmq.config.authmode` in `application.yml` needs to be set to `acl` and the login function must be enabled for proper operation. After logging in, the username and password from ACL 2.0 will be used to construct the RPCHook for communication with the broker. The format of the `users.properties` file is:
```$xslt
# This file supports hot change, any change will be auto-reloaded without Console restarting.
# Format: a user per line, username=password[,N] #N is optional, 0 (Normal User); 1 (Admin)
# Define Admin
```Properties
# This file supports hot modification; adding and modifying users does not require restarting the console.
# Format: Each line defines a user, username=password[,N] #N is optional, 0 for normal user; 1 for administrator.
# Define administrator
admin=admin,1 admin=admin,1
# Define Normal users # Define normal users
user1=user1 user1=user1
user2=user2 user2=user2
``` ```
* 3.Restart Console Application after above configuration setting well.
3. Starting the console will enable the login function.
------
## Permission Control
If the login function is enabled when a user accesses the Console, the user controls the access permission of the interface based on the login role.
* 1.Turn on the property in resources/application.properties. ## Permission Verification
```$xslt
# open the login func
If the user accesses the console with the login function enabled, interface access will be controlled based on the logged-in role.
1. In the Spring configuration file `resources/application.properties`, modify `rocketmq.config.loginRequired=true` to **enable the login function**:
```Properties
# Enable login function
rocketmq.config.loginRequired=true rocketmq.config.loginRequired=true
# Directory of ashboard & login user configure file # Dashboard file directory, where the login user configuration file is located
rocketmq.config.dataPath=/tmp/rocketmq-console/data rocketmq.config.dataPath=/tmp/rocketmq-console/data
``` ```
* 2.Make sure the directory defined in property ${rocketmq.config.dataPath} exists and the permission control file "role-permission.yml" is created under it.
The console system will use the resources/role-permission.yml by default if a customized file is not found。
The format in the content of role-permission.yml: 2. Ensure that the directory defined by `${rocketmq.config.dataPath}` exists and create an access permission configuration file named "**role-permission.yml**" within it. If this file doesn't exist, `resources/role-permission.yml` will be used by default. This file saves all accessible interface addresses for the normal user role. The format of the `role-permission.yml` file is:
```$xslt
# This file supports hot change, any change will be auto-reloaded without Console restarting. ```YAML
# Format: To add or delete interface permissions, add or delete interface addresses from the list. # This file supports hot modification; adding and deleting interface permissions directly in the list.
# the interface paths can be configured with wildcard characters. # Interface path configuration supports wildcards:
# ?: Matches 1 characters. # * Matches 0 or more characters that are not '/'.
# *: Matches 0 or more characters that are not /. # ** Matches 0 or more arbitrary characters.
# **: Matches 0 or more characters. # ? Matches 1 arbitrary character.
rolePerms: rolePerms:
# ordinary user # Normal user
ordinary: Normal:
- /rocketmq/nsaddr - /rocketmq/nsaddr
- /ops/* - /ops/*
- /dashboard/** - /dashboard/**
@@ -155,4 +228,5 @@ rolePerms:
- /monitor/* - /monitor/*
.... ....
``` ```
* 3.On the front page, operation buttons such as deleting and updating resources are not displayed for common users in order to better distinguish the rights of common users and admin users. If need to operate related resources, log out and use the admin role to log in
3. On the frontend page, to better distinguish between normal user and admin user permissions, operation buttons for resource deletion and updates are **not displayed for normal user roles**. To perform resource-related operations, you need to log out and log in with the admin role.

View File

@@ -0,0 +1 @@
REACT_APP_API_BASE_URL=http://localhost:8082

View File

@@ -0,0 +1 @@
REACT_APP_API_BASE_URL=

65
frontend-new/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm run start`
Runs the app in the development mode.\
Open [http://localhost:3003](http://localhost:3003) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

17522
frontend-new/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
frontend-new/package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "frontend-new",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.25.1",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^5.6.0",
"framer-motion": "^12.16.0",
"http-proxy-middleware": "^3.0.5",
"i18next": "^23.2.3",
"moment": "^2.30.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "14.1.3",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"react-toastify": "^11.0.5",
"redux": "^5.0.1",
"typescript": "^4.8.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "cross-env PORT=3003 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"cross-env": "^7.0.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -14,3 +14,22 @@
~ See the License for the specific language governing permissions and ~ See the License for the specific language governing permissions and
~ limitations under the License. ~ limitations under the License.
--> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link rel="shortcut icon" href="./favicon.ico" type="image/x-icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content=""/>
<meta name="keywords" content=""/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>RocketMQ Dashboard</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<!-- React App will mount here -->
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

17
frontend-new/src/App.css Normal file
View File

@@ -0,0 +1,17 @@
/*
* 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.
*/

View File

@@ -14,41 +14,25 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
.App {
text-align: center; import React from 'react';
import AppRouter from './router'; // router/index.jsx
import {ToastContainer} from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import {ConfigProvider} from "antd";
import {useTheme} from "./store/context/ThemeContext";
function App() {
const {currentTheme} = useTheme();
return (
<>
<ConfigProvider theme={currentTheme}>
<ToastContainer/>
<AppRouter/>
</ConfigProvider>
</>
);
} }
.App-logo { export default App;
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 40vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import {render, screen} from '@testing-library/react'; import {render, screen} from '@testing-library/react';
import App from './App'; import App from './App';

View File

@@ -0,0 +1,993 @@
/*
* 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.
*/
const appConfig = {
apiBaseUrl: process.env.REACT_APP_API_BASE_URL || window.location.origin
};
let _redirectHandler = null;
const remoteApi = {
setRedirectHandler: (handler) => {
_redirectHandler = handler;
},
buildUrl: (endpoint) => {
if (endpoint.charAt(0) === '/') {
endpoint = endpoint.substring(1);
}
return `${appConfig.apiBaseUrl}/${endpoint}`;
},
async getCsrfToken() {
const csrfToken = this.getCookie();
if (csrfToken) {
return csrfToken;
}
const response = await fetch(remoteApi.buildUrl("/rocketmq-dashboard/csrf-token"), {
method: 'GET',
credentials: 'include'
});
const newCsrfToken = this.getCookie();
if (!newCsrfToken) {
console.error("Failed to get CSRF Token");
throw new Error("CSRF Token not available");
}
return newCsrfToken;
},
getCookie() {
return document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, '$1')
},
_fetch: async (url, options = {}) => {
const headers = {
...options.headers,
'Content-Type': 'application/json',
};
const csrfToken = await remoteApi.getCsrfToken();
console.log(csrfToken)
if (!csrfToken) {
console.warn('CSRF Token not found');
}else{
headers["X-XSRF-TOKEN"] = csrfToken;
}
console.log(csrfToken)
try {
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
if (response.redirected) {
if (_redirectHandler) {
_redirectHandler();
}
return {__isRedirectHandled: true};
}
if(response.status == 403){
window.localStorage.removeItem("csrfToken");
console.log(111)
await remoteApi.getCsrfToken()
}
return response;
} catch (error) {
console.error('fetch error:', error);
window.localStorage.removeItem("csrfToken");
console.log(111)
await remoteApi.getCsrfToken()
}
},
queryTopic: async (skipSysProcess) => {
try {
const params = new URLSearchParams();
if (skipSysProcess) {
params.append('skipSysProcess', 'true');
}
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/list.query?${params.toString()}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching topic list:", error);
}
},
listUsers: async (brokerName, clusterName) => {
const params = new URLSearchParams();
if (brokerName) params.append('brokerName', brokerName);
if (clusterName) params.append('clusterName', clusterName);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/users.query?${params.toString()}`));
return await response.json();
},
createUser: async (brokerName, userInfo, clusterName) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/createUser.do'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brokerName, userInfo, clusterName})
});
return await response.json();
},
updateUser: async (brokerName, userInfo, clusterName) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/updateUser.do'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brokerName, userInfo, clusterName})
});
return await response.json();
},
deleteUser: async (brokerName, username, clusterName) => {
const params = new URLSearchParams();
if (brokerName) params.append('brokerName', brokerName);
if (clusterName) params.append('clusterName', clusterName);
params.append('username', username);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/deleteUser.do?${params.toString()}`), {
method: 'DELETE'
});
return await response.json();
},
listAcls: async (brokerName, searchParam, clusterName) => {
const params = new URLSearchParams();
if (brokerName) params.append('brokerName', brokerName);
if (clusterName) params.append('clusterName', clusterName);
if (searchParam) params.append('searchParam', searchParam);
if (searchParam != null) console.log(1111)
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/acls.query?${params.toString()}`));
return await response.json();
},
createAcl: async (brokerName, subject, policies, clusterName) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/createAcl.do'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brokerName, subject, policies, clusterName})
});
return await response.json();
},
updateAcl: async (brokerName, subject, policies, clusterName) => {
const response = await remoteApi._fetch(remoteApi.buildUrl('/acl/updateAcl.do'), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brokerName, subject, policies, clusterName})
});
return await response.json();
},
deleteAcl: async (brokerName, subject, resource, clusterName) => {
const params = new URLSearchParams();
if (brokerName) params.append('brokerAddress', brokerName);
params.append('subject', subject);
if (resource) params.append('resource', resource);
if (clusterName) params.append('clusterName', clusterName);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/acl/deleteAcl.do?${params.toString()}`), {
method: 'DELETE'
});
return await response.json();
},
queryMessageByMessageId: async (msgId, topic, callback) => {
try {
const params = new URLSearchParams();
params.append('msgId', msgId);
params.append('topic', topic);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/messageTrace/viewMessage.query?${params.toString()}`));
const data = await response.json();
return data
} catch (error) {
console.error("Error querying message by ID:", error);
callback({status: 1, errMsg: "Failed to query message by ID"});
}
},
queryMessageTraceByMessageId: async (msgId, traceTopic, callback) => {
try {
const params = new URLSearchParams();
params.append('msgId', msgId);
params.append('traceTopic', traceTopic);
const response = await remoteApi._fetch(remoteApi.buildUrl(`/messageTrace/viewMessageTraceGraph.query?${params.toString()}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying message trace:", error);
}
},
queryDlqMessageByConsumerGroup: async (consumerGroup, beginTime, endTime, pageNum, pageSize, taskId) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/dlqMessage/queryDlqMessageByConsumerGroup.query"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: `%DLQ%${consumerGroup}`,
begin: beginTime,
end: endTime,
pageNum: pageNum,
pageSize: pageSize,
taskId: taskId,
}),
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying DLQ messages by consumer group:", error);
return {status: 1, errMsg: "Failed to query DLQ messages by consumer group"};
}
},
resendDlqMessage: async (msgId, consumerGroup, topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/message/consumeMessageDirectly.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
params: {
msgId: msgId,
consumerGroup: consumerGroup,
topic: topic
},
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error resending DLQ message:", error);
return {status: 1, errMsg: "Failed to resend DLQ message"};
}
},
exportDlqMessage: async (msgId, consumerGroup) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/dlqMessage/exportDlqMessage.do?msgId=${msgId}&consumerGroup=${consumerGroup}`));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const newWindow = window.open('', '_blank');
if (!newWindow) {
return {status: 1, errMsg: "Failed to open new window. Please allow pop-ups for this site."};
}
newWindow.document.write('<html><head><title>DLQ 导出内容</title></head><body>');
newWindow.document.write('<h1>DLQ 导出 JSON 内容</h1>');
newWindow.document.write('<pre>' + JSON.stringify(data, null, 2) + '</pre>');
newWindow.document.write('</body></html>');
newWindow.document.close();
return {status: 0, msg: "导出请求成功,内容已在新页面显示"};
} catch (error) {
console.error("Error exporting DLQ message:", error);
return {status: 1, errMsg: "Failed to export DLQ message: " + error.message};
}
},
batchResendDlqMessage: async (messages) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/dlqMessage/batchResendDlqMessage.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error batch resending DLQ messages:", error);
return {status: 1, errMsg: "Failed to batch resend DLQ messages"};
}
},
/**
* Queries messages by topic with pagination.
* @param {string} topic The topic to query.
* @param {number} begin Timestamp in milliseconds for the start time.
* @param {number} end Timestamp in milliseconds for the end time.
* @param {number} pageNum The current page number (1-based).
* @param {number} pageSize The number of items per page.
* @param {string} taskId Optional task ID for continuous queries.
* @returns {Promise<Object>} The API response.
*/
queryMessagePageByTopic: async (topic, begin, end, pageNum, pageSize, taskId) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/message/queryMessagePageByTopic.query"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: topic,
begin: begin,
end: end,
pageNum: pageNum,
pageSize: pageSize,
taskId: taskId
})
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message page by topic:", error);
}
},
/**
* Queries messages by topic and key.
* @param {string} topic The topic to query.
* @param {string} key The message key to query.
* @returns {Promise<Object>} The API response.
*/
queryMessageByTopicAndKey: async (topic, key) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/message/queryMessageByTopicAndKey.query?topic=${encodeURIComponent(topic)}&key=${key}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message by topic and key:", error);
}
},
/**
* Views a message by its message ID and topic.
* @param {string} msgId The message ID.
* @param {string} topic The topic of the message.
* @returns {Promise<Object>} The API response.
*/
viewMessage: async (msgId, topic) => {
try {
const encodedTopic = encodeURIComponent(topic);
const url = remoteApi.buildUrl(
`/message/viewMessage.query?msgId=${msgId}&topic=${encodedTopic}`
);
const response = await remoteApi._fetch(url);
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching message by message ID:", error);
}
},
/**
* Resends a message directly to a consumer group.
* @param {string} msgId The message ID.
* @param {string} consumerGroup The consumer group to resend to.
* @param {string} topic The topic of the message.
* @returns {Promise<Object>} The API response.
*/
resendMessageDirectly: async (msgId, consumerGroup, topic) => {
topic = encodeURIComponent(topic)
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/message/consumeMessageDirectly.do?msgId=${msgId}&consumerGroup=${consumerGroup}&topic=${topic}`), {
method: 'POST',
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error resending message directly:", error);
}
},
queryProducerConnection: async (topic, producerGroup, callback) => {
topic = encodeURIComponent(topic)
producerGroup = encodeURIComponent(producerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/producer/producerConnection.query?topic=${topic}&producerGroup=${producerGroup}`));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching producer connection list:", error);
callback({status: 1, errMsg: "Failed to fetch producer connection list"}); // Simulate error response
}
},
queryConsumerGroupList: async (skipSysGroup, address) => {
if (address === undefined) {
address = ""
}
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/groupList.query?skipSysGroup=${skipSysGroup}&address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching consumer group list:", error);
return {status: 1, errMsg: "Failed to fetch consumer group list"};
}
},
refreshConsumerGroup: async (consumerGroup) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/group.refresh?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error refreshing consumer group ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to refresh consumer group ${consumerGroup}`};
}
},
refreshAllConsumerGroup: async (address) => {
if (address === undefined) {
address = ""
}
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/group.refresh.all?address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error("Error refreshing all consumer groups:", error);
return {status: 1, errMsg: "Failed to refresh all consumer groups"};
}
},
queryConsumerMonitorConfig: async (consumeGroupName) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/monitor/consumerMonitorConfigByGroupName.query?consumeGroupName=${consumeGroupName}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching monitor config for ${consumeGroupName}:`, error);
return {status: 1, errMsg: `Failed to fetch monitor config for ${consumeGroupName}`};
}
},
createOrUpdateConsumerMonitor: async (consumeGroupName, minCount, maxDiffTotal) => {
consumeGroupName = encodeURIComponent(consumeGroupName)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/monitor/createOrUpdateConsumerMonitor.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({consumeGroupName, minCount, maxDiffTotal})
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error creating or updating consumer monitor:", error);
return {status: 1, errMsg: "Failed to create or update consumer monitor"};
}
},
fetchBrokerNameList: async (consumerGroup) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/fetchBrokerNameList.query?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching broker name list for ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to fetch broker name list for ${consumerGroup}`};
}
},
deleteConsumerGroup: async (groupName, brokerNameList) => {
groupName = encodeURIComponent(groupName)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/deleteSubGroup.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({groupName, brokerNameList})
});
const data = await response.json();
return data;
} catch (error) {
console.error(`Error deleting consumer group ${groupName}:`, error);
return {status: 1, errMsg: `Failed to delete consumer group ${groupName}`};
}
},
queryConsumerConfig: async (consumerGroup) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/examineSubscriptionGroupConfig.query?consumerGroup=${consumerGroup}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching consumer config for ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to fetch consumer config for ${consumerGroup}`};
}
},
createOrUpdateConsumer: async (consumerRequest) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/createOrUpdate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(consumerRequest)
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error creating or updating consumer:", error);
return {status: 1, errMsg: "Failed to create or update consumer"};
}
},
queryTopicByConsumer: async (consumerGroup, address) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/queryTopicByConsumer.query?consumerGroup=${consumerGroup}&address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching topics for consumer group ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to fetch topics for consumer group ${consumerGroup}`};
}
},
queryConsumerConnection: async (consumerGroup, address) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/consumerConnection.query?consumerGroup=${consumerGroup}&address=${address}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching consumer connections for ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to fetch consumer connections for ${consumerGroup}`};
}
},
queryConsumerRunningInfo: async (consumerGroup, clientId, jstack = false) => {
consumerGroup = encodeURIComponent(consumerGroup)
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/consumerRunningInfo.query?consumerGroup=${consumerGroup}&clientId=${clientId}&jstack=${jstack}`));
const data = await response.json();
return data;
} catch (error) {
console.error(`Error fetching running info for client ${clientId} in group ${consumerGroup}:`, error);
return {status: 1, errMsg: `Failed to fetch running info for client ${clientId} in group ${consumerGroup}`};
}
},
queryTopicList: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/list.queryTopicType"));
return await response.json();
} catch (error) {
console.error("Error fetching topic list:", error);
return {status: 1, errMsg: "Failed to fetch topic list"};
}
},
deleteTopic: async (topic) => {
try {
const url = remoteApi.buildUrl(`/topic/deleteTopic.do?topic=${encodeURIComponent(topic)}`);
const response = await remoteApi._fetch(url, {
method: 'POST', // 仍然使用 POST 方法,但参数在 URL 中
headers: {
'Content-Type': 'application/json', // 可以根据你的后端需求决定是否需要这个 header
},
// body: JSON.stringify({ topic }) // 移除 body
});
return await response.json();
} catch (error) {
console.error("Error deleting topic:", error);
return {status: 1, errMsg: "Failed to delete topic"};
}
},
getTopicStats: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/stats.query?topic=${encodeURIComponent(topic)}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic stats:", error);
return {status: 1, errMsg: "Failed to fetch topic stats"};
}
},
getTopicRoute: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/route.query?topic=${encodeURIComponent(topic)}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic route:", error);
return {status: 1, errMsg: "Failed to fetch topic route"};
}
},
getTopicConsumers: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/queryConsumerByTopic.query?topic=${encodeURIComponent(topic)}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic consumers:", error);
return {status: 1, errMsg: "Failed to fetch topic consumers"};
}
},
getTopicConsumerGroups: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/queryTopicConsumerInfo.query?topic=${encodeURIComponent(topic)}`));
return await response.json();
} catch (error) {
console.error("Error fetching consumer groups:", error);
return {status: 1, errMsg: "Failed to fetch consumer groups"};
}
},
getTopicConfig: async (topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/topic/examineTopicConfig.query?topic=${encodeURIComponent(topic)}`));
return await response.json();
} catch (error) {
console.error("Error fetching topic config:", error);
return {status: 1, errMsg: "Failed to fetch topic config"};
}
},
getClusterList: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/cluster/list.query"));
return await response.json();
} catch (error) {
console.error("Error fetching cluster list:", error);
return {status: 1, errMsg: "Failed to fetch cluster list"};
}
},
createOrUpdateTopic: async (topicData) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/createOrUpdate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(topicData)
});
return await response.json();
} catch (error) {
console.error("Error creating/updating topic:", error);
return {status: 1, errMsg: "Failed to create/update topic"};
}
},
resetConsumerOffset: async (data) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/resetOffset.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
return await response.json();
} catch (error) {
console.error("Error resetting consumer offset:", error);
return {status: 1, errMsg: "Failed to reset consumer offset"};
}
},
skipMessageAccumulate: async (data) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/skipAccumulate.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
return await response.json();
} catch (error) {
console.error("Error skipping message accumulate:", error);
return {status: 1, errMsg: "Failed to skip message accumulate"};
}
},
sendTopicMessage: async (messageData) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/sendTopicMessage.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(messageData)
});
return await response.json();
} catch (error) {
console.error("Error sending topic message:", error);
return {status: 1, errMsg: "Failed to send topic message"};
}
},
deleteTopicByBroker: async (brokerName, topic) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/topic/deleteTopicByBroker.do"), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({brokerName, topic})
});
return await response.json();
} catch (error) {
console.error("Error deleting topic by broker:", error);
return {status: 1, errMsg: "Failed to delete topic by broker"};
}
},
// New API methods for Ops page
queryOpsHomePage: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/ops/homePage.query"));
return await response.json();
} catch (error) {
console.error("Error fetching ops home page data:", error);
return {status: 1, errMsg: "Failed to fetch ops home page data"};
}
},
updateNameSvrAddr: async (nameSvrAddr) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateNameSvrAddr.do?nameSvrAddrList=${encodeURIComponent(nameSvrAddr)}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating NameServer address:", error);
return {status: 1, errMsg: "Failed to update NameServer address"};
}
},
addNameSvrAddr: async (newNamesrvAddr) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/addNameSvrAddr.do?newNamesrvAddr=${encodeURIComponent(newNamesrvAddr)}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error adding NameServer address:", error);
return {status: 1, errMsg: "Failed to add NameServer address"};
}
},
updateIsVIPChannel: async (useVIPChannel) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateIsVIPChannel.do?useVIPChannel=${useVIPChannel}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating VIP Channel status:", error);
return {status: 1, errMsg: "Failed to update VIP Channel status"};
}
},
updateUseTLS: async (useTLS) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/ops/updateUseTLS.do?useTLS=${useTLS}`), {
method: 'POST',
});
return await response.json();
} catch (error) {
console.error("Error updating TLS status:", error);
return {status: 1, errMsg: "Failed to update TLS status"};
}
},
queryClusterList: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/cluster/list.query"));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching cluster list:", error);
callback({status: 1, errMsg: "Failed to fetch cluster list"});
}
},
queryBrokerHisData: async (date, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/dashboard/broker.query'));
url.searchParams.append('date', date);
const response = await remoteApi._fetch(url.toString(), {signal: AbortSignal.timeout(15000)}); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Broker history data request timed out:", error);
callback({status: 1, errMsg: "Request timed out for broker history data"});
} else {
console.error("Error fetching broker history data:", error);
callback({status: 1, errMsg: "Failed to fetch broker history data"});
}
}
},
queryTopicHisData: async (date, topicName, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/dashboard/topic.query'));
url.searchParams.append('date', date);
url.searchParams.append('topicName', topicName);
const response = await remoteApi._fetch(url.toString(), {signal: AbortSignal.timeout(15000)}); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Topic history data request timed out:", error);
callback({status: 1, errMsg: "Request timed out for topic history data"});
} else {
console.error("Error fetching topic history data:", error);
callback({status: 1, errMsg: "Failed to fetch topic history data"});
}
}
},
queryTopicCurrentData: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl('/dashboard/topicCurrent.query'), {signal: AbortSignal.timeout(15000)}); // 15s timeout
const data = await response.json();
callback(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.error("Topic current data request timed out:", error);
callback({status: 1, errMsg: "Request timed out for topic current data"});
} else {
console.error("Error fetching topic current data:", error);
callback({status: 1, errMsg: "Failed to fetch topic current data"});
}
}
},
queryBrokerConfig: async (brokerAddr, callback) => {
try {
const url = new URL(remoteApi.buildUrl('/cluster/brokerConfig.query'));
url.searchParams.append('brokerAddr', brokerAddr);
const response = await remoteApi._fetch(url.toString());
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching broker config:", error);
callback({status: 1, errMsg: "Failed to fetch broker config"});
}
},
/**
* 查询 Proxy 首页信息,包括地址列表和当前 Proxy 地址
*/
queryProxyHomePage: async (callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/proxy/homePage.query"));
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error fetching proxy home page:", error);
callback({status: 1, errMsg: "Failed to fetch proxy home page"});
}
},
/**
* 添加新的 Proxy 地址
*/
addProxyAddr: async (newProxyAddr, callback) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/proxy/addProxyAddr.do"), {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({newProxyAddr}).toString()
});
const data = await response.json();
callback(data);
} catch (error) {
console.error("Error adding proxy address:", error);
callback({status: 1, errMsg: "Failed to add proxy address"});
}
},
login: async (username, password) => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/login/login.do"), {
method: 'POST',
body: JSON.stringify({
username: username,
password: password
}),
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
return data;
} catch (error) {
console.error("Error logging in:", error);
return {status: 1, errMsg: "Failed to log in"};
}
},
logout: async () => {
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/login/logout.do"), {
method: 'POST'
});
return await response.json()
} catch (error) {
console.error("Error logging out:", error);
return {status: 1, errMsg: "Failed to log out"};
}
}
};
const tools = {
dashboardRefreshTime: 5000,
generateBrokerMap: (brokerServer, clusterAddrTable, brokerAddrTable) => {
const clusterMap = {};
Object.entries(clusterAddrTable).forEach(([clusterName, brokerNamesInCluster]) => {
clusterMap[clusterName] = [];
brokerNamesInCluster.forEach(brokerName => {
const brokerAddrs = brokerAddrTable[brokerName]?.brokerAddrs;
if (brokerAddrs) {
Object.entries(brokerAddrs).forEach(([brokerIdStr, address]) => {
const brokerId = parseInt(brokerIdStr);
const detail = brokerServer[brokerName]?.[brokerIdStr];
if (detail) {
clusterMap[clusterName].push({
brokerName: brokerName,
brokerId: brokerId,
address: address,
...detail,
detail: detail,
brokerConfig: {},
});
} else {
console.warn(`No detail found for broker: ${brokerName} with ID: ${brokerIdStr}`);
}
});
} else {
console.warn(`No addresses found for brokerName: ${brokerName} in brokerAddrTable`);
}
});
});
return clusterMap;
}
};
export {remoteApi, tools};

View File

@@ -0,0 +1,108 @@
/*
* 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.
*/
export const defaultTheme = {
token: {
colorPrimary: '#0cb5fb', // 主题色
borderRadius: 1.5, // 组件圆角
},
components: {
Button: {
colorPrimary: '#1c324a', // 普通按钮主题色
},
Layout: {
headerBg: '#1c324a', // 设置 Header 的背景色为 #1c324a
headerColor: '#ffffff', // 设置 Header 内文本颜色为白色
backgroundColor: '#ffffff', // 设置 Layout 的背景色为白色
colorBgLayout: '#f9fcfe',
},
Menu: {
darkItemBg: '#1c324a',
horizontalItemSelectedBg: '#0cb5fb',
itemSelectedColor: '#ffffff',
itemColor: '#ffffff',
colorText: 'rgba(255, 255, 255, 0.88)', // Adjust for dark theme menu
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#1c324a', // Drawer 背景色
},
},
};
export const pinkTheme = {
token: {
colorPrimary: '#FF69B4', // 热粉色
borderRadius: 1.5,
},
components: {
Button: {
colorPrimary: '#FFC0CB', // 深粉色
},
Layout: {
headerBg: '#FFC0CB', // 粉色
headerColor: '#000000', // 黑色文本
backgroundColor: '#F8F8FF', // 幽灵白
colorBgLayout: '#faf4f4',
},
Menu: {
darkItemBg: '#FFC0CB', // 粉色
horizontalItemSelectedBg: '#FF69B4',
itemSelectedColor: '#ffffff',
itemColor: '#000000', // 黑色文本
colorText: 'rgba(0, 0, 0, 0.88)',
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#FFC0CB', // Drawer 背景色
},
},
};
export const greenTheme = {
token: {
colorPrimary: '#52c41a', // 绿色
borderRadius: 1.5,
},
components: {
Button: {
colorPrimary: '#7cb305', // 橄榄绿
},
Layout: {
headerBg: '#3f673f', // 深绿色
headerColor: '#ffffff', // 白色文本
backgroundColor: '#f6ffed',
colorBgLayout: '#ebf8eb',
},
Menu: {
darkItemBg: '#3f673f', // 深绿色
horizontalItemSelectedBg: '#52c41a',
itemSelectedColor: '#ffffff',
itemColor: '#ffffff',
colorText: 'rgba(255, 255, 255, 0.88)',
activeBarBorderWidth: 0,
},
Drawer: {
colorBgElevated: '#3f673f', // Drawer 背景色
},
},
};
export const themes = {
default: defaultTheme,
pink: pinkTheme,
green: greenTheme,
};

View File

@@ -0,0 +1,73 @@
/*
* 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 {Form, Input, Typography} from 'antd';
import moment from 'moment';
import {useLanguage} from '../i18n/LanguageContext'; // 根据实际路径调整
const {Text} = Typography;
const DlqMessageDetailViewDialog = ({ngDialogData}) => {
const {t} = useLanguage();
const messageView = ngDialogData?.messageView || {};
return (
<div style={{padding: '20px'}}>
<Form layout="horizontal" labelCol={{span: 4}} wrapperCol={{span: 20}}>
<Form.Item label="Message ID:">
<Text strong>{messageView.msgId}</Text>
</Form.Item>
<Form.Item label="Topic:">
<Text strong>{messageView.topic}</Text>
</Form.Item>
<Form.Item label="Properties:">
<Input.TextArea
value={typeof messageView.properties === 'object' ? JSON.stringify(messageView.properties, null, 2) : messageView.properties}
style={{minHeight: 100, resize: 'none'}}
readOnly
/>
</Form.Item>
<Form.Item label="ReconsumeTimes:">
<Text strong>{messageView.reconsumeTimes}</Text>
</Form.Item>
<Form.Item label="Tag:">
<Text strong>{messageView.properties?.TAGS}</Text>
</Form.Item>
<Form.Item label="Key:">
<Text strong>{messageView.properties?.KEYS}</Text>
</Form.Item>
<Form.Item label="Storetime:">
<Text strong>{moment(messageView.storeTimestamp).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Form.Item>
<Form.Item label="StoreHost:">
<Text strong>{messageView.storeHost}</Text>
</Form.Item>
<Form.Item label="Message body:">
<Input.TextArea
value={messageView.messageBody}
style={{minHeight: 100, resize: 'none'}}
readOnly
/>
</Form.Item>
</Form>
</div>
);
};
export default DlqMessageDetailViewDialog;

View File

@@ -0,0 +1,224 @@
/*
* 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 {Button, Descriptions, Modal, notification, Spin, Tag, Typography} from 'antd';
import moment from 'moment';
import {SyncOutlined} from '@ant-design/icons';
import {useLanguage} from '../i18n/LanguageContext';
import {remoteApi} from '../api/remoteApi/remoteApi'; // 确保这个路径正确
const {Text, Paragraph} = Typography;
const MessageDetailViewDialog = ({visible, onCancel, messageId, topic, onResendMessage}) => {
const {t} = useLanguage();
const [loading, setLoading] = React.useState(true);
const [messageDetail, setMessageDetail] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const fetchMessageDetail = async () => {
// 只有当 visible 为 true 且 messageId 和 topic 存在时才进行数据请求
if (!visible || !messageId || !topic) {
// 如果 Modal 不可见或者必要参数缺失,则不加载数据
setMessageDetail(null); // 清空旧数据
setError(null); // 清空错误信息
setLoading(false); // 停止加载状态
return;
}
setLoading(true);
setError(null); // 在每次新的请求前清除之前的错误
try {
const resp = await remoteApi.viewMessage(messageId, topic);
if (resp.status === 0) {
setMessageDetail(resp.data);
} else {
const errorMessage = resp.errMsg || t.FETCH_MESSAGE_DETAIL_FAILED;
setError(errorMessage);
notification.error({
message: t.ERROR,
description: errorMessage,
});
}
} catch (err) {
const errorMessage = t.FETCH_MESSAGE_DETAIL_FAILED;
setError(errorMessage);
notification.error({
message: t.ERROR,
description: errorMessage,
});
console.error("Error fetching message detail:", err);
} finally {
setLoading(false);
}
};
fetchMessageDetail();
}, [visible, messageId, topic, t]); // 依赖项中添加 visible确保在 Modal 显示时触发
// handleShowExceptionDesc 方法不再需要,因为我们直接使用 Paragraph 的 ellipsis
return (
<Modal
title={t.MESSAGE_DETAIL}
open={visible} // Ant Design 5.x 版本中visible 属性已更名为 open
onCancel={onCancel}
footer={[
<Button key="close" onClick={onCancel}>
{t.CLOSE}
</Button>,
]}
width={900}
destroyOnHidden={true} // 使用新的 destroyOnHidden 替代 destroyOnClose
>
<Spin spinning={loading} tip={t.LOADING}>
{error && (
<Paragraph type="danger" style={{textAlign: 'center'}}>
{error}
</Paragraph>
)}
{messageDetail ? ( // 确保 messageDetail 存在时才渲染内容
<>
{/* 消息信息部分 */}
<Descriptions title={<Text strong>{t.MESSAGE_INFO}</Text>} bordered column={2} size="small"
style={{marginBottom: 20}}>
<Descriptions.Item label="Topic" span={2}><Text
copyable>{messageDetail.messageView.topic}</Text></Descriptions.Item>
<Descriptions.Item label="Message ID" span={2}><Text
copyable>{messageDetail.messageView.msgId}</Text></Descriptions.Item>
<Descriptions.Item
label="StoreHost">{messageDetail.messageView.storeHost}</Descriptions.Item>
<Descriptions.Item label="BornHost">{messageDetail.messageView.bornHost}</Descriptions.Item>
<Descriptions.Item label="StoreTime">
{moment(messageDetail.messageView.storeTimestamp).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="BornTime">
{moment(messageDetail.messageView.bornTimestamp).format("YYYY-MM-DD HH:mm:ss")}
</Descriptions.Item>
<Descriptions.Item label="Queue ID">{messageDetail.messageView.queueId}</Descriptions.Item>
<Descriptions.Item
label="Queue Offset">{messageDetail.messageView.queueOffset}</Descriptions.Item>
<Descriptions.Item
label="StoreSize">{messageDetail.messageView.storeSize} bytes</Descriptions.Item>
<Descriptions.Item
label="ReconsumeTimes">{messageDetail.messageView.reconsumeTimes}</Descriptions.Item>
<Descriptions.Item label="BodyCRC">{messageDetail.messageView.bodyCRC}</Descriptions.Item>
<Descriptions.Item label="SysFlag">{messageDetail.messageView.sysFlag}</Descriptions.Item>
<Descriptions.Item label="Flag">{messageDetail.messageView.flag}</Descriptions.Item>
<Descriptions.Item
label="PreparedTransactionOffset">{messageDetail.messageView.preparedTransactionOffset}</Descriptions.Item>
</Descriptions>
{/* 消息属性部分 */}
{Object.keys(messageDetail.messageView.properties).length > 0 && (
<Descriptions title={<Text strong>{t.MESSAGE_PROPERTIES}</Text>} bordered column={1}
size="small" style={{marginBottom: 20}}>
{Object.entries(messageDetail.messageView.properties).map(([key, value]) => (
<Descriptions.Item label={key} key={key}><Text
copyable>{value}</Text></Descriptions.Item>
))}
</Descriptions>
)}
{/* 消息体部分 */}
<Descriptions title={<Text strong>{t.MESSAGE_BODY}</Text>} bordered column={1} size="small"
style={{marginBottom: 20}}>
<Descriptions.Item>
<Paragraph
copyable
ellipsis={{
rows: 5,
expandable: true,
symbol: t.SHOW_ALL_CONTENT,
}}
>
{messageDetail.messageView.messageBody}
</Paragraph>
</Descriptions.Item>
</Descriptions>
{/* 消息轨迹列表部分 */}
{messageDetail.messageTrackList && messageDetail.messageTrackList.length > 0 ? (
<>
<Text strong>{t.MESSAGE_TRACKING}</Text>
<div style={{marginTop: 10}}>
{messageDetail.messageTrackList.map((track, index) => (
<Descriptions bordered column={1} size="small" key={index}
style={{marginBottom: 15}}>
<Descriptions.Item label={t.CONSUMER_GROUP}>
{track.consumerGroup}
</Descriptions.Item>
<Descriptions.Item label={t.TRACK_TYPE}>
<Tag color={
track.trackType === 'CONSUMED_SOME_TIME_OK' ? 'success' :
track.trackType === 'NOT_ONLINE' ? 'default' :
track.trackType === 'PULL_SUCCESS' ? 'processing' :
track.trackType === 'NO_MATCHED_CONSUMER' ? 'warning' :
'error'
}>
{track.trackType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t.OPERATION}>
<Button
icon={<SyncOutlined/>}
onClick={() => onResendMessage(messageDetail.messageView, track.consumerGroup)}
size="small"
style={{marginRight: 8}}
>
{t.RESEND_MESSAGE}
</Button>
{/* 移除“查看异常”按钮,因为现在直接在下方展示可展开内容 */}
</Descriptions.Item>
{track.exceptionDesc && (
<Descriptions.Item label={t.EXCEPTION_SUMMARY}>
{/* 异常信息截断显示,点击“查看更多”可展开 */}
<Paragraph
ellipsis={{
rows: 2, // 默认显示2行
expandable: true,
symbol: <Text style={{
color: '#1890ff',
cursor: 'pointer'
}}>{t.READ_MORE}</Text>, // 蓝色展开文本
}}
>
{track.exceptionDesc}
</Paragraph>
</Descriptions.Item>
)}
</Descriptions>
))}
</div>
</>
) : (
<Paragraph>{t.NO_TRACKING_INFO}</Paragraph>
)}
</>
) : (
// 当 messageDetail 为 null 时,可以显示一个占位符或者不显示内容
!loading && !error &&
<Paragraph style={{textAlign: 'center'}}>{t.NO_MESSAGE_DETAIL_AVAILABLE}</Paragraph>
)}
</Spin>
</Modal>
);
};
export default MessageDetailViewDialog;

View File

@@ -0,0 +1,567 @@
/*
* 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, {useEffect, useRef} from 'react';
import {Collapse, Form, Input, Table, Tag, Typography} from 'antd';
import moment from 'moment';
import {useLanguage} from '../i18n/LanguageContext';
import Paragraph from "antd/es/skeleton/Paragraph";
import * as echarts from 'echarts'; // Import ECharts
const {Text} = Typography;
const {Panel} = Collapse;
// Constants for styling and formatting, derived from the example
const SUCCESS_COLOR = '#75d874';
const ERROR_COLOR = 'red';
const UNKNOWN_COLOR = 'yellow';
const TRANSACTION_COMMIT_COLOR = SUCCESS_COLOR;
const TRANSACTION_ROLLBACK_COLOR = ERROR_COLOR;
const TRANSACTION_UNKNOWN_COLOR = 'grey';
const TIME_FORMAT_PATTERN = "YYYY-MM-DD HH:mm:ss.SSS";
const DEFAULT_DISPLAY_DURATION = 10 * 1000;
const TRANSACTION_CHECK_COST_TIME = 50; // transactionTraceNode do not have costTime, assume it cost 50ms
const MessageTraceDetailViewDialog = ({ngDialogData}) => {
const {t} = useLanguage();
const messageTraceGraphRef = useRef(null);
const producerNode = ngDialogData?.producerNode;
const subscriptionNodeList = ngDialogData?.subscriptionNodeList || [];
const messageTraceViews = ngDialogData?.messageTraceViews || []; // This data structure seems redundant for the Gantt chart but can be used for extra tooltip details.
useEffect(() => {
if (messageTraceGraphRef.current && ngDialogData) {
const chart = echarts.init(messageTraceGraphRef.current);
let data = [];
let dataZoomEnd = 100;
let startTime = Number.MAX_VALUE;
let endTime = 0;
let messageGroups = []; // This will be our Y-axis categories
if (producerNode) {
startTime = +producerNode.traceNode.beginTimestamp;
endTime = +producerNode.traceNode.endTimestamp;
}
// Helper functions from the provided example
function buildNodeColor(traceNode) {
if (traceNode.transactionState != null) {
switch (traceNode.transactionState) {
case 'COMMIT_MESSAGE':
return TRANSACTION_COMMIT_COLOR;
case 'ROLLBACK_MESSAGE':
return TRANSACTION_ROLLBACK_COLOR;
case 'UNKNOW':
return TRANSACTION_UNKNOWN_COLOR;
default:
return ERROR_COLOR;
}
}
switch (traceNode.status) {
case 'FAILED': // Changed 'failed' to 'FAILED' to match backend typically
return ERROR_COLOR;
case 'UNKNOWN': // Changed 'unknown' to 'UNKNOWN'
return UNKNOWN_COLOR;
default:
return SUCCESS_COLOR;
}
}
function formatXAxisTime(value) {
let duration = Math.max(0, value - startTime);
if (duration < 1000)
return timeFormat(duration, 'ms');
duration /= 1000;
if (duration < 60)
return timeFormat(duration, 's');
duration /= 60;
if (duration < 60)
return timeFormat(duration, 'min');
duration /= 60;
return timeFormat(duration, 'h');
}
function timeFormat(duration, unit) {
return duration.toFixed(2) + unit;
}
function buildTraceInfo(itemName, itemValue) {
if (itemValue) {
return `${itemName}: ${itemValue}<br />`
}
return "";
}
function formatCostTimeStr(costTime) {
if (costTime < 0) {
return "";
}
let costTimeStr = costTime;
if (costTime === 0) {
costTimeStr = '<1'
}
return `${costTimeStr}ms`;
}
function buildCostTimeInfo(costTime) {
if (costTime < 0) {
return "";
}
return `Cost Time: ${formatCostTimeStr(costTime)}<br/>`
}
function buildTimeStamp(timestamp) {
if (timestamp < 0) {
return 'N/A';
}
return moment(timestamp).format(TIME_FORMAT_PATTERN);
}
function formatNodeToolTip(params) {
let traceNode = params.data.traceData.traceNode;
return `
${buildCostTimeInfo(traceNode.costTime)}
Status: ${traceNode.status}<br />
${buildTraceInfo('Begin Timestamp', buildTimeStamp(traceNode.beginTimestamp))}
${buildTraceInfo('End Timestamp', buildTimeStamp(traceNode.endTimestamp))}
Client Host: ${traceNode.clientHost}<br />
Store Host: ${traceNode.storeHost}<br />
Retry Times: ${traceNode.retryTimes < 0 ? 'N/A' : traceNode.retryTimes}<br />
${buildTraceInfo('Message Type', traceNode.msgType)}
${buildTraceInfo('Transaction ID', traceNode.transactionId)}
${buildTraceInfo('Transaction State', traceNode.transactionState)}
${buildTraceInfo('From Transaction Check', traceNode.fromTransactionCheck)}
`;
}
function calcGraphTimestamp(timestamp, relativeTimeStamp, duration, addDuration) {
if (timestamp > 0) {
return timestamp;
}
if (duration < 0) {
return relativeTimeStamp;
}
return addDuration ? relativeTimeStamp + duration : relativeTimeStamp - duration;
}
function addTraceData(traceNode, index, groupName) {
if (traceNode.beginTimestamp < 0 && traceNode.endTimestamp < 0) {
return;
}
let beginTimestamp = calcGraphTimestamp(traceNode.beginTimestamp, traceNode.endTimestamp, traceNode.costTime, false);
let endTimestamp = calcGraphTimestamp(traceNode.endTimestamp, traceNode.beginTimestamp, traceNode.costTime, true);
if (endTimestamp === beginTimestamp) {
endTimestamp = beginTimestamp + 1; // Ensure a minimum duration for visualization
}
data.push({
name: groupName, // To display group name in tooltip or for internal reference
value: [
index,
beginTimestamp,
endTimestamp,
traceNode.costTime
],
itemStyle: {
normal: {
color: buildNodeColor(traceNode),
opacity: 1
}
},
traceData: {
traceNode: traceNode
}
});
startTime = Math.min(startTime, beginTimestamp);
endTime = Math.max(endTime, endTimestamp);
}
// Populate data for the Gantt chart
subscriptionNodeList.forEach(item => {
messageGroups.push(item.subscriptionGroup);
});
subscriptionNodeList.forEach((subscriptionNode, index) => {
subscriptionNode.consumeNodeList.forEach(traceNode => addTraceData(traceNode, index, subscriptionNode.subscriptionGroup));
});
if (producerNode) {
messageGroups.push(producerNode.groupName);
let producerNodeIndex = messageGroups.length - 1;
addTraceData(producerNode.traceNode, producerNodeIndex, producerNode.groupName);
producerNode.transactionNodeList.forEach(transactionNode => {
transactionNode.beginTimestamp = Math.max(producerNode.traceNode.endTimestamp,
transactionNode.endTimestamp - TRANSACTION_CHECK_COST_TIME);
addTraceData(transactionNode, producerNodeIndex, producerNode.groupName);
endTime = Math.max(endTime, transactionNode.endTimestamp);
});
}
let totalDuration = endTime - startTime;
if (totalDuration > DEFAULT_DISPLAY_DURATION) {
dataZoomEnd = (DEFAULT_DISPLAY_DURATION / totalDuration) * 100;
}
function renderItem(params, api) {
let messageGroupIndex = api.value(0); // Y-axis index
let start = api.coord([api.value(1), messageGroupIndex]); // X-axis start time, Y-axis group index
let end = api.coord([api.value(2), messageGroupIndex]); // X-axis end time, Y-axis group index
let height = api.size([0, 1])[1] * 0.6; // Height of the bar
let rectShape = echarts.graphic.clipRectByRect({
x: start[0],
y: start[1] - height / 2,
width: Math.max(end[0] - start[0], 1), // Ensure minimum width
height: height
}, {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
});
return rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style({
text: formatCostTimeStr(api.value(3)), // Display cost time on the bar
textFill: '#000',
textAlign: 'right'
})
};
}
const option = {
tooltip: {
formatter: function (params) {
return formatNodeToolTip(params);
}
},
title: {
text: producerNode ? `Message Trace: ${producerNode.topic}` : "Message Trace",
left: 'center'
},
dataZoom: [
{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
top: 'bottom', // Position at the bottom
start: 0,
end: dataZoomEnd,
labelFormatter: function (value) {
return formatXAxisTime(value + startTime); // Adjust label to show relative time from start
}
},
{
type: 'inside',
filterMode: 'weakFilter'
}
],
grid: {
height: 300,
left: '10%', // Adjust left margin for Y-axis labels
right: '10%'
},
xAxis: {
min: startTime,
scale: true,
axisLabel: {
formatter: function (value) {
return formatXAxisTime(value);
}
}
},
yAxis: {
data: messageGroups, // Use group names as Y-axis categories
axisLabel: {
formatter: function (value, index) {
// Display the group name on the Y-axis
return value;
}
}
},
series: [
{
type: 'custom',
renderItem: renderItem,
encode: {
x: [1, 2], // Use beginTimestamp and endTimestamp for X-axis
y: 0 // Use the index for Y-axis category
},
data: data
}
]
};
chart.setOption(option);
const resizeChart = () => chart.resize();
window.addEventListener('resize', resizeChart);
return () => {
window.removeEventListener('resize', resizeChart);
chart.dispose();
};
}
}, [ngDialogData, t]); // Add t as a dependency for the useEffect hook
// ... (rest of your existing component code)
const transactionColumns = [
{
title: t.TIMESTAMP,
dataIndex: 'beginTimestamp',
key: 'beginTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss.SSS')
},
{
title: t.TRANSACTION_STATE,
dataIndex: 'transactionState',
key: 'transactionState',
align: 'center',
render: (text) => <Tag
color={text === 'COMMIT_MESSAGE' ? 'green' : (text === 'ROLLBACK_MESSAGE' ? 'red' : 'default')}>{text}</Tag>
},
{
title: t.FROM_TRANSACTION_CHECK,
dataIndex: 'fromTransactionCheck',
key: 'fromTransactionCheck',
align: 'center',
render: (text) => (text ? <Tag color="blue">{t.YES}</Tag> : <Tag color="purple">{t.NO}</Tag>)
},
{title: t.CLIENT_HOST, dataIndex: 'clientHost', key: 'clientHost', align: 'center'},
{title: t.STORE_HOST, dataIndex: 'storeHost', key: 'storeHost', align: 'center'},
];
const consumeColumns = [
{
title: t.BEGIN_TIMESTAMP,
dataIndex: 'beginTimestamp',
key: 'beginTimestamp',
align: 'center',
render: (text) => text < 0 ? 'N/A' : moment(text).format('YYYY-MM-DD HH:mm:ss.SSS')
},
{
title: t.END_TIMESTAMP,
dataIndex: 'endTimestamp',
key: 'endTimestamp',
align: 'center',
render: (text) => text < 0 ? 'N/A' : moment(text).format('YYYY-MM-DD HH:mm:ss.SSS')
},
{
title: t.COST_TIME,
dataIndex: 'costTime',
key: 'costTime',
align: 'center',
render: (text) => text < 0 ? 'N/A' : `${text === 0 ? '<1' : text}ms`
},
{
title: t.STATUS,
dataIndex: 'status',
key: 'status',
align: 'center',
render: (text) => <Tag
color={text === 'SUCCESS' ? 'green' : (text === 'FAILED' ? 'red' : 'default')}>{text}</Tag>
},
{
title: t.RETRY_TIMES,
dataIndex: 'retryTimes',
key: 'retryTimes',
align: 'center',
render: (text) => text < 0 ? 'N/A' : text
},
{title: t.CLIENT_HOST, dataIndex: 'clientHost', key: 'clientHost', align: 'center'},
{title: t.STORE_HOST, dataIndex: 'storeHost', key: 'storeHost', align: 'center'},
];
return (
<div style={{padding: '20px', backgroundColor: '#f0f2f5'}}>
<div style={{
marginBottom: '20px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)'
}}>
<Collapse defaultActiveKey={['messageTraceGraph']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{
margin: 0,
color: '#333'
}}>{t.MESSAGE_TRACE_GRAPH}</Typography.Title>} key="messageTraceGraph">
<div ref={messageTraceGraphRef}
style={{height: 500, width: '100%', backgroundColor: '#fff', padding: '10px'}}>
{/* ECharts message trace graph will be rendered here */}
{(!producerNode && subscriptionNodeList.length === 0) && (
<Text type="secondary" style={{
display: 'block',
textAlign: 'center',
marginTop: '150px'
}}>{t.TRACE_GRAPH_PLACEHOLDER}</Text>
)}
</div>
</Panel>
</Collapse>
</div>
<div style={{
marginBottom: '20px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)'
}}>
<Collapse defaultActiveKey={['sendMessageTrace']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{
margin: 0,
color: '#333'
}}>{t.SEND_MESSAGE_TRACE}</Typography.Title>} key="sendMessageTrace">
{!producerNode ? (
<Paragraph style={{
padding: '16px',
textAlign: 'center',
color: '#666'
}}>{t.NO_PRODUCER_TRACE_DATA}</Paragraph>
) : (
<div style={{padding: '16px', backgroundColor: '#fff'}}>
<Typography.Title level={4} style={{marginBottom: '20px'}}>
{t.SEND_MESSAGE_INFO} : ( {t.MESSAGE_ID} <Text strong
copyable>{producerNode.msgId}</Text> )
</Typography.Title>
<Form layout="vertical" colon={false}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px'
}}>
<Form.Item label={<Text strong>{t.TOPIC}</Text>}>
<Input value={producerNode.topic} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.PRODUCER_GROUP}</Text>}>
<Input value={producerNode.groupName} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.MESSAGE_KEY}</Text>}>
<Input value={producerNode.keys} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.TAG}</Text>}>
<Input value={producerNode.tags} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.BEGIN_TIMESTAMP}</Text>}>
<Input
value={moment(producerNode.traceNode.beginTimestamp).format('YYYY-MM-DD HH:mm:ss.SSS')}
readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.END_TIMESTAMP}</Text>}>
<Input
value={moment(producerNode.traceNode.endTimestamp).format('YYYY-MM-DD HH:mm:ss.SSS')}
readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.COST_TIME}</Text>}>
<Input
value={`${producerNode.traceNode.costTime === 0 ? '<1' : producerNode.traceNode.costTime}ms`}
readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.MSG_TYPE}</Text>}>
<Input value={producerNode.traceNode.msgType} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.CLIENT_HOST}</Text>}>
<Input value={producerNode.traceNode.clientHost} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.STORE_HOST}</Text>}>
<Input value={producerNode.traceNode.storeHost} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.RETRY_TIMES}</Text>}>
<Input value={producerNode.traceNode.retryTimes} readOnly/>
</Form.Item>
<Form.Item label={<Text strong>{t.OFFSET_MSG_ID}</Text>}>
<Input value={producerNode.offSetMsgId} readOnly/>
</Form.Item>
</div>
</Form>
{producerNode.transactionNodeList && producerNode.transactionNodeList.length > 0 && (
<div style={{marginTop: '30px'}}>
<Typography.Title level={4}
style={{marginBottom: '15px'}}>{t.CHECK_TRANSACTION_INFO}:</Typography.Title>
<Table
columns={transactionColumns}
dataSource={producerNode.transactionNodeList}
rowKey={(record, index) => `transaction_${index}`}
bordered
pagination={false}
size="middle"
scroll={{x: 'max-content'}}
/>
</div>
)}
</div>
)}
</Panel>
</Collapse>
</div>
<div style={{borderRadius: '8px', overflow: 'hidden', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)'}}>
<Collapse defaultActiveKey={['consumeMessageTrace']} expandIconPosition="right">
<Panel header={<Typography.Title level={3} style={{
margin: 0,
color: '#333'
}}>{t.CONSUME_MESSAGE_TRACE}</Typography.Title>} key="consumeMessageTrace">
{subscriptionNodeList.length === 0 ? (
<Paragraph style={{
padding: '16px',
textAlign: 'center',
color: '#666'
}}>{t.NO_CONSUMER_TRACE_DATA}</Paragraph>
) : (
<div style={{padding: '16px', backgroundColor: '#fff'}}>
{subscriptionNodeList.map(subscriptionNode => (
<Collapse
key={subscriptionNode.subscriptionGroup}
style={{marginBottom: '10px', border: '1px solid #e0e0e0', borderRadius: '4px'}}
defaultActiveKey={[subscriptionNode.subscriptionGroup]}
ghost
>
<Panel
header={<Typography.Title level={4}
style={{margin: 0}}>{t.SUBSCRIPTION_GROUP}: <Text
strong>{subscriptionNode.subscriptionGroup}</Text></Typography.Title>}
key={subscriptionNode.subscriptionGroup}
>
<Table
columns={consumeColumns}
dataSource={subscriptionNode.consumeNodeList}
rowKey={(record, index) => `${subscriptionNode.subscriptionGroup}_${index}`}
bordered
pagination={false}
size="middle"
scroll={{x: 'max-content'}}
/>
</Panel>
</Collapse>
))}
</div>
)}
</Panel>
</Collapse>
</div>
</div>
);
};
export default MessageTraceDetailViewDialog;

View File

@@ -0,0 +1,211 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Drawer, Dropdown, Grid, Layout, Menu, Space} from 'antd';
import {BgColorsOutlined, DownOutlined, GlobalOutlined, MenuOutlined, UserOutlined} from '@ant-design/icons';
import {useLocation, useNavigate} from 'react-router-dom';
import {useLanguage} from '../i18n/LanguageContext';
import {useTheme} from "../store/context/ThemeContext";
import {remoteApi} from "../api/remoteApi/remoteApi";
const {Header} = Layout;
const {useBreakpoint} = Grid; // Used to determine screen breakpoints
const Navbar = ({rmqVersion = true, showAcl = true}) => {
const location = useLocation();
const navigate = useNavigate();
const {lang, setLang, t} = useLanguage();
const screens = useBreakpoint(); // Get current screen size breakpoints
const {currentThemeName, setCurrentThemeName} = useTheme();
const [userName, setUserName] = useState(null);
const [drawerVisible, setDrawerVisible] = useState(false); // Controls drawer visibility
// Get selected menu item key based on current route path
const getPath = () => location.pathname.replace('/', '');
const handleMenuClick = ({key}) => {
navigate(`/${key}`);
setDrawerVisible(false); // Close drawer after clicking a menu item
};
const onLogout = () => {
remoteApi.logout().then(res => {
if (res.status === 0) {
window.localStorage.removeItem("username");
window.localStorage.removeItem("userRole");
window.localStorage.removeItem("token");
window.localStorage.removeItem("rmqVersion");
navigate('/login');
} else {
console.error('Logout failed:', res.message)
navigate('/login');
}
})
};
useEffect(() => {
const storedUsername = window.localStorage.getItem("username");
if (storedUsername) {
setUserName(storedUsername);
} else {
setUserName(null);
}
}, []);
const langMenu = (
<Menu onClick={({key}) => setLang(key)}>
<Menu.Item key="en">{t.ENGLISH}</Menu.Item>
<Menu.Item key="zh">{t.CHINESE}</Menu.Item>
</Menu>
);
const userMenu = (
<Menu>
<Menu.Item key="logout" onClick={onLogout}>{t.LOGOUT}</Menu.Item>
</Menu>
);
const themeMenu = (
<Menu onClick={({key}) => setCurrentThemeName(key)}>
<Menu.Item key="default">{t.BLUE} ({t.DEFAULT})</Menu.Item>
<Menu.Item key="pink">{t.PINK}</Menu.Item>
<Menu.Item key="green">{t.GREEN}</Menu.Item>
</Menu>
);
// Menu item configuration
const menuItems = [
{key: 'ops', label: t.OPS},
...(rmqVersion ? [{key: 'proxy', label: t.PROXY}] : []),
{key: '', label: t.DASHBOARD}, // Dashboard corresponds to root path
{key: 'cluster', label: t.CLUSTER},
{key: 'topic', label: t.TOPIC},
{key: 'consumer', label: t.CONSUMER},
{key: 'producer', label: t.PRODUCER},
{key: 'message', label: t.MESSAGE},
{key: 'dlqMessage', label: t.DLQ_MESSAGE},
{key: 'messageTrace', label: t.MESSAGETRACE},
...(showAcl ? [{key: 'acl', label: t.ACL_MANAGEMENT}] : []),
];
// Determine if it's a small screen (e.g., less than md)
const isSmallScreen = !screens.md;
// Determine if it's an extra small screen (e.g., less than sm)
const isExtraSmallScreen = !screens.sm;
return (
<Header
className="navbar"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: isExtraSmallScreen ? '0 16px' : '0 24px', // Smaller padding on extra small screens
}}
>
<div className="navbar-left" style={{display: 'flex', alignItems: 'center'}}>
<div
style={{
fontWeight: 'bold',
marginRight: isSmallScreen ? '16px' : '24px', // Adjust margin on small screens
whiteSpace: 'nowrap', // Prevent text wrapping
flexShrink: 0, // Prevent shrinking in flex container
color: 'white', // Title text color also set to white
fontSize: isSmallScreen ? '14px' : '18px',
}}
>
{t.TITLE}
</div>
{!isSmallScreen && ( // Display full menu on large screens
<Menu
onClick={handleMenuClick}
selectedKeys={[getPath()]}
mode="horizontal"
items={menuItems}
theme="dark" // Use dark theme to match Header background
style={{flex: 1, minWidth: 0}} // Allow menu items to adapt width
/>
)}
</div>
<Space size={isExtraSmallScreen ? 8 : 16}> {/* Adjust spacing for buttons */}
{/* Theme switch button */}
<Dropdown overlay={themeMenu}>
<Button icon={<BgColorsOutlined/>} size="small">
{!isExtraSmallScreen && `${t.TOPIC}: ${currentThemeName}`}
<DownOutlined/>
</Button>
</Dropdown>
<Dropdown overlay={langMenu}>
<Button icon={<GlobalOutlined/>} size="small">
{!isExtraSmallScreen && t.CHANGE_LANG} {/* Hide text on extra small screens */}
<DownOutlined/>
</Button>
</Dropdown>
{userName && (
<Dropdown overlay={userMenu}>
{/* 使用一个可点击的元素作为 Dropdown 的唯一子元素 */}
<a onClick={e => e.preventDefault()} style={{display: 'flex', alignItems: 'center'}}>
<UserOutlined style={{marginRight: 8}}/> {/* 添加一些间距 */}
{userName}
<DownOutlined style={{marginLeft: 8}}/>
</a>
</Dropdown>
)}
{isSmallScreen && ( // Display hamburger icon on small screens
<Button
type="primary"
icon={<MenuOutlined/>}
onClick={() => setDrawerVisible(true)}
style={{marginLeft: isExtraSmallScreen ? 8 : 16}} // Adjust margin for hamburger icon
/>
)}
</Space>
{/* Modify Drawer and Menu components here */}
<Drawer
// Default Drawer background color is white. If you need to change the Drawer's own background color, set it additionally
// or set a dark background in bodyStyle, then let Menu override it
title={t.MENU} // Drawer title
placement="left" // Drawer pops out from the left
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
// If you want the Drawer's background to match the Menu's background color, you can set bodyStyle like this
// or set components.Drawer.colorBgElevated in theme.js, etc.
bodyStyle={{padding: 0, backgroundColor: '#1c324a'}} // Set Drawer body background to dark
width={200} // Set drawer width
>
<Menu
onClick={handleMenuClick}
selectedKeys={[getPath()]}
mode="inline" // Use vertical menu in drawer
items={menuItems}
theme="dark"
style={{height: '100%', borderRight: 0}} // Ensure menu fills the drawer
/>
</Drawer>
</Header>
);
};
export default Navbar;

View File

@@ -0,0 +1,127 @@
/*
* 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 {Input, Select, Space, Tag} from 'antd';
import {PlusOutlined} from '@ant-design/icons';
import React, {useState} from 'react';
const {Option} = Select;
// 资源类型枚举
const resourceTypes = [
{value: 0, label: 'Unknown', prefix: 'UNKNOWN'},
{value: 1, label: 'Any', prefix: 'ANY'},
{value: 2, label: 'Cluster', prefix: 'CLUSTER'},
{value: 3, label: 'Namespace', prefix: 'NAMESPACE'},
{value: 4, label: 'Topic', prefix: 'TOPIC'},
{value: 5, label: 'Group', prefix: 'GROUP'},
];
const ResourceInput = ({value = [], onChange}) => {
// 确保 value 始终是数组
const safeValue = Array.isArray(value) ? value : [];
const [selectedType, setSelectedType] = useState(resourceTypes[0].prefix); // 默认选中第一个
const [resourceName, setResourceName] = useState('');
const [inputVisible, setInputVisible] = useState(false);
const inputRef = React.useRef(null);
// 处理删除已添加的资源
const handleClose = removedResource => {
const newResources = safeValue.filter(resource => resource !== removedResource);
onChange(newResources);
};
// 显示输入框
const showInput = () => {
setInputVisible(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
};
// 处理资源类型选择
const handleTypeChange = type => {
setSelectedType(type);
};
// 处理资源名称输入
const handleNameChange = e => {
setResourceName(e.target.value);
};
// 添加资源到列表
const handleAddResource = () => {
if (resourceName) {
const fullResource = `${selectedType}:${resourceName}`;
// 避免重复添加
if (!safeValue.includes(fullResource)) {
onChange([...safeValue, fullResource]);
}
setResourceName(''); // 清空输入
setInputVisible(false); // 隐藏输入框
}
};
return (
<Space size={[0, 8]} wrap>
{/* 显示已添加的资源标签 */}
{safeValue.map(resource => ( // 使用 safeValue
<Tag
key={resource}
closable
onClose={() => handleClose(resource)}
color="blue"
>
{resource}
</Tag>
))}
{/* 新增资源输入区域 */}
{inputVisible ? (
<Space>
<Select
value={selectedType}
style={{width: 120}}
onChange={handleTypeChange}
>
{resourceTypes.map(type => (
<Option key={type.value} value={type.prefix}>
{type.label}
</Option>
))}
</Select>
<Input
ref={inputRef}
style={{width: 180}}
value={resourceName}
onChange={handleNameChange}
onPressEnter={handleAddResource}
onBlur={handleAddResource} // 失去焦点也自动添加
placeholder="请输入资源名称"
/>
</Space>
) : (
<Tag onClick={showInput} style={{background: '#fff', borderStyle: 'dashed'}}>
<PlusOutlined/> 添加资源
</Tag>
)}
</Space>
);
};
export default ResourceInput;

View File

@@ -0,0 +1,99 @@
/*
* 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 {Input, Select} from 'antd';
import React, {useEffect, useState} from 'react';
const {Option} = Select;
const subjectTypes = [
{value: 'User', label: 'User'},
];
const SubjectInput = ({value, onChange, disabled, t}) => {
const parseValue = (val) => {
if (!val || typeof val !== 'string') {
return {type: subjectTypes[0].value, name: ''}; // 默认值
}
const parts = val.split(':');
if (parts.length === 2 && subjectTypes.some(t => t.value === parts[0])) {
return {type: parts[0], name: parts[1]};
}
return {type: subjectTypes[0].value, name: val};
};
const [currentType, setCurrentType] = useState(() => parseValue(value).type);
const [currentName, setCurrentName] = useState(() => parseValue(value).name);
useEffect(() => {
const parsed = parseValue(value);
setCurrentType(parsed.type);
setCurrentName(parsed.name);
}, [value]);
const triggerChange = (changedType, changedName) => {
if (onChange) {
if (changedName) {
onChange(`${changedType}:${changedName}`);
} else if (changedType) {
onChange('');
} else {
onChange('');
}
}
};
const onTypeChange = (newType) => {
setCurrentType(newType);
triggerChange(newType, currentName);
};
const onNameChange = (e) => {
const newName = e.target.value;
setCurrentName(newName);
triggerChange(currentType, newName);
};
return (
<Input.Group compact>
<Select
style={{width: '30%'}}
value={currentType}
onChange={onTypeChange}
disabled={disabled}
>
{subjectTypes.map(type => (
<Option key={type.value} value={type.value}>
{type.label}
</Option>
))}
</Select>
<Input
style={{width: '70%'}}
value={currentName}
onChange={onNameChange}
placeholder={t.PLEASE_INPUT_NAME}
disabled={disabled}
/>
</Input.Group>
);
};
export default SubjectInput;

View File

@@ -0,0 +1,171 @@
/*
* 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, {useEffect, useState} from 'react';
import {Descriptions, Modal, Spin, Table, Tag, Tooltip} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import {useLanguage} from '../../i18n/LanguageContext';
const ClientInfoModal = ({visible, group, address, onCancel, messageApi}) => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [connectionData, setConnectionData] = useState(null);
useEffect(() => {
const fetchData = async () => {
if (!visible) return;
setLoading(true);
try {
const connResponse = await remoteApi.queryConsumerConnection(group, address);
if (connResponse.status === 0) {
setConnectionData(connResponse.data);
}else{
messageApi.error(connResponse.errMsg);
setConnectionData(null);
}
} finally {
setLoading(false);
}
};
fetchData();
}, [visible, group, address]);
const connectionColumns = [
{
title: t.CLIENTID, dataIndex: 'clientId', key: 'clientId', width: 220, ellipsis: true,
render: (text) => (
<Tooltip title={text}>
{text}
</Tooltip>
)
},
{title: t.CLIENTADDR, dataIndex: 'clientAddr', key: 'clientAddr', width: 150, ellipsis: true},
{title: t.LANGUAGE, dataIndex: 'language', key: 'language', width: 100},
{title: t.VERSION, dataIndex: 'versionDesc', key: 'versionDesc', width: 100},
];
const subscriptionColumns = [
{
title: t.TOPIC, dataIndex: 'topic', key: 'topic', width: 250, ellipsis: true,
render: (text) => (
<Tooltip title={text}>
{text}
</Tooltip>
)
},
{title: t.SUBSCRIPTION_EXPRESSION, dataIndex: 'subString', key: 'subString', width: 150, ellipsis: true},
{
title: t.EXPRESSION_TYPE, dataIndex: 'expressionType', key: 'expressionType', width: 120,
render: (text) => <Tag color="blue">{text}</Tag>
},
// --- Added Columns for TagsSet and CodeSet ---
{
title: t.TAGS_SET, // Ensure t.TAGS_SET is defined in your language file
dataIndex: 'tagsSet',
key: 'tagsSet',
width: 150,
render: (tags) => (
tags && tags.length > 0 ? (
<Tooltip title={tags.join(', ')}>
{tags.map((tag, index) => (
<Tag key={index} color="default">{tag}</Tag>
))}
</Tooltip>
) : 'N/A'
),
ellipsis: true,
},
{
title: t.CODE_SET, // Ensure t.CODE_SET is defined in your language file
dataIndex: 'codeSet',
key: 'codeSet',
width: 150,
render: (codes) => (
codes && codes.length > 0 ? (
<Tooltip title={codes.join(', ')}>
{codes.map((code, index) => (
<Tag key={index} color="default">{code}</Tag>
))}
</Tooltip>
) : 'N/A'
),
ellipsis: true,
},
// --- End of Added Columns ---
{title: t.SUB_VERSION, dataIndex: 'subVersion', key: 'subVersion', width: 150},
];
const formattedSubscriptionData = connectionData?.subscriptionTable
? Object.keys(connectionData.subscriptionTable).map(key => ({
...connectionData.subscriptionTable[key],
key: key,
}))
: [];
return (
<Modal
title={`[${group}] ${t.CLIENT_INFORMATION}`}
visible={visible}
onCancel={onCancel}
footer={null}
width={1200} // Increased width to accommodate more columns
>
<Spin spinning={loading}>
{connectionData && (
<>
<Descriptions bordered column={2} title={t.CONNECTION_OVERVIEW} style={{marginBottom: 20}}>
<Descriptions.Item label={t.CONSUME_TYPE}>
<Tag color="green">{connectionData.consumeType}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t.MESSAGE_MODEL}>
<Tag color="geekblue">{connectionData.messageModel}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t.CONSUME_FROM_WHERE}>
<Tag color="purple">{connectionData.consumeFromWhere}</Tag>
</Descriptions.Item>
</Descriptions>
<h3>{t.CLIENT_CONNECTIONS}</h3>
<Table
columns={connectionColumns}
dataSource={connectionData.connectionSet}
rowKey="clientId"
pagination={false}
scroll={{x: 'max-content'}}
style={{marginBottom: 20}}
/>
<h3>{t.CLIENT_SUBSCRIPTIONS}</h3>
<Table
columns={subscriptionColumns}
dataSource={formattedSubscriptionData}
rowKey="key"
pagination={false}
scroll={{x: 'max-content'}}
/>
</>
)}
</Spin>
</Modal>
);
};
export default ClientInfoModal;

View File

@@ -0,0 +1,297 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Descriptions, Form, Input, message, Select, Switch} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi'; // 确保路径正确
const {Option} = Select;
const ConsumerConfigItem = ({
initialConfig,
isAddConfig,
group,
brokerName,
allBrokerList,
allClusterNames,
onCancel,
onSuccess,
t
}) => {
const [form] = Form.useForm();
const [currentBrokerName, setCurrentBrokerName] = useState(brokerName);
useEffect(() => {
if (initialConfig) {
if (!isAddConfig && initialConfig.brokerNameList && initialConfig.brokerNameList.length > 0) {
// 更新模式设置当前BrokerName为第一个如果只有一个的话或者您有其他选择逻辑
setCurrentBrokerName(initialConfig.brokerNameList[0]);
}
form.setFieldsValue({
...initialConfig.subscriptionGroupConfig,
groupName: isAddConfig ? undefined : initialConfig.subscriptionGroupConfig.groupName, // 添加模式下groupName可编辑
brokerName: isAddConfig ? [] : initialConfig.brokerNameList, // 更新模式下显示已有的brokerName
clusterName: isAddConfig ? [] : initialConfig.clusterNameList, // 更新模式下显示已有的clusterName
});
} else {
// Reset form for add mode or when initialConfig is null (e.g., when the modal is closed)
form.resetFields();
form.setFieldsValue({
groupName: undefined,
autoCommit: true,
enableAutoCommit: true,
enableAutoOffsetReset: true,
groupSysFlag: 0,
consumeTimeoutMinute: 10,
consumeEnable: true,
consumeMessageOrderly: false,
consumeBroadcastEnable: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
brokerName: [],
clusterName: [],
});
setCurrentBrokerName(undefined); // 清空当前brokerName
}
}, [initialConfig, isAddConfig, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const numericValues = {
retryQueueNums: Number(values.retryQueueNums),
retryMaxTimes: Number(values.retryMaxTimes),
brokerId: Number(values.brokerId),
whichBrokerWhenConsumeSlowly: Number(values.whichBrokerWhenConsumeSlowly),
};
// 确保brokerNameList是数组
let finalBrokerNameList = Array.isArray(values.brokerName) ? values.brokerName : [values.brokerName];
// 确保clusterNameList是数组
let finalClusterNameList = Array.isArray(values.clusterName) ? values.clusterName : [values.clusterName];
const payload = {
subscriptionGroupConfig: {
...(initialConfig && initialConfig.subscriptionGroupConfig ? initialConfig.subscriptionGroupConfig : {}), // 保留旧的配置,除非被新值覆盖
...values,
...numericValues,
groupName: isAddConfig ? values.groupName : group, // 添加模式使用表单中的groupName更新模式使用传入的group
},
brokerNameList: finalBrokerNameList,
clusterNameList: isAddConfig ? finalClusterNameList : null, // 更新模式保留原有clusterNameList
};
const response = await remoteApi.createOrUpdateConsumer(payload);
if (response.status === 0) {
message.success(t.SUCCESS);
onSuccess();
} else {
message.error(`${t.OPERATION_FAILED}: ${response.errMsg}`);
console.error('Failed to create or update consumer:', response.errMsg);
}
} catch (error) {
console.error('Validation failed or API call error:', error);
message.error(t.FORM_VALIDATION_FAILED);
} finally {
onCancel()
}
};
// Helper function to parse input value to number
const parseNumber = (event) => {
const value = event.target.value;
return value === '' ? undefined : Number(value);
};
// 如果是添加模式并且用户还没有选择brokerName或者没有clusterName可供选择则不渲染表单
if (isAddConfig && (!allBrokerList || allBrokerList.length === 0 || !allClusterNames || allClusterNames.length === 0)) {
return <p>{t.NO_DATA}</p>;
}
return (
<div style={{border: '1px solid #e8e8e8', padding: 20, marginBottom: 20, borderRadius: 8}}>
{/* 标题根据当前BrokerName或“添加新配置”显示 */}
<h3>{isAddConfig ? t.ADD_CONSUMER : `${t.CONFIG_FOR_BROKER}: ${currentBrokerName || 'N/A'}`}</h3>
{!isAddConfig && initialConfig && (
<Descriptions bordered column={2} style={{marginBottom: 24}} size="small">
<Descriptions.Item label={t.CLUSTER_NAME} span={2}>
{initialConfig.clusterNameList?.join(', ') || 'N/A'}
</Descriptions.Item>
<Descriptions.Item label={t.RETRY_POLICY} span={2}>
<pre style={{margin: 0, maxHeight: '100px', overflow: 'auto', fontSize: '12px'}}>
{JSON.stringify(
initialConfig.subscriptionGroupConfig.groupRetryPolicy,
null,
2
) || 'N/A'}
</pre>
</Descriptions.Item>
<Descriptions.Item label={t.CONSUME_TIMEOUT}>
{`${initialConfig.subscriptionGroupConfig.consumeTimeoutMinute} ${t.MINUTES}` || 'N/A'}
</Descriptions.Item>
<Descriptions.Item label={t.SYSTEM_FLAG}>
{initialConfig.subscriptionGroupConfig.groupSysFlag || 'N/A'}
</Descriptions.Item>
</Descriptions>
)}
<Form form={form} layout="vertical">
<Form.Item
name="groupName"
label={t.GROUP_NAME}
rules={[{required: true, message: t.CANNOT_BE_EMPTY}]}
>
<Input disabled={!isAddConfig}/>
</Form.Item>
{isAddConfig && (
<Form.Item
name="clusterName"
label={t.CLUSTER_NAME}
rules={[{required: true, message: t.PLEASE_SELECT_CLUSTER_NAME}]}
>
<Select
mode="multiple"
placeholder={t.SELECT_CLUSTERS}
disabled={!isAddConfig}
>
{allClusterNames.map((cluster) => (
<Option key={cluster} value={cluster}>
{cluster}
</Option>
))}
</Select>
</Form.Item>
)}
<Form.Item
name="brokerName"
label={t.BROKER_NAME}
rules={[{required: true, message: t.PLEASE_SELECT_BROKER}]}
>
<Select
mode="multiple"
placeholder={t.SELECT_BROKERS}
disabled={!isAddConfig} // 只有在添加模式下才能选择brokerName
onChange={(selectedBrokers) => {
if (isAddConfig && selectedBrokers.length > 0) {
// 在添加模式下如果选择了broker则将第一个选中的broker设置为当前brokerName用于显示
setCurrentBrokerName(selectedBrokers[0]);
}
}}
>
{allBrokerList.map((broker) => (
<Option key={broker} value={broker}>
{broker}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="consumeEnable" label={t.CONSUME_ENABLE} valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item name="consumeMessageOrderly" label={t.ORDERLY_CONSUMPTION} valuePropName="checked">
<Switch/>
</Form.Item>
<Form.Item name="consumeBroadcastEnable" label={t.BROADCAST_CONSUMPTION} valuePropName="checked">
<Switch/>
</Form.Item>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16}}>
<Form.Item
name="retryQueueNums"
label={t.RETRY_QUEUES}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="retryMaxTimes"
label={t.MAX_RETRIES}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="brokerId"
label={t.BROKER_ID}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
<Form.Item
name="whichBrokerWhenConsumeSlowly"
label={t.SLOW_CONSUMPTION_BROKER}
rules={[{
type: 'number',
message: t.PLEASE_INPUT_NUMBER,
transform: value => Number(value)
}, {
required: true,
message: t.CANNOT_BE_EMPTY
}]}
getValueFromEvent={parseNumber}
>
<Input type="number"/>
</Form.Item>
</div>
<div style={{textAlign: 'right', marginTop: 20}}>
<Button type="primary" onClick={handleSubmit}>
{t.COMMIT}
</Button>
</div>
</Form>
</div>
);
};
export default ConsumerConfigItem;

View File

@@ -0,0 +1,169 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Modal, Spin} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import {useLanguage} from '../../i18n/LanguageContext';
import ConsumerConfigItem from './ConsumerConfigItem'; // 导入子组件
const ConsumerConfigModal = ({visible, isAddConfig, group, onCancel, setIsAddConfig, onSuccess}) => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [allBrokerList, setAllBrokerList] = useState([]); // 存储所有可用的broker
const [allClusterNames, setAllClusterNames] = useState([]); // 存储所有可用的cluster names
const [initialConfigData, setInitialConfigData] = useState({}); // 存储按brokerName分的初始配置数据
useEffect(() => {
if (visible) {
const fetchInitialData = async () => {
setLoading(true);
try {
// Fetch cluster list for broker names and cluster names
if (isAddConfig) {
const clusterResponse = await remoteApi.getClusterList();
if (clusterResponse.status === 0 && clusterResponse.data) {
const clusterInfo = clusterResponse.data.clusterInfo;
const brokers = [];
const clusterNames = Object.keys(clusterInfo?.clusterAddrTable || {});
clusterNames.forEach(clusterName => {
const brokersInCluster = clusterInfo?.clusterAddrTable?.[clusterName] || [];
brokers.push(...brokersInCluster);
});
setAllBrokerList([...new Set(brokers)]); // 确保brokerName唯一
setAllClusterNames(clusterNames);
} else {
console.error('Failed to fetch cluster list:', clusterResponse.errMsg);
}
}
if (!isAddConfig) {
// Fetch existing consumer config for update mode
const consumerConfigResponse = await remoteApi.queryConsumerConfig(group);
if (consumerConfigResponse.status === 0 && consumerConfigResponse.data && consumerConfigResponse.data.length > 0) {
const configMap = {};
consumerConfigResponse.data.forEach(config => {
// 假设每个brokerName有一个独立的配置项
config.brokerNameList.forEach(brokerName => {
configMap[brokerName] = {
...config,
// 确保brokerNameList和clusterNameList是数组形式即使API返回单值
brokerNameList: Array.isArray(config.brokerNameList) ? config.brokerNameList : [config.brokerNameList],
clusterNameList: Array.isArray(config.clusterNameList) ? config.clusterNameList : [config.clusterNameList]
};
});
});
setInitialConfigData(configMap);
} else {
console.error(`Failed to fetch consumer config for group: ${group}`);
onCancel(); // Close modal if config not found
}
} else {
// For add mode, initialize with empty values and allow selecting any broker
setInitialConfigData({
// 当isAddConfig为true时我们只提供一个空的配置模板用户选择broker后会创建新的配置
// 在这里我们将设置一个空的初始配置供用户选择broker来创建新配置
newConfig: {
groupName: undefined,
subscriptionGroupConfig: {
autoCommit: true,
enableAutoCommit: true,
enableAutoOffsetReset: true,
groupSysFlag: 0,
consumeTimeoutMinute: 10,
consumeEnable: true,
consumeMessageOrderly: false,
consumeBroadcastEnable: false,
retryQueueNums: 1,
retryMaxTimes: 16,
brokerId: 0,
whichBrokerWhenConsumeSlowly: 0,
},
brokerNameList: [],
clusterNameList: []
}
});
}
} catch (error) {
console.error('Error in fetching initial data:', error);
} finally {
setLoading(false);
}
};
fetchInitialData();
} else {
// Reset state when modal is closed
setInitialConfigData({});
setAllBrokerList([]);
setAllClusterNames([]);
}
}, [visible, isAddConfig, group, onCancel]);
const getBrokersToRender = () => {
if (isAddConfig) {
return ['newConfig'];
} else {
return Object.keys(initialConfigData);
}
}
return (
<Modal
title={isAddConfig ? t.ADD_CONSUMER : `${t.CONFIG} - ${group}`}
visible={visible}
onCancel={() => {
onCancel();
setIsAddConfig(false); // 确保关闭时重置添加模式
}}
width={800}
footer={[
<Button key="cancel" onClick={() => {
onCancel();
setIsAddConfig(false);
}}>
{t.CLOSE}
</Button>,
]}
style={{top: 20}} // 让弹窗靠上一点方便内容滚动
bodyStyle={{maxHeight: 'calc(100vh - 200px)', overflowY: 'auto'}} // 允许内容滚动
>
<Spin spinning={loading}>
{getBrokersToRender().map(brokerOrKey => (
<ConsumerConfigItem
key={brokerOrKey} // 使用brokerName作为key
initialConfig={initialConfigData[brokerOrKey]}
isAddConfig={isAddConfig}
group={group} // 传递当前group
brokerName={isAddConfig ? undefined : brokerOrKey} // 添加模式下brokerName由用户选择更新模式下是当前遍历的brokerName
allBrokerList={allBrokerList}
allClusterNames={allClusterNames}
onSuccess={onSuccess}
onCancel={onCancel}
t={t} // 传递i18n函数
/>
))}
</Spin>
</Modal>
);
};
export default ConsumerConfigModal;

View File

@@ -0,0 +1,139 @@
/*
* 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, {useEffect, useState} from 'react';
import {Modal, Spin, Table} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import {useLanguage} from '../../i18n/LanguageContext';
const ConsumerDetailModal = ({visible, group, address, onCancel ,messageApi}) => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [details, setDetails] = useState([]);
useEffect(() => {
const fetchData = async () => {
if (!visible) return;
setLoading(true);
try {
const response = await remoteApi.queryTopicByConsumer(group, address);
if (response.status === 0) {
setDetails(response.data);
}else {
// Handle error case
messageApi.error(response.errMsg);
setDetails([]);
}
} finally {
setLoading(false);
}
};
fetchData();
}, [visible, group, address]);
// Format timestamp to readable date
const formatTimestamp = (timestamp) => {
if (!timestamp || timestamp === 0) return '-';
return new Date(timestamp).toLocaleString();
};
// Group data by topic for better organization
const groupByTopic = (data) => {
const grouped = {};
data.forEach(item => {
if (!grouped[item.topic]) {
grouped[item.topic] = [];
}
grouped[item.topic].push(item);
});
return grouped;
};
const groupedDetails = groupByTopic(details);
const queueColumns = [
{title: 'Broker', dataIndex: 'brokerName', width: 120},
{title: 'Queue ID', dataIndex: 'queueId', width: 100},
{title: 'Broker Offset', dataIndex: 'brokerOffset', width: 120},
{title: 'Consumer Offset', dataIndex: 'consumerOffset', width: 120},
{
title: 'Lag (Diff)', dataIndex: 'diffTotal', width: 100,
render: (diff) => (
<span style={{color: diff > 0 ? '#f5222d' : '#52c41a'}}>
{diff}
</span>
)
},
{title: 'Client Info', dataIndex: 'clientInfo', width: 200},
{
title: 'Last Consume Time', dataIndex: 'lastTimestamp', width: 180,
render: (timestamp) => formatTimestamp(timestamp)
},
];
return (
<Modal
title={
<span>Consumer Details - Group: <strong>{group}</strong> | Address: <strong>{address}</strong></span>}
visible={visible}
onCancel={onCancel}
footer={null}
width={1400}
style={{top: 20}}
>
<Spin spinning={loading}>
{Object.entries(groupedDetails).map(([topic, topicDetails]) => (
<div key={topic} style={{marginBottom: 24}}>
<div style={{
background: '#f0f0f0',
padding: '8px 16px',
marginBottom: 8,
borderRadius: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{margin: 0}}>Topic: <strong>{topic}</strong></h3>
<div>
<span style={{marginRight: 16}}>Total Lag: <strong>{topicDetails[0].diffTotal}</strong></span>
<span>Last Consume Time: <strong>{formatTimestamp(topicDetails[0].lastTimestamp)}</strong></span>
</div>
</div>
{topicDetails.map((detail, index) => (
<div key={index} style={{marginBottom: 16}}>
<Table
columns={queueColumns}
dataSource={detail.queueStatInfoList}
rowKey={(record) => `${record.brokerName}-${record.queueId}`}
pagination={false}
size="small"
bordered
scroll={{x: 'max-content'}}
/>
</div>
))}
</div>
))}
</Spin>
</Modal>
);
};
export default ConsumerDetailModal;

View File

@@ -0,0 +1,106 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Checkbox, Modal, notification, Spin} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
const DeleteConsumerModal = ({visible, group, onCancel, onSuccess, t}) => {
const [brokerList, setBrokerList] = useState([]);
const [selectedBrokers, setSelectedBrokers] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchBrokers = async () => {
if (!visible) return;
setLoading(true);
try {
const response = await remoteApi.fetchBrokerNameList(group);
if (response.status === 0) {
setBrokerList(response.data);
}
} finally {
setLoading(false);
}
};
fetchBrokers();
}, [visible, group]);
const handleDelete = async () => {
if (selectedBrokers.length === 0) {
notification.warning({message: t.PLEASE_SELECT_BROKER});
return;
}
setLoading(true);
try {
const response = await remoteApi.deleteConsumerGroup(
group,
selectedBrokers
);
if (response.status === 0) {
notification.success({message: t.DELETE_SUCCESS});
onSuccess();
onCancel();
}
} finally {
setLoading(false);
}
};
return (
<Modal
title={`${t.DELETE_CONSUMER_GROUP} - ${group}`}
visible={visible}
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
{t.CANCEL}
</Button>,
<Button
key="delete"
type="primary"
danger
loading={loading}
onClick={handleDelete}
>
{t.CONFIRM_DELETE}
</Button>
]}
>
<Spin spinning={loading}>
<div style={{marginBottom: 16}}>{t.SELECT_DELETE_BROKERS}:</div>
<Checkbox.Group
style={{width: '100%'}}
value={selectedBrokers}
onChange={values => setSelectedBrokers(values)}
>
{brokerList.map(broker => (
<div key={broker}>
<Checkbox value={broker}>{broker}</Checkbox>
</div>
))}
</Checkbox.Group>
</Spin>
</Modal>
);
};
export default DeleteConsumerModal;

View File

@@ -0,0 +1,76 @@
/*
* 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 {Button, DatePicker, Form, Modal, Select} from "antd";
import React, {useEffect, useState} from "react";
const ConsumerResetOffsetDialog = ({visible, onClose, topic, allConsumerGroupList, handleResetOffset, t}) => {
const [form] = Form.useForm();
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState([]);
const [selectedTime, setSelectedTime] = useState(null);
useEffect(() => {
if (!visible) {
setSelectedConsumerGroup([]);
setSelectedTime(null);
form.resetFields();
}
}, [visible, form]);
const handleResetButtonClick = () => {
handleResetOffset(selectedConsumerGroup, selectedTime ? selectedTime.valueOf() : null);
};
return (
<Modal
title={`${topic} ${t.RESET_OFFSET}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="reset" type="primary" onClick={handleResetButtonClick}>
{t.RESET}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{span: 6}} wrapperCol={{span: 18}}>
<Form.Item label={t.SUBSCRIPTION_GROUP} required>
<Select
mode="multiple"
placeholder={t.SELECT_CONSUMER_GROUP}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
options={allConsumerGroupList.map(group => ({value: group, label: group}))}
/>
</Form.Item>
<Form.Item label={t.TIME} required>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={selectedTime}
onChange={setSelectedTime}
style={{width: '100%'}}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default ConsumerResetOffsetDialog;

View File

@@ -0,0 +1,100 @@
/*
* 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 moment from "moment/moment";
import {Button, Modal, Table} from "antd";
import React from "react";
const ConsumerViewDialog = ({visible, onClose, topic, consumerData, consumerGroupCount, t}) => {
const columns = [
{title: t.BROKER, dataIndex: 'brokerName', key: 'brokerName', align: 'center'},
{title: t.QUEUE, dataIndex: 'queueId', key: 'queueId', align: 'center'},
{title: t.CONSUMER_CLIENT, dataIndex: 'clientInfo', key: 'clientInfo', align: 'center'},
{title: t.BROKER_OFFSET, dataIndex: 'brokerOffset', key: 'brokerOffset', align: 'center'},
{title: t.CONSUMER_OFFSET, dataIndex: 'consumerOffset', key: 'consumerOffset', align: 'center'},
{
title: t.DIFF_TOTAL,
dataIndex: 'diffTotal',
key: 'diffTotal',
align: 'center',
render: (_, record) => record.brokerOffset - record.consumerOffset,
},
{
title: t.LAST_TIME_STAMP,
dataIndex: 'lastTimestamp',
key: 'lastTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<Modal
title={`${topic} ${t.SUBSCRIPTION_GROUP}`}
open={visible}
onCancel={onClose}
width={1000}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
{consumerGroupCount === 0 ? (
<div>{t.NO_DATA} {t.SUBSCRIPTION_GROUP}</div>
) : (
consumerData && Object.entries(consumerData).map(([consumerGroup, consumeDetail]) => (
<div key={consumerGroup} style={{marginBottom: '24px'}}>
<Table
bordered
pagination={false}
showHeader={false}
dataSource={[{
consumerGroup,
diffTotal: consumeDetail.diffTotal,
lastTimestamp: consumeDetail.lastTimestamp
}]}
columns={[
{title: t.SUBSCRIPTION_GROUP, dataIndex: 'consumerGroup', key: 'consumerGroup'},
{title: t.DELAY, dataIndex: 'diffTotal', key: 'diffTotal'},
{
title: t.LAST_CONSUME_TIME,
dataIndex: 'lastTimestamp',
key: 'lastTimestamp',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
]}
rowKey="consumerGroup"
size="small"
style={{marginBottom: '12px'}}
/>
<Table
bordered
pagination={false}
dataSource={consumeDetail.queueStatInfoList}
columns={columns}
rowKey={(record, index) => `${record.brokerName}-${record.queueId}-${index}`}
size="small"
/>
</div>
))
)}
</Modal>
);
};
export default ConsumerViewDialog;

View File

@@ -0,0 +1,65 @@
/*
* 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 {Button, Modal, Table} from "antd";
import React from "react";
const ResetOffsetResultDialog = ({visible, onClose, result, t}) => {
return (
<Modal
title="ResetResult"
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
{result && Object.entries(result).map(([groupName, groupData]) => (
<div key={groupName} style={{marginBottom: '16px', border: '1px solid #f0f0f0', padding: '10px'}}>
<Table
dataSource={[{groupName, status: groupData.status}]}
columns={[
{title: 'GroupName', dataIndex: 'groupName', key: 'groupName'},
{title: 'State', dataIndex: 'status', key: 'status'},
]}
pagination={false}
rowKey="groupName"
size="small"
bordered
/>
{groupData.rollbackStatsList === null ? (
<div>You Should Check It Yourself</div>
) : (
<Table
dataSource={groupData.rollbackStatsList.map((item, index) => ({key: index, item}))}
columns={[{dataIndex: 'item', key: 'item'}]}
pagination={false}
rowKey="key"
size="small"
bordered
showHeader={false}
/>
)}
</div>
))}
</Modal>
);
};
export default ResetOffsetResultDialog;

View File

@@ -0,0 +1,115 @@
/*
* 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 {Button, Modal, Table} from "antd";
import React from "react";
const RouterViewDialog = ({visible, onClose, topic, routeData, t}) => {
const brokerColumns = [
{
title: 'Broker',
dataIndex: 'brokerName',
key: 'brokerName',
},
{
title: 'Broker Addrs',
key: 'brokerAddrs',
render: (_, record) => (
<Table
dataSource={Object.entries(record.brokerAddrs || []).map(([key, value]) => ({
key,
idx: key,
address: value
}))}
columns={[
{title: 'Index', dataIndex: 'idx', key: 'idx'},
{title: 'Address', dataIndex: 'address', key: 'address'},
]}
pagination={false}
bordered
size="small"
/>
),
},
];
const queueColumns = [
{
title: t.BROKER_NAME,
dataIndex: 'brokerName',
key: 'brokerName',
},
{
title: t.READ_QUEUE_NUMS,
dataIndex: 'readQueueNums',
key: 'readQueueNums',
},
{
title: t.WRITE_QUEUE_NUMS,
dataIndex: 'writeQueueNums',
key: 'writeQueueNums',
},
{
title: t.PERM,
dataIndex: 'perm',
key: 'perm',
},
];
return (
<Modal
title={`${topic}${t.ROUTER}`}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<div className="limit_height">
<div>
<h3>Broker Datas:</h3>
{routeData?.brokerDatas?.map((item, index) => (
<div key={index} style={{marginBottom: '15px', border: '1px solid #d9d9d9', padding: '10px'}}>
<Table
dataSource={[item]}
columns={brokerColumns}
pagination={false}
bordered
size="small"
/>
</div>
))}
</div>
<div style={{marginTop: '20px'}}>
<h3>{t.QUEUE_DATAS}:</h3>
<Table
dataSource={routeData?.queueDatas || []}
columns={queueColumns}
pagination={false}
bordered
size="small"
/>
</div>
</div>
</Modal>
);
};
export default RouterViewDialog;

View File

@@ -0,0 +1,64 @@
/*
* 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 {Button, Form, Modal, Table} from "antd";
import React from "react";
const SendResultDialog = ({visible, onClose, result, t}) => {
return (
<Modal
title="SendResult"
open={visible}
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form layout="horizontal">
<Table
bordered
dataSource={
result
? Object.entries(result).map(([key, value], index) => ({
key: index,
label: key,
value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value),
}))
: []
}
columns={[
{dataIndex: 'label', key: 'label'},
{
dataIndex: 'value',
key: 'value',
render: (text) => <pre style={{whiteSpace: 'pre-wrap', margin: 0}}>{text}</pre>,
},
]}
pagination={false}
showHeader={false}
rowKey="key"
size="small"
/>
</Form>
</Modal>
);
};
export default SendResultDialog;

View File

@@ -0,0 +1,103 @@
/*
* 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 {Button, Checkbox, Form, Input, Modal} from "antd";
import React, {useEffect} from "react";
import {remoteApi} from "../../api/remoteApi/remoteApi";
const SendTopicMessageDialog = ({
visible,
onClose,
topic,
setSendResultData,
setIsSendResultModalVisible,
setIsSendTopicMessageModalVisible,
message,
t,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (visible) {
form.setFieldsValue({
topic: topic,
tag: '',
key: '',
messageBody: '',
traceEnabled: false,
});
} else {
form.resetFields();
}
}, [visible, topic, form]);
const handleSendTopicMessage = async () => {
try {
const values = await form.validateFields();
const result = await remoteApi.sendTopicMessage(values);
if (result.status === 0) {
setSendResultData(result.data);
setIsSendResultModalVisible(true);
setIsSendTopicMessageModalVisible(false);
} else {
message.error(result.errMsg);
}
} catch (error) {
console.error("Error sending message:", error);
message.error("Failed to send message");
}
};
return (
<Modal
title={`${t.SEND}[${topic}]${t.MESSAGE}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="commit" type="primary" onClick={handleSendTopicMessage}>
{t.COMMIT}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{span: 6}} wrapperCol={{span: 18}}>
<Form.Item label={t.TOPIC} name="topic">
<Input disabled/>
</Form.Item>
<Form.Item label={t.TAG} name="tag">
<Input/>
</Form.Item>
<Form.Item label={t.KEY} name="key">
<Input/>
</Form.Item>
<Form.Item label={t.MESSAGE_BODY} name="messageBody" rules={[{required: true, message: t.REQUIRED}]}>
<Input.TextArea
style={{maxHeight: '200px', minHeight: '200px', resize: 'none'}}
rows={8}
/>
</Form.Item>
<Form.Item label={t.ENABLE_MESSAGE_TRACE} name="traceEnabled" valuePropName="checked">
<Checkbox/>
</Form.Item>
</Form>
</Modal>
);
};
export default SendTopicMessageDialog;

View File

@@ -0,0 +1,77 @@
/*
* 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 {Button, Form, message, Modal, Select} from "antd";
import React, {useEffect, useState} from "react";
const SkipMessageAccumulateDialog = ({
visible,
onClose,
topic,
allConsumerGroupList,
handleSkipMessageAccumulate,
t
}) => {
const [form] = Form.useForm();
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState([]);
useEffect(() => {
if (!visible) {
setSelectedConsumerGroup([]);
form.resetFields();
}
}, [visible, form]);
const handleCommit = () => {
if (!selectedConsumerGroup.length) {
message.error(t.PLEASE_SELECT_GROUP);
return;
}
handleSkipMessageAccumulate(selectedConsumerGroup);
onClose();
};
return (
<Modal
title={`${topic} ${t.SKIP_MESSAGE_ACCUMULATE}`}
open={visible}
onCancel={onClose}
footer={[
<Button key="commit" type="primary" onClick={handleCommit}>
{t.COMMIT}
</Button>,
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Form form={form} layout="horizontal" labelCol={{span: 6}} wrapperCol={{span: 18}}>
<Form.Item label={t.SUBSCRIPTION_GROUP} required>
<Select
mode="multiple"
placeholder={t.SELECT_CONSUMER_GROUP}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
options={allConsumerGroupList.map(group => ({value: group, label: group}))}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default SkipMessageAccumulateDialog;

View File

@@ -0,0 +1,66 @@
/*
* 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 moment from "moment/moment";
import {Button, Modal, Table} from "antd";
import React from "react";
const StatsViewDialog = ({visible, onClose, topic, statsData, t}) => {
const columns = [
{title: t.QUEUE, dataIndex: 'queue', key: 'queue', align: 'center'},
{title: t.MIN_OFFSET, dataIndex: 'minOffset', key: 'minOffset', align: 'center'},
{title: t.MAX_OFFSET, dataIndex: 'maxOffset', key: 'maxOffset', align: 'center'},
{
title: t.LAST_UPDATE_TIME_STAMP,
dataIndex: 'lastUpdateTimestamp',
key: 'lastUpdateTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
];
const dataSource = statsData?.offsetTable ? Object.entries(statsData.offsetTable).map(([queue, info]) => ({
key: queue,
queue: queue,
...info,
})) : [];
return (
<Modal
title={`[${topic}]${t.STATUS}`}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
>
<Table
bordered
dataSource={dataSource}
columns={columns}
pagination={false}
rowKey="key"
size="small"
/>
</Modal>
);
};
export default StatsViewDialog;

View File

@@ -0,0 +1,65 @@
/*
* 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.
*/
// TopicModifyDialog.js
import {Button, Modal} from "antd";
import React from "react";
import TopicSingleModifyForm from './TopicSingleModifyForm';
const TopicModifyDialog = ({
visible,
onClose,
initialData,
bIsUpdate,
writeOperationEnabled,
allClusterNameList,
allBrokerNameList,
onSubmit,
t,
}) => {
return (
<Modal
title={bIsUpdate ? t.TOPIC_CHANGE : t.TOPIC_ADD}
open={visible}
onCancel={onClose}
width={700}
footer={[
<Button key="close" onClick={onClose}>
{t.CLOSE}
</Button>,
]}
Style={{maxHeight: '70vh', overflowY: 'auto'}}
>
{initialData.map((data, index) => (
<TopicSingleModifyForm
key={index}
initialData={data}
bIsUpdate={bIsUpdate}
writeOperationEnabled={writeOperationEnabled}
allClusterNameList={allClusterNameList}
allBrokerNameList={allBrokerNameList}
onSubmit={onSubmit}
formIndex={index}
t={t}
/>
))}
</Modal>
);
};
export default TopicModifyDialog;

View File

@@ -0,0 +1,145 @@
/*
* 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.
*/
// TopicSingleModifyForm.js
import React, {useEffect} from "react";
import {Button, Col, Divider, Form, Input, Row, Select} from "antd";
const TopicSingleModifyForm = ({
initialData,
bIsUpdate,
writeOperationEnabled,
allClusterNameList,
allBrokerNameList,
onSubmit,
formIndex,
t,
}) => {
const [form] = Form.useForm();
useEffect(() => {
if (initialData) {
form.setFieldsValue(initialData);
} else {
form.resetFields();
}
}, [initialData, form, formIndex]);
const handleFormSubmit = () => {
form.validateFields()
.then(values => {
const updatedValues = {...values};
// 提交时,如果 clusterNameList 或 brokerNameList 为空,则填充所有可用的名称
if (!bIsUpdate) {
if (!updatedValues.clusterNameList || updatedValues.clusterNameList.length === 0) {
updatedValues.clusterNameList = allClusterNameList;
}
if (!updatedValues.brokerNameList || updatedValues.brokerNameList.length === 0) {
updatedValues.brokerNameList = allBrokerNameList;
}
}
onSubmit(updatedValues, formIndex); // 传递 formIndex
})
.catch(info => {
console.log('Validate Failed:', info);
});
};
const messageTypeOptions = [
{value: 'TRANSACTION', label: 'TRANSACTION'},
{value: 'FIFO', label: 'FIFO'},
{value: 'DELAY', label: 'DELAY'},
{value: 'NORMAL', label: 'NORMAL'},
];
return (
<div style={{paddingBottom: 24}}>
{bIsUpdate && <Divider
orientation="left">{`${t.TOPIC_CONFIG} - ${initialData.brokerNameList ? initialData.brokerNameList.join(', ') : t.UNKNOWN_BROKER}`}</Divider>}
<Row justify="center"> {/* 使用 Row 居中内容 */}
<Col span={16}> {/* 表单内容占据 12 栅格宽度,并自动居中 */}
<Form
form={form}
layout="horizontal"
labelCol={{span: 8}}
wrapperCol={{span: 16}}
>
<Form.Item label={t.CLUSTER_NAME} name="clusterNameList">
<Select
mode="multiple"
disabled={bIsUpdate}
placeholder={t.SELECT_CLUSTER_NAME}
options={allClusterNameList.map(name => ({value: name, label: name}))}
/>
</Form.Item>
<Form.Item label="BROKER_NAME" name="brokerNameList">
<Select
mode="multiple"
disabled={bIsUpdate}
placeholder={t.SELECT_BROKER_NAME}
options={allBrokerNameList.map(name => ({value: name, label: name}))}
/>
</Form.Item>
<Form.Item
label={t.TOPIC_NAME}
name="topicName"
rules={[{required: true, message: `${t.TOPIC_NAME}${t.CANNOT_BE_EMPTY}`}]}
>
<Input disabled={bIsUpdate}/>
</Form.Item>
<Form.Item label={t.MESSAGE_TYPE} name="messageType">
<Select
disabled={bIsUpdate}
options={messageTypeOptions}
/>
</Form.Item>
<Form.Item
label={t.WRITE_QUEUE_NUMS}
name="writeQueueNums"
rules={[{required: true, message: `${t.WRITE_QUEUE_NUMS}${t.CANNOT_BE_EMPTY}`}]}
>
<Input disabled={!writeOperationEnabled}/>
</Form.Item>
<Form.Item
label={t.READ_QUEUE_NUMS}
name="readQueueNums"
rules={[{required: true, message: `${t.READ_QUEUE_NUMS}${t.CANNOT_BE_EMPTY}`}]}
>
<Input disabled={!writeOperationEnabled}/>
</Form.Item>
<Form.Item
label={t.PERM}
name="perm"
rules={[{required: true, message: `${t.PERM}${t.CANNOT_BE_EMPTY}`}]}
>
<Input disabled={!writeOperationEnabled}/>
</Form.Item>
{!initialData.sysFlag && writeOperationEnabled && (
<Form.Item wrapperCol={{offset: 8, span: 16}}>
<Button type="primary" onClick={handleFormSubmit}>
{t.COMMIT}
</Button>
</Form.Item>
)}
</Form>
</Col>
</Row>
</div>
);
};
export default TopicSingleModifyForm;

View File

@@ -0,0 +1,38 @@
/*
* 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, {createContext, useContext, useState} from 'react';
import {translations} from '../i18n';
const LanguageContext = createContext({
lang: 'en',
setLang: () => {
},
t: translations['en'], // 当前语言的文本资源
});
export const LanguageProvider = ({children}) => {
const [lang, setLang] = useState('en');
const t = translations[lang] || translations['en'];
return (
<LanguageContext.Provider value={{lang, setLang, t}}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => useContext(LanguageContext);

View File

@@ -0,0 +1,601 @@
/*
* 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.
*/
export const translations = {
zh: {
"SELECT_TRACE_TOPIC_PLACEHOLDER": "请选择消息轨迹主题",
"TRACE_TOPIC_HINT": "消息轨迹主题",
"ONLY_RETURN_64_MESSAGES": "仅返回64条消息",
"SELECT_TOPIC_PLACEHOLDER": "请选择主题",
"MESSAGE_ID_TOPIC_HINT": "消息ID主题",
"OPERATION_FAILED": "操作失败",
"FORM_VALIDATION_FAILED": "表单验证失败",
"CONFIG_FOR_BROKER": "配置代理",
"RETRY_POLICY": "重试策略",
"CONSUME_TIMEOUT": "消费超时",
"MINUTES": "分钟",
"SYSTEM_FLAG": "系统标志",
"GROUP_NAME": "组名称",
"PLEASE_SELECT_CLUSTER_NAME": "请选择集群名称",
"SELECT_CLUSTERS": "选择集群",
"SELECT_BROKERS": "选择代理",
"CONSUME_ENABLE": "启用消费",
"ORDERLY_CONSUMPTION": "有序消费",
"BROADCAST_CONSUMPTION": "广播消费",
"RETRY_QUEUES": "重试队列",
"MAX_RETRIES": "最大重试次数",
"BROKER_ID": "代理ID",
"SLOW_CONSUMPTION_BROKER": "慢消费代理",
"PLEASE_INPUT_NUMBER": "请输入数字",
"TOPIC_CONFIG": "主题配置",
"TOPIC_ADD": "添加主题",
"SELECT_CLUSTER_NAME": "请选择集群",
"FETCH_TOPIC_FAILED": "获取主题列表失败",
"CONFIRM_DELETE": "确认删除",
"CANCEL": "取消",
"SELECT_DELETE_BROKERS": "请选择在哪个Broker删除消费者组",
"DELETE_CONSUMER_GROUP": "删除消费者组",
"ENGLISH": "英文",
"ADD_CONSUMER": "添加消费者",
"CHINESE": "简体中文",
"CANNOT_BE_EMPTY": "不能为空",
"TITLE": "RocketMQ仪表板",
"CLOSE": "关闭",
"NO": "编号",
"ADDRESS": "地址",
"VERSION": "版本",
"PRO_MSG_TPS": "生产消息TPS",
"CUS_MSG_TPS": "消费消息TPS",
"YESTERDAY_PRO_COUNT": "昨日生产总数",
"YESTERDAY_CUS_COUNT": "昨日消费总数",
"TODAY_PRO_COUNT": "今天生产总数",
"TODAY_CUS_COUNT": "今天消费总数",
"INSTANCE": "实例",
"SPLIT": "分片",
"CLUSTER": "集群",
"CLUSTER_DETAIL": "集群详情",
"COMMIT": "提交",
"TOPIC": "主题",
"SUBSCRIPTION_GROUP": "订阅组",
"PRODUCER_GROUP": "生产组",
"CONSUMER": "消费者",
"PRODUCER": "生产者",
"MESSAGE": "消息",
"MESSAGE_DETAIL": "消息详情",
"RESEND_MESSAGE": "重新发送",
"VIEW_EXCEPTION": "查看异常",
"DLQ_MESSAGE": "死信消息",
"MESSAGETRACE": "消息轨迹",
"OPERATION": "操作",
"ADD": "新增",
"UPDATE": "更新",
"STATUS": "状态",
"ROUTER": "路由",
"MANAGE": "管理",
"CONFIG": "配置",
"SEND_MSG": "发送消息",
"RESET_CUS_OFFSET": "重置消费位点",
"SKIP_MESSAGE_ACCUMULATE": "跳过堆积",
"DELETE": "删除",
"CHANGE_LANG": "更换语言",
"CHANGE_VERSION": "更换版本",
"BROKER": "Broker",
"NORMAL": "普通",
"RETRY": "重试",
"FIFO": "顺序",
"TRANSACTION": "事务",
"UNSPECIFIED": "未指定",
"DLQ": "死信",
"QUANTITY": "数量",
"TYPE": "类型",
"MODE": "模式",
"DELAY": "延迟",
"DASHBOARD": "驾驶舱",
"CONSUME_DETAIL": "消费详情",
"CLIENT": "终端",
"LAST_CONSUME_TIME": "最后消费时间",
"TIME": "时间点",
"RESET": "重置",
"DATE": "日期",
"NO_DATA": "暂无数据",
"SEARCH": "搜索",
"BEGIN": "开始",
"END": "结束",
"TOPIC_CHANGE": "修改主题",
"SEND": "发送",
"SUBSCRIPTION_CHANGE": "修改订阅",
"QUEUE": "队列",
"MIN_OFFSET": "最小位点",
"MAX_OFFSET": "最大位点",
"LAST_UPDATE_TIME_STAMP": "上次更新时间",
"QUEUE_DATAS": "队列信息",
"READ_QUEUE_NUMS": "读队列数量",
"WRITE_QUEUE_NUMS": "写队列数量",
"PERM": "perm",
"TAG": "标签",
"KEY": "值",
"MESSAGE_BODY": "消息主体",
"TOPIC_NAME": "主题名",
"ORDER": "顺序",
"CONSUMER_CLIENT": "消费者终端",
"BROKER_OFFSET": "代理者位点",
"CONSUMER_OFFSET": "消费者位点",
"DIFF_TOTAL": "差值",
"LAST_TIME_STAMP": "上次时间",
"RESET_OFFSET": "重置位点",
"CLUSTER_NAME": "集群名",
"OPS": "运维",
"PROXY": "代理",
"AUTO_REFRESH": "自动刷新",
"REFRESH": "刷新",
"LOGOUT": "退出",
"LOGIN": "登录",
"USER_NAME": "用户名",
"PASSWORD": "密码",
"SYSTEM": "系统",
"WELCOME": "欢迎使用RocketMQ仪表盘",
"ENABLE_MESSAGE_TRACE": "开启消息轨迹",
"MESSAGE_TRACE_DETAIL": "消息轨迹详情",
"TRACE_TOPIC": "消息轨迹主题",
"SELECT_TRACE_TOPIC": "选择消息轨迹主题",
"EXPORT": "导出",
"NO_MATCH_RESULT": "没有查到符合条件的结果",
"BATCH_RESEND": "批量重发",
"BATCH_EXPORT": "批量导出",
"ACCOUNT_INFO": "账户信息",
"IS_ADMIN": "是否管理员",
"DEFAULT_TOPIC_PERM": "topic默认权限",
"DEFAULT_GROUP_PERM": "消费组默认权限",
"TOPIC_PERM": "topic权限",
"GROUP_PERM": "消费组权限",
"SYNCHRONIZE": "同步",
"SHOW": "显示",
"HIDE": "隐藏",
"MESSAGE_TYPE": "消息类型",
"MESSAGE_TYPE_UNSPECIFIED": "未指定,为普通消息",
"MESSAGE_TYPE_NORMAL": "普通消息",
"MESSAGE_TYPE_FIFO": "顺序消息",
"MESSAGE_TYPE_DELAY": "定时/延时消息",
"MESSAGE_TYPE_TRANSACTION": "事务消息",
"UPDATE_TIME": "更新时间",
"TREND": "趋势",
"PROXY_CONFIG": "代理配置",
"READ_MORE": "阅读更多",
"FETCH_PROXY_LIST_FAILED": "获取代理列表失败",
"INPUT_PROXY_ADDR_REQUIRED": "请输入代理地址",
"SUCCESS": "成功",
"ADD_PROXY_FAILED": "添加代理失败",
"INPUT_PROXY_ADDR": "输入代理地址",
"NO_CONFIG_DATA": "无配置数据",
"FETCH_MESSAGE_DETAIL_FAILED": "获取消息详情失败",
"MESSAGE_INFO": "消息信息",
"MESSAGE_PROPERTIES": "消息属性",
"SHOW_ALL_CONTENT": "显示全部内容",
"MESSAGE_TRACKING": "消息追踪",
"CONSUMER_GROUP": "消费者组",
"PLEASE_SELECT_BROKER": "请选择Broker",
"DELETE_SUCCESS": "删除成功",
"FAILED_TO_FETCH_DATA": "获取数据失败",
"REFRESH_SUCCESS": "刷新成功",
"REFRESH_FAILED": "刷新失败",
"REFRESHED": "已刷新",
"QUERY_BROKER_HISTORY_FAILED": "查询Broker历史失败",
"QUERY_TOPIC_HISTORY_FAILED": "查询Topic历史失败",
"QUERY_CLUSTER_LIST_FAILED": "查询集群列表失败",
"QUERY_TOPIC_CURRENT_FAILED": "查询当前Topic失败",
"BROKER_NAME": "Broker名称",
"BROKER_ADDR": "Broker地址",
"WARNING": "警告",
"PLEASE_SELECT_CONSUMER_GROUP": "请选择消费者组",
"END_TIME_LATER_THAN_BEGIN_TIME": "结束时间应晚于开始时间",
"NO_RESULT": "无结果",
"QUERY_FAILED": "查询失败",
"MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED": "消息ID和消费者组为必填项",
"RESEND_SUCCESS": "重新发送成功",
"RESULT": "结果",
"TOPIC_AND_KEY_REQUIRED": "Topic和Key为必填项",
"MESSAGE_ID_REQUIRED": "消息ID为必填项",
"REFRESHING_TOPIC_LIST": "正在刷新Topic列表",
"TOPIC_OPERATION_SUCCESS": "Topic操作成功",
"ARE_YOU_SURE_TO_DELETE": "您确定要删除吗?",
"YES": "是",
"NOT": "否",
"BLUE": "蓝色",
"GREEN": "绿色",
"PINK": "粉色",
"DEFAULT": "默认",
"INVALID_IP_ADDRESSES": "以下IP地址不合法: ",
"ENABLED": "启用",
"DISABLED": "禁用",
"GET_USERS_FAILED": "获取用户列表失败: ",
"UNKNOWN_ERROR": "未知错误",
"GET_USERS_EXCEPTION": "获取用户列表异常",
"N_A": "N/A",
"INVALID_OR_EMPTY_ACL_DATA": "收到无效或空的ACL数据。",
"GET_ACLS_FAILED": "获取ACL列表失败: ",
"GET_ACLS_EXCEPTION": "获取ACL列表异常",
"USER_DELETE_SUCCESS": "用户删除成功",
"USER_DELETE_FAILED": "用户删除失败: ",
"USER_DELETE_EXCEPTION": "用户删除异常",
"USER_UPDATE_SUCCESS": "用户更新成功",
"USER_CREATE_SUCCESS": "用户创建成功",
"SAVE_USER_FAILED": "保存用户失败",
"ACL_DELETE_SUCCESS": "ACL 删除成功",
"ACL_DELETE_FAILED": "ACL 删除失败: ",
"ACL_DELETE_EXCEPTION": "ACL 删除异常",
"ACL_UPDATE_SUCCESS": "ACL 更新成功",
"ACL_UPDATE_FAILED": "ACL 更新失败: ",
"ACL_CREATE_SUCCESS": "ACL 创建成功",
"ACL_CREATE_FAILED": "ACL 创建失败: ",
"SAVE_ACL_FAILED": "保存 ACL 失败",
"USERNAME": "用户名",
"VIEW": "查看",
"USER_TYPE": "用户类型",
"USER_STATUS": "用户状态",
"MODIFY": "修改",
"CONFIRM_DELETE_USER": "确定删除此用户吗?",
"USERNAME_SUBJECT": "用户名/Subject",
"POLICY_TYPE": "策略类型",
"RESOURCE_NAME": "资源名",
"OPERATION_TYPE": "操作类型",
"SOURCE_IP": "来源IP",
"DECISION": "决策",
"CONFIRM_DELETE_ACL": "确定删除此ACL吗",
"ACL_MANAGEMENT": "ACL 管理",
"ACL_USERS": "ACL 用户",
"ACL_PERMISSIONS": "ACL 权限",
"ADD_USER": "添加用户",
"ADD_ACL_PERMISSION": "添加 ACL 权限",
"SEARCH_PLACEHOLDER": "搜索...",
"USER": "用户",
"ACL_PERMISSION": "ACL 权限",
"EDIT_USER": "编辑用户",
"CONFIRM": "确认",
"PLEASE_ENTER_USERNAME": "请输入用户名!",
"PLEASE_ENTER_PASSWORD": "请输入密码!",
"PLEASE_SELECT_USER_TYPE": "请选择用户类型!",
"PLEASE_SELECT_USER_STATUS": "请选择用户状态!",
"EDIT_ACL_PERMISSION": "编辑 ACL 权限",
"SUBJECT_LABEL": "Subject (例如: User:yourUsername)",
"PLEASE_ENTER_SUBJECT": "请输入 Subject!",
"PLEASE_ENTER_POLICY_TYPE": "请输入策略类型!",
"RESOURCE": "资源",
"PLEASE_ADD_RESOURCE": "请添加资源!",
"ENTER_IP_HINT": "请输入 IP 地址,按回车键添加,支持 IPv4、IPv6 和 CIDR",
"PLEASE_ENTER_DECISION": "请输入决策!",
"MENU": "菜单",
"SELECT_PROXY": "选择代理",
"ENABLE_PROXY": "启用代理",
"PROXY_DISABLED": "代理禁用",
"PROXY_ENABLED": "代理启用",
"BROKER_OVERVIEW": "Broker概览",
"TOTAL_MSG_RECEIVED_TODAY": "今天接收的总消息数",
"LOGIN_SUCCESS": "登录成功",
"LOGIN_FAILED": "登录失败",
"USERNAME_REQUIRED": "用户名为必填项",
"USERNAME_PLACEHOLDER": "用户名",
"PASSWORD_REQUIRED": "密码为必填项",
"PASSWORD_PLACEHOLDER": "密码",
"PLEASE_INPUT_NAME":"请输入名称",
"PLEASE_SELECT_CLUSTER": "请选择集群",
"CLIENT_INFORMATION": "客户端信息",
"CONSUME_TYPE": "消费类型",
"MESSAGE_MODEL": "消息模型",
"CONSUME_FROM_WHERE": "从何处消费",
"CLIENT_CONNECTIONS": "客户端连接",
"CLIENT_SUBSCRIPTIONS": "客户端订阅",
"CONNECTION_OVERVIEW": "连接概览",
"CLIENTID": "客户端 ID",
"CLIENTADDR": "客户端地址",
"LANGUAGE": "语言",
"SUBSCRIPTION_EXPRESSION": "订阅表达式",
"EXPRESSION_TYPE": "表达式类型",
"SUB_VERSION": "订阅版本",
"CODE_SET": "代码集",
"TAGS_SET": "标签集"
},
en: {
"DEFAULT": "Default",
"BLUE": "Blue",
"GREEN": "Green",
"PINK": "Pink",
"NOT": "No",
"ARE_YOU_SURE_TO_DELETE": "Are you sure to delete?",
"YES": "Yes",
"SELECT_TRACE_TOPIC_PLACEHOLDER": "Please select trace topic",
"TRACE_TOPIC_HINT": "Trace Topic",
"ONLY_RETURN_64_MESSAGES": "Only return 64 messages",
"SELECT_TOPIC_PLACEHOLDER": "Please select topic",
"MESSAGE_ID_TOPIC_HINT": "Message ID Topic",
"TOPIC_ADD": "Add Topic",
"SKIP_MESSAGE_ACCUMULATE": "Skip Message Accumulate",
"OPERATION_FAILED": "Operation Failed",
"FORM_VALIDATION_FAILED": "Form Validation Failed",
"ADD_CONSUMER": "Add Consumer",
"CONFIG_FOR_BROKER": "Config for Broker",
"RETRY_POLICY": "Retry Policy",
"CONSUME_TIMEOUT": "Consume Timeout",
"MINUTES": "Minutes",
"SYSTEM_FLAG": "System Flag",
"GROUP_NAME": "Group Name",
"CANNOT_BE_EMPTY": "Cannot be empty",
"PLEASE_SELECT_CLUSTER_NAME": "Please select cluster name",
"SELECT_CLUSTERS": "Select Clusters",
"SELECT_BROKERS": "Select Brokers",
"CONSUME_ENABLE": "Consume Enable",
"ORDERLY_CONSUMPTION": "Orderly Consumption",
"BROADCAST_CONSUMPTION": "Broadcast Consumption",
"RETRY_QUEUES": "Retry Queues",
"MAX_RETRIES": "Max Retries",
"BROKER_ID": "Broker ID",
"SLOW_CONSUMPTION_BROKER": "Slow Consumption Broker",
"PLEASE_INPUT_NUMBER": "Please input number",
"FETCH_TOPIC_FAILED": "Failed to fetch topic list",
"ENGLISH": "English",
"CHINESE": "Chinese",
"TITLE": "RocketMQ-Dashboard",
"CLOSE": "Close",
"NO": "NO.",
"ADDRESS": "Address",
"VERSION": "Version",
"PRO_MSG_TPS": "Produce Message TPS",
"CUS_MSG_TPS": "Consumer Message TPS",
"YESTERDAY_PRO_COUNT": "Yesterday Produce Count",
"YESTERDAY_CUS_COUNT": "Yesterday Consume Count",
"TODAY_PRO_COUNT": "Today Produce Count",
"TODAY_CUS_COUNT": "Today Consume Count",
"INSTANCE": "Instance",
"SPLIT": "Broker",
"CLUSTER": "Cluster",
"CLUSTER_DETAIL": "Cluster Detail",
"TOPIC": "Topic",
"SUBSCRIPTION_GROUP": "SubscriptionGroup",
"PRODUCER_GROUP": "ProducerGroup",
"CONSUMER": "Consumer",
"PRODUCER": "Producer",
"MESSAGE": "Message",
"MESSAGE_DETAIL": "Message Detail",
"RESEND_MESSAGE": "Resend Message",
"VIEW_EXCEPTION": "View Exception",
"MESSAGETRACE": "MessageTrace",
"DLQ_MESSAGE": "DLQMessage",
"COMMIT": "Commit",
"OPERATION": "Operation",
"ADD": "Add",
"UPDATE": "Update",
"STATUS": "Status",
"ROUTER": "Router",
"MANAGE": "Manage",
"CONFIG": "Config",
"SEND_MSG": "Send Massage",
"RESET_CUS_OFFSET": "Reset Consumer Offset",
"DELETE": "Delete",
"CHANGE_LANG": "ChangeLanguage",
"CHANGE_VERSION": "ChangeVersion",
"BROKER": "Broker",
"NORMAL": "NORMAL",
"RETRY": "RETRY",
"FIFO": "FIFO",
"TRANSACTION": "TRANSACTION",
"UNSPECIFIED": "UNSPECIFIED",
"DLQ": "DLQ",
"QUANTITY": "Quantity",
"TYPE": "Type",
"MODE": "Mode",
"DELAY": "Delay",
"DASHBOARD": "Dashboard",
"CONSUME_DETAIL": "CONSUME DETAIL",
"CLIENT": "CLIENT",
"LAST_CONSUME_TIME": "LastConsumeTime",
"TIME": "Time",
"RESET": "RESET",
"DATE": "Date",
"NO_DATA": "NO DATA",
"SEARCH": "Search",
"BEGIN": "Begin",
"END": "End",
"TOPIC_CHANGE": "Topic Change",
"SEND": "Send",
"SUBSCRIPTION_CHANGE": "Subscription Change",
"QUEUE": "Queue",
"MIN_OFFSET": "minOffset",
"MAX_OFFSET": "maxOffset",
"LAST_UPDATE_TIME_STAMP": "lastUpdateTimeStamp",
"QUEUE_DATAS": "queueDatas",
"READ_QUEUE_NUMS": "readQueueNums",
"WRITE_QUEUE_NUMS": "writeQueueNums",
"PERM": "perm",
"TAG": "Tag",
"KEY": "Key",
"MESSAGE_BODY": "Message Body",
"TOPIC_NAME": "topicName",
"ORDER": "order",
"CONSUMER_CLIENT": "consumerClient",
"BROKER_OFFSET": "brokerOffset",
"CONSUMER_OFFSET": "consumerOffset",
"DIFF_TOTAL": "diffTotal",
"LAST_TIME_STAMP": "lastTimeStamp",
"RESET_OFFSET": "resetOffset",
"CLUSTER_NAME": "clusterName",
"OPS": "OPS",
"PROXY": "Proxy",
"AUTO_REFRESH": "AUTO_REFRESH",
"REFRESH": "REFRESH",
"LOGOUT": "Logout",
"LOGIN": "Login",
"USER_NAME": "Username",
"PASSWORD": "Password",
"SYSTEM": "SYSTEM",
"WELCOME": "Welcome using RocketMQ Dashboard",
"ENABLE_MESSAGE_TRACE": "Enable Message Trace",
"MESSAGE_TRACE_DETAIL": "Message Trace Detail",
"TRACE_TOPIC": "TraceTopic",
"SELECT_TRACE_TOPIC": "selectTraceTopic",
"EXPORT": "export",
"NO_MATCH_RESULT": "no match result",
"BATCH_RESEND": "batchReSend",
"BATCH_EXPORT": "batchExport",
"ACCOUNT_INFO": "Account Info",
"IS_ADMIN": "Is Admin",
"DEFAULT_TOPIC_PERM": "Default Topic Permission",
"DEFAULT_GROUP_PERM": "Default Group Permission",
"TOPIC_PERM": "Topic Permission",
"GROUP_PERM": "Group Permission",
"SYNCHRONIZE": "Synchronize Data",
"SHOW": "Show",
"HIDE": "Hide",
"MESSAGE_TYPE": "messageType",
"MESSAGE_TYPE_UNSPECIFIED": "UNSPECIFIED, is NORMAL",
"MESSAGE_TYPE_NORMAL": "NORMAL",
"MESSAGE_TYPE_FIFO": "FIFO",
"MESSAGE_TYPE_DELAY": "DELAY",
"MESSAGE_TYPE_TRANSACTION": "TRANSACTION",
"UPDATE_TIME": "Update Time",
"TREND": "trend",
"FETCH_PROXY_LIST_FAILED": "Failed to fetch proxy list",
"INPUT_PROXY_ADDR_REQUIRED": "Input proxy address required",
"SUCCESS": "Success",
"ADD_PROXY_FAILED": "Failed to add proxy",
"INPUT_PROXY_ADDR": "Input proxy address",
"NO_CONFIG_DATA": "No configuration data",
"FETCH_MESSAGE_DETAIL_FAILED": "Failed to fetch message details",
"MESSAGE_INFO": "Message info",
"MESSAGE_PROPERTIES": "Message properties",
"SHOW_ALL_CONTENT": "Show all content",
"MESSAGE_TRACKING": "Message tracking",
"CONSUMER_GROUP": "Consumer group",
"PLEASE_SELECT_BROKER": "Please select a broker",
"DELETE_SUCCESS": "Delete successful",
"FAILED_TO_FETCH_DATA": "Failed to fetch data",
"REFRESH_SUCCESS": "Refresh successful",
"REFRESH_FAILED": "Refresh failed",
"REFRESHED": "Refreshed",
"QUERY_BROKER_HISTORY_FAILED": "Failed to query broker history",
"QUERY_TOPIC_HISTORY_FAILED": "Failed to query topic history",
"QUERY_CLUSTER_LIST_FAILED": "Failed to query cluster list",
"QUERY_TOPIC_CURRENT_FAILED": "Failed to query current topic",
"BROKER_NAME": "Broker name",
"BROKER_ADDR": "Broker address",
"WARNING": "Warning",
"PLEASE_SELECT_CONSUMER_GROUP": "Please select a consumer group",
"END_TIME_LATER_THAN_BEGIN_TIME": "End time should be later than begin time",
"NO_RESULT": "No result",
"QUERY_FAILED": "Query failed",
"MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED": "Message ID and consumer group required",
"RESEND_SUCCESS": "Resend successful",
"RESULT": "Result",
"TOPIC_AND_KEY_REQUIRED": "Topic and key required",
"MESSAGE_ID_REQUIRED": "Message ID required",
"REFRESHING_TOPIC_LIST": "Refreshing topic list",
"TOPIC_OPERATION_SUCCESS": "Topic operation successful",
"INVALID_IP_ADDRESSES": "The following IP addresses are invalid: ",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"GET_USERS_FAILED": "Failed to get user list: ",
"UNKNOWN_ERROR": "Unknown error",
"GET_USERS_EXCEPTION": "Exception getting user list",
"N_A": "N/A",
"INVALID_OR_EMPTY_ACL_DATA": "Received invalid or empty ACL data.",
"GET_ACLS_FAILED": "Failed to get ACL list: ",
"GET_ACLS_EXCEPTION": "Exception getting ACL list",
"USER_DELETE_SUCCESS": "User deleted successfully",
"USER_DELETE_FAILED": "User deletion failed: ",
"USER_DELETE_EXCEPTION": "User deletion exception",
"USER_UPDATE_SUCCESS": "User updated successfully",
"USER_CREATE_SUCCESS": "User created successfully",
"SAVE_USER_FAILED": "Failed to save user",
"ACL_DELETE_SUCCESS": "ACL deleted successfully",
"ACL_DELETE_FAILED": "ACL deletion failed: ",
"ACL_DELETE_EXCEPTION": "ACL deletion exception",
"ACL_UPDATE_SUCCESS": "ACL updated successfully",
"ACL_UPDATE_FAILED": "ACL update failed: ",
"ACL_CREATE_SUCCESS": "ACL created successfully",
"ACL_CREATE_FAILED": "ACL creation failed: ",
"SAVE_ACL_FAILED": "Failed to save ACL",
"USERNAME": "Username",
"VIEW": "View",
"USER_TYPE": "User Type",
"USER_STATUS": "User Status",
"MODIFY": "Modify",
"CONFIRM_DELETE_USER": "Are you sure you want to delete this user?",
"USERNAME_SUBJECT": "Username/Subject",
"POLICY_TYPE": "Policy Type",
"RESOURCE_NAME": "Resource Name",
"OPERATION_TYPE": "Operation Type",
"SOURCE_IP": "Source IP",
"DECISION": "Decision",
"CONFIRM_DELETE_ACL": "Are you sure you want to delete this ACL?",
"ACL_MANAGEMENT": "ACL Management",
"ACL_USERS": "ACL Users",
"ACL_PERMISSIONS": "ACL Permissions",
"ADD_USER": "Add User",
"ADD_ACL_PERMISSION": "Add ACL Permission",
"SEARCH_PLACEHOLDER": "Search ...",
"USER": "User",
"ACL_PERMISSION": "ACL Permission",
"EDIT_USER": "Edit User",
"CANCEL": "Cancel",
"CONFIRM": "Confirm",
"PLEASE_ENTER_USERNAME": "Please enter username!",
"PLEASE_ENTER_PASSWORD": "Please enter password!",
"PLEASE_SELECT_USER_TYPE": "Please select user type!",
"PLEASE_SELECT_USER_STATUS": "Please select user status!",
"EDIT_ACL_PERMISSION": "Edit ACL Permission",
"SUBJECT_LABEL": "Subject (e.g.: User:yourUsername)",
"PLEASE_ENTER_SUBJECT": "Please enter Subject!",
"PLEASE_ENTER_POLICY_TYPE": "Please enter policy type!",
"RESOURCE": "Resource",
"PLEASE_ADD_RESOURCE": "Please add resource!",
"ENTER_IP_HINT": "Please enter IP address, press Enter to add. Supports IPv4, IPv6, and CIDR.",
"PLEASE_ENTER_DECISION": "Please enter decision!",
"MENU": "Menu",
"SELECT_PROXY": "Select Proxy",
"ENABLE_PROXY": "Enable Proxy",
"PROXY_DISABLED": "Proxy Disabled",
"PROXY_ENABLED": "Proxy Enabled",
"BROKER_OVERVIEW": "Broker Overview",
"TOTAL_MSG_RECEIVED_TODAY": "Total messages received today",
"LOGIN_SUCCESS": "Login successful",
"LOGIN_FAILED": "Login failed",
"USERNAME_REQUIRED": "Username is required",
"USERNAME_PLACEHOLDER": "Username placeholder",
"PASSWORD_REQUIRED": "Password is required",
"PASSWORD_PLACEHOLDER": "Password placeholder",
"PLEASE_INPUT_NAME": "Please input name",
"PLEASE_SELECT_CLUSTER": "Please select cluster",
"SUBSCRIPTION": "Subscription",
"CLIENT_INFORMATION": "Client Information",
"CONSUME_TYPE": "Consume Type",
"MESSAGE_MODEL": "Message Model",
"CONSUME_FROM_WHERE": "Consume From Where",
"CLIENT_CONNECTIONS": "Client Connections",
"CLIENT_SUBSCRIPTIONS": "Client Subscriptions",
"CONNECTION_OVERVIEW": "Connection Overview",
"CLIENTID": "Client ID",
"CLIENTADDR": "Client Address",
"LANGUAGE": "Language",
"SUBSCRIPTION_EXPRESSION": "Subscription Expression",
"EXPRESSION_TYPE": "Expression Type",
"SUB_VERSION": "Sub Version",
"CODE_SET": "Code Set",
"TAGS_SET": "Tags Set",
"DELETE_CONSUMER_GROUP": "Delete Consumer Group",
"SELECT_DELETE_BROKERS": "Please select brokers to delete consumer group",
"CONFIRM_DELETE": "Confirm Delete",
}
};

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@@ -14,17 +14,28 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import {App as AntdApp} from 'antd';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import {LanguageProvider} from "./i18n/LanguageContext";
import {Provider} from "react-redux";
import store from './store';
ReactDOM.render( const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<LanguageProvider>
<React.StrictMode> <React.StrictMode>
<AntdApp>
<Provider store={store}>
<App/> <App/>
</React.StrictMode>, </Provider>
document.getElementById('root') </AntdApp>
</React.StrictMode>
</LanguageProvider>
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function

View File

@@ -0,0 +1,798 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag} from 'antd';
import {DeleteOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined} from '@ant-design/icons';
import {remoteApi} from "../../api/remoteApi/remoteApi";
import ResourceInput from '../../components/acl/ResourceInput';
import SubjectInput from "../../components/acl/SubjectInput";
import {useLanguage} from "../../i18n/LanguageContext";
const {TabPane} = Tabs;
const {Search} = Input;
const Acl = () => {
const [activeTab, setActiveTab] = useState('users');
const [userListData, setUserListData] = useState([]);
const [aclListData, setAclListData] = useState([]);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [isUserModalVisible, setIsUserModalVisible] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [userForm] = Form.useForm();
const [showPassword, setShowPassword] = useState(false);
const [isAclModalVisible, setIsAclModalVisible] = useState(false);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
const [currentAcl, setCurrentAcl] = useState(null);
const [aclForm] = Form.useForm();
const [messageApi, msgContextHolder] = message.useMessage();
const [isUpdate, setIsUpdate] = useState(false);
const [ips, setIps] = useState([]);
const {t} = useLanguage();
// 校验IP地址的正则表达式
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^((?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){6}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,1}|(?:[0-9A-Fa-f]{1,4}:){5}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,2}|(?:[0-9A-Fa-f]{1,4}:){4}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,3}|(?:[0-9A-Fa-f]{1,4}:){3}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,4}|(?:[0-9A-Fa-f]{1,4}:){2}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}|(?:[0-9A-Fa-f]{1,4}:){1}[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}|(?::(?::[0-9A-Fa-f]{1,4}){1,7}|::))(\/(?:12[0-7]|1[0-1][0-9]|[1-9]?[0-9]))?$/;
// 支持 IPv4 和 IPv6包括 CIDR 表示法
// State to store the entire clusterInfo object for easy access
const [clusterData, setClusterData] = useState(null);
// State for the list of available cluster names for the dropdown
const [clusterNamesOptions, setClusterNamesOptions] = useState([]);
// State for the currently selected cluster name
const [selectedCluster, setSelectedCluster] = useState(undefined);
// State for the list of available broker names for the dropdown (depends on selectedCluster)
const [brokerNamesOptions, setBrokerNamesOptions] = useState([]);
// State for the currently selected broker name
const [selectedBroker, setSelectedBroker] = useState(undefined);
// State for the address of the selected broker
const [brokerAddress, setBrokerAddress] = useState(undefined);
const [searchValue, setSearchValue] = useState('');
// --- Data Fetching and Initial Setup ---
useEffect(() => {
const fetchData = async () => {
const clusterResponse = await remoteApi.getClusterList();
if (clusterResponse.status === 0 && clusterResponse.data) {
const {clusterInfo} = clusterResponse.data;
setClusterData(clusterInfo); // Store the entire clusterInfo
// Populate cluster names for the first dropdown
const clusterNames = Object.keys(clusterInfo?.clusterAddrTable || {});
setClusterNamesOptions(clusterNames.map(name => ({label: name, value: name})));
// Set initial selections if clusters are available
if (clusterNames.length > 0) {
const defaultCluster = clusterNames[0];
setSelectedCluster(defaultCluster);
// Manually trigger broker list update for the default cluster
updateBrokerOptions(defaultCluster, clusterInfo);
// Set default broker and its address if available
const brokersInDefaultCluster = clusterInfo.clusterAddrTable[defaultCluster] || [];
if (brokersInDefaultCluster.length > 0) {
const defaultBroker = brokersInDefaultCluster[0];
setSelectedBroker(defaultBroker);
// Get the address from brokerAddrTable using the defaultBroker name
const addr = clusterInfo.brokerAddrTable?.[defaultBroker]?.brokerAddrs?.["0"];
setBrokerAddress(addr);
}
}
} else {
console.error('Failed to fetch cluster list:', clusterResponse.errMsg);
}
};
if (!clusterData) {
setLoading(true);
fetchData().finally(() => setLoading(false));
}
if (brokerAddress) {
if (activeTab === 'users') {
fetchUsers().finally(() => setLoading(false));
} else {
fetchAcls().finally(() => setLoading(false));
}
}
}, [activeTab]); // Dependencies for useEffect
useEffect(() => {
const userPermission = localStorage.getItem('userrole');
if (userPermission == 2) {
setWriteOperationEnabled(false);
} else {
setWriteOperationEnabled(true);
}
}, []);
// --- Helper function to update broker options based on selected cluster ---
const updateBrokerOptions = (clusterName, info = clusterData) => {
if (!info || !info.clusterAddrTable) {
setBrokerNamesOptions([]);
return;
}
const brokersInCluster = info.clusterAddrTable[clusterName] || [];
setBrokerNamesOptions(brokersInCluster.map(broker => ({label: broker, value: broker})));
};
// --- Event Handlers ---
const handleClusterChange = (value) => {
setSelectedCluster(value);
setSelectedBroker(undefined); // Reset broker selection
setBrokerAddress(undefined); // Reset broker address
// Update the broker options based on the newly selected cluster
updateBrokerOptions(value);
};
const handleBrokerChange = (value) => {
setSelectedBroker(value);
// Find the corresponding broker address from clusterData
if (clusterData && clusterData.brokerAddrTable && clusterData.brokerAddrTable[value]) {
const addr = clusterData.brokerAddrTable[value].brokerAddrs?.["0"];
setBrokerAddress(addr);
} else {
setBrokerAddress(undefined);
}
};
const handleIpChange = value => {
// 过滤掉重复的IP地址
const uniqueIps = Array.from(new Set(value));
setIps(uniqueIps);
};
const handleIpDeselect = value => {
// 移除被取消选择的IP
setIps(ips.filter(ip => ip !== value));
};
const validateIp = (rule, value) => {
if (!value || value.length === 0) {
return Promise.resolve(); // Allow empty
}
const invalidIps = value.filter(ip => !ipRegex.test(ip));
if (invalidIps.length > 0) {
return Promise.reject(t.INVALID_IP_ADDRESSES + "ips:" + invalidIps.join(', '));
}
return Promise.resolve();
};
// --- Data Loading Functions ---
const fetchUsers = async () => {
setLoading(true);
try {
const result = await remoteApi.listUsers(selectedBroker, selectedCluster);
if (result && result.status === 0 && result.data) {
const formattedUsers = result.data.map(user => ({
...user,
key: user.username, // Table needs key
userStatus: user.userStatus === 'enable' ? t.ENABLED : t.DISABLED // Format status
}));
setUserListData(formattedUsers);
} else {
messageApi.error(t.GET_USERS_FAILED + result?.errMsg);
}
} catch (error) {
console.error("Failed to fetch users:", error);
messageApi.error(t.GET_USERS_EXCEPTION);
} finally {
setLoading(false);
}
};
const fetchAcls = async () => {
setLoading(true);
try {
const result = await remoteApi.listAcls(selectedBroker, searchValue, selectedCluster);
if (result && result.status === 0) {
const formattedAcls = [];
if (result && result.data && Array.isArray(result.data)) {
result.data.forEach((acl, aclIndex) => {
const subject = acl.subject;
if (acl.policies && Array.isArray(acl.policies)) {
acl.policies.forEach((policy, policyIndex) => {
const policyType = policy.policyType;
if (policy.entries && Array.isArray(policy.entries)) {
policy.entries.forEach((entry, entryIndex) => {
const resources = Array.isArray(entry.resource) ? entry.resource : (entry.resource ? [entry.resource] : []);
resources.forEach((singleResource, resourceIndex) => {
formattedAcls.push({
key: `acl-${aclIndex}-policy-${policyIndex}-entry-${entryIndex}-resource-${singleResource}`,
subject: subject,
policyType: policyType,
resource: singleResource || t.N_A,
actions: (entry.actions && Array.isArray(entry.actions)) ? entry.actions.join(', ') : '',
sourceIps: (entry.sourceIps && Array.isArray(entry.sourceIps)) ? entry.sourceIps.join(', ') : t.N_A,
decision: entry.decision || t.N_A
});
});
});
}
});
}
});
} else {
console.warn(t.INVALID_OR_EMPTY_ACL_DATA);
}
setAclListData(formattedAcls);
} else {
messageApi.error(t.GET_ACLS_FAILED + result?.errMsg);
}
} catch (error) {
console.error("Failed to fetch ACLs:", error);
messageApi.error(t.GET_ACLS_EXCEPTION);
} finally {
setLoading(false);
}
};
// --- User Management Logic ---
const handleAddUser = () => {
setCurrentUser(null);
userForm.resetFields();
setShowPassword(false);
setIsUserModalVisible(true);
};
const handleEditUser = (record) => {
setCurrentUser(record);
userForm.setFieldsValue({
username: record.username,
password: record.password,
userType: record.userType,
userStatus: record.userStatus === t.ENABLED ? 'enable' : 'disable'
});
setShowPassword(false);
setIsUserModalVisible(true);
};
const handleDeleteUser = async (username) => {
setLoading(true);
try {
const result = await remoteApi.deleteUser(selectedBroker, username, selectedCluster);
if (result.status === 0) {
messageApi.success(t.USER_DELETE_SUCCESS);
fetchUsers();
} else {
messageApi.error(t.USER_DELETE_FAILED + result.errMsg);
}
} catch (error) {
console.error("Failed to delete user:", error);
messageApi.error(t.USER_DELETE_EXCEPTION);
} finally {
setLoading(false);
}
};
const handleUserModalOk = async () => {
try {
const values = await userForm.validateFields();
setLoading(true);
let result;
const userInfoParam = {
username: values.username,
password: values.password,
userType: values.userType,
userStatus: values.userStatus,
};
if (currentUser) {
result = await remoteApi.updateUser(selectedBroker, userInfoParam, selectedCluster);
if (result.status === 0) {
messageApi.success(t.USER_UPDATE_SUCCESS);
} else {
messageApi.error(result.errMsg);
}
} else {
result = await remoteApi.createUser(selectedBroker, userInfoParam, selectedCluster);
if (result.status === 0) {
messageApi.success(t.USER_CREATE_SUCCESS);
} else {
messageApi.error(result.errMsg);
}
}
setIsUserModalVisible(false);
fetchUsers();
} catch (error) {
console.error("Failed to save user:", error);
messageApi.error(t.SAVE_USER_FAILED);
} finally {
setLoading(false);
}
};
// --- ACL Permission Management Logic ---
const handleAddAcl = () => {
setCurrentAcl(null);
setIsUpdate(false)
aclForm.resetFields();
setIsAclModalVisible(true);
};
const handleEditAcl = (record) => {
setCurrentAcl(record);
setIsUpdate(true);
aclForm.setFieldsValue({
subject: record.subject,
policyType: record.policyType,
resource: record.resource,
actions: record.actions ? record.actions.split(', ') : [],
sourceIps: record.sourceIps ? record.sourceIps.split(', ') : [],
decision: record.decision
});
setIsAclModalVisible(true);
};
const handleDeleteAcl = async (subject, resource) => {
setLoading(true);
try {
const result = await remoteApi.deleteAcl(selectedBroker, subject, resource, selectedCluster);
if (result.status === 0) {
messageApi.success(t.ACL_DELETE_SUCCESS);
fetchAcls();
} else {
messageApi.error(t.ACL_DELETE_FAILED + result.errMsg);
}
} catch (error) {
console.error("Failed to delete ACL:", error);
messageApi.error(t.ACL_DELETE_EXCEPTION);
} finally {
setLoading(false);
}
};
const handleAclModalOk = async () => {
try {
const values = await aclForm.validateFields();
setLoading(true);
let result;
const policiesParam = [
{
policyType: values.policyType,
entries: [
{
resource: isUpdate ? [values.resource] : values.resource,
actions: values.actions,
sourceIps: values.sourceIps,
decision: values.decision
}
]
}
];
if (isUpdate) { // This condition seems reversed for update/create based on the current logic.
result = await remoteApi.updateAcl(selectedBroker, values.subject, policiesParam, selectedCluster);
if (result.status === 0) {
messageApi.success(t.ACL_UPDATE_SUCCESS);
setIsAclModalVisible(false);
fetchAcls();
} else {
messageApi.error(t.ACL_UPDATE_FAILED + result.errMsg);
}
setIsUpdate(false)
} else {
result = await remoteApi.createAcl(selectedBroker, values.subject, policiesParam, selectedCluster);
if (result.status === 0) {
messageApi.success(t.ACL_CREATE_SUCCESS);
setIsAclModalVisible(false);
fetchAcls();
} else {
messageApi.error(t.ACL_CREATE_FAILED + result.errMsg);
}
}
} catch (error) {
console.error("Failed to save ACL:", error);
messageApi.error(t.SAVE_ACL_FAILED);
} finally {
setLoading(false);
}
};
const handleInputChange = (e) => {
setSearchValue(e.target.value);
};
// --- Search Functionality ---
const handleSearch = (value) => {
if (activeTab === 'users') {
const filteredData = userListData.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(value.toLowerCase())
)
);
if (value === '') {
fetchUsers();
} else {
setUserListData(filteredData);
}
} else {
fetchAcls();
}
};
// --- User Table Column Definitions ---
const userColumns = [
{
title: t.USERNAME,
dataIndex: 'username',
key: 'username',
},
{
title: t.PASSWORD,
dataIndex: 'password',
key: 'password',
render: (text) => (
<span>
{showPassword ? text : '********'}
<Button
type="link"
icon={showPassword ? <EyeInvisibleOutlined/> : <EyeOutlined/>}
onClick={() => setShowPassword(!showPassword)}
style={{marginLeft: 8}}
>
{showPassword ? t.HIDE : t.VIEW}
</Button>
</span>
),
},
{
title: t.USER_TYPE,
dataIndex: 'userType',
key: 'userType',
},
{
title: t.USER_STATUS,
dataIndex: 'userStatus',
key: 'userStatus',
render: (status) => (
<Tag color={status === 'Enabled' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: t.OPERATION,
key: 'action',
render: (_, record) => (
writeOperationEnabled ? (
<Space size="middle">
<Button icon={<EditOutlined/>} onClick={() => handleEditUser(record)}>{t.MODIFY}</Button>
<Popconfirm
title={t.CONFIRM_DELETE_USER}
onConfirm={() => handleDeleteUser(record.username)}
okText={t.YES}
cancelText={t.NO}
>
<Button icon={<DeleteOutlined/>} danger>{t.DELETE}</Button>
</Popconfirm>
</Space>
) : null
),
},
];
// --- ACL Permission Table Column Definitions ---
const aclColumns = [
{
title: t.USERNAME_SUBJECT,
dataIndex: 'subject',
key: 'subject',
},
{
title: t.POLICY_TYPE,
dataIndex: 'policyType',
key: 'policyType',
},
{
title: t.RESOURCE_NAME,
dataIndex: 'resource',
key: 'resource',
},
{
title: t.OPERATION_TYPE,
dataIndex: 'actions',
key: 'actions',
render: (text) => text ? text.split(', ').map((action, index) => (
<Tag key={index} color="blue">{action}</Tag>
)) : null,
},
{
title: t.SOURCE_IP,
dataIndex: 'sourceIps',
key: 'sourceIps',
},
{
title: t.DECISION,
dataIndex: 'decision',
key: 'decision',
render: (text) => (
<Tag color={text === 'Allow' ? 'green' : 'red'}>{text}</Tag>
),
},
{
title: t.OPERATION,
key: 'action',
render: (_, record) => (
writeOperationEnabled ? (
<Space size="middle">
<Button icon={<EditOutlined/>} onClick={() => handleEditAcl(record)}>{t.MODIFY}</Button>
<Popconfirm
title={t.CONFIRM_DELETE_ACL}
onConfirm={() => handleDeleteAcl(record.subject, record.resource)}
okText={t.YES}
cancelText={t.NO}
>
<Button icon={<DeleteOutlined/>} danger>{t.DELETE}</Button>
</Popconfirm>
</Space>
) : null
),
},
];
return (
<>
{msgContextHolder}
<div style={{padding: 24}}>
<h2>{t.ACL_MANAGEMENT}</h2>
<div style={{marginBottom: 16, display: 'flex', gap: 16}}>
<Form.Item label={t.PLEASE_SELECT_CLUSTER} style={{marginBottom: 0}}>
<Select
placeholder={t.PLEASE_SELECT_CLUSTER}
style={{width: 200}}
onChange={handleClusterChange}
value={selectedCluster}
options={clusterNamesOptions}
/>
</Form.Item>
<Form.Item label={t.PLEASE_SELECT_BROKER} style={{marginBottom: 0}}>
<Select
placeholder={t.PLEASE_SELECT_BROKER}
style={{width: 200}}
onChange={handleBrokerChange}
value={selectedBroker}
options={brokerNamesOptions}
disabled={!selectedCluster}
allowClear
/>
</Form.Item>
<Button type="primary" onClick={activeTab === 'users' ? fetchUsers : fetchAcls}>
{t.CONFIRM}
</Button>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab={t.ACL_USERS} key="users"/>
<TabPane tab={t.ACL_PERMISSIONS} key="acls"/>
</Tabs>
<div style={{marginBottom: 16, display: 'flex', justifyContent: 'space-between'}}>
<Button type="primary" onClick={activeTab === 'users' ? handleAddUser : handleAddAcl}>
{activeTab === 'users' ? t.ADD_USER : t.ADD_ACL_PERMISSION}
</Button>
<Search
placeholder={t.SEARCH_PLACEHOLDER}
allowClear
onSearch={handleSearch}
value={searchValue}
onChange={handleInputChange}
style={{width: 300}}
/>
</div>
{activeTab === 'users' && (
<Table
columns={userColumns}
dataSource={userListData}
loading={loading}
pagination={{pageSize: 10}}
rowKey="username"
/>
)}
{activeTab === 'acls' && (
<Table
columns={aclColumns}
dataSource={aclListData}
loading={loading}
pagination={{pageSize: 10}}
rowKey="key"
/>
)}
{/* User Management Modal */}
<Modal
title={currentUser ? t.EDIT_USER : t.ADD_USER}
visible={isUserModalVisible}
onOk={handleUserModalOk}
onCancel={() => setIsUserModalVisible(false)}
confirmLoading={loading}
footer={[
<Button key="cancel" onClick={() => setIsUserModalVisible(false)}>
{t.CANCEL}
</Button>,
<Button key="submit" type="primary" onClick={handleUserModalOk} loading={loading}>
{t.CONFIRM}
</Button>,
]}
>
<Form
form={userForm}
layout="vertical"
name="user_form"
initialValues={{userStatus: 'enable'}}
>
<Form.Item
name="username"
label={t.USERNAME}
rules={[{required: true, message: t.PLEASE_ENTER_USERNAME}]}
>
<Input disabled={!!currentUser}/>
</Form.Item>
<Form.Item
name="password"
label={t.PASSWORD}
rules={[{required: !currentUser, message: t.PLEASE_ENTER_PASSWORD}]}
>
<Input.Password
placeholder={t.PASSWORD}
iconRender={visible => (visible ? <EyeOutlined/> : <EyeInvisibleOutlined/>)}
/>
</Form.Item>
<Form.Item
name="userType"
label={t.USER_TYPE}
rules={[{required: true, message: t.PLEASE_SELECT_USER_TYPE}]}
>
<Select mode="single" placeholder="Super, Normal" style={{width: '100%'}}>
<Select.Option value="Super">Super</Select.Option>
<Select.Option value="Normal">Normal</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="userStatus"
label={t.USER_STATUS}
rules={[{required: true, message: t.PLEASE_SELECT_USER_STATUS}]}
>
<Select mode="single" placeholder="enable, disable" style={{width: '100%'}}>
<Select.Option value="enable">enable</Select.Option>
<Select.Option value="disable">disable</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* ACL Permission Management Modal */}
<Modal
title={currentAcl ? t.EDIT_ACL_PERMISSION : t.ADD_ACL_PERMISSION}
visible={isAclModalVisible}
onOk={handleAclModalOk}
onCancel={() => setIsAclModalVisible(false)}
confirmLoading={loading}
>
<Form
form={aclForm}
layout="vertical"
name="acl_form"
>
<Form.Item
name="subject"
label={t.SUBJECT_LABEL}
rules={[{required: true, message: t.PLEASE_ENTER_SUBJECT}]}
>
<SubjectInput disabled={!!currentAcl} t={t}/>
</Form.Item>
<Form.Item
name="policyType"
label={t.POLICY_TYPE}
rules={[{required: true, message: t.PLEASE_ENTER_POLICY_TYPE}]}
>
<Select mode="single" disabled={isUpdate} placeholder="policyType" style={{width: '100%'}}>
<Select.Option value="Custom">Custom</Select.Option>
<Select.Option value="Default">Default</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="resource"
label={t.RESOURCE}
rules={[{required: true, message: t.PLEASE_ADD_RESOURCE}]}
>
{isUpdate ? (
<Input disabled={isUpdate}/>
) : (
<ResourceInput/>
)}
</Form.Item>
<Form.Item
name="actions"
label={t.OPERATION_TYPE}
>
<Select mode="multiple" placeholder="action" style={{width: '100%'}}>
<Select.Option value="All">All</Select.Option>
<Select.Option value="Pub">Pub</Select.Option>
<Select.Option value="Sub">Sub</Select.Option>
<Select.Option value="Create">Create</Select.Option>
<Select.Option value="Update">Update</Select.Option>
<Select.Option value="Delete">Delete</Select.Option>
<Select.Option value="Get">Get</Select.Option>
<Select.Option value="List">List</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="sourceIps"
label={t.SOURCE_IP}
rules={[
{
validator: validateIp,
},
]}
>
<Select
mode="tags"
style={{width: '100%'}}
placeholder={t.ENTER_IP_HINT}
onChange={handleIpChange}
onDeselect={handleIpDeselect}
value={ips}
tokenSeparators={[',', ' ']}
>
<Select.Option value="192.168.1.1">192.168.1.1</Select.Option>
<Select.Option value="0.0.0.0">0.0.0.0</Select.Option>
<Select.Option value="127.0.0.1">127.0.0.1</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="decision"
label={t.DECISION}
rules={[{required: true, message: t.PLEASE_ENTER_DECISION}]}
>
<Select mode="single" placeholder="Allow, Deny" style={{width: '100%'}}>
<Select.Option value="Allow">Allow</Select.Option>
<Select.Option value="Deny">Deny</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
</>
);
}
export default Acl;

View File

@@ -0,0 +1,303 @@
/*
* 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, {useCallback, useEffect, useState} from 'react';
import {Button, Modal, notification, Select, Spin, Table} from 'antd';
import {useLanguage} from "../../i18n/LanguageContext";
import {remoteApi, tools} from "../../api/remoteApi/remoteApi"; // 确保路径正确
const {Option} = Select;
const Cluster = () => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [clusterNames, setClusterNames] = useState([]);
const [selectedCluster, setSelectedCluster] = useState('');
const [instances, setInstances] = useState([]);
const [allBrokersData, setAllBrokersData] = useState({});
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [configModalVisible, setConfigModalVisible] = useState(false);
const [currentDetail, setCurrentDetail] = useState({});
const [currentConfig, setCurrentConfig] = useState({});
const [currentBrokerName, setCurrentBrokerName] = useState('');
const [currentIndex, setCurrentIndex] = useState(null); // 对应 brokerId
const [currentBrokerAddress, setCurrentBrokerAddress] = useState('');
const [api, contextHolder] = notification.useNotification();
const switchCluster = useCallback((clusterName) => {
if (allBrokersData[clusterName]) {
setInstances(allBrokersData[clusterName]);
} else {
setInstances([]);
}
}, [allBrokersData]);
const handleChangeCluster = (value) => {
setSelectedCluster(value);
switchCluster(value);
};
useEffect(() => {
setLoading(true);
remoteApi.queryClusterList((resp) => {
setLoading(false);
if (resp.status === 0) {
const {clusterInfo, brokerServer} = resp.data;
const {clusterAddrTable, brokerAddrTable} = clusterInfo;
const generatedBrokers = tools.generateBrokerMap(brokerServer, clusterAddrTable, brokerAddrTable);
setAllBrokersData(generatedBrokers);
const names = Object.keys(clusterAddrTable);
setClusterNames(names);
if (names.length > 0) {
const defaultCluster = names[0];
setSelectedCluster(defaultCluster);
if (generatedBrokers[defaultCluster]) {
setInstances(generatedBrokers[defaultCluster]);
} else {
setInstances([]);
}
}
} else {
api.error({message: resp.errMsg || t.QUERY_CLUSTER_LIST_FAILED, duration: 2});
}
});
}, []);
const showDetail = (brokerName, brokerId, record) => { // 传入 record 整个对象,方便直接显示
setCurrentBrokerName(brokerName);
setCurrentIndex(brokerId);
setCurrentDetail(record); // 直接使用 record 作为详情
setDetailModalVisible(true);
};
const showConfig = (brokerAddress, brokerName, brokerId) => { // 保持一致,传入 brokerId
setCurrentBrokerName(brokerName);
setCurrentIndex(brokerId);
setCurrentBrokerAddress(brokerAddress);
setLoading(true);
remoteApi.queryBrokerConfig(brokerAddress, (resp) => {
setLoading(false);
if (resp.status === 0) {
// ✨ 确保 resp.data 是一个对象,如果后端返回的不是对象,这里需要处理
if (typeof resp.data === 'object' && resp.data !== null) {
setCurrentConfig(resp.data);
setConfigModalVisible(true);
} else {
api.error({message: t.INVALID_CONFIG_DATA || 'Invalid config data received', duration: 2});
setCurrentConfig({}); // 清空配置,避免显示错误
}
} else {
api.error({message: resp.errMsg || t.QUERY_BROKER_CONFIG_FAILED, duration: 2});
}
});
};
const columns = [
{
title: t.SPLIT,
dataIndex: 'brokerName', // 直接使用 brokerId
key: 'split',
align: 'center'
},
{
title: t.NO,
dataIndex: 'brokerId', // 直接使用 brokerId
key: 'no',
align: 'center',
render: (brokerId) => `${brokerId}${brokerId === 0 ? `(${t.MASTER})` : `(${t.SLAVE})`}`,
},
{
title: t.ADDRESS,
dataIndex: 'address', // 确保 generateBrokerMap 返回的数据有 address 字段
key: 'address',
align: 'center',
},
{
title: t.VERSION,
dataIndex: 'brokerVersionDesc',
key: 'version',
align: 'center',
},
{
title: t.PRO_MSG_TPS,
dataIndex: 'putTps',
key: 'putTps',
align: 'center',
render: (text) => {
const tpsValue = text ? Number(String(text).split(' ')[0]) : 0; // 确保text是字符串
return tpsValue.toFixed(2);
},
},
{
title: t.CUS_MSG_TPS,
key: 'cusMsgTps',
align: 'center',
render: (_, record) => {
// 根据你提供的数据结构,这里可能是 getTransferredTps
const val = record.getTransferedTps?.trim() ? record.getTransferedTps : record.getTransferredTps;
const tpsValue = val ? Number(String(val).split(' ')[0]) : 0; // 确保val是字符串
return tpsValue.toFixed(2);
},
},
{
title: t.YESTERDAY_PRO_COUNT,
key: 'yesterdayProCount',
align: 'center',
render: (_, record) => {
const putTotalTodayMorning = parseFloat(record.msgPutTotalTodayMorning || 0);
const putTotalYesterdayMorning = parseFloat(record.msgPutTotalYesterdayMorning || 0);
return (putTotalTodayMorning - putTotalYesterdayMorning).toLocaleString();
}
},
{
title: t.YESTERDAY_CUS_COUNT,
key: 'yesterdayCusCount',
align: 'center',
render: (_, record) => {
const getTotalTodayMorning = parseFloat(record.msgGetTotalTodayMorning || 0);
const getTotalYesterdayMorning = parseFloat(record.msgGetTotalYesterdayMorning || 0);
return (getTotalTodayMorning - getTotalYesterdayMorning).toLocaleString();
}
},
{
title: t.TODAY_PRO_COUNT,
key: 'todayProCount',
align: 'center',
render: (_, record) => {
const putTotalTodayNow = parseFloat(record.msgPutTotalTodayNow || 0);
const putTotalTodayMorning = parseFloat(record.msgPutTotalTodayMorning || 0);
return (putTotalTodayNow - putTotalTodayMorning).toLocaleString();
}
},
{
title: t.TODAY_CUS_COUNT,
key: 'todayCusCount',
align: 'center',
render: (_, record) => {
const getTotalTodayNow = parseFloat(record.msgGetTotalTodayNow || 0);
const getTotalTodayMorning = parseFloat(record.msgGetTotalTodayMorning || 0);
return (getTotalTodayNow - getTotalTodayMorning).toLocaleString();
}
},
{
title: t.OPERATION,
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button size="small" type="primary"
onClick={() => showDetail(record.brokerName, record.brokerId, record)}
style={{marginRight: 8}}>
{t.STATUS}
</Button>
{/* 传入 record.address */}
<Button size="small" type="primary"
onClick={() => showConfig(record.address, record.brokerName, record.brokerId)}>
{t.CONFIG}
</Button>
</>
),
},
];
return (
<>
{contextHolder}
<Spin spinning={loading} tip={t.LOADING}>
<div style={{padding: 24}}>
<div style={{marginBottom: 16, display: 'flex', alignItems: 'center'}}>
<label style={{marginRight: 8}}>{t.CLUSTER}:</label>
<Select
style={{width: 300}}
placeholder={t.SELECT_CLUSTER || "Please select a cluster"}
value={selectedCluster}
onChange={handleChangeCluster}
allowClear
>
{clusterNames.map((name) => (
<Option key={name} value={name}>
{name}
</Option>
))}
</Select>
</div>
<Table
dataSource={instances}
columns={columns}
rowKey={(record) => `${record.brokerName}-${record.brokerId}`}
pagination={false}
bordered
size="middle"
/>
<Modal
title={`${t.BROKER} [${currentBrokerName}][${currentIndex}]`}
open={detailModalVisible}
footer={null}
onCancel={() => setDetailModalVisible(false)}
width={800}
bodyStyle={{maxHeight: '60vh', overflowY: 'auto'}}
>
<Table
dataSource={Object.entries(currentDetail).map(([key, value]) => ({key, value}))}
columns={[
{title: t.KEY || 'Key', dataIndex: 'key', key: 'key'},
{title: t.VALUE || 'Value', dataIndex: 'value', key: 'value'},
]}
pagination={false}
size="small"
bordered
rowKey="key"
/>
</Modal>
<Modal
title={`${t.BROKER} [${currentBrokerName}][${currentIndex}]`}
open={configModalVisible}
footer={null}
onCancel={() => setConfigModalVisible(false)}
width={800}
bodyStyle={{maxHeight: '60vh', overflowY: 'auto'}}
>
<Table
dataSource={Object.entries(currentConfig).map(([key, value]) => ({key, value}))}
columns={[
{title: t.KEY || 'Key', dataIndex: 'key', key: 'key'},
{title: t.VALUE || 'Value', dataIndex: 'value', key: 'value'},
]}
pagination={false}
size="small"
bordered
rowKey="key"
/>
</Modal>
</div>
</Spin>
</>
);
};
export default Cluster;

View File

@@ -0,0 +1,589 @@
/*
* 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, {useCallback, useEffect, useState} from 'react';
import {Button, Checkbox, Input, message, notification, Select, Spin, Switch, Table} from 'antd';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import ClientInfoModal from "../../components/consumer/ClientInfoModal";
import ConsumerDetailModal from "../../components/consumer/ConsumerDetailModal";
import ConsumerConfigModal from "../../components/consumer/ConsumerConfigModal";
import DeleteConsumerModal from "../../components/consumer/DeleteConsumerModal";
const ConsumerGroupList = () => {
const {t} = useLanguage();
const [filterStr, setFilterStr] = useState('');
const [filterNormal, setFilterNormal] = useState(true);
const [filterFIFO, setFilterFIFO] = useState(false);
const [filterSystem, setFilterSystem] = useState(false);
const [rmqVersion, setRmqVersion] = useState(true);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
const [intervalProcessSwitch, setIntervalProcessSwitch] = useState(false);
const [loading, setLoading] = useState(false);
const [consumerGroupShowList, setConsumerGroupShowList] = useState([]);
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [selectedAddress, setSelectedAddress] = useState(null);
const [showClientInfo, setShowClientInfo] = useState(false);
const [showConsumeDetail, setShowConsumeDetail] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [isAddConfig, setIsAddConfig] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [messageApi, msgContextHolder] = message.useMessage();
const [notificationApi, notificationContextHolder] = notification.useNotification();
const [proxyEnabled, setProxyEnabled] = useState(() => {
try {
const storedValue = localStorage.getItem('proxyEnabled');
return storedValue ? JSON.parse(storedValue) : false;
} catch (error) {
console.error("Failed to read proxyEnabled from localStorage:", error);
return false;
}
});
const [selectedProxy, setSelectedProxy] = useState(() => {
try {
const storedValue = localStorage.getItem('selectedProxy');
return storedValue || undefined;
} catch (error) {
console.error("Failed to read selectedProxy from localStorage:", error);
return undefined;
}
});
const [proxyOptions, setProxyOptions] = useState([]);
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [sortConfig, setSortConfig] = useState({
sortKey: null,
sortOrder: 1,
});
const loadConsumerGroups = useCallback(async (currentPage) => {
setLoading(true);
try {
var response;
if (!proxyEnabled) {
response = await remoteApi.queryConsumerGroupList(false);
} else {
response = await remoteApi.queryConsumerGroupList(false, selectedProxy);
}
if (response.status === 0) {
setAllConsumerGroupList(response.data);
if (currentPage != null) {
filterList(currentPage, response.data);
} else {
filterList(1, response.data);
}
} else {
messageApi.error({title: t.ERROR, content: response.errMsg});
}
} catch (error) {
console.error("Error loading consumer groups:", error);
messageApi.error({title: t.ERROR, content: t.FAILED_TO_FETCH_DATA});
} finally {
setLoading(false);
}
}, [t, proxyEnabled, selectedProxy, messageApi, setAllConsumerGroupList, remoteApi, setLoading]);
const filterByType = (str, type, version) => {
if (filterSystem && type === "SYSTEM") return true;
if (filterNormal && (type === "NORMAL" || (!version && type === "FIFO"))) return true;
if (filterFIFO && type === "FIFO") return true;
return false;
};
const filterList = useCallback((currentPage, data) => {
let sortedData = [...data];
if (sortConfig.sortKey) {
sortedData.sort((a, b) => {
const aValue = a[sortConfig.sortKey];
const bValue = b[sortConfig.sortKey];
if (typeof aValue === 'string') {
return sortConfig.sortOrder * aValue.localeCompare(bValue);
}
return sortConfig.sortOrder * (aValue > bValue ? 1 : -1);
});
}
// 过滤处理
const lowExceptStr = filterStr.toLowerCase();
const canShowList = sortedData.filter(element =>
filterByType(element.group, element.subGroupType, rmqVersion) &&
element.group.toLowerCase().includes(lowExceptStr)
);
// 更新分页和显示列表
const perPage = paginationConf.pageSize;
const from = (currentPage - 1) * perPage;
const to = from + perPage;
setPaginationConf(prev => ({
...prev,
current: currentPage,
total: canShowList.length,
}));
setConsumerGroupShowList(canShowList.slice(from, to));
}, [filterStr, filterNormal, filterSystem, filterFIFO, rmqVersion, sortConfig, paginationConf.pageSize]);
const doSort = useCallback(() => {
const sortedList = [...allConsumerGroupList];
if (sortConfig.sortKey === 'diffTotal') {
sortedList.sort((a, b) => {
return (a.diffTotal > b.diffTotal) ? sortConfig.sortOrder :
((b.diffTotal > a.diffTotal) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'group') {
sortedList.sort((a, b) => {
return (a.group > b.group) ? sortConfig.sortOrder :
((b.group > a.group) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'count') {
sortedList.sort((a, b) => {
return (a.count > b.count) ? sortConfig.sortOrder :
((b.count > a.count) ? -sortConfig.sortOrder : 0);
});
}
if (sortConfig.sortKey === 'consumeTps') {
sortedList.sort((a, b) => {
return (a.consumeTps > b.consumeTps) ? sortConfig.sortOrder :
((b.consumeTps > a.consumeTps) ? -sortConfig.sortOrder : 0);
});
}
setAllConsumerGroupList(sortedList);
filterList(paginationConf.current, sortedList);
}, [sortConfig, allConsumerGroupList, paginationConf.current]);
const fetchProxyList = useCallback(async () => {
remoteApi.queryProxyHomePage((resp) => {
setLoading(false);
if (resp.status === 0) {
const {proxyAddrList, currentProxyAddr} = resp.data;
const options = proxyAddrList.map(proxyAddress => ({
label: proxyAddress,
value: proxyAddress,
}));
setProxyOptions(options || []);
setSelectedProxy(prevSelectedProxy => {
if (prevSelectedProxy) {
return prevSelectedProxy;
}
if (options.length > 0) {
return options[0].value;
}
return undefined;
});
} else {
notificationApi.error({message: resp.errMsg || t.FETCH_PROXY_LIST_FAILED, duration: 2});
}
});
}, [t]);
useEffect(() => {
localStorage.setItem('proxyEnabled', JSON.stringify(proxyEnabled));
}, [proxyEnabled]);
useEffect(() => {
if (selectedProxy) {
localStorage.setItem('selectedProxy', selectedProxy);
} else {
localStorage.removeItem('selectedProxy');
}
}, [selectedProxy]);
useEffect(() => {
fetchProxyList();
}, []);
useEffect(() => {
loadConsumerGroups();
}, [loadConsumerGroups]);
useEffect(() => {
let intervalId;
if (intervalProcessSwitch) {
intervalId = setInterval(loadConsumerGroups, 10000);
}
return () => clearInterval(intervalId);
}, [intervalProcessSwitch, loadConsumerGroups]);
useEffect(() => {
const userPermission = localStorage.getItem('userrole');
console.log(userPermission);
if (userPermission == 2) {
setWriteOperationEnabled(false);
} else {
setWriteOperationEnabled(true);
}
}, []);
useEffect(() => {
filterList(paginationConf.current, allConsumerGroupList);
}, [allConsumerGroupList, filterStr, filterNormal, filterSystem, filterFIFO, sortConfig, filterList, paginationConf.current]);
const handleFilterInputChange = (value) => {
setFilterStr(value);
setPaginationConf(prev => ({...prev, current: 1}));
};
const handleTypeFilterChange = (filterType, checked) => {
switch (filterType) {
case 'normal':
setFilterNormal(checked);
break;
case 'fifo':
setFilterFIFO(checked);
break;
case 'system':
setFilterSystem(checked);
break;
default:
break;
}
setPaginationConf(prev => ({...prev, current: 1}));
};
const handleRefreshConsumerData = async () => {
setLoading(true);
const refreshResult = await remoteApi.refreshAllConsumerGroup(selectedProxy);
setLoading(false);
if (refreshResult && refreshResult.status === 0) {
notificationApi.success({message: t.REFRESH_SUCCESS, duration: 2});
loadConsumerGroups();
} else if (refreshResult && refreshResult.errMsg) {
notificationApi.error({message: t.REFRESH_FAILED + ": " + refreshResult.errMsg, duration: 2});
} else {
notificationApi.error({message: t.REFRESH_FAILED, duration: 2});
}
};
const handleOpenAddDialog = () => {
setIsAddConfig(true)
setShowConfig(true);
};
const handleClient = (group, address) => {
setSelectedGroup(group);
setSelectedAddress(address);
setShowClientInfo(true);
};
const handleDetail = (group, address) => {
setSelectedGroup(group);
setSelectedAddress(address);
setShowConsumeDetail(true);
};
const handleUpdateConfigDialog = (group) => {
setSelectedGroup(group);
setShowConfig(true);
};
const handleDelete = (group) => {
setSelectedGroup(group);
setShowDeleteModal(true);
};
const handleRefreshConsumerGroup = async (group) => {
setLoading(true);
const response = await remoteApi.refreshConsumerGroup(group);
setLoading(false);
if (response.status === 0) {
messageApi.success({content: `${group} ${t.REFRESHED}`});
loadConsumerGroups(paginationConf.current);
} else {
messageApi.error({title: t.ERROR, content: response.errMsg});
}
};
const handleSort = (sortKey) => {
setSortConfig(prev => ({
sortKey,
sortOrder: prev.sortKey === sortKey ? -prev.sortOrder : 1,
}));
setPaginationConf(prev => ({...prev, current: 1}));
};
const columns = [
{
title: <a onClick={() => handleSort('group')}>{t.SUBSCRIPTION_GROUP}</a>,
dataIndex: 'group',
key: 'group',
align: 'center',
render: (text) => {
const sysFlag = text.startsWith('%SYS%');
return (
<span style={{color: sysFlag ? 'red' : ''}}>
{sysFlag ? text.substring(5) : text}
</span>
);
},
},
{
title: <a onClick={() => handleSort('count')}>{t.QUANTITY}</a>,
dataIndex: 'count',
key: 'count',
align: 'center',
},
{
title: t.VERSION,
dataIndex: 'version',
key: 'version',
align: 'center',
},
{
title: t.TYPE,
dataIndex: 'consumeType',
key: 'consumeType',
align: 'center',
},
{
title: t.MODE,
dataIndex: 'messageModel',
key: 'messageModel',
align: 'center',
},
{
title: <a onClick={() => handleSort('consumeTps')}>TPS</a>,
dataIndex: 'consumeTps',
key: 'consumeTps',
align: 'center',
},
{
title: <a onClick={() => handleSort('diffTotal')}>{t.DELAY}</a>,
dataIndex: 'diffTotal',
key: 'diffTotal',
align: 'center',
},
{
title: t.UPDATE_TIME,
dataIndex: 'updateTime',
key: 'updateTime',
align: 'center',
},
{
title: t.OPERATION,
key: 'operation',
align: 'left',
render: (_, record) => {
const sysFlag = record.group.startsWith('%SYS%');
return (
<>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleClient(record.group, record.address)}
>
{t.CLIENT}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleDetail(record.group, record.address)}
>
{t.CONSUME_DETAIL}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleUpdateConfigDialog(record.group)}
>
{t.CONFIG}
</Button>
<Button
type="primary"
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleRefreshConsumerGroup(record.group)}
>
{t.REFRESH}
</Button>
{!sysFlag && writeOperationEnabled && (
<Button
type="primary"
danger
size="small"
style={{marginRight: 8, marginBottom: 8}}
onClick={() => handleDelete(record.group)}
>
{t.DELETE}
</Button>
)}
</>
);
},
},
];
const handleTableChange = (pagination) => {
setPaginationConf(prev => ({
...prev,
current: pagination.current,
pageSize: pagination.pageSize
}));
filterList(pagination.current, allConsumerGroupList);
};
const closeConfigModal = () => {
setShowConfig(false);
setIsAddConfig(false);
}
return (
<>
{msgContextHolder}
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING}>
<div style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
{/* 左侧:筛选和操作按钮 */}
<div style={{display: 'flex', alignItems: 'center', gap: '15px', flexWrap: 'wrap'}}>
<div style={{display: 'flex', alignItems: 'center'}}>
<label
style={{marginRight: '8px', whiteSpace: 'nowrap'}}>{t.SUBSCRIPTION_GROUP}:</label>
<Input
style={{width: '200px'}}
value={filterStr}
onChange={(e) => handleFilterInputChange(e.target.value)}
placeholder="输入订阅组名称"
/>
</div>
<Checkbox checked={filterNormal}
onChange={(e) => handleTypeFilterChange('normal', e.target.checked)}>
{t.NORMAL}
</Checkbox>
{rmqVersion && (
<Checkbox checked={filterFIFO}
onChange={(e) => handleTypeFilterChange('fifo', e.target.checked)}>
{t.FIFO}
</Checkbox>
)}
<Checkbox checked={filterSystem}
onChange={(e) => handleTypeFilterChange('system', e.target.checked)}>
{t.SYSTEM}
</Checkbox>
{writeOperationEnabled && (
<Button type="primary" onClick={handleOpenAddDialog}>
{t.ADD} / {t.UPDATE}
</Button>
)}
<Button type="primary" onClick={handleRefreshConsumerData}>
{t.REFRESH}
</Button>
</div>
{/* 右侧:代理选项 */}
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
<label style={{marginRight: '8px', whiteSpace: 'nowrap'}}>{t.SELECT_PROXY}:</label>
<Select
style={{width: '220px'}}
placeholder={t.SELECT_PROXY}
onChange={(value) => setSelectedProxy(value)}
value={selectedProxy}
options={proxyOptions}
disabled={!proxyEnabled}
allowClear
/>
<label style={{marginRight: '8px', whiteSpace: 'nowrap'}}>{t.ENABLE_PROXY}:</label>
<Switch
checked={proxyEnabled}
onChange={(checked) => {
setProxyEnabled(checked);
if (!checked) {
setSelectedProxy(undefined);
messageApi.info(t.PROXY_DISABLED);
} else {
messageApi.info(t.PROXY_ENABLED);
}
}}
checkedChildren={t.ENABLED}
unCheckedChildren={t.DISABLED}
/>
</div>
</div>
<Table
dataSource={consumerGroupShowList}
columns={columns}
rowKey="group"
bordered
pagination={paginationConf}
onChange={handleTableChange}
sortDirections={['ascend', 'descend']}
/>
</Spin>
<ClientInfoModal
visible={showClientInfo}
group={selectedGroup}
address={selectedAddress}
onCancel={() => setShowClientInfo(false)}
messageApi={messageApi}
/>
<ConsumerDetailModal
visible={showConsumeDetail}
group={selectedGroup}
address={selectedAddress}
onCancel={() => setShowConsumeDetail(false)}
messageApi={messageApi}
/>
<ConsumerConfigModal
visible={showConfig}
isAddConfig={isAddConfig}
group={selectedGroup}
onCancel={closeConfigModal}
setIsAddConfig={setIsAddConfig}
onSuccess={loadConsumerGroups}
/>
<DeleteConsumerModal
visible={showDeleteModal}
group={selectedGroup}
onCancel={() => setShowDeleteModal(false)}
onSuccess={loadConsumerGroups}
t={t}
/>
</div>
</>
);
};
export default ConsumerGroupList;

View File

@@ -0,0 +1,454 @@
/*
* 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, {useCallback, useEffect, useRef, useState} from 'react';
import {Card, Col, DatePicker, message, notification, Row, Select, Spin, Table} from 'antd';
import * as echarts from 'echarts';
import moment from 'moment';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi, tools} from '../../api/remoteApi/remoteApi';
const {Option} = Select;
const DashboardPage = () => {
const {t} = useLanguage();
const barChartRef = useRef(null);
const lineChartRef = useRef(null);
const topicBarChartRef = useRef(null);
const topicLineChartRef = useRef(null);
const [loading, setLoading] = useState(false);
const [date, setDate] = useState(moment());
const [topicNames, setTopicNames] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [brokerTableData, setBrokerTableData] = useState([]);
const barChartInstance = useRef(null);
const lineChartInstance = useRef(null);
const topicBarChartInstance = useRef(null);
const topicLineChartInstance = useRef(null);
const [messageApi, msgContextHolder] = message.useMessage();
const [notificationApi, notificationContextHolder] = notification.useNotification();
const initChart = useCallback((chartRef, titleText, isLine = false) => {
if (chartRef.current) {
const chart = echarts.init(chartRef.current);
let option = {
title: {text: titleText},
tooltip: {},
legend: {data: ['TotalMsg']},
axisPointer: {type: 'shadow'},
xAxis: {
type: 'category',
data: [],
axisLabel: {
inside: false,
color: '#000000',
rotate: 0,
interval: 0
},
axisTick: {show: true},
axisLine: {show: true},
z: 10
},
yAxis: {
type: 'value',
boundaryGap: [0, '100%'],
axisLabel: {formatter: (value) => value.toFixed(2)},
splitLine: {show: true}
},
series: [{name: 'TotalMsg', type: 'bar', data: []}]
};
if (isLine) {
option = {
title: {text: titleText},
toolbox: {
feature: {
dataZoom: {yAxisIndex: 'none'},
restore: {},
saveAsImage: {}
}
},
tooltip: {trigger: 'axis', axisPointer: {animation: false}},
yAxis: {
type: 'value',
boundaryGap: [0, '80%'],
axisLabel: {formatter: (value) => value.toFixed(2)},
splitLine: {show: true}
},
dataZoom: [{
type: 'inside', start: 90, end: 100
}, {
start: 0,
end: 10,
handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z',
handleSize: '80%',
handleStyle: {
color: '#fff',
shadowBlur: 3,
shadowColor: 'rgba(0, 0, 0, 0.6)',
shadowOffsetX: 2,
shadowOffsetY: 2
}
}],
legend: {data: [], top: 30},
xAxis: {type: 'time', boundaryGap: false, data: []},
series: []
};
}
chart.setOption(option);
return chart;
}
return null;
}, []);
useEffect(() => {
barChartInstance.current = initChart(barChartRef, t.BROKER + ' TOP 10');
lineChartInstance.current = initChart(lineChartRef, t.BROKER + ' 5min trend', true);
topicBarChartInstance.current = initChart(topicBarChartRef, t.TOPIC + ' TOP 10');
topicLineChartInstance.current = initChart(topicLineChartRef, t.TOPIC + ' 5min trend', true);
return () => {
barChartInstance.current?.dispose();
lineChartInstance.current?.dispose();
topicBarChartInstance.current?.dispose();
topicLineChartInstance.current?.dispose();
};
}, [t, initChart]);
const getBrokerBarChartOp = useCallback((xAxisData, data) => {
return {
xAxis: {data: xAxisData},
series: [{name: 'TotalMsg', data: data}]
};
}, []);
const getBrokerLineChartOp = useCallback((legend, data) => {
const series = [];
let xAxisData = [];
let isFirstSeries = true;
Object.entries(data).forEach(([key, values]) => {
const tpsValues = [];
values.forEach(tpsValue => {
const tpsArray = tpsValue.split(",");
if (isFirstSeries) {
xAxisData.push(moment(parseInt(tpsArray[0])).format("HH:mm:ss"));
}
tpsValues.push(parseFloat(tpsArray[1]));
});
isFirstSeries = false;
series.push({
name: key,
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
data: tpsValues
});
});
return {
legend: {data: legend},
color: ["#FF0000", "#00BFFF", "#FF00FF", "#1ce322", "#000000", '#EE7942'],
xAxis: {type: 'category', boundaryGap: false, data: xAxisData},
series: series
};
}, []);
const getTopicLineChartOp = useCallback((legend, data) => {
const series = [];
let xAxisData = [];
let isFirstSeries = true;
Object.entries(data).forEach(([key, values]) => {
const tpsValues = [];
values.forEach(tpsValue => {
const tpsArray = tpsValue.split(",");
if (isFirstSeries) {
xAxisData.push(moment(parseInt(tpsArray[0])).format("HH:mm:ss"));
}
tpsValues.push(parseFloat(tpsArray[2]));
});
isFirstSeries = false;
series.push({
name: key,
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
data: tpsValues
});
});
return {
legend: {data: legend},
xAxis: {type: 'category', boundaryGap: false, data: xAxisData},
series: series
};
}, []);
const queryLineData = useCallback(async () => {
const _date = date ? date.format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
lineChartInstance.current?.showLoading();
await remoteApi.queryBrokerHisData(_date, (resp) => {
lineChartInstance.current?.hideLoading();
if (resp.status === 0) {
const _data = {};
const _xAxisData = [];
Object.entries(resp.data).forEach(([address, values]) => {
_data[address] = values;
_xAxisData.push(address);
});
lineChartInstance.current?.setOption(getBrokerLineChartOp(_xAxisData, _data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_BROKER_HISTORY_FAILED, duration: 2});
}
});
if (selectedTopic) {
topicLineChartInstance.current?.showLoading();
await remoteApi.queryTopicHisData(_date, selectedTopic, (resp) => {
topicLineChartInstance.current?.hideLoading();
if (resp.status === 0) {
const _data = {};
_data[selectedTopic] = resp.data;
topicLineChartInstance.current?.setOption(getTopicLineChartOp([selectedTopic], _data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_TOPIC_HISTORY_FAILED, duration: 2});
}
});
}
}, [date, selectedTopic, getBrokerLineChartOp, getTopicLineChartOp, t]);
useEffect(() => {
setLoading(true);
barChartInstance.current?.showLoading();
remoteApi.queryClusterList((resp) => {
setLoading(false);
barChartInstance.current?.hideLoading();
if (resp.status === 0) {
const clusterAddrTable = resp.data.clusterInfo.clusterAddrTable;
const brokerAddrTable = resp.data.clusterInfo.brokerAddrTable; // Corrected to brokerAddrTable
const brokerDetail = resp.data.brokerServer;
const clusterMap = tools.generateBrokerMap(brokerDetail, clusterAddrTable, brokerAddrTable);
let brokerArray = [];
Object.values(clusterMap).forEach(brokersInCluster => {
brokerArray = brokerArray.concat(brokersInCluster);
});
const newData = brokerArray.map(broker => ({
...broker,
key: broker.brokerName,
}));
setBrokerTableData(newData);
brokerArray.sort((firstBroker, lastBroker) => {
const firstTotalMsg = parseFloat(firstBroker.msgGetTotalTodayNow || 0);
const lastTotalMsg = parseFloat(lastBroker.msgGetTotalTodayNow || 0);
return lastTotalMsg - firstTotalMsg;
});
const xAxisData = [];
const data = [];
brokerArray.slice(0, 10).forEach(broker => {
xAxisData.push(`${broker.brokerName}:${broker.index}`);
data.push(parseFloat(broker.msgGetTotalTodayNow || 0));
});
barChartInstance.current?.setOption(getBrokerBarChartOp(xAxisData, data));
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_CLUSTER_LIST_FAILED, duration: 2});
}
});
}, [getBrokerBarChartOp, t]);
useEffect(() => {
topicBarChartInstance.current?.showLoading();
remoteApi.queryTopicCurrentData((resp) => {
topicBarChartInstance.current?.hideLoading();
if (resp.status === 0) {
const topicList = resp.data;
topicList.sort((first, last) => {
const firstTotalMsg = parseFloat(first.split(",")[1] || 0);
const lastTotalMsg = parseFloat(last.split(",")[1] || 0);
return lastTotalMsg - firstTotalMsg;
});
const xAxisData = [];
const data = [];
const names = [];
topicList.forEach((currentData) => {
const currentArray = currentData.split(",");
names.push(currentArray[0]);
});
setTopicNames(names);
if (names.length > 0 && selectedTopic === null) {
setSelectedTopic(names[0]);
}
topicList.slice(0, 10).forEach((currentData) => {
const currentArray = currentData.split(",");
xAxisData.push(currentArray[0]);
data.push(parseFloat(currentArray[1] || 0));
});
const option = {
xAxis: {
data: xAxisData,
axisLabel: {
inside: false,
color: '#000000',
rotate: 60,
interval: 0
},
},
series: [{name: 'TotalMsg', data: data}]
};
topicBarChartInstance.current?.setOption(option);
} else {
notificationApi.error({message: resp.errMsg || t.QUERY_TOPIC_CURRENT_FAILED, duration: 2});
}
});
}, [selectedTopic, t]);
useEffect(() => {
if (barChartInstance.current && lineChartInstance.current && topicBarChartInstance.current && topicLineChartInstance.current) {
queryLineData();
}
}, [date, selectedTopic, queryLineData]);
useEffect(() => {
const intervalId = setInterval(queryLineData, tools.dashboardRefreshTime);
return () => {
clearInterval(intervalId);
};
}, [queryLineData]);
const brokerColumns = [
{title: t.BROKER_NAME, dataIndex: 'brokerName', key: 'brokerName'},
{title: t.BROKER_ADDR, dataIndex: 'address', key: 'address'},
{
title: t.TOTAL_MSG_RECEIVED_TODAY,
dataIndex: 'msgGetTotalTodayNow',
key: 'msgGetTotalTodayNow',
render: (text) => parseFloat(text || 0).toLocaleString(),
sorter: (a, b) => parseFloat(a.msgGetTotalTodayNow || 0) - parseFloat(b.msgGetTotalTodayNow || 0),
},
{
title: t.TODAY_PRO_COUNT,
key: 'todayProCount',
render: (_, record) => parseFloat(record.msgPutTotalTodayMorning || 0).toLocaleString(), // Assuming msgPutTotalTodayMorning is 'today pro count'
},
{
title: t.YESTERDAY_PRO_COUNT,
key: 'yesterdayProCount',
// This calculation (today morning - yesterday morning) might not be correct for 'yesterday pro count'.
// It depends on what msgPutTotalTodayMorning and msgPutTotalYesterdayMorning truly represent.
// If they are cumulative totals up to morning, then the difference is not accurate for yesterday's count.
// You might need a specific 'msgPutTotalYesterdayNow' from the backend.
render: (_, record) => (parseFloat(record.msgPutTotalTodayMorning || 0) - parseFloat(record.msgPutTotalYesterdayMorning || 0)).toLocaleString(),
},
];
return (
<>
{msgContextHolder}
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING}>
<Row gutter={[16, 16]} style={{marginBottom: '20px'}}>
<Col span={12}>
<Card title={t.BROKER_OVERVIEW} bordered>
<Table
columns={brokerColumns}
dataSource={brokerTableData}
rowKey="key"
pagination={false}
size="small"
bordered
scroll={{y: 240}}
/>
</Card>
</Col>
<Col span={12}>
<Card title={t.DASHBOARD_DATE_SELECTION} bordered>
<DatePicker
format="YYYY-MM-DD"
value={date}
onChange={setDate}
allowClear
style={{width: '100%'}}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{marginBottom: '20px'}}>
<Col span={12}>
<Card title={`${t.BROKER} TOP 10`} bordered>
<div ref={barChartRef} style={{height: 300}}/>
</Card>
</Col>
<Col span={12}>
<Card title={`${t.BROKER} 5min ${t.TREND}`} bordered>
<div ref={lineChartRef} style={{height: 300}}/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col span={12}>
<Card title={`${t.TOPIC} TOP 10`} bordered>
<div ref={topicBarChartRef} style={{height: 300}}/>
</Card>
</Col>
<Col span={12}>
<Card title={`${t.TOPIC} 5min ${t.TREND}`} bordered>
<div style={{marginBottom: '10px'}}>
<Select
showSearch
style={{width: '100%'}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{topicNames.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</div>
<div ref={topicLineChartRef} style={{height: 300}}/>
</Card>
</Col>
</Row>
</Spin>
</div>
</>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,700 @@
/*
* 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, {useCallback, useEffect, useState} from 'react';
import {
Button,
Checkbox,
DatePicker,
Form,
Input,
Modal,
notification,
Select,
Spin,
Table,
Tabs,
Typography
} from 'antd';
import moment from 'moment';
import {ExportOutlined, SearchOutlined, SendOutlined} from '@ant-design/icons';
import DlqMessageDetailViewDialog from "../../components/DlqMessageDetailViewDialog"; // Ensure this path is correct
import {useLanguage} from '../../i18n/LanguageContext'; // Ensure this path is correct
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Adjust the path to your remoteApi.js file
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const SYS_GROUP_TOPIC_PREFIX = "CID_RMQ_SYS_"; // Define this constant as in Angular
const DLQ_GROUP_TOPIC_PREFIX = "%DLQ%"; // Define this constant
const DlqMessageQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('consumer');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// Consumer 查询状态
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [selectedConsumerGroup, setSelectedConsumerGroup] = useState(null);
const [timepickerBegin, setTimepickerBegin] = useState(moment().subtract(3, 'hour')); // 默认三小时前
const [timepickerEnd, setTimepickerEnd] = useState(moment());
const [messageShowList, setMessageShowList] = useState([]);
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 20, // Adjusted to 20 as per Angular code
total: 0,
});
const [checkedAll, setCheckedAll] = useState(false);
const [selectedMessageIds, setSelectedMessageIds] = useState(new Set()); // Stores msgId for selected messages
const [messageCheckedList, setMessageCheckedList] = useState([]); // Stores full message objects for checked items
const [taskId, setTaskId] = useState("");
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
const [queryDlqMessageByMessageIdResult, setQueryDlqMessageByMessageIdResult] = useState([]);
const [modalApi, modalContextHolder] = Modal.useModal();
const [notificationApi, notificationContextHolder] = notification.useNotification();
// Fetch consumer group list on component mount
useEffect(() => {
const fetchConsumerGroups = async () => {
setLoading(true);
const resp = await remoteApi.queryConsumerGroupList(false);
if (resp.status === 0) {
const filteredGroups = resp.data
.filter(consumerGroup => !consumerGroup.group.startsWith(SYS_GROUP_TOPIC_PREFIX))
.map(consumerGroup => consumerGroup.group)
.sort();
setAllConsumerGroupList(filteredGroups);
} else {
notificationApi.error({message: t.ERROR, description: resp.errMsg});
}
setLoading(false);
};
fetchConsumerGroups();
}, [t]);
// Effect to manage batch buttons' disabled state
useEffect(() => {
const batchResendBtn = document.getElementById('batchResendBtn');
const batchExportBtn = document.getElementById('batchExportBtn');
if (selectedMessageIds.size > 0) {
batchResendBtn?.classList.remove('disabled');
batchExportBtn?.classList.remove('disabled');
} else {
batchResendBtn?.classList.add('disabled');
batchExportBtn?.classList.add('disabled');
}
}, [selectedMessageIds]);
const onChangeQueryCondition = useCallback(() => {
// console.log("查询条件改变");
setTaskId(""); // Reset taskId when query conditions change
setPaginationConf(prev => ({...prev, currentPage: 1, totalItems: 0}));
}, []);
const queryDlqMessageByConsumerGroup = useCallback(async (page = paginationConf.current, pageSize = paginationConf.pageSize) => {
if (!selectedConsumerGroup) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_CONSUMER_GROUP,
});
return;
}
if (moment(timepickerEnd).valueOf() < moment(timepickerBegin).valueOf()) {
notificationApi.error({message: t.END_TIME_LATER_THAN_BEGIN_TIME, delay: 2000});
return;
}
setLoading(true);
// console.log("根据消费者组查询DLQ消息:", { selectedConsumerGroup, timepickerBegin, timepickerEnd, page, pageSize, taskId });
try {
const resp = await remoteApi.queryDlqMessageByConsumerGroup(
selectedConsumerGroup,
moment(timepickerBegin).valueOf(),
moment(timepickerEnd).valueOf(),
page,
pageSize,
taskId
);
if (resp.status === 0) {
const fetchedMessages = resp.data.page.content.map(msg => ({...msg, checked: false}));
setMessageShowList(fetchedMessages);
if (fetchedMessages.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
setPaginationConf(prev => ({
...prev,
current: resp.data.page.number + 1,
pageSize: pageSize,
total: resp.data.page.totalElements,
}));
setTaskId(resp.data.taskId);
setSelectedMessageIds(new Set()); // Reset选中项
setCheckedAll(false); // Reset全选状态
setMessageCheckedList([]); // Clear checked list
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [selectedConsumerGroup, timepickerBegin, timepickerEnd, paginationConf.current, paginationConf.pageSize, taskId, t]);
const queryDlqMessageByMessageId = useCallback(async () => {
if (!messageId || !selectedConsumerGroup) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_AND_CONSUMER_GROUP_REQUIRED,
});
return;
}
setLoading(true);
try {
const resp = await remoteApi.viewMessage(messageId, DLQ_GROUP_TOPIC_PREFIX + selectedConsumerGroup);
if (resp.status === 0) {
setQueryDlqMessageByMessageIdResult(resp.data ? [resp.data] : []);
if (!resp.data) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [messageId, selectedConsumerGroup, t]);
const queryDlqMessageDetail = useCallback(async (msgId, consumerGroup) => {
setLoading(true);
// console.log(`查询DLQ消息详情: ${msgId}, 消费者组: ${consumerGroup}`);
try {
const resp = await remoteApi.viewMessage(msgId, DLQ_GROUP_TOPIC_PREFIX + consumerGroup);
if (resp.status === 0) {
modalApi.info({
title: t.MESSAGE_DETAIL,
width: 800,
content: (
<DlqMessageDetailViewDialog
ngDialogData={{messageView: resp.data}}
/>
),
onOk: () => {
},
okText: t.CLOSE,
});
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
console.error("查询失败:", error);
} finally {
setLoading(false);
}
}, [t]);
const resendDlqMessage = useCallback(async (messageView, consumerGroup) => {
setLoading(true);
const topic = messageView.properties.RETRY_TOPIC;
const msgId = messageView.properties.ORIGIN_MESSAGE_ID;
// console.log(`重发DLQ消息: MsgId=${msgId}, Topic=${topic}, 消费者组=${consumerGroup}`);
try {
const resp = await remoteApi.resendDlqMessage(msgId, consumerGroup, topic);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.RESEND_SUCCESS,
});
modalApi.info({
title: t.RESULT,
content: resp.data,
});
// Refresh list
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
modalApi.error({
title: t.RESULT,
content: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.RESEND_FAILED,
});
console.error("重发失败:", error);
} finally {
setLoading(false);
}
}, [paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const exportDlqMessage = useCallback(async (msgId, consumerGroup) => {
setLoading(true);
// console.log(`导出DLQ消息: MsgId=${msgId}, 消费者组=${consumerGroup}`);
try {
const resp = await remoteApi.exportDlqMessage(msgId, consumerGroup);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.EXPORT_SUCCESS,
});
// The actual file download is handled within remoteApi.js
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.EXPORT_FAILED,
});
console.error("导出失败:", error);
} finally {
setLoading(false);
}
}, [t]);
const batchResendDlqMessage = useCallback(async () => {
if (selectedMessageIds.size === 0) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_MESSAGE_TO_RESEND,
});
return;
}
setLoading(true);
const messagesToResend = messageCheckedList.map(message => ({
topic: message.properties.RETRY_TOPIC,
msgId: message.properties.ORIGIN_MESSAGE_ID,
consumerGroup: selectedConsumerGroup,
}));
try {
const resp = await remoteApi.batchResendDlqMessage(messagesToResend);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.BATCH_RESEND_SUCCESS,
});
modalApi.info({
title: t.RESULT,
content: resp.data,
});
// Refresh list and reset selected state
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
setSelectedMessageIds(new Set());
setCheckedAll(false);
setMessageCheckedList([]);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
modalApi.error({
title: t.RESULT,
content: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.BATCH_RESEND_FAILED,
});
} finally {
setLoading(false);
}
}, [selectedMessageIds, messageCheckedList, selectedConsumerGroup, paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const batchExportDlqMessage = useCallback(async () => {
if (selectedMessageIds.size === 0) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_MESSAGE_TO_EXPORT,
});
return;
}
setLoading(true);
const messagesToExport = messageCheckedList.map(message => ({
msgId: message.msgId,
consumerGroup: selectedConsumerGroup,
}));
// console.log(`批量导出DLQ消息从 ${selectedConsumerGroup}:`, messagesToExport);
try {
const resp = await remoteApi.batchExportDlqMessage(messagesToExport);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.BATCH_EXPORT_SUCCESS,
});
// The actual file download is handled within remoteApi.js
// Refresh list and reset selected state
queryDlqMessageByConsumerGroup(paginationConf.current, paginationConf.pageSize);
setSelectedMessageIds(new Set());
setCheckedAll(false);
setMessageCheckedList([]);
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.BATCH_EXPORT_FAILED,
});
console.error("批量导出失败:", error);
} finally {
setLoading(false);
}
}, [selectedMessageIds, messageCheckedList, selectedConsumerGroup, paginationConf.current, paginationConf.pageSize, queryDlqMessageByConsumerGroup, t]);
const handleSelectAll = (e) => {
const checked = e.target.checked;
setCheckedAll(checked);
const newSelectedIds = new Set();
const newCheckedList = [];
const updatedList = messageShowList.map(item => {
if (checked) {
newSelectedIds.add(item.msgId);
newCheckedList.push(item);
}
return {...item, checked};
});
setMessageShowList(updatedList);
setSelectedMessageIds(newSelectedIds);
setMessageCheckedList(newCheckedList);
};
const handleSelectItem = (item, checked) => {
const newSelectedIds = new Set(selectedMessageIds);
const newCheckedList = [...messageCheckedList];
if (checked) {
newSelectedIds.add(item.msgId);
newCheckedList.push(item);
} else {
newSelectedIds.delete(item.msgId);
const index = newCheckedList.findIndex(msg => msg.msgId === item.msgId);
if (index > -1) {
newCheckedList.splice(index, 1);
}
}
setSelectedMessageIds(newSelectedIds);
setMessageCheckedList(newCheckedList);
// Update single item checked state in the displayed list
const updatedList = messageShowList.map(msg =>
msg.msgId === item.msgId ? {...msg, checked} : msg
);
setMessageShowList(updatedList);
// Check if all are selected
setCheckedAll(newSelectedIds.size === updatedList.length && updatedList.length > 0);
};
const consumerColumns = [
{
title: (
<Checkbox
checked={checkedAll}
onChange={handleSelectAll}
disabled={messageShowList.length === 0}
/>
),
dataIndex: 'checked',
key: 'checkbox',
align: 'center',
render: (checked, record) => (
<Checkbox
checked={checked}
onChange={(e) => handleSelectItem(record, e.target.checked)}
/>
),
},
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{
title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center',
render: (tags) => tags || '-' // Display '-' if tags are null or undefined
},
{
title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center',
render: (keys) => keys || '-' // Display '-' if keys are null or undefined
},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => queryDlqMessageDetail(record.msgId, selectedConsumerGroup)}>
{t.MESSAGE_DETAIL}
</Button>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => resendDlqMessage(record, selectedConsumerGroup)}>
{t.RESEND_MESSAGE}
</Button>
<Button type="primary" size="small" style={{marginBottom: 8}}
onClick={() => exportDlqMessage(record.msgId, selectedConsumerGroup)}>
{t.EXPORT}
</Button>
</>
),
},
];
const messageIdColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{
title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center',
render: (tags) => tags || '-'
},
{
title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center',
render: (keys) => keys || '-'
},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => queryDlqMessageDetail(record.msgId, selectedConsumerGroup)}>
{t.MESSAGE_DETAIL}
</Button>
<Button type="primary" size="small" style={{marginRight: 8, marginBottom: 8}}
onClick={() => resendDlqMessage(record, selectedConsumerGroup)}>
{t.RESEND_MESSAGE}
</Button>
<Button type="primary" size="small" style={{marginBottom: 8}}
onClick={() => exportDlqMessage(record.msgId, selectedConsumerGroup)}>
{t.EXPORT}
</Button>
</>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip="加载中...">
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab={t.CONSUMER} key="consumer">
<h5 style={{margin: '15px 0'}}>{t.TOTAL_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label={t.CONSUMER}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_CONSUMER_GROUP_PLACEHOLDER}
value={selectedConsumerGroup}
onChange={(value) => {
setSelectedConsumerGroup(value);
onChangeQueryCondition();
}}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allConsumerGroupList.map(group => (
<Option key={group} value={group}>{group}</Option>
))}
</Select>
</Form.Item>
<Form.Item label={t.BEGIN}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerBegin}
onChange={(date) => {
setTimepickerBegin(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item label={t.END}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerEnd}
onChange={(date) => {
setTimepickerEnd(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryDlqMessageByConsumerGroup()}>
{t.SEARCH}
</Button>
</Form.Item>
<Form.Item>
<Button
id="batchResendBtn"
type="primary"
icon={<SendOutlined/>}
onClick={batchResendDlqMessage}
disabled={selectedMessageIds.size === 0}
>
{t.BATCH_RESEND}
</Button>
</Form.Item>
<Form.Item>
<Button
id="batchExportBtn"
type="primary"
icon={<ExportOutlined/>}
onClick={batchExportDlqMessage}
disabled={selectedMessageIds.size === 0}
>
{t.BATCH_EXPORT}
</Button>
</Form.Item>
</Form>
<Table
columns={consumerColumns}
dataSource={messageShowList}
rowKey="msgId"
bordered
pagination={{
current: paginationConf.current,
pageSize: paginationConf.pageSize,
total: paginationConf.total,
onChange: (page, pageSize) => queryDlqMessageByConsumerGroup(page, pageSize),
showSizeChanger: true, // Allow changing page size
pageSizeOptions: ['10', '20', '50', '100'], // Customizable page size options
}}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>
{t.MESSAGE_ID_CONSUMER_GROUP_HINT}
</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label={t.CONSUMER}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_CONSUMER_GROUP_PLACEHOLDER}
value={selectedConsumerGroup}
onChange={setSelectedConsumerGroup}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allConsumerGroupList.map(group => (
<Option key={group} value={group}>{group}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryDlqMessageByMessageId}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={messageIdColumns}
dataSource={queryDlqMessageByMessageIdResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
{modalContextHolder}
</Tabs>
</Spin>
</div>
</>
);
};
export default DlqMessageQueryPage;

View File

@@ -0,0 +1,88 @@
/*
* 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 {Button, Form, Input, message, Typography} from 'antd';
import {remoteApi} from "../../api/remoteApi/remoteApi";
import {useLanguage} from "../../i18n/LanguageContext";
const {Title} = Typography;
const Login = () => {
const [form] = Form.useForm();
const [messageApi, msgContextHolder] = message.useMessage();
const {t} = useLanguage();
const onFinish = async (values) => {
const {username, password} = values;
remoteApi.login(username, password).then((res) => {
if (res.status === 0) {
messageApi.success(t.LOGIN_SUCCESS);
window.localStorage.setItem("username", res.data.loginUserName);
window.localStorage.setItem("userrole", res.data.loginUserRole);
window.location.href = '/';
} else {
messageApi.error(res.message || t.LOGIN_FAILED);
}
})
};
return (
<>
{msgContextHolder}
<div style={{
maxWidth: 400,
margin: '100px auto',
padding: 24,
boxShadow: '0 2px 8px #f0f1f2',
borderRadius: 8
}}>
<Title level={3} style={{textAlign: 'center', marginBottom: 24}}>
{t.WELCOME}
</Title>
<Form
form={form}
name="login_form"
layout="vertical"
onFinish={onFinish}
initialValues={{username: '', password: ''}}
>
<Form.Item
label={t.USERNAME}
name="username"
rules={[{required: true, message: t.USERNAME_REQUIRED}]}>
<Input placeholder={t.USERNAME_PLACEHOLDER}/>
</Form.Item>
<Form.Item
label={t.PASSWORD}
name="password"
rules={[{required: true, message: t.PASSWORD_REQUIRED}]}>
<Input.Password placeholder={t.PASSWORD_PLACEHOLDER}/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t.LOGIN}
</Button>
</Form.Item>
</Form>
</div>
</>
);
};
export default Login;

View File

@@ -0,0 +1,473 @@
/*
* 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, {useCallback, useEffect, useState} from 'react';
import {Button, DatePicker, Form, Input, notification, Select, Spin, Table, Tabs, Typography} from 'antd';
import moment from 'moment';
import {SearchOutlined} from '@ant-design/icons';
import {useLanguage} from '../../i18n/LanguageContext';
import MessageDetailViewDialog from "../../components/MessageDetailViewDialog"; // Keep this path
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Keep this path
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const MessageQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('topic');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [allTopicList, setAllTopicList] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [timepickerBegin, setTimepickerBegin] = useState(moment().subtract(1, 'hour'));
const [timepickerEnd, setTimepickerEnd] = useState(moment());
const [messageShowList, setMessageShowList] = useState([]);
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [taskId, setTaskId] = useState("");
// Message Key 查询状态
const [key, setKey] = useState('');
const [queryMessageByTopicAndKeyResult, setQueryMessageByTopicAndKeyResult] = useState([]);
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
// State for Message Detail Dialog
const [isMessageDetailModalVisible, setIsMessageDetailModalVisible] = useState(false);
const [currentMessageIdForDetail, setCurrentMessageIdForDetail] = useState(null);
const [currentTopicForDetail, setCurrentTopicForDetail] = useState(null);
const [notificationApi, notificationContextHolder] = notification.useNotification();
const fetchAllTopics = useCallback(async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(false);
if (resp.status === 0) {
setAllTopicList(resp.data.topicList.sort());
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.FETCH_TOPIC_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.FETCH_TOPIC_FAILED,
});
console.error("Error fetching topic list:", error);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
fetchAllTopics();
}, [fetchAllTopics]);
const onChangeQueryCondition = () => {
setTaskId("");
setPaginationConf(prev => ({
...prev,
current: 1,
total: 0,
}));
};
const queryMessagePageByTopic = async (page = paginationConf.current, pageSize = paginationConf.pageSize) => {
if (!selectedTopic) {
notificationApi.warning({
message: t.WARNING,
description: t.PLEASE_SELECT_TOPIC,
});
return;
}
if (timepickerEnd.valueOf() < timepickerBegin.valueOf()) {
notificationApi.error({message: t.ERROR, description: t.END_TIME_EARLIER_THAN_BEGIN_TIME});
return;
}
setLoading(true);
try {
const resp = await remoteApi.queryMessagePageByTopic(
selectedTopic,
timepickerBegin.valueOf(),
timepickerEnd.valueOf(),
page,
pageSize,
taskId
);
if (resp.status === 0) {
setMessageShowList(resp.data.page.content);
setPaginationConf(prev => ({
...prev,
current: resp.data.page.number + 1,
total: resp.data.page.totalElements,
pageSize: pageSize,
}));
setTaskId(resp.data.taskId);
if (resp.data.page.content.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
} finally {
setLoading(false);
}
};
const queryMessageByTopicAndKey = async () => {
if (!selectedTopic || !key) {
notificationApi.warning({
message: t.WARNING,
description: t.TOPIC_AND_KEY_REQUIRED,
});
return;
}
setLoading(true);
try {
const resp = await remoteApi.queryMessageByTopicAndKey(selectedTopic, key);
if (resp.status === 0) {
setQueryMessageByTopicAndKeyResult(resp.data);
if (resp.data.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.QUERY_FAILED,
});
} finally {
setLoading(false);
}
};
// Updated to open the dialog
const showMessageDetail = (msgIdToQuery, topicToQuery) => {
if (!msgIdToQuery) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setCurrentMessageIdForDetail(msgIdToQuery);
setCurrentTopicForDetail(topicToQuery);
setIsMessageDetailModalVisible(true);
};
const handleCloseMessageDetailModal = () => {
setIsMessageDetailModalVisible(false);
setCurrentMessageIdForDetail(null);
setCurrentTopicForDetail(null);
};
const handleResendMessage = async (messageView, consumerGroup) => {
setLoading(true); // Set loading for the main page as well, as the dialog itself can't control it
let topicToResend = messageView.topic;
let msgIdToResend = messageView.msgId;
if (topicToResend.startsWith('%DLQ%')) {
if (messageView.properties && messageView.properties.hasOwnProperty("RETRY_TOPIC")) {
topicToResend = messageView.properties.RETRY_TOPIC;
}
if (messageView.properties && messageView.properties.hasOwnProperty("ORIGIN_MESSAGE_ID")) {
msgIdToResend = messageView.properties.ORIGIN_MESSAGE_ID;
}
}
try {
const resp = await remoteApi.resendMessageDirectly(msgIdToResend, consumerGroup, topicToResend);
if (resp.status === 0) {
notificationApi.success({
message: t.SUCCESS,
description: t.RESEND_SUCCESS,
});
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.RESEND_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: t.RESEND_FAILED,
});
} finally {
setLoading(false);
// Optionally, you might want to refresh the message detail after resend
// or close the modal if resend was successful and you don't need to see details immediately.
// For now, we'll keep the modal open and let the user close it.
}
};
const topicColumns = [
{
title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center',
render: (text) => <Text copyable>{text}</Text>
},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small" onClick={() => showMessageDetail(record.msgId, record.topic)}>
{t.MESSAGE_DETAIL}
</Button>
),
},
];
const keyColumns = [
{
title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center',
render: (text) => <Text copyable>{text}</Text>
},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format("YYYY-MM-DD HH:mm:ss"),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small" onClick={() => showMessageDetail(record.msgId, record.topic)}>
{t.MESSAGE_DETAIL}
</Button>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip={t.LOADING_DATA}>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab="Topic" key="topic">
<h5 style={{margin: '15px 0'}}>{t.TOTAL_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label={t.TOPIC}>
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={(value) => {
setSelectedTopic(value);
onChangeQueryCondition();
}}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label={t.BEGIN}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerBegin}
onChange={(date) => {
setTimepickerBegin(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item label={t.END}>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
value={timepickerEnd}
onChange={(date) => {
setTimepickerEnd(date);
onChangeQueryCondition();
}}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryMessagePageByTopic()}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={topicColumns}
dataSource={messageShowList}
rowKey="msgId"
bordered
pagination={{
current: paginationConf.current,
pageSize: paginationConf.pageSize,
total: paginationConf.total,
onChange: (page, pageSize) => queryMessagePageByTopic(page, pageSize),
}}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message Key" key="messageKey">
<h5 style={{margin: '15px 0'}}>{t.ONLY_RETURN_64_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Key:">
<Input
style={{width: 450}}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryMessageByTopicAndKey}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={keyColumns}
dataSource={queryMessageByTopicAndKeyResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>
{t.MESSAGE_ID_TOPIC_HINT}
</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => showMessageDetail(messageId, selectedTopic)}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
</div>
</TabPane>
</Tabs>
</Spin>
{/* Message Detail Dialog Component */}
<MessageDetailViewDialog
visible={isMessageDetailModalVisible}
onCancel={handleCloseMessageDetailModal}
messageId={currentMessageIdForDetail}
topic={currentTopicForDetail}
onResendMessage={handleResendMessage} // Pass the resend function
/>
</div>
</>
);
};
export default MessageQueryPage;

View File

@@ -0,0 +1,429 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Form, Input, notification, Select, Spin, Table, Tabs, Typography} from 'antd';
import moment from 'moment';
import {SearchOutlined} from '@ant-design/icons';
import {useLanguage} from '../../i18n/LanguageContext';
import MessageTraceDetailViewDialog from "../../components/MessageTraceDetailViewDialog";
import {remoteApi} from '../../api/remoteApi/remoteApi'; // Import the remoteApi
const {TabPane} = Tabs;
const {Option} = Select;
const {Text, Paragraph} = Typography;
const MessageTraceQueryPage = () => {
const {t} = useLanguage();
const [activeTab, setActiveTab] = useState('messageKey');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 轨迹主题选择
const [allTraceTopicList, setAllTraceTopicList] = useState([]);
const [selectedTraceTopic, setSelectedTraceTopic] = useState(null); // Initialize as null or a default trace topic if applicable
// Topic 查询状态
const [allTopicList, setAllTopicList] = useState([]);
const [selectedTopic, setSelectedTopic] = useState(null);
const [key, setKey] = useState('');
const [queryMessageByTopicAndKeyResult, setQueryMessageByTopicAndKeyResult] = useState([]);
// Message ID 查询状态
const [messageId, setMessageId] = useState('');
const [queryMessageByMessageIdResult, setQueryMessageByMessageIdResult] = useState([]);
// State for MessageTraceDetailViewDialog
const [isTraceDetailViewOpen, setIsTraceDetailViewOpen] = useState(false);
const [traceDetailData, setTraceDetailData] = useState(null);
const [notificationApi, notificationContextHolder] = notification.useNotification();
useEffect(() => {
const fetchTopics = async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(true);
if (resp.status === 0) {
const topics = resp.data.topicList.sort();
setAllTopicList(topics);
const traceTopics = topics.filter(topic =>
!topic.startsWith('%RETRY%') && !topic.startsWith('%DLQ%')
);
setAllTraceTopicList(traceTopics);
// Optionally set a default trace topic if available, e.g., 'RMQ_SYS_TRACE_TOPIC'
if (traceTopics.includes('RMQ_SYS_TRACE_TOPIC')) {
setSelectedTraceTopic('RMQ_SYS_TRACE_TOPIC');
} else if (traceTopics.length > 0) {
setSelectedTraceTopic(traceTopics[0]); // Select the first one if no default
}
} else {
notificationApi.error({
message: t.ERROR,
description: resp.errMsg || t.QUERY_FAILED,
});
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
} finally {
setLoading(false);
}
};
fetchTopics();
}, [t]);
const queryMessageByTopicAndKey = async () => {
if (!selectedTopic || !key) {
notificationApi.warning({
message: t.WARNING,
description: t.TOPIC_AND_KEY_REQUIRED,
});
return;
}
setLoading(true);
try {
const data = await remoteApi.queryMessageByTopicAndKey(selectedTopic, key);
if (data.status === 0) {
setQueryMessageByTopicAndKeyResult(data.data);
if (data.data.length === 0) {
notificationApi.info({
message: t.NO_RESULT,
description: t.NO_MATCH_RESULT,
});
}
} else {
notificationApi.error({
message: t.ERROR,
description: data.errMsg || t.QUERY_FAILED,
});
setQueryMessageByTopicAndKeyResult([]); // Clear previous results on error
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setQueryMessageByTopicAndKeyResult([]); // Clear previous results on error
} finally {
setLoading(false);
}
};
const queryMessageByMessageId = async (msgIdToQuery, topicToQuery) => {
if (!msgIdToQuery) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setLoading(true);
try {
const res = await remoteApi.queryMessageByMessageId(msgIdToQuery, topicToQuery);
if (res.status === 0) {
// 确保 data.data.messageView 存在,并将其包装成数组
setQueryMessageByMessageIdResult(res.data && res.data.messageView ? [res.data.messageView] : []);
} else {
notificationApi.error({
message: t.ERROR,
description: res.errMsg || t.QUERY_FAILED,
});
setQueryMessageByMessageIdResult([]); // 清除错误时的旧数据
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setQueryMessageByMessageIdResult([]); // 清除错误时的旧数据
} finally {
setLoading(false);
}
};
const queryMessageTraceByMessageId = async (msgId, traceTopic) => {
if (!msgId) {
notificationApi.warning({
message: t.WARNING,
description: t.MESSAGE_ID_REQUIRED,
});
return;
}
setLoading(true);
try {
const data = await remoteApi.queryMessageTraceByMessageId(msgId, traceTopic || 'RMQ_SYS_TRACE_TOPIC');
if (data.status === 0) {
setTraceDetailData(data.data);
setIsTraceDetailViewOpen(true);
} else {
notificationApi.error({
message: t.ERROR,
description: data.errMsg || t.QUERY_FAILED,
});
setTraceDetailData(null); // Clear previous trace data on error
setIsTraceDetailViewOpen(false); // Do not open dialog if data is not available
}
} catch (error) {
notificationApi.error({
message: t.ERROR,
description: error.message || t.QUERY_FAILED,
});
setTraceDetailData(null); // Clear previous trace data on error
setIsTraceDetailViewOpen(false); // Do not open dialog if data is not available
} finally {
setLoading(false);
}
};
const handleCloseTraceDetailView = () => {
setIsTraceDetailViewOpen(false);
setTraceDetailData(null);
};
const keyColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Message Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small"
onClick={() => queryMessageTraceByMessageId(record.msgId, selectedTraceTopic)}>
{t.MESSAGE_TRACE_DETAIL}
</Button>
),
},
];
const messageIdColumns = [
{title: 'Message ID', dataIndex: 'msgId', key: 'msgId', align: 'center'},
// 注意:这里的 dataIndex 直接指向了 messageView 内部的属性
{title: 'Tag', dataIndex: ['properties', 'TAGS'], key: 'tags', align: 'center'},
{title: 'Message Key', dataIndex: ['properties', 'KEYS'], key: 'keys', align: 'center'},
{
title: 'StoreTime',
dataIndex: 'storeTimestamp',
key: 'storeTimestamp',
align: 'center',
render: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: 'Operation',
key: 'operation',
align: 'center',
render: (_, record) => (
<Button type="primary" size="small"
onClick={() => queryMessageTraceByMessageId(record.msgId, selectedTraceTopic)}>
{t.MESSAGE_TRACE_DETAIL}
</Button>
),
},
];
return (
<>
{notificationContextHolder}
<div style={{padding: '20px'}}>
<Spin spinning={loading} tip="加载中...">
<div style={{marginBottom: '20px', borderBottom: '1px solid #f0f0f0', paddingBottom: '15px'}}>
<Form layout="inline">
<Form.Item label={<Text strong>{t.TRACE_TOPIC}:</Text>}>
<Select
showSearch
style={{minWidth: 300}}
placeholder={t.SELECT_TRACE_TOPIC_PLACEHOLDER}
value={selectedTraceTopic}
onChange={setSelectedTraceTopic}
filterOption={(input, option) =>
option.children && option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTraceTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Text type="secondary" style={{marginLeft: 10}}>({t.TRACE_TOPIC_HINT})</Text>
</Form>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<TabPane tab="Message Key" key="messageKey">
<h5 style={{margin: '15px 0'}}>{t.ONLY_RETURN_64_MESSAGES}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" form={form} style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
required
filterOption={(input, option) =>
option.children && option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
<Option value="">{t.SELECT_TOPIC_PLACEHOLDER}</Option>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Key:">
<Input
style={{width: 450}}
value={key}
onChange={(e) => setKey(e.target.value)}
required
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={queryMessageByTopicAndKey}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={keyColumns}
dataSource={queryMessageByTopicAndKeyResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
<TabPane tab="Message ID" key="messageId">
<h5 style={{margin: '15px 0'}}>{t.MESSAGE_ID_TOPIC_HINT}</h5>
<div style={{padding: '20px', minHeight: '600px'}}>
<Form layout="inline" style={{marginBottom: '20px'}}>
<Form.Item label="Topic:">
<Select
showSearch
style={{width: 300}}
placeholder={t.SELECT_TOPIC_PLACEHOLDER}
value={selectedTopic}
onChange={setSelectedTopic}
required
filterOption={(input, option) => {
if (option.children && typeof option.children === 'string') {
return option.children.toLowerCase().includes(input.toLowerCase());
}
return false;
}}
>
<Option value="">{t.SELECT_TOPIC_PLACEHOLDER}</Option>
{allTopicList.map(topic => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="MessageId:">
<Input
style={{width: 450}}
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
required
/>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<SearchOutlined/>}
onClick={() => queryMessageByMessageId(messageId, selectedTopic)}>
{t.SEARCH}
</Button>
</Form.Item>
</Form>
<Table
columns={messageIdColumns}
dataSource={queryMessageByMessageIdResult}
rowKey="msgId"
bordered
pagination={false}
locale={{emptyText: t.NO_MATCH_RESULT}}
/>
</div>
</TabPane>
</Tabs>
</Spin>
{/* MessageTraceDetailViewDialog as a child component */}
{isTraceDetailViewOpen && traceDetailData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: '#fff',
padding: '20px',
borderRadius: '8px',
width: '80%',
maxHeight: '90%',
overflowY: 'auto',
position: 'relative'
}}>
<Typography.Title level={4}
style={{marginBottom: '20px'}}>{t.MESSAGE_TRACE_DETAIL}</Typography.Title>
<Button
onClick={handleCloseTraceDetailView}
style={{
position: 'absolute',
top: '20px',
right: '20px',
}}
>
{t.CLOSE}
</Button>
<MessageTraceDetailViewDialog
ngDialogData={traceDetailData}
/>
</div>
</div>
)}
</div>
</>
);
};
export default MessageTraceQueryPage;

View File

@@ -0,0 +1,193 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Input, message, Select, Space, Switch, Typography} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
const {Title} = Typography;
const {Option} = Select;
const Ops = () => {
const [namesrvAddrList, setNamesrvAddrList] = useState([]);
const [selectedNamesrv, setSelectedNamesrv] = useState('');
const [newNamesrvAddr, setNewNamesrvAddr] = useState('');
const [useVIPChannel, setUseVIPChannel] = useState(false);
const [useTLS, setUseTLS] = useState(false);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); // Default to true
const [messageApi, msgContextHolder] = message.useMessage();
useEffect(() => {
const fetchOpsData = async () => {
const userRole = sessionStorage.getItem("userrole");
setWriteOperationEnabled(userRole === null || userRole === "1"); // Assuming "1" means write access
const resp = await remoteApi.queryOpsHomePage();
if (resp.status === 0) {
setNamesrvAddrList(resp.data.namesvrAddrList);
setUseVIPChannel(resp.data.useVIPChannel);
setUseTLS(resp.data.useTLS);
setSelectedNamesrv(resp.data.currentNamesrv);
} else {
messageApi.error(resp.errMsg);
}
};
fetchOpsData();
}, []);
useEffect(() => {
const userPermission = localStorage.getItem('userrole');
console.log(userPermission);
if (userPermission == 2) {
setWriteOperationEnabled(false);
} else {
setWriteOperationEnabled(true);
}
}, []);
const handleUpdateNameSvrAddr = async () => {
if (!selectedNamesrv) {
messageApi.warning('请选择一个 NameServer 地址');
return;
}
const resp = await remoteApi.updateNameSvrAddr(selectedNamesrv);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
}
};
const handleAddNameSvrAddr = async () => {
if (!newNamesrvAddr.trim()) {
messageApi.warning('请输入新的 NameServer 地址');
return;
}
const resp = await remoteApi.addNameSvrAddr(newNamesrvAddr.trim());
if (resp.status === 0) {
if (!namesrvAddrList.includes(newNamesrvAddr.trim())) {
setNamesrvAddrList([...namesrvAddrList, newNamesrvAddr.trim()]);
}
setNewNamesrvAddr('');
messageApi.info('ADD SUCCESS');
} else {
messageApi.error(resp.errMsg);
}
};
const handleUpdateIsVIPChannel = async (checked) => {
setUseVIPChannel(checked); // Optimistic update
const resp = await remoteApi.updateIsVIPChannel(checked);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
setUseVIPChannel(!checked); // Revert on error
}
};
const handleUpdateUseTLS = async (checked) => {
setUseTLS(checked); // Optimistic update
const resp = await remoteApi.updateUseTLS(checked);
if (resp.status === 0) {
messageApi.info('UPDATE SUCCESS');
} else {
messageApi.error(resp.errMsg);
setUseTLS(!checked); // Revert on error
}
};
return (
<>
{msgContextHolder}
<div style={{padding: 24}}>
<div style={{marginBottom: 24}}>
<Title level={4}>NameServerAddressList</Title>
<Space wrap align="start">
<Select
style={{minWidth: 400, maxWidth: 500}}
value={selectedNamesrv}
onChange={setSelectedNamesrv}
disabled={!writeOperationEnabled}
placeholder="请选择 NameServer 地址"
>
{namesrvAddrList.map((addr) => (
<Option key={addr} value={addr}>
{addr}
</Option>
))}
</Select>
{writeOperationEnabled && (
<Button type="primary" onClick={handleUpdateNameSvrAddr}>
UPDATE
</Button>
)}
{writeOperationEnabled && (
<Input.Group compact style={{minWidth: 400}}>
<Input
style={{width: 300}}
placeholder="NamesrvAddr"
value={newNamesrvAddr}
onChange={(e) => setNewNamesrvAddr(e.target.value)}
/>
<Button type="primary" onClick={handleAddNameSvrAddr}>
ADD
</Button>
</Input.Group>
)}
</Space>
</div>
<div style={{marginBottom: 24}}>
<Title level={4}>IsUseVIPChannel</Title>
<Space align="center">
<Switch
checked={useVIPChannel}
onChange={handleUpdateIsVIPChannel}
disabled={!writeOperationEnabled}
/>
{writeOperationEnabled && (
<Button type="primary" onClick={() => handleUpdateIsVIPChannel(useVIPChannel)}>
UPDATE
</Button>
)}
</Space>
</div>
<div style={{marginBottom: 24}}>
<Title level={4}>useTLS</Title>
<Space align="center">
<Switch
checked={useTLS}
onChange={handleUpdateUseTLS}
disabled={!writeOperationEnabled}
/>
{writeOperationEnabled && (
<Button type="primary" onClick={() => handleUpdateUseTLS(useTLS)}>
UPDATE
</Button>
)}
</Space>
</div>
</div>
</>
);
};
export default Ops;

View File

@@ -0,0 +1,143 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Form, Input, message, Select, Table} from 'antd';
import {remoteApi} from '../../api/remoteApi/remoteApi';
const {Option} = Select;
const ProducerConnectionList = () => {
const [form] = Form.useForm();
const [allTopicList, setAllTopicList] = useState([]);
const [connectionList, setConnectionList] = useState([]);
const [loading, setLoading] = useState(false);
const [messageApi, msgContextHolder] = message.useMessage();
useEffect(() => {
const fetchTopicList = async () => {
setLoading(true);
try {
const resp = await remoteApi.queryTopic(true);
if (!resp) {
messageApi.error("Failed to fetch topic list - no response");
return;
}
if (resp.status === 0) {
setAllTopicList(resp.data.topicList.sort());
} else {
messageApi.error(resp.errMsg || "Failed to fetch topic list");
}
} catch (error) {
messageApi.error("An error occurred while fetching topic list");
console.error("Fetch error:", error);
} finally {
setLoading(false);
}
};
fetchTopicList();
}, []);
const onFinish = (values) => {
setLoading(true);
const {selectedTopic, producerGroup} = values;
remoteApi.queryProducerConnection(selectedTopic, producerGroup, (resp) => {
if (resp.status === 0) {
setConnectionList(resp.data.connectionSet);
} else {
messageApi.error(resp.errMsg || "Failed to fetch producer connection list");
}
setLoading(false);
});
};
const columns = [
{
title: 'clientId',
dataIndex: 'clientId',
key: 'clientId',
align: 'center',
},
{
title: 'clientAddr',
dataIndex: 'clientAddr',
key: 'clientAddr',
align: 'center',
},
{
title: 'language',
dataIndex: 'language',
key: 'language',
align: 'center',
},
{
title: 'version',
dataIndex: 'versionDesc',
key: 'versionDesc',
align: 'center',
},
];
return (
<>
{msgContextHolder}
<div className="container-fluid" id="deployHistoryList">
<Form
form={form}
layout="inline"
onFinish={onFinish}
style={{marginBottom: 20}}
>
<Form.Item label="TOPIC" name="selectedTopic"
rules={[{required: true, message: 'Please select a topic!'}]}>
<Select
showSearch
placeholder="Select a topic"
style={{width: 300}}
optionFilterProp="children"
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{allTopicList.map((topic) => (
<Option key={topic} value={topic}>{topic}</Option>
))}
</Select>
</Form.Item>
<Form.Item label="PRODUCER_GROUP" name="producerGroup"
rules={[{required: true, message: 'Please input producer group!'}]}>
<Input style={{width: 300}}/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}>
<span className="glyphicon glyphicon-search"></span> SEARCH
</Button>
</Form.Item>
</Form>
<Table
dataSource={connectionList}
columns={columns}
rowKey="clientId"
pagination={false}
bordered
/>
</div>
</>
);
};
export default ProducerConnectionList;

View File

@@ -0,0 +1,189 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Card, Col, Input, Modal, notification, Row, Select, Spin} from 'antd';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi} from "../../api/remoteApi/remoteApi";
const {Option} = Select;
const ProxyManager = () => {
const {t} = useLanguage();
const [loading, setLoading] = useState(false);
const [proxyAddrList, setProxyAddrList] = useState([]);
const [selectedProxy, setSelectedProxy] = useState('');
const [newProxyAddr, setNewProxyAddr] = useState('');
const [allProxyConfig, setAllProxyConfig] = useState({});
const [showModal, setShowModal] = useState(false); // 控制 Modal 弹窗显示
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true); // 写操作权限,默认 true
const [notificationApi, notificationContextHolder] = notification.useNotification();
useEffect(() => {
const userPermission = localStorage.getItem('userrole');
console.log(userPermission);
if (userPermission == 2) {
setWriteOperationEnabled(false);
} else {
setWriteOperationEnabled(true);
}
}, []);
useEffect(() => {
setLoading(true);
remoteApi.queryProxyHomePage((resp) => {
setLoading(false);
if (resp.status === 0) {
const {proxyAddrList, currentProxyAddr} = resp.data;
setProxyAddrList(proxyAddrList || []);
setSelectedProxy(currentProxyAddr || (proxyAddrList && proxyAddrList.length > 0 ? proxyAddrList[0] : ''));
if (currentProxyAddr) {
localStorage.setItem('proxyAddr', currentProxyAddr);
} else if (proxyAddrList && proxyAddrList.length > 0) {
localStorage.setItem('proxyAddr', proxyAddrList[0]);
}
} else {
notificationApi.error({message: resp.errMsg || t.FETCH_PROXY_LIST_FAILED, duration: 2});
}
});
}, [t]);
const handleSelectChange = (value) => {
setSelectedProxy(value);
localStorage.setItem('proxyAddr', value);
};
const handleAddProxyAddr = () => {
if (!newProxyAddr.trim()) {
notificationApi.warning({
message: t.INPUT_PROXY_ADDR_REQUIRED || "Please input a new proxy address.",
duration: 2
});
return;
}
setLoading(true);
remoteApi.addProxyAddr(newProxyAddr.trim(), (resp) => {
setLoading(false);
if (resp.status === 0) {
if (!proxyAddrList.includes(newProxyAddr.trim())) {
setProxyAddrList(prevList => [...prevList, newProxyAddr.trim()]);
}
setNewProxyAddr('');
notificationApi.info({message: t.SUCCESS || "SUCCESS", duration: 2});
} else {
notificationApi.error({message: resp.errMsg || t.ADD_PROXY_FAILED, duration: 2});
}
});
};
return (
<Spin spinning={loading} tip={t.LOADING}>
<div className="container-fluid" style={{padding: '24px'}} id="deployHistoryList">
<Card
title={
<div style={{fontSize: '20px', fontWeight: 'bold'}}>
ProxyServerAddressList
</div>
}
bordered={false}
>
<Row gutter={[16, 16]} align="middle">
<Col flex="auto" style={{minWidth: 300, maxWidth: 500}}>
<Select
style={{width: '100%'}}
value={selectedProxy}
onChange={handleSelectChange}
placeholder={t.SELECT}
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{proxyAddrList.map(addr => (
<Option key={addr} value={addr}>
{addr}
</Option>
))}
</Select>
</Col>
</Row>
{writeOperationEnabled && (
<Row gutter={[16, 16]} align="middle" style={{marginTop: 16}}>
<Col>
<label htmlFor="newProxyAddrInput">ProxyAddr:</label>
</Col>
<Col>
<Input
id="newProxyAddrInput"
style={{width: 300}}
value={newProxyAddr}
onChange={(e) => setNewProxyAddr(e.target.value)}
placeholder={t.INPUT_PROXY_ADDR}
/>
</Col>
<Col>
<Button type="primary" onClick={handleAddProxyAddr}>
{t.ADD}
</Button>
</Col>
</Row>
)}
</Card>
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
title={`${t.PROXY_CONFIG} [${selectedProxy}]`}
footer={
<div style={{textAlign: 'center'}}>
<Button onClick={() => setShowModal(false)}>{t.CLOSE}</Button>
</div>
}
width={800}
bodyStyle={{maxHeight: '60vh', overflowY: 'auto'}}
>
<table className="table table-bordered" style={{width: '100%'}}>
<tbody>
{Object.entries(allProxyConfig).length > 0 ? (
Object.entries(allProxyConfig).map(([key, value]) => (
<tr key={key}>
<td style={{fontWeight: 500, width: '30%'}}>{key}</td>
<td>{value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2"
style={{textAlign: 'center'}}>{t.NO_CONFIG_DATA || "No configuration data available."}</td>
</tr>
)}
</tbody>
</table>
</Modal>
</div>
</Spin>
);
};
export default ProxyManager;

View File

@@ -0,0 +1,714 @@
/*
* 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, {useEffect, useState} from 'react';
import {Button, Checkbox, Form, Input, message, Popconfirm, Space, Table} from 'antd';
import {useLanguage} from '../../i18n/LanguageContext';
import {remoteApi} from '../../api/remoteApi/remoteApi';
import ResetOffsetResultDialog from "../../components/topic/ResetOffsetResultDialog";
import SendResultDialog from "../../components/topic/SendResultDialog";
import TopicModifyDialog from "../../components/topic/TopicModifyDialog";
import ConsumerViewDialog from "../../components/topic/ConsumerViewDialog";
import ConsumerResetOffsetDialog from "../../components/topic/ConsumerResetOffsetDialog";
import SkipMessageAccumulateDialog from "../../components/topic/SkipMessageAccumulateDialog";
import StatsViewDialog from "../../components/topic/StatsViewDialog";
import RouterViewDialog from "../../components/topic/RouterViewDialog";
import SendTopicMessageDialog from "../../components/topic/SendTopicMessageDialog";
const DeployHistoryList = () => {
const {t} = useLanguage();
const [filterStr, setFilterStr] = useState('');
const [filterNormal, setFilterNormal] = useState(true);
const [filterDelay, setFilterDelay] = useState(false);
const [filterFifo, setFilterFifo] = useState(false);
const [filterTransaction, setFilterTransaction] = useState(false);
const [filterUnspecified, setFilterUnspecified] = useState(false);
const [filterRetry, setFilterRetry] = useState(false);
const [filterDLQ, setFilterDLQ] = useState(false);
const [filterSystem, setFilterSystem] = useState(false);
const [rmqVersion, setRmqVersion] = useState(true);
const [writeOperationEnabled, setWriteOperationEnabled] = useState(true);
const [allTopicList, setAllTopicList] = useState([]);
const [allMessageTypeList, setAllMessageTypeList] = useState([]);
const [topicShowList, setTopicShowList] = useState([]);
const [loading, setLoading] = useState(false);
// Dialog visibility states
const [isAddUpdateTopicModalVisible, setIsAddUpdateTopicModalVisible] = useState(false);
const [isResetOffsetResultModalVisible, setIsResetOffsetResultModalVisible] = useState(false);
const [isSendResultModalVisible, setIsSendResultModalVisible] = useState(false);
const [isConsumerViewModalVisible, setIsConsumerViewModalVisible] = useState(false);
const [isConsumerResetOffsetModalVisible, setIsConsumerResetOffsetModalVisible] = useState(false);
const [isSkipMessageAccumulateModalVisible, setIsSkipMessageAccumulateModalVisible] = useState(false);
const [isStatsViewModalVisible, setIsStatsViewModalVisible] = useState(false);
const [isRouterViewModalVisible, setIsRouterViewModalVisible] = useState(false);
const [isSendTopicMessageModalVisible, setIsSendTopicMessageModalVisible] = useState(false);
// Data for dialogs
const [currentTopicForDialogs, setCurrentTopicForDialogs] = useState('');
const [isUpdateMode, setIsUpdateMode] = useState(false);
const [resetOffsetResultData, setResetOffsetResultData] = useState(null);
const [sendResultData, setSendResultData] = useState(null);
const [consumerData, setConsumerData] = useState(null);
const [allConsumerGroupList, setAllConsumerGroupList] = useState([]);
const [statsData, setStatsData] = useState(null);
const [routeData, setRouteData] = useState(null);
const [topicModifyData, setTopicModifyData] = useState([]);
const [sendTopicMessageData, setSendTopicMessageData] = useState({
topic: '',
tag: '',
key: '',
messageBody: '',
traceEnabled: false,
});
const [selectedConsumerGroups, setSelectedConsumerGroups] = useState([]);
const [resetOffsetTime, setResetOffsetTime] = useState(new Date());
const [allClusterNameList, setAllClusterNameList] = useState([]);
const [allBrokerNameList, setAllBrokerNameList] = useState([]);
const [messageApi, msgContextHolder] = message.useMessage();
// Pagination config
const [paginationConf, setPaginationConf] = useState({
current: 1,
pageSize: 10,
total: 0,
});
useEffect(() => {
getTopicList();
}, []);
useEffect(() => {
filterList(paginationConf.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterStr, filterNormal, filterDelay, filterFifo, filterTransaction,
filterUnspecified, filterRetry, filterDLQ, filterSystem, allTopicList]);
useEffect(() => {
const userPermission = localStorage.getItem('userrole');
console.log(userPermission);
if (userPermission == 2) {
setWriteOperationEnabled(false);
} else {
setWriteOperationEnabled(true);
}
}, []);
// Close functions for Modals
const closeAddUpdateDialog = () => {
setIsAddUpdateTopicModalVisible(false);
setTopicModifyData([]);
};
const closeResetOffsetResultDialog = () => {
setIsResetOffsetResultModalVisible(false);
setResetOffsetResultData(null);
};
const closeSendResultDialog = () => {
setIsSendResultModalVisible(false);
setSendResultData(null);
};
const closeConsumerViewDialog = () => {
setIsConsumerViewModalVisible(false);
setConsumerData(null);
setAllConsumerGroupList([]);
};
const closeConsumerResetOffsetDialog = () => {
setIsConsumerResetOffsetModalVisible(false);
setSelectedConsumerGroups([]);
setResetOffsetTime(new Date());
setAllConsumerGroupList([]);
};
const closeSkipMessageAccumulateDialog = () => {
setIsSkipMessageAccumulateModalVisible(false);
setSelectedConsumerGroups([]);
setAllConsumerGroupList([]);
};
const closeStatsViewDialog = () => {
setIsStatsViewModalVisible(false);
setStatsData(null);
};
const closeRouterViewDialog = () => {
setIsRouterViewModalVisible(false);
setRouteData(null);
};
const closeSendTopicMessageDialog = () => {
setIsSendTopicMessageModalVisible(false);
setSendTopicMessageData({topic: '', tag: '', key: '', messageBody: '', traceEnabled: false});
};
const getTopicList = async () => {
setLoading(true);
try {
const result = await remoteApi.queryTopicList();
if (result.status === 0) {
setAllTopicList(result.data.topicNameList);
setAllMessageTypeList(result.data.messageTypeList);
setPaginationConf(prev => ({
...prev,
total: result.data.topicNameList.length
}));
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching topic list:", error);
messageApi.error("Failed to fetch topic list");
} finally {
setLoading(false);
}
};
const filterList = (currentPage) => {
const lowExceptStr = filterStr.toLowerCase();
const canShowList = allTopicList.filter((topic, index) => {
if (filterStr && !topic.toLowerCase().includes(lowExceptStr)) {
return false;
}
return filterByType(topic, allMessageTypeList[index]);
});
const perPage = paginationConf.pageSize;
const from = (currentPage - 1) * perPage;
const to = (from + perPage) > canShowList.length ? canShowList.length : from + perPage;
setTopicShowList(canShowList.slice(from, to));
setPaginationConf(prev => ({
...prev,
current: currentPage,
total: canShowList.length
}));
};
const filterByType = (topic, type) => {
if (filterRetry && type.includes("RETRY")) return true;
if (filterDLQ && type.includes("DLQ")) return true;
if (filterSystem && type.includes("SYSTEM")) return true;
if (rmqVersion && filterUnspecified && type.includes("UNSPECIFIED")) return true;
if (filterNormal && type.includes("NORMAL")) return true;
if (!rmqVersion && filterNormal && type.includes("UNSPECIFIED")) return true;
if (rmqVersion && filterDelay && type.includes("DELAY")) return true;
if (rmqVersion && filterFifo && type.includes("FIFO")) return true;
if (rmqVersion && filterTransaction && type.includes("TRANSACTION")) return true;
return false;
};
const handleTableChange = (pagination) => {
setPaginationConf(pagination);
filterList(pagination.current);
};
const openAddUpdateDialog = async (topic, isSys) => {
setCurrentTopicForDialogs(typeof topic === 'string' ? topic : (topic && topic.name) || '');
const isUpdate = typeof topic === 'string' && !!topic; // 如果 topic 是非空字符串,则认为是更新
setIsUpdateMode(isUpdate);
try {
if (isUpdate) {
// topic 已经是字符串
const configResult = await remoteApi.getTopicConfig(topic);
if (configResult.status === 0) {
const dataToSet = Array.isArray(configResult.data) ? configResult.data : [configResult.data];
setTopicModifyData(dataToSet.map(item => ({
clusterNameList: [],
brokerNameList: item.brokerNameList || [],
topicName: item.topicName,
messageType: item.messageType || 'NORMAL',
writeQueueNums: item.writeQueueNums || 8,
readQueueNums: item.readQueueNums || 8,
perm: item.perm || 7,
})));
} else {
messageApi.error(configResult.errMsg);
return;
}
} else {
setTopicModifyData([{
clusterNameList: [],
brokerNameList: [],
topicName: '',
messageType: 'NORMAL',
writeQueueNums: 8,
readQueueNums: 8,
perm: 7,
}]);
}
} catch (error) {
console.error("Error opening add/update dialog:", error);
messageApi.error("Failed to open dialog");
return;
}
if (!isUpdate) {
const clusterResult = await remoteApi.getClusterList();
if (clusterResult.status === 0) {
setAllClusterNameList(Object.keys(clusterResult.data.clusterInfo.clusterAddrTable));
setAllBrokerNameList(Object.keys(clusterResult.data.brokerServer));
} else {
messageApi.error(clusterResult.errMsg);
}
}
setIsAddUpdateTopicModalVisible(true);
};
// Post Topic Request (Add/Update)
const postTopicRequest = async (values) => {
try {
const result = await remoteApi.createOrUpdateTopic(values);
if (result.status === 0) {
messageApi.success(t.TOPIC_OPERATION_SUCCESS);
closeAddUpdateDialog();
if (!isUpdateMode) {
await getTopicList()
}
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error creating/updating topic:", error);
messageApi.error("Failed to create/update topic");
}
};
// Delete Topic
const deleteTopic = async (topicToDelete) => {
try {
const result = await remoteApi.deleteTopic(topicToDelete);
if (result.status === 0) {
messageApi.success(`${t.TOPIC} [${topicToDelete}] ${t.DELETED_SUCCESSFULLY}`);
setAllTopicList(allTopicList.filter(topic => topic !== topicToDelete));
await getTopicList()
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error deleting topic:", error);
messageApi.error("Failed to delete topic");
}
};
// Open Stats View Dialog
const statsView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicStats(topic);
if (result.status === 0) {
setStatsData(result.data);
setIsStatsViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching stats:", error);
messageApi.error("Failed to fetch stats");
}
};
// Open Router View Dialog
const routerView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicRoute(topic);
if (result.status === 0) {
setRouteData(result.data);
setIsRouterViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching route:", error);
messageApi.error("Failed to fetch route");
}
};
// Open Consumer View Dialog
const consumerView = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumers(topic);
if (result.status === 0) {
setConsumerData(result.data);
setAllConsumerGroupList(Object.keys(result.data));
setIsConsumerViewModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumers:", error);
messageApi.error("Failed to fetch consumers");
}
};
// Open Consumer Reset Offset Dialog
const openConsumerResetOffsetDialog = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumerGroups(topic);
if (result.status === 0) {
if (!result.data.groupList) {
messageApi.error("No consumer groups found");
return;
}
setAllConsumerGroupList(result.data.groupList);
setIsConsumerResetOffsetModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumer groups:", error);
messageApi.error("Failed to fetch consumer groups");
}
};
// Open Skip Message Accumulate Dialog
const openSkipMessageAccumulateDialog = async (topic) => {
setCurrentTopicForDialogs(topic);
try {
const result = await remoteApi.getTopicConsumerGroups(topic);
if (result.status === 0) {
if (!result.data.groupList) {
messageApi.error("No consumer groups found");
return;
}
setAllConsumerGroupList(result.data.groupList);
setIsSkipMessageAccumulateModalVisible(true);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error fetching consumer groups:", error);
messageApi.error("Failed to fetch consumer groups");
}
};
// Open Send Topic Message Dialog
const openSendTopicMessageDialog = (topic) => {
setCurrentTopicForDialogs(topic);
setSendTopicMessageData(prev => ({...prev, topic}));
setIsSendTopicMessageModalVisible(true);
};
const handleInputChange = (e) => {
const {name, value} = e.target;
setSendTopicMessageData(prevData => ({
...prevData,
[name]: value,
}));
};
const handleResetOffset = async (consumerGroupList, resetTime) => {
try {
const result = await remoteApi.resetConsumerOffset({
resetTime: resetTime, // 使用传递过来的 resetTime
consumerGroupList: consumerGroupList, // 使用传递过来的 consumerGroupList
topic: currentTopicForDialogs,
force: true
});
if (result.status === 0) {
setResetOffsetResultData(result.data);
setIsResetOffsetResultModalVisible(true);
setIsConsumerResetOffsetModalVisible(false);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error resetting offset:", error);
messageApi.error("Failed to reset offset");
}
};
const handleSkipMessageAccumulate = async (consumerGroupListFromDialog) => {
try {
const result = await remoteApi.skipMessageAccumulate({
resetTime: -1,
consumerGroupList: consumerGroupListFromDialog, // 使用子组件传递的 consumerGroupList
topic: currentTopicForDialogs, // 使用父组件中管理的 topic
force: true
});
if (result.status === 0) {
setResetOffsetResultData(result.data); // 注意这里使用了 setResetOffsetResultData确认这是你期望的
setIsResetOffsetResultModalVisible(true); // 注意这里使用了 setIsResetOffsetResultModalVisible确认这是你期望的
setIsSkipMessageAccumulateModalVisible(false);
} else {
messageApi.error(result.errMsg);
}
} catch (error) {
console.error("Error skipping message accumulate:", error);
messageApi.error("Failed to skip message accumulate");
}
};
const columns = [
{
title: t.TOPIC,
dataIndex: 'topic',
key: 'topic',
align: 'center',
render: (text) => {
const sysFlag = text.startsWith('%SYS%');
const topic = sysFlag ? text.substring(5) : text;
return <span style={{color: sysFlag ? 'red' : ''}}>{topic}</span>;
},
},
{
title: t.OPERATION,
key: 'operation',
align: 'left',
render: (_, record) => {
const sysFlag = record.topic.startsWith('%SYS%');
const topicName = sysFlag ? record.topic.substring(5) : record.topic;
return (
<Space size="small">
<Button type="primary" size="small" onClick={() => statsView(topicName)}>
{t.STATUS}
</Button>
<Button type="primary" size="small" onClick={() => routerView(topicName)}>
{t.ROUTER}
</Button>
<Button type="primary" size="small" onClick={() => consumerView(topicName)}>
Consumer {t.MANAGE}
</Button>
<Button type="primary" size="small" onClick={() => openAddUpdateDialog(topicName, sysFlag)}>
Topic {t.CONFIG}
</Button>
{!sysFlag && (
<Button type="primary" size="small" onClick={() => openSendTopicMessageDialog(topicName)}>
{t.SEND_MSG}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Button type="primary" danger size="small"
onClick={() => openConsumerResetOffsetDialog(topicName)}>
{t.RESET_CUS_OFFSET}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Button type="primary" danger size="small"
onClick={() => openSkipMessageAccumulateDialog(topicName)}>
{t.SKIP_MESSAGE_ACCUMULATE}
</Button>
)}
{!sysFlag && writeOperationEnabled && (
<Popconfirm
title={`${t.ARE_YOU_SURE_TO_DELETE}`}
onConfirm={() => deleteTopic(topicName)}
okText={t.YES}
cancelText={t.NOT}
>
<Button type="primary" danger size="small">
{t.DELETE}
</Button>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<>
{msgContextHolder}
<div className="container-fluid" id="deployHistoryList">
<div className="modal-body">
<div className="row">
<Form layout="inline" className="pull-left col-sm-12">
<Form.Item label={t.TOPIC}>
<Input
value={filterStr}
onChange={(e) => setFilterStr(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={filterNormal} onChange={(e) => setFilterNormal(e.target.checked)}>
{t.NORMAL}
</Checkbox>
</Form.Item>
{rmqVersion && (
<>
<Form.Item>
<Checkbox checked={filterDelay}
onChange={(e) => setFilterDelay(e.target.checked)}>
{t.DELAY}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterFifo}
onChange={(e) => setFilterFifo(e.target.checked)}>
{t.FIFO}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterTransaction}
onChange={(e) => setFilterTransaction(e.target.checked)}>
{t.TRANSACTION}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterUnspecified}
onChange={(e) => setFilterUnspecified(e.target.checked)}>
{t.UNSPECIFIED}
</Checkbox>
</Form.Item>
</>
)}
<Form.Item>
<Checkbox checked={filterRetry} onChange={(e) => setFilterRetry(e.target.checked)}>
{t.RETRY}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterDLQ} onChange={(e) => setFilterDLQ(e.target.checked)}>
{t.DLQ}
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={filterSystem} onChange={(e) => setFilterSystem(e.target.checked)}>
{t.SYSTEM}
</Checkbox>
</Form.Item>
{writeOperationEnabled && (
<Form.Item>
<Button type="primary" onClick={openAddUpdateDialog}>
{t.ADD} / {t.UPDATE}
</Button>
</Form.Item>
)}
<Form.Item>
<Button type="primary" onClick={getTopicList}>
{t.REFRESH}
</Button>
</Form.Item>
</Form>
</div>
<br/>
<div>
<div className="row">
<Table
bordered
loading={loading}
dataSource={topicShowList.map((topic, index) => ({key: index, topic}))}
columns={columns}
pagination={paginationConf}
onChange={handleTableChange}
/>
</div>
</div>
</div>
{/* Modals/Dialogs - 传递 visible 和 onClose prop */}
<ResetOffsetResultDialog
visible={isResetOffsetResultModalVisible}
onClose={closeResetOffsetResultDialog} // 传递关闭函数
result={resetOffsetResultData}
t={t}
/>
<SendResultDialog
visible={isSendResultModalVisible}
onClose={closeSendResultDialog} // 传递关闭函数
result={sendResultData}
t={t}
/>
<TopicModifyDialog
visible={isAddUpdateTopicModalVisible}
onClose={closeAddUpdateDialog}
initialData={topicModifyData}
bIsUpdate={isUpdateMode}
writeOperationEnabled={writeOperationEnabled}
allClusterNameList={allClusterNameList || []}
allBrokerNameList={allBrokerNameList || []}
onSubmit={postTopicRequest}
onInputChange={handleInputChange}
t={t}
/>
<ConsumerViewDialog
visible={isConsumerViewModalVisible}
onClose={closeConsumerViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
consumerData={consumerData}
consumerGroupCount={allConsumerGroupList.length}
t={t}
/>
<ConsumerResetOffsetDialog
visible={isConsumerResetOffsetModalVisible}
onClose={closeConsumerResetOffsetDialog} // 传递关闭函数
topic={currentTopicForDialogs}
allConsumerGroupList={allConsumerGroupList}
handleResetOffset={handleResetOffset}
t={t}
/>
<SkipMessageAccumulateDialog
visible={isSkipMessageAccumulateModalVisible}
onClose={closeSkipMessageAccumulateDialog} // 传递关闭函数
topic={currentTopicForDialogs}
allConsumerGroupList={allConsumerGroupList}
handleSkipMessageAccumulate={handleSkipMessageAccumulate}
t={t}
/>
<StatsViewDialog
visible={isStatsViewModalVisible}
onClose={closeStatsViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
statsData={statsData}
t={t}
/>
<RouterViewDialog
visible={isRouterViewModalVisible}
onClose={closeRouterViewDialog} // 传递关闭函数
topic={currentTopicForDialogs}
routeData={routeData}
t={t}
/>
<SendTopicMessageDialog
visible={isSendTopicMessageModalVisible}
onClose={closeSendTopicMessageDialog} // 传递关闭函数
topic={currentTopicForDialogs}
setSendResultData={setSendResultData}
setIsSendResultModalVisible={setIsSendResultModalVisible}
setIsSendTopicMessageModalVisible={setIsSendTopicMessageModalVisible}
sendTopicMessageData={sendTopicMessageData}
message={messageApi}
t={t}
/>
</div>
</>
);
};
export default DeployHistoryList;

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const reportWebVitals = onPerfEntry => { const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) { if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => { import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {

View File

@@ -0,0 +1,262 @@
/*
* 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, {useEffect} from 'react';
import {HashRouter as Router, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import {Layout} from 'antd';
import {AnimatePresence, motion} from 'framer-motion';
import Login from '../pages/Login/login';
import Ops from '../pages/Ops/ops';
import Proxy from '../pages/Proxy/proxy';
import Cluster from '../pages/Cluster/cluster';
import Topic from '../pages/Topic/topic';
import Consumer from '../pages/Consumer/consumer';
import Producer from '../pages/Producer/producer';
import Message from '../pages/Message/message';
import DlqMessage from '../pages/DlqMessage/dlqmessage';
import MessageTrace from '../pages/MessageTrace/messagetrace';
import Acl from '../pages/Acl/acl';
import Navbar from '../components/Navbar';
import DashboardPage from "../pages/Dashboard/DashboardPage";
import {remoteApi} from "../api/remoteApi/remoteApi";
const {Header, Content} = Layout;
const pageVariants = {
initial: {
opacity: 0,
x: "-100vw"
},
in: {
opacity: 1,
x: 0
},
out: {
opacity: 0,
x: "100vw"
}
};
const pageTransition = {
type: "tween",
ease: "anticipate",
duration: 0.2
};
const AppRouter = () => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
remoteApi.setRedirectHandler(() => {
navigate('/login', {replace: true});
});
}, [navigate]);
return (
<Layout style={{minHeight: '100vh'}}>
<Header style={{padding: 0, height: 'auto', lineHeight: 'normal'}}>
<Navbar/>
</Header>
<Content style={{padding: '24px'}}>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/login"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Login/>
</motion.div>
}
/>
<Route
path="/"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<DashboardPage/>
</motion.div>
}
/>
<Route
path="/ops"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Ops/>
</motion.div>
}
/>
<Route
path="/proxy"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Proxy/>
</motion.div>
}
/>
<Route
path="/cluster"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Cluster/>
</motion.div>
}
/>
<Route
path="/topic"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Topic/>
</motion.div>
}
/>
<Route
path="/consumer"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Consumer/>
</motion.div>
}
/>
<Route
path="/producer"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Producer/>
</motion.div>
}
/>
<Route
path="/message"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Message/>
</motion.div>
}
/>
<Route
path="/dlqMessage"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<DlqMessage/>
</motion.div>
}
/>
<Route
path="/messageTrace"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<MessageTrace/>
</motion.div>
}
/>
<Route
path="/acl"
element={
<motion.div
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
<Acl/>
</motion.div>
}
/>
<Route path="*" element={<Navigate to="/"/>}/>
</Routes>
</AnimatePresence>
</Content>
</Layout>
);
};
const AppWrapper = () => (
<Router>
<AppRouter/>
</Router>
);
export default AppWrapper;

View File

@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
// jest-dom adds custom jest matchers for asserting on DOM nodes. // jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like: // allows you to do things like:
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)

View File

@@ -15,15 +15,9 @@
* limitations under the License. * limitations under the License.
*/ */
.login-panel{ export const SET_THEME = 'SET_THEME';
} export const setTheme = (themeName) => ({
type: SET_THEME,
.login-panel .qrcode { payload: themeName,
width: 100%; });
hight: 100%;
}
.login-panel .validateCode {
/*height:20px;*/
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {defaultTheme, themes} from '../../assets/styles/theme';
import {setTheme} from '../actions/themeActions';
export const useTheme = () => {
const currentThemeName = useSelector(state => state.theme.currentThemeName);
const dispatch = useDispatch();
const currentTheme = themes[currentThemeName] || defaultTheme;
useEffect(() => {
localStorage.setItem('appTheme', currentThemeName);
}, [currentThemeName]);
return {
currentTheme,
currentThemeName,
setCurrentThemeName: (themeName) => dispatch(setTheme(themeName)),
};
};

View File

@@ -0,0 +1,28 @@
/*
* 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 {combineReducers, createStore} from 'redux';
import themeReducer from './reducers/themeReducer';
// 组合所有的 reducers
const rootReducer = combineReducers({
theme: themeReducer, // theme 状态将通过 state.theme 访问
});
// 创建 Redux store
const store = createStore(rootReducer);
export default store;

View File

@@ -0,0 +1,40 @@
/*
* 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 {SET_THEME} from '../actions/themeActions';
const getInitialTheme = () => {
return localStorage.getItem('appTheme') || 'default';
};
const initialState = {
currentThemeName: getInitialTheme(),
};
const themeReducer = (state = initialState, action) => {
switch (action.type) {
case SET_THEME:
return {
...state,
currentThemeName: action.payload,
};
default:
return state;
}
};
export default themeReducer;

16464
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-json-view": "^1.21.3",
"react-scripts": "4.0.3",
"web-vitals": "^1.0.1"
},
"proxy": "http://localhost:8080",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,59 +0,0 @@
<!--
~ 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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,49 +0,0 @@
/*
* 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, {useState, useEffect} from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
const [message, setMessage] = useState("");
useEffect(() => {
fetch('cluster/list.query')
.then(response => response.text())
.then(message => {
setMessage(message);
});
}, [])
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" height="60"/>
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
</header>
<h1>ClusterInfo</h1>
<p>
{message}
</p>
</div>
);
}
export default App;

View File

@@ -1,17 +0,0 @@
<!--
~ 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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because it is too large Load Diff

80
pom.xml
View File

@@ -28,7 +28,7 @@
<groupId>org.apache.rocketmq</groupId> <groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-dashboard</artifactId> <artifactId>rocketmq-dashboard</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>
<version>1.0.1-SNAPSHOT</version> <version>2.1.1-SNAPSHOT</version>
<name>rocketmq-dashboard</name> <name>rocketmq-dashboard</name>
<scm> <scm>
@@ -82,29 +82,29 @@
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<maven.test.skip>false</maven.test.skip>
<guava.version>29.0-jre</guava.version> <guava.version>29.0-jre</guava.version>
<commons-digester.version>2.1</commons-digester.version> <commons-digester.version>2.1</commons-digester.version>
<commons-lang.version>2.6</commons-lang.version> <commons-lang.version>2.6</commons-lang.version>
<commons-io.version>2.4</commons-io.version> <commons-io.version>2.4</commons-io.version>
<commons-cli.version>1.2</commons-cli.version> <commons-cli.version>1.2</commons-cli.version>
<commons-collections.version>3.2.2</commons-collections.version> <commons-collections.version>3.2.2</commons-collections.version>
<rocketmq.version>4.9.3</rocketmq.version> <rocketmq.version>5.3.3</rocketmq.version>
<surefire.version>2.19.1</surefire.version> <surefire.version>2.19.1</surefire.version>
<aspectj.version>1.9.6</aspectj.version> <aspectj.version>1.9.6</aspectj.version>
<lombok.version>1.18.12</lombok.version> <lombok.version>1.18.22</lombok.version>
<main.basedir>${basedir}/../..</main.basedir> <main.basedir>${basedir}/../..</main.basedir>
<docker.image.prefix>apacherocketmq</docker.image.prefix> <docker.image.prefix>apacherocketmq</docker.image.prefix>
<spring.boot.version>2.6.0</spring.boot.version> <spring.boot.version>3.4.5</spring.boot.version>
<mockito-inline.version>3.3.3</mockito-inline.version> <mockito-inline.version>3.3.3</mockito-inline.version>
<jaxb-api.version>2.3.1</jaxb-api.version> <jakarta.xml.bind-api.version>4.0.0</jakarta.xml.bind-api.version>
<commons-pool2.version>2.4.3</commons-pool2.version> <commons-pool2.version>2.4.3</commons-pool2.version>
<easyexcel.version>2.2.10</easyexcel.version> <easyexcel.version>2.2.10</easyexcel.version>
<asm.version>4.2</asm.version> <asm.version>4.2</asm.version>
<junit.version>4.12</junit.version> <junit.version>4.12</junit.version>
<snakeyaml.version>1.30</snakeyaml.version> <snakeyaml.version>2.0</snakeyaml.version>
<cglib.version>2.2.2</cglib.version> <cglib.version>2.2.2</cglib.version>
<joor.version>0.9.6</joor.version> <joor.version>0.9.6</joor.version>
<bcpkix-jdk15on.version>1.68</bcpkix-jdk15on.version> <bcpkix-jdk15on.version>1.68</bcpkix-jdk15on.version>
@@ -115,6 +115,12 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version> <version>${spring.boot.version}</version>
<exclusions>
<exclusion>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -143,6 +149,11 @@
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
<version>${spring.boot.version}</version> <version>${spring.boot.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency> <dependency>
<groupId>commons-collections</groupId> <groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId> <artifactId>commons-collections</artifactId>
@@ -167,6 +178,7 @@
<groupId>org.apache.rocketmq</groupId> <groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-namesrv</artifactId> <artifactId>rocketmq-namesrv</artifactId>
<version>${rocketmq.version}</version> <version>${rocketmq.version}</version>
<scope>test</scope>
<exclusions> <exclusions>
<exclusion> <exclusion>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
@@ -186,6 +198,7 @@
<groupId>org.apache.rocketmq</groupId> <groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-broker</artifactId> <artifactId>rocketmq-broker</artifactId>
<version>${rocketmq.version}</version> <version>${rocketmq.version}</version>
<scope>test</scope>
<exclusions> <exclusions>
<exclusion> <exclusion>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
@@ -233,14 +246,15 @@
<version>${bcpkix-jdk15on.version}</version> <version>${bcpkix-jdk15on.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>javax.xml.bind</groupId> <groupId>jakarta.xml.bind</groupId>
<artifactId>jaxb-api</artifactId> <artifactId>jakarta.xml.bind-api</artifactId>
<version>${jaxb-api.version}</version> <version>${jakarta.xml.bind-api.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>${lombok.version}</version> <version>${lombok.version}</version>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mockito</groupId> <groupId>org.mockito</groupId>
@@ -280,7 +294,7 @@
<plugins> <plugins>
<plugin> <plugin>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version> <version>3.11.0</version>
<configuration> <configuration>
<source>${maven.compiler.source}</source> <source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target> <target>${maven.compiler.target}</target>
@@ -294,6 +308,9 @@
<version>${lombok.version}</version> <version>${lombok.version}</version>
</path> </path>
</annotationProcessorPaths> </annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>
@@ -382,9 +399,9 @@
<version>4.3.0</version> <version>4.3.0</version>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>javax.xml.bind</groupId> <groupId>jakarta.xml.bind</groupId>
<artifactId>jaxb-api</artifactId> <artifactId>jakarta.xml.bind-api</artifactId>
<version>${jaxb-api.version}</version> <version>${jakarta.xml.bind-api.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>
</plugin> </plugin>
@@ -402,11 +419,11 @@
<exclude>docs/**</exclude> <exclude>docs/**</exclude>
<exclude>src/main/resources/static/vendor/**</exclude> <exclude>src/main/resources/static/vendor/**</exclude>
<exclude>src/main/resources/static/src/data/**</exclude> <exclude>src/main/resources/static/src/data/**</exclude>
<exclude>frontend/node_modules/**</exclude> <exclude>frontend-new/node_modules/**</exclude>
<exclude>frontend/build/**</exclude> <exclude>frontend-new/build/**</exclude>
<exclude>frontend/**.json</exclude> <exclude>frontend-new/**.json</exclude>
<exclude>frontend/**.lock</exclude> <exclude>frontend-new/**.lock</exclude>
<exclude>frontend/public/manifest.json</exclude> <exclude>frontend-new/public/manifest.json</exclude>
<exclude>package-lock.json</exclude> <exclude>package-lock.json</exclude>
</excludes> </excludes>
</configuration> </configuration>
@@ -416,37 +433,36 @@
<artifactId>frontend-maven-plugin</artifactId> <artifactId>frontend-maven-plugin</artifactId>
<version>1.11.3</version> <version>1.11.3</version>
<configuration> <configuration>
<workingDirectory>frontend</workingDirectory> <workingDirectory>frontend-new</workingDirectory>
<installDirectory>target</installDirectory> <installDirectory>target</installDirectory>
</configuration> </configuration>
<executions> <executions>
<execution> <execution>
<id>install node and yarn</id> <id>install node</id>
<goals> <goals>
<goal>install-node-and-yarn</goal> <goal>install-node-and-npm</goal>
</goals> </goals>
<configuration> <configuration>
<nodeVersion>v16.2.0</nodeVersion> <nodeVersion>v18.2.0</nodeVersion>
<yarnVersion>v1.22.10</yarnVersion>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
<id>yarn install</id> <id>npm install</id>
<goals> <goals>
<goal>yarn</goal> <goal>npm</goal>
</goals> </goals>
<configuration> <configuration>
<arguments>install</arguments> <arguments>install</arguments>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
<id>yarn build</id> <id>npm build</id>
<goals> <goals>
<goal>yarn</goal> <goal>npm</goal>
</goals> </goals>
<configuration> <configuration>
<arguments>build</arguments> <arguments>run build</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>
@@ -459,7 +475,7 @@
<configuration> <configuration>
<target> <target>
<copy todir="${project.build.directory}/classes/public"> <copy todir="${project.build.directory}/classes/public">
<fileset dir="${project.basedir}/frontend/build"/> <fileset dir="${project.basedir}/frontend-new/build" />
</copy> </copy>
</target> </target>
</configuration> </configuration>

View File

@@ -15,7 +15,7 @@
# limitations under the License. # limitations under the License.
# #
FROM java:8 FROM eclipse-temurin:17.0.16_8-jre-ubi9-minimal
VOLUME /tmp VOLUME /tmp
ADD rocketmq-dashboard-*.jar rocketmq-dashboard.jar ADD rocketmq-dashboard-*.jar rocketmq-dashboard.jar
RUN sh -c 'touch /rocketmq-dashboard.jar' RUN sh -c 'touch /rocketmq-dashboard.jar'

View File

@@ -16,7 +16,6 @@
*/ */
package org.apache.rocketmq.dashboard.admin; package org.apache.rocketmq.dashboard.admin;
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.acl.common.AclClientRPCHook; import org.apache.rocketmq.acl.common.AclClientRPCHook;
@@ -26,6 +25,8 @@ import org.apache.rocketmq.remoting.RPCHook;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt; import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.tools.admin.MQAdminExt; import org.apache.rocketmq.tools.admin.MQAdminExt;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j @Slf4j
public class MQAdminFactory { public class MQAdminFactory {
private RMQConfigure rmqConfigure; private RMQConfigure rmqConfigure;

View File

@@ -21,7 +21,7 @@ import org.apache.commons.collections.MapUtils;
import org.apache.commons.pool2.PooledObject; import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory; import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject; import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.rocketmq.common.protocol.body.ClusterInfo; import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.tools.admin.MQAdminExt; import org.apache.rocketmq.tools.admin.MQAdminExt;
@Slf4j @Slf4j

View File

@@ -0,0 +1,132 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.admin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.apache.rocketmq.client.ClientConfig;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Component
@Slf4j
public class UserMQAdminPoolManager {
private final ConcurrentMap<String/* userAk */, GenericObjectPool<MQAdminExt>> userPools = new ConcurrentHashMap<>();
private final ClientConfig baseClientConfig;
@Autowired
public UserMQAdminPoolManager(RMQConfigure rmqConfigure) {
this.baseClientConfig = new ClientConfig();
this.baseClientConfig.setNamesrvAddr(rmqConfigure.getNamesrvAddr());
this.baseClientConfig.setClientCallbackExecutorThreads(rmqConfigure.getClientCallbackExecutorThreads());
this.baseClientConfig.setVipChannelEnabled(Boolean.parseBoolean(rmqConfigure.getIsVIPChannel()));
this.baseClientConfig.setUseTLS(rmqConfigure.isUseTLS());
log.info("UserMQAdminPoolManager initialized with baseClientConfig for NameServer: {}", rmqConfigure.getNamesrvAddr());
}
public MQAdminExt borrowMQAdminExt(String userAk, String userSk) throws Exception {
GenericObjectPool<MQAdminExt> userPool = userPools.get(userAk);
if (userPool == null) {
log.info("Creating new MQAdminExt pool for user: {}", userAk);
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
poolConfig.setMaxTotal(1);
poolConfig.setMaxIdle(1);
poolConfig.setMinIdle(0);
poolConfig.setTestWhileIdle(true);
poolConfig.setTimeBetweenEvictionRunsMillis(20000);
poolConfig.setMaxWaitMillis(10000);
UserSpecificMQAdminPooledObjectFactory factory =
new UserSpecificMQAdminPooledObjectFactory(baseClientConfig, userAk, userSk);
GenericObjectPool<MQAdminExt> newUserPool = new GenericObjectPool<>(factory, poolConfig);
GenericObjectPool<MQAdminExt> existingPool = userPools.putIfAbsent(userAk, newUserPool);
if (existingPool != null) {
log.warn("Another thread concurrently created MQAdminExt pool for user {}. Shutting down redundant pool.", userAk);
newUserPool.close();
userPool = existingPool;
} else {
userPool = newUserPool;
log.info("Successfully created and registered MQAdminExt pool for user: {}", userAk);
}
}
return userPool.borrowObject();
}
public void returnMQAdminExt(String userAk, MQAdminExt mqAdminExt) {
GenericObjectPool<MQAdminExt> userPool = userPools.get(userAk);
if (userPool != null) {
try {
userPool.returnObject(mqAdminExt);
log.debug("Returned MQAdminExt object ({}) to pool for user: {}", mqAdminExt, userAk);
} catch (Exception e) {
log.error("Failed to return MQAdminExt object ({}) for user {}: {}", mqAdminExt, userAk, e.getMessage(), e);
if (mqAdminExt != null) {
try {
mqAdminExt.shutdown();
} catch (Exception se) {
log.warn("Error shutting down MQAdminExt after failed return: {}", se.getMessage());
}
}
}
} else {
log.warn("Attempted to return MQAdminExt for non-existent user pool: {}. Shutting down the object directly.", userAk);
if (mqAdminExt != null) {
try {
mqAdminExt.shutdown();
} catch (Exception se) {
log.warn("Error shutting down MQAdminExt for non-existent pool: {}", se.getMessage());
}
}
}
}
public void shutdownUserPool(String userAk) {
GenericObjectPool<MQAdminExt> userPool = userPools.remove(userAk);
if (userPool != null) {
userPool.close();
log.info("Shutdown and removed MQAdminExt pool for user: {}", userAk);
} else {
log.warn("Attempted to shut down non-existent user pool: {}", userAk);
}
}
@PreDestroy
public void shutdownAllPools() {
log.info("Shutting down all MQAdminExt user pools...");
userPools.forEach((userAk, pool) -> {
pool.close();
log.info("Shutdown MQAdminExt pool for user: {}", userAk);
});
userPools.clear();
log.info("All MQAdminExt user pools have been shut down.");
}
}

View File

@@ -0,0 +1,112 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.admin;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.PooledObjectFactory;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.rocketmq.acl.common.AclClientRPCHook;
import org.apache.rocketmq.acl.common.SessionCredentials;
import org.apache.rocketmq.client.ClientConfig;
import org.apache.rocketmq.remoting.RPCHook;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.apache.rocketmq.tools.admin.MQAdminExt;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
public class UserSpecificMQAdminPooledObjectFactory implements PooledObjectFactory<MQAdminExt> {
private final ClientConfig userSpecificClientConfig;
private final RPCHook rpcHook;
private final String userAk;
private final AtomicLong instanceCreationCounter = new AtomicLong(0);
public UserSpecificMQAdminPooledObjectFactory(ClientConfig baseClientConfig, String userAk, String userSk) {
this.userSpecificClientConfig = baseClientConfig.cloneClientConfig();
this.userSpecificClientConfig.setInstanceName("MQ_ADMIN_INSTANCE_" + userAk + "_" + UUID.randomUUID());
if (StringUtils.isNotEmpty(userAk) && StringUtils.isNotEmpty(userSk)) {
this.rpcHook = new AclClientRPCHook(new SessionCredentials(userAk, userSk));
} else {
this.rpcHook = null;
}
this.userAk = userAk;
log.info("UserSpecificMQAdminPooledObjectFactory initialized for user: {}", userAk);
log.debug("Factory ClientConfig for user {}: {}", userAk, userSpecificClientConfig);
}
@Override
public PooledObject<MQAdminExt> makeObject() throws Exception {
DefaultMQAdminExt mqAdminExt = new DefaultMQAdminExt(rpcHook);
mqAdminExt.setAdminExtGroup("MQ_ADMIN_GROUP_FOR_" + userAk + "_" + instanceCreationCounter.getAndIncrement());
mqAdminExt.start();
log.info("Created new MQAdminExt instance ({}) for user {}", mqAdminExt, userAk);
return new DefaultPooledObject<>(mqAdminExt);
}
@Override
public void destroyObject(PooledObject<MQAdminExt> p) {
MQAdminExt mqAdmin = p.getObject();
if (mqAdmin != null) {
try {
mqAdmin.shutdown();
} catch (Exception e) {
log.warn("Failed to shut down MQAdminExt object ({}) for user {}: {}", p.getObject(), userAk, e.getMessage(), e);
}
}
log.info("Destroyed MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
@Override
public boolean validateObject(PooledObject<MQAdminExt> p) {
MQAdminExt mqAdmin = p.getObject();
if (mqAdmin == null) {
log.warn("MQAdminExt object is null or not started for user {}: {}", userAk, mqAdmin);
return false;
}
try {
ClusterInfo clusterInfo = mqAdmin.examineBrokerClusterInfo();
boolean isValid = clusterInfo != null && !clusterInfo.getBrokerAddrTable().isEmpty();
if (!isValid) {
log.warn("Validation failed for MQAdminExt object for user {}: ClusterInfo is invalid or empty. ClusterInfo = {}", userAk, clusterInfo);
}
return isValid;
} catch (Exception e) {
log.warn("Validation error for MQAdminExt object for user {}: {}", userAk, e.getMessage(), e);
return false;
}
}
@Override
public void activateObject(PooledObject<MQAdminExt> p) {
log.debug("Activating MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
@Override
public void passivateObject(PooledObject<MQAdminExt> p) {
log.debug("Passivating MQAdminExt object ({}) for user {}", p.getObject(), userAk);
}
}

View File

@@ -18,42 +18,118 @@ package org.apache.rocketmq.dashboard.aspect.admin;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.impl.GenericObjectPool; import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.rocketmq.dashboard.admin.UserMQAdminPoolManager;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.service.client.MQAdminInstance; import org.apache.rocketmq.dashboard.service.client.MQAdminInstance;
import org.apache.rocketmq.dashboard.util.UserInfoContext;
import org.apache.rocketmq.dashboard.util.WebUtil;
import org.apache.rocketmq.remoting.protocol.body.UserInfo;
import org.apache.rocketmq.tools.admin.MQAdminExt; import org.apache.rocketmq.tools.admin.MQAdminExt;
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
@Aspect @Aspect
@Service @Component
@Slf4j @Slf4j
public class MQAdminAspect { public class MQAdminAspect {
@Autowired
private UserMQAdminPoolManager userMQAdminPoolManager;
@Autowired @Autowired
private GenericObjectPool<MQAdminExt> mqAdminExtPool; private GenericObjectPool<MQAdminExt> mqAdminExtPool;
public MQAdminAspect() { @Autowired
private RMQConfigure rmqConfigure;
private static final Set<String> METHODS_TO_CHECK = new HashSet<>();
static {
METHODS_TO_CHECK.add("getUser");
METHODS_TO_CHECK.add("examineBrokerClusterInfo");
METHODS_TO_CHECK.add("examineConsumerConnectionInfo");
METHODS_TO_CHECK.add("examineConsumeStats");
METHODS_TO_CHECK.add("examineProducerConnectionInfo");
METHODS_TO_CHECK.add("fetchBrokerRuntimeStats");
METHODS_TO_CHECK.add("fetchAllTopicList");
METHODS_TO_CHECK.add("examineTopicRouteInfo");
METHODS_TO_CHECK.add("queryTopicConsumeByWho");
} }
// Pointcut remains the same, targeting methods in MQAdminExtImpl
@Pointcut("execution(* org.apache.rocketmq.dashboard.service.client.MQAdminExtImpl..*(..))") @Pointcut("execution(* org.apache.rocketmq.dashboard.service.client.MQAdminExtImpl..*(..))")
public void mQAdminMethodPointCut() { public void mQAdminMethodPointCut() {
} }
@Around(value = "mQAdminMethodPointCut()") @Pointcut("execution(* org.apache.rocketmq.dashboard.service.client.ProxyAdminImpl..*(..))")
public void proxyAdminMethodPointCut() {
}
@Around(value = "mQAdminMethodPointCut()||proxyAdminMethodPointCut()")
public Object aroundMQAdminMethod(ProceedingJoinPoint joinPoint) throws Throwable { public Object aroundMQAdminMethod(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
Object obj = null; MQAdminExt mqAdminExt = null; // The MQAdminExt instance borrowed from the pool
UserInfo currentUserInfo = null; // The user initiating the operation
String methodName = joinPoint.getSignature().getName();
try { try {
MQAdminInstance.createMQAdmin(mqAdminExtPool); if (isPoolConfigIsolatedByUser(rmqConfigure.isLoginRequired(), rmqConfigure.getAuthMode(), methodName)) {
obj = joinPoint.proceed(); currentUserInfo = (UserInfo) UserInfoContext.get(WebUtil.USER_NAME);
// 2. Borrow the user-specific MQAdminExt instance.
// currentUser.getName() is assumed to be the AccessKey, and currentUser.getPassword() is SecretKey.
mqAdminExt = userMQAdminPoolManager.borrowMQAdminExt(currentUserInfo.getUsername(), currentUserInfo.getPassword());
// 3. Set the borrowed MQAdminExt instance into the ThreadLocal for MQAdminInstance.
// This makes it available to MQAdminExtImpl methods.
MQAdminInstance.setCurrentMQAdminExt(mqAdminExt);
log.debug("MQAdminExt borrowed for user {} and set in ThreadLocal.", currentUserInfo.getUsername());
} else {
mqAdminExt = mqAdminExtPool.borrowObject(); // Fallback to a default MQAdminExt if no user is provided
MQAdminInstance.setCurrentMQAdminExt(mqAdminExt);
}
// 4. Proceed with the original method execution.
return joinPoint.proceed();
} finally { } finally {
MQAdminInstance.returnMQAdmin(mqAdminExtPool);
log.debug("op=look method={} cost={}", joinPoint.getSignature().getName(), System.currentTimeMillis() - start); if (currentUserInfo != null) {
if (mqAdminExt != null) {
userMQAdminPoolManager.returnMQAdminExt(currentUserInfo.getUsername(), mqAdminExt);
MQAdminInstance.clearCurrentMQAdminExt();
log.debug("MQAdminExt returned for user {} and cleared from ThreadLocal.", currentUserInfo.getUsername());
} }
return obj; log.debug("Operation {} for user {} cost {}ms",
methodName,
currentUserInfo.getUsername(),
System.currentTimeMillis() - start);
} else {
if (mqAdminExt != null) {
mqAdminExtPool.returnObject(mqAdminExt);
MQAdminInstance.clearCurrentMQAdminExt();
log.debug("MQAdminExt returned to default pool and cleared from ThreadLocal.");
}
log.debug("Operation {} cost {}ms",
methodName,
System.currentTimeMillis() - start);
}
} }
} }
private boolean isPoolConfigIsolatedByUser(boolean loginRequired, String authMode, String methodName) {
if (!loginRequired || authMode.equals("file")) {
return false;
} else {
return !METHODS_TO_CHECK.contains(methodName);
}
}
}

View File

@@ -17,6 +17,8 @@
package org.apache.rocketmq.dashboard.config; package org.apache.rocketmq.dashboard.config;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.rocketmq.dashboard.interceptor.AuthInterceptor; import org.apache.rocketmq.dashboard.interceptor.AuthInterceptor;
import org.apache.rocketmq.dashboard.model.UserInfo; import org.apache.rocketmq.dashboard.model.UserInfo;
import org.apache.rocketmq.dashboard.util.WebUtil; import org.apache.rocketmq.dashboard.util.WebUtil;
@@ -31,14 +33,13 @@ import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.List; import java.util.List;
@Configuration @Configuration
public class AuthWebMVCConfigurerAdapter extends WebMvcConfigurerAdapter { public class AuthWebMVCConfigurerAdapter implements WebMvcConfigurer {
@Autowired @Autowired
@Qualifier("authInterceptor") @Qualifier("authInterceptor")
private AuthInterceptor authInterceptor; private AuthInterceptor authInterceptor;
@@ -86,10 +87,10 @@ public class AuthWebMVCConfigurerAdapter extends WebMvcConfigurerAdapter {
throw new MissingServletRequestPartException(UserInfo.USER_INFO); throw new MissingServletRequestPartException(UserInfo.USER_INFO);
} }
}); });
super.addArgumentResolvers(argumentResolvers); //REVIEW ME
} }
@Override @Override
public void addViewControllers(ViewControllerRegistry registry) { public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("*.htm").setViewName("forward:/app.html"); registry.addViewController("*.htm").setViewName("forward:/app.html");

View File

@@ -17,16 +17,17 @@
package org.apache.rocketmq.dashboard.config; package org.apache.rocketmq.dashboard.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
@ConfigurationProperties(prefix = "threadpool.config") @ConfigurationProperties(prefix = "threadpool.config")

View File

@@ -16,7 +16,8 @@
*/ */
package org.apache.rocketmq.dashboard.config; package org.apache.rocketmq.dashboard.config;
import java.util.ArrayList; import lombok.Getter;
import lombok.Setter;
import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.common.MixAll; import org.apache.rocketmq.common.MixAll;
@@ -31,59 +32,74 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import java.io.File; import java.io.File;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import static org.apache.rocketmq.client.ClientConfig.SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY; import static org.apache.rocketmq.client.ClientConfig.SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY;
@Setter
@Getter
@Configuration @Configuration
@ConfigurationProperties(prefix = "rocketmq.config") @ConfigurationProperties(prefix = "rocketmq.config")
public class RMQConfigure { public class RMQConfigure {
private Logger logger = LoggerFactory.getLogger(RMQConfigure.class); private Logger logger = LoggerFactory.getLogger(RMQConfigure.class);
//use rocketmq.namesrv.addr first,if it is empty,than use system proerty or system env //use rocketmq.namesrv.addr first,if it is empty,than use system proerty or system env
@Getter
private volatile String namesrvAddr = System.getProperty(MixAll.NAMESRV_ADDR_PROPERTY, System.getenv(MixAll.NAMESRV_ADDR_ENV)); private volatile String namesrvAddr = System.getProperty(MixAll.NAMESRV_ADDR_PROPERTY, System.getenv(MixAll.NAMESRV_ADDR_ENV));
@Setter
@Getter
private volatile String proxyAddr;
@Getter
private volatile String isVIPChannel = System.getProperty(SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY, "true"); private volatile String isVIPChannel = System.getProperty(SEND_MESSAGE_WITH_VIP_CHANNEL_PROPERTY, "true");
@Setter
private String dataPath = "/tmp/rocketmq-console/data"; private String dataPath = "/tmp/rocketmq-console/data";
@Getter
private boolean enableDashBoardCollect; private boolean enableDashBoardCollect;
@Setter
@Getter
private boolean loginRequired = false; private boolean loginRequired = false;
private String accessKey; private String accessKey;
@Setter
@Getter
private String secretKey; private String secretKey;
@Setter
@Getter
private boolean useTLS = false; private boolean useTLS = false;
@Setter
@Getter
private Long timeoutMillis; private Long timeoutMillis;
@Getter
private List<String> namesrvAddrs = new ArrayList<>(); private List<String> namesrvAddrs = new ArrayList<>();
public String getAccessKey() { @Getter
return accessKey; private List<String> proxyAddrs = new ArrayList<>();
}
public void setAccessKey(String accessKey) { @Setter
this.accessKey = accessKey; @Getter
} private Integer clientCallbackExecutorThreads = 4;
public String getSecretKey() { @Setter
return secretKey; @Getter
} private String authMode = "file";
public void setSecretKey(String secretKey) { public void setProxyAddrs(List<String> proxyAddrs) {
this.secretKey = secretKey; this.proxyAddrs = proxyAddrs;
if (CollectionUtils.isNotEmpty(proxyAddrs)) {
this.setProxyAddr(proxyAddrs.get(0));
} }
public String getNamesrvAddr() {
return namesrvAddr;
}
public List<String> getNamesrvAddrs() {
return namesrvAddrs;
} }
public void setNamesrvAddrs(List<String> namesrvAddrs) { public void setNamesrvAddrs(List<String> namesrvAddrs) {
@@ -100,10 +116,12 @@ public class RMQConfigure {
logger.info("setNameSrvAddrByProperty nameSrvAddr={}", namesrvAddr); logger.info("setNameSrvAddrByProperty nameSrvAddr={}", namesrvAddr);
} }
} }
public boolean isACLEnabled() { public boolean isACLEnabled() {
return !(StringUtils.isAnyBlank(this.accessKey, this.secretKey) || return !(StringUtils.isAnyBlank(this.accessKey, this.secretKey) ||
StringUtils.isAnyEmpty(this.accessKey, this.secretKey)); StringUtils.isAnyEmpty(this.accessKey, this.secretKey));
} }
public String getRocketMqDashboardDataPath() { public String getRocketMqDashboardDataPath() {
return dataPath; return dataPath;
} }
@@ -112,14 +130,6 @@ public class RMQConfigure {
return dataPath + File.separator + "dashboard"; return dataPath + File.separator + "dashboard";
} }
public void setDataPath(String dataPath) {
this.dataPath = dataPath;
}
public String getIsVIPChannel() {
return isVIPChannel;
}
public void setIsVIPChannel(String isVIPChannel) { public void setIsVIPChannel(String isVIPChannel) {
if (StringUtils.isNotBlank(isVIPChannel)) { if (StringUtils.isNotBlank(isVIPChannel)) {
this.isVIPChannel = isVIPChannel; this.isVIPChannel = isVIPChannel;
@@ -128,38 +138,10 @@ public class RMQConfigure {
} }
} }
public boolean isEnableDashBoardCollect() {
return enableDashBoardCollect;
}
public void setEnableDashBoardCollect(String enableDashBoardCollect) { public void setEnableDashBoardCollect(String enableDashBoardCollect) {
this.enableDashBoardCollect = Boolean.valueOf(enableDashBoardCollect); this.enableDashBoardCollect = Boolean.valueOf(enableDashBoardCollect);
} }
public boolean isLoginRequired() {
return loginRequired;
}
public void setLoginRequired(boolean loginRequired) {
this.loginRequired = loginRequired;
}
public boolean isUseTLS() {
return useTLS;
}
public void setUseTLS(boolean useTLS) {
this.useTLS = useTLS;
}
public Long getTimeoutMillis() {
return timeoutMillis;
}
public void setTimeoutMillis(Long timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
// Error Page process logic, move to a central configure later // Error Page process logic, move to a central configure later
@Bean @Bean
public ErrorPageRegistrar errorPageRegistrar() { public ErrorPageRegistrar errorPageRegistrar() {

View File

@@ -0,0 +1,74 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(withDefaults())
.csrf(csrf -> csrf
.ignoringRequestMatchers("/actuator/**")
.ignoringRequestMatchers("/rocketmq-dashboard/csrf-token")
.csrfTokenRepository(csrfTokenRepository())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.httpBasic(withDefaults());
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3003"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("content-type", "Authorization", "X-Requested-With", "Origin", "Accept", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public CsrfTokenRepository csrfTokenRepository() {
return CookieCsrfTokenRepository.withHttpOnlyFalse();
}
}

View File

@@ -14,133 +14,97 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions; import org.apache.rocketmq.dashboard.model.PolicyRequest;
import java.util.List; import org.apache.rocketmq.dashboard.model.UserInfoDto;
import javax.annotation.Resource; import org.apache.rocketmq.dashboard.model.request.UserCreateRequest;
import javax.servlet.http.HttpServletRequest; import org.apache.rocketmq.dashboard.model.request.UserUpdateRequest;
import org.apache.commons.collections.CollectionUtils; import org.apache.rocketmq.dashboard.service.impl.AclServiceImpl;
import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired;
import org.apache.rocketmq.common.AclConfig; import org.springframework.stereotype.Controller;
import org.apache.rocketmq.common.PlainAccessConfig;
import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.model.User;
import org.apache.rocketmq.dashboard.model.UserInfo;
import org.apache.rocketmq.dashboard.model.request.AclRequest;
import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.AclService;
import org.apache.rocketmq.dashboard.support.JsonResult;
import org.apache.rocketmq.dashboard.util.WebUtil;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.ResponseBody;
@RestController import java.util.List;
@Controller
@RequestMapping("/acl") @RequestMapping("/acl")
@Permission
public class AclController { public class AclController {
@Resource @Autowired
private AclService aclService; private AclServiceImpl aclService;
@Resource @GetMapping("/users.query")
private RMQConfigure configure; @ResponseBody
public List<UserInfoDto> listUsers(@RequestParam(required = false) String brokerName,
@GetMapping("/enable.query") @RequestParam(required = false) String clusterName) {
public Object isEnableAcl() { return aclService.listUsers(clusterName, brokerName);
return new JsonResult<>(configure.isACLEnabled());
} }
@GetMapping("/config.query") @GetMapping("/acls.query")
public AclConfig getAclConfig(HttpServletRequest request) { @ResponseBody
if (!configure.isLoginRequired()) { public Object listAcls(
return aclService.getAclConfig(false); @RequestParam(required = false) String brokerName,
} @RequestParam(required = false) String searchParam,
UserInfo userInfo = (UserInfo) WebUtil.getValueFromSession(request, WebUtil.USER_INFO); @RequestParam(required = false) String clusterName) {
// if user info is null but reach here, must exclude secret key for safety. return aclService.listAcls(clusterName, brokerName, searchParam);
return aclService.getAclConfig(userInfo == null || userInfo.getUser().getType() != User.ADMIN);
} }
@PostMapping("/add.do") @PostMapping("/createAcl.do")
public Object addAclConfig(@RequestBody PlainAccessConfig config) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null"); public Object createAcl(@RequestBody PolicyRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null"); aclService.createAcl(request);
aclService.addAclConfig(config);
return true; return true;
} }
@PostMapping("/delete.do") @DeleteMapping("/deleteUser.do")
public Object deleteAclConfig(@RequestBody PlainAccessConfig config) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null"); public Object deleteUser(@RequestParam(required = false) String brokerName,
aclService.deleteAclConfig(config); @RequestParam String username,
@RequestParam(required = false) String clusterName) {
aclService.deleteUser(clusterName, brokerName, username);
return true; return true;
} }
@PostMapping("/update.do") @RequestMapping(value = "/updateUser.do", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
public Object updateAclConfig(@RequestBody PlainAccessConfig config) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null"); public Object updateUser(@RequestBody UserUpdateRequest request) {
aclService.updateAclConfig(config); aclService.updateUser(request.getClusterName(), request.getBrokerName(), request.getUserInfo());
return true; return true;
} }
@PostMapping("/topic/add.do") @PostMapping("/createUser.do")
public Object addAclTopicConfig(@RequestBody AclRequest request) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null"); public Object createUser(@RequestBody UserCreateRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null"); aclService.createUser(request.getClusterName(), request.getBrokerName(), request.getUserInfo());
Preconditions.checkArgument(CollectionUtils.isNotEmpty(request.getConfig().getTopicPerms()), "topic perms is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getTopicPerm()), "topic perm is null");
aclService.addOrUpdateAclTopicConfig(request);
return true; return true;
} }
@PostMapping("/group/add.do") @DeleteMapping("/deleteAcl.do")
public Object addAclGroupConfig(@RequestBody AclRequest request) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null"); public Object deleteAcl(
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null"); @RequestParam(required = false) String brokerName,
Preconditions.checkArgument(CollectionUtils.isNotEmpty(request.getConfig().getGroupPerms()), "group perms is null"); @RequestParam(required = false) String clusterName,
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getGroupPerm()), "group perm is null"); @RequestParam String subject,
aclService.addOrUpdateAclGroupConfig(request); @RequestParam(required = false) String resource) {
aclService.deleteAcl(clusterName, brokerName, subject, resource);
return true; return true;
} }
@PostMapping("/perm/delete.do") @RequestMapping(value = "/updateAcl.do", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
public Object deletePermConfig(@RequestBody AclRequest request) { @ResponseBody
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getAccessKey()), "accessKey is null"); public Object updateAcl(@RequestBody PolicyRequest request) {
Preconditions.checkArgument(StringUtils.isNotEmpty(request.getConfig().getSecretKey()), "secretKey is null"); aclService.updateAcl(request);
aclService.deletePermConfig(request);
return true; return true;
} }
@PostMapping("/sync.do")
public Object syncConfig(@RequestBody PlainAccessConfig config) {
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getAccessKey()), "accessKey is null");
Preconditions.checkArgument(StringUtils.isNotEmpty(config.getSecretKey()), "secretKey is null");
aclService.syncData(config);
return true;
}
@PostMapping("/white/list/add.do")
public Object addWhiteList(@RequestBody List<String> whiteList) {
Preconditions.checkArgument(CollectionUtils.isNotEmpty(whiteList), "white list is null");
aclService.addWhiteList(whiteList);
return true;
}
@DeleteMapping("/white/list/delete.do")
public Object deleteWhiteAddr(@RequestParam String request) {
aclService.deleteWhiteAddr(request);
return true;
}
@PostMapping("/white/list/sync.do")
public Object synchronizeWhiteList(@RequestBody List<String> whiteList) {
Preconditions.checkArgument(CollectionUtils.isNotEmpty(whiteList), "white list is null");
aclService.synchronizeWhiteList(whiteList);
return true;
}
} }

View File

@@ -16,14 +16,13 @@
*/ */
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.permisssion.Permission; import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ClusterService; import org.apache.rocketmq.dashboard.service.ClusterService;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
@Controller @Controller

View File

@@ -17,9 +17,8 @@
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import javax.annotation.Resource; import jakarta.annotation.Resource;
import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.CollectionUtils;
import org.apache.rocketmq.common.protocol.body.ConsumerConnection;
import org.apache.rocketmq.dashboard.model.ConnectionInfo; import org.apache.rocketmq.dashboard.model.ConnectionInfo;
import org.apache.rocketmq.dashboard.model.request.ConsumerConfigInfo; import org.apache.rocketmq.dashboard.model.request.ConsumerConfigInfo;
import org.apache.rocketmq.dashboard.model.request.DeleteSubGroupRequest; import org.apache.rocketmq.dashboard.model.request.DeleteSubGroupRequest;
@@ -27,6 +26,7 @@ import org.apache.rocketmq.dashboard.model.request.ResetOffsetRequest;
import org.apache.rocketmq.dashboard.permisssion.Permission; import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.ConsumerService; import org.apache.rocketmq.dashboard.service.ConsumerService;
import org.apache.rocketmq.dashboard.util.JsonUtil; import org.apache.rocketmq.dashboard.util.JsonUtil;
import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@@ -47,14 +47,27 @@ public class ConsumerController {
@RequestMapping(value = "/groupList.query") @RequestMapping(value = "/groupList.query")
@ResponseBody @ResponseBody
public Object list(@RequestParam(value = "skipSysGroup", required = false) boolean skipSysGroup) { public Object list(@RequestParam(value = "skipSysGroup", required = false) boolean skipSysGroup, String address) {
return consumerService.queryGroupList(skipSysGroup); return consumerService.queryGroupList(skipSysGroup, address);
}
@RequestMapping(value = "/group.refresh")
@ResponseBody
public Object refresh(String address,
String consumerGroup) {
return consumerService.refreshGroup(address, consumerGroup);
}
@RequestMapping(value = "group.refresh.all")
@ResponseBody
public Object refreshAll(String address) {
return consumerService.refreshAllGroup(address);
} }
@RequestMapping(value = "/group.query") @RequestMapping(value = "/group.query")
@ResponseBody @ResponseBody
public Object groupQuery(@RequestParam String consumerGroup) { public Object groupQuery(@RequestParam String consumerGroup, String address) {
return consumerService.queryGroup(consumerGroup); return consumerService.queryGroup(consumerGroup, address);
} }
@RequestMapping(value = "/resetOffset.do", method = {RequestMethod.POST}) @RequestMapping(value = "/resetOffset.do", method = {RequestMethod.POST})
@@ -99,14 +112,14 @@ public class ConsumerController {
@RequestMapping(value = "/queryTopicByConsumer.query") @RequestMapping(value = "/queryTopicByConsumer.query")
@ResponseBody @ResponseBody
public Object queryConsumerByTopic(@RequestParam String consumerGroup) { public Object queryConsumerByTopic(@RequestParam String consumerGroup, String address) {
return consumerService.queryConsumeStatsListByGroupName(consumerGroup); return consumerService.queryConsumeStatsListByGroupName(consumerGroup, address);
} }
@RequestMapping(value = "/consumerConnection.query") @RequestMapping(value = "/consumerConnection.query")
@ResponseBody @ResponseBody
public Object consumerConnection(@RequestParam(required = false) String consumerGroup) { public Object consumerConnection(@RequestParam(required = false) String consumerGroup, String address) {
ConsumerConnection consumerConnection = consumerService.getConsumerConnection(consumerGroup); ConsumerConnection consumerConnection = consumerService.getConsumerConnection(consumerGroup, address);
consumerConnection.setConnectionSet(ConnectionInfo.buildConnectionInfoHashSet(consumerConnection.getConnectionSet())); consumerConnection.setConnectionSet(ConnectionInfo.buildConnectionInfoHashSet(consumerConnection.getConnectionSet()));
return consumerConnection; return consumerConnection;
} }

View File

@@ -0,0 +1,45 @@
/*
* 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.
*/
package org.apache.rocketmq.dashboard.controller;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/rocketmq-dashboard")
public class CsrfTokenController {
@Autowired
private CsrfTokenRepository csrfTokenRepository;
@RequestMapping(value = "/csrf-token", method = RequestMethod.GET)
@ResponseBody
public Object getCsrfToken(HttpServletRequest request, HttpServletResponse response) {
CsrfToken token = csrfTokenRepository.generateToken(request);
csrfTokenRepository.saveToken(token, request, response);
return token;
}
}

View File

@@ -17,9 +17,8 @@
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import javax.annotation.Resource;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import jakarta.annotation.Resource;
import org.apache.rocketmq.dashboard.permisssion.Permission; import org.apache.rocketmq.dashboard.permisssion.Permission;
import org.apache.rocketmq.dashboard.service.DashboardService; import org.apache.rocketmq.dashboard.service.DashboardService;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;

View File

@@ -17,10 +17,8 @@
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import java.util.ArrayList; import jakarta.annotation.Resource;
import java.util.List; import jakarta.servlet.http.HttpServletResponse;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.MixAll; import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.message.MessageExt; import org.apache.rocketmq.common.message.MessageExt;
@@ -41,6 +39,9 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import java.util.ArrayList;
import java.util.List;
@Controller @Controller
@RequestMapping("/dlqMessage") @RequestMapping("/dlqMessage")
@Permission @Permission

View File

@@ -17,6 +17,9 @@
package org.apache.rocketmq.dashboard.controller; package org.apache.rocketmq.dashboard.controller;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.rocketmq.dashboard.config.RMQConfigure; import org.apache.rocketmq.dashboard.config.RMQConfigure;
import org.apache.rocketmq.dashboard.model.LoginInfo; import org.apache.rocketmq.dashboard.model.LoginInfo;
import org.apache.rocketmq.dashboard.model.LoginResult; import org.apache.rocketmq.dashboard.model.LoginResult;
@@ -30,14 +33,11 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller @Controller
@RequestMapping("/login") @RequestMapping("/login")
@@ -66,12 +66,11 @@ public class LoginController {
@RequestMapping(value = "/login.do", method = RequestMethod.POST) @RequestMapping(value = "/login.do", method = RequestMethod.POST)
@ResponseBody @ResponseBody
public Object login(@RequestParam("username") String username, public Object login(@RequestBody org.apache.rocketmq.remoting.protocol.body.UserInfo userInfoRequest,
@RequestParam(value = "password") String password,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) throws Exception { HttpServletResponse response) throws Exception {
logger.info("user:{} login", username); logger.info("user:{} login", userInfoRequest.getUsername());
User user = userService.queryByUsernameAndPassword(username, password); User user = userService.queryByUsernameAndPassword(userInfoRequest.getUsername(), userInfoRequest.getPassword());
if (user == null) { if (user == null) {
throw new IllegalArgumentException("Bad username or password!"); throw new IllegalArgumentException("Bad username or password!");
@@ -79,9 +78,9 @@ public class LoginController {
user.setPassword(null); user.setPassword(null);
UserInfo userInfo = WebUtil.setLoginInfo(request, response, user); UserInfo userInfo = WebUtil.setLoginInfo(request, response, user);
WebUtil.setSessionValue(request, WebUtil.USER_INFO, userInfo); WebUtil.setSessionValue(request, WebUtil.USER_INFO, userInfo);
WebUtil.setSessionValue(request, WebUtil.USER_NAME, username); WebUtil.setSessionValue(request, WebUtil.USER_NAME, userInfoRequest.getUsername());
userInfo.setSessionId(WebUtil.getSessionId(request)); userInfo.setSessionId(WebUtil.getSessionId(request));
LoginResult result = new LoginResult(username, user.getType(), contextPath); LoginResult result = new LoginResult(userInfoRequest.getUsername(), user.getType(), contextPath);
return result; return result;
} }
} }

Some files were not shown because too many files have changed in this diff Show More