From e81c0f30403a24a8a96d8d93d78f9f60dc01d2ff Mon Sep 17 00:00:00 2001 From: zengqiao Date: Mon, 13 Feb 2023 16:35:43 +0800 Subject: [PATCH] =?UTF-8?q?v2.8.0=5Fe=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、测试代码,开源用户尽量不要使用; 2、包含Kafka-HA的相关功能; 3、并非基于2.6.0拉的分支,是基于master分支的 commit-id: 462303fca0f117ef20d3f7e4bb87f46b6f7dab69 拉的2.8.0_e的分支。出现这个情况的原因是v2.6.0的代码并不是最新的,2.x最新的代码是 462303fca0f117ef20d3f7e4bb87f46b6f7dab69 这个commit对应的代码; --- README.md | 46 +- docs/didi/Kafka主备切换流程简介.md | 97 +++ docs/didi/assets/Kafka主备切换流程.png | Bin 0 -> 260140 bytes .../assets/Kafka基于网关的生产消费流程.png | Bin 0 -> 53819 bytes docs/didi/drawio/Kafka主备切换流程.drawio | 367 +++++++++ .../drawio/Kafka基于网关的生产消费流程.drawio | 95 +++ kafka-manager-common/pom.xml | 10 + .../common/bizenum/JobLogBizTypEnum.java | 21 + .../common/bizenum/TaskActionEnum.java | 10 +- .../common/bizenum/TaskStatusEnum.java | 24 +- .../common/bizenum/TopicAuthorityEnum.java | 14 +- .../bizenum/gateway/GatewayConfigKeyEnum.java | 5 +- .../common/bizenum/ha/HaRelationTypeEnum.java | 27 + .../common/bizenum/ha/HaResTypeEnum.java | 25 + .../common/bizenum/ha/HaStatusEnum.java | 75 ++ .../bizenum/ha/job/HaJobActionEnum.java | 44 ++ .../bizenum/ha/job/HaJobStatusEnum.java | 75 ++ .../common/constant/ConfigConstant.java | 2 + .../common/constant/KafkaConstant.java | 26 + .../manager/common/constant/MsgConstant.java | 96 +++ .../manager/common/entity/BaseResult.java | 28 + .../kafka/manager/common/entity/Result.java | 165 ++-- .../manager/common/entity/ResultStatus.java | 6 + .../common/entity/ao/ClusterDetailDTO.java | 141 +--- .../common/entity/ao/RdTopicBasic.java | 78 +- .../common/entity/ao/ha/HaSwitchTopic.java | 54 ++ .../common/entity/ao/ha/job/HaJobDetail.java | 28 + .../common/entity/ao/ha/job/HaJobLog.java | 16 + .../common/entity/ao/ha/job/HaJobState.java | 70 ++ .../entity/ao/ha/job/HaSubJobExtendData.java | 12 + .../common/entity/ao/topic/TopicBasicDTO.java | 132 +--- .../common/entity/ao/topic/TopicOverview.java | 108 +-- .../entity/dto/ha/ASSwitchJobActionDTO.java | 26 + .../common/entity/dto/ha/ASSwitchJobDTO.java | 31 + .../dto/op/topic/HaTopicRelationDTO.java | 51 ++ .../entity/dto/rd/AppRelateTopicsDTO.java | 24 + .../common/entity/dto/rd/ClusterDTO.java | 79 +- .../common/entity/pagination/Pagination.java | 24 + .../entity/pagination/PaginationData.java | 17 + .../manager/common/entity/pojo/BaseDO.java | 30 + .../common/entity/pojo/LogicalClusterDO.java | 101 +-- .../manager/common/entity/pojo/RegionDO.java | 107 +-- .../manager/common/entity/pojo/TopicDO.java | 62 +- .../common/entity/pojo/ha/HaASRelationDO.java | 69 ++ .../entity/pojo/ha/HaASSwitchJobDO.java | 42 + .../entity/pojo/ha/HaASSwitchSubJobDO.java | 67 ++ .../common/entity/pojo/ha/JobLogDO.java | 50 ++ .../entity/vo/common/TopicOverviewVO.java | 108 +-- .../common/entity/vo/ha/HaClusterTopicVO.java | 34 + .../common/entity/vo/ha/HaClusterVO.java | 48 ++ .../entity/vo/ha/job/HaJobDetailVO.java | 37 + .../common/entity/vo/ha/job/HaJobStateVO.java | 46 ++ .../topic/HaClusterTopicHaStatusVO.java | 26 + .../entity/vo/normal/topic/TopicBasicVO.java | 124 +-- .../entity/vo/normal/topic/TopicHaVO.java | 26 + .../common/entity/vo/rd/RdTopicBasicVO.java | 78 +- .../entity/vo/rd/app/AppRelateTopicsVO.java | 30 + .../entity/vo/rd/cluster/ClusterDetailVO.java | 48 +- .../common/entity/vo/rd/job/JobLogVO.java | 30 + .../common/entity/vo/rd/job/JobMulLogVO.java | 31 + .../manager/common/utils/ConvertUtil.java | 404 ++++++++++ .../kafka/manager/common/utils/CopyUtils.java | 1 + .../manager/common/utils/FutureUtil.java | 8 + .../manager/common/zookeeper/ZkPathUtil.java | 2 + kafka-manager-console/package.json | 4 +- .../src/component/x-form-wrapper/index.tsx | 5 +- .../src/component/x-form/index.less | 35 +- .../src/component/x-form/index.tsx | 14 +- .../admin/cluster-detail/cluster-overview.tsx | 33 +- .../admin/cluster-detail/cluster-topic.tsx | 44 +- .../admin/cluster-detail/logical-cluster.tsx | 2 + .../container/admin/cluster-list/index.less | 381 ++++++++++ .../container/admin/cluster-list/index.tsx | 268 ++++++- .../src/container/admin/config.tsx | 90 ++- .../src/container/cluster/my-cluster.tsx | 6 +- .../src/container/header/index.tsx | 2 +- .../container/modal/admin/SwitchTaskLog.tsx | 300 ++++++++ .../container/modal/admin/TopicHaRelation.tsx | 351 +++++++++ .../container/modal/admin/TopicHaSwitch.tsx | 718 ++++++++++++++++++ .../src/container/modal/topic.tsx | 70 +- .../src/container/search-filter.tsx | 2 +- .../container/topic/topic-detail/index.tsx | 15 +- kafka-manager-console/src/lib/api.ts | 75 +- kafka-manager-console/src/lib/fetch.ts | 11 +- .../src/routers/page/index.less | 21 +- kafka-manager-console/src/routers/router.tsx | 89 ++- kafka-manager-console/src/store/admin.ts | 52 +- kafka-manager-console/src/store/app.ts | 33 +- kafka-manager-console/src/store/topic.ts | 1 + kafka-manager-console/src/types/base-type.ts | 8 + kafka-manager-console/webpack.config.js | 4 +- .../service/biz/ha/HaASRelationManager.java | 32 + .../manager/service/biz/ha/HaAppManager.java | 16 + .../service/biz/ha/HaClusterManager.java | 19 + .../service/biz/ha/HaTopicManager.java | 44 ++ .../biz/ha/impl/HaASRelationManagerImpl.java | 140 ++++ .../service/biz/ha/impl/HaAppManagerImpl.java | 94 +++ .../biz/ha/impl/HaClusterManagerImpl.java | 169 +++++ .../biz/ha/impl/HaTopicManagerImpl.java | 559 ++++++++++++++ .../service/biz/job/HaASSwitchJobManager.java | 41 + .../job/impl/HaASSwitchJobManagerImpl.java | 452 +++++++++++ .../service/service/ClusterService.java | 2 +- .../service/service/JobLogService.java | 15 + .../service/service/TopicManagerService.java | 15 +- .../manager/service/service/TopicService.java | 1 + .../service/service/ZookeeperService.java | 9 + .../service/service/gateway/AppService.java | 7 + .../service/gateway/AuthorityService.java | 2 + .../service/gateway/impl/AppServiceImpl.java | 58 +- .../gateway/impl/AuthorityServiceImpl.java | 13 +- .../service/ha/HaASRelationService.java | 61 ++ .../service/ha/HaASSwitchJobService.java | 57 ++ .../service/service/ha/HaClusterService.java | 45 ++ .../service/ha/HaKafkaUserService.java | 23 + .../service/service/ha/HaTopicService.java | 43 ++ .../ha/impl/HaASRelationServiceImpl.java | 199 +++++ .../ha/impl/HaASSwitchJobServiceImpl.java | 190 +++++ .../service/ha/impl/HaClusterServiceImpl.java | 389 ++++++++++ .../ha/impl/HaKafkaUserServiceImpl.java | 42 + .../service/ha/impl/HaTopicServiceImpl.java | 469 ++++++++++++ .../service/impl/AdminServiceImpl.java | 49 +- .../service/impl/ClusterServiceImpl.java | 46 +- .../service/impl/JobLogServiceImpl.java | 42 + .../impl/LogicalClusterServiceImpl.java | 10 +- .../service/impl/TopicManagerServiceImpl.java | 98 ++- .../service/impl/TopicServiceImpl.java | 34 +- .../service/impl/ZookeeperServiceImpl.java | 40 + .../manager/service/utils/ConfigUtils.java | 3 + .../service/utils/HaClusterCommands.java | 112 +++ .../service/utils/HaKafkaUserCommands.java | 93 +++ .../service/utils/HaTopicCommands.java | 136 ++++ .../manager/service/utils/TopicCommands.java | 35 +- .../service/service/ClusterServiceTest.java | 14 +- .../service/service/TopicServiceTest.java | 2 +- kafka-manager-dao/pom.xml | 4 +- .../manager/dao/gateway/AuthorityDao.java | 1 + .../dao/gateway/impl/AuthorityDaoImpl.java | 22 + .../kafka/manager/dao/ha/HaASRelationDao.java | 12 + .../manager/dao/ha/HaASSwitchJobDao.java | 17 + .../manager/dao/ha/HaASSwitchSubJobDao.java | 12 + .../kafka/manager/dao/ha/JobLogDao.java | 12 + .../manager/dao/impl/ClusterDaoImpl.java | 6 +- .../src/main/resources/mapper/ClusterDao.xml | 8 + .../mapper/HaActiveStandbyRelationDao.xml | 18 + .../mapper/HaActiveStandbySwitchJobDao.xml | 32 + .../mapper/HaActiveStandbySwitchSubJobDao.xml | 19 + .../src/main/resources/mapper/JobLogDao.xml | 15 + .../src/main/resources/mapper/RegionDao.xml | 5 +- .../common/handle/OrderHandleQuotaDTO.java | 4 + .../bpm/order/impl/ApplyAuthorityOrder.java | 107 ++- .../bpm/order/impl/ApplyPartitionOrder.java | 70 +- .../bpm/order/impl/ApplyQuotaOrder.java | 129 +++- .../bpm/order/impl/DeleteTopicOrder.java | 47 +- .../order/impl/ThirdPartDeleteTopicOrder.java | 1 + .../kcm/component/agent/AbstractAgent.java | 6 +- .../manager/kcm/component/agent/n9e/N9e.java | 8 +- .../kcm/impl/ClusterTaskServiceImpl.java | 26 +- .../manager/kcm/ClusterTaskServiceTest.java | 26 +- .../task/dispatch/op/HaFlushASSwitchJob.java | 41 + kafka-manager-web/pom.xml | 6 + .../normal/NormalAppController.java | 13 + .../normal/NormalClusterController.java | 11 + .../normal/NormalTopicMineController.java | 42 +- .../versionone/op/OpClusterController.java | 16 +- .../op/OpHaASSwitchJobController.java | 87 +++ .../op/OpHaRelationsController.java | 130 ++++ .../versionone/op/OpHaTopicController.java | 43 ++ .../api/versionone/op/OpQuotaController.java | 21 + .../api/versionone/op/OpTopicController.java | 82 +- .../api/versionone/rd/RdAppController.java | 14 + .../versionone/rd/RdClusterController.java | 21 +- .../versionone/rd/RdHaClusterController.java | 55 ++ .../manager/web/config/DataSourceConfig.java | 32 +- .../web/converters/ClusterModelConverter.java | 3 +- .../web/converters/TopicModelConverter.java | 1 + .../handler/CustomGlobalExceptionHandler.java | 47 ++ .../src/main/resources/application.yml | 7 +- pom.xml | 21 +- 178 files changed, 9938 insertions(+), 1674 deletions(-) create mode 100644 docs/didi/Kafka主备切换流程简介.md create mode 100644 docs/didi/assets/Kafka主备切换流程.png create mode 100644 docs/didi/assets/Kafka基于网关的生产消费流程.png create mode 100644 docs/didi/drawio/Kafka主备切换流程.drawio create mode 100644 docs/didi/drawio/Kafka基于网关的生产消费流程.drawio create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/JobLogBizTypEnum.java rename kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/common/bizenum/ClusterTaskActionEnum.java => kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskActionEnum.java (74%) create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaRelationTypeEnum.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaResTypeEnum.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaStatusEnum.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobActionEnum.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobStatusEnum.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/MsgConstant.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/BaseResult.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/HaSwitchTopic.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobDetail.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobLog.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobState.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaSubJobExtendData.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobActionDTO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobDTO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/op/topic/HaTopicRelationDTO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/AppRelateTopicsDTO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/Pagination.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/PaginationData.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/BaseDO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASRelationDO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchJobDO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchSubJobDO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/JobLogDO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterTopicVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobDetailVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobStateVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/HaClusterTopicHaStatusVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicHaVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/app/AppRelateTopicsVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobLogVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobMulLogVO.java create mode 100644 kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/ConvertUtil.java create mode 100644 kafka-manager-console/src/container/modal/admin/SwitchTaskLog.tsx create mode 100644 kafka-manager-console/src/container/modal/admin/TopicHaRelation.tsx create mode 100644 kafka-manager-console/src/container/modal/admin/TopicHaSwitch.tsx create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaASRelationManager.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaAppManager.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaClusterManager.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaTopicManager.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaASRelationManagerImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaAppManagerImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaClusterManagerImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaTopicManagerImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/HaASSwitchJobManager.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/impl/HaASSwitchJobManagerImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/JobLogService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASRelationService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASSwitchJobService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaClusterService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaKafkaUserService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaTopicService.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASRelationServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASSwitchJobServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaClusterServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaKafkaUserServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaTopicServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/JobLogServiceImpl.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaClusterCommands.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaKafkaUserCommands.java create mode 100644 kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaTopicCommands.java create mode 100644 kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASRelationDao.java create mode 100644 kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchJobDao.java create mode 100644 kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchSubJobDao.java create mode 100644 kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/JobLogDao.java create mode 100644 kafka-manager-dao/src/main/resources/mapper/HaActiveStandbyRelationDao.xml create mode 100644 kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchJobDao.xml create mode 100644 kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchSubJobDao.xml create mode 100644 kafka-manager-dao/src/main/resources/mapper/JobLogDao.xml create mode 100644 kafka-manager-task/src/main/java/com/xiaojukeji/kafka/manager/task/dispatch/op/HaFlushASSwitchJob.java create mode 100644 kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaASSwitchJobController.java create mode 100644 kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaRelationsController.java create mode 100644 kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaTopicController.java create mode 100644 kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdHaClusterController.java create mode 100644 kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/handler/CustomGlobalExceptionHandler.java diff --git a/README.md b/README.md index f685b311..8f874c9b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ --- -![KnowStreaing](https://user-images.githubusercontent.com/71620349/183546097-71451983-d00e-4ad4-afb0-43fb597c69a9.png) -**一站式`Apache Kafka`管控平台** +![logikm_logo](https://user-images.githubusercontent.com/71620349/125024570-9e07a100-e0b3-11eb-8ebc-22e73e056771.png) -`LogiKM开源至今备受关注,考虑到开源项目应该更贴合Apache Kafka未来发展方向,经项目组慎重考虑,我们将其品牌升级成Know Streaming,新的大版本更新马上就绪,感谢大家一如既往的支持!也欢迎Kafka爱好者一起共建社区` +**一站式`Apache Kafka`集群指标监控与运维管控平台** -阅读本README文档,您可以了解到滴滴Know Streaming的用户群体、产品定位等信息,并通过体验地址,快速体验Kafka集群指标监控与运维管控的全流程。 +`LogiKM开源至今备受关注,考虑到开源项目应该更贴合Apache Kafka未来发展方向,经项目组慎重考虑,预计22年下半年将其品牌升级成Know Streaming,届时项目名称和Logo也将统一更新,感谢大家一如既往的支持,敬请期待!` + +阅读本README文档,您可以了解到滴滴Logi-KafkaManager的用户群体、产品定位等信息,并通过体验地址,快速体验Kafka集群指标监控与运维管控的全流程。 ## 1 产品简介 -滴滴Know Streaming脱胎于滴滴内部多年的Kafka运营实践经验,是面向Kafka用户、Kafka运维人员打造的共享多租户Kafka云平台。专注于Kafka运维管控、监控告警、资源治理等核心场景,经历过大规模集群、海量大数据的考验。内部满意度高达90%的同时,还与多家知名企业达成商业化合作。 +滴滴Logi-KafkaManager脱胎于滴滴内部多年的Kafka运营实践经验,是面向Kafka用户、Kafka运维人员打造的共享多租户Kafka云平台。专注于Kafka运维管控、监控告警、资源治理等核心场景,经历过大规模集群、海量大数据的考验。内部满意度高达90%的同时,还与多家知名企业达成商业化合作。 ### 1.1 快速体验地址 -- 体验地址(新的体验地址马上就来) http://117.51.150.133:8080 账号密码 admin/admin +- 体验地址 http://117.51.150.133:8080 账号密码 admin/admin ### 1.2 体验地图 相比较于同类产品的用户视角单一(大多为管理员视角),滴滴Logi-KafkaManager建立了基于分角色、多场景视角的体验地图。分别是:**用户体验地图、运维体验地图、运营体验地图** @@ -44,7 +45,7 @@ - 高 效 的 问 题 定 位  :监控多项核心指标,统计不同分位数据,提供种类丰富的指标监控报表,帮助用户、运维人员快速高效定位问题 - 便 捷 的 集 群 运 维  :按照Region定义集群资源划分单位,将逻辑集群根据保障等级划分。在方便资源隔离、提高扩展能力的同时,实现对服务端的强管控 - 专 业 的 资 源 治 理  :基于滴滴内部多年运营实践,沉淀资源治理方法,建立健康分体系。针对Topic分区热点、分区不足等高频常见问题,实现资源治理专家化 -- 友 好 的 运 维 生 态  :与Prometheus、Grafana、滴滴夜莺监控告警系统打通,集成指标分析、监控告警、集群部署、集群升级等能力。形成运维生态,凝练专家服务,使运维更高效 +- 友 好 的 运 维 生 态  :与滴滴夜莺监控告警系统打通,集成监控告警、集群部署、集群升级等能力。形成运维生态,凝练专家服务,使运维更高效 ### 1.4 滴滴Logi-KafkaManager架构图 @@ -54,29 +55,29 @@ ## 2 相关文档 ### 2.1 产品文档 -- [滴滴Know Streaming 安装手册](docs/install_guide/install_guide_cn.md) -- [滴滴Know Streaming 接入集群](docs/user_guide/add_cluster/add_cluster.md) -- [滴滴Know Streaming 用户使用手册](docs/user_guide/user_guide_cn.md) -- [滴滴Know Streaming FAQ](docs/user_guide/faq.md) +- [滴滴LogiKM 安装手册](docs/install_guide/install_guide_cn.md) +- [滴滴LogiKM 接入集群](docs/user_guide/add_cluster/add_cluster.md) +- [滴滴LogiKM 用户使用手册](docs/user_guide/user_guide_cn.md) +- [滴滴LogiKM FAQ](docs/user_guide/faq.md) ### 2.2 社区文章 - [滴滴云官网产品介绍](https://www.didiyun.com/production/logi-KafkaManager.html) - [7年沉淀之作--滴滴Logi日志服务套件](https://mp.weixin.qq.com/s/-KQp-Qo3WKEOc9wIR2iFnw) -- [滴滴Know Streaming 一站式Kafka管控平台](https://mp.weixin.qq.com/s/9qSZIkqCnU6u9nLMvOOjIQ) -- [滴滴Know Streaming 开源之路](https://xie.infoq.cn/article/0223091a99e697412073c0d64) -- [滴滴Know Streaming 系列视频教程](https://space.bilibili.com/442531657/channel/seriesdetail?sid=571649) +- [滴滴LogiKM 一站式Kafka监控与管控平台](https://mp.weixin.qq.com/s/9qSZIkqCnU6u9nLMvOOjIQ) +- [滴滴LogiKM 开源之路](https://xie.infoq.cn/article/0223091a99e697412073c0d64) +- [滴滴LogiKM 系列视频教程](https://space.bilibili.com/442531657/channel/seriesdetail?sid=571649) - [kafka最强最全知识图谱](https://www.szzdzhp.com/kafka/) -- [滴滴Know Streaming新用户入门系列文章专栏 --石臻臻](https://www.szzdzhp.com/categories/LogIKM/) -- [kafka实践(十五):滴滴开源Kafka管控平台 Know Streaming研究--A叶子叶来](https://blog.csdn.net/yezonggang/article/details/113106244) -- [基于云原生应用管理平台Rainbond安装 滴滴Know Streaming](https://www.rainbond.com/docs/opensource-app/logikm/?channel=logikm) +- [滴滴LogiKM新用户入门系列文章专栏 --石臻臻](https://www.szzdzhp.com/categories/LogIKM/) +- [kafka实践(十五):滴滴开源Kafka管控平台 LogiKM研究--A叶子叶来](https://blog.csdn.net/yezonggang/article/details/113106244) +- [基于云原生应用管理平台Rainbond安装 滴滴LogiKM](https://www.rainbond.com/docs/opensource-app/logikm/?channel=logikm) -## 3 Know Streaming开源用户交流群 +## 3 滴滴Logi开源用户交流群 ![image](https://user-images.githubusercontent.com/5287750/111266722-e531d800-8665-11eb-9242-3484da5a3099.png) 想跟各个大佬交流Kafka Es 等中间件/大数据相关技术请 加微信进群。 -微信加群:添加mike_zhangliangPenceXie的微信号备注Know Streaming加群或关注公众号 云原生可观测性 回复 "Know Streaming加群" +微信加群:添加mike_zhangliangdanke-x的微信号备注Logi加群或关注公众号 云原生可观测性 回复 "Logi加群" ## 4 知识星球 @@ -113,9 +114,4 @@ PS:提问请尽量把问题一次性描述清楚,并告知环境信息情况 ## 6 协议 -`Know Streaming`基于`Apache-2.0`协议进行分发和使用,更多信息参见[协议文件](./LICENSE) - -## 7 Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=didi/KnowStreaming&type=Date)](https://star-history.com/#didi/KnowStreaming&Date) - +`LogiKM`基于`Apache-2.0`协议进行分发和使用,更多信息参见[协议文件](./LICENSE) diff --git a/docs/didi/Kafka主备切换流程简介.md b/docs/didi/Kafka主备切换流程简介.md new file mode 100644 index 00000000..279ae242 --- /dev/null +++ b/docs/didi/Kafka主备切换流程简介.md @@ -0,0 +1,97 @@ + +--- + +![kafka-manager-logo](../assets/images/common/logo_name.png) + +**一站式`Apache Kafka`集群指标监控与运维管控平台** + +--- + +# Kafka主备切换流程简介 + +## 1、客户端读写流程 + +在介绍Kafka主备切换流程之前,我们先来了解一下客户端通过我们自研的网关的大致读写流程。 + +![基于网关的生产消费流程](./assets/Kafka基于网关的生产消费流程.png) + + +如上图所示,客户端读写流程大致为: +1. 客户端:向网关请求Topic元信息; +2. 网关:发现客户端使用的KafkaUser是A集群的KafkaUser,因此将Topic元信息请求转发到A集群; +3. A集群:收到网关的Topic元信息,处理并返回给网关; +4. 网关:将集群A返回的结果,返回给客户端; +5. 客户端:从Topic元信息中,获取到Topic实际位于集群A,然后客户端会连接集群A进行生产消费; + +**备注:客户端为Kafka原生客户端,无任何定制。** + +--- + +## 2、主备切换流程 + +介绍完基于网关的客户端读写流程之后,我们再来看一下主备高可用版的Kafka,需要如何进行主备切换。 + +### 2.1、大体流程 + +![Kafka主备切换流程](./assets/Kafka主备切换流程.png) + +图有点多,总结起来就是: +1. 先阻止客户端数据的读写; +2. 等待主备数据同步完成; +3. 调整主备集群数据同步方向; +4. 调整配置,引导客户端到备集群进行读写; + + +### 2.2、详细操作 + +看完大体流程,我们再来看一下实际操作的命令。 + +```bash +1. 阻止用户生产和消费 +bin/kafka-configs.sh --zookeeper ${主集群A的ZK地址} --entity-type users --entity-name ${客户端使用的kafkaUser} --add-config didi.ha.active.cluster=None --alter + + +2. 等待FetcherLag 和 Offset 同步 +无需操作,仅需检查主备Topic的Offset是否一致了。 + + +3. 取消备集群B向主集群A进行同步数据的配置 +bin/kafka-configs.sh --zookeeper ${备集群B的ZK地址} --entity-type ha-topics --entity-name ${Topic名称} --delete-config didi.ha.remote.cluster --alter + + +4. 增加主集群A向备集群B进行同步数据的配置 +bin/kafka-configs.sh --zookeeper ${主集群A的ZK地址} --entity-type ha-topics --entity-name ${Topic名称} --add-config didi.ha.remote.cluster=${备集群B的集群ID} --alter + + +5. 修改主集群A,备集群B,网关中,kafkaUser对应的集群,从而引导请求走向备集群 +bin/kafka-configs.sh --zookeeper ${主集群A的ZK地址} --entity-type users --entity-name ${客户端使用的kafkaUser} --add-config didi.ha.active.cluster=${备集群B的集群ID} --alter + +bin/kafka-configs.sh --zookeeper ${备集群B的ZK地址} --entity-type users --entity-name ${客户端使用的kafkaUser} --add-config didi.ha.active.cluster=${备集群B的集群ID} --alter + +bin/kafka-configs.sh --zookeeper ${网关的ZK地址} --entity-type users --entity-name ${客户端使用的kafkaUser} --add-config didi.ha.active.cluster=${备集群B的集群ID} --alter +``` + +--- + +## 3、FAQ + +**问题一:使用中,有没有什么需要注意的地方?** + +1. 主备切换是按照KafkaUser维度进行切换的,因此建议**不同服务之间,使用不同的KafkaUser**。这不仅有助于主备切换,也有助于做权限管控等。 +2. 在建立主备关系的过程中,如果主Topic的数据量比较大,建议逐步建立主备关系,避免一次性建立太多主备关系的Topic导致主集群需要被同步大量数据从而出现压力。 +  + +**问题二:消费客户端如果重启之后,会不会导致变成从最旧或者最新的数据开始消费?** + +不会。主备集群,会相互同步__consumer_offsets这个Topic的数据,因此客户端在主集群的消费进度信息,也会被同步到备集群,客户端在备集群进行消费时,也会从上次提交在主集群Topic的位置开始消费。 +  + +**问题三:如果是类似Flink任务,是自己维护消费进度的程序,在主备切换之后,会不会存在数据丢失或者重复消费的情况?** + +如果Flink自己管理好了消费进度,那么就不会。因为主备集群之间的数据同步就和一个集群内的副本同步一样,备集群会将主集群Topic中的Offset信息等都同步过来,因此不会。 +  + +**问题四:可否做到不重启客户端?** + +即将开发完成的高可用版Kafka二期将具备该能力,敬请期待。 +  \ No newline at end of file diff --git a/docs/didi/assets/Kafka主备切换流程.png b/docs/didi/assets/Kafka主备切换流程.png new file mode 100644 index 0000000000000000000000000000000000000000..199b72f44ca994c18ded9f9cb4c580d452d9cd5b GIT binary patch literal 260140 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
    iGP`_Uu0h{QCxl~fvu0mg>#)k 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А$gOMO{USd+sduyk$uS4~O@?=M2r3 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

    U%mUOk#xC)B$I;<(2C|tvRmd zT~vYg4*5r$fe7iXMai2DyS(yjDUX(N)}2QNvxY`3hPHj;O*;w#MTcsV@GR$wH$n_t zZN^?pRO)z2AqR7Fw-oX$FGNeA#)MzTs4{I7hLd(6dZSL;@J7Q(`>v9rAi8=Sl=~V{ zQic!vIA>IFia%65a4+;aF++v&iVc3Q^xIHwli>!&%{$%279*=^n^GI@w@)3O?u(sv zofLSf+rS&KH0o>A5-hgDXQ3>koSOS!r9p~X=Y~^Pw9wAap}c1exz+L{x%$+}YLd%5 z*;@qIPfa_r1r!!Ka!vcvoqviZx-5%|`xNgbHt4-h?$fPNd{U&F1x%fxO~QJMmU!ER zlry3O<&$$CYHr`9`1I=LO%F2mBzksuC!8$@93cWO~x(gXvrT z{iUHO%eId3K4><2Ay={0>iy%QLMf6>913sJh+Kna@UmH9OOggqd zZsd2dQnSfT2UHl1YsO#`R;FLgw+i6SKABJAg|5Eta*}hL`p=tY%!is^NaWi;d%*i} zQ1}Pm1Hrp5R8a0!w$gch`WZIX7tctEDS1TdKK0iNyKxt&g`{Rh9!S)do9F&C@VtEV@xge) zJujiiKK(jz>o){BqTWx%Y{{;Q?oZJztJ|OQ(r7tY__UV~Nh2;UwOSL1t*WMdd9k_j& zpI<-VJgT)OxPDkipm(z-&3m-wncyb1lt7sYQy>19%^`Mqgs6V}G6B_1F#;NqM0-sU zcD>5Tk*$lIz=hJQ_YT{pIhQd`t@D0$d|3*mnD!>VXvshUy@T<+^0{neA{*5|3mj&R z!3_KI`PKApNmR*at<4SF3fm*2u9)kLM~dMkEyhezlU>=ub@b?RWP`obK0mTo_L* zw-`~ejAMEvpncU8$4fJ(&a>Wdk@Kbc3Epes5;>FLnUSN>9ra9E@4QKzgJ=g9MPK1B zFQkY1oK{-Q_SVA4=|8+2y`S8;L@uAocDm9+MS)vIbM>N|80w-Nt+5r_9VEop{`AMc zrXhSaWfiO7DbjD}3eZTzP?HmsSHP8)ipO7-3F|_%TXO3vWEp*R+3cMc`A{WCt-Usy zuAX=I(MY+=X;O}8nW{E+{n8ua$iceqTQ4f_lxeFyST!`I5Xw-9UrrbHlBTDp7iUj1 z(2b!sv_h?hT)(tluaKhAliMq_T@S3WG+^*Wjqnw7dG$O!$@;*$XZRf_hF*AXZGt`X zv6{K%aPdSK`xJ*T`#nv&)mDeV*$WfI+Nf1B>c$%T&VH^7N2PA$&htXasO`hl5#HmD z_vTQoQ8~P*izXjLWGK2&ngn-Z_$>r5-k194JEs5fj@^hfIKd>#V*gS1!e-=02A=Pn zTFkLCpPs!@FSKV31z8aZ@A)@MZT1yKSw{Q5=M^NGBtMBPy=y+BUTd*XjK|s?r72s| z^SNQ}@rRR(JYG#!+yUixT`7A8k&JA{?mnI8UZ~Y)n%MK7%Xe5CQfz&tKuto%`U3A> z=CvaswY*Q~7j_z52C>s!Vyv&%RlVm9IQU*Ds$hnnl^sHSCopRCPJMDhzH#?il*mjp zJ!-mrk^=47uuRKF=_C}%5Q^=2COG58`ljQ_8^2uJ*gss`{$u>wM-=cu5#x)#BnSW8^4Cj{ct`#fSy|8aAR>w3ML+uUOdLRz^z4|r^rtpNQj|iJ zePfs5SW?{cjho1^kV{viMC)Ci3^r*3AA{j^H% zrX?@6*TL$5v7qBIx2fzCZ4)^Y@?jwXub;7Fai2tUax2{rSUM24#L_2eXgv~c=spxU z(F)CZiqlsBL%O2oC8}y2wQ~in1Lf$y^7C3UIMo5dx4WE(Rc7RLbD90CC+JuWdG+-U zjxR8O>Uu_#joh<&oH8llD_>QN>G*JWlkx3|F_Ya=;k)fInG%01l@~Z5ZW?E>TJeV9 zhuY^=0ZkJ9y^s%!8~?k|sRMzTF?t`OOG>OS*oo-ajPB16xNr`}Y%@EebPT+}G^aoX z&ttgr>G#%GnM{Ju`z>KApl^WqGX(zRSQt(3KG>%DTQo#9f`k;Y<<$I*ix3K*72Eb@ zM&BU#&k%UA_Ff0#rlm3aBN%`hg$v90bP$1FyYGH+uo;WyBMtHLl4x;dGN^f}mc43D zW7Ubl6)Dy0Cs+YFLmFdU$0+JTR1Ul!(Z@1+pEZ(|rN=#9oGBGRStku2w6Yi|ox=q_ zRvInxrr#+-k%Bdni*l#(Ixosi9F?MJX+eqm2s3k$1q4g zOU+LGvh5yxs`Bg}c%nc(X@)2v{55D$1O4@EZ->z6 zo@M4d0hTa&g%OhsVB<(CJPvFE<&-4v(=9>oLw?YG#6s^p2~a*gy3GUSE%jQ5+2P!J z?<05Us!zH+RvQPpSt&T{8QmL5U?l{}!;%9>JU7q)3`H&A(6Uz!>ZJjpl{yLAhCP<$ ziNQo%jo5*`Vi$Ml!%K5LG49l8xbDOhG0&`TW#r zB`j$EbqiEG%w%UHLAIk0QfM}4s-wrRg?K&mhps!p;7}_xoaUzXP>uUnG}ySm`i?*f zRR;nz?uT}EB|WupTtbCOC()cbNC+fA)0hVWK~+GqW)I-7h3AhZyoUFj%S<}wL5uPv z{VOQ;;4lG2!1i;Xt$x>jk{&ej?U#iW1B{d`)&O*CS}5-q$pV~e0NSAimn-i2Xw^I% zt%?9Kq8J>U0d3#8anqGWkJR@;3nb{Z+Js$pW)7r!xm*pqepNPKe~?@^GYoQj{8%3= z8ktFM*&m4sO)1jmdv^er{PFRm6I9p}9#y3b{|FL^5jaPNU$bNg(9*O*y*G*D?u#6A zOK?QA7|04pzBCaTqIFZJ;WR19cfB1Ny1)=gFxYnFG7BQbJ_5CY(JDw z6R!2_O`<{Td_h{i_js=0LZ9GN0Z|_b2r=l^MpzV|(s2hM1%SWO1E*GwtQTfv(7^!{ zdKE~1eeA%~x8B}DLlnb&8p*=};kfW;f@ zMhNQi{uFX@PQ6+O=A-p)t_nb$V<=PWn`47o`#@j6smY!Jo@Rb$%MM`4^frvgx* zsk>UPVhjaCWDi2Wn9&$jt^^&D#Xy5ic&&LlXco6Sd&1{L&()6QT=8{~+{YrJ-xBfE z#{2+dC;n6+FMHJC;bC0}rkDR?OW28@-r&7+8+fH?=dgutaRLC!PK6;80tcWMtbQyU z3x>C}p8)fWGdLDd@Kx6N(%|)GgEt;pDRd@AL({NMuAl4@{6gOriyLztX1hWTCzn~- zDI#fZUJjI@+^y3p|5wLm=$0e(Z`b%}f(#g4Z*7nkrH4|ZH z^y9~;rnko;O>q=r@<*(aGp3c;CBT>%b-Xk^?Zng*Z+c$08Pkk>>)e6po5t~4>*T5n zY_&fZF=Os2i0?Q#!pN>%bL+BgmR98hQnY+pH6TH&WNGpt;rAx-OAmVyKchx}ekl*C zxzE9gC_!ghR?^h}g;hNUtp5~-4I$2f;SqEZd|}x{exhoScbodlNK+-x_Rp_ZFfyRk zLW_=6vrU{mi3(_pUVT`&zxQ^Ku58;z>2s?zyF`r*F1g68-^)4{nbT@yjL&%_G(Dl( z6$_OvuFQaw)kONf-AVUL?3}Kz&eLX5LX!;{qK#G^Ki5%wJo4ie5YTqV)Oo(fx4k0& zOt8|<7}?B#QZ6*S#~xl&!;Y5?;_o*#++!Co8qoV=vi)CM_7U3KHTIwZ#+@-(AFfg$ z^>!r!PmWgdj|Jz*fOvEEiBr+@DgMAfpBK=P_B9FZZ1}lxnn5^{(cyT0XaD2>=6YwZ9CVtTe+EF^>VL(pO;kx&T|yv!m`EW6KP$|?4s@|0Etv9L)B-Z0KoG98>sM2wtfL%GA@ z2Lkp%VVw?l?lx$at!`5!GQ#2wQ$^($U|Ng4)xd=kNHS=2euZO>gevG{saPCyMdR}3 zdorv#B^c0gmhfY-w?tTeetzFRYma{?+BPC={zB_%q$Ejh>o|o`27ySZQz1L5X65NM zk9}oBc_ErDQ)x{ zEMW;c1Io~;q_p>8`dFH%`RinDr_rH0`j1`2E$G^+e@rWw+=f(ot+8kfKKnht?|c7|IiB(49oK!|*E0-Mx+jZ`c>@y(2?-l2C#8&p1d2jJ zLjG_S4QQcF!JY;FAUi0_N+9KTQ7j-KffF31;f_|W#%7jANc5bNKY!74N&~+Q&Gby{ z^{nVQAZCX2oKo~09Qvku3QiCC`R}t!xJx~7G%!|%+XBr%4{JRuqn~}0^h}Ic*%7Vh zlUTXR+UwbxD%cnrSpxkGU4Qmshj4KJ9BJtGvxS%MqQS)ea=G(aI3S1y6*KpX`Pm^q z$C)^p85%iU%=WX%(Z)DlQ@{_8QQq~9Dm+) zE;xG|8(^gLxlSaFED^%Hl*`X?GJogoZ~M15vc6dR9}l?Pn6sXx)5YQB^^7g_=%xAT z`6cN2c<7~h=_RD;#kp8n#ebf`!O`tf4)!)q)`mt1*+J+zC0tC+9F0_L^$ZXbxd566 z+Dsj-EP*F>;HRFxgN>z=qmj71!G%W8TYrDz0y;T3+S^zdT?!S^0kBR>NgGQW`*R_2 z8WTTz+NOQ_4JJ_m24c$9L;R3fl&s4ER2BR63#~Uj%I)sVSno2 zXoJ|Qo~4<|-|Q}a@j~Cm(b2{V=y|E+--Uc3cfK zfYgm_0V$oo`R`S+p`H<+u>m0GpT_Wuig^t9jP#BFO&Z|*qXxMDh6XOR@~Z|e7587# z0OwycaPD=i5(w+Jcldun3&zGqJO+PL3x>S>`VfS}|J`-Z~Ya}09dhigI}QA`GY#}0J#H1H%^K3XDNiYBP8|n z3E}JKPp)Q;zl{Ta{~X49J`8yLeH7y1a?sxuwu6n6{W;_UJve!Oc5~FTH!->h0GAMt zI2*75pu;&@{S}(*jV$#X&76Pj!~ywRW_~_Y+}>Uf0ce*UY;DY}9nUTIVrC^o!|#a4 zeFL+bf9wum+|Lc1FLsW2vX@Z!d(%GvLGDY){yp$<&`WdD^FiqO z*w2HW6g@xigPR`6O(Zzaf5@D-@Y3^1(@Q|;rFrQ2`RK)E#Qz#A5##yA>G`idUFJdtXc6vTwi+uDFQuN}I zzibHDBG3r=Tl@Gknj>uBJo*9a{u-M9S?IGeGc-g5eF=Lb2Qzm){qqC>VagX;2Q@4pZDVZg05}xlY`;d?-?sR_XLA=>?!OE;7r1eWA?gS` zx$}=<$8~8jzZZ)C z!_}Nu@Ld1NFvR^=vgVfp_O~kMH?rlI+6Y0@{LZPIKOxdoMAttujD|+WdQRunxdT9; zI06L2IW=!!spsGT6b6XyK(h=$NCU$W-1Of~uYN6(E(@anyPVAbc9HbgaL)Zpk;IQ6 zKmh6jK{LqE^YQ-ElIS1t8r<~az|8@`SpfADFdx8T@Y0J*APOl2KO#Xd0hC_AE1(as z1%T-QXcgcUaPa_ilLq$i=PQVdT>&Ezy#YD|U_pS1xaj$TVL-7aPA~qGDLG#caRi|8 zB5eK=3;$b4kW2ghOXB@6ks!Pre8#0yu4Ap%Z%P&gg3$BX;@@pP(dAacGg~R2zORfAXS3X=9UjK`n|2NR{CnNn2 z;M3@mkot{;LclHp84+)O$J}3eY=rlz{6mWUUq;nSe&F9nJzZ4Af8h!*@{dbZUe*DZ zD*6fk7o&cs4F3k{_{WJZZ$B>Q`57|)GS9z4cV8^@ix&dC)^Bw8pK0cQ3j2TNSN>Zt z_0p>UEtty5$$N1tccImP6S4d!X6z!3xzzB#g;&1hu7C0Ff9_>}%m2?k7vb*+H~w$E z$hy3=`Za0#Pq{bGCE{Kf!ezmCsk498yDzJj%X$8ecmE$>tX&3;i|w*s5Of#!6n_C7 z?mzXv3`7_G|LZqv7Z(7(Kn=j9UqH=2$m)LQ;s1I^_D}H$$O9z+3ZMUPT&DfM0acgm z&7XOPe<}U@zXDXO?0>uhyIADXTK^TK0&MB`Pnmv3t=~TgySy^H{M797`M1x|(q#;Z zkdSDQpi<%wT(y^z&|eaLB6;=%1vHmCsq5hhTXQ$}~MWIh(%MMQ8jl6>;I426VkUXS2MJ$P38yxI$ZIi&>o$&bQ&lqzj49l5$3D$}kSpM=#8}kY3e4Q47 zYS9VfgZNX6?q54xVO~&yj>{biJ8DZqRT@suiqk74YX!xWRiX6sc^Kj>R-W2D0#Mnz zk8Wj~T(g?^axe0R922YAz2N!bN#KOxdYIQ?#)IA(E(*vhG>WK5rSlEBZd7#duZe3} zle+5kXxy~8H9u*M#`1>oNY0(%iY=Vb&Ov20;_0Wa8UnJUNjl25wB z9V60F=!s*uwcZM|$$YyCm2Mba)D$JT9$JJc*Z~O3H{HA5FkI%_p*|q;KqVRqu%Mi~ zTn~4o2xg@#A=JfRcLN)WE%~KSrf1;w*>u`2#4R)3>!#eNqWJFzbvK>9QxsY2tEX47 zEOUzNqkdMw?ckb=1*mU=s zaTIvI)uEA(?tWf)y!Zxhtz4|<{p+&<>`zMW?Q~8PyP3K>kKsM@Hw&&j@KK!DTU0uU zlCX;smCCNW3eBM}joyBfZpu=0Uwasb6BXt|11OlR>3*q5JSbC42ootvQWApxz$(&F zFV1O|2nxp+#rQ-Aih?t|l1~6*xKe;Ic=xhjhj6j^PeeM;I@biWebR#*$h9h8uX#N` z6wZ9;4t+2GQLCpP?~8t6IxcB|)IMYeCyTlyprN)lY+n=->_mw5HZ1zFO3ynyzLq!H zNeC$*s{uw^=VzgX*wSOh3B64a^p&KVsI!7W_M4YiE1nF!oz4rebZx@{50yy9-_BMh zns<<^2#=0VT$kq1QkVqMSmY`mR z4ls%AVvF+K;aiyO(KD~Fot^32Y+qD{T5i6!Jso+?cW-n_4TcQ3=%6^VAGDJ3=WlHv zxwuhQqOI2Z%<%MctY8m<|@(J~wz?Txso6Xp@BE4Os%?XJw;dH`WM+QEv ztT!Ki{QeVauEU0f&jCk_3R9|qE&wHD=1J4%Rx~o63MFr@Da@s(kx!?sSJ}*5TM6Wj zZ)H0@FEm8wQEJOyPwZ-WB*#R<61<$LO=YjAgKsZiESab^nW#8AUH`h1KS*NL6lrr* zX+-~fXNY`_v6Pp>9e+V;UO~YY&yTk%1JBRgs$9ns<;-~wczBhd@_@qA2+hP0HgxFl z^`aXx4yDUoXRKE;%d>rW^>4}h>-N}W-pryC>=-(hJfy;e1bI-DUqRc#iuzhI63UJW zOsR{GdJVji2|@g(aU!l$=zNx9RKbNG{t30qgk&fLyH{E65zde1L#?%5)y7B0fb}tE z13q{cCy4tq;DhJ80{-}HD*-ks+}!ra4CwM39N$F%Vq~L!2w*)3{ry}=m|=Ip2(_mTbDU=%wAwK@i01B^ z=a4D(8rX^Y+f9vGv?yoXI8fVtV7vSR8myUOV8k8+-y}J*$9(_<;$*vL-p7h+3t9H=K68DsQ{dq8PcZ_rDZFILD94(yn-r z|2sfLuRq&Flb@Rloh&fn%n!@bI8gjs;0-v`9dl<$m%|^eF@eAomr<(!C2Y`IrT)(rGW#&dy5PZafk#Jgc`+MtDc{8hmUdO)P)@KXh5xso5X6g@LOh8 zMrdAw`P9n__LNs`48S~_KQzWw5Q=+1L2MDlh<6P!`s{QojOV!Ax-!CScmmn;uc z!l^%w@N#RMY^N@VaoZ>chHqOnU0-A`{gb?cVuWo0(l^`l1tdW966E9CCKu5hN|ByL z^+ZgvxhfBftwQDt&-?t zAFVLy#C_@-W*XQ;z9~FR$wE`mSg3bb6ku$*_C(z36?ZS8VrhS1R^n?k+=mZmnf87} zv?MIP+fwWa%u3D=SmOS=ogKyPDCSaqv_ZfcCpu73(NZO%#r;k*M|f{oqZ+;TR^4U; zXQ*4JvCT+F5S;=-E+;PdWl3d1y7F^o7 zN#Bmn5^tMM&#j&r$XZmL zUYWR2LBqgpyb6uCl;m`jac_AthMd0W$|sZ#CA?|?ye4yf0{#u=!}XBH7IHBDdv!@bQ9kEsApB$Q7^&>!Vk* zIPX_xddk%@=M7k9)v}N3Gc#X854Q^u&QbCi+VjY^TUSSDvv! zm*`n1ouj$LdzPCX8a?c|f}WpIg1XwGb4(^h9qGMy6)*5rO;pv?eP&sLDD%V)Zl2VOfIVw4ocj=6hCIr>|ux zvLCiw`}Xvq%sY$P2!ZW&+9cJ;PKZmNt5k*}KRhJG>ix*O{@!4-wRfi|F6LcRJFDX& z+;|fQ>($ZRcAtgvbjoD7b2M}C-`A|^)9C!zsgk+2nujZ@kxkWI)|-xDz^&UPHP7>8 zE->Ih3EYm`E5F(gs?SGl)eC!Hdl-46ze3jLF=X5-ziq)C$-a&?>Qs3C`*$j)v#8#< z&dKbv+w^(aQ~-~cf?M=VvhNH&XPO3@kBn?+@ppzp1H zQVh%JTo)SX2sdS7bikXYFrsdJt@h*{j~3dGenE@naFe2D$^p}@IZ-b62Ch-I;D+#k0oUt`^=8U3U-wU~S`d8EFxeVwoQlOQN6D@pw>)?BxPQ{ln<=`i+pJASSA zHf0|k)mRn^>{28Y@hTfO>N`5f*31h%0NwlW%A@7J$jc(Y!E9m^34P-^O<>WVuAN@jz*X>RPzK;R@bk0?HG6uPxzPUG z7epH#u*gAgIyECmJW=6D(Vlz{Cp5d032Fhj(Kq+~+>K1JyFkQ>?`e#x3E_-`eIAUC z?Un0M{U@C$@jr{YJy13;zT1OHQc9>qEUFm8dl1mFy`F9}eGMGN;rw`b0T+5Ueh~fA zJTKDm0kAhp+0J20%=7>dd%dB%W80&6Cd}TqA`IP`LYij2#+`}QYZXG#pLdZQX-Tsd zVnt6l7FjBx9OmVD#X#Ukq#jYjOgStDKnf6S@pckr)7JWEzlvCeh7>r>$}S@P^94ju zl=`xCNhSkG86I%VI)>L9YHk|!x1mMFbC30FsL;eC(hn7@91BFsIsK6l_$QLB_#RJO zNAAXlu>?>XVyE%u8d_*kfz{~Oz=Sy_sqc;dl2W}g;>h;^dfqwUSTgi$>$qXFl*bqj z1Llm%{?KO>)7CVG0?g3I`Q;&J+_mlXNAoZr2_SwJDb1FB?&KgF5C+n;*vIisMfHZp zA!R%LL~@yk6!O8yB1gNS{u&_GrbV(JIb?sy9F60Hm#6>{$k%iT0NGn{^N70zCY>bm zT(ru8N~UE6yC?X;U6~}c07l)vxeJi0H=6-cNY>NS8l|4L$h&gGu+I6UCzGou+O-v8 z5b-tId&T9jQ|tU3cZ%(&lG!$!eJipe%FeSpp?qC~dI|4D4`JJ^uDRVPQh zT-(Z{LLf~f3wk6-Ve#j4m4ICCn%`VNf*$#69Wy}DqZu_;sr}ldJ*21=fk1c_B3TXN}3BCdW3Ztpw0lGn@IO zPt)~9^^WUuL@1AxZoUCb$m>D7K+B)UTt)4Q>6P!Xhla@=vO0b>d!6(h-T%}krcLUt z)j(?*pvHrkP(tiK8P*2Vx>9SN7Q?p7{!UlK6;m9JxZsDzf};3njhZfM zy%?T#4K2#;?#mJ6KM#Ww>jvyj-?W&KXHTXege5;dS-#F@6JYWV6f}O@3ZlO5X9N6m z13eSXudVJ{hEo%f+*{C0DBUb>3alyb#7ZRJ8WNBDb@^o#0QnsnZ>~`2LE@3{IcEs~ zZ{z6cS5%>KM{RJB{SbUwLK(^x7c40x$Zdjf5qlRz4KSzsKp#RDY*(TU@Home94>}J z1D&_yj?KQY4cnwtPk(Li3Q-0WciaEP8pN+{f4lu{F<>A+0U;DKya&B(_aS}-?Q{f- zm9G5(Z;s=!KkvekTa@Ge&5<6%dvQVvykke@38EXR$)3lj6i!IDn8!}y=GY!(#vXq7 z7G>rBW6MQ$R-+7=wt30tSzei7?dU;9ZZDa?`sLGy}RE zA3bQg-T+2N1c|!G8`qKk^+QbpXoxvnW3|NF~PV`t%Th zO*%DFUs;H}*0#E_6ybqE{ap1F14W}P=JKL{&^k*~hnci_ageL6G5|pm;D9y(TXf?Jd*pu|{RzEZU z@^)amWlnTQcSmFU!AU~?xW77JH)%EXhRTLGGW7m}v{xi0m;4#SLosJ~T?|<~6$Im! zhtqthJa^rM9rsulzvV;KbjprT%I+q5R402@#h~P6T6rv#M1`u1WI9na34ePX1`b=y zNZ~nI)ZKi8;rC#&OJLaJy)c)T5(B!` z+INqFCNyb3UnGGKE=%QH&{ca%wNkmKZT{ehs+!j`W_X7pErq1^JsXKBJS6g-bxXC_Zrxb zJ+vPSQT$G|QIg3Eo1sTJ=5G&WneUol?W`;hIQ8NOd_1*2J|I;%F%4zXaq;iB*>9_- zRI9?(A%DAP(s4gJ;FbBxaa#h{EN!3LyXsKELpi|N8AhUlbbB0=W5m7cc_Kuf}JehDnt$AfGcq+^7>M$Gk|JI-6+3u#K)*z=6Y-f zsZ7ngx$eDz*tIY_2*&lW>%7n{a9FP$eOPbS7U@Ej#XE~n41^DgnkKuVy?&?@C;D$q zpzJw7Dwf}D2-Fy2pr@9J?x&BDf0K>Xv<)S>bMy#(r8WgeaHbBv>i3lT6&j@CRXQui znyWpVDI{LA`rAjI%j^U%3KZB1h{PWdiSQ#{ij~1ITV?UnN?oG02IUUg+x@|l_kE-cN z-cIWhTJ3#O1`DFnPSH~+o1PAyB)oY%=(?b$*N8Y`D$$I`Gx_jlFl(7RnAOd3+- zKoT%>^y2Hahj}cynWyw-)#>z+#zk53$^u4nym2hKf(+P8+;Ol*@nY~R(1Fs#uF_*e zUg*f}^a=Ul`t3gaNHO~^Z`XNc4!_cl$Vf$l^p@y02 zFdm1(XBqGF@&&C~-rwsMNmx2~a#|{VcCwd39-=Sp;!tp%jI%9t<3zDS;#)KBmy)n~ zEA*(6t$yQ%#N(VQ}@gv>aniuJ8 zboio$1xl#F`SyNoKOBqR(2>E0t#mQ3==gL+S_5$C#vAxuK_gD;&9K5q&R%%o;o&p} z4jOu>d#CtRa|-Xmu=!5Oh_&?Svp9g6Rm7fgdHUpZ>^8bSe-br6RTHdeVYYMf`}g-q z0=nyO_){wPTSBV?fV08n?F_U%Zf>aBW)kCUsiNg9bY{*814%}%246fIVY2a-Q7$DliDe9C!HHb)7q3USV5 zQqbiP47nOPD~Ytc>|U%S-BAe~tctscRszTghVy`vJ@oMftTyP88Fp0d@nPk?%4HtP zCoqh8VgbA;c{kuGGwykqiSmA3C~7vR*SRvaW!olp`T#D{ zDDUhBW@GY<`N(B6F~vXPNS!X-Uh}npigna%`Afc1Y3LlA=G_iT}(-HW@`VwI8I-JS8a)G;&aiso}IwmEh3R(kvAXsxG+$+3qbWoP_V6(Nc z6bK{Fg+k#BL8Qz#Iv1?p$P3NSt6>y|FxMPuR}wR(5d=^Zs4gatpAF^`Ge7x)A3*ui zLfK(Txb}3{!=>XjIyIiE4%T5RR{(UTUIH4v6oQ(kN-@9-1`%56NR|qT zzBK4^prYM;u5k4#?1NroRl13?WAHVwileS_p*mVrHXl3{;Ck8ODi%I>ZWU$Pgb@gclWdHe4It!=Q^nLBdEI{>w#)^ju z0!0PLA4=JG+cJ?Lh{$LntEhUz7dXM(?L^Ndd>Q~lkS;F5VUG)zW)8#`_q{?ZZDKtq3v6iDKjFq$?w+m zfXrLwOQ;=Y`l5nmqXEE|4>2nZ0tzCFeXD0bBG~r=!N9uJ*7P8O54OK{6>bXzw%mY$ z?=L&PXIjf*|L$SB!i(zr#=DzR{nh)q4Dm93?>4R8aM=M`G(ACvRUERcxH4D_|) ztuFZ}YoJnW6GJVH2U5}yz&?H{(MGmaX!`7Bo6}N%62TY`5mgrSkJ)r)EGRopkb?vj z8*FgDJyf9_$hqyH=6|R>c!Y`g?kc}m()D^b8yYMP#7G1+OqdU3^_r14V9oS8#z~ft zc=c(kY6VYJWnsWLFo9-3d9Q2{rD0_6AM$=HbLMayKsxuo=yH&hE)PCM@a(baf6S>> zRD2vi1ODp!leh*WiurOn?mrZ3uiP>52%(P+Kh7X_g&tLysN0eN3RKhW?^wt_PA*QL z4WzQI)P8+C{4=il>#O?F2_UGl_h>{leNvop1#J{vn5qG$;ZxwmWb7UFYw7id>n(%s zaqgJu4y&!bD&HO%Vy3?b-Yy6__isXgldL#HP$P2yH3fQj!0dBA3?nXCJ|T$oLgd1^ z8AV`^#|y?L@t{Jc2?yED4&hs7M!BQ~fRU=u*aAR=W-Z5AED2?m-{tWDmnm|3z#1y9wz+0s5hAHpb53Urr$+US5#$iPG02a&Ag%_s<7{$80$54x z+8d`^sKo0Pw6Xp@r^hkARwZD>HQprRVx;^~V+8}jVu(u&8^L^ltKLMx9<*WLzJ?$R zP}@{V2{UmTFqi-B8dXX@R2CP*iD$h6hL}WefksrO_gX^gi0WS6EQjF-CVaIuHLPH# zn@Fw-cpF%AFr0PyEx!Tin$n(ki|Mq0hF~bETUy2g7Q<62| ze*kV>QFDOp)Gt~*REz}Xh7t_F!$<@H2ey?or{qA5MA%pleHkVe;>5m6efeaLh|PYH z9XvF_{OuZsr~t8RmI5siCCrzRz48PVw00I}RBy-BO%NIyLL5cIF6X@%pehzT^_4W_ z2i~3BO|qZ}Ptc&~QY3K6K&JyNFk@lOP2!E1i{_mchZ5r82n=f`l4C?@#+)yg{Q$w- z*M1&yi%$jY;NV$$3+L;`ep!U}-XRVUJ;KC%8|aL6SH05uBeU3QtE{B;3j4wsnMEzI zOnN%diIJbB|1BJ-^nz8{1x0?9FC}6b6P8?hI|K(1;OcBXK~yjr;X|&x6#g461fNbr zY~oQynvgZXU~9Y);-bYFDbVx7;hXS&xwfPzdXiqfH>Od!Toh!n^uG4=o$&6Uq~KbW zL#Wl+beQP&h)wOngdgs`gVWjmx+Le}wmF{?DshX)HxL9?v!98=SuJRn0J{Dik0`Ec zt4mNd>mH?+<5%gAs~?v>s4Uc;ojQbbJ1@i0HKUnSdsGyKGq$=#pP+N&^3UMqJu1B` z{elQu&{Lta3N#Op+Z5Pd8Ml-FNxQyBbtX8gxM6>C5 z1z^FFh13zZI%970l%5B(vF^+4BJI_Anw~p-!%hQQQNshS%$z{2FD^JvbiodE-Gk8s zLL(Hqk&P5p*BK^y8rN40R!I2KtD+c^ofSmF+JntInyOvxz7Q#Y=j&*NLtnNEL*Lu` zl9YnZy?`|GKSwiHT6DeQRUFWC2$2b+${T+&E)wR__fD1D@rP10i@LJs(XQ&*>4}SJ zON1;{_FB!+oyFY{a^5bj`Mk2R&l`#FYYy)^%yqPF>}@ZsH{!a~o*m4mIQ2jo76KWP zznG+W&Ut-s-O%6L!n#vlcXIZEIp5Y6TJMV?BpU;$&thk>52xT;HUQ+`Ow~0|G69pX zL*r7ZG=RiUM6g-L9UraLmX26g@_TQgS`6t0P?9((KFrr#$h8z3c|pv)J@?8+;BBsU z^~X?=L(}gL-NNeq!V|!sJ;!x2?cp+;_h{HR-a0Pyyn`2}ogOZ+?Jb?`jgPDEb_wrk z&Obu?lEzw|K9H}K6QwA^KdfRejB{19*f`_{uXV1|QomVy4C@GG7@91Fom9*-SN{ch z!+1WsGzSlI7}yZLd<(d4Mcis)yh%j%(~$DOyiRroa2#v)r?4hNyTaOugGPhN zI1@SG>(&*sBz-v#yM1)KlZBIvisWPOyi+gE?BbuNP)HH^PN|Wv$&XJP=oI=y#opcl z#W3aZX5i>R>~ZIQAyH=WanWs_tlE|-duIgQnPQ( z;tsAGRvV&k6r;cj!;^<)-<@C0b*I<>AO38ffB5qhK#dZOn|KHmalMX34j%Vd6DB<> z8_@R1cHNl5*?&}Gim%c!g)4fTN~-ND&Q`r0DHC#IcC=P(O7!e_n&qWQvRh^ht43x7 zt*pS_x;HWk#nmbIX3*V4dWd`PeOYPLsP7-09Bk3vf-)6F4$^B5=E(6|_BUFnEr8SG z9D0AWsXghhr@P~zPipgV0X+Yr2Jn(3BmCF{Etl`~`Otb)#mCcb^GWkG`0}?Qa@jhy zHOWs--A*>!*#tJ42q~3%aHeP)u{`PS^pfVj#TD%sATDF`JltmIb@5GMs7z$UL&Rwm zk-GREVZXL3VUBu@bnBm*3F|NiR%T;$^7FMS1(x)4{rBaj*nHa1sUIu7;gaZmoa<=TJI2ei8+rS6H_{c5<^a^nk)?8LQkV2pul1_VyqC1dl2|vBt6oe| z@GO>q{;uJtdz%i6O?}kr&#=h>DtSr4xW%Ld^FwFjBwmsH{ zp_9FZBC2!9xca90vm&gRM%SGoU6+AIKvaBB1u|*79@ya9^LL*AV;TwlLOR+|UIcj+c zeoUI9M7ht(-(`~{Z5@z%R!_1=Enk~IgLQ~~S2szT1*_-FcF>V)AeDbp6#cGHNA0WVXRfIX@k!MB18-NRSU_UR6@vXAxRh!74+!d}5A>1S8p0sS$4&mU z_*+=787=3wD)fiyS}B?zf0BsyZSlz^^1?C5N1iEo1WGQo7;t7EXMx0f#9_tL`p8K* z4(7@oP6CGZ5$^^`mtnxfbsl^wkLAKULM=Mfa`aaigl6IU;gRMcwwV}&;3lq~1J^Df zi&o|<#sxk+S>JCF;0Kqxm%WOEl6dR)RO}90P->Det97VjwY`CVuvABXUO}vnQ)p~I zW)!QHnW^yVV;jyF{nC)=$P~^9!ll-9*yMP|-DrDe2WLMN?xtjNL;V{AFs;k66{M7<|ZA# zf(jFWQ{3j_S9BvkAVtARU2no(@pP(#GEVX17ymx{i!v@vb$^{O^5{AwdGuZTjC zrY(rx%3&-En?QLv(DNfdb&ko4g*-j8(NMt!u^ zn14`k6$0g`Un}kwS%v6Ec1nre^xO~)tCBI)B#eTvC&3|)ljXW@J|n!1<{kf|&~@rX z*i+~tY8<2GSUmv-Odmf($NLtvJAT{8s$@cXg7xh&br4EntoQ8|=&U{@{_`8cOB66Ol`EkK53nA@cKtJi#IsP!Up*hM_X0B zZ^E|m@lEGMY4CvIR{&~U1cNeC3=W=yf;@DuzegL34WM$rZQ<3t(m5wRH)|V5ccnGp zOqXUtl7PhfceR01}eTA6F`^4&vE1LJJ^t zENBB9$>DIUNme+Tn}|AmigLrv0_xG+5c7${zt_N3`xq28D5D$6_ZgkawQe@Us7u$6 zh6HB60*WzwPQ&tS(4HiLMWzrIxp*yy!Fm5WZN!L}=~6L?2nnWZS?0dQlfh&JBb_ywfQzE|iLb5QGjS8(!C9D3 z9FPtKL+=Rk`5=5@>IL{&ID`;$lJ;p{(n8hmuy}|^kF&*FW66+mfEY_*WcszV zFE}D9L46U|<(#7l(J^KwIT%C|LqZc3kLgy;^Zm#ZO`@^GA1@=9amRB!9|1Re%yq=C zKCVS(5Dc+HWSdi~#TImkODw5(WW!0V&BWl_m>W-gs1^YN-f^ zN^nK?>oQVqdIcDcEHfFRvv2U`cG-I)-LZ+NTd3?8)R8&f~%>dqct0~;36)=`G* zJ-o&2#Pe-^NCK1DqV9!&w*f)yQMrWk0|Eo7M`^7MIObsW9?kGjhQ|gtEgY|9QHD_? zjBx)>~HH*$5YZsh-^)%z&>G(^ALWHLWrI&>x_?ytbMtE z5Jhm+alB@nVFU70E<$+oF~gahLn9NZtKluW3GyVT_M(jIY zEu_vA$~yaW?WJ%lPN+v59qtW6Nhme_g~YX^5)yS=4ZP~bHD zVH5Vc*me4wbA->EuG;0Yd6U#Hh_K=nG#iFEdAoZu{us5XBhkgleM8NLstFG!J1jU# z^3Cw|2W1B$iRcS7Vv&g2tiY>W3*~hr#$e5p%f;Q!XS@EfH*XlQqkUi$`&f3v9!IJB ztkC!i7?T7`+p)w4`^qSxX=gXGr*^JLo1mF|!L#)S1G~nc7w|h%^@DYFwPcb&uB-@h zu6RrQ92I;bt$rQZZ%BPDzOvvL+53Re&{}tZ>&xdIx6r`$8I@e3xvM0f>gcyn)TEQe zogIo@+cCiLYi7JbPn7eKn>tP=Q5#WG-WrK3Ch>cg2J^>Ke1sbsFajwBYquc_$Yj3| zEefLsIgX-G4jJ0ii3H1auJ(vuQ$hr1V-bk&qUtTqI$D6j6 z$snUulauB2GG--CdEIN!1oN`MDX?t!RU!S1b&+iZ7UXI?Vo#?F`}Mx+Poap}w&7(> zW93*N1z)H2MrWo{Q>?xJ3>q`xaQMj(ivrX-DVSbCvW^w!9@oTi*ArG$M_0e{(zokG zrdo`gQTBQLEn)P6=cSQ$X*Nwvb`%EQ`EUk%=9zcX8TC$NDMQ63HnEvkCmPL{uxL7~ zeXy`vuNa%5Ow41Eqr2WGz(jULd0D<(pc_t+OiDQ&+#c$Cnr@Y5%|ob*%^=fm15ed~ zH*9T0Lf2IcV%r~}ZlXk0n0?Z0IsPGxy|MUNQ?^|3o}N^1OL6-#d9r*ge=zEsl)ZSA z+ksd2+H6ubHxdC~dboT|s9Tq#-mhy$uIi>Wj!3EOriSkGHq;!_a3~URLz(VkCB@?p z#lMaWF8qOv2~R1wRmAjZhg`kD@DRyKbEMo{_~n5?;Vn?sq#Jp)5kU^)DhW$6ya~>T zBAi>$ z9J&v|{PeWnN8tg5SEdfkcT@G`-_u@ml8R=FqeQ+olN3y}2h8G_PCp zmVlZI-3!dTQIV~=>nL2&NjxV-^djnaZUV!vxOZ@t zL)AyB(J*uIx9LY=KTEVDZ;3p}6gH*O?b9|w@cDZFd1*UaV9!q&;0-bO2?x@>Ao8#s*(DcUv80 zHl>uXSeS3hjY`c-#HWH)5#22gz;_o_lhC6C1cmezsA|VBP1n)GQ9_@a(Hdi?>K9}5 z)3H2+KYw01O`cyhQOpca6-q}oX&PHcFJrcIEKjgghmqjJ|DdUDczfaU9!mtls8r_Ic0^LOE>sQ865&*g}2(qqC9}F*^+J*1M&mO6bKcH zTuG8`pj*FtjX_P0#Y>D=EI|6DGD0el!{FrdCxxfTE^y4RL_v^L49(&L`l|i+2ChMz zc-O*@kla%Vz4ulhccD%l53qX+tc``vIX5MR5Xae~H8+qh&L*d^ZKJrh5O*0P>l6^6 zv;`B7?`6zP(-SIiu3Sg_Mfp^7lfS;tDUOuLyaD2!U;_J`l zpkR1iDS1koqArbFNCSOfmVa+8?6J^1kgefJpiu*C$wa+QmFtEYi%t3M!YSlyBKION zUL?6A5k#REMq5TA2^5cDP!5$mDmAYNq437U^ND_efr3RWKIwgB$Neob*atm@Yd{A# zhfxsZ1Epzx;&6k;kfSL&h&#v`6c1NGW?8#~8vvc!7>t_|jc#4@h7RN7M0wN2C{CT& zMgW4#cLRO#)bP=gxKe;dVDJbK++ccx2|2RZXd{qFIlF@9ngr%hqkeS24V-q_Zt3fg zdFXgMuOe$s1)~Iom}-O5u{R3ZyH3{O8c1>e-YyPXHo5PD(LbWhc!?uDcMozA(dNML zGGDfje$}NrJujm!L+daWp@F8*tzdkKrsFv*GNJ}v z%*j$+yU)~^H>HW09@vt*ulGLf{@RgOK}?cUv&|!#p8d zUv2!_9zTy6Vf3=UB7$_YwsIh)@s6bSrk8(EWx{p-Tqif;9;}E5q~0@}TIGWAbcIus zUb8l%1BeRIa``G799bB4m~s>cT^lyy#3=|8Yc@}$asYTil*S{Ea8Ba_`Fvm6Xu{n8_H?1JoeLBTxJI@2wA?1 zwe>2uMSr=p7@z5!Gj%FCn{aMoaQwJg7r*5fil;<+ka35^&N6t5eYvBej=r05+h#p-s!f%C&U$!tA6pYK zxi-ZSv}kXrd}*fNXw&l|^YW7_!vS%FDvr-xG- znx-a$w@KiYa`ArjY-PTE;ei-Il-3ryzJa$G;;!fx6czgMjuPLc%zaw8i;sSlZ}J)H z91@QgT3um+k%UQf``nGuO`?=-(jSeRfuHc(nO2D>p0@QoiD+m{*I^9U3tPfNZ%vmo z$JRgl(oQ-Y7L@eSC(pO_L`D=kE~y3kygXwpRDjn*1=ic%qGtetYx^7t?gr8=H8UM= zv&Gjblc-vQp1A9>2JvVg$UJzi>LnSIicJ5&dX+R-HKP|PjZX5g84Zhm@0Q)mCg&b7 z3Fgb+fd7F{60h~>j}QDuYw8|3RNcvEiF#baNX9I8J;z2(yV@46lQgn4w2mR|`)6@t z3A5dyXtm+H0`@6ur5x{AN~hR@w9N@g`28T=qK=Wy4S;hZi`AxnvOt3Mppo<%>d-v! zcMF&1QR-;HhR{H4BD_NRY%te@Zh9XC@NC%3nTv5#cUP!`rz;{jmNkKN3D=bmRPNz!v@CWMhr%$A#%B9TQ}2K;f*Wk>Vwass2N1em?|C^{01X{o9FpN zPjg5o9s1iMyDOwz**4zNND)vxSJV%;o34Uh<-QB#G*m{7F{SpNagaibWadk;hp_$C>%WvdK-2P ztW!j@OjE|k{2tXdEZSEZ#72l4_2aqp^&s9={wXy#{He%!m#ie`1Qe*YTvIMVWL+te z5fT?QTYQ!E!=q|Oyl9YDP1EP*#O7j|4TmeqB&!DASW7e{LaF$;ph>C@1y|?wG(6c* z(r5fvnQwJpu{K-vpr%gLc;kvzLDDbA@>JxeTe`)_JnrrQ2%Mb)!<)=7XbH6zK?SSp ze&d-_AnEJ~VVNrAI0S7Y`yErorLT1`Zu|2mTZHch?M`NFLfSaHroNDB{^+O|d6E}a zc^>1)`m`+3Un3}#H55^PePWQ*<1SGhY&@j$gMB`5)bWhe)`D3>RJaN0D5b@oExHl9 z%aA8d_;p$8(IhQlc=vPk;NzzTRTUEi(MKW9>kL_nFqz%JHfqnucj<;ScU^ygUxBcr6!ktFUGb8~o;D&k#Tl2}O_Nuf;M7;!O5Ca})6uVHji4z3|if+mdASDTdpLrsK)nPKv znDCiU_T71TU(N47<5g;t2wjr(BE}L+M~d3^gPZH#SVNx(%);s>fwUKvH(_*c_k+cj6{z!{g%?&@pN8er*7BB68nca|FKTBR)Ds1xDr+lB_d#h6M5~e@2`07Tzp8 zWDE0OY6c6j1iGfSdW*aBXih&C4qm6>;6}=HW#3;%N(K#IUEbhDlCR>5=c_`+e^{|b zzJtr1@F8>`Pl<|3Bq~h$jXGFmK@Ym|0ic@pkx+Bp z49DhZzg>|LrYfjWcR^g;%AgkcL8nTPMa05u?e3#kFQ+$iU`S0RfXCuO0xjh>Ac5rZ ziX;T>uu%?cWBoS9@{&O6-If60`ojMco)}gDv@U#bX1%+u$Syq2K%`eJ5e&JyKV@yg zd5h71UksH*RY&xdwJGn$vNw40p607g`>~?y$$H3fbP6~6<)(Uk9t{U&MDOa}H`;UG zx3)%#rJN`~gzm{u2&=)VKNv#DraR37S`geM%zmie?slEa^@BLR{#JH7&6a!Iz-RQ{ zw6-?p1Ron#aln|+FH|-*hajsth4?*Ov-P-t(K3?Cqq!$U7*A43wNJRJEh`5Rr z@%<}&aeolSf4tBP?Rw19kmc8LWp5DWL`*1(OIA1m_Q=Mve1<^337sd}c=~ zIlB%LoEZv-TB|o)c|poXwRAMb5{2y675Oy;Zj*0V(zbXt1~e0Cusg;S7#4XZIwb@@ zDH#L)GpJZF<_w9!y=7`^y?9tG&gHiNNa5IYO%##;i@CRoilck?b&=o!f`{PN1cwGf za6S^e6M{p6ySqCfxYJnUPLSa44vhwa1b26L*v0o>Yn^kk$2nu{t9`*3Ty$5}oK;;l z=R2S0S1L%wlxCH*XIr;LA@MI(Jr|{X@!DUs+LGQyN!tp)TYcpNrz(kP*Pw^EPgl2z zL#R4CWtD9qEfgij4*VdNF1 zYj8Czch%UDH|eKnMV38-4S2})oiyc#Ta#y zVWs~aDATfFc`E5+nD4`o_btU`F9z z#TG!5pJGtK5*}98hlCVK%9aTohRl(&x3wWhXLwX0ys_t^|M8|dQS1R{eEcv{>=_gY z)?+>Y48RKFR7e)jQ3bhKz|G3+z3J3C1+cg3CR*)hXrdW&_U4%(DFjx%jCcFdN|sQe zh-AO%UHa^$KpM8iBLE$O^lW=%268(H0;|sr22}E+a0(d+JwX-#{z%CPP6HnSYXIXX z2;~F9#eyza%paCp3hbF4!)o&R%2)>W_wxRgi6k0;mm8!KOv(*Xz}_+(2>jbT%d0QH^QZyo=UseZ$B4COQqIxCc^Vw2Y`)any8STADGmETr_C_ zf`Jh;#{Qg=kS{g?>|2s8o+4=g_y?$v)0bHRyo%e`Z=e1>I9K494z$sqsUpAEz4Zcm z097A5`uSOhRH9x2zOwmEK#(i`dGeh%CT;xa;+^?s z(@E#Z{NcLT!VPqH(qGAMYcG#FcGE9{-Y7xiU0A0#H&W`)n?Tw!PrD!ZU?{ z7t_r8D+wtezr6(ZN9jh`1ZcNQph~JVisB88oS~?{CmR40t&e>=YUNMb=gv}ezv}#& zQH%L(LIE{PVb9}^wpizpE291G@aN)rOVF&nRQ}a9O3MW*^dG7f!lMiTV?ze+<^San z-A2}JL_(G-2ICR{U1vbLz*H1K=H9&iAFt+*O>>un6h7mP)~!>=*VJcO+qhq>z?|>- zW2FDj$1IwGX1GVF<<|jl`JW?S@x!*~Jpm2&X{HPDq|1GFuNSF#n`urAO~+^)w; zS$oeg-*@1{|M9H~MHhh+c+uDYHR(V67vVE%WCyqTpDme3Js(EKZ` zYo3z*51g00_I#2ZG)q(}p1V3l+u8B^^PNcU%l{m(l1($50?vrXDC;hCk!->S1_C(% z2Zmf|zxgMJIa)H(+b=n{sX_tL$Vv0eOe#xVfDE&!=j^LStzxqZp~x};l%F3S9+vr` zx(_(v&uUsr(FW2VyFKX+=HFiH=hW@=M@z%8p+qHLZ(18W3|sRCn*p~6D!Q2{ll(^E zU@X>MZdLFX(9|0PeO}^lpp7Uw7QH&W)SPbhx$^Y!(qo7KmObVt(_>5u0l6rf%kDDn zxXLUA7S+zjz$uW6+1#FgAM$(QMNGhVYH}GFv|!o(CY-QrWN7FxI0{ppvcK+e=ufT4Yf!%~FTkrS( z#$z}%*LWPuasd$YMLJiPZyxl09v-fURT}e(C@sbndqRn(PmRJRO6(}S_jY?^-dE%i54p?c&HRPY6z>>Rcwx?{Bj;9Gw)H8Q#m|6HJ@E|J7`*}z1aT$ z73$-DTkiaS3iVQ8Xr6Nj0m!dEKlaU!`Jo=@{5RAemK1pmY|{1%70M>h+T6dCbjK2c z1cTnlU%~7mwyY4#U%3XDt)!~{Owo=gNLWy;wrdEVmRI^49;-aGy^0|5H{ACqHC9t! z#q%58O_^06W)NjOvi^D#-YTZZ8txd|a zTSq{|{dGBSVRJ!wABr%DHTqtB@~Ak+f_X6;QCM)dZpnMBU#&m@;g`wu2;z+?2 zLSc<3kp*uxwuP@3+FFW5xg+oIgaBRGUnKQ5G7x*ea3*Yu|M$?2yH{haLZb7ifNo3M zXF4rgJPby~rgZ|4@JA8ifdlDgeFB+<*uURcV_&}tPVu1&x!5dV{F#J=sG4?HIF&YSv8X0$7A3qwS~ziX$B(tVc(-4?`Du6O zXKbHoPluJnUOY{%cpe=f?)Td&3Chm+cvi#gzJD*DA- zl1YX%Q4HfB)~&N<-$@H_)+QSWB$KUuktvY-{<~QEmUbdRxmNo2Ge95n%)`8nKZl_P zzr0sPDrslBD%d9>Mk;A^!rqgIRc<`)0gsAw{bQ6=N9nJR|2uWx3;C7c1$hxm+04`u z>`%dV`rJUVM9?D6)MDy8(Y?RS@$6%~eDkZCi|V9I2&;nJB!YW}Vfh!lo?pr+96kOI z%>DGWIA*kNbm$trN}Mu1{D3j_*-V+oi3Q~z8>j5&awS1dv7sZA{7FJLx6Pt*@gr$N z>$M!-YeGPD$|=Q{pI*4+kjtZ)f6~74P8J^x*(Jb_6@t62L zYy9v^2=Q)LqVE7e9Z~D&YXV}xGMyDC=`rHxgF`?pF!2#u@FTYiuTyKX5!}EPUX^YU zFirwBgB7*zA||qesCG(XEdv{hd+C_H20F~|Ej<=gYx0dD9%>g<^-2!JWiC&bg?X%=7B!FE=HsS3Dn(i1BnsY zJ)y-X4hIwv{H`0*9Ao!~$;65B5n=Z={~K$EPoJleqmiv)=PR0v3kO-TC2t~T$kS(| z6tZeYbSr=7%D07LgZ|hI>6=&lXHFQR0$A`fIZ9Y3j7>;l2}~X#=E@D9<%q2vOpyH^ zg5+%vEySY#N-rN>EqSY?up>L}rSaCz4G;749@a~y@I7UX5#$$YRRzA8NIZID>EP$G zBPdRy>DdtWt9w%Ttm~?3cZbV1Jj~?Sh5FZSNW+O3noZPJ=@wP{6( zl5PA``R+HXnbSmBN{{Sfli^(z9FVA1<3DYWjWTysw$XBOgxbJ?@mR^^8La-0St81e z6@q$x>>P*$p2M?Rcs0ONUqLQD9gUa}V!dIdDjh)buEM0%@^AS+Xh5O7gG4b0vtgFX z=UxGgvfj#o`NP>DN1JJe7dp{k!#x1s3T$WkE9drK-r+lAGG<=i#@lg{W@w(I9q8E~ zRz_G8$Vbb4*9G_EGi2KUDEk+#52e6x_1N#TQPTb8oXuo46RDs*JBg;P-|p0}6sLB8 z)#UMc^t{*}>w0)eE%0=I1~8~Pg{BIM?y}ExYNN|h=|BFc4H+#by<}e-9Sgl5Z%n)Z z{`K%4Ag{HpW~;{J{Q@T2Ao^96E>ImjHFyxHOu~YZ2sax4+us*`w45U@8;EYGthG0L^XiOc(2I$SMy2 zHZT1MNByYO%blDzxFSWg68Dr)`ghbJD!>p)!t(#EGxA?%XX@b>FAwuAmC$?$M1LKU zhu{7CZG2scnx49!s30YEBmTSk$-vrGbz^3tD8IOW)sLh1lKs~5QiN#gv+=l(>hwrvT3 zlAMNlrwt!UHL8qftIeRu20D_FGCE{GEY z{bJ{jRMSCiN>wQUBWpDswKhGo$D6|`7R&8^?f{Oy;53-XapVha^zGE|32pE}4)Qo^ zshh2_CnuI~yBjG*+_zQ@R z*M$n2C1Hu%pZrgMfV)EIRZ%b`jEX2 zG%82c&+SkFMB$QRl_F(3Rps5DmV1Es-wEd)`2sNi=l5r$#UDfPM;gE8NMa8Ia6cKJ z^^nsK*n0WzMF6bB`2o9zi2+pgCB;aWEc}N&B4`YB`o1^%9gs#bfHsK3@|+XqkeGpT zx<6CpAROGocZRb8s1)Nclk`HMxkheDls@-%6u^b*z8+)crsPGlAKVZgz^a`FkT^?h zQ!u6GAk?yPz`B4s;W$v7S-H}@*y$nB8M?&_I}agXG@di|2IPl638&ErO5xdIgNaeW z(CZW1QKFNJiQ#5aZ7Q!$S5AZ8ms@OIt{r!1U4cF}&ll5SavTnYKs|pIz~(E|uq68n zy^wOSETRC)HRg1&%a??N5O(!)U5fxrA|97R&8z4w5|AGg!fqg$wQWUMJMomn{5U|m zj(nFN$D~HUH!DF@jTq29L{kVpKr2=VCN_5J;ae=k zGI*AVyRQV0%olNUB(1*&F_si>rMaZ1XqGjf%1%?2_)Vy_xI6J$&;RYPoU64;Q+dV5 z$KtKeRbgN07kCy-MzGnYcJJj;({v$~# z#tGEhAAj0oBVMf%B<(YwEfGfUNCJX@uBYk-Q8fMDOrAad?d^&OnF5|yNGKw^twaT+ z@^25Fze;ouEv3J0m>y3A!all#Ub^n0L21W_4)Tr+;P*H({0SkKW2kM&gEbBVhKS(D zBE->pf4f(P!>LxL%|=}KqYoi(Z@SDSi_iC86df=7ulw^c~kO_PrbQVH`Yr`Um5}Bv-8rM_rF?V4is18$p7`T0;@Y>ju+uf$h>7Zw2 zM?Dksz@y_pNMp#~7j(pM8x-^1)loM|g5Tk8#w6pQ^E)T)@=bPmd!IpX^H7o=By+i; zShJzhqfJk_zw|eikP#L!>z9JhX{jgr-NATcFKaAV7W0o^yDc(Rb89aBWGC+X2Pg^z zC*l+kzk(18o(*yH8IT%Y^nM`2g;d7UtDXX<)!EvH@;iaN(AX#0MKFAz+F|R-CAs~rxU^%VR0d&y7-gF|;oW<`iZpS9Tb{iG3(BMy@7(Z~ z#~EwmWZ+%&P;!DMof)LcAS!Rygt=}Tc71(g|FN7Xkz^z6Ja!Ou39_Y_K0Z=?1?xxR z%nYt<1nBng4^nMY64jZ`?{G)ggp!Dz0MDX2ettomeO0P4w1W)#Rc9Ew^nE$ z#=?;}L#ENh^l7j%xv0Qf2#)Q`0KB`0fNiUXlk@7`#|KtxMA^sTc}Oa6fP@g1@#YtA zR57P&>^RoiXB(yq`odad|cuq zO5ayW>n?^j^?slY5{@(Y4egkv8Z*`0Az{+?g(F`}jHUQ`fg}PChq8vIftJ^ocJ7Via^AGMYosN&eR)DqfgqwG0Td^mHJB9EYkCsD`d9ylnA}M zN$?+V0Od0TRy~}cDsKqT`w(Lw`sz33gp!R74f+m= zo{>;X{(W`Ea>j6mOY@Nur19hQ{toHB{O1+c=UBn(dKu!qAzpHw8gmsBqOkBnp@Zr_ z^Ub~FkheI8#+zq~d9oWAP+xN5wqqo9iB9@JwB?AK@WG717<0jy-`-P4lD5Ck8pN_7N`9)wcj=^!-P?SqC0FTcFU(gE>KqS-^j2=_-l}@kRR%_Ye9p zNl!q1-1kv-ms)iQ66uZn+1bhnC5;SnOTiZ#B7nhQV!@P0d{m7)UIB*Rrb2i)Y_PI{zmZZpKZ<~RiD-Viti zTOn203j!X3e!D)3#J6?26NP@`=O>lM1@%ZkAj&iTG?0y3{Kf&xf%z;oDAjZW6%<0S zj+?UQ%T63OBvo&1N8ypw%?zk4N{G}{^6(?uhM=ZdKadb-OyPnrONoz&Wf1>j&>d&n zu@7tM3J_a@`dg0+o6|@wX{}$fV?MYBZ(5L8rWBcaOlnDl)!)^?RchJ0FBpMG;jeNC z4&kO`HN!l{@#aFiQ!rvrz$p1h#SeT$C=fOWL$=)1eo5Xec${5<7dx7M<&$Y8NgKJaEmw|{Ht=$am7!3m6^rboza^SsKH8Sw)xC;{?EtuEEw_t9x zBVY5ad4r7gYTz!gQ3&E*M&0M|#913dSu%gvR_zbh@wxlZ6Gk*y+Vm)D=2VTV{<-g= z;zr)9$-D+fKv$Ha{^`lZrpIGJ`F)$OEbU)KNKlteJL1>BKRTC0Cgq*Ka#q-O zRpNIB41OB!!XV`0I*!J}>hvExLg3Pz6nfE4(}$qbP7KB3ViTe-dGB`29~2aQO+DMW z-j^%A@>GJVp#UZ|G8tQYuXQP* zCI8UZ(*#xshe`8pzhSI9jL};zb;wYNHAGVV(+%HAW(+5sKKQPe1mp!`0Qh`Lgfx}+ z9IVhBoBHSc4fD{k-m7?q@i;D2g8|Wh{ex*k)J{$~0F?hD_4gIHGCw~dtY<2XSdI}Oh-@NFA4}YkphG@G zctUqWY3+z-bPj}PIdzagofH8??%lp@;vM@SZj*4mOsIQ^9pa^#r+rbrxjcjI)t;jrG??u*A|RN2$}t(G2~$> z#LF*!dg_%LtQ0l%nF_v9 zqMA{3#Xb9sO3gKJ?67*rC41euidaU(xnuBP&~SuF1QM3kR=#D8*kAbC6S|pNd<0Bb z$E>AvGD!(gcd7}ZCR-VG^0BcBOrN~Y(4ZvHzf(H!vj za3qRW@~a7ZyfaV7LOqK3)^^H7;HX5Y#^Yq)!Fq$?Rzq!}uwZW~5oOGcBID=M79x~> zX?3o8t}?LiOEx^H_eTJeXhA3PQ0}Yrr!lI*qt|q8v|CGVUP(P$G6vwxA3pY6fu#)V zUvQx-c6?*nuRqxj93(98mhQd6^TZGAbnG(vunXGK$F%bLN*SLLUruqt3lBA#Uz$u zHEE|+Js!x$y zu5L_&t%(S&?)V1sqnyR=MyzN3{cz;_O&?oh2ssCEN)39>x$b>gY=?zp?m?=Ml1kUo z@?I|aY_ztngQreimM_7G2)4+Pg(TO#Z!plM4mXV%`6@EcgDUcH!!auM zaw7*v!0;Lb@$+nvcaf**=U;YzmCZB0mIZq5+k6+dB#C%4dR3=AX1Kw@W*TJv?UW*G zeX~Ku7keQAfJL1>u{H{KU0`A4|KH3rkW0Jk(Ko?MeT17Z zdtDYj2VTVsw#6|oO@C3p8sT3vY&pK?7AWxKMT6cQwiLXn-OuOUdsn~bS#e!#IT}T{ z;tz9hnqk8=Jvmo%9JhOVs)T=M&?#OjSDUCcqi~|&9O$4=7$`padEk51--R<(;)Zqp zF{~6vghkWY4@j-aM82GpdI*g8b7e7Q)-yoGbK@VCR^wz^x4A1~Tm99EVta&Qz6Q4E zP$`SUiShB2q6?MAWwVxoiYv}k7aTZcrW+9E(Dzo_cmvX*6hj&^l7M)J|Xif?SuPTNcF@AxtE--)5OQE@;A-+#dx?I=9>| zK>b7WKdyYfth>Ehn4Y#c*4m1X%LfSJ<-K|gXYDW4^_ z6{B3cHS0Du;d=id9N&&!Y&wn-VM0Wnlhp23S#VyEcpq0f&lr6pC)>ykW7(_38vXMy zX+6F@4YC-Ii#p%9<1pJS(AmzG<>hU(A=7g2;1}56w>SrK&n6n@#3KGH`HTTal)mqb z?$_6eC(Q!yuYx|`2K@I!>mWGW=hY4l9BEviJ&qa*aD^)GgZP37mT?JSQLJ zM^G)7P6Od`^wdR~?l z3hPkUj4sIDceTxTxt&LNFoG@p?hw4}E%>ew4qZEY>{$YkpnKl)+a-}zqThZ_2Rf?e zozBI2DTZWT1*1RLI!ZJ&kY?ojCIfve-!F(cZ{Bs72Hu5?N06Pi@P5Z#rJf*TU$9`EDd#AdY%x4dZ$7D>>yakokZ5%w33l ztWv@+5c{o*Z&XfybFRnFgkp+7I>R7{mTGI|KKPZD=3{H?{Oqe64apHWizC`kw%~I5 zk_a(X=Pc#w!Eej1i9vhOBRbhK^M9%iWV`G2X-2T(uuwo8O~;xM+cUD0dm=eZ+?%E= zXa*G2ia>gI@n#wt`Z9gw$(~KyC?2TAw2HB1dzI8duuY@D+kDC5r(ix1Unj8nlg@Fg z>yt}x6Yx$2P)4%L;!hFaIq9W=Zmku2U?<`N8_CQ;HJ|2w@jIdC*>YMZzmJ#_^GD#~>0DSC72nn)5vyr>S_CO?0Tk;+uTHTQWUS zTQZ$W;n+<%^6o#m*K%Ie@Oe6mf*RD(N>uCa*r$1^sGzy?>B!+6T5a@H*!*#v$u3b#R+b%Sex7{%eT%t~!VcyP<9vY^L-9 zOvpKC2v)8YuzzNJtZ|-SJwV6|8JAe4u<<#TX%Wiw4|5ZGy7f4JXqFjK)VL~)e8Z{V z?5*>d6;>SX=um|iL>|L1((>7Bme(%D!CpYLN$c;#-Ojzlo5`k#zpr@is!$qR-Mwlo z&&zkG8@Vlj0isdtFfY+zNe*uBh1yP)yi(sHN6|Ep<>q}zU$cKJ9?9uu9nf^%t`!et zLk_@6XTGrEaY6rG){4^NOPCNYjHny{Kfeh8Lb^kEPypogU_){(l0UtB^v7DMsib+~ z(&RFH*W5H#9UjI1>TY`tsY(z{jkn?lJ?`j8Pox~Bnu}`!mUkeQP}AkDK<64kNL@I? zG$Xsi?am+4jY&0e+}}`j=~c3j4-r!bREI?zkSouuE_&K3rMrcljC$ApH&MJKb|G<6 zM`cAZYfJV`8E7tEpCzK$i_1jQm&XzDxOdJVwR`;C2g{GUIch3rC&V-9^7HzJ8cp6g zhcmWV(@<;N_8fEx&^F%R~8lrsyb*-<=(}Z z#JmFunehu2$dIn_l^}>&qU#4a(qG5m1i1>n>xpLX_euJm^eNKFg)Z^n5{UCVyC7WW6RF1jcbe3e8OC|_z`sQK**Mfrcu)@Rc;bxi`_|utn zv3W-;rKT_J(eDIl~f6kEUjG`$G(ol|bnyZ`lLV1k}BIhZyc9QOz&qQwsw z`XJpq^nnnz5!84PdE67H%K>MOktH>X#fRra z{H;Bbd+VA2G77KkTZ?dYrmd=XOJ^80Sg#O-wCkY?L``IUJe{m93EKKvS^kOg-d=Y32~qXX?k3IhzF-u;elHK~c$ingD0XI%To6}v>-|CJ zk=r4i1k4D?76Nt6iugdshcB6mQ*j6A2=uR4F+(}}MoSzAM7TF#oNG49+=m5SPb{5^ zpogQ@((o{`@F}Xr?ItenLpmKCx%WW4Vx2TPeB_2Wlo;1+3vnk-Z_1#JFV&_x z3qgrfU!fH{hD^+MBlv)=7*lh*4RLmg6K!>qU5<^vz*+;H!@H_nm_V{N9h9EsUA z-zyH0j&1PAlKXq*gXp5dtxx-Di5L6cxw6qEl=w^WDg-kX2wlaZ#X~9jI=KHKm2qm6 z2Bm=xZ9QUTg8Yj4b&x_KyKd-Ah;YOCYbIzM3ngQ>k$$U6fXyt7Q}4UmFl%0PkjGq% zjO6*g0}GS7kZr00Uz4p3O;7k8VMFdPI#rHy9D^iEL%3YW`kaGhjGb9#~^d=?CRBS#p>g#^R~BDkcW`oP81t z=d_2XD(JMj1)I2@vdR$;&@7c3w5=wV)DQbUWH~B!Hw;-z@!tf}P+E^{gL87Tm}B8z zVqAV|!E--kPC^`C&_mtX#bw37qA8*!DRNlS~XSe5|P0Y7~tuIQ-W@}w< z60y_aQi7EDkqiq_d18XP3VtmJ}tm+nX#-bYsEQf%f-PL-k)f8GeK+4tAn5(8X#d8)qHc z>Cokcc#|#ViP_%>@PDCikNFD+!N z>n(?k>;{+uLhfE4Y*Q|&4L|sVquV#|7eN$b_xH^%Lui41o73fi9-(ZFl1>`L#db>5 z7_E=g9eb@pYO`3CFqyuy5=sen1TQJAh7z-x(`FF9A7HO4YOrhtZY%wD?O%StC`OD- z4)@lbJfHZ=!kD2Dp4HIBPX zn{ab*EZhotehuDmJp3Z+4nsPXg&rEx#bs#`0|`8saLqFC9$f!*HtXsa)SG1b!n!@+ z^odQWSNRw4Tv(1H2pwB&@%S_mDXvni%68l`qZtbXIQ@#D6hsW-ReW{x*-DbW;fGv< zo_;7>Xmg-kB?L8#)4njl^(3J<)~ogl-$ z1O6uvspqbNeu&k2fF>tdvv+e*d;pY~8$}^#eA}Dn34)hvVN&OmYO2=e#d}aQ1!=aL z;J>_U-)5ovPPq0nU~o%Qw(g2KmVqvZ&{i-d#eALK%kR9Ex^29}%;wZ7~uw+UH5kGEig`ax4{oyoRlt5Hd=3 z*UQJ8c3HfZFwEylO!m0kH(T!$nV7#yl?i=i6b7dqD!Gi8x!Wc`#4!B+@=9v<$Upy; zV&z+?rhWcLuu>{)DULIg%_ufNZcZ~lKh=IFrBg0zjY?7qMl|gH7nLr5h}=Z4B~8%f z-Q9aq-u2g{Zn=(%xlFw!{`f=Nv~2o0T7no*)pbc1 z*@$D)IXPFyTqwP>f>qD0rAvTFxMur|j4(lED|nJ|!m?a>aX}&6Awi-BIhJ>3@R6Nj0)$&s<< zXmG)X=W1Y>Ya3P(6ERY(j4&k)JvHnljVB);8w}DsV^ke8IMs^5<9u)*uJB$kD?mrd zRi8yc$4pu;J89O&Xa&rTdu6;T2XW#;>XRnVNiT_F*iEsy=AP87J*MwbCVm`8J`>C1NeVm@c9?LU&cr7Ir z!usuu!g8U3q)PHi@-Drrd}ELF@lWukZ0l`}_uk>P{mtZqHfvd;7ox$fbGVW^E~UVC zrKlSL|8R#Y2i8EqMN)QTV^tK3r9coqF)QH^Vorhn zRjdD7g=^CD!-?+w1NRDhgZ;l%R2)Ps!TC`y0WXbQ52lL<2I7^ zS%7>>BK0HIx?TI-8z2q6t?eIeo2$l#8gsNSTmOotbvrfPYjWJd)c!e-euqmb*#qu5 zKB;d7$<8#+>Hay2l}PW%Ed6DBb4nCf{D89=e&Bz{y%eUZh2|%>YjEd;WK=8N6PP5D zjrQ8Xqig`7ieZMd&~V zo#8k2$6Gq4>N&Imyk%rt-i_Vvnheko3Gl4@074S7ZxF9FuDM*%JTEY~DTNJeqtU_~ zZ(am>yAC6s*Zw%wkz>A@MVDmmQUx)YhwrwiRMARa9KMn{8Q&P}KXoK^G`j&|uDiXY z@QC14=+vf2(H)AD|J`u(mNlIVsbD0P8{7cWN0MzF>+WBk$9RZ=E8tUo-6+NMhbe>Cr(+a7%jv*#s4e%n4Fw8_4;w z+5%}&&a%C0PLue3xQN-c{H+F!v*g;w)N9?5RfpSddn2}VenqW54F)MnU}b9_BqWL2 zA$SWozQCS!?3=EVr144Dj1-EAg=gw*`p>2{u_1U;R;wyk?4bjf6<^*ojx>1Z6ayz} zB@MwgXp$&jO_3RmOA+=oBW@xkp&4P`qiUVDHN+3SOqaO~z^lJlI%@B@$XIEGkV39L zr;CJbSiH*=-_d{Xy-L`KO&&Bn=S&96R#q{az>})ej{gNpyQSWHfOYh*pUU^HPJL7C zb$?2r@UVTvMYZ>vk23slKzu}ok;ZfFbyg*CGqs6c0KB#p+u z4x5WNI_y#r`C7+Ye!PDht`O3h6|mc^fcILQES|YySZaWgn|y?VWwGZB;QI=$kFbJr zgCp;GufBu&tMsPTtet$ZySl#!o7a%#YfoCO}V zTy&3i8zf&EG(GLaPFCy$X>C$Y@mW0Td$t;?tf%)Gv}N#EJ>E=9th1|<#>rsXKpsxw z;rVH|`q$kv)530|tZ4zlcevSm?RKVrexefloc-cIr5`omhm6Zls>x-!_l&>hH;Zft z^m5&Pgc#qXf?;3@dqb~FB1h|!{D!i==##%KyGp?1MD*u$5gBOo@nxaXLa0FeAZ(j0 zU^WCl6CyB{m;1L@0-+CaAGQ5uIXc4}eUI78C=m|@@Y_*sau-)2JDD;P6*if5(Z(=@ zlB&lO;h}hVE?;Wq`Lzpz%U9kzNsz~ePwj7*ZJZ1+>IvbI(6KPBEFe=Z^KHON%o1;T z_qW^>Gkqw&i`7a#qv`Een4w|J%>0dZk>5aBoY)gPn|{kvSy*Dbf11WyGpf%SL4j5t z+61J$*$sytgv?L*!2C|iU^l7^VttE^h{vMzPsn{U8Qx}W!_+W>3saKF&O6-Yx11bX8+>_Ohn2r}Le0SUoL)yd{szxf_WanNE zvNlfI4UZAS5ax(as%QHZEe6dQF{3|?)~3=x1gRRUteW7%iRN^?>5-%65VWJg*9Kl3 zZR+T5+9iRGW{R*p6Jv8+a_-@YuDmN!I*-i1Pf5{O&7!)jsFASHKTQcK8~4Ed`s0Bt zM+#lcH7KI8W)X2CE>Gg22=wMoJnKFXO)m0DMQoO*u>3GAYUoNTD&ym#;-uYnKi8kF zJ}R%*cu=&JNXHP5Bo9bby5V@r(`$=~6**w)-Rh8&9srDV4EN949YB4E$MOB;@iIP? zOaqgx{z0@$k`9~uZUkw~3`Uqf)_nHF{SVEL(>Z;^+A^T?k6t~RmKR*esaFcy>Tf#; zg+sX#$B~(Y#L~d7bHD0*eE`x1D5$sQ#3 zHTs;{#7(4yy-b9C5&gK#j93Pz?;BeC21Yc^3=(^>O5MIOr-R@~ zQ0R&m4e7;7u?`Ibig)bdK-nslVOavU-dn(cP>Z!UJ@K8PkqTt zgpcHj^)T>leMzw^8tzopisaSrN@T}H~jl4L}1+xNjqM~WA+H~ z3#J$%3uX^r%CVPjoyHO4B-y9=2dq;*U(u57^HBt6r0XexR80?a0+B1ke#4X>b@^|E zJ}#qbzFp!QIeEuS;^$6St6?LP|{${4vwVzB1hO2c^FSI6H# z{$usKgg`^m5%FvTf!_-0h`GdzW@$0-PLBp|4i|7IG6hc%sS!j&0%y)22MI#`l>vC# zKaNR>Rw6VBSEto}1JnENfX{FwmGoXfqtfu7-A}8kcL3GBR@zh#yteN5c9qE}E#Mqf zygLQ#gH<3{y%&J}J%^q6J__>+*fs6u&C)})lMgt_iw(gq86~vr8G!3G>63+iKRrXa zdWHV>Qk&>AmCdSN-X3lb?7cskmnGeOm+8za;tg*pv?s&+7Q?3b1Qh_We*hfvG61sm z9MmlxzRv@Ug5@aD`m~eVSeg&0R;n7X|7HOu0Ndfz9~lj28OT6xZ}l3=K&rKxGZxXQ zvtDrGJDb&Na)xC9f##%ilHtK|Un&P;=+KtLvX{cTVRpd$%~qN11eoxIGrnH{AtB=b z_TfQ(LA*a`1>2u4qXO2L1J!iAc(p%SFE*2I48(T$@HM$+z%gbp)&9!Y@)bpmXCU&F)xYFsiqkqVs$4j{&ImRI*j*GZ^L{H`K?VSG>I#I7mlmcV-2%~!K<>UsG~`woYN!1c7ETbujt&A&0q0JU zHQsd;@Y5Jd4+Q$-;Tfi1TX0$8UHt=y7_L{xzq39VlU@P-L{}t*V7b?w^OtV0(ZG+@ zp0FGsqd5tnsqqAxzOYW0>DbVwv~CoawSqf)J53jXfOt-eDb}+GtGb22b@+o1?lF*4 z?h9mizbaJZLIde-bcYb+mjXy@lJw@y*;ZNc@zggP3E$j~K051-x zTK|?MjzVB%pv9_r35XRfH>7fa@Dj}^Psv%?&!B@zlh}Yo_|_N5$WDHU2XfH4725A_ z&ZKZ)a?5fFtQ{s9C&8bS+P>A88_{NBlfCN(ka~meGk{tLXZPiuGOHA20{Q&)fLQYB z8vvDyAI>*?dO${fDVD&h@w?Hw&ZmM%TNL_%_x+8100^6Dq3jBts&50q5@{D=hcQU`iKDx1ui?uscSx{xhGcQZ>EUwS%p>an+*~mTNxXusQd#|PJaXL479uR zEp1{SQ-U)vdILm~HNchW{;7}?&1a)ca9}ub1~@BJ-TVTND$gMCAB)hlfa-^-<>^jD} zC&e}|3KyBZ@H&MvPWN;9v^s2k76P*1f%>6WB{0t#RH{J%Zat*boLcNi|M`8@>nzA{`$>&O7Z)xKR_s3=6@AJCTqk1=hQ#s)%VE)>K;!Yx6P6}A zFvB0QAp*{ogMhE}rOn&RwlEzmGqZkr3cTH5_)%UGVg-A~>?05^w>uK?5P^IS$=-0% z7y&2qGvTUVOwEJ~fX{`;_O&`VJn+=P*h(qY4Lk7|=2~sL(z%zPZwQZ-2BP_MA&QYq zk*xs_1Bo0NhG07?OW@{ks%(Xb57dLvA&FsPLcsFS8V0+19f}-?w8a1k6Kf-UO#B<@ zhFewxFj9s_8wiKqJ{R5;WfN?GlK^-eq;}Pb&CjfJGa$okn#2i7@@cd_Tz-pttI+9b zGf1Bk93^xrZ7BLSYKXjTBjMYSu%*ZO_83SHBmk?v>wf73l-rYI#(o+dFL$`^C@&iRPIJ|meq@oa-u=QH3bPudPlPo&AA|zw`blqTqfqsU z`EopgQt4PA*qB5ICggtp)>Ro8lKD7Lf$r1*$eI9_`_cZU33z$JrgS<->ou(+Xokc1 zmk(ev8Mo0UPHRN9&<{UA0aFD0zuNomps2R3&(jS8l9Pdqga#B4y2)8WlNv-p1SLv? zisX!>MpQBgtw;lc5|t!L5R~9WfhH#j5|su;vP4OKyS?|l``*kqRZ}%Jf6UaQx{6}c z=bU}^TI;vMZ|!qh_q8ACFVB!BFCM>&5Ua9ESpfzQSes2OmGZXBMFYY!YtXXy zq+;*T9*H4JiCfvsRmeLgC+QdnC4Tvwi8eTPBo+yt&NG9?&teoq!2Xl2o|E$qJbp6C zM0wycWI6_lBeK@oPPGf^B<%4Jq)_gYDyES{v*@~d)E%sp#RcgEu!PB@v58Iq)55mCH>;!LOyz;UASy%%%! zShy9v-nV7(9E)ELwb452X!fw9>mg=~klbFMF1{ZK-d5qczvH>C+LKb1*C&r8s%Ae# zzme*1Q_Uz&oXIb{s0lXc@es`3*QY$ZoN1_k4(y#N2CgIY>>v*dGmM2fd{r1!2rFx& z4H2W(=w;Ec!{d^tOxs0ov5SDp9=cYWp-#>2W2Yg9V1# z8kE}nXmVwg8=OLb`6P^X;!SW-L62)atct>fwdspRVG*?*Gj0e9Fh7x3;Rt?BNO>;C|8d_4PS`cJcUfK9bS!4=nW(TH90VqHe{LUP=YJ4YG@z{P(a-! zO6?{*r87Jr@$2l>oEo4N&3T^8mQ=WcrEOT#UI6{E5B*b`1gwy!5>6aC=^4!1WDD(Z zFh$MQ+622EbNnI3%W&&bx65mzlwh@^)ATqBNDjI&?@sOWgsgSiKyZVoQ{=S>XIXuu zA2W6pGJsjkm>N+-A0;gJxiz@Xz(?7dLKlM2M_mcQ<5%wF=OQaOCJt2evi{IJw|@*` zO3#yNu+!oA8KMKRn_oq^Q$FY~u)QY{D6z1PYWR_iEd0IHYXTsqIw3b91Q(dLhgV~| zkMz<)wzVbF}yWRE5L=5^#{;>Gx{T0~@yqS6o)<7(tKwsD*bDUX#&>3OvBrNn5;x12# zrGvlTMMJn1o_d}pe+YRMN&JSr44r{YcxzR<9G5d7S}4_#=xDV)W;hzBR<<=y7249bxsokvzI!4wkng_sJYuMFU0Wqypx)6e?nzEp3 z=o`(`$2#|eID#O%Y{q+dM-6%;F_$OhND9r5g^}eN$K*|~DIxq#(F$9bD4ZWJT)u_e zUNBEiRK4i{%cYW)zHVp9+gkHcVGpfW^sE}`=#`K=B5zH;C)$VmugyZBPZ^r zxm?}K+Bx1Va|TAhB6)F ze(-J$C{EzV>r?F_mBl%4vXI2E8nHMlfyUNP5sKVRtwX~q$3?&QX<%0EHu>SymKb)OmBzgAucaTOTLQ+Kg}~nXmbcEHs9W!p}ENn zE}j7LEu{o2hM2#67_h=1ptKXH$DGms@B=s+1(1o(D|pF@lIQ#9XBn_eK0Q{>LjN2Q zm{tH2m}&d?^zQ>-M}kaG)%!)#ZwLA3_zVzmg5c$de>spBz}I$X9{RUw{yF}iiTy2+ z|E$#Zd31CF&j%9olP6W7pRmG)ud{tQFPTa>k zG?bbkfu@nLJ_D{x1G`Kl^{6j3)Af*|{Pwk4y51W)+S1*Pul4)Tuk&rVUhug11p8{e zO~X0l{OsuPNOKa1RJgC}gs{T=nLQq14nt;XjrT(r(+Kk;g)q6$8cw>)hdBCeC&qvw zAv{bFB@dF@RwMstjWQt82p#iH;IUpCAaabqX3VqozHp?#q#X#;s@Zab0+xIW{Za&h zFa85~&zMQ+wM%aYK2zjk0_nDvCZe~;WZzYnq_3J(bAKCj<BBkx!*U9%Gra7lufTcUFlf|`t;kI5_zYn&+$hQuhYE?8ZHn%XtIknr1k6Tjsj7jPnre%;U2yBu}li&6+pmAq$AgPSw52N6$@Xg>wy_ zPzj2J!eboYjT20AY}g*2zREIlf4lkT`gQ4$NiV^t?&cvJPlD*tLy>$sdvlv6FLu)d zN}OF)vcAGdp~$nJ>W5DhrG(uRn~8Kd=ypKMI25`MPG_DRd=twuv}^R~3jw@hWhd(W zOEDsL|Mk2i3(aw0V`XiHHXs(g+)g5(3QVf?cuAcHLTKY%cKOMG9Kw7|Ugo)H*}{F< z)|b~0GH%XD7yqLErshFE8Jt5e_bY(5CeOfCtpmU0#|FdT<#_7+ZrtDF4QQhw4b~cZ zc1YA*>mNClD&@@FFzN%zk9$xsE{??~_sss0RuZ}Dwov2o)q1|qVcNoUxD|!q9L?BY zyC#UQRJV;6$&$nY)fiq}TU5oqO&Uu5?r?WAhbhuwb4V-6;pn#RWhBQkD)%rj9@xh% zROwm_&YRLo1P`8h!<04{!tlo5t+^n!QR`Dxjc$J~{}!Jb#|OhlDD|svyu_UAZQv8T z2{IL1A7V<9hRwycRSfE_`osfK?QNRp_MMj`*`6#8l`G~OpY)u-gj5I9r40q~P;V{O z&Y=uWZ@XSZ3@@p0)^Z;@y?UJVvgwz{Mvb8KPz|;(c>ncv5vsV1FXQ;6rW7IkQOf6I zI{Zn%64^MO7qY=vqJ*ZE2;9nV&+huKL&Od2YkV<@YNg-K~zOO`mmphcPZ|?MQc)a)e*k_cWJfTH&m7^FJQ-9@aOU9RI3X zY_*jaZB}7b8_W^p@2b135_q8`_yQ@cT8gvg_HeM@)`l3jdMLa9#FNQ3QpKjafN2V~ zz}U^PTKR;-ty^)MAqv@z*o%!-09B3xpvEw(O^5@apd$mtuKQ)qq#3mqgOA^>QH%5h zq{HN*Skw+yi7JthK+l0ne-7#ng~NedKY5K3{j7JVHw%5sZ+{rRF*(iPGU7WP49G$4 zH5aotaDXatGp(Uo6Q|GGe|qca#6*e|q_X?;KFMP9py#CAG=+FvYJ}&&@l?n4?vg3Z zn1?k_CxSxaFkVd8W2;wYDg!Hf^YtZy54+6qR&1sr>{uPdW)PU?17^^xbGLV(9!$TS z1>h6o!W6?koAon|xv;H3(9*&A#^1`iJD)P)38#I^OR+oST>3xvX7&{~xMdh$)?fHq z=Z#n}lf0+?>n)DJa;A6hKrU74_JbF(zVw``3_bfVpO0Q8<}JS}O3|rCx$L`CP9-4; z3u`JP4Zf8(V~OgUTs6|eAIzq{ovJT$UybC?{zm%I_KFp&v=AV*J-r{ff1M3>8oAMn3iXdwiWS>|z(u zlYiv>uw~+TpJ#Yttx0RtNV$LBIUix{ycrpIFYyDE#yg>}HX2talN4Tt(UIqC_mNh$N4C>sqWR8&8b=i{dqWx-bvU&vT$71@^N561l$x- zEQ(ATCKK&UE?|nO-u4`7z5(h9Qfc(QA3lQH zDb2KDFsA(7zkG4u*$VmMy2gJF`M#{p8UtrPARyC+ieH0G)RN4}d{VosWD_Vi(9g z`IeburFLvJ%Hl2j<)B-1X$e=hVaKjCGFv;PWRiImG~dNR|HM(zbOxxqiN(5x{5fzXc42_Uwh`-X~M>ybnm@?i$OJ#9}+=};>^do z66x&g%5SarHrt|e>@NQBTTzZ;y_a=v#n=eEElqy^ivts_Vp%PXxd_hm9 zR+rPtGrP&wz6O0)ZU3-*`uUeZQlfY*wT$Scj_bOiLvOvqiuXd5ha^*@qEUh#(~oUE zciN9_1?FJVSs}-kGglcI(v>-=wiB_T^Rt`is&w&%F;Q^z@mQ}Z`=+qB4 z=)mq~>~8X2lFRd<9qYkTtK%h8h4$DV+e?OV{N0wTvP0sjQJvfQy5Y!I4WVE8T%aJ7doCE0BrZ``b2(>&w-8usZoiw6CN*;?_#fVhUY^MMISEAUv+3lN zJuu}oH9GDz0+Gp$;pra6k;%a-

    )5nF`5Qob>Gx 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 zYcces + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/didi/drawio/Kafka基于网关的生产消费流程.drawio b/docs/didi/drawio/Kafka基于网关的生产消费流程.drawio new file mode 100644 index 00000000..24477ff5 --- /dev/null +++ b/docs/didi/drawio/Kafka基于网关的生产消费流程.drawio @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kafka-manager-common/pom.xml b/kafka-manager-common/pom.xml index f784bf8d..d319fb24 100644 --- a/kafka-manager-common/pom.xml +++ b/kafka-manager-common/pom.xml @@ -112,5 +112,15 @@ lombok compile + + + com.baomidou + mybatis-plus-boot-starter + + + + org.hibernate.validator + hibernate-validator + \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/JobLogBizTypEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/JobLogBizTypEnum.java new file mode 100644 index 00000000..13491dc9 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/JobLogBizTypEnum.java @@ -0,0 +1,21 @@ +package com.xiaojukeji.kafka.manager.common.bizenum; + +import lombok.Getter; + +@Getter +public enum JobLogBizTypEnum { + HA_SWITCH_JOB_LOG(100, "HA-主备切换日志"), + + UNKNOWN(-1, "unknown"), + + ; + + JobLogBizTypEnum(int code, String msg) { + this.code = code; + this.msg = msg; + } + + private final int code; + + private final String msg; +} diff --git a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/common/bizenum/ClusterTaskActionEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskActionEnum.java similarity index 74% rename from kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/common/bizenum/ClusterTaskActionEnum.java rename to kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskActionEnum.java index a51e2c68..293ddfde 100644 --- a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/common/bizenum/ClusterTaskActionEnum.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskActionEnum.java @@ -1,11 +1,11 @@ -package com.xiaojukeji.kafka.manager.kcm.common.bizenum; +package com.xiaojukeji.kafka.manager.common.bizenum; /** * 任务动作 * @author zengqiao * @date 20/4/26 */ -public enum ClusterTaskActionEnum { +public enum TaskActionEnum { UNKNOWN("unknown"), START("start"), @@ -17,13 +17,15 @@ public enum ClusterTaskActionEnum { REDO("redo"), KILL("kill"), + FORCE("force"), + ROLLBACK("rollback"), ; - private String action; + private final String action; - ClusterTaskActionEnum(String action) { + TaskActionEnum(String action) { this.action = action; } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskStatusEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskStatusEnum.java index a478eafe..08045ae2 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskStatusEnum.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TaskStatusEnum.java @@ -1,10 +1,13 @@ package com.xiaojukeji.kafka.manager.common.bizenum; +import lombok.Getter; + /** * 任务状态 * @author zengqiao * @date 2017/6/29. */ +@Getter public enum TaskStatusEnum { UNKNOWN( -1, "未知"), @@ -15,6 +18,7 @@ public enum TaskStatusEnum { RUNNING( 30, "运行中"), KILLING( 31, "杀死中"), + RUNNING_IN_TIMEOUT( 32, "超时运行中"), BLOCKED( 40, "暂停"), @@ -30,31 +34,15 @@ public enum TaskStatusEnum { ; - private Integer code; + private final Integer code; - private String message; + private final String message; TaskStatusEnum(Integer code, String message) { this.code = code; this.message = message; } - public Integer getCode() { - return code; - } - - public String getMessage() { - return message; - } - - @Override - public String toString() { - return "TaskStatusEnum{" + - "code=" + code + - ", message='" + message + '\'' + - '}'; - } - public static Boolean isFinished(Integer code) { return code >= FINISHED.getCode(); } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TopicAuthorityEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TopicAuthorityEnum.java index 7abafb8c..30f2b048 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TopicAuthorityEnum.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/TopicAuthorityEnum.java @@ -17,9 +17,9 @@ public enum TopicAuthorityEnum { OWNER(4, "可管理"), ; - private Integer code; + private final Integer code; - private String message; + private final String message; TopicAuthorityEnum(Integer code, String message) { this.code = code; @@ -34,6 +34,16 @@ public enum TopicAuthorityEnum { return message; } + public static String getMsgByCode(Integer code) { + for (TopicAuthorityEnum authorityEnum: TopicAuthorityEnum.values()) { + if (authorityEnum.getCode().equals(code)) { + return authorityEnum.message; + } + } + + return DENY.message; + } + @Override public String toString() { return "TopicAuthorityEnum{" + diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/gateway/GatewayConfigKeyEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/gateway/GatewayConfigKeyEnum.java index b3403e69..c1b9fdca 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/gateway/GatewayConfigKeyEnum.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/gateway/GatewayConfigKeyEnum.java @@ -10,12 +10,11 @@ public enum GatewayConfigKeyEnum { SD_APP_RATE("SD_APP_RATE", "SD_APP_RATE"), SD_IP_RATE("SD_IP_RATE", "SD_IP_RATE"), SD_SP_RATE("SD_SP_RATE", "SD_SP_RATE"), - ; - private String configType; + private final String configType; - private String configName; + private final String configName; GatewayConfigKeyEnum(String configType, String configName) { this.configType = configType; diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaRelationTypeEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaRelationTypeEnum.java new file mode 100644 index 00000000..3e8a1091 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaRelationTypeEnum.java @@ -0,0 +1,27 @@ +package com.xiaojukeji.kafka.manager.common.bizenum.ha; + +import lombok.Getter; + +/** + * @author zengqiao + * @date 20/7/28 + */ +@Getter +public enum HaRelationTypeEnum { + UNKNOWN(-1, "非高可用"), + + STANDBY(0, "备"), + + ACTIVE(1, "主"), + + MUTUAL_BACKUP(2 , "互备"); + + private final int code; + + private final String msg; + + HaRelationTypeEnum(int code, String msg) { + this.code = code; + this.msg = msg; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaResTypeEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaResTypeEnum.java new file mode 100644 index 00000000..409758c2 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaResTypeEnum.java @@ -0,0 +1,25 @@ +package com.xiaojukeji.kafka.manager.common.bizenum.ha; + +import lombok.Getter; + +/** + * @author zengqiao + * @date 20/7/28 + */ +@Getter +public enum HaResTypeEnum { + CLUSTER(0, "Cluster"), + TOPIC(1, "Topic"), + KAFKA_USER(2, "KafkaUser"), + + ; + + private final int code; + + private final String msg; + + HaResTypeEnum(int code, String msg) { + this.code = code; + this.msg = msg; + } +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaStatusEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaStatusEnum.java new file mode 100644 index 00000000..1ef138f7 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/HaStatusEnum.java @@ -0,0 +1,75 @@ +package com.xiaojukeji.kafka.manager.common.bizenum.ha; + +/** + * @author zengqiao + * @date 20/7/28 + */ +public enum HaStatusEnum { + UNKNOWN(-1, "未知状态"), + + STABLE(HaStatusEnum.STABLE_CODE, "稳定状态"), + +// SWITCHING(HaStatusEnum.SWITCHING_CODE, "切换中"), + SWITCHING_PREPARE( + HaStatusEnum.SWITCHING_PREPARE_CODE, + "主备切换--源集群[%s]--预处理(阻止当前主Topic写入)"), + + SWITCHING_WAITING_IN_SYNC( + HaStatusEnum.SWITCHING_WAITING_IN_SYNC_CODE, + "主备切换--目标集群[%s]--等待主与备Topic数据同步完成"), + + SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH( + HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE, + "主备切换--目标集群[%s]--关闭旧的备Topic的副本同步"), + SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH( + HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH_CODE, + "主备切换--源集群[%s]--开启新的备Topic的副本同步"), + + SWITCHING_CLOSEOUT( + HaStatusEnum.SWITCHING_CLOSEOUT_CODE, + "主备切换--目标集群[%s]--收尾(允许新的主Topic写入)"), + + ; + + public static final int UNKNOWN_CODE = -1; + public static final int STABLE_CODE = 0; + + public static final int SWITCHING_CODE = 100; + public static final int SWITCHING_PREPARE_CODE = 101; + + public static final int SWITCHING_WAITING_IN_SYNC_CODE = 102; + public static final int SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE = 103; + public static final int SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH_CODE = 104; + + public static final int SWITCHING_CLOSEOUT_CODE = 105; + + + private final int code; + + private final String msg; + + public int getCode() { + return code; + } + + public String getMsg(String clusterName) { + if (this.code == UNKNOWN_CODE || this.code == STABLE_CODE) { + return this.msg; + } + return String.format(msg, clusterName); + } + + HaStatusEnum(int code, String msg) { + this.code = code; + this.msg = msg; + } + + public static Integer calProgress(Integer status) { + if (status == null || status == HaStatusEnum.STABLE_CODE || status == UNKNOWN_CODE) { + return 100; + } + + // 最小进度为 1% + return Math.max(1, (status - 101) * 100 / 5); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobActionEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobActionEnum.java new file mode 100644 index 00000000..e5da7391 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobActionEnum.java @@ -0,0 +1,44 @@ +package com.xiaojukeji.kafka.manager.common.bizenum.ha.job; + +public enum HaJobActionEnum { + /** + * + */ + START(1,"start"), + + STOP(2, "stop"), + + CANCEL(3,"cancel"), + + CONTINUE(4,"continue"), + + UNKNOWN(-1, "unknown"); + + HaJobActionEnum(int status, String value) { + this.status = status; + this.value = value; + } + + private final int status; + + private final String value; + + public int getStatus() { + return status; + } + + public String getValue() { + return value; + } + + public static HaJobActionEnum valueOfStatus(int status) { + for (HaJobActionEnum statusEnum : HaJobActionEnum.values()) { + if (status == statusEnum.getStatus()) { + return statusEnum; + } + } + + return HaJobActionEnum.UNKNOWN; + } + +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobStatusEnum.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobStatusEnum.java new file mode 100644 index 00000000..d19e0213 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/bizenum/ha/job/HaJobStatusEnum.java @@ -0,0 +1,75 @@ +package com.xiaojukeji.kafka.manager.common.bizenum.ha.job; + +import com.xiaojukeji.kafka.manager.common.bizenum.TaskStatusEnum; + +public enum HaJobStatusEnum { + /**执行中*/ + RUNNING(TaskStatusEnum.RUNNING), + RUNNING_IN_TIMEOUT(TaskStatusEnum.RUNNING_IN_TIMEOUT), + + SUCCESS(TaskStatusEnum.SUCCEED), + + FAILED(TaskStatusEnum.FAILED), + + UNKNOWN(TaskStatusEnum.UNKNOWN); + + HaJobStatusEnum(TaskStatusEnum taskStatusEnum) { + this.status = taskStatusEnum.getCode(); + this.value = taskStatusEnum.getMessage(); + } + + private final int status; + + private final String value; + + public int getStatus() { + return status; + } + + public String getValue() { + return value; + } + + public static HaJobStatusEnum valueOfStatus(int status) { + for (HaJobStatusEnum statusEnum : HaJobStatusEnum.values()) { + if (status == statusEnum.getStatus()) { + return statusEnum; + } + } + + return HaJobStatusEnum.UNKNOWN; + } + + public static HaJobStatusEnum getStatusBySubStatus(int totalJobNum, + int successJobNu, + int failedJobNu, + int runningJobNu, + int runningInTimeoutJobNu, + int unknownJobNu) { + if (unknownJobNu > 0) { + return UNKNOWN; + } + + if((failedJobNu + runningJobNu + runningInTimeoutJobNu + unknownJobNu) == 0) { + return SUCCESS; + } + + if((runningJobNu + runningInTimeoutJobNu + unknownJobNu) == 0 && failedJobNu > 0) { + return FAILED; + } + + if (runningInTimeoutJobNu > 0) { + return RUNNING_IN_TIMEOUT; + } + + return RUNNING; + } + + public static boolean isRunning(Integer jobStatus) { + return jobStatus != null && (RUNNING.status == jobStatus || RUNNING_IN_TIMEOUT.status == jobStatus); + } + + public static boolean isFinished(Integer jobStatus) { + return jobStatus != null && (SUCCESS.status == jobStatus || FAILED.status == jobStatus); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/ConfigConstant.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/ConfigConstant.java index 361c841f..17f20223 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/ConfigConstant.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/ConfigConstant.java @@ -31,6 +31,8 @@ public class ConfigConstant { public static final String KAFKA_CLUSTER_DO_CONFIG_KEY = "KAFKA_CLUSTER_DO_CONFIG"; + public static final String HA_SWITCH_JOB_TIMEOUT_UNIT_SEC_CONFIG_PREFIX = "HA_SWITCH_JOB_TIMEOUT_UNIT_SEC_CONFIG_CLUSTER"; + private ConfigConstant() { } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/KafkaConstant.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/KafkaConstant.java index 463e9b1a..b1f15bee 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/KafkaConstant.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/KafkaConstant.java @@ -21,6 +21,32 @@ public class KafkaConstant { public static final String INTERNAL_KEY = "INTERNAL"; + public static final String BOOTSTRAP_SERVERS = "bootstrap.servers"; + + + /** + * HA + */ + + public static final String DIDI_KAFKA_ENABLE = "didi.kafka.enable"; + + public static final String DIDI_HA_REMOTE_CLUSTER = "didi.ha.remote.cluster"; + + // TODO 平台来管理配置,不需要底层来管理,因此可以删除该配置 + public static final String DIDI_HA_SYNC_TOPIC_CONFIGS_ENABLED = "didi.ha.sync.topic.configs.enabled"; + + public static final String DIDI_HA_ACTIVE_CLUSTER = "didi.ha.active.cluster"; + + public static final String DIDI_HA_REMOTE_TOPIC = "didi.ha.remote.topic"; + + public static final String SECURITY_PROTOCOL = "security.protocol"; + + public static final String SASL_MECHANISM = "sasl.mechanism"; + + public static final String SASL_JAAS_CONFIG = "sasl.jaas.config"; + + public static final String NONE = "None"; + private KafkaConstant() { } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/MsgConstant.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/MsgConstant.java new file mode 100644 index 00000000..d1c9a1d2 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/constant/MsgConstant.java @@ -0,0 +1,96 @@ +package com.xiaojukeji.kafka.manager.common.constant; + +/** + * 信息模版Constant + * @author zengqiao + * @date 22/03/03 + */ +public class MsgConstant { + private MsgConstant() { + } + + /**************************************************** Cluster ****************************************************/ + + public static String getClusterBizStr(Long clusterPhyId, String clusterName){ + return String.format("集群ID:[%d] 集群名称:[%s]", clusterPhyId, clusterName); + } + + public static String getClusterPhyNotExist(Long clusterPhyId) { + return String.format("集群ID:[%d] 不存在或者未加载", clusterPhyId); + } + + + + /**************************************************** Broker ****************************************************/ + + public static String getBrokerNotExist(Long clusterPhyId, Integer brokerId) { + return String.format("集群ID:[%d] brokerId:[%d] 不存在或未存活", clusterPhyId, brokerId); + } + + public static String getBrokerBizStr(Long clusterPhyId, Integer brokerId) { + return String.format("集群ID:[%d] brokerId:[%d]", clusterPhyId, brokerId); + } + + + /**************************************************** Topic ****************************************************/ + + public static String getTopicNotExist(Long clusterPhyId, String topicName) { + return String.format("集群ID:[%d] Topic名称:[%s] 不存在", clusterPhyId, topicName); + } + + public static String getTopicBizStr(Long clusterPhyId, String topicName) { + return String.format("集群ID:[%d] Topic名称:[%s]", clusterPhyId, topicName); + } + + public static String getTopicExtend(Long existPartitionNum, Long totalPartitionNum,String expandParam){ + return String.format("新增分区, 从:[%d] 增加到:[%d], 详细参数信息:[%s]", existPartitionNum,totalPartitionNum,expandParam); + } + + public static String getClusterTopicKey(Long clusterPhyId, String topicName) { + return String.format("%d@%s", clusterPhyId, topicName); + } + + /**************************************************** Partition ****************************************************/ + + public static String getPartitionNotExist(Long clusterPhyId, String topicName) { + return String.format("集群ID:[%d] Topic名称:[%s] 存在非法的分区ID", clusterPhyId, topicName); + } + + public static String getPartitionNotExist(Long clusterPhyId, String topicName, Integer partitionId) { + return String.format("集群ID:[%d] Topic名称:[%s] 分区Id:[%d] 不存在", clusterPhyId, topicName, partitionId); + } + + /**************************************************** KafkaUser ****************************************************/ + + public static String getKafkaUserBizStr(Long clusterPhyId, String kafkaUser) { + return String.format("集群ID:[%d] kafkaUser:[%s]", clusterPhyId, kafkaUser); + } + + public static String getKafkaUserNotExist(Long clusterPhyId, String kafkaUser) { + return String.format("集群ID:[%d] kafkaUser:[%s] 不存在", clusterPhyId, kafkaUser); + } + + public static String getKafkaUserDuplicate(Long clusterPhyId, String kafkaUser) { + return String.format("集群ID:[%d] kafkaUser:[%s] 已存在", clusterPhyId, kafkaUser); + } + + /**************************************************** ha-Cluster ****************************************************/ + + public static String getActiveClusterDuplicate(Long clusterPhyId, String clusterName) { + return String.format("集群ID:[%d] 主集群:[%s] 已存在", clusterPhyId, clusterName); + } + + /**************************************************** reassign ****************************************************/ + + public static String getReassignJobBizStr(Long jobId, Long clusterPhyId) { + return String.format("任务Id:[%d] 集群ID:[%s]", jobId, clusterPhyId); + } + + public static String getJobIdCanNotNull() { + return "jobId不允许为空"; + } + + public static String getJobNotExist(Long jobId) { + return String.format("jobId:[%d] 不存在", jobId); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/BaseResult.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/BaseResult.java new file mode 100644 index 00000000..05eb6440 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/BaseResult.java @@ -0,0 +1,28 @@ +package com.xiaojukeji.kafka.manager.common.entity; + +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.ToString; + +import java.io.Serializable; + +@Data +@ToString +public class BaseResult implements Serializable { + private static final long serialVersionUID = -5771016784021901099L; + + @ApiModelProperty(value = "信息", example = "成功") + protected String message; + + @ApiModelProperty(value = "状态", example = "0") + protected int code; + + public boolean successful() { + return !this.failed(); + } + + public boolean failed() { + return !Constant.SUCCESS.equals(code); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/Result.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/Result.java index 471a3d07..56416372 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/Result.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/Result.java @@ -1,21 +1,23 @@ package com.xiaojukeji.kafka.manager.common.entity; -import com.alibaba.fastjson.JSON; -import com.xiaojukeji.kafka.manager.common.constant.Constant; - -import java.io.Serializable; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; /** * @author huangyiminghappy@163.com * @date 2019-07-08 */ -public class Result implements Serializable { - private static final long serialVersionUID = -2772975319944108658L; +@Data +@ApiModel(description = "调用结果") +public class Result extends BaseResult { + @ApiModelProperty(value = "数据") + protected T data; - private T data; - private String message; - private String tips; - private int code; + public Result() { + this.code = ResultStatus.SUCCESS.getCode(); + this.message = ResultStatus.SUCCESS.getMessage(); + } public Result(T data) { this.data = data; @@ -23,10 +25,6 @@ public class Result implements Serializable { this.message = ResultStatus.SUCCESS.getMessage(); } - public Result() { - this(null); - } - public Result(Integer code, String message) { this.message = message; this.code = code; @@ -38,48 +36,31 @@ public class Result implements Serializable { this.code = code; } - public T getData() - { - return (T)this.data; + public static Result build(boolean succ) { + if (succ) { + return buildSuc(); + } + return buildFail(); } - public void setData(T data) - { - this.data = data; + public static Result buildFail() { + Result result = new Result<>(); + result.setCode(ResultStatus.FAIL.getCode()); + result.setMessage(ResultStatus.FAIL.getMessage()); + return result; } - public String getMessage() - { - return this.message; - } - - public void setMessage(String message) - { - this.message = message; - } - - public String getTips() { - return tips; - } - - public void setTips(String tips) { - this.tips = tips; - } - - public int getCode() - { - return this.code; - } - - public void setCode(int code) - { - this.code = code; - } - - @Override - public String toString() - { - return JSON.toJSONString(this); + public static Result build(boolean succ, T data) { + Result result = new Result<>(); + if (succ) { + result.setCode(ResultStatus.SUCCESS.getCode()); + result.setMessage(ResultStatus.SUCCESS.getMessage()); + result.setData(data); + } else { + result.setCode(ResultStatus.FAIL.getCode()); + result.setMessage(ResultStatus.FAIL.getMessage()); + } + return result; } public static Result buildSuc() { @@ -97,14 +78,6 @@ public class Result implements Serializable { return result; } - public static Result buildGatewayFailure(String message) { - Result result = new Result<>(); - result.setCode(ResultStatus.GATEWAY_INVALID_REQUEST.getCode()); - result.setMessage(message); - result.setData(null); - return result; - } - public static Result buildFailure(String message) { Result result = new Result<>(); result.setCode(ResultStatus.FAIL.getCode()); @@ -113,10 +86,34 @@ public class Result implements Serializable { return result; } - public static Result buildFrom(ResultStatus resultStatus) { + public static Result buildFailure(String message, T data) { Result result = new Result<>(); - result.setCode(resultStatus.getCode()); - result.setMessage(resultStatus.getMessage()); + result.setCode(ResultStatus.FAIL.getCode()); + result.setMessage(message); + result.setData(data); + return result; + } + + public static Result buildFailure(ResultStatus rs) { + Result result = new Result<>(); + result.setCode(rs.getCode()); + result.setMessage(rs.getMessage()); + result.setData(null); + return result; + } + + public static Result buildGatewayFailure(String message) { + Result result = new Result<>(); + result.setCode(ResultStatus.GATEWAY_INVALID_REQUEST.getCode()); + result.setMessage(message); + result.setData(null); + return result; + } + + public static Result buildFrom(ResultStatus rs) { + Result result = new Result<>(); + result.setCode(rs.getCode()); + result.setMessage(rs.getMessage()); return result; } @@ -128,8 +125,46 @@ public class Result implements Serializable { return result; } - public boolean failed() { - return !Constant.SUCCESS.equals(code); + public static Result buildFromRSAndMsg(ResultStatus resultStatus, String message) { + Result result = new Result<>(); + result.setCode(resultStatus.getCode()); + result.setMessage(message); + result.setData(null); + return result; } + public static Result buildFromRSAndData(ResultStatus rs, T data) { + Result result = new Result<>(); + result.setCode(rs.getCode()); + result.setMessage(rs.getMessage()); + result.setData(data); + return result; + } + + public static Result buildFromIgnoreData(Result anotherResult) { + Result result = new Result<>(); + result.setCode(anotherResult.getCode()); + result.setMessage(anotherResult.getMessage()); + return result; + } + + public static Result buildParamIllegal(String msg) { + Result result = new Result<>(); + result.setCode(ResultStatus.PARAM_ILLEGAL.getCode()); + result.setMessage(ResultStatus.PARAM_ILLEGAL.getMessage() + ":" + msg + ",请检查后再提交!"); + return result; + } + + public boolean hasData(){ + return !failed() && this.data != null; + } + + @Override + public String toString() { + return "Result{" + + "message='" + message + '\'' + + ", code=" + code + + ", data=" + data + + '}'; + } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ResultStatus.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ResultStatus.java index 0f8aebd6..d385cf0c 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ResultStatus.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ResultStatus.java @@ -23,6 +23,8 @@ public enum ResultStatus { API_CALL_EXCEED_LIMIT(1403, "api call exceed limit"), USER_WITHOUT_AUTHORITY(1404, "user without authority"), CHANGE_ZOOKEEPER_FORBIDDEN(1405, "change zookeeper forbidden"), + HA_CLUSTER_DELETE_FORBIDDEN(1409, "先删除主topic,才能删除该集群"), + HA_TOPIC_DELETE_FORBIDDEN(1410, "先解除高可用关系,才能删除该topic"), APP_OFFLINE_FORBIDDEN(1406, "先下线topic,才能下线应用~"), @@ -76,6 +78,8 @@ public enum ResultStatus { QUOTA_NOT_EXIST(7113, "quota not exist, please check clusterId, topicName and appId"), CONSUMER_GROUP_NOT_EXIST(7114, "consumerGroup not exist"), TOPIC_BIZ_DATA_NOT_EXIST(7115, "topic biz data not exist, please sync topic to db"), + SD_ZK_NOT_EXIST(7116, "SD_ZK未配置"), + // 资源已存在 RESOURCE_ALREADY_EXISTED(7200, "资源已经存在"), @@ -88,6 +92,7 @@ public enum ResultStatus { RESOURCE_ALREADY_USED(7400, "资源早已被使用"), + /** * 因为外部系统的问题, 操作时引起的错误, [8000, 9000) * ------------------------------------------------------------------------------------------ @@ -98,6 +103,7 @@ public enum ResultStatus { ZOOKEEPER_READ_FAILED(8021, "zookeeper read failed"), ZOOKEEPER_WRITE_FAILED(8022, "zookeeper write failed"), ZOOKEEPER_DELETE_FAILED(8023, "zookeeper delete failed"), + ZOOKEEPER_OPERATE_FAILED(8024, "zookeeper operate failed"), // 调用集群任务里面的agent失败 CALL_CLUSTER_TASK_AGENT_FAILED(8030, " call cluster task agent failed"), diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ClusterDetailDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ClusterDetailDTO.java index 2e903485..6fb8ad24 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ClusterDetailDTO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ClusterDetailDTO.java @@ -1,11 +1,14 @@ package com.xiaojukeji.kafka.manager.common.entity.ao; +import lombok.Data; + import java.util.Date; /** * @author zengqiao * @date 20/4/23 */ +@Data public class ClusterDetailDTO { private Long clusterId; @@ -41,141 +44,9 @@ public class ClusterDetailDTO { private Integer regionNum; - public Long getClusterId() { - return clusterId; - } + private Integer haRelation; - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getClusterName() { - return clusterName; - } - - public void setClusterName(String clusterName) { - this.clusterName = clusterName; - } - - public String getZookeeper() { - return zookeeper; - } - - public void setZookeeper(String zookeeper) { - this.zookeeper = zookeeper; - } - - public String getBootstrapServers() { - return bootstrapServers; - } - - public void setBootstrapServers(String bootstrapServers) { - this.bootstrapServers = bootstrapServers; - } - - public String getKafkaVersion() { - return kafkaVersion; - } - - public void setKafkaVersion(String kafkaVersion) { - this.kafkaVersion = kafkaVersion; - } - - public String getIdc() { - return idc; - } - - public void setIdc(String idc) { - this.idc = idc; - } - - public Integer getMode() { - return mode; - } - - public void setMode(Integer mode) { - this.mode = mode; - } - - public String getSecurityProperties() { - return securityProperties; - } - - public void setSecurityProperties(String securityProperties) { - this.securityProperties = securityProperties; - } - - public String getJmxProperties() { - return jmxProperties; - } - - public void setJmxProperties(String jmxProperties) { - this.jmxProperties = jmxProperties; - } - - public Integer getStatus() { - return status; - } - - public void setStatus(Integer status) { - this.status = status; - } - - public Date getGmtCreate() { - return gmtCreate; - } - - public void setGmtCreate(Date gmtCreate) { - this.gmtCreate = gmtCreate; - } - - public Date getGmtModify() { - return gmtModify; - } - - public void setGmtModify(Date gmtModify) { - this.gmtModify = gmtModify; - } - - public Integer getBrokerNum() { - return brokerNum; - } - - public void setBrokerNum(Integer brokerNum) { - this.brokerNum = brokerNum; - } - - public Integer getTopicNum() { - return topicNum; - } - - public void setTopicNum(Integer topicNum) { - this.topicNum = topicNum; - } - - public Integer getConsumerGroupNum() { - return consumerGroupNum; - } - - public void setConsumerGroupNum(Integer consumerGroupNum) { - this.consumerGroupNum = consumerGroupNum; - } - - public Integer getControllerId() { - return controllerId; - } - - public void setControllerId(Integer controllerId) { - this.controllerId = controllerId; - } - - public Integer getRegionNum() { - return regionNum; - } - - public void setRegionNum(Integer regionNum) { - this.regionNum = regionNum; - } + private String mutualBackupClusterName; @Override public String toString() { @@ -197,6 +68,8 @@ public class ClusterDetailDTO { ", consumerGroupNum=" + consumerGroupNum + ", controllerId=" + controllerId + ", regionNum=" + regionNum + + ", haRelation=" + haRelation + + ", mutualBackupClusterName='" + mutualBackupClusterName + '\'' + '}'; } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/RdTopicBasic.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/RdTopicBasic.java index bf57a800..97367cfc 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/RdTopicBasic.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/RdTopicBasic.java @@ -1,5 +1,7 @@ package com.xiaojukeji.kafka.manager.common.entity.ao; +import lombok.Data; + import java.util.List; import java.util.Properties; @@ -7,6 +9,7 @@ import java.util.Properties; * @author zengqiao * @date 20/6/10 */ +@Data public class RdTopicBasic { private Long clusterId; @@ -26,77 +29,7 @@ public class RdTopicBasic { private List regionNameList; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getClusterName() { - return clusterName; - } - - public void setClusterName(String clusterName) { - this.clusterName = clusterName; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { - this.topicName = topicName; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public Properties getProperties() { - return properties; - } - - public void setProperties(Properties properties) { - this.properties = properties; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public List getRegionNameList() { - return regionNameList; - } - - public void setRegionNameList(List regionNameList) { - this.regionNameList = regionNameList; - } + private Integer haRelation; @Override public String toString() { @@ -109,7 +42,8 @@ public class RdTopicBasic { ", appName='" + appName + '\'' + ", properties=" + properties + ", description='" + description + '\'' + - ", regionNameList='" + regionNameList + '\'' + + ", regionNameList=" + regionNameList + + ", haRelation=" + haRelation + '}'; } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/HaSwitchTopic.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/HaSwitchTopic.java new file mode 100644 index 00000000..b1f63dfa --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/HaSwitchTopic.java @@ -0,0 +1,54 @@ +package com.xiaojukeji.kafka.manager.common.entity.ao.ha; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +@Data +public class HaSwitchTopic { + /** + * 是否完成 + */ + private boolean finished; + + /** + * 每一个Topic的状态 + */ + private Map activeTopicSwitchStatusMap; + + public HaSwitchTopic(boolean finished) { + this.finished = finished; + this.activeTopicSwitchStatusMap = new HashMap<>(); + } + + public void addHaSwitchTopic(HaSwitchTopic haSwitchTopic) { + this.finished &= haSwitchTopic.finished; + } + + public boolean isFinished() { + return this.finished; + } + + public void addActiveTopicStatus(String activeTopicName, Integer status) { + activeTopicSwitchStatusMap.put(activeTopicName, status); + } + + public boolean isActiveTopicSwitchFinished(String activeTopicName) { + Integer status = activeTopicSwitchStatusMap.get(activeTopicName); + if (status == null) { + return false; + } + + return status.equals(HaStatusEnum.STABLE.getCode()); + } + + @Override + public String toString() { + return "HaSwitchTopic{" + + "finished=" + finished + + ", activeTopicSwitchStatusMap=" + activeTopicSwitchStatusMap + + '}'; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobDetail.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobDetail.java new file mode 100644 index 00000000..5dedd3ce --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobDetail.java @@ -0,0 +1,28 @@ +package com.xiaojukeji.kafka.manager.common.entity.ao.ha.job; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job详情") +public class HaJobDetail { + @ApiModelProperty(value = "Topic名称") + private String topicName; + + @ApiModelProperty(value="主集群ID") + private Long activeClusterPhyId; + + @ApiModelProperty(value="备集群ID") + private Long standbyClusterPhyId; + + @ApiModelProperty(value="Lag和") + private Long sumLag; + + @ApiModelProperty(value="状态") + private Integer status; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobLog.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobLog.java new file mode 100644 index 00000000..dbed3369 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobLog.java @@ -0,0 +1,16 @@ +package com.xiaojukeji.kafka.manager.common.entity.ao.ha.job; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job日志") +public class HaJobLog { + @ApiModelProperty(value = "日志信息") + private String log; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobState.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobState.java new file mode 100644 index 00000000..ce8dd2b9 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaJobState.java @@ -0,0 +1,70 @@ +package com.xiaojukeji.kafka.manager.common.entity.ao.ha.job; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.job.HaJobStatusEnum; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class HaJobState { + + /** + * @see com.xiaojukeji.kafka.manager.common.bizenum.ha.job.HaJobStatusEnum + */ + private int status; + + private int total; + + private int success; + + private int failed; + + private int doing; + private int doingInTimeout; + + private int unknown; + + private Integer progress; + + /** + * 按照状态,直接进行聚合 + */ + public HaJobState(List jobStatusList, Integer progress) { + this.total = jobStatusList.size(); + this.success = 0; + this.failed = 0; + this.doing = 0; + this.doingInTimeout = 0; + this.unknown = 0; + for (Integer jobStatus: jobStatusList) { + if (HaJobStatusEnum.SUCCESS.getStatus() == jobStatus) { + success += 1; + } else if (HaJobStatusEnum.FAILED.getStatus() == jobStatus) { + failed += 1; + } else if (HaJobStatusEnum.RUNNING.getStatus() == jobStatus) { + doing += 1; + } else if (HaJobStatusEnum.RUNNING_IN_TIMEOUT.getStatus() == jobStatus) { + doingInTimeout += 1; + } else { + unknown += 1; + } + } + + this.status = HaJobStatusEnum.getStatusBySubStatus(this.total, this.success, this.failed, this.doing, this.doingInTimeout, this.unknown).getStatus(); + + this.progress = progress; + } + + public HaJobState(Integer doingSize, Integer progress) { + this.total = doingSize; + this.success = 0; + this.failed = 0; + this.doing = doingSize; + this.doingInTimeout = 0; + this.unknown = 0; + + this.progress = progress; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaSubJobExtendData.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaSubJobExtendData.java new file mode 100644 index 00000000..dbb82265 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/ha/job/HaSubJobExtendData.java @@ -0,0 +1,12 @@ +package com.xiaojukeji.kafka.manager.common.entity.ao.ha.job; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HaSubJobExtendData { + private Long sumLag; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicBasicDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicBasicDTO.java index 9150569b..e1d0124d 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicBasicDTO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicBasicDTO.java @@ -1,11 +1,14 @@ package com.xiaojukeji.kafka.manager.common.entity.ao.topic; +import lombok.Data; + import java.util.List; /** * @author arthur * @date 2018/09/03 */ +@Data public class TopicBasicDTO { private Long clusterId; @@ -39,133 +42,7 @@ public class TopicBasicDTO { private Long retentionBytes; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public String getPrincipals() { - return principals; - } - - public void setPrincipals(String principals) { - this.principals = principals; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { - this.topicName = topicName; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public List getRegionNameList() { - return regionNameList; - } - - public void setRegionNameList(List regionNameList) { - this.regionNameList = regionNameList; - } - - public Integer getScore() { - return score; - } - - public void setScore(Integer score) { - this.score = score; - } - - public String getTopicCodeC() { - return topicCodeC; - } - - public void setTopicCodeC(String topicCodeC) { - this.topicCodeC = topicCodeC; - } - - public Integer getPartitionNum() { - return partitionNum; - } - - public void setPartitionNum(Integer partitionNum) { - this.partitionNum = partitionNum; - } - - public Integer getReplicaNum() { - return replicaNum; - } - - public void setReplicaNum(Integer replicaNum) { - this.replicaNum = replicaNum; - } - - public Integer getBrokerNum() { - return brokerNum; - } - - public void setBrokerNum(Integer brokerNum) { - this.brokerNum = brokerNum; - } - - public Long getModifyTime() { - return modifyTime; - } - - public void setModifyTime(Long modifyTime) { - this.modifyTime = modifyTime; - } - - public Long getCreateTime() { - return createTime; - } - - public void setCreateTime(Long createTime) { - this.createTime = createTime; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public Long getRetentionBytes() { - return retentionBytes; - } - - public void setRetentionBytes(Long retentionBytes) { - this.retentionBytes = retentionBytes; - } + private Integer haRelation; @Override public String toString() { @@ -186,6 +63,7 @@ public class TopicBasicDTO { ", createTime=" + createTime + ", retentionTime=" + retentionTime + ", retentionBytes=" + retentionBytes + + ", haRelation=" + haRelation + '}'; } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicOverview.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicOverview.java index fe02fe94..c9666dc1 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicOverview.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/ao/topic/TopicOverview.java @@ -1,10 +1,13 @@ package com.xiaojukeji.kafka.manager.common.entity.ao.topic; +import lombok.Data; + /** * Topic概览信息 * @author zengqiao * @date 20/5/14 */ +@Data public class TopicOverview { private Long clusterId; @@ -32,109 +35,7 @@ public class TopicOverview { private Long logicalClusterId; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { - this.topicName = topicName; - } - - public Integer getReplicaNum() { - return replicaNum; - } - - public void setReplicaNum(Integer replicaNum) { - this.replicaNum = replicaNum; - } - - public Integer getPartitionNum() { - return partitionNum; - } - - public void setPartitionNum(Integer partitionNum) { - this.partitionNum = partitionNum; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public Object getByteIn() { - return byteIn; - } - - public void setByteIn(Object byteIn) { - this.byteIn = byteIn; - } - - public Object getByteOut() { - return byteOut; - } - - public void setByteOut(Object byteOut) { - this.byteOut = byteOut; - } - - public Object getProduceRequest() { - return produceRequest; - } - - public void setProduceRequest(Object produceRequest) { - this.produceRequest = produceRequest; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public Long getUpdateTime() { - return updateTime; - } - - public void setUpdateTime(Long updateTime) { - this.updateTime = updateTime; - } - - public Long getLogicalClusterId() { - return logicalClusterId; - } - - public void setLogicalClusterId(Long logicalClusterId) { - this.logicalClusterId = logicalClusterId; - } + private Integer haRelation; @Override public String toString() { @@ -152,6 +53,7 @@ public class TopicOverview { ", description='" + description + '\'' + ", updateTime=" + updateTime + ", logicalClusterId=" + logicalClusterId + + ", haRelation=" + haRelation + '}'; } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobActionDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobActionDTO.java new file mode 100644 index 00000000..1f1d41c6 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobActionDTO.java @@ -0,0 +1,26 @@ +package com.xiaojukeji.kafka.manager.common.entity.dto.ha; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +@ApiModel(description="Topic信息") +public class ASSwitchJobActionDTO { + /** + * @see com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum + */ + @NotBlank(message = "action不允许为空") + @ApiModelProperty(value = "动作, force") + private String action; + +// @NotNull(message = "all不允许为NULL") +// @ApiModelProperty(value = "所有的Topic") +// private Boolean allJumpWaitInSync; +// +// @NotNull(message = "jumpWaitInSyncActiveTopicList不允许为NULL") +// @ApiModelProperty(value = "操作的Topic") +// private List jumpWaitInSyncActiveTopicList; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobDTO.java new file mode 100644 index 00000000..8c4ae0dc --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/ha/ASSwitchJobDTO.java @@ -0,0 +1,31 @@ +package com.xiaojukeji.kafka.manager.common.entity.dto.ha; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +@Data +@ApiModel(description="主备切换任务") +public class ASSwitchJobDTO { + @NotNull(message = "all不允许为NULL") + @ApiModelProperty(value = "所有Topic") + private Boolean all; + + @NotNull(message = "mustContainAllKafkaUserTopics不允许为NULL") + @ApiModelProperty(value = "是否需要包含KafkaUser关联的所有Topic") + private Boolean mustContainAllKafkaUserTopics; + + @NotNull(message = "activeClusterPhyId不允许为NULL") + @ApiModelProperty(value="主集群ID") + private Long activeClusterPhyId; + + @NotNull(message = "standbyClusterPhyId不允许为NULL") + @ApiModelProperty(value="备集群ID") + private Long standbyClusterPhyId; + + @NotNull(message = "topicNameList不允许为NULL") + private List topicNameList; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/op/topic/HaTopicRelationDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/op/topic/HaTopicRelationDTO.java new file mode 100644 index 00000000..d6aea1e5 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/op/topic/HaTopicRelationDTO.java @@ -0,0 +1,51 @@ +package com.xiaojukeji.kafka.manager.common.entity.dto.op.topic; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author huangyiminghappy@163.com, zengqiao + * @date 2022-06-29 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@ApiModel(description = "Topic高可用关联|解绑") +public class HaTopicRelationDTO { + @NotNull(message = "主集群id不能为空") + @ApiModelProperty(value = "主集群id") + private Long activeClusterId; + + @NotNull(message = "备集群id不能为空") + @ApiModelProperty(value = "备集群id") + private Long standbyClusterId; + + @NotNull(message = "是否应用于所有topic") + @ApiModelProperty(value = "是否应用于所有topic") + private Boolean all; + + @ApiModelProperty(value = "需要关联|解绑的topic名称列表") + private List topicNames; + + @Override + public String toString() { + return "HaTopicRelationDTO{" + + ", activeClusterId=" + activeClusterId + + ", standbyClusterId=" + standbyClusterId + + ", all=" + all + + ", topicNames=" + topicNames + + '}'; + } + + public boolean paramLegal() { + if(!all && ValidateUtils.isEmptyList(topicNames)) { + return false; + } + return true; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/AppRelateTopicsDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/AppRelateTopicsDTO.java new file mode 100644 index 00000000..bc49f136 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/AppRelateTopicsDTO.java @@ -0,0 +1,24 @@ +package com.xiaojukeji.kafka.manager.common.entity.dto.rd; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * @author zengqiao + * @date 20/5/4 + */ +@Data +@ApiModel(description="App关联Topic信息") +public class AppRelateTopicsDTO { + @NotNull(message = "clusterPhyId不允许为NULL") + @ApiModelProperty(value="物理集群ID") + private Long clusterPhyId; + + @NotNull(message = "filterTopicNameList不允许为NULL") + @ApiModelProperty(value="过滤的Topic列表") + private List filterTopicNameList; +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/ClusterDTO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/ClusterDTO.java index 7afc09c6..9b913539 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/ClusterDTO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/dto/rd/ClusterDTO.java @@ -4,11 +4,13 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.Data; /** * @author zengqiao * @date 20/4/23 */ +@Data @ApiModel(description = "集群接入&修改") @JsonIgnoreProperties(ignoreUnknown = true) public class ClusterDTO { @@ -33,60 +35,21 @@ public class ClusterDTO { @ApiModelProperty(value="Jmx配置") private String jmxProperties; - public Long getClusterId() { - return clusterId; - } + @ApiModelProperty(value="主集群Id") + private Long activeClusterId; - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } + @ApiModelProperty(value="是否高可用") + private boolean isHa; - public String getClusterName() { - return clusterName; - } - - public void setClusterName(String clusterName) { - this.clusterName = clusterName; - } - - public String getZookeeper() { - return zookeeper; - } - - public void setZookeeper(String zookeeper) { - this.zookeeper = zookeeper; - } - - public String getBootstrapServers() { - return bootstrapServers; - } - - public void setBootstrapServers(String bootstrapServers) { - this.bootstrapServers = bootstrapServers; - } - - public String getIdc() { - return idc; - } - - public void setIdc(String idc) { - this.idc = idc; - } - - public String getSecurityProperties() { - return securityProperties; - } - - public void setSecurityProperties(String securityProperties) { - this.securityProperties = securityProperties; - } - - public String getJmxProperties() { - return jmxProperties; - } - - public void setJmxProperties(String jmxProperties) { - this.jmxProperties = jmxProperties; + public boolean legal() { + if (ValidateUtils.isNull(clusterName) + || ValidateUtils.isNull(zookeeper) + || ValidateUtils.isNull(idc) + || ValidateUtils.isNull(bootstrapServers) + || (isHa && ValidateUtils.isNull(activeClusterId))) { + return false; + } + return true; } @Override @@ -99,16 +62,8 @@ public class ClusterDTO { ", idc='" + idc + '\'' + ", securityProperties='" + securityProperties + '\'' + ", jmxProperties='" + jmxProperties + '\'' + + ", activeClusterId=" + activeClusterId + + ", isHa=" + isHa + '}'; } - - public boolean legal() { - if (ValidateUtils.isNull(clusterName) - || ValidateUtils.isNull(zookeeper) - || ValidateUtils.isNull(idc) - || ValidateUtils.isNull(bootstrapServers)) { - return false; - } - return true; - } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/Pagination.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/Pagination.java new file mode 100644 index 00000000..cb0faf84 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/Pagination.java @@ -0,0 +1,24 @@ +package com.xiaojukeji.kafka.manager.common.entity.pagination; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +@ApiModel(description = "分页信息") +public class Pagination { + @ApiModelProperty(value = "总记录数", example = "100") + private long total; + + @ApiModelProperty(value = "当前页码", example = "0") + private long pageNo; + + @ApiModelProperty(value = "单页大小", example = "10") + private long pageSize; + + public Pagination(long total, long pageNo, long pageSize) { + this.total = total; + this.pageNo = pageNo; + this.pageSize = pageSize; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/PaginationData.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/PaginationData.java new file mode 100644 index 00000000..04a90b86 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pagination/PaginationData.java @@ -0,0 +1,17 @@ +package com.xiaojukeji.kafka.manager.common.entity.pagination; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +@Data +@ApiModel(description = "分页数据") +public class PaginationData { + @ApiModelProperty(value = "业务数据") + private List bizData; + + @ApiModelProperty(value = "分页信息") + private Pagination pagination; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/BaseDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/BaseDO.java new file mode 100644 index 00000000..63113694 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/BaseDO.java @@ -0,0 +1,30 @@ +package com.xiaojukeji.kafka.manager.common.entity.pojo; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * @author zengqiao + * @date 21/07/19 + */ +@Data +public class BaseDO implements Serializable { + private static final long serialVersionUID = 8782560709154468485L; + + /** + * 主键ID + */ + protected Long id; + + /** + * 创建时间 + */ + protected Date createTime; + + /** + * 更新时间 + */ + protected Date modifyTime; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/LogicalClusterDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/LogicalClusterDO.java index db81c1c9..50362fe4 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/LogicalClusterDO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/LogicalClusterDO.java @@ -1,11 +1,18 @@ package com.xiaojukeji.kafka.manager.common.entity.pojo; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + import java.util.Date; /** * @author zengqiao * @date 20/6/29 */ +@Data +@ToString +@NoArgsConstructor public class LogicalClusterDO { private Long id; @@ -27,99 +34,17 @@ public class LogicalClusterDO { private Date gmtModify; - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { + public LogicalClusterDO(String name, + String identification, + Integer mode, + String appId, + Long clusterId, + String regionList) { this.name = name; - } - - public String getIdentification() { - return identification; - } - - public void setIdentification(String identification) { this.identification = identification; - } - - public Integer getMode() { - return mode; - } - - public void setMode(Integer mode) { this.mode = mode; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { this.appId = appId; - } - - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { this.clusterId = clusterId; - } - - public String getRegionList() { - return regionList; - } - - public void setRegionList(String regionList) { this.regionList = regionList; } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public Date getGmtCreate() { - return gmtCreate; - } - - public void setGmtCreate(Date gmtCreate) { - this.gmtCreate = gmtCreate; - } - - public Date getGmtModify() { - return gmtModify; - } - - public void setGmtModify(Date gmtModify) { - this.gmtModify = gmtModify; - } - - @Override - public String toString() { - return "LogicalClusterDO{" + - "id=" + id + - ", name='" + name + '\'' + - ", identification='" + identification + '\'' + - ", mode=" + mode + - ", appId='" + appId + '\'' + - ", clusterId=" + clusterId + - ", regionList='" + regionList + '\'' + - ", description='" + description + '\'' + - ", gmtCreate=" + gmtCreate + - ", gmtModify=" + gmtModify + - '}'; - } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/RegionDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/RegionDO.java index 1f948510..e300e9ce 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/RegionDO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/RegionDO.java @@ -1,7 +1,14 @@ package com.xiaojukeji.kafka.manager.common.entity.pojo; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + import java.util.Date; +@Data +@ToString +@NoArgsConstructor public class RegionDO implements Comparable { private Long id; @@ -25,111 +32,13 @@ public class RegionDO implements Comparable { private String description; - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Integer getStatus() { - return status; - } - - public void setStatus(Integer status) { + public RegionDO(Integer status, String name, Long clusterId, String brokerList) { this.status = status; - } - - public Date getGmtCreate() { - return gmtCreate; - } - - public void setGmtCreate(Date gmtCreate) { - this.gmtCreate = gmtCreate; - } - - public Date getGmtModify() { - return gmtModify; - } - - public void setGmtModify(Date gmtModify) { - this.gmtModify = gmtModify; - } - - public String getName() { - return name; - } - - public void setName(String name) { this.name = name; - } - - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { this.clusterId = clusterId; - } - - public String getBrokerList() { - return brokerList; - } - - public void setBrokerList(String brokerList) { this.brokerList = brokerList; } - public Long getCapacity() { - return capacity; - } - - public void setCapacity(Long capacity) { - this.capacity = capacity; - } - - public Long getRealUsed() { - return realUsed; - } - - public void setRealUsed(Long realUsed) { - this.realUsed = realUsed; - } - - public Long getEstimateUsed() { - return estimateUsed; - } - - public void setEstimateUsed(Long estimateUsed) { - this.estimateUsed = estimateUsed; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @Override - public String toString() { - return "RegionDO{" + - "id=" + id + - ", status=" + status + - ", gmtCreate=" + gmtCreate + - ", gmtModify=" + gmtModify + - ", name='" + name + '\'' + - ", clusterId=" + clusterId + - ", brokerList='" + brokerList + '\'' + - ", capacity=" + capacity + - ", realUsed=" + realUsed + - ", estimateUsed=" + estimateUsed + - ", description='" + description + '\'' + - '}'; - } - @Override public int compareTo(RegionDO regionDO) { return this.id.compareTo(regionDO.id); diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/TopicDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/TopicDO.java index ecb97e47..e44e58b3 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/TopicDO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/TopicDO.java @@ -2,6 +2,8 @@ package com.xiaojukeji.kafka.manager.common.entity.pojo; import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicCreationDTO; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import lombok.Data; +import lombok.NoArgsConstructor; import java.util.Date; @@ -9,6 +11,8 @@ import java.util.Date; * @author zengqiao * @date 20/4/24 */ +@Data +@NoArgsConstructor public class TopicDO { private Long id; @@ -26,70 +30,14 @@ public class TopicDO { private Long peakBytesIn; - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { + public TopicDO(String appId, Long clusterId, String topicName, String description, Long peakBytesIn) { this.appId = appId; - } - - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { this.clusterId = clusterId; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { this.topicName = topicName; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { this.description = description; - } - - public Long getPeakBytesIn() { - return peakBytesIn; - } - - public void setPeakBytesIn(Long peakBytesIn) { this.peakBytesIn = peakBytesIn; } - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Date getGmtCreate() { - return gmtCreate; - } - - public void setGmtCreate(Date gmtCreate) { - this.gmtCreate = gmtCreate; - } - - public Date getGmtModify() { - return gmtModify; - } - - public void setGmtModify(Date gmtModify) { - this.gmtModify = gmtModify; - } - public static TopicDO buildFrom(TopicCreationDTO dto) { TopicDO topicDO = new TopicDO(); topicDO.setAppId(dto.getAppId()); diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASRelationDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASRelationDO.java new file mode 100644 index 00000000..a55d5d00 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASRelationDO.java @@ -0,0 +1,69 @@ +package com.xiaojukeji.kafka.manager.common.entity.pojo.ha; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.xiaojukeji.kafka.manager.common.entity.pojo.BaseDO; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * HA-主备关系表 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@TableName("ha_active_standby_relation") +public class HaASRelationDO extends BaseDO { + /** + * 主集群ID + */ + private Long activeClusterPhyId; + + /** + * 主集群资源名称 + */ + private String activeResName; + + /** + * 备集群ID + */ + private Long standbyClusterPhyId; + + /** + * 备集群资源名称 + */ + private String standbyResName; + + /** + * 资源类型 + */ + private Integer resType; + + /** + * 主备状态 + */ + private Integer status; + + /** + * 主备关系中的唯一性字段 + */ + private String uniqueField; + + public HaASRelationDO(Long id, Integer status) { + this.id = id; + this.status = status; + } + + public HaASRelationDO(Long activeClusterPhyId, String activeResName, Long standbyClusterPhyId, String standbyResName, Integer resType, Integer status) { + this.activeClusterPhyId = activeClusterPhyId; + this.activeResName = activeResName; + this.standbyClusterPhyId = standbyClusterPhyId; + this.standbyResName = standbyResName; + this.resType = resType; + this.status = status; + + // 主备两个资源之间唯一,但是不保证两个资源之间,只存在主备关系,也可能存在双活关系,及各自都为对方的主备 + this.uniqueField = String.format("%d_%s||%d_%s||%d", activeClusterPhyId, activeResName, standbyClusterPhyId, standbyResName, resType); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchJobDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchJobDO.java new file mode 100644 index 00000000..d68c4f88 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchJobDO.java @@ -0,0 +1,42 @@ +package com.xiaojukeji.kafka.manager.common.entity.pojo.ha; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.xiaojukeji.kafka.manager.common.entity.pojo.BaseDO; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * HA-主备关系切换任务表 + */ +@Data +@NoArgsConstructor +@TableName("ha_active_standby_switch_job") +public class HaASSwitchJobDO extends BaseDO { + /** + * 主集群ID + */ + private Long activeClusterPhyId; + + /** + * 备集群ID + */ + private Long standbyClusterPhyId; + + /** + * 主备状态 + */ + private Integer jobStatus; + + /** + * 操作人 + */ + private String operator; + + public HaASSwitchJobDO(Long activeClusterPhyId, Long standbyClusterPhyId, Integer jobStatus, String operator) { + this.activeClusterPhyId = activeClusterPhyId; + this.standbyClusterPhyId = standbyClusterPhyId; + this.jobStatus = jobStatus; + this.operator = operator; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchSubJobDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchSubJobDO.java new file mode 100644 index 00000000..c62c8834 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/HaASSwitchSubJobDO.java @@ -0,0 +1,67 @@ +package com.xiaojukeji.kafka.manager.common.entity.pojo.ha; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.xiaojukeji.kafka.manager.common.entity.pojo.BaseDO; +import lombok.Data; +import lombok.NoArgsConstructor; + + +/** + * HA-主备关系切换子任务表 + */ +@Data +@NoArgsConstructor +@TableName("ha_active_standby_switch_sub_job") +public class HaASSwitchSubJobDO extends BaseDO { + /** + * 任务ID + */ + private Long jobId; + + /** + * 主集群ID + */ + private Long activeClusterPhyId; + + /** + * 主集群资源名称 + */ + private String activeResName; + + /** + * 备集群ID + */ + private Long standbyClusterPhyId; + + /** + * 备集群资源名称 + */ + private String standbyResName; + + /** + * 资源类型 + */ + private Integer resType; + + /** + * 任务状态 + */ + private Integer jobStatus; + + /** + * 扩展数据 + * @see com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaSubJobExtendData + */ + private String extendData; + + public HaASSwitchSubJobDO(Long jobId, Long activeClusterPhyId, String activeResName, Long standbyClusterPhyId, String standbyResName, Integer resType, Integer jobStatus, String extendData) { + this.jobId = jobId; + this.activeClusterPhyId = activeClusterPhyId; + this.activeResName = activeResName; + this.standbyClusterPhyId = standbyClusterPhyId; + this.standbyResName = standbyResName; + this.resType = resType; + this.jobStatus = jobStatus; + this.extendData = extendData; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/JobLogDO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/JobLogDO.java new file mode 100644 index 00000000..ea5b4e57 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/pojo/ha/JobLogDO.java @@ -0,0 +1,50 @@ +package com.xiaojukeji.kafka.manager.common.entity.pojo.ha; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.xiaojukeji.kafka.manager.common.entity.pojo.BaseDO; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + + +@Data +@NoArgsConstructor +@TableName("job_log") +public class JobLogDO extends BaseDO { + /** + * 业务类型 + */ + private Integer bizType; + + /** + * 业务关键字 + */ + private String bizKeyword; + + /** + * 打印时间 + */ + private Date printTime; + + /** + * 内容 + */ + private String content; + + public JobLogDO(Integer bizType, String bizKeyword) { + this.bizType = bizType; + this.bizKeyword = bizKeyword; + } + + public JobLogDO(Integer bizType, String bizKeyword, Date printTime, String content) { + this.bizType = bizType; + this.bizKeyword = bizKeyword; + this.printTime = printTime; + this.content = content; + } + + public JobLogDO setAndCopyNew(Date printTime, String content) { + return new JobLogDO(this.bizType, this.bizKeyword, printTime, content); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/common/TopicOverviewVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/common/TopicOverviewVO.java index 724e31b2..9b0d94fd 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/common/TopicOverviewVO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/common/TopicOverviewVO.java @@ -2,12 +2,14 @@ package com.xiaojukeji.kafka.manager.common.entity.vo.common; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.Data; /** * Topic信息 * @author zengqiao * @date 19/4/1 */ +@Data @ApiModel(description = "Topic信息概览") public class TopicOverviewVO { @ApiModelProperty(value = "集群ID") @@ -49,109 +51,8 @@ public class TopicOverviewVO { @ApiModelProperty(value = "逻辑集群id") private Long logicalClusterId; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { - this.topicName = topicName; - } - - public Integer getReplicaNum() { - return replicaNum; - } - - public void setReplicaNum(Integer replicaNum) { - this.replicaNum = replicaNum; - } - - public Integer getPartitionNum() { - return partitionNum; - } - - public void setPartitionNum(Integer partitionNum) { - this.partitionNum = partitionNum; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public Object getByteIn() { - return byteIn; - } - - public void setByteIn(Object byteIn) { - this.byteIn = byteIn; - } - - public Object getByteOut() { - return byteOut; - } - - public void setByteOut(Object byteOut) { - this.byteOut = byteOut; - } - - public Object getProduceRequest() { - return produceRequest; - } - - public void setProduceRequest(Object produceRequest) { - this.produceRequest = produceRequest; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public Long getUpdateTime() { - return updateTime; - } - - public void setUpdateTime(Long updateTime) { - this.updateTime = updateTime; - } - - public Long getLogicalClusterId() { - return logicalClusterId; - } - - public void setLogicalClusterId(Long logicalClusterId) { - this.logicalClusterId = logicalClusterId; - } + @ApiModelProperty(value = "高可用关系:1:主topic, 0:备topic , 其他:非高可用topic") + private Integer haRelation; @Override public String toString() { @@ -169,6 +70,7 @@ public class TopicOverviewVO { ", description='" + description + '\'' + ", updateTime=" + updateTime + ", logicalClusterId=" + logicalClusterId + + ", haRelation=" + haRelation + '}'; } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterTopicVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterTopicVO.java new file mode 100644 index 00000000..ddd8e6f5 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterTopicVO.java @@ -0,0 +1,34 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.ha; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * @author zengqiao + * @date 20/4/29 + */ +@Data +@ApiModel(description="HA集群-Topic信息") +public class HaClusterTopicVO { + @ApiModelProperty(value="当前查询的集群ID") + private Long clusterId; + + @ApiModelProperty(value="Topic名称") + private String topicName; + + @ApiModelProperty(value="生产Acl数量") + private Integer produceAclNum; + + @ApiModelProperty(value="消费Acl数量") + private Integer consumeAclNum; + + @ApiModelProperty(value="主集群ID") + private Long activeClusterId; + + @ApiModelProperty(value="备集群ID") + private Long standbyClusterId; + + @ApiModelProperty(value="主备状态") + private Integer status; +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterVO.java new file mode 100644 index 00000000..765da022 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/HaClusterVO.java @@ -0,0 +1,48 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.ha; + +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ClusterBaseVO; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * @author zengqiao + * @date 20/4/29 + */ +@Data +@ApiModel(description="HA集群-集群信息") +public class HaClusterVO extends ClusterBaseVO { + @ApiModelProperty(value="broker数量") + private Integer brokerNum; + + @ApiModelProperty(value="topic数量") + private Integer topicNum; + + @ApiModelProperty(value="消费组数") + private Integer consumerGroupNum; + + @ApiModelProperty(value="region数") + private Integer regionNum; + + @ApiModelProperty(value="ControllerID") + private Integer controllerId; + + /** + * @see com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum + */ + @ApiModelProperty(value="主备状态") + private Integer haStatus; + + @ApiModelProperty(value="主topic数") + private Long activeTopicCount; + + @ApiModelProperty(value="备topic数") + private Long standbyTopicCount; + + @ApiModelProperty(value="备集群信息") + private HaClusterVO haClusterVO; + + @ApiModelProperty(value="切换任务id") + private Long haASSwitchJobId; + +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobDetailVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobDetailVO.java new file mode 100644 index 00000000..871e5f77 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobDetailVO.java @@ -0,0 +1,37 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.ha.job; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job详情") +public class HaJobDetailVO { + @ApiModelProperty(value = "Topic名称") + private String topicName; + + @ApiModelProperty(value="主物理集群ID") + private Long activeClusterPhyId; + + @ApiModelProperty(value="主物理集群名称") + private String activeClusterPhyName; + + @ApiModelProperty(value="备物理集群ID") + private Long standbyClusterPhyId; + + @ApiModelProperty(value="备物理集群名称") + private String standbyClusterPhyName; + + @ApiModelProperty(value="Lag和") + private Long sumLag; + + @ApiModelProperty(value="状态") + private Integer status; + + @ApiModelProperty(value="超时时间配置") + private Long timeoutUnitSecConfig; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobStateVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobStateVO.java new file mode 100644 index 00000000..0850e86e --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/ha/job/HaJobStateVO.java @@ -0,0 +1,46 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.ha.job; + +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobState; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job状态") +public class HaJobStateVO { + @ApiModelProperty(value = "任务总数") + private Integer jobNu; + + @ApiModelProperty(value = "运行中的任务数") + private Integer runningNu; + + @ApiModelProperty(value = "超时运行中的任务数") + private Integer runningInTimeoutNu; + + @ApiModelProperty(value = "准备好待运行的任务数") + private Integer waitingNu; + + @ApiModelProperty(value = "运行成功的任务数") + private Integer successNu; + + @ApiModelProperty(value = "运行失败的任务数") + private Integer failedNu; + + @ApiModelProperty(value = "进度,[0 - 100]") + private Integer progress; + + public HaJobStateVO(HaJobState jobState) { + this.jobNu = jobState.getTotal(); + this.runningNu = jobState.getDoing(); + this.runningInTimeoutNu = jobState.getDoingInTimeout(); + this.waitingNu = 0; + this.successNu = jobState.getSuccess(); + this.failedNu = jobState.getFailed(); + + this.progress = jobState.getProgress(); + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/HaClusterTopicHaStatusVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/HaClusterTopicHaStatusVO.java new file mode 100644 index 00000000..8681e66a --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/HaClusterTopicHaStatusVO.java @@ -0,0 +1,26 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * @author zengqiao + * @date 20/4/8 + */ +@Data +@ApiModel(value = "集群的topic高可用状态") +public class HaClusterTopicHaStatusVO { + @ApiModelProperty(value = "物理集群ID") + private Long clusterId; + + @ApiModelProperty(value = "物理集群名称") + private String clusterName; + + @ApiModelProperty(value = "Topic名称") + private String topicName; + + @ApiModelProperty(value = "高可用关系:1:主topic, 0:备topic , 其他:非高可用topic") + private Integer haRelation; + +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicBasicVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicBasicVO.java index b200a150..ddaf8dca 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicBasicVO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicBasicVO.java @@ -2,6 +2,7 @@ package com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.Data; import java.util.List; @@ -10,6 +11,7 @@ import java.util.List; * @author zengqiao * @date 19/4/1 */ +@Data @ApiModel(description = "Topic基本信息") public class TopicBasicVO { @ApiModelProperty(value = "集群id") @@ -57,125 +59,8 @@ public class TopicBasicVO { @ApiModelProperty(value = "所属region") private List regionNameList; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public Integer getPartitionNum() { - return partitionNum; - } - - public void setPartitionNum(Integer partitionNum) { - this.partitionNum = partitionNum; - } - - public Integer getReplicaNum() { - return replicaNum; - } - - public void setReplicaNum(Integer replicaNum) { - this.replicaNum = replicaNum; - } - - public String getPrincipals() { - return principals; - } - - public void setPrincipals(String principals) { - this.principals = principals; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public Long getRetentionBytes() { - return retentionBytes; - } - - public void setRetentionBytes(Long retentionBytes) { - this.retentionBytes = retentionBytes; - } - - public Long getCreateTime() { - return createTime; - } - - public void setCreateTime(Long createTime) { - this.createTime = createTime; - } - - public Long getModifyTime() { - return modifyTime; - } - - public void setModifyTime(Long modifyTime) { - this.modifyTime = modifyTime; - } - - public Integer getScore() { - return score; - } - - public void setScore(Integer score) { - this.score = score; - } - - public String getTopicCodeC() { - return topicCodeC; - } - - public void setTopicCodeC(String topicCodeC) { - this.topicCodeC = topicCodeC; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getBootstrapServers() { - return bootstrapServers; - } - - public void setBootstrapServers(String bootstrapServers) { - this.bootstrapServers = bootstrapServers; - } - - public List getRegionNameList() { - return regionNameList; - } - - public void setRegionNameList(List regionNameList) { - this.regionNameList = regionNameList; - } + @ApiModelProperty(value = "高可用关系:1:主topic, 0:备topic , 其他:非主备topic") + private Integer haRelation; @Override public String toString() { @@ -195,6 +80,7 @@ public class TopicBasicVO { ", description='" + description + '\'' + ", bootstrapServers='" + bootstrapServers + '\'' + ", regionNameList=" + regionNameList + + ", haRelation=" + haRelation + '}'; } } diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicHaVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicHaVO.java new file mode 100644 index 00000000..9f65f15a --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/normal/topic/TopicHaVO.java @@ -0,0 +1,26 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * @author zengqiao + * @date 20/4/8 + */ +@Data +@ApiModel(value = "Topic信息") +public class TopicHaVO { + @ApiModelProperty(value = "物理集群ID") + private Long clusterId; + + @ApiModelProperty(value = "物理集群名称") + private String clusterName; + + @ApiModelProperty(value = "Topic名称") + private String topicName; + + @ApiModelProperty(value = "高可用关系:1:主topic, 0:备topic , 其他:非高可用topic") + private Integer haRelation; + +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/RdTopicBasicVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/RdTopicBasicVO.java index 75d50f05..49074d94 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/RdTopicBasicVO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/RdTopicBasicVO.java @@ -2,6 +2,7 @@ package com.xiaojukeji.kafka.manager.common.entity.vo.rd; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.Data; import java.util.List; import java.util.Properties; @@ -10,6 +11,7 @@ import java.util.Properties; * @author zengqiao * @date 20/6/10 */ +@Data @ApiModel(description = "Topic基本信息(RD视角)") public class RdTopicBasicVO { @ApiModelProperty(value = "集群ID") @@ -39,77 +41,8 @@ public class RdTopicBasicVO { @ApiModelProperty(value = "所属region") private List regionNameList; - public Long getClusterId() { - return clusterId; - } - - public void setClusterId(Long clusterId) { - this.clusterId = clusterId; - } - - public String getClusterName() { - return clusterName; - } - - public void setClusterName(String clusterName) { - this.clusterName = clusterName; - } - - public String getTopicName() { - return topicName; - } - - public void setTopicName(String topicName) { - this.topicName = topicName; - } - - public Long getRetentionTime() { - return retentionTime; - } - - public void setRetentionTime(Long retentionTime) { - this.retentionTime = retentionTime; - } - - public String getAppId() { - return appId; - } - - public void setAppId(String appId) { - this.appId = appId; - } - - public String getAppName() { - return appName; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public Properties getProperties() { - return properties; - } - - public void setProperties(Properties properties) { - this.properties = properties; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public List getRegionNameList() { - return regionNameList; - } - - public void setRegionNameList(List regionNameList) { - this.regionNameList = regionNameList; - } + @ApiModelProperty(value = "高可用关系:1:主topic, 0:备topic , 其他:非主备topic") + private Integer haRelation; @Override public String toString() { @@ -122,7 +55,8 @@ public class RdTopicBasicVO { ", appName='" + appName + '\'' + ", properties=" + properties + ", description='" + description + '\'' + - ", regionNameList='" + regionNameList + '\'' + + ", regionNameList=" + regionNameList + + ", haRelation=" + haRelation + '}'; } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/app/AppRelateTopicsVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/app/AppRelateTopicsVO.java new file mode 100644 index 00000000..1ebe57a4 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/app/AppRelateTopicsVO.java @@ -0,0 +1,30 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.rd.app; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +/** + * @author zengqiao + * @date 20/5/4 + */ +@Data +@ApiModel(description="App关联Topic信息") +public class AppRelateTopicsVO { + @ApiModelProperty(value="物理集群ID") + private Long clusterPhyId; + + @ApiModelProperty(value="kafkaUser") + private String kafkaUser; + + @ApiModelProperty(value="选中的Topic列表") + private List selectedTopicNameList; + + @ApiModelProperty(value="未选中的Topic列表") + private List notSelectTopicNameList; + + @ApiModelProperty(value="未建立HA的Topic列表") + private List notHaTopicNameList; +} \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/cluster/ClusterDetailVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/cluster/ClusterDetailVO.java index cdeb7da7..8ac7a28b 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/cluster/ClusterDetailVO.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/cluster/ClusterDetailVO.java @@ -2,11 +2,13 @@ package com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.Data; /** * @author zengqiao * @date 20/4/23 */ +@Data @ApiModel(description="集群信息") public class ClusterDetailVO extends ClusterBaseVO { @ApiModelProperty(value="Broker数") @@ -24,45 +26,11 @@ public class ClusterDetailVO extends ClusterBaseVO { @ApiModelProperty(value="Region数") private Integer regionNum; - public Integer getBrokerNum() { - return brokerNum; - } + @ApiModelProperty(value = "高可用关系:1:主, 0:备 , 其他:非高可用") + private Integer haRelation; - public void setBrokerNum(Integer brokerNum) { - this.brokerNum = brokerNum; - } - - public Integer getTopicNum() { - return topicNum; - } - - public void setTopicNum(Integer topicNum) { - this.topicNum = topicNum; - } - - public Integer getConsumerGroupNum() { - return consumerGroupNum; - } - - public void setConsumerGroupNum(Integer consumerGroupNum) { - this.consumerGroupNum = consumerGroupNum; - } - - public Integer getControllerId() { - return controllerId; - } - - public void setControllerId(Integer controllerId) { - this.controllerId = controllerId; - } - - public Integer getRegionNum() { - return regionNum; - } - - public void setRegionNum(Integer regionNum) { - this.regionNum = regionNum; - } + @ApiModelProperty(value = "互备集群名称") + private String mutualBackupClusterName; @Override public String toString() { @@ -72,6 +40,8 @@ public class ClusterDetailVO extends ClusterBaseVO { ", consumerGroupNum=" + consumerGroupNum + ", controllerId=" + controllerId + ", regionNum=" + regionNum + - "} " + super.toString(); + ", haRelation=" + haRelation + + ", mutualBackupClusterName='" + mutualBackupClusterName + '\'' + + '}'; } } \ No newline at end of file diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobLogVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobLogVO.java new file mode 100644 index 00000000..8bae7454 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobLogVO.java @@ -0,0 +1,30 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.rd.job; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job日志") +public class JobLogVO { + @ApiModelProperty(value = "日志ID") + protected Long id; + + @ApiModelProperty(value = "业务类型") + private Integer bizType; + + @ApiModelProperty(value = "业务关键字") + private String bizKeyword; + + @ApiModelProperty(value = "打印时间") + private Date printTime; + + @ApiModelProperty(value = "内容") + private String content; +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobMulLogVO.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobMulLogVO.java new file mode 100644 index 00000000..d2cb67a0 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/entity/vo/rd/job/JobMulLogVO.java @@ -0,0 +1,31 @@ +package com.xiaojukeji.kafka.manager.common.entity.vo.rd.job; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ApiModel(description = "Job日志") +public class JobMulLogVO { + @ApiModelProperty(value = "末尾日志ID") + private Long endLogId; + + @ApiModelProperty(value = "日志信息") + private List logList; + + public JobMulLogVO(List logList, Long startLogId) { + this.logList = logList == null? new ArrayList<>(): logList; + if (!this.logList.isEmpty()) { + this.endLogId = this.logList.stream().map(elem -> elem.id).reduce(Long::max).get() + 1; + } else { + this.endLogId = startLogId; + } + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/ConvertUtil.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/ConvertUtil.java new file mode 100644 index 00000000..a6fbcef2 --- /dev/null +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/ConvertUtil.java @@ -0,0 +1,404 @@ +package com.xiaojukeji.kafka.manager.common.utils; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.TypeReference; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.google.common.collect.*; +import org.apache.commons.collections.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.*; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; + +public class ConvertUtil { + private static final Logger LOGGER = LoggerFactory.getLogger(ConvertUtil.class); + + private ConvertUtil(){} + + public static T toObj(String json, Type resultType) { + if (resultType instanceof Class) { + Class clazz = (Class) resultType; + return str2ObjByJson(json, clazz); + } + + return JSON.parseObject(json, resultType); + } + + public static T str2ObjByJson(String srcStr, Class tgtClass) { + return JSON.parseObject(srcStr, tgtClass); + } + + public static T str2ObjByJson(String srcStr, TypeReference tt) { + return JSON.parseObject(srcStr, tt); + } + + public static String obj2Json(Object srcObj) { + if (srcObj == null) { + return null; + } + if (srcObj instanceof String) { + return (String) srcObj; + } else { + return JSON.toJSONString(srcObj); + } + } + + public static String obj2JsonWithIgnoreCircularReferenceDetect(Object srcObj) { + return JSON.toJSONString(srcObj, SerializerFeature.DisableCircularReferenceDetect); + } + + public static List str2ObjArrayByJson(String srcStr, Class tgtClass) { + return JSON.parseArray(srcStr, tgtClass); + } + + public static T obj2ObjByJSON(Object srcObj, Class tgtClass) { + return JSON.parseObject( JSON.toJSONString(srcObj), tgtClass); + } + + public static String list2String(List list, String separator) { + if (list == null || list.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (Object item : list) { + sb.append(item).append(separator); + } + return sb.deleteCharAt(sb.length() - 1).toString(); + } + + public static Map list2Map(List list, Function mapper) { + Map map = Maps.newHashMap(); + if (CollectionUtils.isNotEmpty(list)) { + for (V v : list) { + map.put(mapper.apply(v), v); + } + } + return map; + } + + public static Map list2MapParallel(List list, Function mapper) { + Map map = new ConcurrentHashMap<>(); + if (CollectionUtils.isNotEmpty(list)) { + list.parallelStream().forEach(v -> map.put(mapper.apply(v), v)); + } + return map; + } + + public static Map list2Map(List list, Function keyMapper, + Function valueMapper) { + Map map = Maps.newHashMap(); + if (CollectionUtils.isNotEmpty(list)) { + for (O o : list) { + map.put(keyMapper.apply(o), valueMapper.apply(o)); + } + } + return map; + } + + public static Multimap list2MulMap(List list, Function mapper) { + Multimap multimap = ArrayListMultimap.create(); + if (CollectionUtils.isNotEmpty(list)) { + for (V v : list) { + multimap.put(mapper.apply(v), v); + } + } + return multimap; + } + + public static Multimap list2MulMap(List list, Function keyMapper, + Function valueMapper) { + Multimap multimap = ArrayListMultimap.create(); + if (CollectionUtils.isNotEmpty(list)) { + for (O o : list) { + multimap.put(keyMapper.apply(o), valueMapper.apply(o)); + } + } + return multimap; + } + + public static Map> list2MapOfList(List list, Function keyMapper, + Function valueMapper) { + ArrayListMultimap multimap = ArrayListMultimap.create(); + if (CollectionUtils.isNotEmpty(list)) { + for (O o : list) { + multimap.put(keyMapper.apply(o), valueMapper.apply(o)); + } + } + + return Multimaps.asMap(multimap); + } + + public static Set list2Set(List list, Function mapper) { + Set set = Sets.newHashSet(); + if (CollectionUtils.isNotEmpty(list)) { + for (V v : list) { + set.add(mapper.apply(v)); + } + } + return set; + } + + public static Set set2Set(Set set, Class tClass) { + if (CollectionUtils.isEmpty(set)) { + return new HashSet<>(); + } + + Set result = new HashSet<>(); + + for (Object o : set) { + T t = obj2Obj(o, tClass); + if (t != null) { + result.add(t); + } + } + + return result; + } + + public static List list2List(List list, Class tClass) { + return list2List(list, tClass, (t) -> { + }); + } + + public static List list2List(List list, Class tClass, Consumer consumer) { + if (CollectionUtils.isEmpty(list)) { + return Lists.newArrayList(); + } + + List result = Lists.newArrayList(); + + for (Object object : list) { + T t = obj2Obj(object, tClass, consumer); + if (t != null) { + result.add(t); + } + } + + return result; + } + + /** + * 对象转换工具 + * @param srcObj 元对象 + * @param tgtClass 目标对象类 + * @param 泛型 + * @return 目标对象 + */ + public static T obj2Obj(final Object srcObj, Class tgtClass) { + return obj2Obj(srcObj, tgtClass, (t) -> { + }); + } + + public static T obj2Obj(final Object srcObj, Class tgtClass, Consumer consumer) { + if (srcObj == null) { + return null; + } + + T tgt = null; + try { + tgt = tgtClass.newInstance(); + BeanUtils.copyProperties(srcObj, tgt); + consumer.accept(tgt); + } catch (Exception e) { + LOGGER.warn("class=ConvertUtil||method=obj2Obj||msg={}", e.getMessage()); + } + + return tgt; + } + + public static Map mergeMapList(List> mapList) { + Map result = Maps.newHashMap(); + for (Map map : mapList) { + result.putAll(map); + } + return result; + } + + public static Map Obj2Map(Object obj) { + if (null == obj) { + return null; + } + + Map map = new HashMap<>(); + Field[] fields = obj.getClass().getDeclaredFields(); + for (Field field : fields) { + field.setAccessible(true); + try { + map.put(field.getName(), field.get(obj)); + } catch (IllegalAccessException e) { + LOGGER.warn("class=ConvertUtil||method=Obj2Map||msg={}", e.getMessage(), e); + } + } + return map; + } + + public static Object map2Obj(Map map, Class clz) { + Object obj = null; + try { + obj = clz.newInstance(); + Field[] declaredFields = obj.getClass().getDeclaredFields(); + for (Field field : declaredFields) { + int mod = field.getModifiers(); + if (Modifier.isStatic(mod) || Modifier.isFinal(mod)) { + continue; + } + field.setAccessible(true); + field.set(obj, map.get(field.getName())); + } + } catch (Exception e) { + LOGGER.warn("class=ConvertUtil||method=map2Obj||msg={}", e.getMessage(), e); + } + + return obj; + } + + public static Map sortMapByValue(Map map) { + List> data = new ArrayList<>(map.entrySet()); + data.sort((o1, o2) -> { + if ((o2.getValue() - o1.getValue()) > 0) { + return 1; + } else if ((o2.getValue() - o1.getValue()) == 0) { + return 0; + } else { + return -1; + } + }); + + Map result = Maps.newLinkedHashMap(); + + for (Entry next : data) { + result.put(next.getKey(), next.getValue()); + } + return result; + } + + public static Map directFlatObject(JSONObject obj) { + Map ret = new HashMap<>(); + + if(obj==null) { + return ret; + } + + for (Entry entry : obj.entrySet()) { + String key = entry.getKey(); + Object o = entry.getValue(); + + if (o instanceof JSONObject) { + Map m = directFlatObject((JSONObject) o); + for (Entry e : m.entrySet()) { + ret.put(key + "." + e.getKey(), e.getValue()); + } + } else { + ret.put(key, o); + } + } + + return ret; + } + + public static Long string2Long(String s) { + if (ValidateUtils.isNull(s)) { + return null; + } + try { + return Long.parseLong(s); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Float string2Float(String s) { + if (ValidateUtils.isNull(s)) { + return null; + } + try { + return Float.parseFloat(s); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static String float2String(Float f) { + if (ValidateUtils.isNull(f)) { + return null; + } + try { + return String.valueOf(f); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Integer string2Integer(String s) { + if (null == s) { + return null; + } + try { + return Integer.parseInt(s); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Double string2Double(String s) { + if (null == s) { + return null; + } + try { + return Double.parseDouble(s); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Long double2Long(Double d) { + if (null == d) { + return null; + } + try { + return d.longValue(); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Integer double2Int(Double d) { + if (null == d) { + return null; + } + try { + return d.intValue(); + } catch (Exception e) { + // ignore exception + } + return null; + } + + public static Long Float2Long(Float f) { + if (null == f) { + return null; + } + try { + return f.longValue(); + } catch (Exception e) { + // ignore exception + } + return null; + } +} diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/CopyUtils.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/CopyUtils.java index bef175e4..ea265d47 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/CopyUtils.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/CopyUtils.java @@ -15,6 +15,7 @@ import java.util.concurrent.ConcurrentHashMap; * @author huangyiminghappy@163.com * @date 2019/3/15 */ +@Deprecated public class CopyUtils { @SuppressWarnings({"unchecked", "rawtypes"}) diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/FutureUtil.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/FutureUtil.java index b061ebed..6830c915 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/FutureUtil.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/utils/FutureUtil.java @@ -40,6 +40,14 @@ public class FutureUtil { return futureUtil; } + public Future directSubmitTask(Callable callable) { + return executor.submit(callable); + } + + public Future directSubmitTask(Runnable runnable) { + return (Future) executor.submit(runnable); + } + /** * 必须配合 waitExecute使用 否则容易会撑爆内存 */ diff --git a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/zookeeper/ZkPathUtil.java b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/zookeeper/ZkPathUtil.java index 0410a553..4e909528 100644 --- a/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/zookeeper/ZkPathUtil.java +++ b/kafka-manager-common/src/main/java/com/xiaojukeji/kafka/manager/common/zookeeper/ZkPathUtil.java @@ -8,6 +8,8 @@ package com.xiaojukeji.kafka.manager.common.zookeeper; public class ZkPathUtil { private static final String ZOOKEEPER_SEPARATOR = "/"; + public static final String CLUSTER_ID_NODE = ZOOKEEPER_SEPARATOR + "cluster/id"; + public static final String BROKER_ROOT_NODE = ZOOKEEPER_SEPARATOR + "brokers"; public static final String CONTROLLER_ROOT_NODE = ZOOKEEPER_SEPARATOR + "controller"; diff --git a/kafka-manager-console/package.json b/kafka-manager-console/package.json index 5d33a320..1362e7f6 100644 --- a/kafka-manager-console/package.json +++ b/kafka-manager-console/package.json @@ -1,6 +1,6 @@ { "name": "logi-kafka", - "version": "2.6.1", + "version": "2.8.0", "description": "", "scripts": { "prestart": "npm install --save-dev webpack-dev-server", @@ -58,4 +58,4 @@ "dependencies": { "format-to-json": "^1.0.4" } -} +} \ No newline at end of file diff --git a/kafka-manager-console/src/component/x-form-wrapper/index.tsx b/kafka-manager-console/src/component/x-form-wrapper/index.tsx index e39f3ef4..bd068e95 100755 --- a/kafka-manager-console/src/component/x-form-wrapper/index.tsx +++ b/kafka-manager-console/src/component/x-form-wrapper/index.tsx @@ -8,7 +8,7 @@ export class XFormWrapper extends React.Component { public state = { confirmLoading: false, formMap: this.props.formMap || [] as any, - formData: this.props.formData || {} + formData: this.props.formData || {}, }; private $formRef: any; @@ -121,7 +121,8 @@ export class XFormWrapper extends React.Component { this.closeModalWrapper(); }).catch((err: any) => { const { formMap, formData } = wrapper.xFormWrapper; - onSubmitFaild(err, this.$formRef, formData, formMap); + // tslint:disable-next-line:no-unused-expression + onSubmitFaild && onSubmitFaild(err, this.$formRef, formData, formMap); }).finally(() => { this.setState({ confirmLoading: false, diff --git a/kafka-manager-console/src/component/x-form/index.less b/kafka-manager-console/src/component/x-form/index.less index a08230a6..ed06afaa 100644 --- a/kafka-manager-console/src/component/x-form/index.less +++ b/kafka-manager-console/src/component/x-form/index.less @@ -1,4 +1,5 @@ -.ant-input-number, .ant-form-item-children .ant-select { +.ant-input-number, +.ant-form-item-children .ant-select { width: 314px } @@ -8,4 +9,36 @@ Button:first-child { margin-right: 16px; } +} + +.x-form { + .ant-form-item-label { + line-height: 32px; + } + + .ant-form-item-control { + line-height: 32px; + } +} + +.prompt-info { + color: #ccc; + font-size: 12px; + line-height: 20px; + display: block; + + &.inline { + margin-left: 16px; + display: inline-block; + + font-family: PingFangSC-Regular; + font-size: 12px; + color: #042866; + letter-spacing: 0; + text-align: justify; + + .anticon { + margin-right: 6px; + } + } } \ No newline at end of file diff --git a/kafka-manager-console/src/component/x-form/index.tsx b/kafka-manager-console/src/component/x-form/index.tsx index dc435d0f..cd65366b 100755 --- a/kafka-manager-console/src/component/x-form/index.tsx +++ b/kafka-manager-console/src/component/x-form/index.tsx @@ -85,6 +85,10 @@ class XForm extends React.Component { initialValue = false; } + if (formItem.type === FormItemType.select) { + initialValue = initialValue || undefined; + } + // if (formItem.type === FormItemType.select && formItem.attrs // && ['tags'].includes(formItem.attrs.mode)) { // initialValue = formItem.defaultValue ? [formItem.defaultValue] : []; @@ -105,7 +109,7 @@ class XForm extends React.Component { const { form, formData, formMap, formLayout, layout } = this.props; const { getFieldDecorator } = form; return ( -

    ({})}> + ({})}> {formMap.map(formItem => { const { initialValue, valuePropName } = this.handleFormItem(formItem, formData); const getFieldValue = { @@ -131,7 +135,13 @@ class XForm extends React.Component { )} {formItem.renderExtraElement ? formItem.renderExtraElement() : null} {/* 添加保存时间提示文案 */} - {formItem.attrs?.prompttype ? {formItem.attrs.prompttype} : null} + {formItem.attrs?.prompttype ? + + {formItem.attrs?.prompticon ? + : null} + {formItem.attrs.prompttype} + + : null} ); })} diff --git a/kafka-manager-console/src/container/admin/cluster-detail/cluster-overview.tsx b/kafka-manager-console/src/container/admin/cluster-detail/cluster-overview.tsx index 86b0b67b..5391d9f2 100644 --- a/kafka-manager-console/src/container/admin/cluster-detail/cluster-overview.tsx +++ b/kafka-manager-console/src/container/admin/cluster-detail/cluster-overview.tsx @@ -30,13 +30,13 @@ export class ClusterOverview extends React.Component { const content = this.props.basicInfo as IMetaData; const gmtCreate = moment(content.gmtCreate).format(timeFormat); const clusterContent = [{ - value: content.clusterName, + value: `${content.clusterName}${content.haRelation === 0 ? '(备)' : content.haRelation === 1 ? '(主)' : content.haRelation === 2 ? '(主&备)' : ''}`, label: '集群名称', - }, + }, // { // value: clusterTypeMap[content.mode], // label: '集群类型', - // }, + // }, { value: gmtCreate, label: '接入时间', @@ -50,6 +50,9 @@ export class ClusterOverview extends React.Component { }, { value: content.zookeeper, label: 'Zookeeper', + }, { + value: `${content.mutualBackupClusterName || '-'}${content.haRelation === 0 ? '(主)' : content.haRelation === 1 ? '(备)' : content.haRelation === 2 ? '(主&备)' : ''}`, + label: '互备集群', }]; return ( <> @@ -64,18 +67,18 @@ export class ClusterOverview extends React.Component { ))} {clusterInfo.map((item: ILabelValue, index: number) => ( - - - - copyString(item.value)} - type="copy" - className="didi-theme overview-theme" - /> - {item.value} - - - + + + + copyString(item.value)} + type="copy" + className="didi-theme overview-theme" + /> + {item.value} + + + ))} diff --git a/kafka-manager-console/src/container/admin/cluster-detail/cluster-topic.tsx b/kafka-manager-console/src/container/admin/cluster-detail/cluster-topic.tsx index c2d3aa54..e66a0a73 100644 --- a/kafka-manager-console/src/container/admin/cluster-detail/cluster-topic.tsx +++ b/kafka-manager-console/src/container/admin/cluster-detail/cluster-topic.tsx @@ -118,10 +118,10 @@ export class ClusterTopic extends SearchAndFilterContainer { public renderClusterTopicList() { const clusterColumns = [ { - title: 'Topic名称', + title: `Topic名称`, dataIndex: 'topicName', key: 'topicName', - width: '120px', + width: '140px', sorter: (a: IClusterTopics, b: IClusterTopics) => a.topicName.charCodeAt(0) - b.topicName.charCodeAt(0), render: (text: string, record: IClusterTopics) => { return ( @@ -130,7 +130,7 @@ export class ClusterTopic extends SearchAndFilterContainer { // tslint:disable-next-line:max-line-length href={`${urlPrefix}/topic/topic-detail?clusterId=${record.clusterId || ''}&topic=${record.topicName || ''}&isPhysicalClusterId=true®ion=${region.currentRegion}`} > - {text} + {text}{record.haRelation === 0 ? '(备)' : record.haRelation === 1 ? '(主)' : record.haRelation === 2 ? '(主&备)' : ''} ); }, @@ -208,23 +208,27 @@ export class ClusterTopic extends SearchAndFilterContainer { { title: '操作', width: '120px', - render: (value: string, item: IClusterTopics) => ( - <> - this.getBaseInfo(item)} className="action-button">编辑 - this.expandPartition(item)} className="action-button">扩分区 - {/* this.expandPartition(item)} className="action-button">删除 */} - this.confirmDetailTopic(item)} - // onConfirm={() => this.deleteTopic(item)} - cancelText="取消" - okText="确认" - > - 删除 - - - ), + render: (value: string, item: IClusterTopics) => { + if (item.haRelation === 0) return '-'; + + return ( + <> + this.getBaseInfo(item)} className="action-button">编辑 + this.expandPartition(item)} className="action-button">扩分区 + {/* this.expandPartition(item)} className="action-button">删除 */} + this.confirmDetailTopic(item)} + // onConfirm={() => this.deleteTopic(item)} + cancelText="取消" + okText="确认" + > + 删除 + + + ); + }, }, ]; if (users.currentUser.role !== 2) { diff --git a/kafka-manager-console/src/container/admin/cluster-detail/logical-cluster.tsx b/kafka-manager-console/src/container/admin/cluster-detail/logical-cluster.tsx index b0ae63f4..25688649 100644 --- a/kafka-manager-console/src/container/admin/cluster-detail/logical-cluster.tsx +++ b/kafka-manager-console/src/container/admin/cluster-detail/logical-cluster.tsx @@ -73,6 +73,7 @@ export class LogicalCluster extends SearchAndFilterContainer { key: 'mode', render: (value: number) => { let val = ''; + // tslint:disable-next-line:no-unused-expression cluster.clusterModes && cluster.clusterModes.forEach((ele: any) => { if (value === ele.code) { val = ele.message; @@ -206,6 +207,7 @@ export class LogicalCluster extends SearchAndFilterContainer { } public render() { + const clusterModes = cluster.clusterModes; return (
      diff --git a/kafka-manager-console/src/container/admin/cluster-list/index.less b/kafka-manager-console/src/container/admin/cluster-list/index.less index e69de29b..54bae32c 100644 --- a/kafka-manager-console/src/container/admin/cluster-list/index.less +++ b/kafka-manager-console/src/container/admin/cluster-list/index.less @@ -0,0 +1,381 @@ +.switch-style { + &.ant-switch { + min-width: 32px; + height: 20px; + line-height: 18px; + + ::after { + height: 16px; + width: 16px; + } + } + + &.ant-switch-loading-icon, + &.ant-switch::after { + height: 16px; + width: 16px; + } +} + +.expanded-table { + width: auto ! important; + + .ant-table-thead { + // visibility: hidden; + display: none; + } + + .ant-table-tbody>tr>td { + background-color: #FAFAFA; + border-bottom: none; + } +} + +tr.ant-table-expanded-row td>.expanded-table { + padding: 10px; + // margin: -13px 0px -14px ! important; + border: none; +} + +.cluster-tag { + background: #27D687; + border-radius: 2px; + font-family: PingFangSC-Medium; + color: #FFFFFF; + letter-spacing: 0; + text-align: justify; + -webkit-transform: scale(0.5); + margin-right: 0px; +} + +.no-padding { + .ant-modal-body { + padding: 0; + + .attribute-content { + .tag-gray { + font-family: PingFangSC-Regular; + font-size: 12px; + color: #575757; + text-align: center; + line-height: 18px; + padding: 0 4px; + margin: 3px; + height: 20px; + background: #EEEEEE; + border-radius: 5px; + } + + .icon { + zoom: 0.8; + } + + .tag-num { + font-family: PingFangSC-Medium; + text-align: right; + line-height: 13px; + margin-left: 6px; + transform: scale(0.8333); + } + } + + .attribute-tag { + .ant-popover-inner-content { + padding: 12px; + max-width: 480px; + } + + .ant-popover-arrow { + display: none; + } + + .ant-popover-placement-bottom, + .ant-popover-placement-bottomLeft, + .ant-popover-placement-bottomRight { + top: 23px !important; + border-radius: 2px; + } + + .tag-gray { + font-family: PingFangSC-Regular; + font-size: 12px; + color: #575757; + text-align: center; + line-height: 12px; + padding: 0 4px; + margin: 3px; + height: 20px; + background: #EEEEEE; + border-radius: 5px; + } + } + + .col-status { + font-family: PingFangSC-Regular; + font-size: 12px; + letter-spacing: 0; + text-align: justify; + + &.green { + .ant-badge-status-text { + color: #2FC25B; + } + } + + &.black { + .ant-badge-status-text { + color: #575757; + } + } + + &.red { + .ant-badge-status-text { + color: #F5202E; + } + } + } + + .ant-alert-message { + font-family: PingFangSC-Regular; + font-size: 12px; + letter-spacing: 0; + text-align: justify; + } + + .ant-alert-warning { + border: none; + color: #592D00; + padding: 7px 15px 7px 41px; + background: #FFFAE0; + + .ant-alert-message { + color: #592D00 + } + } + + .ant-alert-info { + border: none; + padding: 7px 15px 7px 41px; + color: #042866; + background: #EFF8FF; + + .ant-alert-message { + color: #042866; + } + } + + .ant-alert-icon { + left: 24px; + top: 10px; + } + + .switch-warning { + .btn { + position: absolute; + top: 60px; + right: 24px; + height: 22px; + width: 64px; + padding: 0px; + + &.disabled { + top: 77px; + } + + button { + height: 22px; + width: 64px; + padding: 0px; + } + + &.loading { + width: 80px; + + button { + height: 22px; + width: 88px; + padding: 0px 0px 0px 12px; + } + } + } + } + + .modal-table-content { + padding: 0px 24px 16px; + + .ant-table-small { + border: none; + border-top: 1px solid #e8e8e8; + + .ant-table-thead { + background: #FAFAFA; + } + } + } + + .modal-table-download { + height: 40px; + line-height: 40px; + text-align: center; + border-top: 1px solid #e8e8e8; + } + + .ant-form { + padding: 18px 24px 0px; + + .ant-col-3 { + width: 9.5%; + } + + .ant-form-item-label { + text-align: left; + } + + .no-label { + .ant-col-21 { + width: 100%; + } + + .transfe-list { + .ant-transfer-list { + height: 359px; + } + } + + .ant-transfer-list { + width: 249px; + border: 1px solid #E8E8E8; + border-radius: 8px; + + .ant-transfer-list-header-title { + font-family: PingFangSC-Regular; + font-size: 12px; + color: #252525; + letter-spacing: 0; + text-align: right; + } + + .ant-transfer-list-body-search-wrapper { + padding: 19px 16px 6px; + + input { + height: 27px; + background: #FAFAFA; + border-radius: 8px; + border: none; + } + + .ant-transfer-list-search-action { + line-height: 27px; + height: 27px; + top: 19px; + } + } + } + + .ant-transfer-list-header { + border-radius: 8px 8px 0px 0px; + padding: 16px; + } + } + + .ant-transfer-customize-list .ant-transfer-list-body-customize-wrapper { + padding: 0px; + margin: 0px 16px; + background: #FAFAFA; + border-radius: 8px; + + .ant-table-header-column { + font-family: PingFangSC-Regular; + font-size: 12px; + color: #575757; + letter-spacing: 0; + text-align: justify; + } + + .ant-table-thead>tr { + border: none; + background: #FAFAFA; + } + + .ant-table-tbody>tr>td { + border: none; + background: #FAFAFA; + } + + .ant-table-body { + background: #FAFAFA; + } + } + + .ant-table-selection-column { + + .ant-table-header-column { + opacity: 0; + } + } + } + + .log-process { + height: 56px; + background: #FAFAFA; + padding: 6px 8px; + margin-bottom: 15px; + + .name { + display: flex; + color: #575757; + justify-content: space-between; + } + } + + .log-panel { + padding: 24px; + font-family: PingFangSC-Regular; + font-size: 12px; + + .title { + color: #252525; + letter-spacing: 0; + text-align: justify; + margin-bottom: 15px; + + .divider { + display: inline-block; + border-left: 2px solid #F38031; + height: 9px; + margin-right: 6px; + } + } + + .log-info { + color: #575757; + letter-spacing: 0; + text-align: justify; + margin-bottom: 10px; + + .text-num { + font-size: 14px; + } + + .warning-num { + color: #F38031; + font-size: 14px; + } + } + + .log-table { + margin-bottom: 24px; + + .ant-table-small { + border: none; + border-top: 1px solid #e8e8e8; + + .ant-table-thead { + background: #FAFAFA; + } + } + } + } + } +} \ No newline at end of file diff --git a/kafka-manager-console/src/container/admin/cluster-list/index.tsx b/kafka-manager-console/src/container/admin/cluster-list/index.tsx index cdd197fa..08769507 100644 --- a/kafka-manager-console/src/container/admin/cluster-list/index.tsx +++ b/kafka-manager-console/src/container/admin/cluster-list/index.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { Modal, Table, Button, notification, message, Tooltip, Icon, Popconfirm, Alert, Popover } from 'component/antd'; +import { Modal, Table, Button, notification, message, Tooltip, Icon, Popconfirm, Alert, Dropdown } from 'component/antd'; import { wrapper } from 'store'; import { observer } from 'mobx-react'; -import { IXFormWrapper, IMetaData, IRegister } from 'types/base-type'; +import { IXFormWrapper, IMetaData, IRegister, ILabelValue } from 'types/base-type'; import { admin } from 'store/admin'; import { users } from 'store/users'; import { registerCluster, createCluster, pauseMonitoring } from 'lib/api'; @@ -10,11 +10,14 @@ import { SearchAndFilterContainer } from 'container/search-filter'; import { cluster } from 'store/cluster'; import { customPagination } from 'constants/table'; import { urlPrefix } from 'constants/left-menu'; -import { indexUrl } from 'constants/strategy' +import { indexUrl } from 'constants/strategy'; import { region } from 'store'; import './index.less'; -import Monacoeditor from 'component/editor/monacoEditor'; import { getAdminClusterColumns } from '../config'; +import { FormItemType } from 'component/x-form'; +import { TopicHaRelationWrapper } from 'container/modal/admin/TopicHaRelation'; +import { TopicSwitchWrapper } from 'container/modal/admin/TopicHaSwitch'; +import { TopicSwitchLog } from 'container/modal/admin/SwitchTaskLog'; const { confirm } = Modal; @@ -22,6 +25,10 @@ const { confirm } = Modal; export class ClusterList extends SearchAndFilterContainer { public state = { searchKey: '', + haVisible: false, + switchVisible: false, + logVisible: false, + currentCluster: {} as IMetaData, }; private xFormModal: IXFormWrapper; @@ -36,7 +43,26 @@ export class ClusterList extends SearchAndFilterContainer { ); } + public updateFormModal(value: boolean, metaList: ILabelValue[]) { + const formMap = wrapper.xFormWrapper.formMap; + formMap[1].attrs.prompttype = !value ? '' : metaList.length ? '已设置为高可用集群,请选择所关联的主集群' : '当前暂无可用集群进行关联高可用关系,请先添加集群'; + formMap[1].attrs.prompticon = 'true'; + formMap[2].invisible = !value; + formMap[2].attrs.disabled = !metaList.length; + formMap[6].rules[0].required = value; + + // tslint:disable-next-line:no-unused-expression + wrapper.ref && wrapper.ref.updateFormMap$(formMap, wrapper.xFormWrapper.formData); + + } + public createOrRegisterCluster(item: IMetaData) { + const self = this; + const metaList = Array.from(admin.metaList).filter(item => item.haRelation === null).map(item => ({ + label: item.clusterName, + value: item.clusterId, + })); + this.xFormModal = { formMap: [ { @@ -51,6 +77,38 @@ export class ClusterList extends SearchAndFilterContainer { disabled: item ? true : false, }, }, + { + key: 'ha', + label: '高可用', + type: FormItemType._switch, + invisible: item ? true : false, + rules: [{ + required: false, + }], + attrs: { + className: 'switch-style', + prompttype: '', + prompticon: '', + prompticomclass: '', + promptclass: 'inline', + onChange(value: boolean) { + self.updateFormModal(value, metaList); + }, + }, + }, + { + key: 'activeClusterId', + label: '主集群', + type: FormItemType.select, + options: metaList, + invisible: true, + rules: [{ + required: false, + }], + attrs: { + placeholder: '请选择主集群', + }, + }, { key: 'zookeeper', label: 'zookeeper地址', @@ -130,9 +188,9 @@ export class ClusterList extends SearchAndFilterContainer { }], attrs: { placeholder: `请输入安全协议,例如: -{ - "security.protocol": "SASL_PLAINTEXT", - "sasl.mechanism": "PLAIN", +{ + "security.protocol": "SASL_PLAINTEXT", + "sasl.mechanism": "PLAIN", "sasl.jaas.config": "org.apache.kafka.common.security.plain.PlainLoginModule required username=\\"xxxxxx\\" password=\\"xxxxxx\\";" }`, rows: 8, @@ -162,17 +220,18 @@ export class ClusterList extends SearchAndFilterContainer { visible: true, width: 590, title: item ? '编辑' : '接入集群', + isWaitting: true, onSubmit: (value: IRegister) => { value.idc = region.currentRegion; if (item) { value.clusterId = item.clusterId; - registerCluster(value).then(data => { - admin.getMetaData(true); + return registerCluster(value).then(data => { + admin.getHaMetaData(); notification.success({ message: '编辑集群成功' }); }); } else { - createCluster(value).then(data => { - admin.getMetaData(true); + return createCluster(value).then(data => { + admin.getHaMetaData(); notification.success({ message: '接入集群成功' }); }); } @@ -186,7 +245,7 @@ export class ClusterList extends SearchAndFilterContainer { const info = item.status === 1 ? '暂停监控' : '开始监控'; const status = item.status === 1 ? 0 : 1; pauseMonitoring(item.clusterId, status).then(data => { - admin.getMetaData(true); + admin.getHaMetaData(); notification.success({ message: `${info}成功` }); }); } @@ -198,7 +257,7 @@ export class ClusterList extends SearchAndFilterContainer { title: <> 删除集群  - + @@ -216,12 +275,34 @@ export class ClusterList extends SearchAndFilterContainer { } admin.deleteCluster(record.clusterId).then(data => { notification.success({ message: '删除成功' }); + admin.getHaMetaData(); }); }, }); }); } + public showDelStandModal = (record: IMetaData) => { + confirm({ + // tslint:disable-next-line:jsx-wrap-multiline + title: '删除集群', + // icon: 'none', + content: <>{record.activeTopicCount ? `当前集群含有主topic,无法删除!` : record.haStatus !== 0 ? `当前集群正在进行主备切换,无法删除!` : `确认删除集群${record.clusterName}吗?`}, + width: 500, + okText: '确认', + cancelText: '取消', + onOk() { + if (record.activeTopicCount || record.haStatus !== 0) { + return; + } + admin.deleteCluster(record.clusterId).then(data => { + notification.success({ message: '删除成功' }); + admin.getHaMetaData(); + }); + }, + }); + } + public deleteMonitorModal = (source: any) => { const cellStyle = { overflow: 'hidden', @@ -275,11 +356,105 @@ export class ClusterList extends SearchAndFilterContainer { return data; } + public expandedRowRender = (record: IMetaData) => { + const dataSource: any = record.haClusterVO ? [record.haClusterVO] : []; + const cols = getAdminClusterColumns(false); + const role = users.currentUser.role; + + if (!record.haClusterVO) return null; + + const haRecord = record.haClusterVO; + + const btnsMenu = ( + <> + + ); + + const noAuthMenu = ( + + ); + + const col = { + title: '操作', + width: 270, + render: (value: string, item: IMetaData) => ( + <> + + Topic高可用关联 + + {item.haStatus !== 0 ? null : + Topic主备切换 + } + {item.haASSwitchJobId ? + 查看日志 + : null} + + + ··· + + + + ), + }; + cols.push(col as any); + return ( + + ); + } + public getColumns = () => { const cols = getAdminClusterColumns(); const role = users.currentUser.role; const col = { title: '操作', + width: 270, render: (value: string, item: IMetaData) => ( <> { @@ -307,10 +482,10 @@ export class ClusterList extends SearchAndFilterContainer { 删除 : - 编辑 - {item.status === 1 ? '暂停监控' : '开始监控'} - 删除 - + 编辑 + {item.status === 1 ? '暂停监控' : '开始监控'} + 删除 + } ), @@ -319,6 +494,20 @@ export class ClusterList extends SearchAndFilterContainer { return cols; } + public openModal(type: string, record: IMetaData) { + this.setState({ + currentCluster: record, + }, () => { + this.handleVisible(type, true); + }); + } + + public handleVisible(type: string, visible: boolean) { + this.setState({ + [type]: visible, + }); + } + public renderClusterList() { const role = users.currentUser.role; return ( @@ -333,8 +522,8 @@ export class ClusterList extends SearchAndFilterContainer { role && role === 2 ? : - - + + } @@ -343,26 +532,63 @@ export class ClusterList extends SearchAndFilterContainer {
      ( + record.haClusterVO ? + onExpand(record, e)} /> + : null + )} loading={admin.loading} - dataSource={this.getData(admin.metaList)} + expandedRowRender={this.expandedRowRender} + dataSource={this.getData(admin.haMetaList)} columns={this.getColumns()} pagination={customPagination} /> + {this.state.haVisible && this.handleVisible('haVisible', val)} + visible={this.state.haVisible} + currentCluster={this.state.currentCluster} + reload={() => admin.getHaMetaData()} + formData={{}} + />} + {this.state.switchVisible && + { + admin.getHaMetaData().then((res) => { + const currentRecord = res.find(item => item.clusterId === this.state.currentCluster.clusterId); + currentRecord.haClusterVO.haASSwitchJobId = jobId; + this.openModal('logVisible', currentRecord); + }); + }} + handleVisible={(val: boolean) => this.handleVisible('switchVisible', val)} + visible={this.state.switchVisible} + currentCluster={this.state.currentCluster} + formData={{}} + /> + } + {this.state.logVisible && + admin.getHaMetaData()} + handleVisible={(val: boolean) => this.handleVisible('logVisible', val)} + visible={this.state.logVisible} + currentCluster={this.state.currentCluster} + /> + } ); } public componentDidMount() { admin.getMetaData(true); + admin.getHaMetaData(); cluster.getClusterModes(); admin.getDataCenter(); } public render() { return ( - admin.metaList ? <> {this.renderClusterList()} : null + admin.haMetaList ? <> {this.renderClusterList()} : null ); } } diff --git a/kafka-manager-console/src/container/admin/config.tsx b/kafka-manager-console/src/container/admin/config.tsx index 1f9d6d81..29499bc8 100644 --- a/kafka-manager-console/src/container/admin/config.tsx +++ b/kafka-manager-console/src/container/admin/config.tsx @@ -3,12 +3,13 @@ import { IUser, IUploadFile, IConfigure, IConfigGateway, IMetaData } from 'types import { users } from 'store/users'; import { version } from 'store/version'; import { showApplyModal, showApplyModalModifyPassword, showModifyModal, showConfigureModal, showConfigGatewayModal } from 'container/modal/admin'; -import { Popconfirm, Tooltip } from 'component/antd'; +import { Icon, Popconfirm, Tooltip } from 'component/antd'; import { admin } from 'store/admin'; import { cellStyle } from 'constants/table'; import { timeFormat } from 'constants/strategy'; import { urlPrefix } from 'constants/left-menu'; import moment = require('moment'); +import { Tag } from 'antd'; export const getUserColumns = () => { const columns = [ @@ -28,15 +29,15 @@ export const getUserColumns = () => { showApplyModal(record)}>编辑 showApplyModalModifyPassword(record)}>修改密码 - {record.username == users.currentUser.username ? "" : - users.deleteUser(record.username)} - cancelText="取消" - okText="确认" - > - 删除 - + {record.username === users.currentUser.username ? '' : + users.deleteUser(record.username)} + cancelText="取消" + okText="确认" + > + 删除 + } ); }, @@ -271,33 +272,82 @@ export const getConfigColumns = () => { const renderClusterHref = (value: number | string, item: IMetaData, key: number) => { return ( // 0 暂停监控--不可点击 1 监控中---可正常点击 <> - {item.status === 1 ? {value} - : {value}} + {item.status === 1 ? {value} : + {value}} ); }; -export const getAdminClusterColumns = () => { +const renderTopicNum = (value: number | string, item: IMetaData, key: number, active?: boolean) => { + const show = item.haClusterVO || (!item.haClusterVO && !active); + + if (!show) { + return ( // 0 暂停监控--不可点击 1 监控中---可正常点击 + <> + {item.status === 1 ? + {value} + : + + {value} + } + + ); + } + return ( // 0 暂停监控--不可点击 1 监控中---可正常点击 + <> + {item.status === 1 ? + {value} + <>(主{item.activeTopicCount ?? '-'}/备{item.standbyTopicCount ?? '-'}) + : + + {value} + <>(主{item.activeTopicCount ?? '-'}/备{item.standbyTopicCount ?? '-'}) + } + + ); +}; + +const renderClusterName = (value: number | string, item: IMetaData, key: number, active: boolean) => { + const show = item.haClusterVO || (!item.haClusterVO && !active); + + return ( // 0 暂停监控--不可点击 1 监控中---可正常点击 + <> + {item.status === 1 ? + {value} : + {value}} + {active ? <> + {item.haClusterVO ? HA : null} + {item.haClusterVO && item.haStatus !== 0 ? + : null} + : null} + + ); +}; +export const getAdminClusterColumns = (active = true) => { return [ { title: '物理集群ID', dataIndex: 'clusterId', key: 'clusterId', - sorter: (a: IMetaData, b: IMetaData) => b.clusterId - a.clusterId, + sorter: (a: IMetaData, b: IMetaData) => a.clusterId - b.clusterId, + width: active ? 115 : 111, + render: (text: number) => active ? text : `(${text ?? 0})`, }, { title: '物理集群名称', dataIndex: 'clusterName', key: 'clusterName', sorter: (a: IMetaData, b: IMetaData) => a.clusterName.charCodeAt(0) - b.clusterName.charCodeAt(0), - render: (text: string, item: IMetaData) => renderClusterHref(text, item, 1), + render: (text: string, item: IMetaData) => renderClusterName(text, item, 1, active), + width: 235, }, { title: 'Topic数', dataIndex: 'topicNum', key: 'topicNum', sorter: (a: any, b: IMetaData) => b.topicNum - a.topicNum, - render: (text: number, item: IMetaData) => renderClusterHref(text, item, 2), + render: (text: number, item: IMetaData) => renderTopicNum(text, item, 2, active), + width: 140, }, { title: 'Broker数', @@ -305,6 +355,7 @@ export const getAdminClusterColumns = () => { key: 'brokerNum', sorter: (a: IMetaData, b: IMetaData) => b.brokerNum - a.brokerNum, render: (text: number, item: IMetaData) => renderClusterHref(text, item, 3), + width: 140, }, { title: 'Consumer数', @@ -312,6 +363,8 @@ export const getAdminClusterColumns = () => { key: 'consumerGroupNum', sorter: (a: IMetaData, b: IMetaData) => b.consumerGroupNum - a.consumerGroupNum, render: (text: number, item: IMetaData) => renderClusterHref(text, item, 4), + width: 150, + }, { title: 'Region数', @@ -319,6 +372,8 @@ export const getAdminClusterColumns = () => { key: 'regionNum', sorter: (a: IMetaData, b: IMetaData) => b.regionNum - a.regionNum, render: (text: number, item: IMetaData) => renderClusterHref(text, item, 5), + width: 140, + }, { title: 'Controllerld', @@ -326,12 +381,15 @@ export const getAdminClusterColumns = () => { key: 'controllerId', sorter: (a: IMetaData, b: IMetaData) => b.controllerId - a.controllerId, render: (text: number, item: IMetaData) => renderClusterHref(text, item, 7), + width: 150, + }, { title: '监控中', dataIndex: 'status', key: 'status', sorter: (a: IMetaData, b: IMetaData) => b.key - a.key, + width: 140, render: (value: number) => value === 1 ? : , }, diff --git a/kafka-manager-console/src/container/cluster/my-cluster.tsx b/kafka-manager-console/src/container/cluster/my-cluster.tsx index 3cb6115f..f8c5a7bc 100644 --- a/kafka-manager-console/src/container/cluster/my-cluster.tsx +++ b/kafka-manager-console/src/container/cluster/my-cluster.tsx @@ -44,7 +44,7 @@ export class MyCluster extends SearchAndFilterContainer { label: '所属应用', rules: [{ required: true, message: '请选择所属应用' }], type: 'select', - options: app.data.map((item) => { + options: app.clusterAppData.map((item) => { return { label: item.name, value: item.appId, @@ -135,8 +135,8 @@ export class MyCluster extends SearchAndFilterContainer { if (!cluster.clusterModes.length) { cluster.getClusterModes(); } - if (!app.data.length) { - app.getAppList(); + if (!app.clusterAppData.length) { + app.getAppListByClusterId(-1); } } diff --git a/kafka-manager-console/src/container/header/index.tsx b/kafka-manager-console/src/container/header/index.tsx index 3805e653..0c5ee512 100644 --- a/kafka-manager-console/src/container/header/index.tsx +++ b/kafka-manager-console/src/container/header/index.tsx @@ -145,7 +145,7 @@ export const Header = observer((props: IHeader) => {
      LogiKM - v2.6.1 + v2.8.0 {/* 添加版本超链接 */}
      diff --git a/kafka-manager-console/src/container/modal/admin/SwitchTaskLog.tsx b/kafka-manager-console/src/container/modal/admin/SwitchTaskLog.tsx new file mode 100644 index 00000000..297f2d08 --- /dev/null +++ b/kafka-manager-console/src/container/modal/admin/SwitchTaskLog.tsx @@ -0,0 +1,300 @@ +import * as React from 'react'; +import { Modal, Progress, Tooltip } from 'antd'; +import { IMetaData } from 'types/base-type'; +import { Alert, Badge, Button, Input, message, notification, Table } from 'component/antd'; +import { getJobDetail, getJobState, getJobLog, switchAsJobs } from 'lib/api'; +import moment from 'moment'; +import { timeFormat } from 'constants/strategy'; + +interface IProps { + reload: any; + visible?: boolean; + handleVisible?: any; + currentCluster?: IMetaData; +} + +interface IJobState { + failedNu: number; + jobNu: number; + runningNu: number; + successNu: number; + waitingNu: number; + runningInTimeoutNu: number; + progress: number; +} + +interface IJobDetail { + standbyClusterPhyId: number; + status: number; + sumLag: number; + timeoutUnitSecConfig: number; + topicName: string; + activeClusterPhyName: string; + standbyClusterPhyName: string; +} + +interface ILog { + bizKeyword: string; + bizType: number; + content: string; + id: number; + printTime: number; +} +interface IJobLog { + logList: ILog[]; + endLogId: number; +} +const STATUS_MAP = { + '-1': '未知', + '30': '运行中', + '32': '超时运行中', + '101': '成功', + '102': '失败', +} as any; +const STATUS_COLORS = { + '-1': '#575757', + '30': '#575757', + '32': '#F5202E', + '101': '#2FC25B', + '102': '#F5202E', +} as any; +const STATUS_COLOR_MAP = { + '-1': 'black', + '30': 'black', + '32': 'red', + '101': 'green', + '102': 'red', +} as any; + +const getFilters = () => { + const keys = Object.keys(STATUS_MAP); + const filters = []; + for (const key of keys) { + filters.push({ + text: STATUS_MAP[key], + value: key, + }); + } + return filters; +}; + +const columns = [ + { + dataIndex: 'key', + title: '编号', + width: 60, + }, + { + dataIndex: 'topicName', + title: 'Topic名称', + width: 120, + ellipsis: true, + }, + { + dataIndex: 'sumLag', + title: '延迟', + width: 100, + render: (value: number) => value ?? '-', + }, + { + dataIndex: 'status', + title: '状态', + width: 100, + filters: getFilters(), + onFilter: (value: string, record: IJobDetail) => record.status === Number(value), + render: (t: number) => ( + + + + ), + }, +]; + +export class TopicSwitchLog extends React.Component { + public state = { + radioCheck: 'all', + jobDetail: [] as IJobDetail[], + jobState: {} as IJobState, + jobLog: {} as IJobLog, + textStr: '', + primaryTargetKeys: [] as string[], + loading: false, + }; + public timer = null as number; + public jobId = this.props.currentCluster?.haClusterVO?.haASSwitchJobId as number; + + public handleOk = () => { + this.props.handleVisible(false); + this.props.reload(); + } + + public handleCancel = () => { + this.props.handleVisible(false); + this.props.reload(); + } + + public iTimer = () => { + this.timer = window.setInterval(() => { + const { jobLog } = this.state; + this.getContentJobLog(jobLog.endLogId); + this.getContentJobState(); + this.getContentJobDetail(); + }, 10 * 1 * 1000); + } + + public getTextAreaStr = (logList: ILog[]) => { + const strs = []; + + for (const item of logList) { + strs.push(`${moment(item.printTime).format(timeFormat)} ${item.content}`); + } + + return strs.join(`\n`); + } + + public getContentJobLog = (startId?: number) => { + getJobLog(this.jobId, startId).then((res: IJobLog) => { + const { jobLog } = this.state; + const logList = (jobLog.logList || []); + logList.push(...(res?.logList || [])); + + const newJobLog = { + endLogId: res?.endLogId, + logList, + }; + + this.setState({ + textStr: this.getTextAreaStr(logList), + jobLog: newJobLog, + }); + }); + } + + public getContentJobState = () => { + getJobState(this.jobId).then((res: IJobState) => { + // 成功后清除调用 + if (res?.jobNu === res.successNu) { + clearInterval(this.timer); + } + this.setState({ + jobState: res || {}, + }); + }); + } + public getContentJobDetail = () => { + getJobDetail(this.jobId).then((res: IJobDetail[]) => { + this.setState({ + jobDetail: (res || []).map((row, index) => ({ + ...row, + key: index, + })), + }); + }); + } + + public switchJobs = () => { + const { jobState } = this.state; + Modal.confirm({ + title: '强制切换', + content: `当前有${jobState.runningNu}个Topic切换中,${jobState.runningInTimeoutNu}个Topic切换超时,强制切换会使这些Topic有数据丢失的风险,确定强制切换吗?`, + onOk: () => { + this.setState({ + loading: true, + }); + switchAsJobs(this.jobId, { + action: 'force', + allJumpWaitInSync: true, + jumpWaitInSyncActiveTopicList: [], + }).then(res => { + message.success('强制切换成功'); + }).finally(() => { + this.setState({ + loading: false, + }); + }); + }, + }); + } + + public componentWillUnmount() { + clearInterval(this.timer); + } + + public componentDidMount() { + this.getContentJobDetail(); + this.getContentJobState(); + this.getContentJobLog(); + setTimeout(this.iTimer, 0); + } + + public render() { + const { visible, currentCluster } = this.props; + const { jobState, jobDetail, textStr, loading } = this.state; + const runtimeJob = jobDetail.filter(item => item.status === 32); + const percent = jobState?.progress; + return ( + + {runtimeJob.length ? + + : null} +
      + + + +
      + +
      +
      +
      + Topic切换详情: +
      +
      +
      + 源集群 {jobDetail?.[0]?.standbyClusterPhyName || ''} + 目标集群 {jobDetail?.[0]?.activeClusterPhyName || ''} +
      + +
      +
      + Topic总数 {jobState.jobNu ?? '-'} 个, + 切换成功 {jobState.successNu ?? '-'} 个, + 切换超时 {jobState.failedNu ?? '-'} 个, + 待切换 {jobState.waitingNu ?? '-'} 个。 +
      +
      +
      +
      + 集群切换日志: +
      +
      + +
      +
      + + + ); + } +} diff --git a/kafka-manager-console/src/container/modal/admin/TopicHaRelation.tsx b/kafka-manager-console/src/container/modal/admin/TopicHaRelation.tsx new file mode 100644 index 00000000..8f3b128d --- /dev/null +++ b/kafka-manager-console/src/container/modal/admin/TopicHaRelation.tsx @@ -0,0 +1,351 @@ +import * as React from 'react'; +import { admin } from 'store/admin'; +import { Modal, Form, Radio } from 'antd'; +import { IBrokersMetadata, IBrokersRegions, IMetaData } from 'types/base-type'; +import { Alert, message, notification, Table, Tooltip, Transfer } from 'component/antd'; +import { getClusterHaTopicsStatus, setHaTopics, unbindHaTopics } from 'lib/api'; +import { cellStyle } from 'constants/table'; + +const layout = { + labelCol: { span: 3 }, + wrapperCol: { span: 21 }, +}; + +interface IXFormProps { + form: any; + reload: any; + formData?: any; + visible?: boolean; + handleVisible?: any; + currentCluster?: IMetaData; +} + +interface IHaTopic { + clusterId: number; + clusterName: string; + haRelation: number; + topicName: string; + key: string; + disabled?: boolean; +} + +const resColumns = [ + { + title: 'TopicName', + dataIndex: 'topicName', + key: 'topicName', + width: 120, + }, + { + title: '状态', + dataIndex: 'code', + key: 'code', + width: 60, + render: (t: number) => { + return ( + + {t === 0 ? '成功' : '失败'} + + ); + }, + }, + { + title: '原因', + dataIndex: 'message', + key: 'message', + width: 125, + onCell: () => ({ + style: { + maxWidth: 120, + ...cellStyle, + }, + }), + render: (text: string) => { + return ( + + {text} + ); + }, + }, +]; +class TopicHaRelation extends React.Component { + public state = { + radioCheck: 'spec', + haTopics: [] as IHaTopic[], + targetKeys: [] as string[], + confirmLoading: false, + firstMove: true, + primaryActiveKeys: [] as string[], + primaryStandbyKeys: [] as string[], + }; + + public handleOk = () => { + this.props.form.validateFields((err: any, values: any) => { + const unbindTopics = []; + const bindTopics = []; + + if (values.rule === 'all') { + setHaTopics({ + all: true, + activeClusterId: this.props.currentCluster.clusterId, + standbyClusterId: this.props.currentCluster.haClusterVO.clusterId, + topicNames: [], + }).then(res => { + handleMsg(res, '关联成功'); + this.setState({ + confirmLoading: false, + }); + this.handleCancel(); + }); + return; + } + + for (const item of this.state.primaryStandbyKeys) { + if (!this.state.targetKeys.includes(item)) { + unbindTopics.push(item); + } + } + for (const item of this.state.targetKeys) { + if (!this.state.primaryStandbyKeys.includes(item)) { + bindTopics.push(item); + } + } + + if (!unbindTopics.length && !bindTopics.length) { + return message.info('请选择您要操作的Topic'); + } + + const handleMsg = (res: any[], successTip: string) => { + const errorRes = res.filter(item => item.code !== 0); + + if (errorRes.length) { + Modal.confirm({ + title: '执行结果', + width: 520, + icon: null, + content: ( +
      + ), + }); + } else { + notification.success({ message: successTip }); + } + + this.props.reload(); + }; + + if (bindTopics.length) { + this.setState({ + confirmLoading: true, + }); + setHaTopics({ + all: false, + activeClusterId: this.props.currentCluster.clusterId, + standbyClusterId: this.props.currentCluster.haClusterVO.clusterId, + topicNames: bindTopics, + }).then(res => { + this.setState({ + confirmLoading: false, + }); + this.handleCancel(); + handleMsg(res, '关联成功'); + }); + } + + if (unbindTopics.length) { + this.setState({ + confirmLoading: true, + }); + unbindHaTopics({ + all: false, + activeClusterId: this.props.currentCluster.clusterId, + standbyClusterId: this.props.currentCluster.haClusterVO.clusterId, + topicNames: unbindTopics, + }).then(res => { + this.setState({ + confirmLoading: false, + }); + this.handleCancel(); + handleMsg(res, '解绑成功'); + }); + } + }); + } + + public handleCancel = () => { + this.props.handleVisible(false); + this.props.form.resetFields(); + } + + public handleRadioChange = (e: any) => { + this.setState({ + radioCheck: e.target.value, + }); + } + + public isPrimaryStatus = (targetKeys: string[]) => { + const { primaryStandbyKeys } = this.state; + let isReset = false; + // 判断当前移动是否还原为最初的状态 + if (primaryStandbyKeys.length === targetKeys.length) { + targetKeys.sort((a, b) => +a - (+b)); + primaryStandbyKeys.sort((a, b) => +a - (+b)); + let i = 0; + while (i < targetKeys.length) { + if (targetKeys[i] === primaryStandbyKeys[i]) { + i++; + } else { + break; + } + } + isReset = i === targetKeys.length; + } + return isReset; + } + + public setTopicsStatus = (targetKeys: string[], disabled: boolean, isAll = false) => { + const { haTopics } = this.state; + const newTopics = Array.from(haTopics); + if (isAll) { + for (let i = 0; i < haTopics.length; i++) { + newTopics[i].disabled = disabled; + } + } else { + for (const key of targetKeys) { + const index = haTopics.findIndex(item => item.key === key); + if (index > -1) { + newTopics[index].disabled = disabled; + } + } + } + this.setState(({ + haTopics: newTopics, + })); + } + + public onTransferChange = (targetKeys: string[], direction: string, moveKeys: string[]) => { + const { primaryStandbyKeys, firstMove, primaryActiveKeys } = this.state; + // 判断当前移动是否还原为最初的状态 + const isReset = this.isPrimaryStatus(targetKeys); + if (firstMove) { + const primaryKeys = direction === 'right' ? primaryStandbyKeys : primaryActiveKeys; + this.setTopicsStatus(primaryKeys, true, false); + this.setState(({ + firstMove: false, + targetKeys, + })); + return; + } + + // 如果是还原为初始状态则还原禁用状态 + if (isReset) { + this.setTopicsStatus([], false, true); + this.setState(({ + firstMove: true, + targetKeys, + })); + return; + } + + this.setState({ + targetKeys, + }); + } + + public componentDidMount() { + Promise.all([ + getClusterHaTopicsStatus(this.props.currentCluster.clusterId, true), + getClusterHaTopicsStatus(this.props.currentCluster.clusterId, false), + ]).then(([activeRes, standbyRes]: IHaTopic[][]) => { + activeRes = (activeRes || []).map(row => ({ + ...row, + key: row.topicName, + })).filter(item => item.haRelation === null); + standbyRes = (standbyRes || []).map(row => ({ + ...row, + key: row.topicName, + })).filter(item => item.haRelation === 1 || item.haRelation === 0); + this.setState({ + haTopics: [].concat([...activeRes, ...standbyRes]).sort((a, b) => a.topicName.localeCompare(b.topicName)), + primaryActiveKeys: activeRes.map(row => row.topicName), + primaryStandbyKeys: standbyRes.map(row => row.topicName), + targetKeys: standbyRes.map(row => row.topicName), + }); + }); + } + + public render() { + const { formData = {} as any, visible, currentCluster } = this.props; + const { getFieldDecorator } = this.props.form; + let metadata = [] as IBrokersMetadata[]; + metadata = admin.brokersMetadata ? admin.brokersMetadata : metadata; + let regions = [] as IBrokersRegions[]; + regions = admin.brokersRegions ? admin.brokersRegions : regions; + return ( + <> + + + + {/* + {getFieldDecorator('rule', { + initialValue: 'spec', + rules: [{ + required: true, + message: '请选择规则', + }], + })( + 应用于所有Topic + 应用于特定Topic + )} + */} + {this.state.radioCheck === 'spec' ? + {getFieldDecorator('topicNames', { + initialValue: this.state.targetKeys, + rules: [{ + required: false, + message: '请选择Topic', + }], + })( + item.topicName} + titles={['未关联', '已关联']} + locale={{ + itemUnit: '', + itemsUnit: '', + }} + />, + )} + : ''} + + + + ); + } +} +export const TopicHaRelationWrapper = Form.create()(TopicHaRelation); diff --git a/kafka-manager-console/src/container/modal/admin/TopicHaSwitch.tsx b/kafka-manager-console/src/container/modal/admin/TopicHaSwitch.tsx new file mode 100644 index 00000000..78c5565b --- /dev/null +++ b/kafka-manager-console/src/container/modal/admin/TopicHaSwitch.tsx @@ -0,0 +1,718 @@ +import * as React from 'react'; +import { admin } from 'store/admin'; +import { Modal, Form, Radio, Tag, Popover, Button } from 'antd'; +import { IBrokersMetadata, IBrokersRegions, IMetaData } from 'types/base-type'; +import { Alert, Icon, message, Table, Transfer } from 'component/antd'; +import { getClusterHaTopics, getAppRelatedTopics, createSwitchTask } from 'lib/api'; +import { TooltipPlacement } from 'antd/es/tooltip'; +import * as XLSX from 'xlsx'; +import moment from 'moment'; +import { timeMinute } from 'constants/strategy'; + +const layout = { + labelCol: { span: 3 }, + wrapperCol: { span: 21 }, +}; + +interface IXFormProps { + form: any; + reload: any; + formData?: any; + visible?: boolean; + handleVisible?: any; + currentCluster?: IMetaData; +} + +interface IHaTopic { + clusterId: number; + topicName: string; + key: string; + activeClusterId: number; + consumeAclNum: number; + produceAclNum: number; + standbyClusterId: number; + status: number; + disabled?: boolean; +} + +interface IKafkaUser { + clusterPhyId: number; + kafkaUser: string; + notHaTopicNameList: string[]; + notSelectTopicNameList: string[]; + selectedTopicNameList: string[]; + show: boolean; +} + +const columns = [ + { + dataIndex: 'topicName', + title: '名称', + width: 100, + ellipsis: true, + }, + { + dataIndex: 'produceAclNum', + title: '生产者数量', + width: 80, + }, + { + dataIndex: 'consumeAclNum', + title: '消费者数量', + width: 80, + }, +]; + +const kafkaUserColumn = [ + { + dataIndex: 'kafkaUser', + title: 'kafkaUser', + width: 100, + ellipsis: true, + }, + { + dataIndex: 'selectedTopicNameList', + title: '已选中Topic', + width: 120, + render: (text: string[]) => { + return text?.length ? renderAttributes({ data: text, limit: 3 }) : '-'; + }, + }, + { + dataIndex: 'notSelectTopicNameList', + title: '选中关联Topic', + width: 120, + render: (text: string[]) => { + return text?.length ? renderAttributes({ data: text, limit: 3 }) : '-'; + }, + }, + { + dataIndex: 'notHaTopicNameList', + title: '未建立HA Topic', + width: 120, + render: (text: string[]) => { + return text?.length ? renderAttributes({ data: text, limit: 3 }) : '-'; + }, + }, +]; + +export const renderAttributes = (params: { + data: any; + type?: string; + limit?: number; + splitType?: string; + placement?: TooltipPlacement; +}) => { + const { data, type = ',', limit = 2, splitType = ';', placement } = params; + let attrArray = data; + if (!Array.isArray(data) && data) { + attrArray = data.split(type); + } + const showItems = attrArray.slice(0, limit) || []; + const hideItems = attrArray.slice(limit, attrArray.length) || []; + const content = hideItems.map((item: string, index: number) => ( + + {item} + + )); + const showItemsContent = showItems.map((item: string, index: number) => ( + + {item} + + )); + + return ( +
      + {showItems.length > 0 ? showItemsContent : '-'} + {hideItems.length > 0 && ( + + 共{attrArray.length}个 + + )} +
      + ); +}; +class TopicHaSwitch extends React.Component { + public state = { + radioCheck: 'spec', + targetKeys: [] as string[], + selectedKeys: [] as string[], + topics: [] as IHaTopic[], + kafkaUsers: [] as IKafkaUser[], + primaryActiveKeys: [] as string[], + primaryStandbyKeys: [] as string[], + firstMove: true, + }; + + public isPrimaryStatus = (targetKeys: string[]) => { + const { primaryStandbyKeys } = this.state; + let isReset = false; + // 判断当前移动是否还原为最初的状态 + if (primaryStandbyKeys.length === targetKeys.length) { + targetKeys.sort((a, b) => +a - (+b)); + primaryStandbyKeys.sort((a, b) => +a - (+b)); + let i = 0; + while (i < targetKeys.length) { + if (targetKeys[i] === primaryStandbyKeys[i]) { + i++; + } else { + break; + } + } + isReset = i === targetKeys.length; + } + return isReset; + } + + public getTargetTopics = (currentKeys: string[], primaryKeys: string[]) => { + const targetTopics = []; + for (const key of currentKeys) { + if (!primaryKeys.includes(key)) { + const topic = this.state.topics.find(item => item.key === key)?.topicName; + targetTopics.push(topic); + } + } + return targetTopics; + } + + public handleOk = () => { + const { primaryStandbyKeys, primaryActiveKeys, topics } = this.state; + const standbyClusterId = this.props.currentCluster.haClusterVO.clusterId; + const activeClusterId = this.props.currentCluster.clusterId; + + this.props.form.validateFields((err: any, values: any) => { + + if (values.rule === 'all') { + createSwitchTask({ + activeClusterPhyId: activeClusterId, + all: true, + mustContainAllKafkaUserTopics: true, + standbyClusterPhyId: standbyClusterId, + topicNameList: [], + }).then(res => { + message.success('任务创建成功'); + this.handleCancel(); + this.props.reload(res); + }); + return; + } + // 判断当前移动是否还原为最初的状态 + const isPrimary = this.isPrimaryStatus(values.targetKeys || []); + if (isPrimary) { + return message.info('请选择您要切换的Topic'); + } + + // 右侧框值 + const currentStandbyKeys = values.targetKeys || []; + // 左侧框值 + const currentActiveKeys = []; + for (const item of topics) { + if (!currentStandbyKeys.includes(item.key)) { + currentActiveKeys.push(item.key); + } + } + + const currentKeys = currentStandbyKeys.length > primaryStandbyKeys.length ? currentStandbyKeys : currentActiveKeys; + const primaryKeys = currentStandbyKeys.length > primaryStandbyKeys.length ? primaryStandbyKeys : primaryActiveKeys; + const activeClusterPhyId = currentStandbyKeys.length > primaryStandbyKeys.length ? standbyClusterId : activeClusterId; + const standbyClusterPhyId = currentStandbyKeys.length > primaryStandbyKeys.length ? activeClusterId : standbyClusterId; + const targetTopics = this.getTargetTopics(currentKeys, primaryKeys); + createSwitchTask({ + activeClusterPhyId, + all: false, + mustContainAllKafkaUserTopics: true, + standbyClusterPhyId, + topicNameList: targetTopics, + }).then(res => { + message.success('任务创建成功'); + this.handleCancel(); + this.props.reload(res); + }); + }); + } + + public handleCancel = () => { + this.props.handleVisible(false); + this.props.form.resetFields(); + } + + public handleRadioChange = (e: any) => { + this.setState({ + radioCheck: e.target.value, + }); + } + + public getNewSelectKeys = (removeKeys: string[], selectedKeys: string[]) => { + const { topics, kafkaUsers } = this.state; + // 根据移除的key找与该key关联的其他key,一起移除 + let relatedTopics: string[] = []; + const relatedKeys: string[] = []; + const newSelectKeys = []; + for (const key of removeKeys) { + const topicName = topics.find(row => row.key === key)?.topicName; + for (const item of kafkaUsers) { + if (item.selectedTopicNameList.includes(topicName)) { + relatedTopics = relatedTopics.concat(item.selectedTopicNameList); + relatedTopics = relatedTopics.concat(item.notSelectTopicNameList); + } + } + for (const item of relatedTopics) { + const key = topics.find(row => row.topicName === item)?.key; + if (key) { + relatedKeys.push(key); + } + } + for (const key of selectedKeys) { + if (!relatedKeys.includes(key)) { + newSelectKeys.push(key); + } + } + } + return newSelectKeys; + } + + public setTopicsStatus = (targetKeys: string[], disabled: boolean, isAll = false) => { + const { topics } = this.state; + const newTopics = Array.from(topics); + if (isAll) { + for (let i = 0; i < topics.length; i++) { + newTopics[i].disabled = disabled; + } + } else { + for (const key of targetKeys) { + const index = topics.findIndex(item => item.key === key); + if (index > -1) { + newTopics[index].disabled = disabled; + } + } + } + this.setState(({ + topics: newTopics, + })); + } + + public getFilterTopics = (selectKeys: string[]) => { + // 依据key值找topicName + const filterTopics: string[] = []; + const targetKeys = selectKeys; + for (const key of targetKeys) { + const topicName = this.state.topics.find(item => item.key === key)?.topicName; + if (topicName) { + filterTopics.push(topicName); + } + } + return filterTopics; + } + + public getNewKafkaUser = (targetKeys: string[]) => { + const { primaryStandbyKeys, topics } = this.state; + const removeKeys = []; + const addKeys = []; + for (const key of primaryStandbyKeys) { + if (targetKeys.indexOf(key) < 0) { + // 移除的 + removeKeys.push(key); + } + } + for (const key of targetKeys) { + if (primaryStandbyKeys.indexOf(key) < 0) { + // 新增的 + addKeys.push(key); + } + } + const keepKeys = [...removeKeys, ...addKeys]; + const newKafkaUsers = this.state.kafkaUsers; + + const moveTopics = this.getFilterTopics(keepKeys); + + for (const topic of moveTopics) { + for (const item of newKafkaUsers) { + if (item.selectedTopicNameList.includes(topic)) { + item.show = true; + } + } + } + + const showKafaUsers = newKafkaUsers.filter(item => item.show === true); + + for (const item of showKafaUsers) { + let i = 0; + while (i < moveTopics.length) { + if (!item.selectedTopicNameList.includes(moveTopics[i])) { + i++; + } else { + break; + } + } + + // 表示该kafkaUser不该展示 + if (i === moveTopics.length) { + item.show = false; + } + } + + return showKafaUsers; + } + + public getAppRelatedTopicList = (selectedKeys: string[]) => { + const { topics, targetKeys, primaryStandbyKeys, kafkaUsers } = this.state; + const filterTopicNameList = this.getFilterTopics(selectedKeys); + const isReset = this.isPrimaryStatus(targetKeys); + + if (!filterTopicNameList.length && isReset) { + // targetKeys + this.setState({ + kafkaUsers: kafkaUsers.map(item => ({ + ...item, + show: false, + })), + }); + return; + } else { + // 保留选中项与移动的的项 + this.setState({ + kafkaUsers: this.getNewKafkaUser(targetKeys), + }); + } + + // 单向选择,所以取当前值的aactiveClusterId + const clusterPhyId = topics.find(item => item.topicName === filterTopicNameList[0]).activeClusterId; + getAppRelatedTopics({ + clusterPhyId, + filterTopicNameList, + }).then((res: IKafkaUser[]) => { + let notSelectTopicNames: string[] = []; + const notSelectTopicKeys: string[] = []; + for (const item of (res || [])) { + notSelectTopicNames = notSelectTopicNames.concat(item.notSelectTopicNameList || []); + } + + for (const item of notSelectTopicNames) { + const key = topics.find(row => row.topicName === item)?.key; + + if (key) { + notSelectTopicKeys.push(key); + } + } + + const newSelectedKeys = selectedKeys.concat(notSelectTopicKeys); + const newKafkaUsers = (res || []).map(item => ({ + ...item, + show: true, + })); + const { kafkaUsers } = this.state; + + for (const item of kafkaUsers) { + const resItem = res.find(row => row.kafkaUser === item.kafkaUser); + if (!resItem) { + newKafkaUsers.push(item); + } + } + this.setState({ + kafkaUsers: newKafkaUsers, + selectedKeys: newSelectedKeys, + }); + + if (notSelectTopicKeys.length) { + this.getAppRelatedTopicList(newSelectedKeys); + } + }); + } + + public getRelatedKeys = (currentKeys: string[]) => { + // 未被选中的项 + const removeKeys = []; + // 对比上一次记录的选中的值找出本次取消的项 + const { selectedKeys } = this.state; + for (const preKey of selectedKeys) { + if (!currentKeys.includes(preKey)) { + removeKeys.push(preKey); + } + } + + return removeKeys?.length ? this.getNewSelectKeys(removeKeys, currentKeys) : currentKeys; + } + + public handleTopicChange = (sourceSelectedKeys: string[], targetSelectedKeys: string[]) => { + const { topics, targetKeys } = this.state; + // 条件限制只允许选中一边,单向操作 + const keys = [...sourceSelectedKeys, ...targetSelectedKeys]; + + // 判断当前选中项属于哪一类 + if (keys.length) { + const activeClusterId = topics.find(item => item.key === keys[0]).activeClusterId; + const needDisabledKeys = topics.filter(item => item.activeClusterId !== activeClusterId).map(row => row.key); + this.setTopicsStatus(needDisabledKeys, true); + } + const selectedKeys = this.state.selectedKeys.length ? this.getRelatedKeys(keys) : keys; + + const isReset = this.isPrimaryStatus(targetKeys); + if (!selectedKeys.length && isReset) { + this.setTopicsStatus([], false, true); + } + this.setState({ + selectedKeys, + }); + this.getAppRelatedTopicList(selectedKeys); + } + + public onDirectChange = (targetKeys: string[], direction: string, moveKeys: string[]) => { + const { primaryStandbyKeys, firstMove, primaryActiveKeys, topics } = this.state; + + const getKafkaUser = () => { + const newKafkaUsers = this.state.kafkaUsers; + const moveTopics = this.getFilterTopics(moveKeys); + for (const topic of moveTopics) { + for (const item of newKafkaUsers) { + if (item.selectedTopicNameList.includes(topic)) { + item.show = true; + } + } + } + return newKafkaUsers; + }; + // 判断当前移动是否还原为最初的状态 + const isReset = this.isPrimaryStatus(targetKeys); + if (firstMove) { + const primaryKeys = direction === 'right' ? primaryStandbyKeys : primaryActiveKeys; + this.setTopicsStatus(primaryKeys, true, false); + this.setState(({ + firstMove: false, + kafkaUsers: getKafkaUser(), + targetKeys, + })); + return; + } + // 如果是还原为初始状态则还原禁用状态 + if (isReset) { + this.setTopicsStatus([], false, true); + this.setState(({ + firstMove: true, + targetKeys, + kafkaUsers: [], + })); + return; + } + + // 切换后重新判定展示项 + this.setState(({ + targetKeys, + kafkaUsers: this.getNewKafkaUser(targetKeys), + })); + + } + + public downloadData = () => { + const { kafkaUsers } = this.state; + const tableData = kafkaUsers.map(item => { + return { + // tslint:disable + 'kafkaUser': item.kafkaUser, + '已选中Topic': item.selectedTopicNameList?.join('、'), + '选中关联Topic': item.notSelectTopicNameList?.join('、'), + '未建立HA Topic': item.notHaTopicNameList?.join(`、`), + }; + }); + const data = [].concat(tableData); + const wb = XLSX.utils.book_new(); + // json转sheet + const ws = XLSX.utils.json_to_sheet(data, { + header: ['kafkaUser', '已选中Topic', '选中关联Topic', '未建立HA Topic'], + }); + // XLSX.utils. + XLSX.utils.book_append_sheet(wb, ws, 'kafkaUser'); + // 输出 + XLSX.writeFile(wb, 'kafkaUser-' + moment((new Date()).getTime()).format(timeMinute) + '.xlsx'); + } + + public judgeSubmitStatus = () => { + const { kafkaUsers } = this.state; + + const newKafkaUsers = kafkaUsers.filter(item => item.show) + for (const item of newKafkaUsers) { + if (item.notHaTopicNameList.length) { + return true; + } + } + return false; + } + + public componentDidMount() { + const standbyClusterId = this.props.currentCluster.haClusterVO.clusterId; + const activeClusterId = this.props.currentCluster.clusterId; + getClusterHaTopics(this.props.currentCluster.clusterId, standbyClusterId).then((res: IHaTopic[]) => { + res = res.map((item, index) => ({ + key: index.toString(), + ...item, + })); + const targetKeys = (res || []).filter((item) => item.activeClusterId === standbyClusterId).map(row => row.key); + const primaryActiveKeys = (res || []).filter((item) => item.activeClusterId === activeClusterId).map(row => row.key); + this.setState({ + topics: res || [], + primaryStandbyKeys: targetKeys, + primaryActiveKeys, + targetKeys, + }); + }); + } + + public render() { + const { visible, currentCluster } = this.props; + const { getFieldDecorator } = this.props.form; + let metadata = [] as IBrokersMetadata[]; + metadata = admin.brokersMetadata ? admin.brokersMetadata : metadata; + let regions = [] as IBrokersRegions[]; + regions = admin.brokersRegions ? admin.brokersRegions : regions; + const tableData = this.state.kafkaUsers.filter(row => row.show); + + return ( + + + + + } + > + +
      + {/* + {getFieldDecorator('rule', { + initialValue: 'spec', + rules: [{ + required: true, + message: '请选择规则', + }], + })( + 应用于所有Topic + 应用于特定Topic + )} + */} + {this.state.radioCheck === 'spec' ? + {getFieldDecorator('targetKeys', { + initialValue: this.state.targetKeys, + rules: [{ + required: false, + message: '请选择Topic', + }], + })( + , + )} + : ''} + + {this.state.radioCheck === 'spec' ? + <> +
      + {this.state.kafkaUsers.length ? : null} + + : null} + + ); + } +} +export const TopicSwitchWrapper = Form.create()(TopicHaSwitch); + +const TableTransfer = ({ leftColumns, ...restProps }: any) => ( + + {({ + filteredItems, + direction, + onItemSelect, + selectedKeys: listSelectedKeys, + }) => { + const columns = leftColumns; + + const rowSelection = { + columnWidth: 40, + getCheckboxProps: (item: any) => ({ + disabled: item.disabled, + }), + onSelect({ key }: any, selected: any) { + onItemSelect(key, selected); + }, + selectedRowKeys: listSelectedKeys, + }; + return ( +
      ({ + onClick: () => { + if (disabled) return; + onItemSelect(key, !listSelectedKeys.includes(key)); + }, + })} + /> + ); + }} + +); + +interface IProps { + value?: any; + onChange?: any; + onDirectChange?: any; + currentCluster: any; + topicChange: any; + dataSource: any[]; + selectedKeys: string[]; +} + +export class TransferTable extends React.Component { + public onChange = (nextTargetKeys: any, direction: string, moveKeys: string[]) => { + this.props.onDirectChange(nextTargetKeys, direction, moveKeys); + // tslint:disable-next-line:no-unused-expression + this.props.onChange && this.props.onChange(nextTargetKeys); + } + + public render() { + const { currentCluster, dataSource, value, topicChange, selectedKeys } = this.props; + return ( +
      + +
      + ); + } +} diff --git a/kafka-manager-console/src/container/modal/topic.tsx b/kafka-manager-console/src/container/modal/topic.tsx index 4e026641..d7f797ec 100644 --- a/kafka-manager-console/src/container/modal/topic.tsx +++ b/kafka-manager-console/src/container/modal/topic.tsx @@ -16,6 +16,17 @@ import { modal } from 'store/modal'; import { TopicAppSelect } from '../topic/topic-app-select'; import Url from 'lib/url-parser'; import { expandRemarks, quotaRemarks } from 'constants/strategy'; +import { getAppListByClusterId } from 'lib/api'; + +const updateApplyTopicFormModal = (clusterId: number) => { + const formMap = wrapper.xFormWrapper.formMap; + const formData = wrapper.xFormWrapper.formData; + getAppListByClusterId(clusterId).then(res => { + formMap[2].customFormItem = ; + // tslint:disable-next-line:no-unused-expression + wrapper.ref && wrapper.ref.updateFormMap$(formMap, formData); + }); +}; export const applyTopic = () => { const xFormModal = { @@ -28,6 +39,9 @@ export const applyTopic = () => { rules: [{ required: true, message: '请选择' }], attrs: { placeholder: '请选择', + onChange(value: number) { + updateApplyTopicFormModal(value); + }, }, }, { key: 'topicName', @@ -49,7 +63,7 @@ export const applyTopic = () => { type: 'custom', defaultValue: '', rules: [{ required: true, message: '请选择' }], - customFormItem: , + customFormItem: , }, { key: 'peakBytesIn', label: '峰值流量', @@ -88,7 +102,7 @@ export const applyTopic = () => { ], formData: {}, visible: true, - title: , + title: , okText: '确认', // customRenderElement: 集群资源充足时,预计1分钟自动审批通过, isWaitting: true, @@ -106,7 +120,7 @@ export const applyTopic = () => { }; return topic.applyTopic(quotaParams).then(data => { window.location.href = `${urlPrefix}/user/order-detail/?orderId=${data.id}®ion=${region.currentRegion}`; - }) + }); }, onSubmitFaild: (err: any, ref: any, formData: any, formMap: any) => { if (err.message === 'topic already existed') { @@ -115,10 +129,10 @@ export const applyTopic = () => { topicName: { value: topic, errors: [new Error('该topic名称已存在')], - } - }) + }, + }); } - } + }, }; wrapper.open(xFormModal); }; @@ -186,7 +200,7 @@ export const showApplyQuatoModal = (item: ITopic | IAppsIdInfo, record: IQuotaQu // rules: [{ required: true, message: '' }], // attrs: { disabled: true }, // invisible: !item.hasOwnProperty('clusterName'), - // }, + // }, { key: 'topicName', label: 'Topic名称', @@ -300,7 +314,7 @@ export const showTopicApplyQuatoModal = (item: ITopic) => { // attrs: { disabled: true }, // defaultValue: item.clusterName, // // invisible: !item.hasOwnProperty('clusterName'), - // }, + // }, { key: 'topicName', label: 'Topic名称', @@ -380,12 +394,19 @@ export const showTopicApplyQuatoModal = (item: ITopic) => { consumeQuota: transMBToB(value.consumeQuota), produceQuota: transMBToB(value.produceQuota), }); + + if (item.isPhysicalClusterId) { + Object.assign(quota, { + isPhysicalClusterId: true, + }); + } const quotaParams = { type: 2, applicant: users.currentUser.username, description: value.description, extensions: JSON.stringify(quota), }; + topic.applyQuota(quotaParams).then((data) => { notification.success({ message: '申请配额成功' }); window.location.href = `${urlPrefix}/user/order-detail/?orderId=${data.id}®ion=${region.currentRegion}`; @@ -454,23 +475,24 @@ const judgeAccessStatus = (access: number) => { export const showAllPermissionModal = (item: ITopic) => { let appId: string = null; + app.getAppListByClusterId(item.clusterId).then(res => { + if (!app.clusterAppData || !app.clusterAppData.length) { + return notification.info({ + message: ( + <> + + 您的账号暂无可用应用,请先 + 申请应用 + + ), + }); + } + const index = app.clusterAppData.findIndex(row => row.appId === item.appId); - if (!app.data || !app.data.length) { - return notification.info({ - message: ( - <> - - 您的账号暂无可用应用,请先 - 申请应用 - - ), + appId = index > -1 ? item.appId : app.clusterAppData[0].appId; + topic.getAuthorities(appId, item.clusterId, item.topicName).then((data) => { + showAllPermission(appId, item, data.access); }); - } - const index = app.data.findIndex(row => row.appId === item.appId); - - appId = index > -1 ? item.appId : app.data[0].appId; - topic.getAuthorities(appId, item.clusterId, item.topicName).then((data) => { - showAllPermission(appId, item, data.access); }); }; @@ -494,7 +516,7 @@ const showAllPermission = (appId: string, item: ITopic, access: number) => { defaultValue: appId, rules: [{ required: true, message: '请选择应用' }], type: 'custom', - customFormItem: , + customFormItem: , }, { key: 'access', diff --git a/kafka-manager-console/src/container/search-filter.tsx b/kafka-manager-console/src/container/search-filter.tsx index f6ed09fa..ca621c03 100644 --- a/kafka-manager-console/src/container/search-filter.tsx +++ b/kafka-manager-console/src/container/search-filter.tsx @@ -18,7 +18,7 @@ interface IFilterParams { } interface ISearchAndFilterState { - [filter: string]: boolean | string | number | any[]; + [filter: string]: boolean | string | number | any; } export class SearchAndFilterContainer extends React.Component { diff --git a/kafka-manager-console/src/container/topic/topic-detail/index.tsx b/kafka-manager-console/src/container/topic/topic-detail/index.tsx index 0220341b..4745acba 100644 --- a/kafka-manager-console/src/container/topic/topic-detail/index.tsx +++ b/kafka-manager-console/src/container/topic/topic-detail/index.tsx @@ -331,11 +331,13 @@ export class TopicDetail extends React.Component { public render() { const role = users.currentUser.role; const baseInfo = topic.baseInfo as ITopicBaseInfo; - const showEditBtn = (role == 1 || role == 2) || (topic.topicBusiness && topic.topicBusiness.principals.includes(users.currentUser.username)); + const showEditBtn = (role == 1 || role == 2) || + (topic.topicBusiness && topic.topicBusiness.principals.includes(users.currentUser.username)); const topicRecord = { clusterId: this.clusterId, topicName: this.topicName, - clusterName: this.clusterName + clusterName: this.clusterName, + isPhysicalClusterId: !!this.isPhysicalTrue, } as ITopic; return ( @@ -349,9 +351,12 @@ export class TopicDetail extends React.Component { title={this.topicName || ''} extra={ <> - {this.needAuth == "true" && } - - + {this.needAuth == 'true' && + } + {baseInfo.haRelation === 0 ? null : + } + {baseInfo.haRelation === 0 ? null : + } {/* {showEditBtn && } */} diff --git a/kafka-manager-console/src/lib/api.ts b/kafka-manager-console/src/lib/api.ts index 39bb63ff..d0200653 100644 --- a/kafka-manager-console/src/lib/api.ts +++ b/kafka-manager-console/src/lib/api.ts @@ -248,6 +248,10 @@ export const getAppTopicList = (appId: string, mine: boolean) => { return fetch(`/normal/apps/${appId}/topics?mine=${mine}`); }; +export const getAppListByClusterId = (clusterId: number) => { + return fetch(`/normal/apps/${clusterId}`); +}; + /** * 专家服务 */ @@ -418,8 +422,69 @@ export const getMetaData = (needDetail: boolean = true) => { return fetch(`/rd/clusters/basic-info?need-detail=${needDetail}`); }; +export const getHaMetaData = () => { + return fetch(`/rd/clusters/ha/basic-info`); +}; + +export const getClusterHaTopics = (firstClusterId: number, secondClusterId?: number) => { + return fetch(`/rd/clusters/${firstClusterId}/ha-topics?secondClusterId=${secondClusterId || ''}`); +}; + +export const getClusterHaTopicsStatus = (firstClusterId: number, checkMetadata: boolean) => { + return fetch(`/rd/clusters/${firstClusterId}/ha-topics/status?checkMetadata=${checkMetadata}`); +}; + +export const setHaTopics = (params: any) => { + return fetch(`/op/ha-topics`, { + method: 'POST', + body: JSON.stringify(params), + }); +}; + +export const getAppRelatedTopics = (params: any) => { + return fetch(`/rd/apps/relate-topics + `, { + method: 'POST', + body: JSON.stringify(params), + }); +}; +// 取消Topic高可用 +export const unbindHaTopics = (params: any) => { + return fetch(`/op/ha-topics`, { + method: 'DELETE', + body: JSON.stringify(params), + }); +}; + +// 创建Topic主备切换任务 +export const createSwitchTask = (params: any) => { + return fetch(`/op/as-switch-jobs`, { + method: 'POST', + body: JSON.stringify(params), + }); +}; + +export const getJobDetail = (jobId: number) => { + return fetch(`/op/as-switch-jobs/${jobId}/job-detail`); +}; + +export const getJobLog = (jobId: number, startLogId?: number) => { + return fetch(`/op/as-switch-jobs/${jobId}/job-logs?startLogId=${startLogId || ''}`); +}; + +export const getJobState = (jobId: number) => { + return fetch(`/op/as-switch-jobs/${jobId}/job-state`); +}; + +export const switchAsJobs = (jobId: number, params: any) => { + return fetch(`/op/as-switch-jobs/${jobId}/action`, { + method: 'PUT', + body: JSON.stringify(params), + }); +}; + export const getOperationRecordData = (params: any) => { - return fetch(`/rd/operate-record`,{ + return fetch(`/rd/operate-record`, { method: 'POST', body: JSON.stringify(params), }); @@ -569,15 +634,15 @@ export const getCandidateController = (clusterId: number) => { return fetch(`/rd/clusters/${clusterId}/controller-preferred-candidates`); }; -export const addCandidateController = (params:any) => { - return fetch(`/op/cluster-controller/preferred-candidates`, { +export const addCandidateController = (params: any) => { + return fetch(`/op/cluster-controller/preferred-candidates`, { method: 'POST', body: JSON.stringify(params), }); }; -export const deleteCandidateCancel = (params:any)=>{ - return fetch(`/op/cluster-controller/preferred-candidates`, { +export const deleteCandidateCancel = (params: any) => { + return fetch(`/op/cluster-controller/preferred-candidates`, { method: 'DELETE', body: JSON.stringify(params), }); diff --git a/kafka-manager-console/src/lib/fetch.ts b/kafka-manager-console/src/lib/fetch.ts index ef307ccb..f51fd7d4 100644 --- a/kafka-manager-console/src/lib/fetch.ts +++ b/kafka-manager-console/src/lib/fetch.ts @@ -33,7 +33,6 @@ const checkStatus = (res: Response) => { }; const filter = (init: IInit) => (res: IRes) => { - if (res.code !== 0 && res.code !== 200) { if (!init.errorNoTips) { notification.error({ @@ -117,7 +116,7 @@ export default function fetch(url: string, init?: IInit) { export function formFetch(url: string, init?: IInit) { url = url.indexOf('?') > 0 ? - `${url}&dataCenter=${region.currentRegion}` : `${url}?dataCenter=${region.currentRegion}`; + `${url}&dataCenter=${region.currentRegion}` : `${url}?dataCenter=${region.currentRegion}`; let realUrl = url; if (!/^http(s)?:\/\//.test(url)) { @@ -127,8 +126,8 @@ export function formFetch(url: string, init?: IInit) { init = addCustomHeader(init); return window - .fetch(realUrl, init) - .then(res => checkStatus(res)) - .then((res) => res.json()) - .then(filter(init)); + .fetch(realUrl, init) + .then(res => checkStatus(res)) + .then((res) => res.json()) + .then(filter(init)); } diff --git a/kafka-manager-console/src/routers/page/index.less b/kafka-manager-console/src/routers/page/index.less index e4559814..21415a74 100644 --- a/kafka-manager-console/src/routers/page/index.less +++ b/kafka-manager-console/src/routers/page/index.less @@ -1,4 +1,3 @@ - * { padding: 0; margin: 0; @@ -13,7 +12,9 @@ li { list-style-type: none; } -html, body, .router-nav { +html, +body, +.router-nav { width: 100%; height: 100%; font-family: PingFangSC-Regular; @@ -52,11 +53,12 @@ html, body, .router-nav { color: @primary-color; } -.ant-table-thead > tr > th, .ant-table-tbody > tr > td { +.ant-table-thead>tr>th, +.ant-table-tbody>tr>td { padding: 13px; } -.ant-table-tbody > tr > td { +.ant-table-tbody>tr>td { background: #fff; } @@ -72,15 +74,11 @@ html, body, .router-nav { overflow: auto; } -.ant-form-item { - margin-bottom: 16px; -} - .mb-24 { margin-bottom: 24px; } -.ant-table-thead > tr > th .ant-table-filter-icon { +.ant-table-thead>tr>th .ant-table-filter-icon { right: initial; } @@ -100,7 +98,7 @@ html, body, .router-nav { margin-left: 10px; } -.config-info{ +.config-info { white-space: pre-line; height: 100%; overflow-y: scroll; @@ -112,5 +110,4 @@ html, body, .router-nav { margin-left: 10px; cursor: pointer; font-size: 12px; -} - +} \ No newline at end of file diff --git a/kafka-manager-console/src/routers/router.tsx b/kafka-manager-console/src/routers/router.tsx index 164eb370..192e55ef 100644 --- a/kafka-manager-console/src/routers/router.tsx +++ b/kafka-manager-console/src/routers/router.tsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Route } from 'react-router-dom'; import { hot } from 'react-hot-loader/root'; import * as React from 'react'; +import zhCN from 'antd/lib/locale/zh_CN'; import Home from './page/topic'; import Admin from './page/admin'; @@ -12,58 +13,62 @@ import { urlPrefix } from 'constants/left-menu'; import ErrorPage from './page/error'; import Login from './page/login'; import InfoPage from './page/info'; +import { ConfigProvider } from 'antd'; class RouterDom extends React.Component { public render() { return ( - - - - + - - + + + + - - + + - - + + - - + + - - + + - - - - + + + + + + + + ); } } diff --git a/kafka-manager-console/src/store/admin.ts b/kafka-manager-console/src/store/admin.ts index 582950a3..c7957788 100644 --- a/kafka-manager-console/src/store/admin.ts +++ b/kafka-manager-console/src/store/admin.ts @@ -57,8 +57,9 @@ import { getBillStaffDetail, getCandidateController, addCandidateController, - deleteCandidateCancel - } from 'lib/api'; + deleteCandidateCancel, + getHaMetaData, +} from 'lib/api'; import { getControlMetricOption, getClusterMetricOption } from 'lib/line-charts-config'; import { copyValueMap } from 'constants/status-map'; @@ -104,12 +105,15 @@ class Admin { @observable public metaList: IMetaData[] = []; + @observable + public haMetaList: IMetaData[] = []; + @observable public oRList: any[] = []; @observable - public oRparams:any={ - moduleId:0 + public oRparams: any = { + moduleId: 0 }; @observable @@ -169,9 +173,9 @@ class Admin { @observable public controllerCandidate: IController[] = []; - @observable + @observable public filtercontrollerCandidate: string = ''; - + @observable public brokersPartitions: IBrokersPartitions[] = []; @@ -329,9 +333,20 @@ class Admin { } @action.bound - public setOperationRecordList(data:any){ + public setHaMetaList(data: IMetaData[]) { this.setLoading(false); - this.oRList = data ? data.map((item:any, index: any) => { + this.haMetaList = data ? data.map((item, index) => { + item.key = index; + return item; + }) : []; + this.haMetaList = this.haMetaList.sort((a, b) => a.clusterId - b.clusterId); + return this.haMetaList; + } + + @action.bound + public setOperationRecordList(data: any) { + this.setLoading(false); + this.oRList = data ? data.map((item: any, index: any) => { item.key = index; return item; }) : []; @@ -394,9 +409,9 @@ class Admin { item.key = index; return item; }) : []; - this.filtercontrollerCandidate = data?data.map((item,index)=>{ + this.filtercontrollerCandidate = data ? data.map((item, index) => { return item.brokerId - }).join(','):'' + }).join(',') : '' } @action.bound @@ -479,8 +494,8 @@ class Admin { } @action.bound - public setBrokersMetadata(data: IBrokersMetadata[]|any) { - this.brokersMetadata = data ? data.map((item:any, index:any) => { + public setBrokersMetadata(data: IBrokersMetadata[] | any) { + this.brokersMetadata = data ? data.map((item: any, index: any) => { item.key = index; return { ...item, @@ -675,6 +690,11 @@ class Admin { getMetaData(needDetail).then(this.setMetaList); } + public getHaMetaData() { + this.setLoading(true); + return getHaMetaData().then(this.setHaMetaList); + } + public getOperationRecordData(params: any) { this.setLoading(true); this.oRparams = params @@ -738,17 +758,17 @@ class Admin { } public getCandidateController(clusterId: number) { - return getCandidateController(clusterId).then(data=>{ + return getCandidateController(clusterId).then(data => { return this.setCandidateController(data) }); } public addCandidateController(clusterId: number, brokerIdList: any) { - return addCandidateController({clusterId, brokerIdList}).then(()=>this.getCandidateController(clusterId)); + return addCandidateController({ clusterId, brokerIdList }).then(() => this.getCandidateController(clusterId)); } - public deleteCandidateCancel(clusterId: number, brokerIdList: any){ - return deleteCandidateCancel({clusterId, brokerIdList}).then(()=>this.getCandidateController(clusterId)); + public deleteCandidateCancel(clusterId: number, brokerIdList: any) { + return deleteCandidateCancel({ clusterId, brokerIdList }).then(() => this.getCandidateController(clusterId)); } public getBrokersBasicInfo(clusterId: number, brokerId: number) { diff --git a/kafka-manager-console/src/store/app.ts b/kafka-manager-console/src/store/app.ts index a3af345f..a64c93a7 100644 --- a/kafka-manager-console/src/store/app.ts +++ b/kafka-manager-console/src/store/app.ts @@ -1,5 +1,5 @@ import { observable, action } from 'mobx'; -import { getAppList, getAppDetail, getAppTopicList, applyOrder, modfiyApplication, modfiyAdminApp, getAdminAppList, getAppsConnections, getTopicAppQuota } from 'lib/api'; +import { getAppList, getAppDetail, getAppTopicList, applyOrder, modfiyApplication, modfiyAdminApp, getAdminAppList, getAppsConnections, getTopicAppQuota, getAppListByClusterId } from 'lib/api'; import { IAppItem, IAppQuota, ITopic, IOrderParams, IConnectionInfo } from 'types/base-type'; class App { @@ -12,6 +12,9 @@ class App { @observable public data: IAppItem[] = []; + @observable + public clusterAppData: IAppItem[] = []; + @observable public adminAppData: IAppItem[] = []; @@ -19,7 +22,7 @@ class App { public selectData: IAppItem[] = [{ appId: '-1', name: '所有关联应用', - } as IAppItem, + } as IAppItem, ]; @observable @@ -51,12 +54,12 @@ class App { @action.bound public setTopicAppQuota(data: IAppQuota[]) { return this.appQuota = data.map((item, index) => { - return { - ...item, - label: item.appName, - value: item.appId, - key: index, - }; + return { + ...item, + label: item.appName, + value: item.appId, + key: index, + }; }); } @@ -87,6 +90,16 @@ class App { this.setLoading(false); } + @action.bound + public setClusterAppData(data: IAppItem[] = []) { + this.clusterAppData = data.map((item, index) => ({ + ...item, + key: index, + principalList: item.principals ? item.principals.split(',') : [], + })); + return this.clusterAppData; + } + @action.bound public setAdminData(data: IAppItem[] = []) { this.adminAppData = data.map((item, index) => ({ @@ -133,6 +146,10 @@ class App { getAppList().then(this.setData); } + public getAppListByClusterId(clusterId: number) { + return getAppListByClusterId(clusterId).then(this.setClusterAppData); + } + public getTopicAppQuota(clusterId: number, topicName: string) { return getTopicAppQuota(clusterId, topicName).then(this.setTopicAppQuota); } diff --git a/kafka-manager-console/src/store/topic.ts b/kafka-manager-console/src/store/topic.ts index b47c1122..cacb7bf4 100644 --- a/kafka-manager-console/src/store/topic.ts +++ b/kafka-manager-console/src/store/topic.ts @@ -37,6 +37,7 @@ export interface ITopicBaseInfo { physicalClusterId: number; percentile: string; regionNameList: any; + haRelation: number; } export interface IRealTimeTraffic { diff --git a/kafka-manager-console/src/types/base-type.ts b/kafka-manager-console/src/types/base-type.ts index f0858c3a..9f8f73c1 100644 --- a/kafka-manager-console/src/types/base-type.ts +++ b/kafka-manager-console/src/types/base-type.ts @@ -474,7 +474,14 @@ export interface IMetaData { status: number; topicNum: number; zookeeper: string; + haRelation?: number; + haASSwitchJobId?: number; + haStatus?: number; + haClusterVO?: IMetaData; + activeTopicCount?: number; + standbyTopicCount?: number; key?: number; + mutualBackupClusterName?: string; } export interface IConfigure { @@ -641,6 +648,7 @@ export interface IClusterTopics { properties: any; clusterName: string; logicalClusterId: number; + haRelation?: number; key?: number; } diff --git a/kafka-manager-console/webpack.config.js b/kafka-manager-console/webpack.config.js index d6d12fa8..1608de20 100644 --- a/kafka-manager-console/webpack.config.js +++ b/kafka-manager-console/webpack.config.js @@ -130,9 +130,7 @@ module.exports = { historyApiFallback: true, proxy: { '/api/v1/': { - // target: 'http://127.0.0.1:8080', - target: 'http://10.179.37.199:8008', - // target: 'http://99.11.45.164:8888', + target: 'http://127.0.0.1:8080/', changeOrigin: true, } }, diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaASRelationManager.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaASRelationManager.java new file mode 100644 index 00000000..11c79127 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaASRelationManager.java @@ -0,0 +1,32 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterTopicVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.HaClusterTopicHaStatusVO; + +import java.util.List; + +public interface HaASRelationManager { + /** + * 获取集群主备信息 + */ + List getHATopics(Long firstClusterPhyId, Long secondClusterPhyId, boolean filterSystemTopics); + + /** + * 获取集群Topic的主备状态信息 + */ + Result> listHaStatusTopics(Long clusterPhyId, Boolean checkMetadata); + + + /** + * 获取获取集群topic高可用关系 0:备topic, 1:主topic, -1非高可用 + */ + Integer getRelation(Long clusterId, String topicName); + + /** + * 获取获取集群topic高可用关系 + */ + HaASRelationDO getASRelation(Long clusterId, String topicName); + +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaAppManager.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaAppManager.java new file mode 100644 index 00000000..c1a480a5 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaAppManager.java @@ -0,0 +1,16 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.app.AppRelateTopicsVO; + +import java.util.List; + + +/** + * Ha App管理 + */ +public interface HaAppManager { + Result> appRelateTopics(Long clusterPhyId, List filterTopicNameList); + + boolean isContainAllRelateAppTopics(Long clusterPhyId, List filterTopicNameList); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaClusterManager.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaClusterManager.java new file mode 100644 index 00000000..7b25e2c0 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaClusterManager.java @@ -0,0 +1,19 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ao.ClusterDetailDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; + +import java.util.List; + +/** + * Ha Cluster管理 + */ +public interface HaClusterManager { + List getClusterDetailDTOList(Boolean needDetail); + + Result addNew(ClusterDO clusterDO, Long activeClusterId, String operator); + + Result deleteById(Long clusterId, String operator); + +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaTopicManager.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaTopicManager.java new file mode 100644 index 00000000..b9755e55 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/HaTopicManager.java @@ -0,0 +1,44 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.TopicOperationResult; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.HaSwitchTopic; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.HaTopicRelationDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; + +import java.util.List; + + +/** + * Ha Topic管理 + */ +public interface HaTopicManager { + /** + * 批量更改主备关系 + */ + Result> batchCreateHaTopic(HaTopicRelationDTO dto, String operator); + + /** + * 批量更改主备关系 + */ + Result> batchRemoveHaTopic(HaTopicRelationDTO dto, String operator); + + /** + * 可重试的执行主备切换 + * @param newActiveClusterPhyId 主集群 + * @param newStandbyClusterPhyId 备集群 + * @param switchTopicNameList 切换的Topic列表 + * @param focus 强制切换 + * @param firstTriggerExecute 第一次触发执行 + * @param switchLogTemplate 切换日志模版 + * @param operator 操作人 + * @return 操作结果 + */ + Result switchHaWithCanRetry(Long newActiveClusterPhyId, + Long newStandbyClusterPhyId, + List switchTopicNameList, + boolean focus, + boolean firstTriggerExecute, + JobLogDO switchLogTemplate, + String operator); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaASRelationManagerImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaASRelationManagerImpl.java new file mode 100644 index 00000000..306671a0 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaASRelationManagerImpl.java @@ -0,0 +1,140 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.TopicAuthorityEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterTopicVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.HaClusterTopicHaStatusVO; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class HaASRelationManagerImpl implements HaASRelationManager { + @Autowired + private HaASRelationService haASRelationService; + + @Autowired + private TopicManagerService topicManagerService; + + @Autowired + private HaTopicService haTopicService; + + @Autowired + private AuthorityService authorityService; + + @Override + public List getHATopics(Long firstClusterPhyId, Long secondClusterPhyId, boolean filterSystemTopics) { + List doList = haASRelationService.listAllHAFromDB(firstClusterPhyId, secondClusterPhyId, HaResTypeEnum.TOPIC); + if (ValidateUtils.isEmptyList(doList)) { + return new ArrayList<>(); + } + + List voList = new ArrayList<>(); + for (HaASRelationDO relationDO: doList) { + if (filterSystemTopics + && (relationDO.getActiveResName().startsWith("__") || relationDO.getStandbyResName().startsWith("__"))) { + // 过滤掉系统Topic && 存在系统Topic,则过滤掉 + continue; + } + + HaClusterTopicVO vo = new HaClusterTopicVO(); + vo.setClusterId(firstClusterPhyId); + if (firstClusterPhyId.equals(relationDO.getActiveClusterPhyId())) { + vo.setTopicName(relationDO.getActiveResName()); + } else { + vo.setTopicName(relationDO.getStandbyResName()); + } + + vo.setProduceAclNum(0); + vo.setConsumeAclNum(0); + vo.setActiveClusterId(relationDO.getActiveClusterPhyId()); + vo.setStandbyClusterId(relationDO.getStandbyClusterPhyId()); + vo.setStatus(relationDO.getStatus()); + + // 补充ACL信息 + List authorityDOList = authorityService.getAuthorityByTopicFromCache(relationDO.getActiveClusterPhyId(), relationDO.getActiveResName()); + authorityDOList.forEach(elem -> { + if ((elem.getAccess() & TopicAuthorityEnum.WRITE.getCode()) > 0) { + vo.setProduceAclNum(vo.getProduceAclNum() + 1); + } + if ((elem.getAccess() & TopicAuthorityEnum.READ.getCode()) > 0) { + vo.setConsumeAclNum(vo.getConsumeAclNum() + 1); + } + }); + + voList.add(vo); + } + + return voList; + } + + @Override + public Result> listHaStatusTopics(Long clusterPhyId, Boolean checkMetadata) { + ClusterDO clusterDO = PhysicalClusterMetadataManager.getClusterFromCache(clusterPhyId); + if (clusterDO == null){ + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + List topicDOS = topicManagerService.getByClusterId(clusterPhyId); + if (ValidateUtils.isEmptyList(topicDOS)) { + return Result.buildSuc(new ArrayList<>()); + } + + Map haRelationMap = haTopicService.getRelation(clusterPhyId); + List statusVOS = new ArrayList<>(); + topicDOS.stream().filter(topicDO -> !topicDO.getTopicName().startsWith("__"))//过滤引擎自带topic + .forEach(topicDO -> { + if(checkMetadata && !PhysicalClusterMetadataManager.isTopicExist(clusterPhyId, topicDO.getTopicName())){ + return; + } + HaClusterTopicHaStatusVO statusVO = new HaClusterTopicHaStatusVO(); + statusVO.setClusterId(clusterPhyId); + statusVO.setClusterName(clusterDO.getClusterName()); + statusVO.setTopicName(topicDO.getTopicName()); + statusVO.setHaRelation(haRelationMap.get(topicDO.getTopicName())); + statusVOS.add(statusVO); + }); + + return Result.buildSuc(statusVOS); + } + + @Override + public Integer getRelation(Long clusterId, String topicName) { + HaASRelationDO relationDO = haASRelationService.getHAFromDB(clusterId, topicName, HaResTypeEnum.TOPIC); + if (relationDO == null){ + return HaRelationTypeEnum.UNKNOWN.getCode(); + } + if (topicName.equals(KafkaConstant.COORDINATOR_TOPIC_NAME)){ + return HaRelationTypeEnum.MUTUAL_BACKUP.getCode(); + } + if (clusterId.equals(relationDO.getActiveClusterPhyId())){ + return HaRelationTypeEnum.ACTIVE.getCode(); + } + if (clusterId.equals(relationDO.getStandbyClusterPhyId())){ + return HaRelationTypeEnum.STANDBY.getCode(); + } + return HaRelationTypeEnum.UNKNOWN.getCode(); + } + + @Override + public HaASRelationDO getASRelation(Long clusterId, String topicName) { + return haASRelationService.getHAFromDB(clusterId, topicName, HaResTypeEnum.TOPIC); + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaAppManagerImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaAppManagerImpl.java new file mode 100644 index 00000000..19ffc5ae --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaAppManagerImpl.java @@ -0,0 +1,94 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.app.AppRelateTopicsVO; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaAppManager; +import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + + +@Service +public class HaAppManagerImpl implements HaAppManager { + + @Autowired + private AuthorityService authorityService; + + @Autowired + private HaASRelationService haASRelationService; + + @Override + public Result> appRelateTopics(Long clusterPhyId, List filterTopicNameList) { + // 获取关联的Topic列表 + Map> userTopicMap = this.appRelateTopicsMap(clusterPhyId, filterTopicNameList); + + // 获取集群已建立HA的Topic列表 + Set haTopicNameSet = haASRelationService.listAllHAFromDB(clusterPhyId, HaResTypeEnum.TOPIC) + .stream() + .map(elem -> elem.getActiveResName()) + .collect(Collectors.toSet()); + + Set filterTopicNameSet = new HashSet<>(filterTopicNameList); + + List voList = new ArrayList<>(); + for (Map.Entry> entry: userTopicMap.entrySet()) { + AppRelateTopicsVO vo = new AppRelateTopicsVO(); + vo.setClusterPhyId(clusterPhyId); + vo.setKafkaUser(entry.getKey()); + vo.setSelectedTopicNameList(new ArrayList<>()); + vo.setNotSelectTopicNameList(new ArrayList<>()); + vo.setNotHaTopicNameList(new ArrayList<>()); + entry.getValue().forEach(elem -> { + if (elem.startsWith("__")) { + // ignore + return; + } + + if (!haTopicNameSet.contains(elem)) { + vo.getNotHaTopicNameList().add(elem); + } else if (filterTopicNameSet.contains(elem)) { + vo.getSelectedTopicNameList().add(elem); + } else { + vo.getNotSelectTopicNameList().add(elem); + } + }); + + voList.add(vo); + } + + return Result.buildSuc(voList); + } + + @Override + public boolean isContainAllRelateAppTopics(Long clusterPhyId, List filterTopicNameList) { + Map> userTopicMap = this.appRelateTopicsMap(clusterPhyId, filterTopicNameList); + + Set relateTopicSet = new HashSet<>(); + userTopicMap.values().forEach(elem -> relateTopicSet.addAll(elem)); + + return filterTopicNameList.containsAll(relateTopicSet); + } + + private Map> appRelateTopicsMap(Long clusterPhyId, List filterTopicNameList) { + Map> userTopicMap = new HashMap<>(); + for (String topicName: filterTopicNameList) { + authorityService.getAuthorityByTopicFromCache(clusterPhyId, topicName) + .stream() + .map(elem -> elem.getAppId()) + .filter(item -> !userTopicMap.containsKey(item)) + .forEach(kafkaUser -> + userTopicMap.put( + kafkaUser, + authorityService.getAuthority(kafkaUser).stream().map(authorityDO -> authorityDO.getTopicName()).collect(Collectors.toSet()) + ) + ); + } + + return userTopicMap; + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaClusterManagerImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaClusterManagerImpl.java new file mode 100644 index 00000000..debc3d96 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaClusterManagerImpl.java @@ -0,0 +1,169 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.ClusterModeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.DBStatusEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.ClusterDetailDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.RegionDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.utils.ListUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaClusterManager; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.LogicalClusterService; +import com.xiaojukeji.kafka.manager.service.service.RegionService; +import com.xiaojukeji.kafka.manager.service.service.ZookeeperService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaClusterService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +import java.util.List; + +@Component +public class HaClusterManagerImpl implements HaClusterManager { + private static final Logger LOGGER = LoggerFactory.getLogger(HaClusterManagerImpl.class); + + @Autowired + private ClusterService clusterService; + + @Autowired + private HaClusterService haClusterService; + + @Autowired + private ZookeeperService zookeeperService; + + @Autowired + private LogicalClusterService logicalClusterService; + + @Autowired + private RegionService regionService; + + @Autowired + private HaASRelationService haASRelationService; + + @Override + public List getClusterDetailDTOList(Boolean needDetail) { + return clusterService.getClusterDetailDTOList(needDetail); + } + + @Override + @Transactional + public Result addNew(ClusterDO clusterDO, Long activeClusterId, String operator) { + if (activeClusterId == null) { + // 普通集群,直接写入DB + Long clusterPhyId = zookeeperService.getClusterIdAndNullIfFailed(clusterDO.getZookeeper()); + if (clusterPhyId != null && clusterService.getById(clusterPhyId) == null) { + // 该集群ID不存在时,则进行设置,如果已经存在了,则忽略 + clusterDO.setId(clusterPhyId); + } + + return Result.buildFrom(clusterService.addNew(clusterDO, operator)); + } + + //高可用集群 + ClusterDO activeClusterDO = clusterService.getById(activeClusterId); + if (activeClusterDO == null) { + // 主集群不存在 + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getClusterPhyNotExist(activeClusterId)); + } + + HaASRelationDO oldRelationDO = haClusterService.getHA(activeClusterId); + if (oldRelationDO != null){ + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_ALREADY_USED, + MsgConstant.getActiveClusterDuplicate(activeClusterDO.getId(), activeClusterDO.getClusterName())); + } + + Long standbyClusterPhyId = zookeeperService.getClusterIdAndNullIfFailed(clusterDO.getZookeeper()); + if (standbyClusterPhyId != null && clusterService.getById(standbyClusterPhyId) == null) { + // 该集群ID不存在时,则进行设置,如果已经存在了,则忽略 + clusterDO.setId(standbyClusterPhyId); + } + + ResultStatus rs = clusterService.addNew(clusterDO, operator); + if (!ResultStatus.SUCCESS.equals(rs)) { + return Result.buildFrom(rs); + } + + Result> rli = zookeeperService.getBrokerIds(clusterDO.getZookeeper()); + if (!rli.hasData()){ + return Result.buildFrom(ResultStatus.BROKER_NOT_EXIST); + } + + // 备集群创建region + RegionDO regionDO = new RegionDO(DBStatusEnum.ALIVE.getStatus(), clusterDO.getClusterName(), clusterDO.getId(), ListUtils.intList2String(rli.getData())); + rs = regionService.createRegion(regionDO); + if (!ResultStatus.SUCCESS.equals(rs)){ + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + + return Result.buildFrom(rs); + } + + // 备集群创建逻辑集群 + List logicalClusterDOS = logicalClusterService.getByPhysicalClusterId(activeClusterId); + if (!logicalClusterDOS.isEmpty()) { + // 有逻辑集群,则对应创建逻辑集群 + Integer mode = logicalClusterDOS.get(0).getMode(); + LogicalClusterDO logicalClusterDO = new LogicalClusterDO( + clusterDO.getClusterName(), + clusterDO.getClusterName(), + ClusterModeEnum.INDEPENDENT_MODE.getCode().equals(mode)?mode:ClusterModeEnum.SHARED_MODE.getCode(), + ClusterModeEnum.INDEPENDENT_MODE.getCode().equals(mode)?logicalClusterDOS.get(0).getAppId(): "", + clusterDO.getId(), + regionDO.getId().toString() + ); + ResultStatus clcRS = logicalClusterService.createLogicalCluster(logicalClusterDO); + if (clcRS.getCode() != ResultStatus.SUCCESS.getCode()){ + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return Result.buildFrom(clcRS); + } + } + + return haClusterService.createHA(activeClusterId, clusterDO.getId(), operator); + } + + @Override + @Transactional + public Result deleteById(Long clusterId, String operator) { + HaASRelationDO haRelationDO = haClusterService.getHA(clusterId); + if (haRelationDO == null){ + return clusterService.deleteById(clusterId, operator); + } + + Result rv = checkForDelete(haRelationDO, clusterId); + if (rv.failed()){ + return rv; + } + + //解除高可用关系 + Result result = haClusterService.deleteHA(haRelationDO.getActiveClusterPhyId(), haRelationDO.getStandbyClusterPhyId()); + if (result.failed()){ + return result; + } + + //删除集群 + result = clusterService.deleteById(clusterId, operator); + if (result.failed()){ + return result; + } + return Result.buildSuc(); + } + + private Result checkForDelete(HaASRelationDO haRelationDO, Long clusterId){ + List relationDOS = haASRelationService.listAllHAFromDB(haRelationDO.getActiveClusterPhyId(), + haRelationDO.getStandbyClusterPhyId(), + HaResTypeEnum.TOPIC); + if (relationDOS.stream().filter(relationDO -> !relationDO.getActiveResName().startsWith("__")).count() > 0){ + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FORBIDDEN, "集群还存在高可topic"); + } + return Result.buildSuc(); + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaTopicManagerImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaTopicManagerImpl.java new file mode 100644 index 00000000..d1224a4b --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/ha/impl/HaTopicManagerImpl.java @@ -0,0 +1,559 @@ +package com.xiaojukeji.kafka.manager.service.biz.ha.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.TopicOperationResult; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.HaSwitchTopic; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.HaTopicRelationDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; +import com.xiaojukeji.kafka.manager.common.utils.BackoffUtils; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaTopicManager; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.JobLogService; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaKafkaUserService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; +import com.xiaojukeji.kafka.manager.service.utils.ConfigUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +@Component +public class HaTopicManagerImpl implements HaTopicManager { + private static final Logger LOGGER = LoggerFactory.getLogger(HaTopicManagerImpl.class); + + @Autowired + private ClusterService clusterService; + + @Autowired + private AuthorityService authorityService; + + @Autowired + private HaTopicService haTopicService; + + @Autowired + private HaKafkaUserService haKafkaUserService; + + @Autowired + private HaASRelationService haASRelationService; + + @Autowired + private TopicManagerService topicManagerService; + + @Autowired + private ConfigUtils configUtils; + + @Autowired + private JobLogService jobLogService; + + @Override + public Result switchHaWithCanRetry(Long newActiveClusterPhyId, + Long newStandbyClusterPhyId, + List switchTopicNameList, + boolean focus, + boolean firstTriggerExecute, + JobLogDO switchLogTemplate, + String operator) { + LOGGER.info( + "method=switchHaWithCanRetry||newActiveClusterPhyId={}||newStandbyClusterPhyId={}||switchTopicNameList={}||focus={}||operator={}", + newActiveClusterPhyId, newStandbyClusterPhyId, ConvertUtil.obj2Json(switchTopicNameList), focus, operator + ); + + // 1、获取集群 + ClusterDO newActiveClusterPhyDO = clusterService.getById(newActiveClusterPhyId); + if (ValidateUtils.isNull(newActiveClusterPhyDO)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getClusterPhyNotExist(newActiveClusterPhyId)); + } + + ClusterDO newStandbyClusterPhyDO = clusterService.getById(newStandbyClusterPhyId); + if (ValidateUtils.isNull(newStandbyClusterPhyDO)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getClusterPhyNotExist(newStandbyClusterPhyId)); + } + + // 2、进行参数检查 + Result> doListResult = this.checkParamAndGetASRelation(newActiveClusterPhyId, newStandbyClusterPhyId, switchTopicNameList); + if (doListResult.failed()) { + LOGGER.error( + "method=switchHaWithCanRetry||newActiveClusterPhyId={}||newStandbyClusterPhyId={}||switchTopicNameList={}||paramErrResult={}||operator={}", + newActiveClusterPhyId, newStandbyClusterPhyId, ConvertUtil.obj2Json(switchTopicNameList), doListResult, operator + ); + + return Result.buildFromIgnoreData(doListResult); + } + List doList = doListResult.getData(); + + // 3、如果是第一次触发执行,且状态是stable,则修改状态 + for (HaASRelationDO relationDO: doList) { + if (firstTriggerExecute && relationDO.getStatus().equals(HaStatusEnum.STABLE_CODE)) { + relationDO.setStatus(HaStatusEnum.SWITCHING_PREPARE_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_PREPARE_CODE); + } + } + + // 4、进行切换预处理 + HaSwitchTopic switchTopic = this.prepareSwitching(newStandbyClusterPhyDO, doList, focus, switchLogTemplate); + + // 5、直接等待10秒,使得相关数据有机会同步完成 + BackoffUtils.backoff(10000); + + // 6、检查数据同步情况 + for (HaASRelationDO relationDO: doList) { + switchTopic.addHaSwitchTopic(this.checkTopicInSync(newActiveClusterPhyDO, newStandbyClusterPhyDO, relationDO, focus, switchLogTemplate)); + } + + // 7、删除旧的备Topic的同步配置 + for (HaASRelationDO relationDO: doList) { + switchTopic.addHaSwitchTopic(this.oldStandbyTopicDelFetchConfig(newActiveClusterPhyDO, newStandbyClusterPhyDO, relationDO, focus, switchLogTemplate, operator)); + } + + // 8、增加新的备Topic的同步配置, + switchTopic.addHaSwitchTopic(this.newStandbyTopicAddFetchConfig(newActiveClusterPhyDO, newStandbyClusterPhyDO, doList, focus, switchLogTemplate, operator)); + + // 9、进行切换收尾 + switchTopic.addHaSwitchTopic(this.closeoutSwitching(newActiveClusterPhyDO, newStandbyClusterPhyDO, configUtils.getDKafkaGatewayZK(), doList, focus, switchLogTemplate)); + + // 10、状态结果汇总记录 + doList.forEach(elem -> switchTopic.addActiveTopicStatus(elem.getActiveResName(), elem.getStatus())); + + // 11、日志记录并返回 + LOGGER.info( + "method=switchHaWithCanRetry||newActiveClusterPhyId={}||newStandbyClusterPhyId={}||switchTopicNameList={}||switchResult={}||operator={}", + newActiveClusterPhyId, newStandbyClusterPhyId, ConvertUtil.obj2Json(switchTopicNameList), switchTopic, operator + ); + + return Result.buildSuc(switchTopic); + } + + @Override + public Result> batchCreateHaTopic(HaTopicRelationDTO dto, String operator) { + List relationDOS = haASRelationService.listAllHAFromDB(dto.getActiveClusterId(), dto.getStandbyClusterId(), HaResTypeEnum.CLUSTER); + if (relationDOS.isEmpty()){ + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "集群高可用关系未建立"); + } + + //获取主集群已有的高可用topic + Map haRelationMap = haTopicService.getRelation(dto.getActiveClusterId()); + List topicNames = dto.getTopicNames(); + if (dto.getAll()){ + topicNames = topicManagerService.getByClusterId(dto.getActiveClusterId()) + .stream() + .filter(topicDO -> !topicDO.getTopicName().startsWith("__"))//过滤掉kafka自带topic + .filter(topicDO -> !haRelationMap.keySet().contains(topicDO.getTopicName()))//过滤调已成为高可用topic的topic + .filter(topicDO -> PhysicalClusterMetadataManager.isTopicExist(dto.getActiveClusterId(), topicDO.getTopicName())) + .map(TopicDO::getTopicName) + .collect(Collectors.toList()); + + } + + List operationResultList = new ArrayList<>(); + topicNames.forEach(topicName->{ + Result rv = haTopicService.createHA(dto.getActiveClusterId(), dto.getStandbyClusterId(),topicName, operator); + operationResultList.add(TopicOperationResult.buildFrom(dto.getActiveClusterId(), topicName, rv)); + }); + + return Result.buildSuc(operationResultList); + } + + @Override + public Result> batchRemoveHaTopic(HaTopicRelationDTO dto, String operator) { + List relationDOS = haASRelationService.listAllHAFromDB(dto.getActiveClusterId(), dto.getStandbyClusterId(), HaResTypeEnum.CLUSTER); + if (relationDOS.isEmpty()){ + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "集群高可用关系未建立"); + } + + List operationResultList = new ArrayList<>(); + for(String topicName : dto.getTopicNames()){ + HaASRelationDO relationDO = haASRelationService.getHAFromDB( + dto.getActiveClusterId(), + topicName, + HaResTypeEnum.TOPIC + ); + if (relationDO == null) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "主备关系不存在"); + } + + Result rv = haTopicService.deleteHA(relationDO.getActiveClusterPhyId(), relationDO.getStandbyClusterPhyId(), topicName, operator); + operationResultList.add(TopicOperationResult.buildFrom(dto.getActiveClusterId(), topicName, rv)); + } + + return Result.buildSuc(operationResultList); + } + + /**************************************************** private method ****************************************************/ + + private void saveLogs(JobLogDO switchLogTemplate, String content) { + jobLogService.addLogAndIgnoreException(switchLogTemplate.setAndCopyNew(new Date(), content)); + } + + /** + * 切换预处理 + * 1、在主集群上,将Topic关联的KafkaUser的active集群设置为None + */ + private HaSwitchTopic prepareSwitching(ClusterDO oldActiveClusterPhyDO, List doList, boolean focus, JobLogDO switchLogTemplate) { + // 暂停HA的KafkaUser + Set stoppedHaKafkaUserSet = new HashSet<>(); + + HaSwitchTopic haSwitchTopic = new HaSwitchTopic(true); + + boolean allSuccess = true; // 所有都成功 + boolean needLog = false; // 需要记录日志 + for (HaASRelationDO relationDO: doList) { + if (!relationDO.getStatus().equals(HaStatusEnum.SWITCHING_PREPARE_CODE)) { + // 当前不处于prepare状态 + haSwitchTopic.setFinished(true); + continue; + } + needLog = true; + + // 获取关联的KafkaUser + Set relatedKafkaUserSet = authorityService.getAuthorityByTopic(relationDO.getActiveClusterPhyId(), relationDO.getActiveResName()) + .stream() + .map(elem -> elem.getAppId()) + .filter(kafkaUser -> !stoppedHaKafkaUserSet.contains(kafkaUser)) + .collect(Collectors.toSet()); + + // 暂停kafkaUser HA + for (String kafkaUser: relatedKafkaUserSet) { + Result rv = haKafkaUserService.setNoneHAInKafka(oldActiveClusterPhyDO.getZookeeper(), kafkaUser); + if (rv.failed() && !focus) { + haSwitchTopic.setFinished(false); + + this.saveLogs(switchLogTemplate, String.format("%s:\t失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_PREPARE.getMsg(oldActiveClusterPhyDO.getClusterName()))); + return haSwitchTopic; + } else if (rv.failed() && focus) { + allSuccess = false; + } + } + + // 记录操作过的user + stoppedHaKafkaUserSet.addAll(relatedKafkaUserSet); + + // 修改Topic主备状态 + relationDO.setStatus(HaStatusEnum.SWITCHING_WAITING_IN_SYNC_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_WAITING_IN_SYNC_CODE); + } + + if (needLog) { + this.saveLogs(switchLogTemplate, String.format("%s:\t%s", HaStatusEnum.SWITCHING_PREPARE.getMsg(oldActiveClusterPhyDO.getClusterName()), allSuccess? "成功": "存在失败,但进行强制执行,跳过该操作")); + } + + haSwitchTopic.setFinished(true); + return haSwitchTopic; + } + + /** + * 等待主备Topic同步 + */ + private HaSwitchTopic checkTopicInSync(ClusterDO newActiveClusterPhyDO, ClusterDO newStandbyClusterPhyDO, HaASRelationDO relationDO, boolean focus, JobLogDO switchLogTemplate) { + HaSwitchTopic haSwitchTopic = new HaSwitchTopic(true); + if (!relationDO.getStatus().equals(HaStatusEnum.SWITCHING_WAITING_IN_SYNC_CODE)) { + // 状态错误,直接略过 + haSwitchTopic.setFinished(true); + return haSwitchTopic; + } + + if (focus) { + // 无需等待inSync + + // 修改Topic主备状态 + relationDO.setStatus(HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE); + + haSwitchTopic.setFinished(true); + this.saveLogs(switchLogTemplate, String.format( + "%s:\tTopic:[%s] 强制切换,跳过等待主备同步完成,直接进入下一步", + HaStatusEnum.SWITCHING_WAITING_IN_SYNC.getMsg(newActiveClusterPhyDO.getClusterName()), + relationDO.getActiveResName() + )); + return haSwitchTopic; + } + + Result lagResult = haTopicService.getStandbyTopicFetchLag(newStandbyClusterPhyDO.getId(), relationDO.getStandbyResName()); + if (lagResult.failed()) { + // 获取Lag信息失败 + this.saveLogs(switchLogTemplate, String.format( + "%s:\tTopic:[%s] 获取同步的Lag信息失败,1分钟后再检查是否主备同步完成", + HaStatusEnum.SWITCHING_WAITING_IN_SYNC.getMsg(newActiveClusterPhyDO.getClusterName()), + relationDO.getActiveResName() + )); + haSwitchTopic.setFinished(false); + return haSwitchTopic; + } + + if (lagResult.getData().longValue() > 0) { + this.saveLogs(switchLogTemplate, String.format( + "%s:\tTopic:[%s] 还存在 %d 条数据未同步完成,1分钟后再检查是否主备同步完成", + HaStatusEnum.SWITCHING_WAITING_IN_SYNC.getMsg(newActiveClusterPhyDO.getClusterName()), + relationDO.getActiveResName(), + lagResult.getData() + )); + + haSwitchTopic.setFinished(false); + return haSwitchTopic; + } + + // 修改Topic主备状态 + relationDO.setStatus(HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE); + + haSwitchTopic.setFinished(true); + this.saveLogs(switchLogTemplate, String.format( + "%s:\tTopic:[%s] 主备同步完成", + HaStatusEnum.SWITCHING_WAITING_IN_SYNC.getMsg(newActiveClusterPhyDO.getClusterName()), + relationDO.getActiveResName() + )); + return haSwitchTopic; + } + + /** + * 备Topic删除拉取主Topic数据的配置 + */ + private HaSwitchTopic oldStandbyTopicDelFetchConfig(ClusterDO newActiveClusterPhyDO, ClusterDO newStandbyClusterPhyDO, HaASRelationDO relationDO, boolean focus, JobLogDO switchLogTemplate, String operator) { + HaSwitchTopic haSwitchTopic = new HaSwitchTopic(true); + if (!relationDO.getStatus().equals(HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH_CODE)) { + // 状态不对 + haSwitchTopic.setFinished(true); + return haSwitchTopic; + } + + Result rv = haTopicService.stopHAInKafka( + newActiveClusterPhyDO, relationDO.getStandbyResName(), // 旧的备 + operator + ); + if (rv.failed() && !focus) { + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH.getMsg(newActiveClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + haSwitchTopic.setFinished(false); + return haSwitchTopic; + } else if (rv.failed() && focus) { + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 失败,但进行强制执行,跳过该操作", HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH.getMsg(newActiveClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + } else { + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 成功", HaStatusEnum.SWITCHING_CLOSE_OLD_STANDBY_TOPIC_FETCH.getMsg(newActiveClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + } + + // 修改Topic主备状态 + relationDO.setStatus(HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH_CODE); + + haSwitchTopic.setFinished(true); + return haSwitchTopic; + } + + /** + * 新的备Topic,创建拉取新主Topic数据的配置 + */ + private HaSwitchTopic newStandbyTopicAddFetchConfig(ClusterDO newActiveClusterPhyDO, + ClusterDO newStandbyClusterPhyDO, + List doList, + boolean focus, + JobLogDO switchLogTemplate, + String operator) { + boolean forceAndFailed = false; + for (HaASRelationDO relationDO: doList) { + if (!relationDO.getStatus().equals(HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH_CODE)) { + // 状态不对 + continue; + } + + Result rv = null; + if (!forceAndFailed) { + // 非 强制切换并且失败了 + rv = haTopicService.activeHAInKafka( + newActiveClusterPhyDO, relationDO.getStandbyResName(), + newStandbyClusterPhyDO, relationDO.getStandbyResName(), + operator + ); + } + + if (forceAndFailed) { + // 强制切换并且失败了,记录该日志 + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 失败,但因为是强制执行且强制执行时依旧出现操作失败,因此直接跳过该操作", HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH.getMsg(newStandbyClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + + } else if (rv.failed() && !focus) { + // 如果失败了,并且非强制切换,则直接返回 + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH.getMsg(newStandbyClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + + return new HaSwitchTopic(false); + } else if (rv.failed() && focus) { + // 如果失败了,但是是强制切换,则记录日志并继续 + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 失败,但因为是强制执行,因此跳过该操作", HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH.getMsg(newStandbyClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + + forceAndFailed = true; + } else { + // 记录成功日志 + this.saveLogs(switchLogTemplate, String.format("%s:\tTopic:[%s] 成功", HaStatusEnum.SWITCHING_OPEN_NEW_STANDBY_TOPIC_FETCH.getMsg(newStandbyClusterPhyDO.getClusterName()), relationDO.getActiveResName())); + } + + // 修改Topic主备状态 + relationDO.setStatus(HaStatusEnum.SWITCHING_CLOSEOUT_CODE); + haASRelationService.updateRelationStatus(relationDO.getId(), HaStatusEnum.SWITCHING_CLOSEOUT_CODE); + } + + return new HaSwitchTopic(true); + } + + /** + * 切换收尾 + * 1、原先的主集群-修改user的active集群,指向新的主集群 + * 2、原先的备集群-修改user的active集群,指向新的主集群 + * 3、网关-修改user的active集群,指向新的主集群 + */ + private HaSwitchTopic closeoutSwitching(ClusterDO newActiveClusterPhyDO, ClusterDO newStandbyClusterPhyDO, String gatewayZK, List doList, boolean focus, JobLogDO switchLogTemplate) { + // 暂停HA的KafkaUser + Set activeHaKafkaUserSet = new HashSet<>(); + + boolean allSuccess = true; + boolean needLog = false; + boolean forceAndNewStandbyFailed = false; // 强制切换,但是新的备依旧操作失败 + + HaSwitchTopic haSwitchTopic = new HaSwitchTopic(true); + for (HaASRelationDO relationDO: doList) { + if (!relationDO.getStatus().equals(HaStatusEnum.SWITCHING_CLOSEOUT_CODE)) { + // 当前不处于closeout状态 + haSwitchTopic.setFinished(false); + continue; + } + + needLog = true; + + // 获取关联的KafkaUser + Set relatedKafkaUserSet = authorityService.getAuthorityByTopic(relationDO.getActiveClusterPhyId(), relationDO.getActiveResName()) + .stream() + .map(elem -> elem.getAppId()) + .filter(kafkaUser -> !activeHaKafkaUserSet.contains(kafkaUser)) + .collect(Collectors.toSet()); + + for (String kafkaUser: relatedKafkaUserSet) { + // 操作新的主集群 + Result rv = haKafkaUserService.activeHAInKafka(newActiveClusterPhyDO.getZookeeper(), newActiveClusterPhyDO.getId(), kafkaUser); + if (rv.failed() && !focus) { + haSwitchTopic.setFinished(false); + this.saveLogs(switchLogTemplate, String.format("%s:\t失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_CLOSEOUT.getMsg(newActiveClusterPhyDO.getClusterName()))); + return haSwitchTopic; + } else if (rv.failed() && focus) { + allSuccess = false; + } + + // 操作新的备集群,如果出现错误,则下次就不再进行操作ZK。新的备的Topic不是那么重要,因此这里允许出现跳过 + rv = null; + if (!forceAndNewStandbyFailed) { + // 如果对备集群的操作过程中,出现了失败,则直接跳过 + rv = haKafkaUserService.activeHAInKafka(newStandbyClusterPhyDO.getZookeeper(), newActiveClusterPhyDO.getId(), kafkaUser); + } + + if (rv != null && rv.failed() && !focus) { + haSwitchTopic.setFinished(false); + this.saveLogs(switchLogTemplate, String.format("%s:\t失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_CLOSEOUT.getMsg(newActiveClusterPhyDO.getClusterName()))); + return haSwitchTopic; + } else if (rv != null && rv.failed() && focus) { + allSuccess = false; + forceAndNewStandbyFailed = true; + } + + // 操作网关 + rv = haKafkaUserService.activeHAInKafka(gatewayZK, newActiveClusterPhyDO.getId(), kafkaUser); + if (rv.failed() && !focus) { + haSwitchTopic.setFinished(false); + this.saveLogs(switchLogTemplate, String.format("%s:\t失败,1分钟后再进行重试", HaStatusEnum.SWITCHING_CLOSEOUT.getMsg(newActiveClusterPhyDO.getClusterName()))); + return haSwitchTopic; + } else if (rv.failed() && focus) { + allSuccess = false; + } + } + + // 记录已经激活的User + activeHaKafkaUserSet.addAll(relatedKafkaUserSet); + + // 修改Topic主备信息 + HaASRelationDO newHaASRelationDO = new HaASRelationDO( + newActiveClusterPhyDO.getId(), relationDO.getActiveResName(), + newStandbyClusterPhyDO.getId(), relationDO.getStandbyResName(), + HaResTypeEnum.TOPIC.getCode(), + HaStatusEnum.STABLE_CODE + ); + newHaASRelationDO.setId(relationDO.getId()); + + haASRelationService.updateById(newHaASRelationDO); + } + + if (!needLog) { + return haSwitchTopic; + } + + this.saveLogs(switchLogTemplate, String.format("%s:\t%s", HaStatusEnum.SWITCHING_CLOSEOUT.getMsg(newActiveClusterPhyDO.getClusterName()), allSuccess? "成功": "存在失败,但进行强制执行,跳过该操作")); + return haSwitchTopic; + } + + /** + * 检查参数,并获取主备关系信息 + */ + private Result> checkParamAndGetASRelation(Long activeClusterPhyId, Long standbyClusterPhyId, List switchTopicNameList) { + List doList = new ArrayList<>(); + for (String topicName: switchTopicNameList) { + Result doResult = this.checkParamAndGetASRelation(activeClusterPhyId, standbyClusterPhyId, topicName); + if (doResult.failed()) { + return Result.buildFromIgnoreData(doResult); + } + + doList.add(doResult.getData()); + } + + return Result.buildSuc(doList); + } + + /** + * 检查参数,并获取主备关系信息 + */ + private Result checkParamAndGetASRelation(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName) { + // newActiveTopic必须存在,新的备Topic可以不存在 + if (!PhysicalClusterMetadataManager.isTopicExist(activeClusterPhyId, topicName)) { + return Result.buildFromRSAndMsg( + ResultStatus.RESOURCE_NOT_EXIST, + String.format("新的主集群ID:[%d]-Topic:[%s] 不存在", activeClusterPhyId, topicName) + ); + } + + // 查询主备关系是否存在 + HaASRelationDO relationDO = haASRelationService.getSpecifiedHAFromDB( + standbyClusterPhyId, + topicName, + activeClusterPhyId, + topicName, + HaResTypeEnum.TOPIC + ); + if (relationDO == null) { + // 查询切换后的关系是否存在,如果已经存在,则后续会重新建立一遍 + relationDO = haASRelationService.getSpecifiedHAFromDB( + activeClusterPhyId, + topicName, + standbyClusterPhyId, + topicName, + HaResTypeEnum.TOPIC + ); + } + + if (relationDO == null) { + // 主备关系不存在 + return Result.buildFromRSAndMsg( + ResultStatus.RESOURCE_NOT_EXIST, + String.format("主集群ID:[%d]-Topic:[%s], 备集群ID:[%d] Topic:[%s] 的主备关系不存在,因此无法切换", activeClusterPhyId, topicName, standbyClusterPhyId, topicName) + ); + } + + return Result.buildSuc(relationDO); + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/HaASSwitchJobManager.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/HaASSwitchJobManager.java new file mode 100644 index 00000000..425ebe03 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/HaASSwitchJobManager.java @@ -0,0 +1,41 @@ +package com.xiaojukeji.kafka.manager.service.biz.job; + + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobState; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobActionDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobDTO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.job.HaJobDetailVO; + +import java.util.List; + + +public interface HaASSwitchJobManager { + /** + * 创建任务 + */ + Result createJob(ASSwitchJobDTO dto, String operator); + + /** + * 执行job + * @param jobId 任务ID + * @param focus 强制切换 + * @param firstTriggerExecute 第一次触发执行 + * @return + */ + Result executeJob(Long jobId, boolean focus, boolean firstTriggerExecute); + + Result jobState(Long jobId); + + /** + * 刷新扩展数据 + */ + void flushExtendData(Long jobId); + + /** + * 对Job执行操作 + */ + Result actionJob(Long jobId, ASSwitchJobActionDTO dto); + + Result> jobDetail(Long jobId); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/impl/HaASSwitchJobManagerImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/impl/HaASSwitchJobManagerImpl.java new file mode 100644 index 00000000..86f68c3f --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/biz/job/impl/HaASSwitchJobManagerImpl.java @@ -0,0 +1,452 @@ +package com.xiaojukeji.kafka.manager.service.biz.job.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.JobLogBizTypEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.job.HaJobStatusEnum; +import com.xiaojukeji.kafka.manager.common.constant.ConfigConstant; +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.HaSwitchTopic; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobDetail; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobState; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaSubJobExtendData; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobActionDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchJobDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchSubJobDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.job.HaJobDetailVO; +import com.xiaojukeji.kafka.manager.common.utils.BackoffUtils; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import com.xiaojukeji.kafka.manager.common.utils.FutureUtil; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaAppManager; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaTopicManager; +import com.xiaojukeji.kafka.manager.service.biz.job.HaASSwitchJobManager; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.ConfigService; +import com.xiaojukeji.kafka.manager.service.service.JobLogService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASSwitchJobService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + + +@Service +public class HaASSwitchJobManagerImpl implements HaASSwitchJobManager { + private static final Logger LOGGER = LoggerFactory.getLogger(HaASSwitchJobManagerImpl.class); + + @Autowired + private JobLogService jobLogService; + + @Autowired + private ClusterService clusterService; + + @Autowired + private ConfigService configService; + + @Autowired + private HaASRelationService haASRelationService; + + @Autowired + private HaASSwitchJobService haASSwitchJobService; + + @Autowired + private HaTopicManager haTopicManager; + + @Autowired + private HaTopicService haTopicService; + + @Autowired + private HaAppManager haAppManager; + + private static final Long BACK_OFF_TIME = 3000L; + + private static final FutureUtil asyncExecuteJob = FutureUtil.init( + "HaASSwitchJobManager", + 10, + 10, + 5000 + ); + + @Override + public Result createJob(ASSwitchJobDTO dto, String operator) { + LOGGER.info("method=createJob||activeClusterPhyId={}||switchTopicParam={}||operator={}", dto.getActiveClusterPhyId(), ConvertUtil.obj2Json(dto), operator); + + // 1、检查参数是否合法,并获取需要执行主备切换的Topics + Result> haTopicSetResult = this.checkParamLegalAndGetNeedSwitchHaTopics(dto); + if (haTopicSetResult.failed()) { + // 检查失败,则直接返回 + return Result.buildFromIgnoreData(haTopicSetResult); + } + + LOGGER.info("method=createJob||activeClusterPhyId={}||switchTopics={}||operator={}", dto.getActiveClusterPhyId(), ConvertUtil.obj2Json(haTopicSetResult.getData()), operator); + + // 2、查看是否将KafkaUser关联的Topic都涵盖了 + if (dto.getMustContainAllKafkaUserTopics() != null + && dto.getMustContainAllKafkaUserTopics() + && (dto.getAll() == null || !dto.getAll()) + && !haAppManager.isContainAllRelateAppTopics(dto.getActiveClusterPhyId(), dto.getTopicNameList())) { + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FORBIDDEN, "存在KafkaUser关联的Topic未选中"); + } + + // 3、创建任务 + Result longResult = haASSwitchJobService.createJob( + dto.getActiveClusterPhyId(), + dto.getStandbyClusterPhyId(), + new ArrayList<>(haTopicSetResult.getData()), + operator + ); + if (longResult.failed()) { + // 创建失败 + return longResult; + } + + LOGGER.info("method=createJob||activeClusterPhyId={}||jobId={}||operator={}||msg=create-job success", dto.getActiveClusterPhyId(), longResult.getData(), operator); + + // 4、为了加快执行效率,这里在创建完成任务之后,会直接异步执行HA切换任务 + asyncExecuteJob.directSubmitTask( + () -> { + BackoffUtils.backoff(BACK_OFF_TIME); + + this.executeJob(longResult.getData(), false, true); + + // 更新扩展数据 + this.flushExtendData(longResult.getData()); + } + ); + + // 5、返回结果 + return longResult; + } + + @Override + public Result executeJob(Long jobId, boolean focus, boolean firstTriggerExecute) { + LOGGER.info("method=executeJob||jobId={}||msg=execute job start", jobId); + + // 查询job + HaASSwitchJobDO jobDO = haASSwitchJobService.getJobById(jobId); + if (jobDO == null) { + LOGGER.warn("method=executeJob||jobId={}||msg=job not exist", jobId); + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, String.format("jobId:[%d] 不存在", jobId)); + } + + // 检查job状态 + if (!HaJobStatusEnum.isRunning(jobDO.getJobStatus())) { + LOGGER.warn("method=executeJob||jobId={}||jobStatus={}||msg=job status illegal", jobId, HaJobStatusEnum.valueOfStatus(jobDO.getJobStatus())); + return this.buildActionForbidden(jobId, jobDO.getJobStatus()); + } + + // 查询子job列表 + List subJobDOList = haASSwitchJobService.listSubJobsById(jobId); + if (ValidateUtils.isEmptyList(subJobDOList)) { + // 无子任务,则设置任务状态为成功 + haASSwitchJobService.updateJobStatus(jobId, HaJobStatusEnum.SUCCESS.getStatus()); + return Result.buildSuc(); + } + + Set statusSet = new HashSet<>(); + subJobDOList.forEach(elem -> statusSet.add(elem.getJobStatus())); + if (statusSet.size() == 1 && statusSet.contains(HaJobStatusEnum.SUCCESS.getStatus())) { + // 无子任务,则设置任务状态为成功 + haASSwitchJobService.updateJobStatus(jobId, HaJobStatusEnum.SUCCESS.getStatus()); + return Result.buildSuc(); + } + + if (firstTriggerExecute) { + this.saveLogs(jobDO.getId(), "主备切换开始..."); + this.saveLogs(jobDO.getId(), "如果主备集群或网关的ZK存在问题,则可能会出现1分钟左右日志不刷新的情况"); + } + + // 进行主备切换 + Result haSwitchTopicResult = haTopicManager.switchHaWithCanRetry( + jobDO.getActiveClusterPhyId(), + jobDO.getStandbyClusterPhyId(), + subJobDOList.stream().map(elem -> elem.getActiveResName()).collect(Collectors.toList()), + focus, + firstTriggerExecute, + new JobLogDO(JobLogBizTypEnum.HA_SWITCH_JOB_LOG.getCode(), String.valueOf(jobId)), + jobDO.getOperator() + ); + + if (haSwitchTopicResult.failed()) { + // 出现错误 + LOGGER.error("method=executeJob||jobId={}||executeResult={}||msg=execute job failed", jobId, haSwitchTopicResult); + return Result.buildFromIgnoreData(haSwitchTopicResult); + } + + + // 执行结果 + HaSwitchTopic haSwitchTopic = haSwitchTopicResult.getData(); + Long timeoutUnitSec = this.getTimeoutUnitSecConfig(jobDO.getActiveClusterPhyId()); + + // 存储日志 + if (haSwitchTopic.isFinished()) { + this.saveLogs(jobDO.getId(), "主备切换完成."); + } + + // 更新状态 + for (HaASSwitchSubJobDO subJobDO: subJobDOList) { + if (haSwitchTopic.isActiveTopicSwitchFinished(subJobDO.getActiveResName()) || haSwitchTopic.isFinished()) { + // 执行完成 + haASSwitchJobService.updateSubJobStatus(subJobDO.getId(), HaJobStatusEnum.SUCCESS.getStatus()); + } else if (runningInTimeout(subJobDO.getCreateTime().getTime(), timeoutUnitSec)) { + // 超时运行中 + haASSwitchJobService.updateSubJobStatus(subJobDO.getId(), HaJobStatusEnum.RUNNING_IN_TIMEOUT.getStatus()); + } + } + + if (haSwitchTopic.isFinished()) { + // 任务执行完成 + LOGGER.info("method=executeJob||jobId={}||executeResult={}||msg=execute job success", jobId, haSwitchTopicResult); + + // 更新状态 + haASSwitchJobService.updateJobStatus(jobId, HaJobStatusEnum.SUCCESS.getStatus()); + } else { + LOGGER.info("method=executeJob||jobId={}||executeResult={}||msg=execute job not finished", jobId, haSwitchTopicResult); + } + + // 返回结果 + return Result.buildSuc(); + } + + @Override + public Result jobState(Long jobId) { + List doList = haASSwitchJobService.listSubJobsById(jobId); + if (ValidateUtils.isEmptyList(doList)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, String.format("jobId:[%d] 不存在", jobId)); + } + + if (System.currentTimeMillis() - doList.get(0).getCreateTime().getTime() <= (BACK_OFF_TIME.longValue() * 2)) { + // 进度0 + return Result.buildSuc(new HaJobState(doList.size(), 0)); + } + + // 这里会假设主备Topic的名称是一样的 + Map progressMap = new HashMap<>(); + haASRelationService.listAllHAFromDB(doList.get(0).getActiveClusterPhyId(), HaResTypeEnum.TOPIC).stream().forEach( + elem -> progressMap.put(elem.getActiveResName(), elem.getStatus()) + ); + + HaJobState haJobState = new HaJobState( + doList.stream().map(elem -> elem.getJobStatus()).collect(Collectors.toList()), + 0 + ); + + // 计算细致的进度信息 + Integer progress = 0; + for (HaASSwitchSubJobDO elem: doList) { + if (HaJobStatusEnum.isFinished(elem.getJobStatus())) { + progress += 100; + continue; + } + + progress += HaStatusEnum.calProgress(progressMap.get(elem.getActiveResName())); + } + haJobState.setProgress(ConvertUtil.double2Int(progress * 1.0 / doList.size())); + + return Result.buildSuc(haJobState); + + } + + @Override + public void flushExtendData(Long jobId) { + // 因为仅仅是刷新扩展数据,因此不会对jobId等进行严格检查 + + // 查询子job列表 + List subJobDOList = haASSwitchJobService.listSubJobsById(jobId); + if (ValidateUtils.isEmptyList(subJobDOList)) { + // 无任务,直接返回 + return; + } + + for (HaASSwitchSubJobDO subJobDO: subJobDOList) { + try { + this.flushExtendData(subJobDO); + } catch (Exception e) { + LOGGER.error("method=flushExtendData||jobId={}||subJobDO={}||errMsg=exception", jobId, subJobDO, e); + } + } + } + + @Override + public Result actionJob(Long jobId, ASSwitchJobActionDTO dto) { + if (!TaskActionEnum.FORCE.getAction().equals(dto.getAction())) { + // 不存在,或者不支持 + return Result.buildFromRSAndMsg(ResultStatus.PARAM_ILLEGAL, "action不存在"); + } + + // 强制执行,异步执行 + this.saveLogs(jobId, "开始执行强制切换..."); + this.saveLogs(jobId, "强制切换过程中,可能出现日志1分钟不刷新情况"); + this.saveLogs(jobId, "强制切换过程中,因可能与正常切换任务同时执行,因此可能出现日志重复问题"); + asyncExecuteJob.directSubmitTask( + () -> this.executeJob(jobId, true, false) + ); + + return Result.buildSuc(); + } + + @Override + public Result> jobDetail(Long jobId) { + // 获取详情 + Result> haResult = haASSwitchJobService.jobDetail(jobId); + if (haResult.failed()) { + return Result.buildFromIgnoreData(haResult); + } + + List voList = ConvertUtil.list2List(haResult.getData(), HaJobDetailVO.class); + if (voList.isEmpty()) { + return Result.buildSuc(voList); + } + + ClusterDO activeClusterDO = clusterService.getById(voList.get(0).getActiveClusterPhyId()); + ClusterDO standbyClusterDO = clusterService.getById(voList.get(0).getStandbyClusterPhyId()); + + // 获取超时配置 + Long timeoutUnitSecConfig = this.getTimeoutUnitSecConfig(voList.get(0).getActiveClusterPhyId()); + voList.forEach(elem -> { + elem.setTimeoutUnitSecConfig(timeoutUnitSecConfig); + elem.setActiveClusterPhyName(activeClusterDO != null? activeClusterDO.getClusterName(): ""); + elem.setStandbyClusterPhyName(standbyClusterDO != null? standbyClusterDO.getClusterName(): ""); + }); + + // 返回结果 + return Result.buildSuc(voList); + } + + /**************************************************** private method ****************************************************/ + + /** + * 检查参数是否合法并返回需要进行主备切换的Topic + */ + private Result> checkParamLegalAndGetNeedSwitchHaTopics(ASSwitchJobDTO dto) { + // 1、检查主集群是否存在 + ClusterDO activeClusterDO = clusterService.getById(dto.getActiveClusterPhyId()); + if (ValidateUtils.isNull(activeClusterDO)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getClusterPhyNotExist(dto.getActiveClusterPhyId())); + } + + // 2、检查备集群是否存在 + ClusterDO standbyClusterDO = clusterService.getById(dto.getStandbyClusterPhyId()); + if (ValidateUtils.isNull(standbyClusterDO)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getClusterPhyNotExist(dto.getStandbyClusterPhyId())); + } + + // 3、检查集群是否建立了主备关系 + List clusterDOList = haASRelationService.listAllHAFromDB(dto.getActiveClusterPhyId(), dto.getStandbyClusterPhyId(), HaResTypeEnum.CLUSTER); + if (ValidateUtils.isEmptyList(clusterDOList)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "集群主备关系未建立"); + } + + // 4、获取集群当前已经建立主备关系的Topic列表 + List topicDOList = haASRelationService.listAllHAFromDB(dto.getActiveClusterPhyId(), dto.getStandbyClusterPhyId(), HaResTypeEnum.TOPIC); + + if (dto.getAll() != null && dto.getAll()) { + // 5.1、对集群所有已经建立主备关系的Topic,进行主备切换 + + // 过滤掉 __打头的Topic + // 过滤掉 当前主集群已经是切换后的主集群的Topic,即这部分Topic已经是切换后的状态了 + return Result.buildSuc( + topicDOList.stream() + .filter(elem -> !elem.getActiveResName().startsWith("__")) + .filter(elem -> !elem.getActiveClusterPhyId().equals(dto.getActiveClusterPhyId())) + .map(elem -> elem.getActiveResName()) + .collect(Collectors.toSet()) + ); + } + + // 5.2、指定Topic进行主备切换 + + // 当前已经有主备关系的Topic + Set relationTopicNameSet = new HashSet<>(); + topicDOList.forEach(elem -> relationTopicNameSet.add(elem.getActiveResName())); + + // 逐个检查Topic,此时这里不进行过滤,如果进行过滤之后,会导致一些用户提交的信息丢失。 + // 比如提交了10个Topic,我过滤成9个,用户就会比较奇怪。 + // 上一步进行过滤,是减少不必要的Topic的刚扰,PS:也可以考虑增加这些干扰,从而让用户明确知道Topic已进行主备切换 + for (String topicName: dto.getTopicNameList()) { + if (!relationTopicNameSet.contains(topicName)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, String.format("Topic:[%s] 主备关系不存在,需要先建立主备关系", topicName)); + } + + // 检查新的主Topic是否存在,如果不存在则直接返回错误,不检查新的备Topic是否存在 + if (!PhysicalClusterMetadataManager.isTopicExist(dto.getActiveClusterPhyId(), topicName)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getTopicNotExist(dto.getActiveClusterPhyId(), topicName)); + } + } + + return Result.buildSuc( + dto.getTopicNameList().stream().collect(Collectors.toSet()) + ); + } + + private void saveLogs(Long jobId, String content) { + jobLogService.addLogAndIgnoreException(new JobLogDO( + JobLogBizTypEnum.HA_SWITCH_JOB_LOG.getCode(), + String.valueOf(jobId), + new Date(), + content + )); + } + + private void flushExtendData(HaASSwitchSubJobDO subJobDO) { + HaSubJobExtendData extendData = new HaSubJobExtendData(); + Result sumLagResult = haTopicService.getStandbyTopicFetchLag(subJobDO.getActiveClusterPhyId(), subJobDO.getActiveResName()); + if (sumLagResult.failed()) { + extendData.setSumLag(Constant.INVALID_CODE.longValue()); + } else { + extendData.setSumLag(sumLagResult.getData()); + } + + haASSwitchJobService.updateSubJobExtendData(subJobDO.getId(), extendData); + } + + private Result buildActionForbidden(Long jobId, Integer jobStatus) { + return Result.buildFromRSAndMsg( + ResultStatus.OPERATION_FORBIDDEN, + String.format("jobId:[%d] 当前 status:[%s], 不允许被执行", jobId, HaJobStatusEnum.valueOfStatus(jobStatus)) + ); + } + + private boolean runningInTimeout(Long startTimeUnitMs, Long timeoutUnitSec) { + if (timeoutUnitSec == null) { + // 配置为空,则返回未超时 + return false; + } + + // 开始时间 + 超时时间 > 当前时间,则为超时 + return startTimeUnitMs + timeoutUnitSec * 1000 > System.currentTimeMillis(); + } + + private Long getTimeoutUnitSecConfig(Long activeClusterPhyId) { + // 获取该集群配置 + Long durationUnitSec = configService.getLongValue( + ConfigConstant.HA_SWITCH_JOB_TIMEOUT_UNIT_SEC_CONFIG_PREFIX + "_" + activeClusterPhyId, + null + ); + + if (durationUnitSec == null) { + // 当前集群配置不存在,则获取默认配置 + durationUnitSec = configService.getLongValue( + ConfigConstant.HA_SWITCH_JOB_TIMEOUT_UNIT_SEC_CONFIG_PREFIX + "_" + Constant.INVALID_CODE, + null + ); + } + + return durationUnitSec; + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ClusterService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ClusterService.java index 35c4be8d..c33611c2 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ClusterService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ClusterService.java @@ -43,7 +43,7 @@ public interface ClusterService { ClusterNameDTO getClusterName(Long logicClusterId); - ResultStatus deleteById(Long clusterId, String operator); + Result deleteById(Long clusterId, String operator); /** * 获取优先被选举为controller的broker diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/JobLogService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/JobLogService.java new file mode 100644 index 00000000..4918b8c6 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/JobLogService.java @@ -0,0 +1,15 @@ +package com.xiaojukeji.kafka.manager.service.service; + + +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; + +import java.util.List; + +/** + * Job相关的日志 + */ +public interface JobLogService { + void addLogAndIgnoreException(JobLogDO jobLogDO); + + List listLogs(Integer bizType, String bizKeyword, Long startId); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicManagerService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicManagerService.java index 79524204..279236ff 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicManagerService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicManagerService.java @@ -2,11 +2,14 @@ package com.xiaojukeji.kafka.manager.service.service; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.TopicOperationResult; import com.xiaojukeji.kafka.manager.common.entity.ao.RdTopicBasic; +import com.xiaojukeji.kafka.manager.common.entity.ao.topic.MineTopicSummary; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicAppData; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicBusinessInfo; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicDTO; -import com.xiaojukeji.kafka.manager.common.entity.ao.topic.MineTopicSummary; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicExpansionDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicModificationDTO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicExpiredDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicStatisticsDO; @@ -130,5 +133,15 @@ public interface TopicManagerService { * @return */ ResultStatus addAuthority(AuthorityDO authorityDO); + + /** + * 修改topic + */ + Result modifyTopic(TopicModificationDTO dto); + + /** + * topic扩分区 + */ + TopicOperationResult expandTopic(TopicExpansionDTO dto); } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicService.java index 7a0e3eb0..8fd0b4f1 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/TopicService.java @@ -65,6 +65,7 @@ public interface TopicService { * 获取Topic的分区的offset */ Map getPartitionOffset(ClusterDO clusterDO, String topicName, OffsetPosEnum offsetPosEnum); + Map getPartitionOffset(Long clusterPhyId, String topicName, OffsetPosEnum offsetPosEnum); /** * 获取Topic概览信息 diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ZookeeperService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ZookeeperService.java index d52d3bc7..c6fe3220 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ZookeeperService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ZookeeperService.java @@ -42,4 +42,13 @@ public interface ZookeeperService { * @return */ Result deleteControllerPreferredCandidate(Long clusterId, Integer brokerId); + + /** + * 获取集群的brokerId + * @param zookeeper zookeeper + * @return 操作结果 + */ + Result> getBrokerIds(String zookeeper); + + Long getClusterIdAndNullIfFailed(String zookeeper); } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AppService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AppService.java index 82aa5513..e289f3f6 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AppService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AppService.java @@ -51,6 +51,13 @@ public interface AppService { */ List getByPrincipal(String principal); + /** + * 通过负责人&集群id(排除已被其他集群绑定的app)来查找 + * @param principal 负责人 + * @return List + */ + List getByPrincipalAndClusterId(String principal, Long phyClusterId); + /** * 通过appId来查,需要check当前登录人是否有权限. * @param appId appId diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AuthorityService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AuthorityService.java index 6a19d84e..7af04408 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AuthorityService.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/AuthorityService.java @@ -46,6 +46,8 @@ public interface AuthorityService { */ List getAuthorityByTopic(Long clusterId, String topicName); + List getAuthorityByTopicFromCache(Long clusterId, String topicName); + List getAuthority(String appId); /** diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AppServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AppServiceImpl.java index 200b3cf4..91afd277 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AppServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AppServiceImpl.java @@ -4,24 +4,27 @@ import com.alibaba.fastjson.JSONObject; import com.xiaojukeji.kafka.manager.common.bizenum.ModuleEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperateEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperationStatusEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.AppTopicDTO; import com.xiaojukeji.kafka.manager.common.entity.dto.normal.AppDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.OperateRecordDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.KafkaUserDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.ListUtils; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; -import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; import com.xiaojukeji.kafka.manager.dao.gateway.AppDao; import com.xiaojukeji.kafka.manager.dao.gateway.KafkaUserDao; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.OperateRecordService; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; -import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -60,6 +63,9 @@ public class AppServiceImpl implements AppService { @Autowired private OperateRecordService operateRecordService; + @Autowired + private HaASRelationService haASRelationService; + @Override public ResultStatus addApp(AppDO appDO, String operator) { try { @@ -181,6 +187,52 @@ public class AppServiceImpl implements AppService { return new ArrayList<>(); } + @Override + public List getByPrincipalAndClusterId(String principal, Long phyClusterId) { + try { + List appDOs = appDao.getByPrincipal(principal); + if (ValidateUtils.isEmptyList(appDOs)){ + return new ArrayList<>(); + } + + List has = haASRelationService.listAllHAFromDB(phyClusterId, HaResTypeEnum.CLUSTER); + List authorityDOS; + if (has.isEmpty()){ + authorityDOS = authorityService.listAll().stream() + .filter(authorityDO -> !authorityDO.getClusterId().equals(phyClusterId)) + .collect(Collectors.toList()); + }else { + authorityDOS = authorityService.listAll().stream() + .filter(authorityDO -> !(has.get(0).getActiveClusterPhyId().equals(authorityDO.getClusterId()) + || has.get(0).getStandbyClusterPhyId().equals(authorityDO.getClusterId()))) + .collect(Collectors.toList()); + } + + Map> appClusterIdMap = authorityDOS + .stream().filter(authorityDO -> !authorityDO.getClusterId().equals(phyClusterId)) + .collect(Collectors.groupingBy(AuthorityDO::getAppId)); + + //过滤已被其他集群topic使用的app + appDOs = appDOs.stream() + .filter(appDO -> ListUtils.string2StrList(appDO.getPrincipals()).contains(principal)) + .filter(appDO -> appClusterIdMap.get(appDO.getAppId()) == null) + .collect(Collectors.toList()); + + //过滤已被其他集群使用的app + List clusterAppIds = logicClusterMetadataManager.getLogicalClusterList() + .stream().filter(logicalClusterDO -> !logicalClusterDO.getClusterId().equals(phyClusterId) ) + .map(LogicalClusterDO::getAppId).collect(Collectors.toList()); + appDOs = appDOs.stream() + .filter(appDO -> !clusterAppIds.contains(appDO.getAppId())) + .collect(Collectors.toList()); + + return appDOs; + } catch (Exception e) { + LOGGER.error("get app list failed, principals:{}.", principal); + } + return new ArrayList<>(); + } + @Override public AppDO getAppByUserAndId(String appId, String curUser) { AppDO appDO = this.getByAppId(appId); diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AuthorityServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AuthorityServiceImpl.java index f5fad493..55cca885 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AuthorityServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/gateway/impl/AuthorityServiceImpl.java @@ -4,6 +4,7 @@ import com.xiaojukeji.kafka.manager.common.bizenum.ModuleEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperateEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperationStatusEnum; import com.xiaojukeji.kafka.manager.common.bizenum.TopicAuthorityEnum; +import com.xiaojukeji.kafka.manager.common.constant.Constant; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; import com.xiaojukeji.kafka.manager.common.entity.pojo.OperateRecordDO; @@ -75,8 +76,10 @@ public class AuthorityServiceImpl implements AuthorityService { return kafkaAclDao.insert(kafkaAclDO); } catch (Exception e) { LOGGER.error("add authority failed, authorityDO:{}.", authorityDO, e); + + // 返回-1表示出错 + return Constant.INVALID_CODE; } - return result; } @Override @@ -124,7 +127,10 @@ public class AuthorityServiceImpl implements AuthorityService { operateRecordService.insert(operateRecordDO); } catch (Exception e) { LOGGER.error("delete authority failed, authorityDO:{}.", authorityDO, e); + + return ResultStatus.MYSQL_ERROR; } + return ResultStatus.SUCCESS; } @@ -152,6 +158,11 @@ public class AuthorityServiceImpl implements AuthorityService { return Collections.emptyList(); } + @Override + public List getAuthorityByTopicFromCache(Long clusterId, String topicName) { + return authorityDao.getAuthorityByTopicFromCache(clusterId, topicName); + } + @Override public List getAuthority(String appId) { List doList = null; diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASRelationService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASRelationService.java new file mode 100644 index 00000000..08445182 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASRelationService.java @@ -0,0 +1,61 @@ +package com.xiaojukeji.kafka.manager.service.service.ha; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; + +import java.util.List; + +public interface HaASRelationService { + Result replaceTopicRelationsToDB(Long standbyClusterPhyId, List topicRelationDOList); + + Result addHAToDB(HaASRelationDO haASRelationDO); + + Result deleteById(Long id); + + int updateRelationStatus(Long relationId, Integer newStatus); + int updateById(HaASRelationDO haASRelationDO); + + /** + * 获取主集群关系 + */ + HaASRelationDO getActiveClusterHAFromDB(Long activeClusterPhyId); + + /** + * 获取主备关系 + */ + HaASRelationDO getSpecifiedHAFromDB(Long activeClusterPhyId, + String activeResName, + Long standbyClusterPhyId, + String standbyResName, + HaResTypeEnum resTypeEnum); + + /** + * 获取主备关系 + */ + HaASRelationDO getHAFromDB(Long firstClusterPhyId, + String firstResName, + HaResTypeEnum resTypeEnum); + + /** + * 获取备集群主备关系 + */ + List getStandbyHAFromDB(Long standbyClusterPhyId, HaResTypeEnum resTypeEnum); + List getActiveHAFromDB(Long activeClusterPhyId, HaResTypeEnum resTypeEnum); + + /** + * 获取主备关系 + */ + List listAllHAFromDB(HaResTypeEnum resTypeEnum); + + /** + * 获取主备关系 + */ + List listAllHAFromDB(Long firstClusterPhyId, HaResTypeEnum resTypeEnum); + + /** + * 获取主备关系 + */ + List listAllHAFromDB(Long firstClusterPhyId, Long secondClusterPhyId, HaResTypeEnum resTypeEnum); + +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASSwitchJobService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASSwitchJobService.java new file mode 100644 index 00000000..189a4ba0 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaASSwitchJobService.java @@ -0,0 +1,57 @@ +package com.xiaojukeji.kafka.manager.service.service.ha; + + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobDetail; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaSubJobExtendData; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchJobDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchSubJobDO; + +import java.util.List; +import java.util.Map; + +public interface HaASSwitchJobService { + /** + * 创建任务 + */ + Result createJob(Long activeClusterPhyId, Long standbyClusterPhyId, List topicNameList, String operator); + + /** + * 更新任务状态 + */ + int updateJobStatus(Long jobId, Integer jobStatus); + + /** + * 更新子任务状态 + */ + int updateSubJobStatus(Long subJobId, Integer jobStatus); + + /** + * 更新子任务扩展数据 + */ + int updateSubJobExtendData(Long subJobId, HaSubJobExtendData extendData); + + /** + * 任务详情 + */ + Result> jobDetail(Long jobId); + + /** + * 正在运行中的job + */ + List listRunningJobs(Long ignoreAfterTime); + + /** + * 集群近期的任务ID + */ + Map listClusterLatestJobs(); + + HaASSwitchJobDO getJobById(Long jobId); + + List listSubJobsById(Long jobId); + + /** + * 获取所有切换任务 + */ + List listAll(Boolean isAsc); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaClusterService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaClusterService.java new file mode 100644 index 00000000..3a4774c0 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaClusterService.java @@ -0,0 +1,45 @@ +package com.xiaojukeji.kafka.manager.service.service.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterVO; + +import java.util.List; +import java.util.Map; + +/** + * 集群主备关系 + */ +public interface HaClusterService { + /** + * 创建主备关系 + */ + Result createHA(Long activeClusterPhyId, Long standbyClusterPhyId, String operator); + Result createHAInKafka(String zookeeper, ClusterDO needWriteToZKClusterDO, String operator); + + /** + * 切换主备关系 + */ + Result switchHA(Long newActiveClusterPhyId, Long newStandbyClusterPhyId); + + /** + * 删除主备关系 + */ + Result deleteHA(Long activeClusterPhyId, Long standbyClusterPhyId); + + /** + * 获取主备关系 + */ + HaASRelationDO getHA(Long activeClusterPhyId); + + /** + * 获取集群主备关系 + */ + Map getClusterHARelation(); + + /** + * 获取主备关系 + */ + Result> listAllHA(); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaKafkaUserService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaKafkaUserService.java new file mode 100644 index 00000000..30310d83 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaKafkaUserService.java @@ -0,0 +1,23 @@ +package com.xiaojukeji.kafka.manager.service.service.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; + + +/** + * Topic主备关系管理 + * 不包括ACL,Gateway等信息 + */ +public interface HaKafkaUserService { + + Result setNoneHAInKafka(String zookeeper, String kafkaUser); + + /** + * 暂停HA + */ + Result stopHAInKafka(String zookeeper, String kafkaUser); + + /** + * 激活HA + */ + Result activeHAInKafka(String zookeeper, Long activeClusterPhyId, String kafkaUser); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaTopicService.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaTopicService.java new file mode 100644 index 00000000..6da4efaf --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/HaTopicService.java @@ -0,0 +1,43 @@ +package com.xiaojukeji.kafka.manager.service.service.ha; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; + +import java.util.List; +import java.util.Map; + +/** + * Topic主备关系管理 + * 不包括ACL,Gateway等信息 + */ +public interface HaTopicService { + /** + * 创建主备关系 + */ + Result createHA(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName, String operator); + Result activeHAInKafkaNotCheck(ClusterDO activeClusterDO, String activeTopicName, ClusterDO standbyClusterDO, String standbyTopicName, String operator); + Result activeHAInKafka(ClusterDO activeClusterDO, String activeTopicName, ClusterDO standbyClusterDO, String standbyTopicName, String operator); + + /** + * 删除主备关系 + */ + Result deleteHA(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName, String operator); + Result stopHAInKafka(ClusterDO standbyClusterDO, String standbyTopicName, String operator); + + /** + * 获取集群topic的主备关系 + */ + Map getRelation(Long clusterId); + + /** + * 获取所有集群的备topic名称 + */ + Map> getClusterStandbyTopicMap(); + + /** + * 激活kafkaUserHA + */ + Result activeUserHAInKafka(ClusterDO activeClusterDO, ClusterDO standbyClusterDO, String kafkaUser, String operator); + + Result getStandbyTopicFetchLag(Long standbyClusterPhyId, String topicName); +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASRelationServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASRelationServiceImpl.java new file mode 100644 index 00000000..097e864e --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASRelationServiceImpl.java @@ -0,0 +1,199 @@ +package com.xiaojukeji.kafka.manager.service.service.ha.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.dao.ha.HaASRelationDao; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class HaASRelationServiceImpl implements HaASRelationService { + private static final Logger LOGGER = LoggerFactory.getLogger(HaASRelationServiceImpl.class); + + @Autowired + private HaASRelationDao haASRelationDao; + + @Override + public Result replaceTopicRelationsToDB(Long standbyClusterPhyId, List topicRelationDOList) { + try { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, HaResTypeEnum.TOPIC.getCode()); + lambdaQueryWrapper.eq(HaASRelationDO::getStandbyClusterPhyId, standbyClusterPhyId); + + Map dbRelationMap = haASRelationDao.selectList(lambdaQueryWrapper).stream().collect(Collectors.toMap(HaASRelationDO::getUniqueField, Function.identity())); + for (HaASRelationDO relationDO: topicRelationDOList) { + HaASRelationDO dbRelationDO = dbRelationMap.remove(relationDO.getUniqueField()); + if (dbRelationDO == null) { + // DB中不存在,则插入新的 + haASRelationDao.insert(relationDO); + } + } + + // dbRelationMap 中剩余的,是需要进行删除的 + for (HaASRelationDO dbRelationDO: dbRelationMap.values()) { + if (System.currentTimeMillis() - dbRelationDO.getModifyTime().getTime() >= 5 * 1000L) { + // 修改时间超过了5分钟了,则进行删除 + haASRelationDao.deleteById(dbRelationDO.getId()); + } + } + + return Result.buildSuc(); + } catch (Exception e) { + LOGGER.error("method=replaceTopicRelationsToDB||standbyClusterPhyId={}||errMsg=exception.", standbyClusterPhyId, e); + + return Result.buildFromRSAndMsg(ResultStatus.MYSQL_ERROR, e.getMessage()); + } + } + + @Override + public Result addHAToDB(HaASRelationDO haASRelationDO) { + try{ + int count = haASRelationDao.insert(haASRelationDO); + if (count < 1){ + LOGGER.error("add ha to db failed! haASRelationDO:{}" , haASRelationDO); + return Result.buildFrom(ResultStatus.MYSQL_ERROR); + } + } catch (Exception e) { + LOGGER.error("add ha to db failed! haASRelationDO:{}" , haASRelationDO); + return Result.buildFrom(ResultStatus.MYSQL_ERROR); + } + return Result.buildSuc(); + } + + @Override + public Result deleteById(Long id) { + try { + haASRelationDao.deleteById(id); + } catch (Exception e){ + LOGGER.error("class=HaASRelationServiceImpl||method=deleteById||id={}||errMsg=exception", id, e); + return Result.buildFrom(ResultStatus.MYSQL_ERROR); + } + return Result.buildSuc(); + } + + @Override + public int updateRelationStatus(Long relationId, Integer newStatus) { + return haASRelationDao.updateById(new HaASRelationDO(relationId, newStatus)); + } + + @Override + public int updateById(HaASRelationDO haASRelationDO) { + return haASRelationDao.updateById(haASRelationDO); + } + + @Override + public HaASRelationDO getActiveClusterHAFromDB(Long activeClusterPhyId) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getActiveClusterPhyId, activeClusterPhyId); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, HaResTypeEnum.CLUSTER.getCode()); + + return haASRelationDao.selectOne(lambdaQueryWrapper); + } + + @Override + public HaASRelationDO getSpecifiedHAFromDB(Long activeClusterPhyId, String activeResName, + Long standbyClusterPhyId, String standbyResName, + HaResTypeEnum resTypeEnum) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + HaASRelationDO relationDO = new HaASRelationDO( + activeClusterPhyId, + activeResName, + standbyClusterPhyId, + standbyResName, + resTypeEnum.getCode(), + HaStatusEnum.UNKNOWN.getCode() + ); + lambdaQueryWrapper.eq(HaASRelationDO::getUniqueField, relationDO.getUniqueField()); + + return haASRelationDao.selectOne(lambdaQueryWrapper); + } + + @Override + public HaASRelationDO getHAFromDB(Long firstClusterPhyId, String firstResName, HaResTypeEnum resTypeEnum) { + List haASRelationDOS = listAllHAFromDB(firstClusterPhyId, resTypeEnum); + for(HaASRelationDO haASRelationDO : haASRelationDOS){ + if (haASRelationDO.getActiveResName().equals(firstResName) + || haASRelationDO.getActiveResName().equals(firstResName)){ + return haASRelationDO; + } + } + return null; + } + + @Override + public List getStandbyHAFromDB(Long standbyClusterPhyId, HaResTypeEnum resTypeEnum) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, resTypeEnum.getCode()); + lambdaQueryWrapper.eq(HaASRelationDO::getStandbyClusterPhyId, standbyClusterPhyId); + + return haASRelationDao.selectList(lambdaQueryWrapper); + } + + @Override + public List getActiveHAFromDB(Long activeClusterPhyId, HaResTypeEnum resTypeEnum) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, resTypeEnum.getCode()); + lambdaQueryWrapper.eq(HaASRelationDO::getActiveClusterPhyId, activeClusterPhyId); + + return haASRelationDao.selectList(lambdaQueryWrapper); + } + + @Override + public List listAllHAFromDB(HaResTypeEnum resTypeEnum) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, resTypeEnum.getCode()); + + return haASRelationDao.selectList(lambdaQueryWrapper); + } + + @Override + public List listAllHAFromDB(Long firstClusterPhyId, HaResTypeEnum resTypeEnum) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASRelationDO::getResType, resTypeEnum.getCode()); + lambdaQueryWrapper.and(lambda -> + lambda.eq(HaASRelationDO::getActiveClusterPhyId, firstClusterPhyId).or().eq(HaASRelationDO::getStandbyClusterPhyId, firstClusterPhyId) + ); + + // 查询HA列表 + List doList = haASRelationDao.selectList(lambdaQueryWrapper); + if (ValidateUtils.isNull(doList)) { + return new ArrayList<>(); + } + + return doList; + } + + @Override + public List listAllHAFromDB(Long firstClusterPhyId, Long secondClusterPhyId, HaResTypeEnum resTypeEnum) { + // 查询HA列表 + List doList = this.listAllHAFromDB(firstClusterPhyId, resTypeEnum); + if (ValidateUtils.isNull(doList)) { + return new ArrayList<>(); + } + + if (secondClusterPhyId == null) { + // 如果为null,则直接返回全部 + return doList; + } + + // 手动过滤掉不需要的集群 + return doList.stream() + .filter(elem -> elem.getActiveClusterPhyId().equals(secondClusterPhyId) || elem.getStandbyClusterPhyId().equals(secondClusterPhyId)) + .collect(Collectors.toList()); + } + +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASSwitchJobServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASSwitchJobServiceImpl.java new file mode 100644 index 00000000..408fcff7 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaASSwitchJobServiceImpl.java @@ -0,0 +1,190 @@ +package com.xiaojukeji.kafka.manager.service.service.ha.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.job.HaJobStatusEnum; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.*; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchJobDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchSubJobDO; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.dao.ha.HaASSwitchJobDao; +import com.xiaojukeji.kafka.manager.dao.ha.HaASSwitchSubJobDao; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASSwitchJobService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class HaASSwitchJobServiceImpl implements HaASSwitchJobService { + private static final Logger LOGGER = LoggerFactory.getLogger(HaASSwitchJobServiceImpl.class); + + @Autowired + private HaASSwitchJobDao haASSwitchJobDao; + + @Autowired + private HaASSwitchSubJobDao haASSwitchSubJobDao; + + @Override + @Transactional + public Result createJob(Long activeClusterPhyId, Long standbyClusterPhyId, List topicNameList, String operator) { + try { + // 父任务 + HaASSwitchJobDO jobDO = new HaASSwitchJobDO(activeClusterPhyId, standbyClusterPhyId, HaJobStatusEnum.RUNNING.getStatus(), operator); + haASSwitchJobDao.insert(jobDO); + + // 子任务 + for (String topicName: topicNameList) { + haASSwitchSubJobDao.insert(new HaASSwitchSubJobDO( + jobDO.getId(), + activeClusterPhyId, + topicName, + standbyClusterPhyId, + topicName, + HaResTypeEnum.TOPIC.getCode(), + HaJobStatusEnum.RUNNING.getStatus(), + "" + )); + } + + return Result.buildSuc(jobDO.getId()); + } catch (Exception e) { + LOGGER.error( + "method=createJob||activeClusterPhyId={}||standbyClusterPhyId={}||topicNameList={}||operator={}||errMsg=exception", + activeClusterPhyId, standbyClusterPhyId, ConvertUtil.obj2Json(topicNameList), operator, e + ); + + // 如果这一步出错了,则对上一步进行手动回滚 + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + + return Result.buildFromRSAndMsg(ResultStatus.MYSQL_ERROR, e.getMessage()); + } + } + + @Override + public int updateJobStatus(Long jobId, Integer jobStatus) { + HaASSwitchJobDO jobDO = new HaASSwitchJobDO(); + jobDO.setId(jobId); + jobDO.setJobStatus(jobStatus); + return haASSwitchJobDao.updateById(jobDO); + } + + @Override + public int updateSubJobStatus(Long subJobId, Integer jobStatus) { + HaASSwitchSubJobDO subJobDO = new HaASSwitchSubJobDO(); + subJobDO.setId(subJobId); + subJobDO.setJobStatus(jobStatus); + return haASSwitchSubJobDao.updateById(subJobDO); + } + + @Override + public int updateSubJobExtendData(Long subJobId, HaSubJobExtendData extendData) { + HaASSwitchSubJobDO subJobDO = new HaASSwitchSubJobDO(); + subJobDO.setId(subJobId); + subJobDO.setExtendData(ConvertUtil.obj2Json(extendData)); + return haASSwitchSubJobDao.updateById(subJobDO); + } + + @Override + public Result> jobDetail(Long jobId) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASSwitchSubJobDO::getJobId, jobId); + + List doList = haASSwitchSubJobDao.selectList(lambdaQueryWrapper); + if (ValidateUtils.isEmptyList(doList)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, String.format("jobId:[%d] 不存在", jobId)); + } + + List detailList = new ArrayList<>(); + doList.stream().forEach(elem -> { + HaJobDetail detail = new HaJobDetail(); + detail.setTopicName(elem.getActiveResName()); + detail.setActiveClusterPhyId(elem.getActiveClusterPhyId()); + detail.setStandbyClusterPhyId(elem.getStandbyClusterPhyId()); + detail.setStatus(elem.getJobStatus()); + + // Lag信息 + HaSubJobExtendData extendData = ConvertUtil.str2ObjByJson(elem.getExtendData(), HaSubJobExtendData.class); + detail.setSumLag(extendData != null? extendData.getSumLag(): null); + + detailList.add(detail); + }); + + return Result.buildSuc(detailList); + } + + @Override + public List listRunningJobs(Long ignoreAfterTime) { + return new ArrayList<>(new HashSet<>( + this.listAfterTimeRunningJobs(ignoreAfterTime).values() + )); + } + + @Override + public Map listClusterLatestJobs() { + List doList = haASSwitchJobDao.listAllLatest(); + + Map doMap = new HashMap<>(); + for (HaASSwitchJobDO jobDO: doList) { + HaASSwitchJobDO inMapJobDO = doMap.get(jobDO.getActiveClusterPhyId()); + if (inMapJobDO == null || inMapJobDO.getId() <= jobDO.getId()) { + doMap.put(jobDO.getActiveClusterPhyId(), jobDO); + } + + inMapJobDO = doMap.get(jobDO.getStandbyClusterPhyId()); + if (inMapJobDO == null || inMapJobDO.getId() <= jobDO.getId()) { + doMap.put(jobDO.getStandbyClusterPhyId(), jobDO); + } + } + + return doMap; + } + + @Override + public HaASSwitchJobDO getJobById(Long jobId) { + return haASSwitchJobDao.selectById(jobId); + } + + @Override + public List listSubJobsById(Long jobId) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(HaASSwitchSubJobDO::getJobId, jobId); + return haASSwitchSubJobDao.selectList(lambdaQueryWrapper); + } + + @Override + public List listAll(Boolean isAsc) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.orderBy(isAsc != null, isAsc, HaASSwitchSubJobDO::getId); + return haASSwitchSubJobDao.selectList(lambdaQueryWrapper); + } + + /**************************************************** private method ****************************************************/ + + private Map listAfterTimeRunningJobs(Long ignoreAfterTime) { + LambdaQueryWrapper jobLambdaQueryWrapper = new LambdaQueryWrapper<>(); + jobLambdaQueryWrapper.eq(HaASSwitchJobDO::getJobStatus, HaJobStatusEnum.RUNNING.getStatus()); + List jobDOList = haASSwitchJobDao.selectList(jobLambdaQueryWrapper); + if (jobDOList == null) { + return new HashMap<>(); + } + + // 获取指定时间之前的任务 + jobDOList = jobDOList.stream().filter(job -> job.getCreateTime().getTime() <= ignoreAfterTime).collect(Collectors.toList()); + + Map clusterPhyIdAndJobIdMap = new HashMap<>(); + jobDOList.forEach(elem -> { + clusterPhyIdAndJobIdMap.put(elem.getActiveClusterPhyId(), elem.getId()); + clusterPhyIdAndJobIdMap.put(elem.getStandbyClusterPhyId(), elem.getId()); + }); + return clusterPhyIdAndJobIdMap; + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaClusterServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaClusterServiceImpl.java new file mode 100644 index 00000000..f3d27689 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaClusterServiceImpl.java @@ -0,0 +1,389 @@ +package com.xiaojukeji.kafka.manager.service.service.ha.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.job.HaJobStatusEnum; +import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.ClusterDetailDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchJobDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterVO; +import com.xiaojukeji.kafka.manager.common.utils.JsonUtils; +import com.xiaojukeji.kafka.manager.dao.ha.HaASRelationDao; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.ZookeeperService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASSwitchJobService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaClusterService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; +import com.xiaojukeji.kafka.manager.service.utils.ConfigUtils; +import com.xiaojukeji.kafka.manager.service.utils.HaClusterCommands; +import com.xiaojukeji.kafka.manager.service.utils.HaTopicCommands; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 集群主备关系 + */ +@Service +public class HaClusterServiceImpl implements HaClusterService { + private static final Logger LOGGER = LoggerFactory.getLogger(HaClusterServiceImpl.class); + + @Autowired + private ClusterService clusterService; + + @Autowired + private HaASRelationService haASRelationService; + + @Autowired + private HaASRelationDao haActiveStandbyRelationDao; + + @Autowired + private HaTopicService haTopicService; + + @Autowired + private PhysicalClusterMetadataManager physicalClusterMetadataManager; + + @Autowired + private HaASSwitchJobService haASSwitchJobService; + + @Autowired + private ConfigUtils configUtils; + + @Autowired + private ZookeeperService zookeeperService; + + @Override + public Result createHA(Long activeClusterPhyId, Long standbyClusterPhyId, String operator) { + ClusterDO activeClusterDO = clusterService.getById(activeClusterPhyId); + if (activeClusterDO == null){ + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + + ClusterDO standbyClusterDO = clusterService.getById(standbyClusterPhyId); + if (standbyClusterDO == null){ + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + + HaASRelationDO oldRelationDO = getHA(activeClusterPhyId); + if (oldRelationDO != null){ + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_ALREADY_USED, + MsgConstant.getActiveClusterDuplicate(activeClusterDO.getId(), activeClusterDO.getClusterName())); + + } + + //更新集群配置 + Result rv = this.modifyHaClusterConfig(activeClusterDO, standbyClusterDO, operator); + if (rv.failed()){ + return rv; + } + + //更新__consumer_offsets配置 + rv = this.modifyHaTopicConfig(activeClusterDO, standbyClusterDO, operator); + if (rv.failed()){ + return rv; + } + + //添加db数据 + return haASRelationService.addHAToDB( + new HaASRelationDO( + activeClusterPhyId, + activeClusterPhyId.toString(), + standbyClusterPhyId, + standbyClusterPhyId.toString(), + HaResTypeEnum.CLUSTER.getCode(), + HaStatusEnum.STABLE.getCode() + ) + ); + } + + @Override + public Result createHAInKafka(String zookeeper, ClusterDO needWriteToZKClusterDO, String operator) { + Properties props = new Properties(); + props.putAll(getSecurityProperties(needWriteToZKClusterDO.getSecurityProperties())); + props.put(KafkaConstant.BOOTSTRAP_SERVERS, needWriteToZKClusterDO.getBootstrapServers()); + props.put(KafkaConstant.DIDI_KAFKA_ENABLE, "false"); + + Result> rli = zookeeperService.getBrokerIds(needWriteToZKClusterDO.getZookeeper()); + if (rli.failed()){ + return Result.buildFromIgnoreData(rli); + } + + String kafkaVersion = physicalClusterMetadataManager.getKafkaVersion(needWriteToZKClusterDO.getId(), rli.getData()); + if (kafkaVersion != null && kafkaVersion.contains("-d-")){ + int dVersion = Integer.valueOf(kafkaVersion.split("-")[2]); + if (dVersion > 200){ + props.put(KafkaConstant.DIDI_KAFKA_ENABLE, "true"); + } + } + + ResultStatus rs = HaClusterCommands.modifyHaClusterConfig(zookeeper, needWriteToZKClusterDO.getId(), props); + if (!ResultStatus.SUCCESS.equals(rs)) { + LOGGER.error("class=HaClusterServiceImpl||method=createHAInKafka||zookeeper={}||firstClusterDO={}||operator={}||msg=add ha-cluster config failed!", zookeeper, needWriteToZKClusterDO, operator); + return Result.buildFailure("add ha-cluster config failed"); + } + + return Result.buildFrom(rs); + } + + @Override + public Result switchHA(Long newActiveClusterPhyId, Long newStandbyClusterPhyId) { + return Result.buildSuc(); + } + + @Override + public Result deleteHA(Long activeClusterPhyId, Long standbyClusterPhyId) { + ClusterDO clusterDO = clusterService.getById(activeClusterPhyId); + if (clusterDO == null){ + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + ClusterDO standbyClusterDO = clusterService.getById(standbyClusterPhyId); + if (standbyClusterDO == null){ + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + + HaASRelationDO relationDO = getHA(activeClusterPhyId); + if (relationDO == null){ + return Result.buildSuc(); + } + + //删除配置 + Result delResult = delClusterHaConfig(clusterDO, standbyClusterDO); + if (delResult.failed()){ + return delResult; + } + + //删除db + Result delDbResult = delDBHaCluster(activeClusterPhyId, standbyClusterPhyId); + if (delDbResult.failed()){ + return delDbResult; + } + + return Result.buildSuc(); + } + + @Override + public HaASRelationDO getHA(Long activeClusterPhyId) { + return haASRelationService.getActiveClusterHAFromDB(activeClusterPhyId); + } + + @Override + public Map getClusterHARelation() { + Map relationMap = new HashMap<>(); + List haASRelationDOS = haASRelationService.listAllHAFromDB(HaResTypeEnum.CLUSTER); + if (haASRelationDOS.isEmpty()){ + return relationMap; + } + haASRelationDOS.forEach(haASRelationDO -> { + relationMap.put(haASRelationDO.getActiveClusterPhyId(), HaRelationTypeEnum.ACTIVE.getCode()); + relationMap.put(haASRelationDO.getStandbyClusterPhyId(), HaRelationTypeEnum.STANDBY.getCode()); + }); + return relationMap; + } + + @Override + public Result> listAllHA() { + //高可用集群 + List clusterRelationDOS = haASRelationService.listAllHAFromDB(HaResTypeEnum.CLUSTER); + Map activeMap = clusterRelationDOS.stream().collect(Collectors.toMap(HaASRelationDO::getActiveClusterPhyId, Function.identity())); + List standbyList = clusterRelationDOS.stream().map(HaASRelationDO::getStandbyClusterPhyId).collect(Collectors.toList()); + + //高可用topic + List topicRelationDOS = haASRelationService.listAllHAFromDB(HaResTypeEnum.TOPIC); + //主集群topic数 + Map activeTopicCountMap = topicRelationDOS.stream() + .filter(haASRelationDO -> !haASRelationDO.getActiveResName().startsWith("__")) + .collect(Collectors.groupingBy(HaASRelationDO::getActiveClusterPhyId, Collectors.counting())); + Map standbyTopicCountMap = topicRelationDOS.stream() + .filter(haASRelationDO -> !haASRelationDO.getStandbyResName().startsWith("__")) + .collect(Collectors.groupingBy(HaASRelationDO::getStandbyClusterPhyId, Collectors.counting())); + + //切换job + Map jobDOS = haASSwitchJobService.listClusterLatestJobs(); + + List haClusterVOS = new ArrayList<>(); + Map clusterDetailDTOMap = clusterService.getClusterDetailDTOList(Boolean.TRUE).stream().collect(Collectors.toMap(ClusterDetailDTO::getClusterId, Function.identity())); + for (Map.Entry entry : clusterDetailDTOMap.entrySet()){ + ClusterDetailDTO clusterDetailDTO = entry.getValue(); + //高可用集群 + if (activeMap.containsKey(entry.getKey())){ + //主集群 + HaASRelationDO relationDO = activeMap.get(clusterDetailDTO.getClusterId()); + HaClusterVO haClusterVO = new HaClusterVO(); + BeanUtils.copyProperties(clusterDetailDTO,haClusterVO); + haClusterVO.setHaStatus(relationDO.getStatus()); + haClusterVO.setActiveTopicCount(activeTopicCountMap.get(clusterDetailDTO.getClusterId())==null + ?0L:activeTopicCountMap.get(clusterDetailDTO.getClusterId())); + haClusterVO.setStandbyTopicCount(standbyTopicCountMap.get(clusterDetailDTO.getClusterId())==null + ?0L:standbyTopicCountMap.get(clusterDetailDTO.getClusterId())); + HaASSwitchJobDO jobDO = jobDOS.get(haClusterVO.getClusterId()); + haClusterVO.setHaStatus(jobDO != null && HaJobStatusEnum.isRunning(jobDO.getJobStatus()) + ?HaStatusEnum.SWITCHING_CODE: HaStatusEnum.STABLE_CODE); + ClusterDetailDTO standbyClusterDetail = clusterDetailDTOMap.get(relationDO.getStandbyClusterPhyId()); + if (standbyClusterDetail != null){ + //备集群 + HaClusterVO standbyCluster = new HaClusterVO(); + BeanUtils.copyProperties(standbyClusterDetail,standbyCluster); + standbyCluster.setActiveTopicCount(activeTopicCountMap.get(standbyClusterDetail.getClusterId())==null + ?0L:activeTopicCountMap.get(standbyClusterDetail.getClusterId())); + standbyCluster.setStandbyTopicCount(standbyTopicCountMap.get(standbyClusterDetail.getClusterId())==null + ?0L:standbyTopicCountMap.get(standbyClusterDetail.getClusterId())); + + standbyCluster.setHaASSwitchJobId(jobDO != null ? jobDO.getId() : null); + standbyCluster.setHaStatus(haClusterVO.getHaStatus()); + haClusterVO.setHaClusterVO(standbyCluster); + } + haClusterVOS.add(haClusterVO); + }else if(!standbyList.contains(clusterDetailDTO.getClusterId())){ + //普通集群 + HaClusterVO haClusterVO = new HaClusterVO(); + BeanUtils.copyProperties(clusterDetailDTO,haClusterVO); + haClusterVOS.add(haClusterVO); + } + } + return Result.buildSuc(haClusterVOS); + } + + private Result modifyHaClusterConfig(ClusterDO activeClusterDO, ClusterDO standbyClusterDO, String operator){ + //更新A集群配置信息 + Result activeResult = createHAInKafka(activeClusterDO.getZookeeper(), standbyClusterDO, operator); + if (activeResult.failed()){ + return activeResult; + } + + //更新gateway上A集群的配置 + Result activeGatewayResult = this.createHAInKafka(configUtils.getDKafkaGatewayZK(), activeClusterDO, operator); + if (activeGatewayResult.failed()){ + return activeGatewayResult; + } + + //更新B集群配置信息 + Result standbyResult = this.createHAInKafka(standbyClusterDO.getZookeeper(), activeClusterDO, operator); + if (standbyResult.failed()){ + return standbyResult; + } + //更新gateway上B集群的配置 + Result standbyGatewayResult = this.createHAInKafka(configUtils.getDKafkaGatewayZK(), standbyClusterDO, operator); + if (standbyGatewayResult.failed()){ + return activeGatewayResult; + } + + return Result.buildSuc(); + } + + private Result modifyHaTopicConfig(ClusterDO activeClusterDO, ClusterDO standbyClusterDO, String operator){ + //添加B集群拉取A集群offsets的配置信息 + Result aResult = haTopicService.activeHAInKafkaNotCheck(activeClusterDO, KafkaConstant.COORDINATOR_TOPIC_NAME, + standbyClusterDO, KafkaConstant.COORDINATOR_TOPIC_NAME, operator); + if (aResult.failed()){ + return aResult; + } + + //添加A集群拉取B集群offsets的配置信息 + return haTopicService.activeHAInKafkaNotCheck(standbyClusterDO, KafkaConstant.COORDINATOR_TOPIC_NAME, + activeClusterDO, KafkaConstant.COORDINATOR_TOPIC_NAME, operator); + + } + + private Result delClusterHaConfig(ClusterDO clusterDO, ClusterDO standbyClusterDO){ + //删除A集群同步B集群Offset配置 + ResultStatus resultStatus = HaTopicCommands.deleteHaTopicConfig( + clusterDO, + KafkaConstant.COORDINATOR_TOPIC_NAME, + Arrays.asList(KafkaConstant.DIDI_HA_REMOTE_CLUSTER, KafkaConstant.DIDI_HA_SYNC_TOPIC_CONFIGS_ENABLED) + ); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete active cluster config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + return Result.buildFailure("删除主集群__consumer_offsets高可用配置失败,请重试!"); + } + + //删除A集群配置信息 + resultStatus = HaClusterCommands.coverHaClusterConfig(clusterDO.getZookeeper(), standbyClusterDO.getId(), new Properties()); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete cluster config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + return Result.buildFailure("删除主集群高可用配置失败,请重试!"); + } + + //删除B集群同步A集群Offset配置 + resultStatus = HaTopicCommands.deleteHaTopicConfig( + standbyClusterDO, + KafkaConstant.COORDINATOR_TOPIC_NAME, + Arrays.asList(KafkaConstant.DIDI_HA_REMOTE_CLUSTER, KafkaConstant.DIDI_HA_SYNC_TOPIC_CONFIGS_ENABLED) + ); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete standby cluster config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + } + + //删除B集群配置信息 + resultStatus = HaClusterCommands.coverHaClusterConfig(standbyClusterDO.getZookeeper(), standbyClusterDO.getId(), new Properties()); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete standby cluster config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + } + + //更新gateway中备集群配置信息 + resultStatus = HaClusterCommands.coverHaClusterConfig(configUtils.getDKafkaGatewayZK(), standbyClusterDO.getId(), new Properties()); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete spare gateway config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + } + + //删除gateway中A集群配置信息 + resultStatus = HaClusterCommands.coverHaClusterConfig(configUtils.getDKafkaGatewayZK(), clusterDO.getId(), new Properties()); + if (resultStatus.getCode() != 0){ + LOGGER.error("delete host gateway config failed! clusterId:{} standbyClusterId:{}" , clusterDO.getId(), standbyClusterDO.getId()); + } + + return Result.buildSuc(); + } + + private Result delDBHaCluster(Long activeClusterPhyId, Long standbyClusterPhyId){ + LambdaQueryWrapper topicQueryWrapper = new LambdaQueryWrapper(); + topicQueryWrapper.eq(HaASRelationDO::getResType, HaResTypeEnum.TOPIC.getCode()); + topicQueryWrapper.eq(HaASRelationDO::getActiveClusterPhyId, activeClusterPhyId); + List relationDOS = haActiveStandbyRelationDao.selectList(topicQueryWrapper); + if (!relationDOS.isEmpty()){ + return Result.buildFrom(ResultStatus.HA_CLUSTER_DELETE_FORBIDDEN); + } + + try { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper(); + queryWrapper.eq(HaASRelationDO::getActiveClusterPhyId, activeClusterPhyId); + queryWrapper.eq(HaASRelationDO::getResType, HaResTypeEnum.CLUSTER.getCode()); + + int count = haActiveStandbyRelationDao.delete(queryWrapper); + if (count < 1){ + LOGGER.error("delete HA failed! clusterId:{} standbyClusterId:{}" , activeClusterPhyId, standbyClusterPhyId); + return Result.buildFrom(ResultStatus.MYSQL_ERROR); + } + }catch (Exception e){ + LOGGER.error("delete HA failed! clusterId:{} standbyClusterId:{}" , activeClusterPhyId, standbyClusterPhyId); + return Result.buildFrom(ResultStatus.MYSQL_ERROR); + } + return Result.buildSuc(); + } + + private Properties getSecurityProperties(String securityPropertiesStr){ + Properties securityProperties = new Properties(); + if (StringUtils.isBlank(securityPropertiesStr)){ + return securityProperties; + } + securityProperties.putAll(JsonUtils.stringToObj(securityPropertiesStr, Properties.class)); + securityProperties.put(KafkaConstant.SASL_JAAS_CONFIG, securityProperties.getProperty(KafkaConstant.SASL_JAAS_CONFIG)==null + ?"":securityProperties.getProperty(KafkaConstant.SASL_JAAS_CONFIG).replaceAll("\"","\\\\\"")); + return securityProperties; + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaKafkaUserServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaKafkaUserServiceImpl.java new file mode 100644 index 00000000..2a12cd0d --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaKafkaUserServiceImpl.java @@ -0,0 +1,42 @@ +package com.xiaojukeji.kafka.manager.service.service.ha.impl; + +import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.service.service.ha.HaKafkaUserService; +import com.xiaojukeji.kafka.manager.service.utils.HaKafkaUserCommands; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.Properties; + +@Service +public class HaKafkaUserServiceImpl implements HaKafkaUserService { + + @Override + public Result setNoneHAInKafka(String zookeeper, String kafkaUser) { + Properties props = new Properties(); + props.put(KafkaConstant.DIDI_HA_ACTIVE_CLUSTER, KafkaConstant.NONE); + + return HaKafkaUserCommands.modifyHaUserConfig(zookeeper, kafkaUser, props)? + Result.buildSuc(): // 修改成功 + Result.buildFrom(ResultStatus.ZOOKEEPER_OPERATE_FAILED); // 修改失败 + } + + @Override + public Result stopHAInKafka(String zookeeper, String kafkaUser) { + return HaKafkaUserCommands.deleteHaUserConfig(zookeeper, kafkaUser, Arrays.asList(KafkaConstant.DIDI_HA_ACTIVE_CLUSTER))? + Result.buildSuc(): // 修改成功 + Result.buildFrom(ResultStatus.ZOOKEEPER_OPERATE_FAILED); // 修改失败 + } + + @Override + public Result activeHAInKafka(String zookeeper, Long activeClusterPhyId, String kafkaUser) { + Properties props = new Properties(); + props.put(KafkaConstant.DIDI_HA_ACTIVE_CLUSTER, String.valueOf(activeClusterPhyId)); + + return HaKafkaUserCommands.modifyHaUserConfig(zookeeper, kafkaUser, props)? + Result.buildSuc(): // 修改成功 + Result.buildFrom(ResultStatus.ZOOKEEPER_OPERATE_FAILED); // 修改失败 + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaTopicServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaTopicServiceImpl.java new file mode 100644 index 00000000..5ad90824 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/ha/impl/HaTopicServiceImpl.java @@ -0,0 +1,469 @@ +package com.xiaojukeji.kafka.manager.service.service.ha.impl; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.constant.TopicCreationConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.common.utils.jmx.JmxAttributeEnum; +import com.xiaojukeji.kafka.manager.common.utils.jmx.JmxConnectorWrap; +import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.PartitionState; +import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.AdminService; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.gateway.QuotaService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaKafkaUserService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; +import com.xiaojukeji.kafka.manager.service.utils.ConfigUtils; +import com.xiaojukeji.kafka.manager.service.utils.HaTopicCommands; +import com.xiaojukeji.kafka.manager.service.utils.KafkaZookeeperUtils; +import com.xiaojukeji.kafka.manager.service.utils.TopicCommands; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +import javax.management.Attribute; +import javax.management.ObjectName; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class HaTopicServiceImpl implements HaTopicService { + private static final Logger LOGGER = LoggerFactory.getLogger(HaTopicServiceImpl.class); + + @Autowired + private ClusterService clusterService; + + @Autowired + private QuotaService quotaService; + + @Autowired + private AdminService adminService; + + @Autowired + private HaASRelationService haASRelationService; + + @Autowired + private AuthorityService authorityService; + + @Autowired + private HaKafkaUserService haKafkaUserService; + + @Autowired + private ConfigUtils configUtils; + + @Autowired + private TopicManagerService topicManagerService; + + @Override + public Result createHA(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName, String operator) { + ClusterDO activeClusterDO = PhysicalClusterMetadataManager.getClusterFromCache(activeClusterPhyId); + if (activeClusterDO == null) { + return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, "主集群不存在"); + } + + ClusterDO standbyClusterDO = PhysicalClusterMetadataManager.getClusterFromCache(standbyClusterPhyId); + if (standbyClusterDO == null) { + return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, "备集群不存在"); + } + + // 查询关系是否已经存在 + HaASRelationDO relationDO = haASRelationService.getSpecifiedHAFromDB( + activeClusterPhyId, + topicName, + standbyClusterPhyId, + topicName, + HaResTypeEnum.TOPIC + ); + if (relationDO != null) { + // 如果已存在该高可用Topic,则直接返回成功 + return Result.buildSuc(); + } + + Result checkResult = this.checkHaTopicAndGetBizInfo(activeClusterPhyId, standbyClusterPhyId, topicName); + if (checkResult.failed()){ + return Result.buildFromIgnoreData(checkResult); + } + + //更新高可用Topic配置 + Result rv = this.modifyHaConfig( + activeClusterDO, + topicName, + standbyClusterDO, + topicName, + operator + ); + if (rv.failed()){ + return rv; + } + + // 新增备Topic + rv = this.addStandbyTopic(checkResult.getData(), activeClusterDO, standbyClusterDO, operator); + if (rv.failed()) { + return rv; + } + + // 备topic添加权限以及quota + rv = this.addStandbyTopicAuthorityAndQuota(activeClusterPhyId, standbyClusterPhyId, topicName); + if (rv.failed()){ + return rv; + } + + //添加db业务信息 + return haASRelationService.addHAToDB( + new HaASRelationDO( + activeClusterPhyId, + topicName, + standbyClusterPhyId, + topicName, + HaResTypeEnum.TOPIC.getCode(), + HaStatusEnum.STABLE.getCode() + ) + ); + } + + private Result addStandbyTopic(TopicDO activeTopicDO, ClusterDO activeClusterDO, ClusterDO standbyClusterDO, String operator){ + // 获取主Topic配置信息 + Properties activeTopicProps = TopicCommands.fetchTopicConfig(activeClusterDO, activeTopicDO.getTopicName()); + if (activeTopicProps == null){ + return Result.buildFromRSAndMsg(ResultStatus.FAIL, "创建备Topic时,获取主Topic配置失败"); + } + + TopicDO newTopicDO = new TopicDO( + activeTopicDO.getAppId(), + standbyClusterDO.getId(), + activeTopicDO.getTopicName(), + activeTopicDO.getDescription(), + TopicCreationConstant.DEFAULT_QUOTA + ); + TopicMetadata topicMetadata = PhysicalClusterMetadataManager.getTopicMetadata(activeClusterDO.getId(), activeTopicDO.getTopicName()); + + ResultStatus rs = adminService.createTopic(standbyClusterDO, + newTopicDO, + topicMetadata.getPartitionNum(), + topicMetadata.getReplicaNum(), + null, + PhysicalClusterMetadataManager.getBrokerIdList(standbyClusterDO.getId()), + activeTopicProps, + operator, + operator + ); + + if (ResultStatus.SUCCESS.equals(rs)) { + LOGGER.error( + "method=createHA||activeClusterPhyId={}||standbyClusterPhyId={}||activeTopicDO={}||result={}||msg=create haTopic create topic failed.", + activeClusterDO.getId(), standbyClusterDO.getId(), activeTopicDO, rs + ); + return Result.buildFromRSAndMsg(rs, String.format("创建备Topic失败,原因:%s", rs.getMessage())); + } + + return Result.buildSuc(); + } + + @Override + public Result activeHAInKafka(ClusterDO activeClusterDO, String activeTopicName, ClusterDO standbyClusterDO, String standbyTopicName, String operator) { + if (!PhysicalClusterMetadataManager.isTopicExist(activeClusterDO.getId(), activeTopicName)) { + // 主Topic不存在 + return Result.buildFrom(ResultStatus.TOPIC_NOT_EXIST); + } + if (!PhysicalClusterMetadataManager.isTopicExist(standbyClusterDO.getId(), standbyTopicName)) { + // 备Topic不存在 + return Result.buildFrom(ResultStatus.TOPIC_NOT_EXIST); + } + + return this.activeTopicHAConfigInKafka(activeClusterDO, activeTopicName, standbyClusterDO, standbyTopicName); + } + + @Override + public Result activeHAInKafkaNotCheck(ClusterDO activeClusterDO, String activeTopicName, ClusterDO standbyClusterDO, String standbyTopicName, String operator) { + //更新开启topic高可用配置,并将备集群的配置信息指向主集群 + Result rv = activeTopicHAConfigInKafka(activeClusterDO, activeTopicName, standbyClusterDO, standbyTopicName); + if (rv.failed()){ + return rv; + } + return Result.buildSuc(); + } + + @Override + @Transactional + public Result deleteHA(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName, String operator) { + ClusterDO activeClusterDO = clusterService.getById(activeClusterPhyId); + if (activeClusterDO == null){ + return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, "主集群不存在"); + } + + ClusterDO standbyClusterDO = clusterService.getById(standbyClusterPhyId); + if (standbyClusterDO == null){ + return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, "备集群不存在"); + } + + HaASRelationDO relationDO = haASRelationService.getHAFromDB( + activeClusterPhyId, + topicName, + HaResTypeEnum.TOPIC + ); + if (relationDO == null) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "主备关系不存在"); + } + if (!relationDO.getStatus().equals(HaStatusEnum.STABLE_CODE)) { + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FORBIDDEN, "主备切换中,不允许解绑"); + } + + // 删除高可用配置信息 + Result rv = this.stopHAInKafka(standbyClusterDO, topicName, operator); + if(rv.failed()){ + return rv; + } + + rv = haASRelationService.deleteById(relationDO.getId()); + if(rv.failed()){ + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return rv; + } + + return rv; + } + + @Override + public Result stopHAInKafka(ClusterDO standbyClusterDO, String standbyTopicName, String operator) { + //删除副集群同步主集群topic配置 + ResultStatus rs = HaTopicCommands.deleteHaTopicConfig( + standbyClusterDO, + standbyTopicName, + Arrays.asList(KafkaConstant.DIDI_HA_SYNC_TOPIC_CONFIGS_ENABLED, KafkaConstant.DIDI_HA_REMOTE_CLUSTER) + ); + if (!ResultStatus.SUCCESS.equals(rs)) { + LOGGER.error( + "method=deleteHAInKafka||standbyClusterId={}||standbyTopicName={}||rs={}||msg=delete topic ha failed.", + standbyClusterDO.getId(), standbyTopicName, rs + ); + return Result.buildFromRSAndMsg(rs, "delete topic ha failed"); + } + + return Result.buildSuc(); + } + + @Override + public Map getRelation(Long clusterId) { + Map relationMap = new HashMap<>(); + List relationDOS = haASRelationService.listAllHAFromDB(clusterId, HaResTypeEnum.TOPIC); + if (relationDOS.isEmpty()){ + return relationMap; + } + + //主topic + List activeTopics = relationDOS.stream().filter(haASRelationDO -> haASRelationDO.getActiveClusterPhyId().equals(clusterId)).map(HaASRelationDO::getActiveResName).collect(Collectors.toList()); + activeTopics.stream().forEach(topicName -> relationMap.put(topicName, HaRelationTypeEnum.ACTIVE.getCode())); + + //备topic + List standbyTopics = relationDOS.stream().filter(haASRelationDO -> haASRelationDO.getStandbyClusterPhyId().equals(clusterId)).map(HaASRelationDO::getStandbyResName).collect(Collectors.toList()); + standbyTopics.stream().forEach(topicName -> relationMap.put(topicName, HaRelationTypeEnum.STANDBY.getCode())); + + //互备 + relationMap.put(KafkaConstant.COORDINATOR_TOPIC_NAME, HaRelationTypeEnum.MUTUAL_BACKUP.getCode()); + + return relationMap; + } + + @Override + public Map> getClusterStandbyTopicMap() { + Map> clusterStandbyTopicMap = new HashMap<>(); + List relationDOS = haASRelationService.listAllHAFromDB(HaResTypeEnum.TOPIC); + if (relationDOS.isEmpty()){ + return clusterStandbyTopicMap; + } + return relationDOS.stream().collect(Collectors.groupingBy(HaASRelationDO::getStandbyClusterPhyId, Collectors.mapping(HaASRelationDO::getStandbyResName, Collectors.toList()))); + } + + @Override + public Result activeUserHAInKafka(ClusterDO activeClusterDO, ClusterDO standbyClusterDO, String kafkaUser, String operator) { + Result rv; + rv = haKafkaUserService.activeHAInKafka(activeClusterDO.getZookeeper(), activeClusterDO.getId(), kafkaUser); + if (rv.failed()) { + return rv; + } + + rv = haKafkaUserService.activeHAInKafka(standbyClusterDO.getZookeeper(), activeClusterDO.getId(), kafkaUser); + if (rv.failed()) { + return rv; + } + + rv = haKafkaUserService.activeHAInKafka(configUtils.getDKafkaGatewayZK(), activeClusterDO.getId(), kafkaUser); + if (rv.failed()) { + return rv; + } + return rv; + } + + @Override + public Result getStandbyTopicFetchLag(Long standbyClusterPhyId, String topicName) { + TopicMetadata metadata = PhysicalClusterMetadataManager.getTopicMetadata(standbyClusterPhyId, topicName); + if (metadata == null) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, MsgConstant.getTopicNotExist(standbyClusterPhyId, topicName)); + } + + List partitionIdList = new ArrayList<>(metadata.getPartitionMap().getPartitions().keySet()); + + List partitionStateList = KafkaZookeeperUtils.getTopicPartitionState( + PhysicalClusterMetadataManager.getZKConfig(standbyClusterPhyId), + topicName, + partitionIdList + ); + + if (partitionStateList.size() != partitionIdList.size()) { + return Result.buildFromRSAndMsg(ResultStatus.ZOOKEEPER_READ_FAILED, "读取ZK的分区元信息失败"); + } + + Long sumLag = 0L; + for (Integer leaderBrokerId: partitionStateList.stream().map(elem -> elem.getLeader()).collect(Collectors.toSet())) { + JmxConnectorWrap jmxConnectorWrap = PhysicalClusterMetadataManager.getJmxConnectorWrap(standbyClusterPhyId, leaderBrokerId); + if (jmxConnectorWrap == null || !jmxConnectorWrap.checkJmxConnectionAndInitIfNeed()) { + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FAILED, String.format("获取BrokerId=%d的jmx客户端失败", leaderBrokerId)); + } + + + try { + ObjectName objectName = new ObjectName( + "kafka.server:type=FetcherLagMetrics,name=ConsumerLag,clientId=MirrorFetcherThread-*" + "-" + standbyClusterPhyId + "*" + ",topic=" + topicName + ",partition=*" + ); + + Set objectNameSet = jmxConnectorWrap.queryNames(objectName, null); + for (ObjectName name: objectNameSet) { + List attributeList = jmxConnectorWrap.getAttributes(name, JmxAttributeEnum.VALUE_ATTRIBUTE.getAttribute()).asList(); + for (Attribute attribute: attributeList) { + sumLag += Long.valueOf(attribute.getValue().toString()); + } + } + } catch (Exception e) { + LOGGER.error( + "class=HaTopicServiceImpl||method=getStandbyTopicFetchLag||standbyClusterPhyId={}||topicName={}||leaderBrokerId={}||errMsg=exception.", + standbyClusterPhyId, topicName, leaderBrokerId, e + ); + + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FAILED, e.getMessage()); + } + } + + return Result.buildSuc(sumLag); + } + + /**************************************************** private method ****************************************************/ + + private Result activeTopicHAConfigInKafka(ClusterDO activeClusterDO, String activeTopicName, ClusterDO standbyClusterDO, String standbyTopicName) { + //更新ha-topic配置 + Properties standbyTopicProps = new Properties(); + standbyTopicProps.put(KafkaConstant.DIDI_HA_SYNC_TOPIC_CONFIGS_ENABLED, Boolean.TRUE.toString()); + standbyTopicProps.put(KafkaConstant.DIDI_HA_REMOTE_CLUSTER, activeClusterDO.getId().toString()); + if (!activeTopicName.equals(standbyTopicName)) { + standbyTopicProps.put(KafkaConstant.DIDI_HA_REMOTE_TOPIC, activeTopicName); + } + ResultStatus rs = HaTopicCommands.modifyHaTopicConfig(standbyClusterDO, standbyTopicName, standbyTopicProps); + if (!ResultStatus.SUCCESS.equals(rs)) { + LOGGER.error( + "method=createHAInKafka||activeClusterId={}||activeTopicName={}||standbyClusterId={}||standbyTopicName={}||rs={}||msg=create topic ha failed.", + activeClusterDO.getId(), activeTopicName, standbyClusterDO.getId(), standbyTopicName, rs + ); + return Result.buildFromRSAndMsg(rs, "modify ha topic config failed"); + } + + return Result.buildSuc(); + } + + public Result addStandbyTopicAuthorityAndQuota(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName) { + List authorityDOS = authorityService.getAuthorityByTopic(activeClusterPhyId, topicName); + try { + for (AuthorityDO authorityDO : authorityDOS) { + //权限 + AuthorityDO newAuthorityDO = new AuthorityDO(); + newAuthorityDO.setAppId(authorityDO.getAppId()); + newAuthorityDO.setClusterId(standbyClusterPhyId); + newAuthorityDO.setTopicName(topicName); + newAuthorityDO.setAccess(authorityDO.getAccess()); + + //quota + TopicQuota activeTopicQuotaDO = quotaService.getQuotaFromZk( + activeClusterPhyId, + topicName, + authorityDO.getAppId() + ); + + TopicQuota standbyTopicQuotaDO = new TopicQuota(); + standbyTopicQuotaDO.setTopicName(topicName); + standbyTopicQuotaDO.setAppId(activeTopicQuotaDO.getAppId()); + standbyTopicQuotaDO.setClusterId(standbyClusterPhyId); + standbyTopicQuotaDO.setConsumeQuota(activeTopicQuotaDO.getConsumeQuota()); + standbyTopicQuotaDO.setProduceQuota(activeTopicQuotaDO.getProduceQuota()); + + int result = authorityService.addAuthorityAndQuota(newAuthorityDO, standbyTopicQuotaDO); + if (Constant.INVALID_CODE == result){ + return Result.buildFrom(ResultStatus.OPERATION_FAILED); + } + } + } catch (Exception e) { + LOGGER.error( + "method=addStandbyTopicAuthorityAndQuota||activeClusterPhyId={}||standbyClusterPhyId={}||topicName={}||errMsg=exception.", + activeClusterPhyId, standbyClusterPhyId, topicName, e + ); + + return Result.buildFailure("备Topic复制主Topic权限及配额失败"); + } + + return Result.buildSuc(); + } + + private Result checkHaTopicAndGetBizInfo(Long activeClusterPhyId, Long standbyClusterPhyId, String topicName){ + if (PhysicalClusterMetadataManager.isTopicExist(standbyClusterPhyId, topicName)) { + return Result.buildFromRSAndMsg(ResultStatus.TOPIC_ALREADY_EXIST, "备集群已存在该Topic,请先删除,再行绑定!"); + } + + if (!PhysicalClusterMetadataManager.isTopicExist(activeClusterPhyId, topicName)) { + return Result.buildFromRSAndMsg(ResultStatus.TOPIC_NOT_EXIST, "主集群不存在该Topic"); + } + + TopicDO topicDO = topicManagerService.getByTopicName(activeClusterPhyId, topicName); + if (ValidateUtils.isNull(topicDO)) { + return Result.buildFromRSAndMsg(ResultStatus.RESOURCE_NOT_EXIST, "主集群Topic所属KafkaUser信息不存在"); + } + + return Result.buildSuc(topicDO); + } + + private Result modifyHaConfig(ClusterDO activeClusterDO, String activeTopic, ClusterDO standbyClusterDO, String standbyTopic, String operator){ + //更新副集群同步主集群topic配置 + Result rv = activeHAInKafkaNotCheck(activeClusterDO, activeTopic, standbyClusterDO, standbyTopic, operator); + if (rv.failed()){ + LOGGER.error("method=createHA||activeTopic:{} standbyTopic:{}||msg=create haTopic modify standby topic config failed!.", activeTopic, standbyTopic); + return Result.buildFailure("modify standby topic config failed,please try again"); + } + + //更新user配置,通知用户指向主集群 + Set relatedKafkaUserSet = authorityService.getAuthorityByTopic(activeClusterDO.getId(), activeTopic) + .stream() + .map(elem -> elem.getAppId()) + .collect(Collectors.toSet()); + for(String kafkaUser: relatedKafkaUserSet) { + rv = this.activeUserHAInKafka(activeClusterDO, standbyClusterDO, kafkaUser, operator); + if (rv.failed()) { + return rv; + } + } + return Result.buildSuc(); + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/AdminServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/AdminServiceImpl.java index 594f1aa1..b2eca0d6 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/AdminServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/AdminServiceImpl.java @@ -2,21 +2,27 @@ package com.xiaojukeji.kafka.manager.service.service.impl; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; -import com.xiaojukeji.kafka.manager.common.bizenum.*; -import com.xiaojukeji.kafka.manager.common.entity.pojo.OperateRecordDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; -import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; +import com.xiaojukeji.kafka.manager.common.bizenum.ModuleEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.OperateEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskStatusEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TopicAuthorityEnum; import com.xiaojukeji.kafka.manager.common.constant.Constant; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.OperateRecordDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.zookeeper.ZkConfigImpl; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.BrokerMetadata; -import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.*; import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; import com.xiaojukeji.kafka.manager.service.utils.KafkaZookeeperUtils; import com.xiaojukeji.kafka.manager.service.utils.TopicCommands; import kafka.admin.AdminOperationException; @@ -55,6 +61,12 @@ public class AdminServiceImpl implements AdminService { @Autowired private AuthorityService authorityService; + @Autowired + private HaTopicService haTopicService; + + @Autowired + private HaASRelationManager haASRelationManager; + @Autowired private OperateRecordService operateRecordService; @@ -123,15 +135,22 @@ public class AdminServiceImpl implements AdminService { } @Override - public ResultStatus deleteTopic(ClusterDO clusterDO, - String topicName, - String operator) { - // 1. 集群中删除topic + public ResultStatus deleteTopic(ClusterDO clusterDO, String topicName, String operator) { + // 1. 若存在高可用topic,先解除高可用关系才能删除topic + HaASRelationDO haASRelationDO = haASRelationManager.getASRelation(clusterDO.getId(), topicName); + if (haASRelationDO != null){ + //高可用topic不允许删除 + if (haASRelationDO.getStandbyClusterPhyId().equals(clusterDO.getId())){ + return ResultStatus.HA_TOPIC_DELETE_FORBIDDEN; + } + } + + // 2. 集群中删除topic ResultStatus rs = TopicCommands.deleteTopic(clusterDO, topicName); if (!ResultStatus.SUCCESS.equals(rs)) { return rs; } - // 2. 记录操作 + // 3. 记录操作 Map content = new HashMap<>(2); content.put("clusterId", clusterDO.getId()); content.put("topicName", topicName); @@ -144,12 +163,13 @@ public class AdminServiceImpl implements AdminService { operateRecordDO.setOperator(operator); operateRecordService.insert(operateRecordDO); - // 3. 数据库中删除topic + // 4. 数据库中删除topic topicManagerService.deleteByTopicName(clusterDO.getId(), topicName); topicExpiredService.deleteByTopicName(clusterDO.getId(), topicName); - // 4. 数据库中删除authority + // 5. 数据库中删除authority authorityService.deleteAuthorityByTopic(clusterDO.getId(), topicName); + return rs; } @@ -346,7 +366,6 @@ public class AdminServiceImpl implements AdminService { @Override public ResultStatus modifyTopicConfig(ClusterDO clusterDO, String topicName, Properties properties, String operator) { - ResultStatus rs = TopicCommands.modifyTopicConfig(clusterDO, topicName, properties); - return rs; + return TopicCommands.modifyTopicConfig(clusterDO, topicName, properties); } } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ClusterServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ClusterServiceImpl.java index 153576c4..314130ff 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ClusterServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ClusterServiceImpl.java @@ -3,13 +3,16 @@ package com.xiaojukeji.kafka.manager.service.service.impl; import com.xiaojukeji.kafka.manager.common.bizenum.DBStatusEnum; import com.xiaojukeji.kafka.manager.common.bizenum.ModuleEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperateEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.ClusterDetailDTO; import com.xiaojukeji.kafka.manager.common.entity.ao.cluster.ControllerPreferredCandidate; +import com.xiaojukeji.kafka.manager.common.entity.pojo.*; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.entity.vo.normal.cluster.ClusterNameDTO; import com.xiaojukeji.kafka.manager.common.utils.ListUtils; -import com.xiaojukeji.kafka.manager.common.entity.pojo.*; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.BrokerMetadata; import com.xiaojukeji.kafka.manager.dao.ClusterDao; @@ -18,15 +21,16 @@ import com.xiaojukeji.kafka.manager.dao.ControllerDao; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.*; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaClusterService; import com.xiaojukeji.kafka.manager.service.utils.ConfigUtils; -import org.apache.zookeeper.WatchedEvent; -import org.apache.zookeeper.Watcher; import org.apache.zookeeper.ZooKeeper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.*; @@ -42,6 +46,9 @@ public class ClusterServiceImpl implements ClusterService { @Autowired private ClusterDao clusterDao; + @Autowired + private HaClusterService haClusterService; + @Autowired private ClusterMetricsDao clusterMetricsDao; @@ -69,6 +76,9 @@ public class ClusterServiceImpl implements ClusterService { @Autowired private OperateRecordService operateRecordService; + @Autowired + private HaASRelationService haASRelationService; + @Override public ResultStatus addNew(ClusterDO clusterDO, String operator) { if (ValidateUtils.isNull(clusterDO) || ValidateUtils.isNull(operator)) { @@ -96,6 +106,7 @@ public class ClusterServiceImpl implements ClusterService { LOGGER.error("add new cluster failed, operate mysql failed, clusterDO:{}.", clusterDO, e); return ResultStatus.MYSQL_ERROR; } + physicalClusterMetadataManager.addNew(clusterDO); return ResultStatus.SUCCESS; } @@ -253,9 +264,11 @@ public class ClusterServiceImpl implements ClusterService { Map consumerGroupNumMap = needDetail? consumerService.getConsumerGroupNumMap(doList): new HashMap<>(0); + Map haRelationMap = haClusterService.getClusterHARelation(); List dtoList = new ArrayList<>(); for (ClusterDO clusterDO: doList) { ClusterDetailDTO dto = getClusterDetailDTO(clusterDO, needDetail); + dto.setHaRelation(haRelationMap.get(clusterDO.getId())); dto.setConsumerGroupNum(consumerGroupNumMap.get(clusterDO.getId())); dto.setRegionNum(regionNumMap.get(clusterDO.getId())); dtoList.add(dto); @@ -281,10 +294,11 @@ public class ClusterServiceImpl implements ClusterService { } @Override - public ResultStatus deleteById(Long clusterId, String operator) { + @Transactional + public Result deleteById(Long clusterId, String operator) { List regionDOList = regionService.getByClusterId(clusterId); if (!ValidateUtils.isEmptyList(regionDOList)) { - return ResultStatus.OPERATION_FORBIDDEN; + return Result.buildFrom(ResultStatus.OPERATION_FORBIDDEN); } try { Map content = new HashMap<>(); @@ -292,13 +306,14 @@ public class ClusterServiceImpl implements ClusterService { operateRecordService.insert(operator, ModuleEnum.CLUSTER, String.valueOf(clusterId), OperateEnum.DELETE, content); if (clusterDao.deleteById(clusterId) <= 0) { LOGGER.error("delete cluster failed, clusterId:{}.", clusterId); - return ResultStatus.MYSQL_ERROR; + return Result.buildFrom(ResultStatus.MYSQL_ERROR); } } catch (Exception e) { LOGGER.error("delete cluster failed, clusterId:{}.", clusterId, e); - return ResultStatus.MYSQL_ERROR; + return Result.buildFrom(ResultStatus.MYSQL_ERROR); } - return ResultStatus.SUCCESS; + + return Result.buildSuc(); } private ClusterDetailDTO getClusterDetailDTO(ClusterDO clusterDO, Boolean needDetail) { @@ -318,6 +333,21 @@ public class ClusterServiceImpl implements ClusterService { dto.setStatus(clusterDO.getStatus()); dto.setGmtCreate(clusterDO.getGmtCreate()); dto.setGmtModify(clusterDO.getGmtModify()); + + List haASRelationDOS = haASRelationService + .listAllHAFromDB(clusterDO.getId(), HaResTypeEnum.CLUSTER); + if (!haASRelationDOS.isEmpty()){ + ClusterDO mbCluster; + if (haASRelationDOS.get(0).getActiveClusterPhyId().equals(clusterDO.getId())){ + dto.setHaRelation(HaRelationTypeEnum.ACTIVE.getCode()); + mbCluster = PhysicalClusterMetadataManager.getClusterFromCache(haASRelationDOS.get(0).getStandbyClusterPhyId()); + }else { + dto.setHaRelation(HaRelationTypeEnum.STANDBY.getCode()); + mbCluster = PhysicalClusterMetadataManager.getClusterFromCache(haASRelationDOS.get(0).getActiveClusterPhyId()); + } + dto.setMutualBackupClusterName(mbCluster != null ? mbCluster.getClusterName() : null); + } + if (ValidateUtils.isNull(needDetail) || !needDetail) { return dto; } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/JobLogServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/JobLogServiceImpl.java new file mode 100644 index 00000000..b47a049c --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/JobLogServiceImpl.java @@ -0,0 +1,42 @@ +package com.xiaojukeji.kafka.manager.service.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; +import com.xiaojukeji.kafka.manager.dao.ha.JobLogDao; +import com.xiaojukeji.kafka.manager.service.service.JobLogService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + + +@Service +public class JobLogServiceImpl implements JobLogService { + private static final Logger LOGGER = LoggerFactory.getLogger(JobLogServiceImpl.class); + + @Autowired + private JobLogDao jobLogDao; + + @Override + public void addLogAndIgnoreException(JobLogDO jobLogDO) { + try { + jobLogDao.insert(jobLogDO); + } catch (Exception e) { + LOGGER.error("method=addLogAndIgnoreException||jobLogDO={}||errMsg=exception", jobLogDO); + } + } + + @Override + public List listLogs(Integer bizType, String bizKeyword, Long startId) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(JobLogDO::getBizType, bizType); + lambdaQueryWrapper.eq(JobLogDO::getBizKeyword, bizKeyword); + if (startId != null) { + lambdaQueryWrapper.ge(JobLogDO::getId, startId); + } + + return jobLogDao.selectList(lambdaQueryWrapper); + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/LogicalClusterServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/LogicalClusterServiceImpl.java index 9a6f40be..47396ee8 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/LogicalClusterServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/LogicalClusterServiceImpl.java @@ -5,19 +5,20 @@ import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.cluster.LogicalCluster; import com.xiaojukeji.kafka.manager.common.entity.ao.cluster.LogicalClusterMetrics; import com.xiaojukeji.kafka.manager.common.entity.metrics.BrokerMetrics; +import com.xiaojukeji.kafka.manager.common.entity.pojo.BrokerMetricsDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; import com.xiaojukeji.kafka.manager.common.utils.ListUtils; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.BrokerMetadata; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; import com.xiaojukeji.kafka.manager.dao.LogicalClusterDao; -import com.xiaojukeji.kafka.manager.common.entity.pojo.BrokerMetricsDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; -import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.BrokerService; import com.xiaojukeji.kafka.manager.service.service.LogicalClusterService; +import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaClusterService; import com.xiaojukeji.kafka.manager.service.utils.MetricsConvertUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,6 +46,9 @@ public class LogicalClusterServiceImpl implements LogicalClusterService { @Autowired private AppService appService; + @Autowired + private HaClusterService haClusterService; + @Autowired private LogicalClusterMetadataManager logicClusterMetadataManager; diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicManagerServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicManagerServiceImpl.java index a30599f8..bc4112d1 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicManagerServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicManagerServiceImpl.java @@ -4,38 +4,41 @@ import com.xiaojukeji.kafka.manager.common.bizenum.KafkaClientEnum; import com.xiaojukeji.kafka.manager.common.bizenum.ModuleEnum; import com.xiaojukeji.kafka.manager.common.bizenum.OperateEnum; import com.xiaojukeji.kafka.manager.common.bizenum.TopicAuthorityEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; import com.xiaojukeji.kafka.manager.common.constant.KafkaMetricsCollections; import com.xiaojukeji.kafka.manager.common.constant.TopicCreationConstant; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.TopicOperationResult; import com.xiaojukeji.kafka.manager.common.entity.ao.RdTopicBasic; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.MineTopicSummary; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicAppData; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicBusinessInfo; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicExpansionDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicModificationDTO; import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicMetrics; +import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicThrottledMetrics; +import com.xiaojukeji.kafka.manager.common.entity.pojo.*; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; -import com.xiaojukeji.kafka.manager.common.utils.DateUtils; -import com.xiaojukeji.kafka.manager.common.utils.JsonUtils; -import com.xiaojukeji.kafka.manager.common.utils.NumberUtils; -import com.xiaojukeji.kafka.manager.common.utils.SpringTool; -import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.common.utils.*; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.config.TopicQuotaData; import com.xiaojukeji.kafka.manager.dao.TopicDao; import com.xiaojukeji.kafka.manager.dao.TopicExpiredDao; import com.xiaojukeji.kafka.manager.dao.TopicStatisticsDao; -import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicThrottledMetrics; -import com.xiaojukeji.kafka.manager.common.entity.pojo.*; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.KafkaMetricsCache; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.*; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; import com.xiaojukeji.kafka.manager.service.utils.KafkaZookeeperUtils; +import com.xiaojukeji.kafka.manager.service.utils.TopicCommands; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -87,6 +90,15 @@ public class TopicManagerServiceImpl implements TopicManagerService { @Autowired private OperateRecordService operateRecordService; + @Autowired + private HaTopicService haTopicService; + + @Autowired + private AdminService adminService; + + @Autowired + private HaASRelationManager haASRelationManager; + @Override public List listAll() { try { @@ -188,6 +200,7 @@ public class TopicManagerServiceImpl implements TopicManagerService { Map>> appMap = authorityService.getAllAuthority(); // 增加权限信息和App信息 List summaryList = new ArrayList<>(); + Map> clusterStandbyTopicMap = haTopicService.getClusterStandbyTopicMap(); for (AppDO appDO : appDOList) { // 查权限 for (Map subMap : appMap.getOrDefault(appDO.getAppId(), Collections.emptyMap()).values()) { @@ -196,6 +209,11 @@ public class TopicManagerServiceImpl implements TopicManagerService { || TopicAuthorityEnum.DENY.getCode().equals(authorityDO.getAccess())) { continue; } + //过滤备topic + List standbyTopics = clusterStandbyTopicMap.get(authorityDO.getClusterId()); + if (standbyTopics != null && standbyTopics.contains(authorityDO.getTopicName())){ + continue; + } MineTopicSummary mineTopicSummary = convert2MineTopicSummary( appDO, @@ -224,6 +242,7 @@ public class TopicManagerServiceImpl implements TopicManagerService { TopicDO topicDO = topicDao.getByTopicName(mineTopicSummary.getPhysicalClusterId(), mineTopicSummary.getTopicName()); mineTopicSummary.setDescription(topicDO.getDescription()); } + return summaryList; } @@ -302,8 +321,9 @@ public class TopicManagerServiceImpl implements TopicManagerService { } List dtoList = new ArrayList<>(); + Map> clusterStandbyTopicMap = haTopicService.getClusterStandbyTopicMap(); for (ClusterDO clusterDO: clusterDOList) { - dtoList.addAll(getTopics(clusterDO, appMap, topicMap.getOrDefault(clusterDO.getId(), new HashMap<>()))); + dtoList.addAll(getTopics(clusterDO, appMap, topicMap.getOrDefault(clusterDO.getId(), new HashMap<>()),clusterStandbyTopicMap.get(clusterDO.getId()))); } return dtoList; } @@ -311,13 +331,18 @@ public class TopicManagerServiceImpl implements TopicManagerService { private List getTopics(ClusterDO clusterDO, Map appMap, - Map topicMap) { + Map topicMap, + List standbyTopicNames) { List dtoList = new ArrayList<>(); + for (String topicName: PhysicalClusterMetadataManager.getTopicNameList(clusterDO.getId())) { if (topicName.equals(KafkaConstant.COORDINATOR_TOPIC_NAME) || topicName.equals(KafkaConstant.TRANSACTION_TOPIC_NAME)) { continue; } - + //过滤备topic + if (standbyTopicNames != null && standbyTopicNames.contains(topicName)){ + continue; + } LogicalClusterDO logicalClusterDO = logicalClusterMetadataManager.getTopicLogicalCluster( clusterDO.getId(), topicName @@ -590,12 +615,12 @@ public class TopicManagerServiceImpl implements TopicManagerService { TopicDO topicDO = getByTopicName(physicalClusterId, topicName); if (ValidateUtils.isNull(topicDO)) { - return new Result<>(convert2RdTopicBasic(clusterDO, topicName, null, null, regionNameList, properties)); + return new Result<>(convert2RdTopicBasic(clusterDO, topicName, null, null, regionNameList, properties, HaRelationTypeEnum.UNKNOWN.getCode())); } AppDO appDO = appService.getByAppId(topicDO.getAppId()); - - return new Result<>(convert2RdTopicBasic(clusterDO, topicName, topicDO, appDO, regionNameList, properties)); + Integer haRelation = haASRelationManager.getRelation(physicalClusterId, topicName); + return new Result<>(convert2RdTopicBasic(clusterDO, topicName, topicDO, appDO, regionNameList, properties, haRelation)); } @Override @@ -656,12 +681,56 @@ public class TopicManagerServiceImpl implements TopicManagerService { return ResultStatus.MYSQL_ERROR; } + @Override + public Result modifyTopic(TopicModificationDTO dto) { + ClusterDO clusterDO = clusterService.getById(dto.getClusterId()); + if (ValidateUtils.isNull(clusterDO)) { + return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); + } + + // 获取属性 + Properties properties = dto.getProperties(); + if (ValidateUtils.isNull(properties)) { + properties = new Properties(); + } + properties.put(KafkaConstant.RETENTION_MS_KEY, String.valueOf(dto.getRetentionTime())); + + // 操作修改 + String operator = SpringTool.getUserName(); + ResultStatus rs = TopicCommands.modifyTopicConfig(clusterDO, dto.getTopicName(), properties); + if (!ResultStatus.SUCCESS.equals(rs)) { + return Result.buildFrom(rs); + } + modifyTopicByOp(dto.getClusterId(), dto.getTopicName(), dto.getAppId(), dto.getDescription(), operator); + return Result.buildSuc(); + } + + @Override + public TopicOperationResult expandTopic(TopicExpansionDTO dto) { + ClusterDO clusterDO = clusterService.getById(dto.getClusterId()); + if (ValidateUtils.isNull(clusterDO)) { + return TopicOperationResult.buildFrom(dto.getClusterId(), dto.getTopicName(), ResultStatus.CLUSTER_NOT_EXIST); + } + + // 参数检查合法, 开始对Topic进行扩分区 + ResultStatus statusEnum = adminService.expandPartitions( + clusterDO, + dto.getTopicName(), + dto.getPartitionNum(), + dto.getRegionId(), + dto.getBrokerIdList(), + SpringTool.getUserName() + ); + return TopicOperationResult.buildFrom(dto.getClusterId(), dto.getTopicName(), statusEnum); + } + private RdTopicBasic convert2RdTopicBasic(ClusterDO clusterDO, String topicName, TopicDO topicDO, AppDO appDO, List regionNameList, - Properties properties) { + Properties properties, + Integer haRelation) { RdTopicBasic rdTopicBasic = new RdTopicBasic(); rdTopicBasic.setClusterId(clusterDO.getId()); rdTopicBasic.setClusterName(clusterDO.getClusterName()); @@ -676,6 +745,7 @@ public class TopicManagerServiceImpl implements TopicManagerService { rdTopicBasic.setRegionNameList(regionNameList); rdTopicBasic.setProperties(properties); rdTopicBasic.setRetentionTime(KafkaZookeeperUtils.getTopicRetentionTime(properties)); + rdTopicBasic.setHaRelation(haRelation); return rdTopicBasic; } } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicServiceImpl.java index 62d1f4cb..f83c0405 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/TopicServiceImpl.java @@ -1,18 +1,19 @@ package com.xiaojukeji.kafka.manager.service.service.impl; -import com.xiaojukeji.kafka.manager.common.bizenum.TopicOffsetChangedEnum; -import com.xiaojukeji.kafka.manager.common.entity.Result; -import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; -import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; import com.xiaojukeji.kafka.manager.common.bizenum.OffsetPosEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TopicOffsetChangedEnum; import com.xiaojukeji.kafka.manager.common.constant.Constant; import com.xiaojukeji.kafka.manager.common.constant.KafkaMetricsCollections; import com.xiaojukeji.kafka.manager.common.constant.TopicSampleConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.PartitionAttributeDTO; import com.xiaojukeji.kafka.manager.common.entity.ao.PartitionOffsetDTO; import com.xiaojukeji.kafka.manager.common.entity.ao.topic.*; import com.xiaojukeji.kafka.manager.common.entity.dto.normal.TopicDataSampleDTO; import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicMetrics; +import com.xiaojukeji.kafka.manager.common.entity.pojo.*; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.utils.jmx.JmxConstant; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.BrokerMetadata; @@ -22,13 +23,14 @@ import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata import com.xiaojukeji.kafka.manager.dao.TopicAppMetricsDao; import com.xiaojukeji.kafka.manager.dao.TopicMetricsDao; import com.xiaojukeji.kafka.manager.dao.TopicRequestMetricsDao; -import com.xiaojukeji.kafka.manager.common.entity.pojo.*; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.KafkaClientPool; import com.xiaojukeji.kafka.manager.service.cache.KafkaMetricsCache; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.*; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; import com.xiaojukeji.kafka.manager.service.strategy.AbstractHealthScoreStrategy; import com.xiaojukeji.kafka.manager.service.utils.KafkaZookeeperUtils; import com.xiaojukeji.kafka.manager.service.utils.MetricsConvertUtils; @@ -90,6 +92,12 @@ public class TopicServiceImpl implements TopicService { @Autowired private KafkaClientPool kafkaClientPool; + @Autowired + private HaTopicService haTopicService; + + @Autowired + private HaASRelationManager haASRelationManager; + @Override public List getTopicMetricsFromDB(Long clusterId, String topicName, Date startTime, Date endTime) { try { @@ -244,6 +252,9 @@ public class TopicServiceImpl implements TopicService { basicDTO.setTopicCodeC(jmxService.getTopicCodeCValue(clusterId, topicName)); basicDTO.setScore(healthScoreStrategy.calTopicHealthScore(clusterId, topicName)); + + basicDTO.setHaRelation(haASRelationManager.getRelation(clusterId, topicName)); + return basicDTO; } @@ -325,6 +336,11 @@ public class TopicServiceImpl implements TopicService { return jmxService.getTopicMetrics(clusterId, topicName, metricsCode, byAdd); } + @Override + public Map getPartitionOffset(Long clusterPhyId, String topicName, OffsetPosEnum offsetPosEnum) { + return this.getPartitionOffset(clusterService.getById(clusterPhyId), topicName, offsetPosEnum); + } + @Override public Map getPartitionOffset(ClusterDO clusterDO, String topicName, @@ -403,6 +419,7 @@ public class TopicServiceImpl implements TopicService { appDOMap.put(appDO.getAppId(), appDO); } + Map haRelationMap = haTopicService.getRelation(clusterId); List dtoList = new ArrayList<>(); for (String topicName : topicNameList) { TopicMetadata topicMetadata = PhysicalClusterMetadataManager.getTopicMetadata(clusterId, topicName); @@ -417,7 +434,8 @@ public class TopicServiceImpl implements TopicService { logicalClusterMetadataManager.getTopicLogicalCluster(clusterId, topicName), topicMetadata, topicDO, - appDO + appDO, + haRelationMap.get(topicName) ); dtoList.add(overview); } @@ -429,13 +447,15 @@ public class TopicServiceImpl implements TopicService { LogicalClusterDO logicalClusterDO, TopicMetadata topicMetadata, TopicDO topicDO, - AppDO appDO) { + AppDO appDO, + Integer haRelation) { TopicOverview overview = new TopicOverview(); overview.setClusterId(physicalClusterId); overview.setTopicName(topicMetadata.getTopic()); overview.setPartitionNum(topicMetadata.getPartitionNum()); overview.setReplicaNum(topicMetadata.getReplicaNum()); overview.setUpdateTime(topicMetadata.getModifyTime()); + overview.setHaRelation(haRelation); overview.setRetentionTime( PhysicalClusterMetadataManager.getTopicRetentionTime(physicalClusterId, topicMetadata.getTopic()) ); diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ZookeeperServiceImpl.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ZookeeperServiceImpl.java index c4c89513..3ca2259f 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ZookeeperServiceImpl.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/service/impl/ZookeeperServiceImpl.java @@ -17,6 +17,7 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; +import java.util.Properties; /** * @author zengqiao @@ -124,4 +125,43 @@ public class ZookeeperServiceImpl implements ZookeeperService { } return Result.buildFrom(ResultStatus.ZOOKEEPER_DELETE_FAILED); } + + @Override + public Result> getBrokerIds(String zookeeper) { + if (ValidateUtils.isNull(zookeeper)) { + return Result.buildFrom(ResultStatus.PARAM_ILLEGAL); + } + ZkConfigImpl zkConfig = new ZkConfigImpl(zookeeper); + if (ValidateUtils.isNull(zkConfig)) { + return Result.buildFrom(ResultStatus.ZOOKEEPER_CONNECT_FAILED); + } + + try { + if (!zkConfig.checkPathExists(ZkPathUtil.BROKER_IDS_ROOT)) { + return Result.buildSuc(new ArrayList<>()); + } + List brokerIdList = zkConfig.getChildren(ZkPathUtil.BROKER_IDS_ROOT); + if (ValidateUtils.isEmptyList(brokerIdList)) { + return Result.buildSuc(new ArrayList<>()); + } + return Result.buildSuc(ListUtils.string2IntList(ListUtils.strList2String(brokerIdList))); + } catch (Exception e) { + LOGGER.error("class=ZookeeperServiceImpl||method=getBrokerIds||zookeeper={}||errMsg={}", zookeeper, e.getMessage()); + } + return Result.buildFrom(ResultStatus.ZOOKEEPER_READ_FAILED); + } + + @Override + public Long getClusterIdAndNullIfFailed(String zookeeper) { + try { + ZkConfigImpl zkConfig = new ZkConfigImpl(zookeeper); + Properties props = zkConfig.get(ZkPathUtil.CLUSTER_ID_NODE, Properties.class); + + return Long.valueOf(props.getProperty("id")); + } catch (Exception e) { + LOGGER.error("class=ZookeeperServiceImpl||method=getClusterIdAndNullIfFailed||zookeeper={}||errMsg=exception", zookeeper, e); + } + + return null; + } } \ No newline at end of file diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/ConfigUtils.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/ConfigUtils.java index 9ec66c8b..b1945ff4 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/ConfigUtils.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/ConfigUtils.java @@ -20,4 +20,7 @@ public class ConfigUtils { @Value(value = "${spring.profiles.active:dev}") private String kafkaManagerEnv; + + @Value(value = "${d-kafka.gateway-zk:}") + private String dKafkaGatewayZK; } diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaClusterCommands.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaClusterCommands.java new file mode 100644 index 00000000..7eda14aa --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaClusterCommands.java @@ -0,0 +1,112 @@ +package com.xiaojukeji.kafka.manager.service.utils; + +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import kafka.admin.AdminUtils; +import kafka.admin.AdminUtils$; +import kafka.utils.ZkUtils; +import org.apache.kafka.common.security.JaasUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Properties; + + +/** + * @author fengqiongfeng + * @date 21/4/11 + */ +public class HaClusterCommands { + private static final Logger LOGGER = LoggerFactory.getLogger(HaClusterCommands.class); + + private static final String HA_CLUSTERS = "ha-clusters"; + + /** + * 修改HA集群配置 + */ + public static ResultStatus modifyHaClusterConfig(String zookeeper, Long clusterPhyId, Properties modifiedProps) { + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + zookeeper, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + // 获取当前配置 + Properties props = AdminUtils.fetchEntityConfig(zkUtils, HA_CLUSTERS, clusterPhyId.toString()); + + // 补充变更的配置 + props.putAll(modifiedProps); + + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, HA_CLUSTERS, clusterPhyId.toString(), props); + + } catch (Exception e) { + LOGGER.error("method=modifyHaClusterConfig||zookeeper={}||clusterPhyId={}||modifiedProps={}||errMsg=exception", zookeeper, clusterPhyId, modifiedProps, e); + + return ResultStatus.ZOOKEEPER_OPERATE_FAILED; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + return ResultStatus.SUCCESS; + } + + /** + * 获取集群高可用配置 + */ + public static Properties fetchHaClusterConfig(String zookeeper, Long clusterPhyId) { + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + zookeeper, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + // 获取配置 + return AdminUtils.fetchEntityConfig(zkUtils, HA_CLUSTERS, clusterPhyId.toString()); + }catch (Exception e){ + LOGGER.error("method=fetchHaClusterConfig||zookeeper={}||clusterPhyId={}||errMsg=exception", zookeeper, clusterPhyId, e); + + return null; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + } + + /** + * 删除 高可用集群的动态配置 + */ + public static ResultStatus coverHaClusterConfig(String zookeeper, Long clusterPhyId, Properties properties){ + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + zookeeper, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, HA_CLUSTERS, clusterPhyId.toString(), properties); + + return ResultStatus.SUCCESS; + }catch (Exception e){ + LOGGER.error("method=deleteHaClusterConfig||zookeeper={}||clusterPhyId={}||delProps={}||errMsg=exception", zookeeper, clusterPhyId, properties, e); + + return ResultStatus.FAIL; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + } + + private HaClusterCommands() { + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaKafkaUserCommands.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaKafkaUserCommands.java new file mode 100644 index 00000000..eeb43d87 --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaKafkaUserCommands.java @@ -0,0 +1,93 @@ +package com.xiaojukeji.kafka.manager.service.utils; + +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.utils.ListUtils; +import kafka.admin.AdminUtils; +import kafka.admin.AdminUtils$; +import kafka.server.ConfigType; +import kafka.utils.ZkUtils; +import org.apache.kafka.common.security.JaasUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Properties; + + +/** + * @author fengqiongfeng + * @date 21/4/11 + */ +public class HaKafkaUserCommands { + private static final Logger LOGGER = LoggerFactory.getLogger(HaKafkaUserCommands.class); + + /** + * 修改User配置 + */ + public static boolean modifyHaUserConfig(String zookeeper, String kafkaUser, Properties modifiedProps) { + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + zookeeper, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + // 获取当前配置 + Properties props = AdminUtils.fetchEntityConfig(zkUtils, ConfigType.User(), kafkaUser); + + // 补充变更的配置 + props.putAll(modifiedProps); + + // 修改配置, 这里不使用changeUserOrUserClientIdConfig方法的原因是changeUserOrUserClientIdConfig这个方法会进行参数检查 + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, ConfigType.User(), kafkaUser, props); + } catch (Exception e) { + LOGGER.error("method=changeHaUserConfig||zookeeper={}||kafkaUser={}||modifiedProps={}||errMsg=exception", zookeeper, kafkaUser, modifiedProps, e); + return false; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + return true; + } + + /** + * 删除 高可用集群的动态配置 + */ + public static boolean deleteHaUserConfig(String zookeeper, String kafkaUser, List needDeleteConfigNameList){ + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + zookeeper, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + Properties presentProps = AdminUtils.fetchEntityConfig(zkUtils, ConfigType.User(), kafkaUser); + + //删除需要删除的的配置 + for (String configName : needDeleteConfigNameList) { + presentProps.remove(configName); + } + + // 修改配置, 这里不使用changeUserOrUserClientIdConfig方法的原因是changeUserOrUserClientIdConfig这个方法会进行参数检查 + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, ConfigType.User(), kafkaUser, presentProps); + + return true; + }catch (Exception e){ + LOGGER.error("method=deleteHaUserConfig||zookeeper={}||kafkaUser={}||delProps={}||errMsg=exception", zookeeper, kafkaUser, ListUtils.strList2String(needDeleteConfigNameList), e); + + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + + return false; + } + + private HaKafkaUserCommands() { + } +} diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaTopicCommands.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaTopicCommands.java new file mode 100644 index 00000000..19a467eb --- /dev/null +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/HaTopicCommands.java @@ -0,0 +1,136 @@ +package com.xiaojukeji.kafka.manager.service.utils; + +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.utils.ListUtils; +import kafka.admin.AdminOperationException; +import kafka.admin.AdminUtils; +import kafka.admin.AdminUtils$; +import kafka.utils.ZkUtils; +import org.apache.kafka.common.errors.*; +import org.apache.kafka.common.security.JaasUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.collection.JavaConversions; + +import java.util.*; + +/** + * HA-Topic Commands + */ +public class HaTopicCommands { + private static final Logger LOGGER = LoggerFactory.getLogger(HaTopicCommands.class); + + private static final String HA_TOPICS = "ha-topics"; + + /** + * 修改HA配置 + */ + public static ResultStatus modifyHaTopicConfig(ClusterDO clusterDO, String topicName, Properties props) { + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + clusterDO.getZookeeper(), + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, HA_TOPICS, topicName, props); + } catch (AdminOperationException aoe) { + LOGGER.error("method=modifyHaTopicConfig||clusterPhyId={}||topicName={}||props={}||errMsg=exception", clusterDO.getId(), topicName, props, aoe); + return ResultStatus.TOPIC_OPERATION_UNKNOWN_TOPIC_PARTITION; + } catch (InvalidConfigurationException ice) { + LOGGER.error("method=modifyHaTopicConfig||clusterPhyId={}||topicName={}||props={}||errMsg=exception", clusterDO.getId(), topicName, props, ice); + return ResultStatus.TOPIC_OPERATION_TOPIC_CONFIG_ILLEGAL; + } catch (Exception e) { + LOGGER.error("method=modifyHaTopicConfig||clusterPhyId={}||topicName={}||props={}||errMsg=exception", clusterDO.getId(), topicName, props, e); + return ResultStatus.TOPIC_OPERATION_UNKNOWN_ERROR; + } finally { + if (zkUtils != null) { + zkUtils.close(); + } + } + + return ResultStatus.SUCCESS; + } + + /** + * 删除指定HA配置 + */ + public static ResultStatus deleteHaTopicConfig(ClusterDO clusterDO, String topicName, List neeDeleteConfigNameList){ + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + clusterDO.getZookeeper(), + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + // 当前配置 + Properties presentProps = AdminUtils.fetchEntityConfig(zkUtils, HA_TOPICS, topicName); + + //删除需要删除的的配置 + for (String configName : neeDeleteConfigNameList) { + presentProps.remove(configName); + } + + AdminUtils$.MODULE$.kafka$admin$AdminUtils$$changeEntityConfig(zkUtils, HA_TOPICS, topicName, presentProps); + } catch (Exception e){ + LOGGER.error("method=deleteHaTopicConfig||clusterPhyId={}||topicName={}||delProps={}||errMsg=exception", clusterDO.getId(), topicName, ListUtils.strList2String(neeDeleteConfigNameList), e); + return ResultStatus.FAIL; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + return ResultStatus.SUCCESS; + } + + public static Properties fetchHaTopicConfig(ClusterDO clusterDO, String topicName){ + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + clusterDO.getZookeeper(), + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + return AdminUtils.fetchEntityConfig(zkUtils, HA_TOPICS, topicName); + } catch (Exception e){ + LOGGER.error("method=fetchHaTopicConfig||clusterPhyId={}||topicName={}||errMsg=exception", clusterDO.getId(), topicName, e); + return null; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + } + + public static Map fetchAllHaTopicConfig(ClusterDO clusterDO) { + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + clusterDO.getZookeeper(), + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + return JavaConversions.asJavaMap(AdminUtils.fetchAllEntityConfigs(zkUtils, HA_TOPICS)); + } catch (Exception e){ + LOGGER.error("method=fetchAllHaTopicConfig||clusterPhyId={}||errMsg=exception", clusterDO.getId(), e); + return null; + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + } + + private HaTopicCommands() { + } +} \ No newline at end of file diff --git a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/TopicCommands.java b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/TopicCommands.java index 6995eb97..c8d2fc88 100644 --- a/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/TopicCommands.java +++ b/kafka-manager-core/src/main/java/com/xiaojukeji/kafka/manager/service/utils/TopicCommands.java @@ -8,6 +8,7 @@ import kafka.admin.AdminOperationException; import kafka.admin.AdminUtils; import kafka.admin.BrokerMetadata; import kafka.common.TopicAndPartition; +import kafka.server.ConfigType; import kafka.utils.ZkUtils; import org.I0Itec.zkclient.exception.ZkNodeExistsException; import org.apache.kafka.common.errors.*; @@ -27,6 +28,8 @@ import java.util.*; public class TopicCommands { private static final Logger LOGGER = LoggerFactory.getLogger(TopicCommands.class); + private TopicCommands() { + } public static ResultStatus createTopic(ClusterDO clusterDO, String topicName, @@ -51,7 +54,7 @@ public class TopicCommands { replicaNum, randomFixedStartIndex(), -1 - ); + ); // 写ZK AdminUtils.createOrUpdateTopicPartitionAssignmentPathInZK( @@ -129,6 +132,11 @@ public class TopicCommands { Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, JaasUtils.isZkSecurityEnabled() ); + + if(!zkUtils.pathExists(zkUtils.getTopicPath(topicName))){ + return ResultStatus.TOPIC_NOT_EXIST; + } + AdminUtils.changeTopicConfig(zkUtils, topicName, config); } catch (AdminOperationException e) { LOGGER.error("class=TopicCommands||method=modifyTopicConfig||errMsg={}||clusterDO={}||topicName={}||config={}", e.getMessage(), clusterDO, topicName,config, e); @@ -209,6 +217,31 @@ public class TopicCommands { return ResultStatus.SUCCESS; } + /** + * 获取Topic的动态配置 + */ + public static Properties fetchTopicConfig(ClusterDO clusterDO, String topicName){ + ZkUtils zkUtils = null; + try { + zkUtils = ZkUtils.apply( + clusterDO.getZookeeper(), + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + Constant.DEFAULT_SESSION_TIMEOUT_UNIT_MS, + JaasUtils.isZkSecurityEnabled() + ); + + return AdminUtils.fetchEntityConfig(zkUtils, ConfigType.Topic(), topicName); + } catch (Exception e){ + LOGGER.error("get topic config failed, zk:{},topic:{} .err:{}", clusterDO.getZookeeper(), topicName, e); + } finally { + if (null != zkUtils) { + zkUtils.close(); + } + } + + return null; + } + private static Seq convert2BrokerMetadataSeq(List brokerIdList) { List brokerMetadataList = new ArrayList<>(); for (Integer brokerId: brokerIdList) { diff --git a/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/ClusterServiceTest.java b/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/ClusterServiceTest.java index 6210ff1a..68bc334e 100644 --- a/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/ClusterServiceTest.java +++ b/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/ClusterServiceTest.java @@ -22,12 +22,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.testng.Assert; import org.testng.annotations.BeforeMethod; -import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.util.*; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; /** @@ -327,8 +325,8 @@ public class ClusterServiceTest extends BaseTest { @Test(description = "测试删除集群时,该集群下还有region,禁止删除") public void deleteById2OperationForbiddenTest() { when(regionService.getByClusterId(Mockito.anyLong())).thenReturn(Arrays.asList(new RegionDO())); - ResultStatus resultStatus = clusterService.deleteById(1L, "admin"); - Assert.assertEquals(resultStatus.getCode(), ResultStatus.OPERATION_FORBIDDEN.getCode()); + Result result = clusterService.deleteById(1L, "admin"); + Assert.assertEquals(result.successful(), ResultStatus.OPERATION_FORBIDDEN.getCode()); } @Test(description = "测试删除集群成功") @@ -337,18 +335,18 @@ public class ClusterServiceTest extends BaseTest { when(regionService.getByClusterId(Mockito.anyLong())).thenReturn(Collections.emptyList()); Mockito.when(operateRecordService.insert(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(1); Mockito.when(clusterDao.deleteById(Mockito.any())).thenReturn(1); - ResultStatus resultStatus = clusterService.deleteById(clusterDO.getId(), "admin"); - Assert.assertEquals(resultStatus.getCode(), ResultStatus.SUCCESS.getCode()); + Result result = clusterService.deleteById(clusterDO.getId(), "admin"); + Assert.assertEquals(result.successful(), ResultStatus.SUCCESS.getCode()); } @Test(description = "测试MYSQL_ERROR") public void deleteById2MysqlErrorTest() { when(regionService.getByClusterId(Mockito.anyLong())).thenReturn(Collections.emptyList()); - ResultStatus resultStatus = clusterService.deleteById(100L, "admin"); + Result result = clusterService.deleteById(100L, "admin"); Mockito.when(operateRecordService.insert(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(1); Mockito.when(clusterDao.deleteById(Mockito.any())).thenReturn(-1); - Assert.assertEquals(resultStatus.getCode(), ResultStatus.MYSQL_ERROR.getCode()); + Assert.assertEquals(result.successful(), ResultStatus.MYSQL_ERROR.getCode()); } @Test(description = "测试从zk中获取被选举的broker") diff --git a/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/TopicServiceTest.java b/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/TopicServiceTest.java index 712039fc..e9af24ef 100644 --- a/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/TopicServiceTest.java +++ b/kafka-manager-core/src/test/java/com/xiaojukeji/kafka/manager/service/service/TopicServiceTest.java @@ -371,7 +371,7 @@ public class TopicServiceTest extends BaseTest { private void getPartitionOffset2EmptyTest() { ClusterDO clusterDO = getClusterDO(); Map partitionOffset = topicService.getPartitionOffset( - null, null, OffsetPosEnum.BEGINNING); + clusterDO, null, OffsetPosEnum.BEGINNING); Assert.assertTrue(partitionOffset.isEmpty()); Map partitionOffset2 = topicService.getPartitionOffset( diff --git a/kafka-manager-dao/pom.xml b/kafka-manager-dao/pom.xml index 8b30c431..2ba9be40 100644 --- a/kafka-manager-dao/pom.xml +++ b/kafka-manager-dao/pom.xml @@ -33,8 +33,8 @@ - org.mybatis.spring.boot - mybatis-spring-boot-starter + com.baomidou + mybatis-plus-boot-starter mysql diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/AuthorityDao.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/AuthorityDao.java index 655218e9..89860ca2 100644 --- a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/AuthorityDao.java +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/AuthorityDao.java @@ -25,6 +25,7 @@ public interface AuthorityDao { List getAuthority(Long clusterId, String topicName, String appId); List getAuthorityByTopic(Long clusterId, String topicName); + List getAuthorityByTopicFromCache(Long clusterId, String topicName); List getByAppId(String appId); diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/impl/AuthorityDaoImpl.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/impl/AuthorityDaoImpl.java index c7bac9e0..5b2621b0 100644 --- a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/impl/AuthorityDaoImpl.java +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/gateway/impl/AuthorityDaoImpl.java @@ -49,6 +49,28 @@ public class AuthorityDaoImpl implements AuthorityDao { return sqlSession.selectList("AuthorityDao.getAuthorityByTopic", params); } + @Override + public List getAuthorityByTopicFromCache(Long clusterId, String topicName) { + updateAuthorityCache(); + + List doList = new ArrayList<>(); + for (Map> authMap: AUTHORITY_MAP.values()) { + Map doMap = authMap.get(clusterId); + if (doMap == null) { + continue; + } + + AuthorityDO authorityDO = doMap.get(topicName); + if (authorityDO == null) { + continue; + } + + doList.add(authorityDO); + } + + return doList; + } + @Override public List getByAppId(String appId) { updateAuthorityCache(); diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASRelationDao.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASRelationDao.java new file mode 100644 index 00000000..9b8e9565 --- /dev/null +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASRelationDao.java @@ -0,0 +1,12 @@ +package com.xiaojukeji.kafka.manager.dao.ha; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import org.springframework.stereotype.Repository; + +/** + * 主备关系信息 + */ +@Repository +public interface HaASRelationDao extends BaseMapper { +} diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchJobDao.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchJobDao.java new file mode 100644 index 00000000..9aa1b4c4 --- /dev/null +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchJobDao.java @@ -0,0 +1,17 @@ +package com.xiaojukeji.kafka.manager.dao.ha; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchJobDO; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 主备关系切换任务 + */ +@Repository +public interface HaASSwitchJobDao extends BaseMapper { + int addAndSetId(HaASSwitchJobDO jobDO); + + List listAllLatest(); +} diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchSubJobDao.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchSubJobDao.java new file mode 100644 index 00000000..daf76846 --- /dev/null +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/HaASSwitchSubJobDao.java @@ -0,0 +1,12 @@ +package com.xiaojukeji.kafka.manager.dao.ha; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASSwitchSubJobDO; +import org.springframework.stereotype.Repository; + +/** + * 主备关系切换子任务 + */ +@Repository +public interface HaASSwitchSubJobDao extends BaseMapper { +} diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/JobLogDao.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/JobLogDao.java new file mode 100644 index 00000000..8d66e506 --- /dev/null +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/ha/JobLogDao.java @@ -0,0 +1,12 @@ +package com.xiaojukeji.kafka.manager.dao.ha; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; +import org.springframework.stereotype.Repository; + +/** + * Job的Log, 正常来说应该与TopicDao等放在一起的,但是因为使用了mybatis-plus,因此零时放在这个地方 + */ +@Repository +public interface JobLogDao extends BaseMapper { +} diff --git a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/impl/ClusterDaoImpl.java b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/impl/ClusterDaoImpl.java index 0d2ea867..9ebff7c3 100644 --- a/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/impl/ClusterDaoImpl.java +++ b/kafka-manager-dao/src/main/java/com/xiaojukeji/kafka/manager/dao/impl/ClusterDaoImpl.java @@ -23,7 +23,11 @@ public class ClusterDaoImpl implements ClusterDao { @Override public int insert(ClusterDO clusterDO) { - return sqlSession.insert("ClusterDao.insert", clusterDO); + if (clusterDO.getId() != null) { + return sqlSession.insert("ClusterDao.insertWithId", clusterDO); + } else { + return sqlSession.insert("ClusterDao.insert", clusterDO); + } } @Override diff --git a/kafka-manager-dao/src/main/resources/mapper/ClusterDao.xml b/kafka-manager-dao/src/main/resources/mapper/ClusterDao.xml index 53b90293..9fbd0d71 100644 --- a/kafka-manager-dao/src/main/resources/mapper/ClusterDao.xml +++ b/kafka-manager-dao/src/main/resources/mapper/ClusterDao.xml @@ -15,6 +15,14 @@ + + INSERT INTO cluster ( + id, cluster_name, zookeeper, bootstrap_servers, security_properties, jmx_properties + ) VALUES ( + #{id}, #{clusterName}, #{zookeeper}, #{bootstrapServers}, #{securityProperties}, #{jmxProperties} + ) + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchJobDao.xml b/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchJobDao.xml new file mode 100644 index 00000000..c1128be8 --- /dev/null +++ b/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchJobDao.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + INSERT INTO ks_km_physical_cluster + (active_cluster_phy_id, standby_cluster_phy_id, job_status, operator) + VALUES + (#{activeClusterPhyId}, #{standbyClusterPhyId}, #{jobStatus}, #{operator}) + + + + diff --git a/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchSubJobDao.xml b/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchSubJobDao.xml new file mode 100644 index 00000000..a5f60444 --- /dev/null +++ b/kafka-manager-dao/src/main/resources/mapper/HaActiveStandbySwitchSubJobDao.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/kafka-manager-dao/src/main/resources/mapper/JobLogDao.xml b/kafka-manager-dao/src/main/resources/mapper/JobLogDao.xml new file mode 100644 index 00000000..d885884b --- /dev/null +++ b/kafka-manager-dao/src/main/resources/mapper/JobLogDao.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kafka-manager-dao/src/main/resources/mapper/RegionDao.xml b/kafka-manager-dao/src/main/resources/mapper/RegionDao.xml index 3b6ede2c..3c20e8c2 100644 --- a/kafka-manager-dao/src/main/resources/mapper/RegionDao.xml +++ b/kafka-manager-dao/src/main/resources/mapper/RegionDao.xml @@ -16,7 +16,10 @@ - + INSERT INTO region (name, cluster_id, broker_list, status, description) VALUES diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/common/handle/OrderHandleQuotaDTO.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/common/handle/OrderHandleQuotaDTO.java index cbc4b6fb..a168e013 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/common/handle/OrderHandleQuotaDTO.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/common/handle/OrderHandleQuotaDTO.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; import java.util.List; @@ -13,6 +15,8 @@ import java.util.List; */ @JsonIgnoreProperties(ignoreUnknown = true) @ApiModel(description = "Quota工单审批参数") +@NoArgsConstructor +@AllArgsConstructor public class OrderHandleQuotaDTO { @ApiModelProperty(value = "分区数, 非必须") private Integer partitionNum; diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyAuthorityOrder.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyAuthorityOrder.java index 60119352..d18d1c89 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyAuthorityOrder.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyAuthorityOrder.java @@ -3,24 +3,31 @@ package com.xiaojukeji.kafka.manager.bpm.order.impl; import com.alibaba.fastjson.JSONException; import com.alibaba.fastjson.JSONObject; import com.xiaojukeji.kafka.manager.account.AccountService; -import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; -import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; -import com.xiaojukeji.kafka.manager.common.entity.ao.account.Account; import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.OrderExtensionAuthorityDTO; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.AbstractOrderDetailData; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.OrderDetailApplyAuthorityDTO; import com.xiaojukeji.kafka.manager.bpm.common.handle.OrderHandleBaseDTO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; -import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.bpm.order.AbstractAuthorityOrder; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaRelationTypeEnum; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.account.Account; +import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.OrderDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AuthorityDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; -import com.xiaojukeji.kafka.manager.bpm.order.AbstractAuthorityOrder; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.gateway.AuthorityService; -import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -52,6 +59,12 @@ public class ApplyAuthorityOrder extends AbstractAuthorityOrder { @Autowired private TopicManagerService topicManagerService; + @Autowired + private HaTopicService haTopicService; + + @Autowired + private HaASRelationManager haASRelationManager; + @Override public AbstractOrderDetailData getOrderExtensionDetailData(String extensions) { OrderDetailApplyAuthorityDTO orderDetailDTO = new OrderDetailApplyAuthorityDTO(); @@ -116,21 +129,40 @@ public class ApplyAuthorityOrder extends AbstractAuthorityOrder { if (ValidateUtils.isNull(physicalClusterId)) { return ResultStatus.CLUSTER_NOT_EXIST; } - TopicQuota topicQuotaDO = new TopicQuota(); - topicQuotaDO.setAppId(orderExtensionDTO.getAppId()); - topicQuotaDO.setTopicName(orderExtensionDTO.getTopicName()); - topicQuotaDO.setClusterId(physicalClusterId); - AuthorityDO authorityDO = new AuthorityDO(); - authorityDO.setAccess(orderExtensionDTO.getAccess()); - authorityDO.setAppId(orderExtensionDTO.getAppId()); - authorityDO.setTopicName(orderExtensionDTO.getTopicName()); - authorityDO.setClusterId(physicalClusterId); -// authorityDO.setApplicant(orderDO.getApplicant()); + HaASRelationDO relation = haASRelationManager.getASRelation(physicalClusterId, orderExtensionDTO.getTopicName()); - if (authorityService.addAuthorityAndQuota(authorityDO, topicQuotaDO) < 1) { - return ResultStatus.OPERATION_FAILED; + //是否高可用topic + Integer haRelation = HaRelationTypeEnum.UNKNOWN.getCode(); + if (relation != null){ + //用户侧不允许操作备topic + if (relation.getStandbyClusterPhyId().equals(orderExtensionDTO.getClusterId())){ + return ResultStatus.OPERATION_FORBIDDEN; + } + haRelation = HaRelationTypeEnum.ACTIVE.getCode(); } + + ResultStatus resultStatus = applyAuthority(physicalClusterId, + orderExtensionDTO.getTopicName(), + userName, + orderExtensionDTO.getAppId(), + orderExtensionDTO.getAccess(), + haRelation); + if (haRelation.equals(HaRelationTypeEnum.UNKNOWN.getCode()) + && ResultStatus.SUCCESS.getCode() != resultStatus.getCode()){ + return resultStatus; + } + + //给备topic添加权限 + if (relation.getActiveResName().equals(orderExtensionDTO.getTopicName())){ + return applyAuthority(relation.getStandbyClusterPhyId(), + relation.getStandbyResName(), + userName, + orderExtensionDTO.getAppId(), + orderExtensionDTO.getAccess(), + HaRelationTypeEnum.STANDBY.getCode()); + } + return ResultStatus.SUCCESS; } @@ -158,4 +190,39 @@ public class ApplyAuthorityOrder extends AbstractAuthorityOrder { } return approverList; } + + private ResultStatus applyAuthority(Long physicalClusterId, String topicName, String userName, String appId, Integer access, Integer haRelation){ + ClusterDO clusterDO = PhysicalClusterMetadataManager.getClusterFromCache(physicalClusterId); + if (clusterDO == null){ + return ResultStatus.CLUSTER_NOT_EXIST; + } + TopicQuota topicQuotaDO = new TopicQuota(); + topicQuotaDO.setAppId(appId); + topicQuotaDO.setTopicName(topicName); + topicQuotaDO.setClusterId(physicalClusterId); + + AuthorityDO authorityDO = new AuthorityDO(); + authorityDO.setAccess(access); + authorityDO.setAppId(appId); + authorityDO.setTopicName(topicName); + authorityDO.setClusterId(physicalClusterId); + + if (authorityService.addAuthorityAndQuota(authorityDO, topicQuotaDO) < 1) { + return ResultStatus.OPERATION_FAILED; + } + + Result result = new Result(); + HaASRelationDO relation = haASRelationManager.getASRelation(physicalClusterId, topicName); + if (HaRelationTypeEnum.STANDBY.getCode() == haRelation){ + result = haTopicService.activeUserHAInKafka(PhysicalClusterMetadataManager.getClusterFromCache(relation.getActiveClusterPhyId()), + PhysicalClusterMetadataManager.getClusterFromCache(relation.getStandbyClusterPhyId()), + appId, + userName); + } + if (result.failed()){ + return ResultStatus.ZOOKEEPER_OPERATE_FAILED; + } + return ResultStatus.SUCCESS; + } + } diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyPartitionOrder.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyPartitionOrder.java index ae466311..b353587e 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyPartitionOrder.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyPartitionOrder.java @@ -3,6 +3,7 @@ package com.xiaojukeji.kafka.manager.bpm.order.impl; import com.alibaba.fastjson.JSONObject; import com.xiaojukeji.kafka.manager.account.AccountService; import com.xiaojukeji.kafka.manager.bpm.common.OrderTypeEnum; +import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.OrderExtensionQuotaDTO; import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.PartitionOrderExtensionDTO; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.AbstractOrderDetailData; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.PartitionOrderDetailData; @@ -12,16 +13,17 @@ import com.xiaojukeji.kafka.manager.bpm.order.AbstractOrder; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.account.Account; -import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.OrderExtensionQuotaDTO; import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicMetrics; import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.OrderDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.RegionDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.DateUtils; import com.xiaojukeji.kafka.manager.common.utils.ListUtils; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.KafkaMetricsCache; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; @@ -61,6 +63,9 @@ public class ApplyPartitionOrder extends AbstractOrder { @Autowired private RegionService regionService; + @Autowired + private HaASRelationManager haASRelationManager; + @Override public AbstractOrderDetailData getOrderExtensionDetailData(String extensions) { PartitionOrderDetailData detailData = new PartitionOrderDetailData(); @@ -169,28 +174,30 @@ public class ApplyPartitionOrder extends AbstractOrder { if (ValidateUtils.isNull(physicalClusterId)) { return ResultStatus.CLUSTER_NOT_EXIST; } - if (!PhysicalClusterMetadataManager.isTopicExistStrictly(physicalClusterId, extensionDTO.getTopicName())) { - return ResultStatus.TOPIC_NOT_EXIST; - } if (handleDTO.isExistNullParam()) { return ResultStatus.OPERATION_FAILED; } - ClusterDO clusterDO = clusterService.getById(physicalClusterId); - return adminService.expandPartitions( - clusterDO, - extensionDTO.getTopicName(), - handleDTO.getPartitionNum(), - handleDTO.getRegionId(), - handleDTO.getBrokerIdList(), - userName - ); - } - private OrderExtensionQuotaDTO supplyExtension(OrderExtensionQuotaDTO extensionDTO, OrderHandleQuotaDTO handleDTO){ - extensionDTO.setPartitionNum(handleDTO.getPartitionNum()); - extensionDTO.setRegionId(handleDTO.getRegionId()); - extensionDTO.setBrokerIdList(handleDTO.getBrokerIdList()); - return extensionDTO; + //备topic扩分区 + HaASRelationDO relationDO = haASRelationManager.getASRelation(physicalClusterId, extensionDTO.getTopicName()); + if (relationDO != null){ + //用户侧不允许操作备topic + if (relationDO.getStandbyClusterPhyId().equals(extensionDTO.getClusterId())){ + return ResultStatus.OPERATION_FORBIDDEN; + } + ResultStatus rv = apply(relationDO.getStandbyClusterPhyId(), + relationDO.getStandbyResName(), + userName, + handleDTO.getPartitionNum(), + null, + PhysicalClusterMetadataManager.getBrokerIdList(relationDO.getStandbyClusterPhyId())); + if (ResultStatus.SUCCESS.getCode() != rv.getCode()){ + return rv; + } + } + + return apply(physicalClusterId, extensionDTO.getTopicName(), userName, + handleDTO.getPartitionNum(), handleDTO.getRegionId(), handleDTO.getBrokerIdList()); } @Override @@ -206,4 +213,29 @@ public class ApplyPartitionOrder extends AbstractOrder { return accountService.getAdminOrderHandlerFromCache(); } + private ResultStatus apply(Long physicalClusterId, String topicName, String userName, int partitionNum, Long regionId, List brokerIds){ + ClusterDO clusterDO = clusterService.getById(physicalClusterId); + if (clusterDO == null){ + return ResultStatus.CLUSTER_NOT_EXIST; + } + + if (!PhysicalClusterMetadataManager.isTopicExistStrictly(physicalClusterId, topicName)) { + return ResultStatus.TOPIC_NOT_EXIST; + } + return adminService.expandPartitions( + clusterDO, + topicName, + partitionNum, + regionId, + brokerIds, + userName + ); + } + + private OrderExtensionQuotaDTO supplyExtension(OrderExtensionQuotaDTO extensionDTO, OrderHandleQuotaDTO handleDTO){ + extensionDTO.setPartitionNum(handleDTO.getPartitionNum()); + extensionDTO.setRegionId(handleDTO.getRegionId()); + extensionDTO.setBrokerIdList(handleDTO.getBrokerIdList()); + return extensionDTO; + } } \ No newline at end of file diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyQuotaOrder.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyQuotaOrder.java index 7bfc9b64..84dae4eb 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyQuotaOrder.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ApplyQuotaOrder.java @@ -3,39 +3,46 @@ package com.xiaojukeji.kafka.manager.bpm.order.impl; import com.alibaba.fastjson.JSONObject; import com.xiaojukeji.kafka.manager.account.AccountService; import com.xiaojukeji.kafka.manager.bpm.common.OrderTypeEnum; -import com.xiaojukeji.kafka.manager.bpm.order.AbstractOrder; -import com.xiaojukeji.kafka.manager.common.entity.Result; -import com.xiaojukeji.kafka.manager.common.entity.ao.account.Account; -import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; -import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.OrderExtensionQuotaDTO; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.AbstractOrderDetailData; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.QuotaOrderDetailData; import com.xiaojukeji.kafka.manager.bpm.common.handle.OrderHandleBaseDTO; import com.xiaojukeji.kafka.manager.bpm.common.handle.OrderHandleQuotaDTO; +import com.xiaojukeji.kafka.manager.bpm.order.AbstractOrder; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.account.Account; +import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; import com.xiaojukeji.kafka.manager.common.entity.metrics.TopicMetrics; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.LogicalClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.OrderDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.RegionDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.DateUtils; import com.xiaojukeji.kafka.manager.common.utils.ListUtils; import com.xiaojukeji.kafka.manager.common.utils.NumberUtils; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.brokers.TopicMetadata; import com.xiaojukeji.kafka.manager.common.zookeeper.znode.config.TopicQuotaData; -import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; -import com.xiaojukeji.kafka.manager.common.entity.pojo.OrderDO; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.KafkaMetricsCache; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; -import com.xiaojukeji.kafka.manager.service.service.*; +import com.xiaojukeji.kafka.manager.service.service.AdminService; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.RegionService; +import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.gateway.QuotaService; import com.xiaojukeji.kafka.manager.service.utils.KafkaZookeeperUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.util.*; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; import java.util.stream.Collectors; /** @@ -68,6 +75,9 @@ public class ApplyQuotaOrder extends AbstractOrder { @Autowired private RegionService regionService; + @Autowired + private HaASRelationManager haASRelationManager; + @Override public AbstractOrderDetailData getOrderExtensionDetailData(String extensions) { QuotaOrderDetailData orderDetailDTO = new QuotaOrderDetailData(); @@ -198,40 +208,40 @@ public class ApplyQuotaOrder extends AbstractOrder { if (ValidateUtils.isNull(physicalClusterId)) { return ResultStatus.CLUSTER_NOT_EXIST; } - if (!PhysicalClusterMetadataManager.isTopicExistStrictly(physicalClusterId, extensionDTO.getTopicName())) { - return ResultStatus.TOPIC_NOT_EXIST; - } - if (!handleDTO.isExistNullParam()) { - ClusterDO clusterDO = clusterService.getById(physicalClusterId); - ResultStatus resultStatus = adminService.expandPartitions( - clusterDO, - extensionDTO.getTopicName(), - handleDTO.getPartitionNum(), - handleDTO.getRegionId(), - handleDTO.getBrokerIdList(), - userName); - if (!ResultStatus.SUCCESS.equals(resultStatus)) { - return resultStatus; + + //备topic调整quota + HaASRelationDO relationDO = haASRelationManager.getASRelation(physicalClusterId, extensionDTO.getTopicName()); + if (relationDO != null){ + if (relationDO.getStandbyClusterPhyId().equals(physicalClusterId)){ + return ResultStatus.OPERATION_FORBIDDEN; + } + List standbyBrokerIds = PhysicalClusterMetadataManager.getBrokerIdList(relationDO.getStandbyClusterPhyId()); + if(standbyBrokerIds == null || standbyBrokerIds.isEmpty()){ + return ResultStatus.BROKER_NOT_EXIST; + } + OrderExtensionQuotaDTO standbyDto = new OrderExtensionQuotaDTO(); + standbyDto.setClusterId(relationDO.getStandbyClusterPhyId()); + standbyDto.setTopicName(relationDO.getStandbyResName()); + standbyDto.setConsumeQuota(extensionDTO.getConsumeQuota()); + standbyDto.setProduceQuota(extensionDTO.getProduceQuota()); + standbyDto.setAppId(extensionDTO.getAppId()); + + ResultStatus rv = applyQuota(userName, + new OrderHandleQuotaDTO(handleDTO.getPartitionNum(), null, standbyBrokerIds), + standbyDto); + if (ResultStatus.SUCCESS.getCode() != rv.getCode()){ + return rv; } } - TopicQuota topicQuotaDO = new TopicQuota(); - topicQuotaDO.setAppId(extensionDTO.getAppId()); - topicQuotaDO.setTopicName(extensionDTO.getTopicName()); - topicQuotaDO.setConsumeQuota(extensionDTO.getConsumeQuota()); - topicQuotaDO.setProduceQuota(extensionDTO.getProduceQuota()); - topicQuotaDO.setClusterId(physicalClusterId); - if (quotaService.addTopicQuota(topicQuotaDO) > 0) { - orderDO.setExtensions(JSONObject.toJSONString(supplyExtension(extensionDTO, handleDTO))); - return ResultStatus.SUCCESS; - } - return ResultStatus.OPERATION_FAILED; - } - private OrderExtensionQuotaDTO supplyExtension(OrderExtensionQuotaDTO extensionDTO, OrderHandleQuotaDTO handleDTO){ - extensionDTO.setPartitionNum(handleDTO.getPartitionNum()); - extensionDTO.setRegionId(handleDTO.getRegionId()); - extensionDTO.setBrokerIdList(handleDTO.getBrokerIdList()); - return extensionDTO; + extensionDTO.setClusterId(physicalClusterId); + ResultStatus resultStatus = applyQuota(userName, handleDTO, extensionDTO); + if (ResultStatus.SUCCESS.getCode() != resultStatus.getCode()){ + return resultStatus; + } + orderDO.setExtensions(JSONObject.toJSONString(supplyExtension(extensionDTO, handleDTO))); + + return ResultStatus.SUCCESS; } @Override @@ -246,4 +256,43 @@ public class ApplyQuotaOrder extends AbstractOrder { public List getApproverList(String extensions) { return accountService.getAdminOrderHandlerFromCache(); } + + private ResultStatus applyQuota( + String userName, + OrderHandleQuotaDTO handleDTO, + OrderExtensionQuotaDTO dto){ + if (!PhysicalClusterMetadataManager.isTopicExistStrictly(dto.getClusterId(), dto.getTopicName())) { + return ResultStatus.TOPIC_NOT_EXIST; + } + if (!handleDTO.isExistNullParam()) { + ClusterDO clusterDO = clusterService.getById(dto.getClusterId()); + ResultStatus resultStatus = adminService.expandPartitions( + clusterDO, + dto.getTopicName(), + handleDTO.getPartitionNum(), + handleDTO.getRegionId(), + handleDTO.getBrokerIdList(), + userName); + if (!ResultStatus.SUCCESS.equals(resultStatus)) { + return resultStatus; + } + } + TopicQuota topicQuotaDO = new TopicQuota(); + topicQuotaDO.setAppId(dto.getAppId()); + topicQuotaDO.setTopicName(dto.getTopicName()); + topicQuotaDO.setConsumeQuota(dto.getConsumeQuota()); + topicQuotaDO.setProduceQuota(dto.getProduceQuota()); + topicQuotaDO.setClusterId(dto.getClusterId()); + if (quotaService.addTopicQuota(topicQuotaDO) > 0) { + return ResultStatus.SUCCESS; + } + return ResultStatus.OPERATION_FAILED; + } + + private OrderExtensionQuotaDTO supplyExtension(OrderExtensionQuotaDTO extensionDTO, OrderHandleQuotaDTO handleDTO){ + extensionDTO.setPartitionNum(handleDTO.getPartitionNum()); + extensionDTO.setRegionId(handleDTO.getRegionId()); + extensionDTO.setBrokerIdList(handleDTO.getBrokerIdList()); + return extensionDTO; + } } diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/DeleteTopicOrder.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/DeleteTopicOrder.java index 5056e51c..8284aeb8 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/DeleteTopicOrder.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/DeleteTopicOrder.java @@ -2,27 +2,29 @@ package com.xiaojukeji.kafka.manager.bpm.order.impl; import com.alibaba.fastjson.JSONObject; import com.xiaojukeji.kafka.manager.bpm.common.OrderTypeEnum; -import com.xiaojukeji.kafka.manager.common.entity.Result; -import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; -import com.xiaojukeji.kafka.manager.common.constant.Constant; -import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; -import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicConnection; import com.xiaojukeji.kafka.manager.bpm.common.entry.apply.OrderExtensionDeleteTopicDTO; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.AbstractOrderDetailData; import com.xiaojukeji.kafka.manager.bpm.common.entry.detail.OrderDetailDeleteTopicDTO; import com.xiaojukeji.kafka.manager.bpm.common.handle.OrderHandleBaseDTO; -import com.xiaojukeji.kafka.manager.common.entity.vo.normal.cluster.ClusterNameDTO; -import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.bpm.order.AbstractTopicOrder; +import com.xiaojukeji.kafka.manager.common.constant.Constant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.ao.topic.TopicConnection; import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.OrderDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.gateway.AppDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.normal.cluster.ClusterNameDTO; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; -import com.xiaojukeji.kafka.manager.bpm.order.AbstractTopicOrder; import com.xiaojukeji.kafka.manager.service.service.AdminService; -import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.ClusterService; import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; +import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.service.service.gateway.TopicConnectionService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -54,6 +56,9 @@ public class DeleteTopicOrder extends AbstractTopicOrder { @Autowired private TopicConnectionService connectionService; + @Autowired + private HaASRelationManager haASRelationManager; + @Override public AbstractOrderDetailData getOrderExtensionDetailData(String extensions) { OrderDetailDeleteTopicDTO orderDetailDTO = new OrderDetailDeleteTopicDTO(); @@ -128,26 +133,32 @@ public class DeleteTopicOrder extends AbstractTopicOrder { if (ValidateUtils.isNull(physicalClusterId)) { return ResultStatus.CLUSTER_NOT_EXIST; } + + HaASRelationDO relationDO = haASRelationManager.getASRelation(physicalClusterId, extensionDTO.getTopicName()); + if (relationDO != null) { + //高可用topic需要先解除高可用关系才能删除 + return ResultStatus.HA_TOPIC_DELETE_FORBIDDEN; + } + + return delTopic(physicalClusterId, extensionDTO.getTopicName(), userName); + } + + private ResultStatus delTopic(Long physicalClusterId, String topicName, String userName){ ClusterDO clusterDO = clusterService.getById(physicalClusterId); - if (!PhysicalClusterMetadataManager.isTopicExistStrictly(physicalClusterId, extensionDTO.getTopicName())) { + if (!PhysicalClusterMetadataManager.isTopicExistStrictly(physicalClusterId, topicName)) { return ResultStatus.TOPIC_NOT_EXIST; } // 最近topic是否还有生产或者消费操作 if (connectionService.isExistConnection( physicalClusterId, - extensionDTO.getTopicName(), + topicName, new Date(System.currentTimeMillis() - Constant.TOPIC_CONNECTION_LATEST_TIME_MS), new Date()) - ) { + ) { return ResultStatus.OPERATION_FORBIDDEN; } - ResultStatus resultStatus = adminService.deleteTopic(clusterDO, extensionDTO.getTopicName(), userName); - - if (!ResultStatus.SUCCESS.equals(resultStatus)) { - return resultStatus; - } - return resultStatus; + return adminService.deleteTopic(clusterDO, topicName, userName); } } diff --git a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ThirdPartDeleteTopicOrder.java b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ThirdPartDeleteTopicOrder.java index ec98ced7..69ad3b52 100644 --- a/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ThirdPartDeleteTopicOrder.java +++ b/kafka-manager-extends/kafka-manager-bpm/src/main/java/com/xiaojukeji/kafka/manager/bpm/order/impl/ThirdPartDeleteTopicOrder.java @@ -155,6 +155,7 @@ public class ThirdPartDeleteTopicOrder extends AbstractTopicOrder { return ResultStatus.USER_WITHOUT_AUTHORITY; } + ResultStatus resultStatus = adminService.deleteTopic(clusterDO, extensionDTO.getTopicName(), userName); if (!ResultStatus.SUCCESS.equals(resultStatus)) { return resultStatus; diff --git a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/AbstractAgent.java b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/AbstractAgent.java index 70ce5902..9146b1bd 100644 --- a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/AbstractAgent.java +++ b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/AbstractAgent.java @@ -1,7 +1,7 @@ package com.xiaojukeji.kafka.manager.kcm.component.agent; import com.xiaojukeji.kafka.manager.common.entity.Result; -import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskActionEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum; import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskStateEnum; import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskSubStateEnum; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.ClusterTaskLog; @@ -37,7 +37,7 @@ public abstract class AbstractAgent { * @param actionEnum 执行动作 * @return true:触发成功, false:触发失败 */ - public abstract boolean actionTask(Long taskId, ClusterTaskActionEnum actionEnum); + public abstract boolean actionTask(Long taskId, TaskActionEnum actionEnum); /** * 执行任务 @@ -46,7 +46,7 @@ public abstract class AbstractAgent { * @param hostname 具体主机 * @return true:触发成功, false:触发失败 */ - public abstract boolean actionHostTask(Long taskId, ClusterTaskActionEnum actionEnum, String hostname); + public abstract boolean actionHostTask(Long taskId, TaskActionEnum actionEnum, String hostname); /** * 获取任务运行的状态[阻塞, 执行中, 完成等] diff --git a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/n9e/N9e.java b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/n9e/N9e.java index d0a2503b..e836150c 100644 --- a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/n9e/N9e.java +++ b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/component/agent/n9e/N9e.java @@ -3,7 +3,7 @@ package com.xiaojukeji.kafka.manager.kcm.component.agent.n9e; import com.xiaojukeji.kafka.manager.common.bizenum.KafkaFileEnum; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.kcm.common.Constant; -import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskActionEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum; import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskTypeEnum; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.ClusterTaskLog; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.CreationTaskData; @@ -94,7 +94,7 @@ public class N9e extends AbstractAgent { } @Override - public boolean actionTask(Long taskId, ClusterTaskActionEnum actionEnum) { + public boolean actionTask(Long taskId, TaskActionEnum actionEnum) { Map param = new HashMap<>(1); param.put("action", actionEnum.getAction()); @@ -115,7 +115,7 @@ public class N9e extends AbstractAgent { } @Override - public boolean actionHostTask(Long taskId, ClusterTaskActionEnum actionEnum, String hostname) { + public boolean actionHostTask(Long taskId, TaskActionEnum actionEnum, String hostname) { Map params = new HashMap<>(2); params.put("action", actionEnum.getAction()); params.put("hostname", hostname); @@ -234,7 +234,7 @@ public class N9e extends AbstractAgent { n9eCreationTask.setScript(this.script); n9eCreationTask.setArgs(sb.toString()); n9eCreationTask.setAccount(this.account); - n9eCreationTask.setAction(ClusterTaskActionEnum.PAUSE.getAction()); + n9eCreationTask.setAction(TaskActionEnum.PAUSE.getAction()); n9eCreationTask.setHosts(creationTaskData.getHostList()); return n9eCreationTask; } diff --git a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/impl/ClusterTaskServiceImpl.java b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/impl/ClusterTaskServiceImpl.java index b3ef959a..cc9547bc 100644 --- a/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/impl/ClusterTaskServiceImpl.java +++ b/kafka-manager-extends/kafka-manager-kcm/src/main/java/com/xiaojukeji/kafka/manager/kcm/impl/ClusterTaskServiceImpl.java @@ -4,7 +4,7 @@ import com.xiaojukeji.kafka.manager.common.utils.ListUtils; import com.xiaojukeji.kafka.manager.common.utils.SpringTool; import com.xiaojukeji.kafka.manager.kcm.ClusterTaskService; import com.xiaojukeji.kafka.manager.kcm.common.Converters; -import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskActionEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum; import com.xiaojukeji.kafka.manager.kcm.common.entry.ClusterTaskConstant; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.ClusterTaskLog; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.ClusterTaskSubStatus; @@ -93,38 +93,38 @@ public class ClusterTaskServiceImpl implements ClusterTaskService { return ResultStatus.CALL_CLUSTER_TASK_AGENT_FAILED; } - if (ClusterTaskActionEnum.START.getAction().equals(action) && ClusterTaskStateEnum.BLOCKED.equals(stateEnumResult.getData())) { + if (TaskActionEnum.START.getAction().equals(action) && ClusterTaskStateEnum.BLOCKED.equals(stateEnumResult.getData())) { // 暂停状态, 可以执行开始 - return actionTaskExceptRollbackAction(agentTaskId, ClusterTaskActionEnum.START, ""); + return actionTaskExceptRollbackAction(agentTaskId, TaskActionEnum.START, ""); } - if (ClusterTaskActionEnum.PAUSE.getAction().equals(action) && ClusterTaskStateEnum.RUNNING.equals(stateEnumResult.getData())) { + if (TaskActionEnum.PAUSE.getAction().equals(action) && ClusterTaskStateEnum.RUNNING.equals(stateEnumResult.getData())) { // 运行状态, 可以执行暂停 - return actionTaskExceptRollbackAction(agentTaskId, ClusterTaskActionEnum.PAUSE, ""); + return actionTaskExceptRollbackAction(agentTaskId, TaskActionEnum.PAUSE, ""); } - if (ClusterTaskActionEnum.IGNORE.getAction().equals(action)) { + if (TaskActionEnum.IGNORE.getAction().equals(action)) { // 忽略 & 取消随时都可以操作 - return actionTaskExceptRollbackAction(agentTaskId, ClusterTaskActionEnum.IGNORE, hostname); + return actionTaskExceptRollbackAction(agentTaskId, TaskActionEnum.IGNORE, hostname); } - if (ClusterTaskActionEnum.CANCEL.getAction().equals(action)) { + if (TaskActionEnum.CANCEL.getAction().equals(action)) { // 忽略 & 取消随时都可以操作 - return actionTaskExceptRollbackAction(agentTaskId, ClusterTaskActionEnum.CANCEL, hostname); + return actionTaskExceptRollbackAction(agentTaskId, TaskActionEnum.CANCEL, hostname); } if ((!ClusterTaskStateEnum.FINISHED.equals(stateEnumResult.getData()) || !rollback) - && ClusterTaskActionEnum.ROLLBACK.getAction().equals(action)) { + && TaskActionEnum.ROLLBACK.getAction().equals(action)) { // 暂未操作完时可以回滚, 回滚所有操作过的机器到上一个版本 return actionTaskRollback(clusterTaskDO); } return ResultStatus.OPERATION_FAILED; } - private ResultStatus actionTaskExceptRollbackAction(Long agentId, ClusterTaskActionEnum actionEnum, String hostname) { + private ResultStatus actionTaskExceptRollbackAction(Long agentId, TaskActionEnum actionEnum, String hostname) { if (!ValidateUtils.isBlank(hostname)) { return actionHostTaskExceptRollbackAction(agentId, actionEnum, hostname); } return abstractAgent.actionTask(agentId, actionEnum)? ResultStatus.SUCCESS: ResultStatus.OPERATION_FAILED; } - private ResultStatus actionHostTaskExceptRollbackAction(Long agentId, ClusterTaskActionEnum actionEnum, String hostname) { + private ResultStatus actionHostTaskExceptRollbackAction(Long agentId, TaskActionEnum actionEnum, String hostname) { return abstractAgent.actionHostTask(agentId, actionEnum, hostname)? ResultStatus.SUCCESS: ResultStatus.OPERATION_FAILED; } @@ -176,7 +176,7 @@ public class ClusterTaskServiceImpl implements ClusterTaskService { if (clusterTaskDao.updateRollback(clusterTaskDO) <= 0) { return ResultStatus.MYSQL_ERROR; } - abstractAgent.actionTask(clusterTaskDO.getAgentTaskId(), ClusterTaskActionEnum.CANCEL); + abstractAgent.actionTask(clusterTaskDO.getAgentTaskId(), TaskActionEnum.CANCEL); return ResultStatus.SUCCESS; } catch (Exception e) { LOGGER.error("create cluster task failed, clusterTaskDO:{}.", clusterTaskDO, e); diff --git a/kafka-manager-extends/kafka-manager-kcm/src/test/java/com/xiaojukeji/kafka/manager/kcm/ClusterTaskServiceTest.java b/kafka-manager-extends/kafka-manager-kcm/src/test/java/com/xiaojukeji/kafka/manager/kcm/ClusterTaskServiceTest.java index b28b828f..e1ca1b13 100644 --- a/kafka-manager-extends/kafka-manager-kcm/src/test/java/com/xiaojukeji/kafka/manager/kcm/ClusterTaskServiceTest.java +++ b/kafka-manager-extends/kafka-manager-kcm/src/test/java/com/xiaojukeji/kafka/manager/kcm/ClusterTaskServiceTest.java @@ -4,7 +4,7 @@ import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterTaskDO; import com.xiaojukeji.kafka.manager.dao.ClusterTaskDao; -import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskActionEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.TaskActionEnum; import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskStateEnum; import com.xiaojukeji.kafka.manager.kcm.common.bizenum.ClusterTaskTypeEnum; import com.xiaojukeji.kafka.manager.kcm.common.entry.ao.ClusterTaskLog; @@ -163,7 +163,7 @@ public class ClusterTaskServiceTest extends BaseTest { } private void executeTask2TaskNotExistTest() { - ResultStatus resultStatus = clusterTaskService.executeTask(INVALID_TASK_ID, ClusterTaskActionEnum.START.getAction(), ADMIN); + ResultStatus resultStatus = clusterTaskService.executeTask(INVALID_TASK_ID, TaskActionEnum.START.getAction(), ADMIN); Assert.assertEquals(resultStatus.getCode(), ResultStatus.RESOURCE_NOT_EXIST.getCode()); } @@ -172,7 +172,7 @@ public class ClusterTaskServiceTest extends BaseTest { ClusterTaskDO clusterTaskDO = getClusterTaskDO(); Mockito.when(clusterTaskDao.getById(Mockito.anyLong())).thenReturn(clusterTaskDO); - ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.START.getAction(), ADMIN); + ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.START.getAction(), ADMIN); Assert.assertEquals(resultStatus.getCode(), ResultStatus.CALL_CLUSTER_TASK_AGENT_FAILED.getCode()); } @@ -183,12 +183,12 @@ public class ClusterTaskServiceTest extends BaseTest { // success Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(true); - ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.START.getAction(), ADMIN); + ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.START.getAction(), ADMIN); Assert.assertEquals(resultStatus.getCode(), ResultStatus.SUCCESS.getCode()); // operation failed Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(false); - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.START.getAction(), ADMIN); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.START.getAction(), ADMIN); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FAILED.getCode()); } @@ -199,12 +199,12 @@ public class ClusterTaskServiceTest extends BaseTest { // success Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(true); - ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.PAUSE.getAction(), ADMIN); + ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.PAUSE.getAction(), ADMIN); Assert.assertEquals(resultStatus.getCode(), ResultStatus.SUCCESS.getCode()); // operation failed Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(false); - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.PAUSE.getAction(), ADMIN); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.PAUSE.getAction(), ADMIN); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FAILED.getCode()); } @@ -215,12 +215,12 @@ public class ClusterTaskServiceTest extends BaseTest { // success Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(true); - ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.IGNORE.getAction(), ""); + ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.IGNORE.getAction(), ""); Assert.assertEquals(resultStatus.getCode(), ResultStatus.SUCCESS.getCode()); // operation failed Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(false); - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.IGNORE.getAction(), ""); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.IGNORE.getAction(), ""); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FAILED.getCode()); } @@ -231,12 +231,12 @@ public class ClusterTaskServiceTest extends BaseTest { // success Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(true); - ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.CANCEL.getAction(), ""); + ResultStatus resultStatus = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.CANCEL.getAction(), ""); Assert.assertEquals(resultStatus.getCode(), ResultStatus.SUCCESS.getCode()); // operation failed Mockito.when(abstractAgent.actionTask(Mockito.anyLong(), Mockito.any())).thenReturn(false); - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.CANCEL.getAction(), ""); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.CANCEL.getAction(), ""); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FAILED.getCode()); } @@ -246,7 +246,7 @@ public class ClusterTaskServiceTest extends BaseTest { Mockito.when(clusterTaskDao.getById(Mockito.anyLong())).thenReturn(clusterTaskDO); // operation failed - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.START.getAction(), ADMIN); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.START.getAction(), ADMIN); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FAILED.getCode()); } @@ -257,7 +257,7 @@ public class ClusterTaskServiceTest extends BaseTest { Mockito.when(clusterTaskDao.getById(Mockito.anyLong())).thenReturn(clusterTaskDO); // operation failed - ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, ClusterTaskActionEnum.ROLLBACK.getAction(), ADMIN); + ResultStatus resultStatus2 = clusterTaskService.executeTask(REAL_TASK_ID_IN_MYSQL, TaskActionEnum.ROLLBACK.getAction(), ADMIN); Assert.assertEquals(resultStatus2.getCode(), ResultStatus.OPERATION_FORBIDDEN.getCode()); } diff --git a/kafka-manager-task/src/main/java/com/xiaojukeji/kafka/manager/task/dispatch/op/HaFlushASSwitchJob.java b/kafka-manager-task/src/main/java/com/xiaojukeji/kafka/manager/task/dispatch/op/HaFlushASSwitchJob.java new file mode 100644 index 00000000..d19726c4 --- /dev/null +++ b/kafka-manager-task/src/main/java/com/xiaojukeji/kafka/manager/task/dispatch/op/HaFlushASSwitchJob.java @@ -0,0 +1,41 @@ +package com.xiaojukeji.kafka.manager.task.dispatch.op; + +import com.xiaojukeji.kafka.manager.service.biz.job.HaASSwitchJobManager; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASSwitchJobService; +import com.xiaojukeji.kafka.manager.task.component.AbstractScheduledTask; +import com.xiaojukeji.kafka.manager.task.component.CustomScheduled; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.*; + +/** + * 主备切换任务 + */ +@Component +@CustomScheduled(name = "HaFlushASSwitchJob", + cron = "0 0/1 * * * ?", + threadNum = 1, + description = "刷新主备切换任务") +public class HaFlushASSwitchJob extends AbstractScheduledTask { + @Autowired + private HaASSwitchJobService haASSwitchJobService; + + @Autowired + private HaASSwitchJobManager haASSwitchJobManager; + + @Override + public List listAllTasks() { + // 获取正在运行的任务ID列表, 忽略1分钟内的任务,尽量避免任务被重复执行 + return haASSwitchJobService.listRunningJobs(System.currentTimeMillis() - (60 * 1000L)); + } + + @Override + public void processTask(Long jobId) { + // 执行Job + haASSwitchJobManager.executeJob(jobId, false, false); + + // 更新任务信息 + haASSwitchJobManager.flushExtendData(jobId); + } +} diff --git a/kafka-manager-web/pom.xml b/kafka-manager-web/pom.xml index a28169e1..4e22d49a 100644 --- a/kafka-manager-web/pom.xml +++ b/kafka-manager-web/pom.xml @@ -83,6 +83,12 @@ spring-boot-starter-logging ${spring.boot.version} + + org.springframework.boot + spring-boot-starter-validation + ${spring.boot.version} + + ch.qos.logback logback-classic diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalAppController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalAppController.java index 34529616..ad19569e 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalAppController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalAppController.java @@ -72,6 +72,19 @@ public class NormalAppController { ); } + @ApiLevel(level = ApiLevelContent.LEVEL_NORMAL_3, rateLimit = 1) + @ApiOperation(value = "App列表", notes = "") + @RequestMapping(value = "apps/{clusterId}", method = RequestMethod.GET) + @ResponseBody + public Result> getApps(@PathVariable Long clusterId, + @RequestParam(value = "isPhysicalClusterId", required = false, defaultValue = "false") Boolean isPhysicalClusterId) { + + Long physicalClusterId = logicalClusterMetadataManager.getPhysicalClusterId(clusterId, isPhysicalClusterId); + return new Result<>(AppConverter.convert2AppVOList( + appService.getByPrincipalAndClusterId(SpringTool.getUserName(), physicalClusterId)) + ); + } + @ApiOperation(value = "App基本信息", notes = "") @RequestMapping(value = "apps/{appId}/basic-info", method = RequestMethod.GET) @ResponseBody diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalClusterController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalClusterController.java index ed6ff6eb..4c3d286a 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalClusterController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalClusterController.java @@ -24,6 +24,7 @@ import com.xiaojukeji.kafka.manager.service.service.ThrottleService; import com.xiaojukeji.kafka.manager.service.service.TopicService; import com.xiaojukeji.kafka.manager.common.utils.SpringTool; import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.service.service.ha.HaTopicService; import com.xiaojukeji.kafka.manager.web.converters.ClusterModelConverter; import com.xiaojukeji.kafka.manager.web.converters.CommonModelConverter; import io.swagger.annotations.Api; @@ -50,6 +51,9 @@ public class NormalClusterController { @Autowired private TopicService topicService; + @Autowired + private HaTopicService haTopicService; + @Autowired private LogicalClusterService logicalClusterService; @@ -144,6 +148,13 @@ public class NormalClusterController { return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); } + //过滤备topic + Map> relationMap = haTopicService.getClusterStandbyTopicMap(); + Set topics = logicalClusterMetadataManager.getTopicNameSet(logicalClusterId); + if (relationMap !=null && relationMap.get(logicalClusterDO.getClusterId()) != null){ + topics.removeAll(new HashSet<>(relationMap.get(logicalClusterDO.getClusterId()))); + } + return new Result<>(CommonModelConverter.convert2TopicOverviewVOList( logicalClusterId, topicService.getTopicOverviewList( diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalTopicMineController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalTopicMineController.java index df5d291e..7c7eaeec 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalTopicMineController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/normal/NormalTopicMineController.java @@ -1,21 +1,23 @@ package com.xiaojukeji.kafka.manager.web.api.versionone.normal; +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; import com.xiaojukeji.kafka.manager.common.constant.Constant; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.dto.normal.TopicModifyDTO; import com.xiaojukeji.kafka.manager.common.entity.dto.normal.TopicRetainDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.TopicExpiredVO; import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.TopicMineVO; import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.TopicVO; +import com.xiaojukeji.kafka.manager.common.utils.SpringTool; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.cache.LogicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.TopicExpiredService; import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; -import com.xiaojukeji.kafka.manager.common.utils.SpringTool; -import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; -import com.xiaojukeji.kafka.manager.web.utils.ResultCache; import com.xiaojukeji.kafka.manager.web.converters.TopicMineConverter; +import com.xiaojukeji.kafka.manager.web.utils.ResultCache; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +42,9 @@ public class NormalTopicMineController { @Autowired private LogicalClusterMetadataManager logicalClusterMetadataManager; + @Autowired + private HaASRelationManager haASRelationManager; + @ApiOperation(value = "我的Topic", notes = "") @RequestMapping(value = "topics/mine", method = RequestMethod.GET) @ResponseBody @@ -75,14 +80,31 @@ public class NormalTopicMineController { if (ValidateUtils.isNull(physicalClusterId)) { return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); } - return Result.buildFrom( - topicManagerService.modifyTopic( - physicalClusterId, - dto.getTopicName(), - dto.getDescription(), - SpringTool.getUserName() - ) + + //修改备topic + HaASRelationDO relationDO = haASRelationManager.getASRelation(dto.getClusterId(), dto.getTopicName()); + if (relationDO != null){ + if (relationDO.getStandbyClusterPhyId().equals(dto.getClusterId())){ + return Result.buildFromRSAndMsg(ResultStatus.OPERATION_FORBIDDEN, "备topic不允许操作!"); + } + ResultStatus rs = topicManagerService.modifyTopic( + relationDO.getStandbyClusterPhyId(), + relationDO.getStandbyResName(), + dto.getDescription(), + SpringTool.getUserName() + ); + if (ResultStatus.SUCCESS.getCode() != rs.getCode()){ + return Result.buildFrom(rs); + } + } + + ResultStatus resultStatus = topicManagerService.modifyTopic( + physicalClusterId, + dto.getTopicName(), + dto.getDescription(), + SpringTool.getUserName() ); + return Result.buildFrom(resultStatus); } @ApiOperation(value = "过期Topic信息", notes = "") diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpClusterController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpClusterController.java index 2caaa69b..0ceec850 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpClusterController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpClusterController.java @@ -1,13 +1,14 @@ package com.xiaojukeji.kafka.manager.web.api.versionone.op; +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.dto.op.ControllerPreferredCandidateDTO; import com.xiaojukeji.kafka.manager.common.entity.dto.rd.ClusterDTO; -import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; -import com.xiaojukeji.kafka.manager.service.service.ClusterService; import com.xiaojukeji.kafka.manager.common.utils.SpringTool; -import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaClusterManager; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; import com.xiaojukeji.kafka.manager.web.converters.ClusterModelConverter; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -26,6 +27,9 @@ public class OpClusterController { @Autowired private ClusterService clusterService; + @Autowired + private HaClusterManager haClusterManager; + @ApiOperation(value = "接入集群") @PostMapping(value = "clusters") @ResponseBody @@ -33,16 +37,14 @@ public class OpClusterController { if (ValidateUtils.isNull(dto) || !dto.legal()) { return Result.buildFrom(ResultStatus.PARAM_ILLEGAL); } - return Result.buildFrom( - clusterService.addNew(ClusterModelConverter.convert2ClusterDO(dto), SpringTool.getUserName()) - ); + return haClusterManager.addNew(ClusterModelConverter.convert2ClusterDO(dto), dto.getActiveClusterId(), SpringTool.getUserName()); } @ApiOperation(value = "删除集群") @DeleteMapping(value = "clusters") @ResponseBody public Result delete(@RequestParam(value = "clusterId") Long clusterId) { - return Result.buildFrom(clusterService.deleteById(clusterId, SpringTool.getUserName())); + return haClusterManager.deleteById(clusterId, SpringTool.getUserName()); } @ApiOperation(value = "修改集群信息") diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaASSwitchJobController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaASSwitchJobController.java new file mode 100644 index 00000000..a645a638 --- /dev/null +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaASSwitchJobController.java @@ -0,0 +1,87 @@ +package com.xiaojukeji.kafka.manager.web.api.versionone.op; + +import com.xiaojukeji.kafka.manager.common.bizenum.JobLogBizTypEnum; +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ao.ha.job.HaJobState; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobActionDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.ha.ASSwitchJobDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.JobLogDO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.job.HaJobDetailVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.job.JobLogVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.job.JobMulLogVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.job.HaJobStateVO; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import com.xiaojukeji.kafka.manager.common.utils.SpringTool; +import com.xiaojukeji.kafka.manager.service.biz.job.HaASSwitchJobManager; +import com.xiaojukeji.kafka.manager.service.service.JobLogService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + + +/** + * @author zengqiao + * @date 20/4/23 + */ +@Api(tags = "OP-HA-主备切换Job相关接口(REST)") +@RestController +@RequestMapping(ApiPrefix.API_V1_OP_PREFIX) +public class OpHaASSwitchJobController { + @Autowired + private JobLogService jobLogService; + + @Autowired + private HaASSwitchJobManager haASSwitchJobManager; + + @ApiOperation(value = "任务创建[ActiveStandbySwitch]") + @PostMapping(value = "as-switch-jobs") + @ResponseBody + public Result createJob(@Validated @RequestBody ASSwitchJobDTO dto) { + return haASSwitchJobManager.createJob(dto, SpringTool.getUserName()); + } + + @ApiOperation(value = "任务状态[ActiveStandbySwitch]", notes = "最近一个任务") + @GetMapping(value = "as-switch-jobs/{jobId}/job-state") + @ResponseBody + public Result jobState(@PathVariable Long jobId) { + Result haResult = haASSwitchJobManager.jobState(jobId); + if (haResult.failed()) { + return Result.buildFromIgnoreData(haResult); + } + + return Result.buildSuc(new HaJobStateVO(haResult.getData())); + } + + @ApiOperation(value = "任务详情[ActiveStandbySwitch]", notes = "") + @GetMapping(value = "as-switch-jobs/{jobId}/job-detail") + @ResponseBody + public Result> jobDetail(@PathVariable Long jobId) { + return haASSwitchJobManager.jobDetail(jobId); + } + + @ApiOperation(value = "任务日志[ActiveStandbySwitch]", notes = "") + @GetMapping(value = "as-switch-jobs/{jobId}/job-logs") + @ResponseBody + public Result jobLog(@PathVariable Long jobId, @RequestParam(required = false) Long startLogId) { + List doList = jobLogService.listLogs(JobLogBizTypEnum.HA_SWITCH_JOB_LOG.getCode(), String.valueOf(jobId), startLogId); + List voList = doList.isEmpty()? new ArrayList<>(): ConvertUtil.list2List( + doList, + JobLogVO.class + ); + + return Result.buildSuc(new JobMulLogVO(voList, startLogId)); + } + + @ApiOperation(value = "任务操作[ActiveStandbySwitch]", notes = "") + @PutMapping(value = "as-switch-jobs/{jobId}/action") + @ResponseBody + public Result actionJob(@PathVariable Long jobId, @Validated @RequestBody ASSwitchJobActionDTO dto) { + return haASSwitchJobManager.actionJob(jobId, dto); + } +} diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaRelationsController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaRelationsController.java new file mode 100644 index 00000000..00dcb02f --- /dev/null +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaRelationsController.java @@ -0,0 +1,130 @@ +package com.xiaojukeji.kafka.manager.web.api.versionone.op; + +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaResTypeEnum; +import com.xiaojukeji.kafka.manager.common.bizenum.ha.HaStatusEnum; +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.common.constant.KafkaConstant; +import com.xiaojukeji.kafka.manager.common.constant.MsgConstant; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.service.ClusterService; +import com.xiaojukeji.kafka.manager.service.service.ha.HaASRelationService; +import com.xiaojukeji.kafka.manager.service.utils.HaTopicCommands; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; + + +/** + * @author zengqiao + * @date 20/4/23 + */ +@Api(tags = "OP-HA-Relations维度相关接口(REST)") +@RestController +@RequestMapping(ApiPrefix.API_V1_OP_PREFIX) +public class OpHaRelationsController { + private static final Logger LOGGER = LoggerFactory.getLogger(OpHaRelationsController.class); + + @Autowired + private ClusterService clusterService; + + @Autowired + private HaASRelationService haASRelationService; + + @ApiOperation(value = "同步Kafka的HA关系到DB") + @PostMapping(value = "ha-relations/{clusterPhyId}/dest-db") + @ResponseBody + public Result syncHaRelationsToDB(@PathVariable Long clusterPhyId) { + // 从ZK获取Topic主备关系信息 + ClusterDO clusterDO = clusterService.getById(clusterPhyId); + if (ValidateUtils.isNull(clusterDO)) { + return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, MsgConstant.getClusterPhyNotExist(clusterPhyId)); + } + + Map haTopicsConfigMap = HaTopicCommands.fetchAllHaTopicConfig(clusterDO); + if (haTopicsConfigMap == null) { + LOGGER.error("method=processTask||clusterPhyId={}||msg=fetch all ha topic config failed", clusterPhyId); + return Result.buildFailure(ResultStatus.ZOOKEEPER_READ_FAILED); + } + + // 获取当前集群的HA信息 + List doList = haTopicsConfigMap.entrySet() + .stream() + .map(elem -> getHaASRelation(clusterPhyId, elem.getKey(), elem.getValue())) + .filter(relation -> relation != null) + .collect(Collectors.toList()); + + // 更新HA关系表 + Result rv = haASRelationService.replaceTopicRelationsToDB(clusterPhyId, doList); + if (rv.failed()) { + LOGGER.error("method=processTask||clusterPhyId={}||result={}||msg=replace topic relation failed", clusterPhyId, rv); + } + + return rv; + } + +// @ApiOperation(value = "同步DB的HA关系到Kafka") +// @PostMapping(value = "ha-relations/{clusterPhyId}/dest-kafka") +// @ResponseBody +// public Result syncHaRelationsToKafka(@PathVariable Long clusterPhyId) { +// // 从ZK获取Topic主备关系信息 +// ClusterDO clusterDO = clusterService.getById(clusterPhyId); +// if (ValidateUtils.isNull(clusterDO)) { +// return Result.buildFromRSAndMsg(ResultStatus.CLUSTER_NOT_EXIST, MsgConstant.getClusterPhyNotExist(clusterPhyId)); +// } +// +// Map haTopicsConfigMap = HaTopicCommands.fetchAllHaTopicConfig(clusterDO); +// if (haTopicsConfigMap == null) { +// LOGGER.error("method=processTask||clusterPhyId={}||msg=fetch all ha topic config failed", clusterPhyId); +// return Result.buildFailure(ResultStatus.ZOOKEEPER_READ_FAILED); +// } +// +// // 获取当前集群的HA信息 +// List doList = haTopicsConfigMap.entrySet() +// .stream() +// .map(elem -> getHaASRelation(clusterPhyId, elem.getKey(), elem.getValue())) +// .filter(relation -> relation != null) +// .collect(Collectors.toList()); +// +// // 更新HA关系表 +// Result rv = haASRelationService.replaceTopicRelationsToDB(clusterPhyId, doList); +// if (rv.failed()) { +// LOGGER.error("method=processTask||clusterPhyId={}||result={}||msg=replace topic relation failed", clusterPhyId, rv); +// } +// +// return rv; +// } + + private HaASRelationDO getHaASRelation(Long standbyClusterPhyId, String standbyTopicName, Properties props) { + Long activeClusterPhyId = ConvertUtil.string2Long(props.getProperty(KafkaConstant.DIDI_HA_REMOTE_CLUSTER)); + if (activeClusterPhyId == null) { + return null; + } + + String activeTopicName = props.getProperty(KafkaConstant.DIDI_HA_REMOTE_TOPIC); + if (activeTopicName == null) { + activeTopicName = standbyTopicName; + } + + return new HaASRelationDO( + activeClusterPhyId, + activeTopicName, + standbyClusterPhyId, + standbyTopicName, + HaResTypeEnum.TOPIC.getCode(), + HaStatusEnum.STABLE.getCode() + ); + } +} \ No newline at end of file diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaTopicController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaTopicController.java new file mode 100644 index 00000000..89d401c0 --- /dev/null +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpHaTopicController.java @@ -0,0 +1,43 @@ +package com.xiaojukeji.kafka.manager.web.api.versionone.op; + +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.TopicOperationResult; +import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.HaTopicRelationDTO; +import com.xiaojukeji.kafka.manager.common.utils.SpringTool; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaTopicManager; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 高可用Topic操作相关接口 + * @author zengqiao + * @date 21/5/18 + */ +@Api(tags = "OP-HA-Topic操作相关接口(REST)") +@RestController +@RequestMapping(ApiPrefix.API_V1_OP_PREFIX) +public class OpHaTopicController { + + @Autowired + private HaTopicManager haTopicManager; + + @ApiOperation(value = "高可用Topic绑定") + @PostMapping(value = "ha-topics") + @ResponseBody + public Result> batchCreateHaTopic(@Validated @RequestBody HaTopicRelationDTO dto) { + return haTopicManager.batchCreateHaTopic(dto, SpringTool.getUserName()); + } + + @ApiOperation(value = "高可用topic解绑") + @DeleteMapping(value = "ha-topics") + @ResponseBody + public Result> batchRemoveHaTopic(@Validated @RequestBody HaTopicRelationDTO dto) { + return haTopicManager.batchRemoveHaTopic(dto, SpringTool.getUserName()); + } +} diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpQuotaController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpQuotaController.java index 7d9c70d7..8ef50de1 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpQuotaController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpQuotaController.java @@ -5,7 +5,9 @@ import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; import com.xiaojukeji.kafka.manager.common.entity.ao.gateway.TopicQuota; import com.xiaojukeji.kafka.manager.common.entity.dto.gateway.TopicQuotaDTO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; import com.xiaojukeji.kafka.manager.service.service.gateway.QuotaService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -24,6 +26,9 @@ public class OpQuotaController { @Autowired private QuotaService quotaService; + @Autowired + private HaASRelationManager haASRelationManager; + @ApiOperation(value = "配额调整",notes = "配额调整") @RequestMapping(value = "topic-quotas",method = RequestMethod.POST) @ResponseBody @@ -32,6 +37,22 @@ public class OpQuotaController { // 非空校验 return Result.buildFrom(ResultStatus.PARAM_ILLEGAL); } + + HaASRelationDO relationDO = haASRelationManager.getASRelation(dto.getClusterId(), dto.getTopicName()); + if (relationDO != null){ + if (relationDO.getStandbyClusterPhyId().equals(dto.getClusterId())){ + return Result.buildFrom(ResultStatus.HA_TOPIC_DELETE_FORBIDDEN); + } + //备topic调整 + dto.setClusterId(relationDO.getStandbyClusterPhyId()); + dto.setTopicName(relationDO.getStandbyResName()); + ResultStatus resultStatus = quotaService + .addTopicQuotaByAuthority(TopicQuota.buildFrom(dto)); + if (ResultStatus.SUCCESS.getCode() != resultStatus.getCode()){ + Result.buildFrom(resultStatus); + } + } + return Result.buildFrom(quotaService.addTopicQuotaByAuthority(TopicQuota.buildFrom(dto))); } } diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpTopicController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpTopicController.java index bf7a1340..dcac0fa1 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpTopicController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/op/OpTopicController.java @@ -13,14 +13,17 @@ import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicExpansionDTO import com.xiaojukeji.kafka.manager.common.entity.dto.op.topic.TopicModificationDTO; import com.xiaojukeji.kafka.manager.common.entity.pojo.ClusterDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.TopicDO; +import com.xiaojukeji.kafka.manager.common.entity.pojo.ha.HaASRelationDO; import com.xiaojukeji.kafka.manager.common.utils.SpringTool; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; +import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.AdminService; import com.xiaojukeji.kafka.manager.service.service.ClusterService; import com.xiaojukeji.kafka.manager.service.service.TopicManagerService; -import com.xiaojukeji.kafka.manager.service.utils.TopicCommands; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @@ -45,6 +48,9 @@ public class OpTopicController { @Autowired private TopicManagerService topicManagerService; + + @Autowired + private HaASRelationManager haASRelationManager; @ApiOperation(value = "创建Topic") @RequestMapping(value = {"topics", "utils/topics"}, method = RequestMethod.POST) @@ -109,28 +115,23 @@ public class OpTopicController { @RequestMapping(value = {"topics", "utils/topics"}, method = RequestMethod.PUT) @ResponseBody public Result modifyTopic(@RequestBody TopicModificationDTO dto) { - Result rc = checkParamAndGetClusterDO(dto); - if (rc.getCode() != ResultStatus.SUCCESS.getCode()) { - return rc; + if (!dto.paramLegal()) { + return Result.buildFrom(ResultStatus.PARAM_ILLEGAL); } - ClusterDO clusterDO = rc.getData(); - - // 获取属性 - Properties properties = dto.getProperties(); - if (ValidateUtils.isNull(properties)) { - properties = new Properties(); + Result rs = topicManagerService.modifyTopic(dto); + if (rs.failed()){ + return rs; } - properties.put(KafkaConstant.RETENTION_MS_KEY, String.valueOf(dto.getRetentionTime())); - // 操作修改 - String operator = SpringTool.getUserName(); - ResultStatus rs = TopicCommands.modifyTopicConfig(clusterDO, dto.getTopicName(), properties); - if (!ResultStatus.SUCCESS.equals(rs)) { - return Result.buildFrom(rs); + //修改备topic + HaASRelationDO relationDO = haASRelationManager.getASRelation(dto.getClusterId(), dto.getTopicName()); + if (relationDO != null && relationDO.getActiveClusterPhyId().equals(dto.getClusterId())){ + dto.setClusterId(relationDO.getStandbyClusterPhyId()); + dto.setTopicName(relationDO.getStandbyResName()); + rs = topicManagerService.modifyTopic(dto); } - topicManagerService.modifyTopicByOp(dto.getClusterId(), dto.getTopicName(), dto.getAppId(), dto.getDescription(), operator); - return new Result(); + return rs; } @ApiOperation(value = "Topic扩分区", notes = "") @@ -143,22 +144,31 @@ public class OpTopicController { List resultList = new ArrayList<>(); for (TopicExpansionDTO dto: dtoList) { - Result rc = checkParamAndGetClusterDO(dto); - if (!Constant.SUCCESS.equals(rc.getCode())) { - resultList.add(TopicOperationResult.buildFrom(dto.getClusterId(), dto.getTopicName(), rc)); - continue; - } + TopicOperationResult result; - // 参数检查合法, 开始对Topic进行扩分区 - ResultStatus statusEnum = adminService.expandPartitions( - rc.getData(), - dto.getTopicName(), - dto.getPartitionNum(), - dto.getRegionId(), - dto.getBrokerIdList(), - SpringTool.getUserName() - ); - resultList.add(TopicOperationResult.buildFrom(dto.getClusterId(), dto.getTopicName(), statusEnum)); + HaASRelationDO relationDO = haASRelationManager.getASRelation(dto.getClusterId(), dto.getTopicName()); + if (relationDO != null){ + //用户侧不允许操作备topic + if (relationDO.getStandbyClusterPhyId().equals(dto.getClusterId())){ + resultList.add(TopicOperationResult.buildFrom(dto.getClusterId(), + dto.getTopicName(), + ResultStatus.OPERATION_FORBIDDEN)); + continue; + } + //备topic扩分区 + TopicExpansionDTO standbyDto = new TopicExpansionDTO(); + BeanUtils.copyProperties(dto, standbyDto); + standbyDto.setClusterId(relationDO.getStandbyClusterPhyId()); + standbyDto.setTopicName(relationDO.getStandbyResName()); + standbyDto.setBrokerIdList(PhysicalClusterMetadataManager.getBrokerIdList(relationDO.getStandbyClusterPhyId())); + standbyDto.setRegionId(null); + result = topicManagerService.expandTopic(standbyDto); + if (ResultStatus.SUCCESS.getCode() != result.getCode()){ + resultList.add(result); + continue; + } + } + resultList.add(topicManagerService.expandTopic(dto)); } for (TopicOperationResult operationResult: resultList) { @@ -178,6 +188,12 @@ public class OpTopicController { if (ValidateUtils.isNull(clusterDO)) { return Result.buildFrom(ResultStatus.CLUSTER_NOT_EXIST); } + + HaASRelationDO relationDO = haASRelationManager.getASRelation(dto.getClusterId(), dto.getTopicName()); + if (relationDO != null) { + return Result.buildFrom(ResultStatus.HA_TOPIC_DELETE_FORBIDDEN); + } + return new Result<>(clusterDO); } } diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdAppController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdAppController.java index 8e0c14cf..288b2eea 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdAppController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdAppController.java @@ -2,7 +2,10 @@ package com.xiaojukeji.kafka.manager.web.api.versionone.rd; import com.xiaojukeji.kafka.manager.common.entity.Result; import com.xiaojukeji.kafka.manager.common.entity.dto.normal.AppDTO; +import com.xiaojukeji.kafka.manager.common.entity.dto.rd.AppRelateTopicsDTO; import com.xiaojukeji.kafka.manager.common.entity.vo.normal.app.AppVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.app.AppRelateTopicsVO; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaAppManager; import com.xiaojukeji.kafka.manager.service.service.gateway.AppService; import com.xiaojukeji.kafka.manager.common.utils.SpringTool; import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; @@ -10,6 +13,7 @@ import com.xiaojukeji.kafka.manager.web.converters.AppConverter; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -25,6 +29,9 @@ public class RdAppController { @Autowired private AppService appService; + @Autowired + private HaAppManager haAppManager; + @ApiOperation(value = "App列表", notes = "") @RequestMapping(value = "apps", method = RequestMethod.GET) @ResponseBody @@ -40,4 +47,11 @@ public class RdAppController { appService.updateByAppId(dto, SpringTool.getUserName(), true) ); } + + @ApiOperation(value = "App关联Topic信息查询", notes = "") + @PostMapping(value = "apps/relate-topics") + @ResponseBody + public Result> appRelateTopics(@Validated @RequestBody AppRelateTopicsDTO dto) { + return haAppManager.appRelateTopics(dto.getClusterPhyId(), dto.getFilterTopicNameList()); + } } \ No newline at end of file diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdClusterController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdClusterController.java index 69ba8c6d..7090ec6d 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdClusterController.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdClusterController.java @@ -1,27 +1,28 @@ package com.xiaojukeji.kafka.manager.web.api.versionone.rd; import com.xiaojukeji.kafka.manager.common.bizenum.KafkaClientEnum; +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; import com.xiaojukeji.kafka.manager.common.constant.KafkaMetricsCollections; import com.xiaojukeji.kafka.manager.common.entity.Result; -import com.xiaojukeji.kafka.manager.common.entity.ao.cluster.ControllerPreferredCandidate; -import com.xiaojukeji.kafka.manager.common.entity.vo.normal.cluster.TopicMetadataVO; -import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ControllerPreferredCandidateVO; -import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.RdClusterMetricsVO; -import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ClusterBrokerStatusVO; import com.xiaojukeji.kafka.manager.common.entity.ao.BrokerOverviewDTO; +import com.xiaojukeji.kafka.manager.common.entity.ao.cluster.ControllerPreferredCandidate; import com.xiaojukeji.kafka.manager.common.entity.pojo.RegionDO; -import com.xiaojukeji.kafka.manager.common.entity.vo.rd.KafkaControllerVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.common.BrokerOverviewVO; import com.xiaojukeji.kafka.manager.common.entity.vo.common.RealTimeMetricsVO; import com.xiaojukeji.kafka.manager.common.entity.vo.common.TopicOverviewVO; -import com.xiaojukeji.kafka.manager.common.entity.vo.common.BrokerOverviewVO; -import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ClusterDetailVO; import com.xiaojukeji.kafka.manager.common.entity.vo.common.TopicThrottleVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.normal.cluster.TopicMetadataVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.KafkaControllerVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ClusterBrokerStatusVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ClusterDetailVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.ControllerPreferredCandidateVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.rd.cluster.RdClusterMetricsVO; import com.xiaojukeji.kafka.manager.common.utils.DateUtils; import com.xiaojukeji.kafka.manager.common.utils.ValidateUtils; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.service.*; -import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; -import com.xiaojukeji.kafka.manager.web.converters.*; +import com.xiaojukeji.kafka.manager.web.converters.ClusterModelConverter; +import com.xiaojukeji.kafka.manager.web.converters.CommonModelConverter; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdHaClusterController.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdHaClusterController.java new file mode 100644 index 00000000..13264acd --- /dev/null +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/api/versionone/rd/RdHaClusterController.java @@ -0,0 +1,55 @@ +package com.xiaojukeji.kafka.manager.web.api.versionone.rd; + +import com.xiaojukeji.kafka.manager.common.constant.ApiPrefix; +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterTopicVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.ha.HaClusterVO; +import com.xiaojukeji.kafka.manager.common.entity.vo.normal.topic.HaClusterTopicHaStatusVO; +import com.xiaojukeji.kafka.manager.service.biz.ha.HaASRelationManager; +import com.xiaojukeji.kafka.manager.service.service.ha.HaClusterService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +/** + * @author zengqiao + * @date 20/4/23 + */ +@Api(tags = "RD-HA-Cluster维度相关接口(REST)") +@RestController +@RequestMapping(ApiPrefix.API_V1_RD_PREFIX) +public class RdHaClusterController { + @Autowired + private HaASRelationManager haASRelationManager; + + @Autowired + private HaClusterService haClusterService; + + @ApiOperation(value = "集群-主备Topic列表", notes = "如果传入secondClusterId,则主备关系必须是firstClusterId与secondClusterId的Topic") + @GetMapping(value = "clusters/{firstClusterId}/ha-topics") + @ResponseBody + public Result> getHATopics(@PathVariable Long firstClusterId, + @RequestParam(required = false) Long secondClusterId, + @RequestParam(required = false, defaultValue = "true") Boolean filterSystemTopics) { + return Result.buildSuc(haASRelationManager.getHATopics(firstClusterId, secondClusterId, filterSystemTopics != null && filterSystemTopics)); + } + + @ApiOperation(value = "集群基本信息列表", notes = "含高可用集群信息") + @GetMapping(value = "clusters/ha/basic-info") + @ResponseBody + public Result> getClusterBasicInfo() { + return haClusterService.listAllHA(); + } + + @ApiOperation(value = "集群Topic高可用状态信息", notes = "") + @GetMapping(value = "clusters/{firstClusterId}/ha-topics/status") + @ResponseBody + public Result> listHaStatusTopics(@PathVariable Long firstClusterId, + @RequestParam(required = false, defaultValue = "true") Boolean checkMetadata) { + return haASRelationManager.listHaStatusTopics(firstClusterId, checkMetadata); + } +} diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/config/DataSourceConfig.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/config/DataSourceConfig.java index 2d2a003a..07a2fb61 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/config/DataSourceConfig.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/config/DataSourceConfig.java @@ -1,8 +1,13 @@ package com.xiaojukeji.kafka.manager.web.config; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.core.config.GlobalConfig; +import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import org.apache.ibatis.session.SqlSessionFactory; -import org.mybatis.spring.SqlSessionFactoryBean; import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; @@ -19,6 +24,7 @@ import javax.sql.DataSource; * @date 20/3/17 */ @Configuration +@MapperScan("com.xiaojukeji.kafka.manager.dao.ha") public class DataSourceConfig { @Bean(name = "dataSource") @ConfigurationProperties(prefix = "spring.datasource.kafka-manager") @@ -30,10 +36,15 @@ public class DataSourceConfig { @Bean(name = "sqlSessionFactory") @Primary public SqlSessionFactory sqlSessionFactory(@Qualifier("dataSource") DataSource dataSource) throws Exception { - SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); + MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml")); bean.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:mybatis-config.xml")); + bean.setGlobalConfig(globalConfig()); + + //添加分页插件,不加这个,分页不生效 + bean.setPlugins(paginationInterceptor()); + return bean.getObject(); } @@ -48,4 +59,21 @@ public class DataSourceConfig { public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception { return new SqlSessionTemplate(sqlSessionFactory); } + + @Bean + public GlobalConfig globalConfig(){ + GlobalConfig globalConfig=new GlobalConfig(); + globalConfig.setBanner(false); + GlobalConfig.DbConfig dbConfig=new GlobalConfig.DbConfig(); + dbConfig.setIdType(IdType.AUTO); + globalConfig.setDbConfig(dbConfig); + return globalConfig; + } + + @Bean + public PaginationInterceptor paginationInterceptor() { + PaginationInterceptor page = new PaginationInterceptor(); + page.setDbType(DbType.MYSQL); + return page; + } } diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/ClusterModelConverter.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/ClusterModelConverter.java index d92967dd..8520d32a 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/ClusterModelConverter.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/ClusterModelConverter.java @@ -30,6 +30,7 @@ import com.xiaojukeji.kafka.manager.common.entity.pojo.ControllerDO; import com.xiaojukeji.kafka.manager.common.entity.pojo.RegionDO; import com.xiaojukeji.kafka.manager.service.cache.PhysicalClusterMetadataManager; import com.xiaojukeji.kafka.manager.service.utils.MetricsConvertUtils; +import org.springframework.beans.BeanUtils; import java.util.*; @@ -89,7 +90,7 @@ public class ClusterModelConverter { return null; } ClusterDetailVO vo = new ClusterDetailVO(); - CopyUtils.copyProperties(vo, dto); + BeanUtils.copyProperties(dto, vo); if (ValidateUtils.isNull(vo.getRegionNum())) { vo.setRegionNum(0); } diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/TopicModelConverter.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/TopicModelConverter.java index c7364cb5..4a2270b1 100644 --- a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/TopicModelConverter.java +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/converters/TopicModelConverter.java @@ -39,6 +39,7 @@ public class TopicModelConverter { vo.setDescription(dto.getDescription()); vo.setBootstrapServers(""); vo.setRegionNameList(dto.getRegionNameList()); + vo.setHaRelation(dto.getHaRelation()); if (!ValidateUtils.isNull(clusterDO)) { vo.setBootstrapServers(clusterDO.getBootstrapServers()); } diff --git a/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/handler/CustomGlobalExceptionHandler.java b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/handler/CustomGlobalExceptionHandler.java new file mode 100644 index 00000000..8f36d180 --- /dev/null +++ b/kafka-manager-web/src/main/java/com/xiaojukeji/kafka/manager/web/handler/CustomGlobalExceptionHandler.java @@ -0,0 +1,47 @@ +package com.xiaojukeji.kafka.manager.web.handler; + +import com.xiaojukeji.kafka.manager.common.entity.Result; +import com.xiaojukeji.kafka.manager.common.entity.ResultStatus; +import com.xiaojukeji.kafka.manager.common.utils.ConvertUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class CustomGlobalExceptionHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(CustomGlobalExceptionHandler.class); + + /** + * 处理参数异常并返回 + * @param me 异常 + * @return + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result methodArgumentNotValidException(MethodArgumentNotValidException me) { + List fieldErrorList = me.getBindingResult().getFieldErrors(); + + List errorList = fieldErrorList.stream().map(elem -> elem.getDefaultMessage()).collect(Collectors.toList()); + + return Result.buildFromRSAndMsg(ResultStatus.PARAM_ILLEGAL, ConvertUtil.list2String(errorList, ",")); + } + + @ExceptionHandler(NullPointerException.class) + public Result handleNullPointerException(Exception e) { + LOGGER.error("method=handleNullPointerException||errMsg=exception", e); + + return Result.buildFromRSAndMsg(ResultStatus.FAIL, "服务空指针异常"); + } + + @ExceptionHandler(Exception.class) + public Result handleException(Exception e) { + LOGGER.error("method=handleException||errMsg=exception", e); + + return Result.buildFromRSAndMsg(ResultStatus.FAIL, e.getMessage()); + } +} diff --git a/kafka-manager-web/src/main/resources/application.yml b/kafka-manager-web/src/main/resources/application.yml index 46ac7134..efe2ff82 100644 --- a/kafka-manager-web/src/main/resources/application.yml +++ b/kafka-manager-web/src/main/resources/application.yml @@ -13,9 +13,9 @@ spring: active: dev datasource: kafka-manager: - jdbc-url: jdbc:mysql://116.85.13.90:3306/logi_kafka_manager?characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8 + jdbc-url: jdbc:mysql://localhost:3306/logi_kafka_manager?characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2B8 username: root - password: DiDi2020@ + password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver main: allow-bean-definition-overriding: true @@ -127,3 +127,6 @@ notify: topic-name: didi-kafka-notify order: detail-url: http://127.0.0.1 + +d-kafka: + gateway-zk: 127.0.0.1:2181/sd \ No newline at end of file diff --git a/pom.xml b/pom.xml index d8c4411e..67662126 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ - 2.6.1 + 2.8.0_e 2.1.18.RELEASE 2.9.2 1.5.21 @@ -29,6 +29,7 @@ UTF-8 8.5.72 2.16.0 + 3.3.2 3.0.0 1.2.9 @@ -113,21 +114,11 @@ 1.2.16 - + - org.mybatis - mybatis - 3.4.6 - - - org.mybatis - mybatis-spring - 1.3.2 - - - org.mybatis.spring.boot - mybatis-spring-boot-starter - 1.3.2 + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version}