Compare commits

...

7 Commits

Author SHA1 Message Date
lensfrex 0ceb65be05
ref: 数据收集部分:批量数据上传时,对时间排序后再处理 7 months ago
lensfrex 3fb9308f95
impl: 完成前端面板相关基本功能 7 months ago
lensfrex c82c6d5799
ref: 调整前端面板监测数据格式,优化传输大小 7 months ago
lensfrex 41221a4bca
impl: 前端面板的简单监测数据查询以及监控规则管理 7 months ago
lensfrex 91e03bee52
impl: 初步的面板接口实现 7 months ago
lensfrex 1152e9b42c
impl: 添加初步的监测数据查询实现 7 months ago
lensfrex 5070304883
ref: 调整包结构,dao相关代码迁移至common 7 months ago
  1. 19
      docs/rition.sql
  2. 6
      rition-center/api/pom.xml
  3. 13
      rition-center/api/src/main/java/rition/backend/aop/exception/GlobalExceptionHandler.java
  4. 36
      rition-center/api/src/main/java/rition/backend/api/interceptor/NotFoundPathInterceptor.java
  5. 2
      rition-center/api/src/main/java/rition/backend/api/v1/collector/DataCollectingController.java
  6. 2
      rition-center/api/src/main/java/rition/backend/api/v1/dto/request/AlertRuleAddRequest.java
  7. 39
      rition-center/api/src/main/java/rition/backend/api/v1/dto/response/AlertResponse.java
  8. 34
      rition-center/api/src/main/java/rition/backend/api/v1/dto/response/ContractResponse.java
  9. 29
      rition-center/api/src/main/java/rition/backend/api/v1/dto/response/MetricDataResponse.java
  10. 54
      rition-center/api/src/main/java/rition/backend/api/v1/dto/response/RuleResponse.java
  11. 46
      rition-center/api/src/main/java/rition/backend/api/v1/panel/AlertHistoryController.java
  12. 59
      rition-center/api/src/main/java/rition/backend/api/v1/panel/AlertRuleController.java
  13. 51
      rition-center/api/src/main/java/rition/backend/api/v1/panel/ContractController.java
  14. 128
      rition-center/api/src/main/java/rition/backend/api/v1/panel/MetricsViewController.java
  15. 10
      rition-center/api/src/main/java/rition/backend/api/v1/panel/PanelController.java
  16. 35
      rition-center/api/src/main/java/rition/backend/configure/OpenAPIConfigure.java
  17. 17
      rition-center/api/src/main/java/rition/backend/configure/WebMvcConfigure.java
  18. 9
      rition-center/api/src/main/java/rition/backend/service/MetricDataCollectingService.java
  19. 7
      rition-center/api/src/main/java/rition/backend/service/panel/PanelMetricService.java
  20. 5
      rition-center/api/src/main/resources/application.yml
  21. 39
      rition-center/api/src/test/java/panel/MetricServiceTest.java
  22. 21
      rition-center/common/src/main/java/rition/common/configure/RedisLuaConfigure.java
  23. 2
      rition-center/common/src/main/java/rition/common/data/dao/BatchSQLInjector.java
  24. 9
      rition-center/common/src/main/java/rition/common/data/dao/mapper/AlertMapper.java
  25. 7
      rition-center/common/src/main/java/rition/common/data/dao/mapper/ContractMapper.java
  26. 35
      rition-center/common/src/main/java/rition/common/data/dao/mapper/MetricRecordMapper.java
  27. 9
      rition-center/common/src/main/java/rition/common/data/dao/mapper/RuleMapper.java
  28. 54
      rition-center/common/src/main/java/rition/common/data/dto/PagingData.java
  29. 54
      rition-center/common/src/main/java/rition/common/data/dto/service/AlertRuleDto.java
  30. 2
      rition-center/common/src/main/java/rition/common/data/dto/service/MetricDataDto.java
  31. 36
      rition-center/common/src/main/java/rition/common/data/dto/service/panel/AlertRuleAddDto.java
  32. 36
      rition-center/common/src/main/java/rition/common/data/dto/service/panel/AlertRuleDto.java
  33. 17
      rition-center/common/src/main/java/rition/common/data/dto/service/panel/ContractAddDto.java
  34. 5
      rition-center/common/src/main/java/rition/common/data/entity/AlertEntity.java
  35. 5
      rition-center/common/src/main/java/rition/common/data/entity/ContractEntity.java
  36. 21
      rition-center/common/src/main/java/rition/common/data/entity/RuleEntity.java
  37. 11
      rition-center/common/src/main/java/rition/common/data/enums/Constants.java
  38. 10
      rition-center/common/src/main/java/rition/common/exception/ServiceException.java
  39. 5
      rition-center/common/src/main/java/rition/common/exception/code/ServiceCode.java
  40. 79
      rition-center/common/src/main/resources/mapper/MetricRecordMapper.xml
  41. 7
      rition-center/common/src/main/resources/redis/const_len_queue.lua
  42. 19
      rition-center/pom.xml
  43. 32
      rition-center/service/collector/src/main/java/rition/service/collector/MetricCollectingService.java
  44. 8
      rition-center/service/collector/src/main/java/rition/service/collector/configure/MybatisPlusConfigure.java
  45. 12
      rition-center/service/collector/src/main/java/rition/service/collector/dao/mapper/MetricRecordMapper.java
  46. 5
      rition-center/service/collector/src/main/resources/mapper/MetricRecordMapper.xml
  47. 2
      rition-center/service/monitor/src/main/java/rition/service/monitor/MonitorService.java
  48. 2
      rition-center/service/monitor/src/main/resources/application-monitor.yml
  49. 48
      rition-center/service/panel/src/main/java/rition/service/panel/AlertHistoryService.java
  50. 78
      rition-center/service/panel/src/main/java/rition/service/panel/AlertRuleService.java
  51. 64
      rition-center/service/panel/src/main/java/rition/service/panel/ContractService.java
  52. 52
      rition-center/service/panel/src/main/java/rition/service/panel/MetricService.java
  53. 7
      rition-center/service/panel/src/main/java/rition/service/panel/PanelService.java
  54. 6
      rition-center/service/panel/src/main/resources/application-panel.yml
  55. 1
      rition-panel/package.json
  56. 12
      rition-panel/quasar.config.js
  57. 5
      rition-panel/src/boot/axios.js
  58. 4
      rition-panel/src/css/app.scss
  59. 4
      rition-panel/src/layouts/MainLayout.vue
  60. 45
      rition-panel/src/pages/AlertListPage.vue
  61. 107
      rition-panel/src/pages/ContractPage.vue
  62. 217
      rition-panel/src/pages/MetricPage.vue
  63. 144
      rition-panel/src/pages/RulePage.vue
  64. 5
      rition-panel/yarn.lock
  65. 4
      rition-probe/client/http.go
  66. 2
      rition-probe/main.go

@ -11,7 +11,7 @@
Target Server Version : 80027 (8.0.27) Target Server Version : 80027 (8.0.27)
File Encoding : 65001 File Encoding : 65001
Date: 08/05/2024 10:15:09 Date: 10/05/2024 14:22:09
*/ */
SET NAMES utf8mb4; SET NAMES utf8mb4;
@ -25,6 +25,7 @@ CREATE TABLE `alert` (
`id` bigint NOT NULL, `id` bigint NOT NULL,
`instance_id` varchar(64) NOT NULL COMMENT '出现警告的实例id', `instance_id` varchar(64) NOT NULL COMMENT '出现警告的实例id',
`rule` bigint NOT NULL COMMENT '触发的规则', `rule` bigint NOT NULL COMMENT '触发的规则',
`value` double NOT NULL COMMENT '警告时的数值',
`time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '警告出现的时间', `time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '警告出现的时间',
`status` tinyint NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@ -38,15 +39,13 @@ CREATE TABLE `alert` (
DROP TABLE IF EXISTS `contract`; DROP TABLE IF EXISTS `contract`;
CREATE TABLE `contract` ( CREATE TABLE `contract` (
`id` bigint NOT NULL, `id` bigint NOT NULL,
`instance_id` varchar(64) NOT NULL COMMENT '绑定的实例',
`contract` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '联系方式', `contract` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '联系方式',
`type` tinyint NOT NULL COMMENT '联系方式类型', `type` tinyint NOT NULL COMMENT '联系方式类型',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`status` tinyint NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_id` (`id`), KEY `idx_id` (`id`)
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ---------------------------- -- ----------------------------
@ -87,17 +86,15 @@ CREATE TABLE `record` (
DROP TABLE IF EXISTS `rule`; DROP TABLE IF EXISTS `rule`;
CREATE TABLE `rule` ( CREATE TABLE `rule` (
`id` bigint NOT NULL COMMENT '规则id', `id` bigint NOT NULL COMMENT '规则id',
`instance_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规则对应的实例id', `expr` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '需要计算的指标项或者表达式',
`expression` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '需要计算的指标项或者表达式', `cond` tinyint NOT NULL COMMENT '触发条件',
`condition` tinyint NOT NULL COMMENT '触发条件',
`threshold` varchar(32) NOT NULL COMMENT '阈值', `threshold` varchar(32) NOT NULL COMMENT '阈值',
`trigger` tinyint NOT NULL COMMENT '触发方法,实时计算或定时计算', `trig` tinyint NOT NULL COMMENT '触发方法,实时计算或定时计算',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '规则描述', `comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '规则描述',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`status` tinyint NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`), PRIMARY KEY (`id`)
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;

@ -43,10 +43,10 @@
<artifactId>notify</artifactId> <artifactId>notify</artifactId>
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>net.rition</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <artifactId>panel</artifactId>
<version>${revision}</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import rition.backend.api.v1.dto.response.Response; import rition.backend.api.v1.dto.response.Response;
import rition.common.exception.ServiceException; import rition.common.exception.ServiceException;
import rition.common.exception.code.ServiceCode; import rition.common.exception.code.ServiceCode;
@ -44,6 +45,18 @@ public class GlobalExceptionHandler {
return Response.error(ServiceCode.ApiNotImplement); return Response.error(ServiceCode.ApiNotImplement);
} }
/**
* 404
*
* @param e 异常
* @return 统一响应
*/
@ResponseBody
@ExceptionHandler(NoResourceFoundException.class)
public Response<Object> baseException(NoResourceFoundException e) {
return Response.error(ServiceCode.ApiNotImplement);
}
/** /**
* 处理参数不完整的请求异常 * 处理参数不完整的请求异常
* *

@ -0,0 +1,36 @@
package rition.backend.api.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Nonnull;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import rition.backend.api.v1.dto.response.Response;
import rition.common.exception.code.ServiceCode;
@Component
public class NotFoundPathInterceptor implements HandlerInterceptor {
private final ObjectMapper objectMapper;
public NotFoundPathInterceptor(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void postHandle(@Nonnull HttpServletRequest request,
@Nonnull HttpServletResponse servletResponse,
@Nonnull Object handler,
ModelAndView modelAndView) throws Exception {
if (servletResponse.getStatus() == HttpStatus.NOT_FOUND.value()) {
var response = Response.error(ServiceCode.ApiNotImplement);
var json = objectMapper.writeValueAsString(response);
servletResponse.setContentType("application/json");
servletResponse.setCharacterEncoding("utf-8");
servletResponse.getWriter().write(json);
}
}
}

@ -9,7 +9,7 @@ import rition.backend.annotation.paramter.RequestId;
import rition.backend.api.v1.dto.request.MetricDataUploadRequest; import rition.backend.api.v1.dto.request.MetricDataUploadRequest;
import rition.backend.api.v1.dto.response.Response; import rition.backend.api.v1.dto.response.Response;
import rition.backend.service.MetricDataCollectingService; import rition.backend.service.MetricDataCollectingService;
import rition.common.data.dto.MetricDataDto; import rition.common.data.dto.service.MetricDataDto;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;

@ -22,7 +22,7 @@ public class AlertRuleAddRequest {
/** /**
* 阈值 * 阈值
*/ */
private String threshold; private Double threshold;
/** /**
* 触发方法实时计算或定时计算 * 触发方法实时计算或定时计算

@ -0,0 +1,39 @@
package rition.backend.api.v1.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AlertResponse {
/**
* id
*/
private Long id;
/**
* 出现警告的实例id
*/
private String instanceId;
/**
* 触发的规则
*/
private Long rule;
/**
* 触发时的数值
*/
private Double value;
/**
* 警告出现的时间
*/
private Long time;
}

@ -0,0 +1,34 @@
package rition.backend.api.v1.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContractResponse {
/**
* id
*/
private Long id;
/**
* 联系方式
*/
private String contract;
/**
* 联系方式类型
*/
private Integer type;
/**
* create_time
*/
private Long createTime;
}

@ -0,0 +1,29 @@
package rition.backend.api.v1.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MetricDataResponse {
/**
* 监控指标值使用json格式存储
*/
private Map<String, MetricDataResponseItem> metricData;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MetricDataResponseItem {
private List<Long> time;
private List<Double> value;
}
}

@ -0,0 +1,54 @@
package rition.backend.api.v1.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class RuleResponse {
/**
* 规则id
*/
private Long id;
/**
* 需要计算的指标项或者表达式
*/
private String expression;
/**
* 触发条件
*/
private Integer condition;
/**
* 阈值
*/
private Double threshold;
/**
* 触发方法实时计算或定时计算
*/
private Integer trigger;
/**
* 规则描述
*/
private String description;
/**
* create_time
*/
private Long createTime;
/**
* update_time
*/
private Long updateTime;
}

@ -0,0 +1,46 @@
package rition.backend.api.v1.panel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import rition.backend.api.v1.dto.response.AlertResponse;
import rition.backend.api.v1.dto.response.Response;
import rition.common.data.dto.PagingData;
import rition.common.data.entity.AlertEntity;
import rition.service.panel.AlertHistoryService;
@RestController
@RequestMapping("/panel/alerts")
public class AlertHistoryController {
private final AlertHistoryService alertHistoryService;
public AlertHistoryController(AlertHistoryService alertHistoryService) {
this.alertHistoryService = alertHistoryService;
}
@GetMapping("/list")
public Response<PagingData<AlertResponse>> getContractList(
@RequestParam(value = "instance_id", required = false) String instanceId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
@RequestParam(value = "page_size", required = false, defaultValue = "10") Integer pageSize
) {
PagingData<AlertEntity> alertEntityPagingData = new PagingData<>(page, pageSize);
var result = alertHistoryService.getAlertHistory(instanceId, alertEntityPagingData);
PagingData<AlertResponse> alertResponsePagingData = PagingData.copyOnlyPagingValues(result);
for (AlertEntity entity : result.getData()) {
alertResponsePagingData.getData().add(AlertResponse.builder()
.id(entity.getId())
.instanceId(entity.getInstanceId())
.rule(entity.getRule())
.value(entity.getValue())
.time(entity.getTime().toEpochMilli())
.build()
);
}
return Response.success(alertResponsePagingData);
}
}

@ -3,24 +3,63 @@ package rition.backend.api.v1.panel;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import rition.backend.api.v1.dto.request.AlertRuleAddRequest; import rition.backend.api.v1.dto.request.AlertRuleAddRequest;
import rition.backend.api.v1.dto.response.Response; import rition.backend.api.v1.dto.response.Response;
import rition.backend.api.v1.dto.response.RuleResponse;
import rition.common.data.dto.service.panel.AlertRuleAddDto;
import rition.common.data.entity.RuleEntity;
import rition.service.panel.AlertRuleService;
import java.util.ArrayList;
import java.util.List;
@RestController @RestController
@RequestMapping("/rules") @RequestMapping("/panel/rules")
public class AlertRuleController { public class AlertRuleController {
@GetMapping("/list/{instanceId}") private final AlertRuleService alertRuleService;
public Response<Object> getAlertRuleList(@PathVariable("instanceId") String instanceId) {
return Response.success(); public AlertRuleController(AlertRuleService alertRuleService) {
this.alertRuleService = alertRuleService;
}
@GetMapping("/list")
public Response<List<RuleResponse>> getAlertRuleList() {
List<RuleEntity> ruleEntityList = alertRuleService.getRule();
List<RuleResponse> ruleResponseList = new ArrayList<>(ruleEntityList.size());
for (RuleEntity ruleEntity : ruleEntityList) {
RuleResponse response = new RuleResponse();
response.setId(ruleEntity.getId());
response.setExpression(ruleEntity.getExpr());
response.setCondition(ruleEntity.getCond());
response.setThreshold(ruleEntity.getThreshold());
response.setTrigger(ruleEntity.getTrig());
response.setDescription(ruleEntity.getComment());
response.setCreateTime(ruleEntity.getCreateTime().toEpochMilli());
response.setUpdateTime(ruleEntity.getUpdateTime().toEpochMilli());
ruleResponseList.add(response);
} }
@PostMapping("/add/{instanceId}") return Response.success(ruleResponseList);
public Response<Object> addAlertRule(@PathVariable("instanceId") String instanceId, }
@RequestBody AlertRuleAddRequest alertRuleAddRequest) {
@PostMapping("/add")
public Response<Object> addAlertRule(@RequestBody AlertRuleAddRequest alertRuleAddRequest) {
AlertRuleAddDto alertRuleAddDto = new AlertRuleAddDto();
alertRuleAddDto.setInstanceId(alertRuleAddRequest.getInstanceId());
alertRuleAddDto.setExpression(alertRuleAddRequest.getExpression());
alertRuleAddDto.setCondition(alertRuleAddRequest.getCondition());
alertRuleAddDto.setThreshold(alertRuleAddRequest.getThreshold());
alertRuleAddDto.setTrigger(alertRuleAddRequest.getTrigger());
alertRuleAddDto.setDescription(alertRuleAddRequest.getDescription());
alertRuleService.addAlertRule(alertRuleAddDto);
return Response.success(); return Response.success();
} }
@PostMapping("/delete/{instanceId}") @PostMapping("/delete")
public Response<Object> deleteAlertRule(@PathVariable("instanceId") String instanceId, public Response<Object> deleteAlertRule(@RequestParam("id") Long alertRuleId) {
@RequestParam("id") String alertRuleId) { alertRuleService.deleteRule(alertRuleId);
return Response.success(); return Response.success();
} }
} }

@ -2,25 +2,56 @@ package rition.backend.api.v1.panel;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import rition.backend.api.v1.dto.request.ContractAddRequest; import rition.backend.api.v1.dto.request.ContractAddRequest;
import rition.backend.api.v1.dto.response.ContractResponse;
import rition.backend.api.v1.dto.response.Response; import rition.backend.api.v1.dto.response.Response;
import rition.common.data.dto.service.panel.ContractAddDto;
import rition.common.data.entity.ContractEntity;
import rition.service.panel.ContractService;
import java.util.ArrayList;
import java.util.List;
@RestController @RestController
@RequestMapping("/contract") @RequestMapping("/panel/contract")
public class ContractController { public class ContractController {
@GetMapping("/list/{instanceId}") private final ContractService contractService;
public Response<Object> getContractList(@PathVariable("instanceId") String instanceId) {
return Response.success(); public ContractController(ContractService contractService) {
this.contractService = contractService;
}
@GetMapping("/list")
public Response<List<ContractResponse>> getContractList() {
List<ContractEntity> contractEntityList = contractService.getContractList();
List<ContractResponse> responseList = new ArrayList<>(contractEntityList.size());
for (var contractEntity : contractEntityList) {
ContractResponse response = new ContractResponse();
response.setId(contractEntity.getId());
response.setContract(contractEntity.getContract());
response.setType(contractEntity.getType());
response.setCreateTime(contractEntity.getCreateTime().toEpochMilli());
responseList.add(response);
} }
@PostMapping("/add/{instanceId}") return Response.success(responseList);
public Response<Object> addContract(@PathVariable("instanceId") String instanceId, }
@RequestBody ContractAddRequest contractAddRequest) {
@PostMapping("/add")
public Response<Object> addContract(@RequestBody ContractAddRequest contractAddRequest) {
ContractAddDto contractAddDto = new ContractAddDto();
contractAddDto.setContract(contractAddRequest.getContract());
contractAddDto.setType(contractAddRequest.getType());
contractService.addContract(contractAddDto);
return Response.success(); return Response.success();
} }
@PostMapping("/delete/{instanceId}") @PostMapping("/delete")
public Response<Object> deleteContract(@PathVariable("instanceId") String instanceId, public Response<Object> deleteContract(@RequestParam("id") Long contractId) {
@RequestParam("id") String contractId) { contractService.deleteContract(contractId);
return Response.success(); return Response.success();
} }
} }

@ -1,18 +1,138 @@
package rition.backend.api.v1.panel; package rition.backend.api.v1.panel;
import org.springframework.web.bind.annotation.GetMapping; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import rition.backend.api.v1.dto.request.MetricDataRequest; import rition.backend.api.v1.dto.request.MetricDataRequest;
import rition.backend.api.v1.dto.response.MetricDataResponse;
import rition.backend.api.v1.dto.response.Response; import rition.backend.api.v1.dto.response.Response;
import rition.common.data.dto.service.MetricDataDto;
import rition.common.data.entity.MetricRecordEntity;
import rition.common.data.enums.Constants;
import rition.service.panel.MetricService;
import java.time.Instant;
import java.util.*;
@Slf4j
@RestController @RestController
@RequestMapping("/panel/metrics") @RequestMapping("/panel/metrics")
public class MetricsViewController { public class MetricsViewController {
@GetMapping("/list") private final MetricService metricService;
public Response<Object> getMetrics(@RequestBody MetricDataRequest metricDataRequest) { private final RedisTemplate<String, Object> redisTemplate;
return Response.success();
public MetricsViewController(MetricService metricService, RedisTemplate<String, Object> redisTemplate) {
this.metricService = metricService;
this.redisTemplate = redisTemplate;
}
@PostMapping("/list")
public Response<MetricDataResponse> getMetrics(@RequestBody MetricDataRequest metricDataRequest) {
MetricDataResponse metricDataResponse = new MetricDataResponse();
metricDataResponse.setMetricData(new HashMap<>(metricDataRequest.getMetricItems().size()));
// 如果未给定范围参数,则使用最近一小时的数据
if (metricDataRequest.getStart() == null || metricDataRequest.getEnd() == null) {
List<Object> recentMetricDataList =
redisTemplate.opsForList().range(
Constants.RedisKeys.RECENT_METRIC_CACHE, 0, Constants.MAX_METRIC_CACHE_NUM
);
if (recentMetricDataList == null) {
return Response.success(MetricDataResponse.builder().metricData(new HashMap<>()).build());
}
for (String metricItem : metricDataRequest.getMetricItems()) {
metricDataResponse.getMetricData().put(metricItem, MetricDataResponse.MetricDataResponseItem.builder()
.time(new ArrayList<>(recentMetricDataList.size()))
.value(new ArrayList<>(recentMetricDataList.size()))
.build()
);
}
for (Object recentMetricData : recentMetricDataList) {
var metricDataDto = (MetricDataDto) recentMetricData;
Map<String, Double> data = metricDataDto.getData();
for (String metric : data.keySet()) {
var metricResponseData = metricDataResponse.getMetricData().get(metric);
metricResponseData.getTime().add(metricDataDto.getTimestamp());
metricResponseData.getValue().add(data.get(metric));
}
}
return Response.success(metricDataResponse);
}
var entityResult = metricService.getMetricDataRange(
metricDataRequest.getInstanceId(),
this.filterMetricItems(metricDataRequest.getMetricItems()),
Instant.ofEpochMilli(metricDataRequest.getStart()),
Instant.ofEpochMilli(metricDataRequest.getEnd())
);
// 转换数据
for (String metricItem : metricDataRequest.getMetricItems()) {
metricDataResponse.getMetricData().put(metricItem, MetricDataResponse.MetricDataResponseItem.builder()
.time(new ArrayList<>(entityResult.size()))
.value(new ArrayList<>(entityResult.size()))
.build()
);
}
for (MetricRecordEntity entity : entityResult) {
var data = entity.getMetricData();
for (String metric : data.keySet()) {
var metricResponseData = metricDataResponse.getMetricData().get(metric);
metricResponseData.getTime().add(entity.getTime().toEpochMilli());
metricResponseData.getValue().add(data.get(metric));
}
}
return Response.success(metricDataResponse);
}
private static final HashSet<String> allowedMetricItems = new HashSet<>(22);
static {
allowedMetricItems.add("up");
allowedMetricItems.add("node_load5");
allowedMetricItems.add("node_sockstat_TCP_tw");
allowedMetricItems.add("node_cpu_seconds_total");
allowedMetricItems.add("node_memory_Cached_bytes");
allowedMetricItems.add("node_memory_Buffers_bytes");
allowedMetricItems.add("node_memory_MemFree_bytes");
allowedMetricItems.add("node_disk_read_bytes_total");
allowedMetricItems.add("node_filesystem_free_bytes");
allowedMetricItems.add("node_filesystem_size_bytes");
allowedMetricItems.add("node_memory_MemTotal_bytes");
allowedMetricItems.add("node_netstat_Tcp_CurrEstab");
allowedMetricItems.add("node_filesystem_avail_bytes");
allowedMetricItems.add("node_disk_written_bytes_total");
allowedMetricItems.add("node_disk_reads_completed_total");
allowedMetricItems.add("node_network_receive_drop_total");
allowedMetricItems.add("node_disk_writes_completed_total");
allowedMetricItems.add("node_network_receive_bytes_total");
allowedMetricItems.add("node_network_transmit_drop_total");
allowedMetricItems.add("node_network_transmit_bytes_total");
allowedMetricItems.add("node_network_receive_packets_total");
allowedMetricItems.add("node_network_transmit_packets_total");
}
private List<String> filterMetricItems(List<String> metricItems) {
List<String> filteredMetricItems = new ArrayList<>(metricItems.size());
for (String metricItem : metricItems) {
if (allowedMetricItems.contains(metricItem)) {
filteredMetricItems.add(metricItem);
} else {
log.warn("[MetricService]: 请求的指标值含有非允许的指标值:{}", metricItem);
}
}
return filteredMetricItems;
} }
} }

@ -1,10 +0,0 @@
package rition.backend.api.v1.panel;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/panel")
public class PanelController {
}

@ -0,0 +1,35 @@
package rition.backend.configure;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@Getter
@Component
@Configuration
public class OpenAPIConfigure {
private String serverUrl;
@Value("${wusthelper.docs.server-url:/}")
public void setServerUrl(String serverUrl) {
this.serverUrl = serverUrl;
}
@Bean
public OpenAPI openAPI() {
var info = new Info()
.title("Rition-center")
.description("Rition-center api")
.version("v1");
return new OpenAPI()
.info(info);
}
}

@ -5,6 +5,8 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import rition.backend.api.interceptor.NotFoundPathInterceptor;
import rition.backend.api.interceptor.ResponseIdInterceptor; import rition.backend.api.interceptor.ResponseIdInterceptor;
import rition.backend.api.resolver.RequestIdArgumentResolver; import rition.backend.api.resolver.RequestIdArgumentResolver;
@ -12,25 +14,30 @@ import java.util.List;
@Configuration @Configuration
@SpringBootConfiguration @SpringBootConfiguration
public class WebAppConfig extends WebMvcConfigurationSupport { public class WebMvcConfigure implements WebMvcConfigurer {
private final RequestIdArgumentResolver requestIdArgumentResolver; private final RequestIdArgumentResolver requestIdArgumentResolver;
private final ResponseIdInterceptor requestIdInterceptor; private final ResponseIdInterceptor requestIdInterceptor;
private final NotFoundPathInterceptor notFoundPathInterceptor;
public WebAppConfig(RequestIdArgumentResolver requestIdArgumentResolver, public WebMvcConfigure(RequestIdArgumentResolver requestIdArgumentResolver,
ResponseIdInterceptor requestIdInterceptor) { ResponseIdInterceptor requestIdInterceptor, NotFoundPathInterceptor notFoundPathInterceptor) {
this.requestIdArgumentResolver = requestIdArgumentResolver; this.requestIdArgumentResolver = requestIdArgumentResolver;
this.requestIdInterceptor = requestIdInterceptor; this.requestIdInterceptor = requestIdInterceptor;
this.notFoundPathInterceptor = notFoundPathInterceptor;
} }
@Override @Override
protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(requestIdArgumentResolver); argumentResolvers.add(requestIdArgumentResolver);
} }
@Override @Override
protected void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestIdInterceptor) registry.addInterceptor(requestIdInterceptor)
.addPathPatterns("/**"); .addPathPatterns("/**");
registry.addInterceptor(notFoundPathInterceptor)
.addPathPatterns("/**");
} }
} }

@ -4,12 +4,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import rition.common.data.dto.MetricDataDto;
import rition.common.data.dto.log.RequestProcessTraceRecord; import rition.common.data.dto.log.RequestProcessTraceRecord;
import rition.common.data.dto.service.MetricDataDto;
import rition.common.data.enums.Constants;
import rition.service.collector.MetricCollectingService; import rition.service.collector.MetricCollectingService;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Map; import java.util.Map;
/** /**
@ -31,8 +30,6 @@ public class MetricDataCollectingService {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
} }
private static final String PROCESS_TRACE_REDIS_KEY_FORMAT = "rition:request:trace:%s";
/** /**
* 接收处理好的监控指标数据异步处理请求发送后处理状态使用requestId追踪处理结果放置于redis * 接收处理好的监控指标数据异步处理请求发送后处理状态使用requestId追踪处理结果放置于redis
* *
@ -57,7 +54,7 @@ public class MetricDataCollectingService {
} }
try { try {
redisTemplate.opsForValue().set(PROCESS_TRACE_REDIS_KEY_FORMAT.formatted(requestId), record); redisTemplate.opsForValue().set(Constants.RedisKeys.PROCESS_TRACE.formatted(requestId), record);
} catch (Exception e2) { } catch (Exception e2) {
log.error("请求结果保存出现异常: ", e2); log.error("请求结果保存出现异常: ", e2);
log.error("请求结果:{}", record); log.error("请求结果:{}", record);

@ -0,0 +1,7 @@
package rition.backend.service.panel;
import org.springframework.stereotype.Service;
@Service
public class PanelMetricService {
}

@ -5,9 +5,12 @@ server:
spring: spring:
profiles: profiles:
include: collector, monitor include: collector, monitor, panel
data: data:
redis: redis:
host: ${REDIS_HOST:127.0.0.1} host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Test2333!} password: ${REDIS_PASSWORD:Test2333!}
logging:
level:
rition: debug

@ -0,0 +1,39 @@
package panel;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.AutoConfigureMybatis;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import rition.backend.RitionBackendMain;
import rition.common.data.dao.mapper.MetricRecordMapper;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@MybatisTest
@ContextConfiguration(classes = RitionBackendMain.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MetricServiceTest {
@Autowired
MetricRecordMapper metricRecordMapper;
@Test
public void testGetMetricRecord() {
List<String> metricItems = new ArrayList<>();
metricItems.add("node_network_receive_packets_total");
metricItems.add("node_sockstat_TCP_tw");
var result = metricRecordMapper.getMetricDataGroupByHour(
"7273a1ea-0089-4674-b606-b1b8d809d866",
metricItems,
Instant.parse("2024-04-17T14:00:00.00Z"),
Instant.parse("2024-04-17T16:00:00.00Z")
);
System.out.println(Arrays.toString(result.toArray()));
}
}

@ -0,0 +1,21 @@
package rition.common.configure;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
@Configuration
public class RedisLuaConfigure {
@Bean(name = "constLenQueueRedisScript")
public RedisScript<Object> constLenQueueRedisScript() {
Resource luaResource = new ClassPathResource("redis/const_len_queue.lua");
DefaultRedisScript<Object> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(luaResource));
return redisScript;
}
}

@ -1,4 +1,4 @@
package rition.service.collector.dao; package rition.common.data.dao;
import com.baomidou.mybatisplus.core.injector.AbstractMethod; import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector; import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector;

@ -0,0 +1,9 @@
package rition.common.data.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import rition.common.data.entity.AlertEntity;
@Mapper
public interface AlertMapper extends BaseMapper<AlertEntity> {
}

@ -0,0 +1,7 @@
package rition.common.data.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import rition.common.data.entity.ContractEntity;
public interface ContractMapper extends BaseMapper<ContractEntity> {
}

@ -0,0 +1,35 @@
package rition.common.data.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Component;
import rition.common.data.entity.MetricRecordEntity;
import java.time.Instant;
import java.util.List;
@Mapper
@Component
public interface MetricRecordMapper extends BaseMapper<MetricRecordEntity> {
int insertBatchSomeColumn(List<MetricRecordEntity> entityList);
List<MetricRecordEntity> getMetricDataGroupByHour(
@Param("instanceId") String instanceId,
@Param("metricItems") List<String> metricItems,
@Param("startTime") Instant startTime, @Param("endTime") Instant endTime
);
List<MetricRecordEntity> getMetricDataGroupByMinute(
@Param("instanceId") String instanceId,
@Param("metricItems") List<String> metricItems,
@Param("startTime") Instant startTime, @Param("endTime") Instant endTime
);
List<MetricRecordEntity> getMetricDataGroupBySomeMinute(
@Param("minutes") Integer minutes,
@Param("instanceId") String instanceId,
@Param("metricItems") List<String> metricItems,
@Param("startTime") Instant startTime, @Param("endTime") Instant endTime
);
}

@ -0,0 +1,9 @@
package rition.common.data.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import rition.common.data.entity.RuleEntity;
@Mapper
public interface RuleMapper extends BaseMapper<RuleEntity> {
}

@ -0,0 +1,54 @@
package rition.common.data.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.Collection;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PagingData<T> {
private long currentPage;
private long pageSize;
private long totalPage;
private long totalResult;
private Collection<T> data;
public PagingData(long currentPage, long pageSize) {
this.currentPage = currentPage;
this.pageSize = pageSize;
}
public PagingData<T> copy() {
PagingData<T> pagingData = new PagingData<>();
pagingData.setCurrentPage(this.getCurrentPage());
pagingData.setPageSize(this.getPageSize());
pagingData.setTotalPage(this.getTotalPage());
pagingData.setTotalResult(this.getTotalResult());
pagingData.setData(this.getData());
return pagingData;
}
public static <K> PagingData<K> copyOnlyPagingValues(PagingData<?> source) {
PagingData<K> pagingData = new PagingData<>();
pagingData.setCurrentPage(source.getCurrentPage());
pagingData.setPageSize(source.getPageSize());
pagingData.setTotalPage(source.getTotalPage());
pagingData.setTotalResult(source.getTotalResult());
pagingData.setData(new ArrayList<>(source.data.size()));
return pagingData;
}
}

@ -0,0 +1,54 @@
package rition.common.data.dto.service;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AlertRuleDto {
/**
* 规则id
*/
private Long id;
/**
* 规则对应的实例id
*/
private String instanceId;
/**
* 需要计算的指标项或者表达式
*/
private String expression;
/**
* 触发条件
*/
private Integer condition;
/**
* 阈值
*/
private Double threshold;
/**
* 触发方法实时计算或定时计算
*/
private Integer trigger;
/**
* 规则描述
*/
private String description;
/**
* create_time
*/
private Instant createTime;
}

@ -1,5 +1,5 @@
package rition.common.data.dto; package rition.common.data.dto.service;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;

@ -0,0 +1,36 @@
package rition.common.data.dto.service.panel;
import lombok.Data;
@Data
public class AlertRuleAddDto {
/**
* 规则对应的实例id
*/
private String instanceId;
/**
* 需要计算的指标项或者表达式
*/
private String expression;
/**
* 触发条件
*/
private Integer condition;
/**
* 阈值
*/
private Double threshold;
/**
* 触发方法实时计算或定时计算
*/
private Integer trigger;
/**
* 规则描述
*/
private String description;
}

@ -0,0 +1,36 @@
package rition.common.data.dto.service.panel;
import lombok.Data;
@Data
public class AlertRuleDto {
/**
* 规则对应的实例id
*/
private String instanceId;
/**
* 需要计算的指标项或者表达式
*/
private String expression;
/**
* 触发条件
*/
private Integer condition;
/**
* 阈值
*/
private Double threshold;
/**
* 触发方法实时计算或定时计算
*/
private Integer trigger;
/**
* 规则描述
*/
private String description;
}

@ -0,0 +1,17 @@
package rition.common.data.dto.service.panel;
import lombok.Data;
@Data
public class ContractAddDto {
/**
* 联系方式
*/
private String contract;
/**
* 联系方式类型
*/
private Integer type;
}

@ -29,6 +29,11 @@ public class AlertEntity {
*/ */
private Long rule; private Long rule;
/**
* 触发时的数值
*/
private Double value;
/** /**
* 警告出现的时间 * 警告出现的时间
*/ */

@ -22,11 +22,6 @@ public class ContractEntity {
*/ */
private Long id; private Long id;
/**
* 绑定的实例
*/
private String instanceId;
/** /**
* 联系方式 * 联系方式
*/ */

@ -23,34 +23,29 @@ public class RuleEntity {
private Long id; private Long id;
/** /**
* 规则对应的实例id * 需要计算的指标项或者表达式(expression)
*/ */
private String instanceId; private String expr;
/** /**
* 需要计算的指标项或者表达式 * 触发条件(condition)
*/ */
private String expression; private Integer cond;
/**
* 触发条件
*/
private Integer condition;
/** /**
* 阈值 * 阈值
*/ */
private String threshold; private Double threshold;
/** /**
* 触发方法实时计算或定时计算 * 触发方法实时计算或定时计算(trigger)
*/ */
private Integer trigger; private Integer trig;
/** /**
* 规则描述 * 规则描述
*/ */
private String description; private String comment;
/** /**
* create_time * create_time

@ -9,4 +9,15 @@ public class Constants {
public static final Integer NORMAL = 0; public static final Integer NORMAL = 0;
public static final Integer DELETED = 1; public static final Integer DELETED = 1;
} }
public static class RedisKeys {
public static final String RULE_CACHE = "rition:cache:rule";
public static final String RECENT_METRIC_CACHE = "rition:cache:metrics";
public static final String PROCESS_TRACE = "rition:request:trace:%s";
}
/**
* 缓存最近一小时的数据
*/
public static final int MAX_METRIC_CACHE_NUM = 3600/5;
} }

@ -43,4 +43,14 @@ public class ServiceException extends RuntimeException {
public static void error(int code) throws ServiceException { public static void error(int code) throws ServiceException {
throw new ServiceException(code); throw new ServiceException(code);
} }
/**
* 直接按错误码抛出异常
*
* @param code 错误码
* @throws ServiceException .
*/
public static void error(ServiceCode code) throws ServiceException {
throw new ServiceException(code);
}
} }

@ -13,7 +13,10 @@ public enum ServiceCode {
ParamWrong(10_00_03, "参数错误"), ParamWrong(10_00_03, "参数错误"),
PermissionDenied(10_00_04, "权限不足"), PermissionDenied(10_00_04, "权限不足"),
TokenInvalid(10_00_05, "用户凭据无效,登录态失效"), TokenInvalid(10_00_05, "用户凭据无效,登录态失效"),
UnknownErr(10_00_06, "服务器内部错误"); UnknownErr(10_00_06, "服务器内部错误"),
ContractTooManyData(11_00_00, "联系通知信息过多"),
;
private final int code; private final int code;

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="rition.common.data.dao.mapper.MetricRecordMapper">
<resultMap id="metricDataEntity" type="rition.common.data.entity.MetricRecordEntity">
<result property="metricData" column="metric_data" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
</resultMap>
<select id="getMetricDataGroupByHour" resultMap="metricDataEntity" resultType="rition.common.data.entity.MetricRecordEntity">
SELECT JSON_OBJECT(
<foreach collection="metricItems" item="metricItem" separator=",">
"${metricItem}", ${metricItem}
</foreach>
) AS metric_data,
t AS time
FROM (
SELECT
<foreach collection="metricItems" item="metricItem">
avg(JSON_EXTRACT(metric_data, "$.${metricItem}")) as ${metricItem},
</foreach>
DATE_FORMAT(time,'%Y-%m-%d %H:00:00') as t
FROM record WHERE `time` BETWEEN #{startTime} and #{endTime} and `instance_id` = #{instanceId}
GROUP BY t ORDER BY t
) AS tab;
<!--
SELECT
JSON_OBJECT('node_sockstat_TCP_tw', node_sockstat_TCP_tw),
t AS time
FROM (
SELECT
avg(JSON_EXTRACT(metric_data, "$.node_sockstat_TCP_tw")) node_sockstat_TCP_tw,
DATE_FORMAT(time,'%Y-%m-%d %H:00:00') as t
FROM record
WHERE `time` BETWEEN '2024-04-17 14:00:00' and '2024-04-17 15:00:00'
GROUP BY t ORDER BY t
) AS tab
-->
</select>
<select id="getMetricDataGroupByMinute" resultMap="metricDataEntity" resultType="rition.common.data.entity.MetricRecordEntity">
SELECT JSON_OBJECT(
<foreach collection="metricItems" item="metricItem" separator=",">
"${metricItem}", ${metricItem}
</foreach>
) AS metric_data,
t AS time
FROM (
SELECT
<foreach collection="metricItems" item="metricItem">
avg(JSON_EXTRACT(metric_data, "$.${metricItem}")) as ${metricItem},
</foreach>
DATE_FORMAT(time,'%Y-%m-%d %H:%i:00') as t
FROM record WHERE `time` BETWEEN #{startTime} and #{endTime} and `instance_id` = #{instanceId}
GROUP BY t ORDER BY t
) AS tab;
</select>
<select id="getMetricDataGroupBySomeMinute" resultMap="metricDataEntity" resultType="rition.common.data.entity.MetricRecordEntity">
SELECT JSON_OBJECT(
<foreach collection="metricItems" item="metricItem" separator=",">
"${metricItem}", ${metricItem}
</foreach>
) AS metric_data,
t AS time
FROM (
SELECT
<foreach collection="metricItems" item="metricItem">
avg(JSON_EXTRACT(metric_data, "$.${metricItem}")) as ${metricItem},
</foreach>
DATE_FORMAT(
concat( date( `time` ), ' ', HOUR ( `time` ), ':', floor( MINUTE ( `time` ) / #{minutes} ) * ${minutes} ),
'%Y-%m-%d %H:%i:00'
) as t
FROM record WHERE `time` BETWEEN #{startTime} and #{endTime} and `instance_id` = #{instanceId}
GROUP BY t ORDER BY t
) AS tab;
</select>
</mapper>

@ -0,0 +1,7 @@
local key = KEYS[1]
local max_len = tonumber(ARGV[1])
local val = ARGV[2]
if (redis.call('llen', key) > max_len) then
redis.call('lpop', key)
end
redis.call('rpush', key, val)

@ -33,6 +33,8 @@
<mybatis-sb-starter.version>3.0.3</mybatis-sb-starter.version> <mybatis-sb-starter.version>3.0.3</mybatis-sb-starter.version>
<mybatis.version>3.5.16</mybatis.version> <mybatis.version>3.5.16</mybatis.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version> <mybatis-plus.version>3.5.6</mybatis-plus.version>
<spring-doc.version>2.5.0</spring-doc.version>
</properties> </properties>
<dependencies> <dependencies>
@ -71,6 +73,23 @@
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${spring-doc.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>${spring-doc.version}</version>
</dependency>
<!-- id生成 --> <!-- id生成 -->
<dependency> <dependency>
<groupId>com.github.yitter</groupId> <groupId>com.github.yitter</groupId>

@ -2,12 +2,16 @@ package rition.service.collector;
import com.github.yitter.idgen.YitIdHelper; import com.github.yitter.idgen.YitIdHelper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import rition.common.data.dto.MetricDataDto; import rition.common.data.dao.mapper.MetricRecordMapper;
import rition.common.data.dto.service.MetricDataDto;
import rition.common.data.entity.MetricRecordEntity; import rition.common.data.entity.MetricRecordEntity;
import rition.common.data.enums.Constants;
import rition.service.collector.configure.CollectorServiceKafkaConfigure; import rition.service.collector.configure.CollectorServiceKafkaConfigure;
import rition.service.collector.dao.mapper.MetricRecordMapper;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
@ -25,13 +29,23 @@ public class MetricCollectingService {
private final String collectedDataTopic; private final String collectedDataTopic;
private final MetricRecordMapper metricRecordMapper; private final MetricRecordMapper metricRecordMapper;
private final RedisTemplate<String, MetricDataDto> redisTemplate;
private final RedisScript<Object> constLenQueueRedisScript;
public MetricCollectingService(KafkaTemplate<String, MetricDataDto> kafkaTemplate, public MetricCollectingService(KafkaTemplate<String, MetricDataDto> kafkaTemplate,
CollectorServiceKafkaConfigure collectorServiceKafkaConfigure, CollectorServiceKafkaConfigure collectorServiceKafkaConfigure,
MetricRecordMapper metricRecordMapper) { MetricRecordMapper metricRecordMapper,
RedisTemplate<String, MetricDataDto> redisTemplate,
@Qualifier("constLenQueueRedisScript") RedisScript<Object> constLenQueueRedisScript) {
this.kafkaTemplate = kafkaTemplate; this.kafkaTemplate = kafkaTemplate;
this.collectedDataTopic = collectorServiceKafkaConfigure.getDataCollecting().getTopic(); this.collectedDataTopic = collectorServiceKafkaConfigure.getDataCollecting().getTopic();
this.metricRecordMapper = metricRecordMapper; this.metricRecordMapper = metricRecordMapper;
this.redisTemplate = redisTemplate;
this.constLenQueueRedisScript = constLenQueueRedisScript;
} }
private static final int METRIC_NUMS = 22; private static final int METRIC_NUMS = 22;
@ -47,7 +61,8 @@ public class MetricCollectingService {
public void receiveData(Map<Long, Map<String, MetricDataDto>> collectedMetricData) { public void receiveData(Map<Long, Map<String, MetricDataDto>> collectedMetricData) {
List<MetricRecordEntity> batchSaveEntityList = new ArrayList<>(DEFAULT_DB_BATCH_SIZE); List<MetricRecordEntity> batchSaveEntityList = new ArrayList<>(DEFAULT_DB_BATCH_SIZE);
int turn = 0; int turn = 0;
for (Long timestamp : collectedMetricData.keySet()) { List<Long> sortedTime = collectedMetricData.keySet().stream().sorted().toList();
for (Long timestamp : sortedTime) {
Map<String, MetricDataDto> instanceMetricData = collectedMetricData.get(timestamp); Map<String, MetricDataDto> instanceMetricData = collectedMetricData.get(timestamp);
for (String instanceId : instanceMetricData.keySet()) { for (String instanceId : instanceMetricData.keySet()) {
MetricDataDto metricDataDto = instanceMetricData.get(instanceId); MetricDataDto metricDataDto = instanceMetricData.get(instanceId);
@ -64,7 +79,7 @@ public class MetricCollectingService {
// kafka发布 // kafka发布
kafkaTemplate.send(this.collectedDataTopic, metricDataDto); kafkaTemplate.send(this.collectedDataTopic, metricDataDto);
// 分批入库 // 分批入库,只是针对大批量请求的处理
batchSaveEntityList.add(entity); batchSaveEntityList.add(entity);
if (turn >= DEFAULT_DB_BATCH_SIZE) { if (turn >= DEFAULT_DB_BATCH_SIZE) {
metricRecordMapper.insertBatchSomeColumn(batchSaveEntityList); metricRecordMapper.insertBatchSomeColumn(batchSaveEntityList);
@ -74,6 +89,13 @@ public class MetricCollectingService {
} else { } else {
turn++; turn++;
} }
// 缓存最近数据
redisTemplate.execute(
constLenQueueRedisScript,
List.of(Constants.RedisKeys.RECENT_METRIC_CACHE),
Constants.MAX_METRIC_CACHE_NUM, metricDataDto
);
} }
} }

@ -1,8 +0,0 @@
package rition.service.collector.configure;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfigure {
}

@ -1,12 +0,0 @@
package rition.service.collector.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import rition.common.data.entity.MetricRecordEntity;
import java.util.List;
@Mapper
public interface MetricRecordMapper extends BaseMapper<MetricRecordEntity> {
int insertBatchSomeColumn(List<MetricRecordEntity> entityList);
}

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="rition.service.collector.dao.mapper.MetricRecordMapper">
</mapper>

@ -3,7 +3,7 @@ package rition.service.monitor;
import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import rition.common.data.dto.MetricDataDto; import rition.common.data.dto.service.MetricDataDto;
@Service @Service
public class MonitorService { public class MonitorService {

@ -13,7 +13,7 @@ spring:
spring: spring:
json: json:
trusted: trusted:
packages: "rition.common.data.dto" packages: "rition.common.data.*"
producer: producer:
retries: 4 retries: 4
compression-type: zstd compression-type: zstd

@ -0,0 +1,48 @@
package rition.service.panel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Service;
import rition.common.data.dao.mapper.AlertMapper;
import rition.common.data.dto.PagingData;
import rition.common.data.entity.AlertEntity;
import rition.common.data.enums.Constants;
@Service
public class AlertHistoryService {
private final AlertMapper alertMapper;
public AlertHistoryService(AlertMapper alertMapper) {
this.alertMapper = alertMapper;
}
/**
* 分页获取报警信息
*
* @param instanceId 实例id
* @param pagingData 分页数据查询完成后会对此对象进行修改
* @return 分页数据含结果实际上与参数里的pagingData是同一个对象
*/
public PagingData<AlertEntity> getAlertHistory(String instanceId, PagingData<AlertEntity> pagingData) {
LambdaQueryWrapper<AlertEntity> query;
if (instanceId != null) {
query = new LambdaQueryWrapper<AlertEntity>()
.eq(AlertEntity::getInstanceId, instanceId)
.eq(AlertEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
} else {
query = new LambdaQueryWrapper<AlertEntity>()
.eq(AlertEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
}
Page<AlertEntity> page = new Page<>(pagingData.getCurrentPage(), pagingData.getPageSize());
var result = alertMapper.selectPage(page, query);
pagingData.setData(result.getRecords());
pagingData.setTotalResult(result.getTotal());
pagingData.setTotalPage(result.getPages());
pagingData.setCurrentPage(result.getCurrent());
pagingData.setPageSize(result.getSize());
return pagingData;
}
}

@ -0,0 +1,78 @@
package rition.service.panel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.github.yitter.idgen.YitIdHelper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import rition.common.data.dao.mapper.RuleMapper;
import rition.common.data.dto.service.AlertRuleDto;
import rition.common.data.dto.service.panel.AlertRuleAddDto;
import rition.common.data.entity.RuleEntity;
import rition.common.data.enums.Constants;
import java.time.Instant;
import java.util.List;
@Service
public class AlertRuleService {
private final RuleMapper ruleMapper;
private final RedisTemplate<String, AlertRuleDto> redisTemplate;
public AlertRuleService(RuleMapper ruleMapper,
RedisTemplate<String, AlertRuleDto> redisTemplate) {
this.ruleMapper = ruleMapper;
this.redisTemplate = redisTemplate;
}
public void addAlertRule(AlertRuleAddDto alertRuleAddDto) {
RuleEntity ruleEntity = new RuleEntity();
ruleEntity.setId(YitIdHelper.nextId());
ruleEntity.setExpr(alertRuleAddDto.getExpression());
ruleEntity.setCond(alertRuleAddDto.getCondition());
ruleEntity.setThreshold(alertRuleAddDto.getThreshold());
ruleEntity.setTrig(alertRuleAddDto.getTrigger());
ruleEntity.setComment(alertRuleAddDto.getDescription());
var now = Instant.now();
ruleEntity.setCreateTime(now);
ruleEntity.setUpdateTime(now);
ruleEntity.setStatus(Constants.EntityCommonStatus.NORMAL);
ruleMapper.insert(ruleEntity);
AlertRuleDto alertRuleDto = new AlertRuleDto();
alertRuleDto.setId(ruleEntity.getId());
alertRuleDto.setExpression(ruleEntity.getExpr());
alertRuleDto.setCondition(ruleEntity.getCond());
alertRuleDto.setThreshold(ruleEntity.getThreshold());
alertRuleDto.setTrigger(ruleEntity.getTrig());
alertRuleDto.setDescription(ruleEntity.getComment());
alertRuleDto.setCreateTime(now);
// 规则缓存到redis
redisTemplate.opsForHash().put(Constants.RedisKeys.RULE_CACHE, alertRuleDto.getId().toString(), alertRuleDto);
}
public List<RuleEntity> getRule() {
var query = new LambdaQueryWrapper<RuleEntity>()
.eq(RuleEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
return ruleMapper.selectList(query);
}
public int deleteRule(Long ruleId) {
var query = new LambdaUpdateWrapper<RuleEntity>()
.set(RuleEntity::getStatus, Constants.EntityCommonStatus.DELETED)
.eq(RuleEntity::getId, ruleId)
.eq(RuleEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
var updatedRows = ruleMapper.update(query);
// 删除相应的缓存
if (updatedRows != 0) {
redisTemplate.opsForHash().delete(Constants.RedisKeys.RULE_CACHE, ruleId);
}
return updatedRows;
}
}

@ -0,0 +1,64 @@
package rition.service.panel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.github.yitter.idgen.YitIdHelper;
import org.springframework.stereotype.Service;
import rition.common.data.dao.mapper.ContractMapper;
import rition.common.data.dto.service.panel.ContractAddDto;
import rition.common.data.entity.ContractEntity;
import rition.common.data.enums.Constants;
import rition.common.exception.ServiceException;
import rition.common.exception.code.ServiceCode;
import java.time.Instant;
import java.util.List;
@Service
public class ContractService {
private final ContractMapper contractMapper;
public ContractService(ContractMapper contractMapper) {
this.contractMapper = contractMapper;
}
public List<ContractEntity> getContractList() {
var query = new LambdaQueryWrapper<ContractEntity>()
.eq(ContractEntity::getStatus, Constants.EntityCommonStatus.NORMAL)
.last("limit 100");
return contractMapper.selectList(query);
}
private static final int MAX_CONTRACT_NUM = 20;
public void addContract(ContractAddDto contractAddDto) {
var countQuery = new LambdaQueryWrapper<ContractEntity>()
.eq(ContractEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
var count = contractMapper.selectCount(countQuery);
if (count > MAX_CONTRACT_NUM) {
ServiceException.error(ServiceCode.ContractTooManyData);
}
var now = Instant.now();
var entity = ContractEntity.builder()
.id(YitIdHelper.nextId())
.contract(contractAddDto.getContract())
.type(contractAddDto.getType())
.createTime(now)
.updateTime(now)
.status(Constants.EntityCommonStatus.NORMAL)
.build();
contractMapper.insert(entity);
}
public void deleteContract(Long id) {
var query = new LambdaUpdateWrapper<ContractEntity>()
.set(ContractEntity::getStatus, Constants.EntityCommonStatus.DELETED)
.eq(ContractEntity::getId, id)
.eq(ContractEntity::getStatus, Constants.EntityCommonStatus.NORMAL);
contractMapper.update(query);
}
}

@ -0,0 +1,52 @@
package rition.service.panel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import rition.common.data.dao.mapper.MetricRecordMapper;
import rition.common.data.entity.MetricRecordEntity;
import java.time.Instant;
import java.util.List;
@Slf4j
@Service
public class MetricService {
private final MetricRecordMapper metricRecordMapper;
public MetricService(MetricRecordMapper metricRecordMapper) {
this.metricRecordMapper = metricRecordMapper;
}
private static final long HOUR = 3600 * 1000;
private static final long DAY = 24 * 3600 * 1000;
private static final long MONTH = 30L * 24 * 3600 * 1000;
/**
* 按时间范围获取监测数据
*
* @param instanceId 实例id
* @param metricItems 监测指标项
* @param start 开始时间
* @param end 结束时间
* @return 监测指标数据指标数据为给定的指标平均统计
*/
public List<MetricRecordEntity> getMetricDataRange(String instanceId,
List<String> metricItems,
Instant start, Instant end) {
// 时间跨度小于6小时:时间粒度为1分钟
// 时间跨度大于6小时:时间粒度为5分钟
// 时间跨度大于12小时:时间粒度为10分钟
// 时间跨度超过15天:时间粒度为小时
var diff = end.minusMillis(start.toEpochMilli()).toEpochMilli();
if (diff < HOUR * 6) {
return metricRecordMapper.getMetricDataGroupByMinute(instanceId, metricItems, start, end);
} else if (diff < HOUR * 12) {
return metricRecordMapper.getMetricDataGroupBySomeMinute(5, instanceId, metricItems, start, end);
} else if (diff < DAY * 15) {
return metricRecordMapper.getMetricDataGroupBySomeMinute(10, instanceId, metricItems, start, end);
} else {
return metricRecordMapper.getMetricDataGroupByHour(instanceId, metricItems, start, end);
}
}
}

@ -1,7 +0,0 @@
package rition.service.panel;
import org.springframework.stereotype.Service;
@Service
public class PanelService {
}

@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/rition?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: root
password: Test2333!

@ -17,6 +17,7 @@
"axios": "^1.2.1", "axios": "^1.2.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"element-plus": "^2.7.2", "element-plus": "^2.7.2",
"moment": "^2.30.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"vue": "^3.4.18", "vue": "^3.4.18",

@ -82,7 +82,15 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: { devServer: {
// https: true // https: true
open: false // opens browser window automatically open: false, // opens browser window automatically
proxy: {
// with options
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '/api')
}
}
}, },
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
@ -100,7 +108,7 @@ module.exports = configure(function (/* ctx */) {
// directives: [], // directives: [],
lang: 'zh-CN', lang: 'zh-CN',
// Quasar plugins // Quasar plugins
plugins: [] plugins: ['Dialog', 'Loading']
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations

@ -7,7 +7,10 @@ import axios from 'axios'
// good idea to move this instance creation inside of the // good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually // "export default () => {}" function below (which runs individually
// for each client) // for each client)
const api = axios.create({ baseURL: 'https://api.example.com' }) const api = axios.create({
baseURL: window.location.origin + "/api",
timeout: 10 * 1000
})
export default boot(({ app }) => { export default boot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api // for use inside Vue files (Options API) through this.$axios and this.$api

@ -2,3 +2,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html, body, #q-app {
height: 100%;
}

@ -24,7 +24,7 @@
</q-list> </q-list>
</q-drawer> </q-drawer>
<q-page-container> <q-page-container class="h-full">
<router-view/> <router-view/>
</q-page-container> </q-page-container>
</q-layout> </q-layout>
@ -45,7 +45,7 @@ const linksList = [
link: '/' link: '/'
}, },
{ {
title: '通知信息', title: '通知设置',
icon: 'notifications', icon: 'notifications',
link: '/contract' link: '/contract'
}, },

@ -1,17 +1,58 @@
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import {api} from "boot/axios";
import moment from "moment";
import {useQuasar} from "quasar";
const quasar = useQuasar()
const alertList = ref([])
const instanceId = ref()
function getAlerts() {
api.get('/panel/alerts/list')
.then(resp => {
const response = resp.data
const data = response.data
alertList.value = data.data
})
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
getAlerts()
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 p-2"> <div class="flex flex-col gap-2 p-2">
<div class="flex flex-row gap-4 items-center">
<q-input class="w-96" v-model="instanceId" label="实例ID"/>
<el-table border style="width: 100%" height="250"> <q-separator vertical></q-separator>
<q-btn unelevated color="primary" @click="getAlerts">刷新</q-btn>
</div>
<el-table :data="alertList" border style="width: 100%;height: 100%">
<el-table-column fixed prop="id" label="ID"/> <el-table-column fixed prop="id" label="ID"/>
<el-table-column prop="instanceId" label="实例ID"/> <el-table-column prop="instanceId" label="实例ID"/>
<el-table-column prop="rule" label="触发规则"/> <el-table-column prop="rule" label="触发规则"/>
<el-table-column prop="value" label="触发值"/> <el-table-column prop="value" label="触发值"/>
<el-table-column prop="time" label="时间"/> <el-table-column label="时间">
<template #default="scope">
<span>{{ moment(scope.row.time).format("YYYY-MM-DD HH:mm:ss") }}</span>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>

@ -1,5 +1,10 @@
<script setup> <script setup>
import {reactive, ref} from "vue"; import {reactive, ref} from "vue";
import {api} from "boot/axios";
import moment from "moment/moment";
import {useQuasar} from "quasar";
const quasar = useQuasar()
const addDialogPanelOpen = ref(false) const addDialogPanelOpen = ref(false)
@ -12,6 +17,70 @@ const form = reactive({
type: undefined type: undefined
}) })
const contractList = ref([])
function getContractList() {
api.get('/panel/contract/list')
.then(r => {
contractList.value = r.data.data
})
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
getContractList()
function addContract() {
api.post('/panel/contract/add', form)
.then(() => getContractList())
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
function deleteContract(id) {
api.post('/panel/contract/delete', undefined, {
params: {
id: id
}
})
.then(() => getContractList())
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
const deleteDialogOpen = ref(false)
const deleteId = ref()
const contractTypeText = {
"0": "电话",
"1": "短信",
"2": "邮件",
}
</script> </script>
<template> <template>
@ -20,12 +89,24 @@ const form = reactive({
<q-btn unelevated color="primary" @click="addDialogPanelOpen = !addDialogPanelOpen">添加</q-btn> <q-btn unelevated color="primary" @click="addDialogPanelOpen = !addDialogPanelOpen">添加</q-btn>
</div> </div>
<el-table border style="width: 100%" height="250"> <el-table :data="contractList" border style="width: 100%;height: 100%">
<el-table-column fixed prop="id" label="ID"/> <el-table-column fixed prop="id" label="ID"/>
<el-table-column prop="instanceId" label="实例ID"/>
<el-table-column prop="contract" label="联系信息"/> <el-table-column prop="contract" label="联系信息"/>
<el-table-column prop="type" label="类型"/> <el-table-column label="类型">
<el-table-column prop="addTime" label="添加时间"/> <template #default="scope">
<span>{{ contractTypeText[scope.row.type] }}</span>
</template>
</el-table-column>
<el-table-column label="添加时间">
<template #default="scope">
<span>{{ moment(scope.row.createTime).format("YYYY-MM-DD HH:mm:ss") }}</span>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<q-btn unelevated color="red" @click="deleteDialogOpen = true;deleteId = scope.row.id">删除</q-btn>
</template>
</el-table-column>
</el-table> </el-table>
<el-dialog v-model="addDialogPanelOpen" title="添加联系信息" width="500"> <el-dialog v-model="addDialogPanelOpen" title="添加联系信息" width="500">
@ -35,9 +116,7 @@ const form = reactive({
</el-form-item> </el-form-item>
<el-form-item label="联系方式"> <el-form-item label="联系方式">
<el-select v-model="form.type" placeholder="选择一个联系方式"> <el-select v-model="form.type" placeholder="选择一个联系方式">
<el-option label="电话" value="0" /> <el-option v-for="(name, val) in contractTypeText" :key="val" :label="name" :value="val"/>
<el-option label="短信" value="1" />
<el-option label="邮件" value="2" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -45,7 +124,19 @@ const form = reactive({
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<q-btn unelevated @click="addDialogPanelOpen = false">取消</q-btn> <q-btn unelevated @click="addDialogPanelOpen = false">取消</q-btn>
<q-btn class="ml-2" unelevated color="primary" @click="addDialogPanelOpen = false">确认</q-btn> <q-btn class="ml-2" unelevated color="primary" @click="addDialogPanelOpen = false;addContract()">确认</q-btn>
</div>
</template>
</el-dialog>
<el-dialog v-model="deleteDialogOpen" title="确认" width="500">
<span>确认删除</span>
<template #footer>
<div class="dialog-footer flex flex-row gap-2 justify-end">
<q-btn unelevated @click="deleteDialogOpen = false">取消</q-btn>
<q-btn unelevated color="red" @click="deleteDialogOpen = false;deleteContract(deleteId)">
确认
</q-btn>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>

@ -1,5 +1,5 @@
<script setup> <script setup>
import {ref, provide, reactive} from "vue"; import {ref, provide, reactive, computed} from "vue";
import VChart, {THEME_KEY} from "vue-echarts"; import VChart, {THEME_KEY} from "vue-echarts";
@ -7,7 +7,8 @@ import {use} from 'echarts/core'
import {LineChart} from 'echarts/charts' import {LineChart} from 'echarts/charts'
import {GridComponent, LegendComponent, TitleComponent, TooltipComponent} from 'echarts/components' import {GridComponent, LegendComponent, TitleComponent, TooltipComponent} from 'echarts/components'
import {CanvasRenderer} from 'echarts/renderers' import {CanvasRenderer} from 'echarts/renderers'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'; import {api} from "boot/axios";
import {useQuasar} from "quasar";
use([ use([
GridComponent, GridComponent,
@ -20,45 +21,161 @@ use([
// provide(THEME_KEY, "dark"); // provide(THEME_KEY, "dark");
const option = reactive({ const options = ref([])
grid: {
left: "1%", const timeRange = ref([
right: "2%", new Date('2024-04-17 12:00:00'),
top: "20%", new Date('2024-04-17 16:00:00'),
bottom: "1%", ])
containLabel: true
const availableMetricItems = [
"up",
"node_load5",
"node_sockstat_TCP_tw",
"node_cpu_seconds_total",
"node_memory_Cached_bytes",
"node_memory_Buffers_bytes",
"node_memory_MemFree_bytes",
"node_disk_read_bytes_total",
"node_filesystem_free_bytes",
"node_filesystem_size_bytes",
"node_memory_MemTotal_bytes",
"node_netstat_Tcp_CurrEstab",
"node_filesystem_avail_bytes",
"node_disk_written_bytes_total",
"node_disk_reads_completed_total",
"node_network_receive_drop_total",
"node_disk_writes_completed_total",
"node_network_receive_bytes_total",
"node_network_transmit_drop_total",
"node_network_transmit_bytes_total",
"node_network_receive_packets_total",
"node_network_transmit_packets_total",
]
const metricText = {
"up": "服务器离线/上线",
"node_load5": "5分钟负载",
"node_sockstat_TCP_tw": "TIME-WAIT连接",
"node_cpu_seconds_total": "CPU使用时间",
"node_memory_Cached_bytes": "缓存内存",
"node_memory_Buffers_bytes": "缓冲内存",
"node_memory_MemFree_bytes": "空闲内存",
"node_disk_read_bytes_total": "磁盘读总字节",
"node_disk_written_bytes_total": "磁盘写总字节",
"node_filesystem_free_bytes": "文件系统空闲空间",
"node_filesystem_size_bytes": "文件系统总大小",
"node_memory_MemTotal_bytes": "总内存",
"node_netstat_Tcp_CurrEstab": "已连接TCP数",
"node_filesystem_avail_bytes": "文件系统可用空间",
"node_disk_reads_completed_total": "磁盘IO读次数",
"node_disk_writes_completed_total": "磁盘IO写次数",
"node_network_receive_drop_total": "接收丢包数",
"node_network_transmit_drop_total": "发送丢包数",
"node_network_receive_bytes_total": "接收总字节",
"node_network_transmit_bytes_total": "发送总字节",
"node_network_receive_packets_total": "总接收包数",
"node_network_transmit_packets_total": "总发送包数"
}
/**
*
* @type {import('vue').Ref<string[]>}
*/
const selectedMetric = ref([
"node_cpu_seconds_total",
"node_memory_MemFree_bytes",
"node_filesystem_free_bytes",
"node_disk_read_bytes_total",
"node_disk_written_bytes_total",
"node_network_receive_bytes_total",
"node_network_transmit_bytes_total",
"node_network_receive_packets_total",
"node_network_transmit_packets_total",
])
const instanceId = ref('7273a1ea-0089-4674-b606-b1b8d809d866')
const quasar = useQuasar()
/**
* @typedef {{
* "instanceId": string,
* "start": number,
* "end": number,
* "metricItems": string[]
* }} MetricRequest
*/
function getMetricData() {
api.post('/panel/metrics/list', {
instanceId: instanceId.value,
start: Number(timeRange.value[0]),
end: Number(timeRange.value[1]),
metricItems: selectedMetric.value
})
.then(r => {
const response = r.data
if (response.code != 0) {
throw response.message
}
const opts = []
const metricOptionsMap = new Map
for (const selectedMetricElement of selectedMetric.value) {
metricOptionsMap.set(selectedMetricElement, {
grid: {left: "1%", right: "2%", top: "20%", bottom: "1%", containLabel: true},
title: {text: metricText[selectedMetricElement], left: "center", textStyle: {fontSize: 16}},
tooltip: {trigger: 'axis'},
xAxis: {
type: 'time'
}, },
title: { yAxis: {
text: "CPU使用率", type: 'value', scale: true,
left: "center", axisLabel: {
textStyle: { color: '#444343',
fontSize: 16 formatter: function (value, index) {
if (value >= 1e9) {
return (value / 1e9).toFixed(2) + 'G';
} else if (value >= 1e6) {
return (value / 1e6).toFixed(2) + 'M';
} else if (value >= 1e3) {
return (value / 1e3).toFixed(2) + 'K';
} else {
return value;
} }
}, },
tooltip: {
trigger: 'axis'
}, },
xAxis: {}, },
yAxis: {}, series: [{data: [], type: 'line', smooth: true}]
series: [ })
{
data: [
[0, 20],
[10, 10],
[20, 64],
[40, 12],
[50, 15],
],
type: 'line',
smooth: true
} }
]
});
const timeRange = ref([ const data = response.data.metricData
new Date(Number(new Date()) - 86400000), for (const metric in data) {
new Date() const metricDataItem = data[metric]
]) for (let i = 0; i < metricDataItem.time.length; i++) {
metricOptionsMap.get(metric).series[0].data.push([metricDataItem.time[i], metricDataItem.value[i]])
}
}
for (const metricOptionsMapElement of metricOptionsMap.values()) {
opts.push(metricOptionsMapElement)
}
options.value = opts
})
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
getMetricData()
</script> </script>
@ -70,23 +187,29 @@ const timeRange = ref([
<el-date-picker v-model="timeRange" type="datetimerange"/> <el-date-picker v-model="timeRange" type="datetimerange"/>
</div> </div>
<q-separator vertical></q-separator>
<q-select filled dense options-dense
v-model="selectedMetric"
multiple
:options="availableMetricItems"
:option-label="opt => metricText[opt]"
label="想要查看的指标数据"
style="width: 35rem"
transition-show="jump-up"
transition-hide="jump-up"
/>
<q-separator vertical></q-separator>
<q-input class="flex-1" v-model="instanceId" label="实例ID"/>
<q-separator vertical></q-separator> <q-separator vertical></q-separator>
<q-btn unelevated color="primary">刷新</q-btn> <q-btn unelevated color="primary" @click="getMetricData">刷新</q-btn>
</div> </div>
<div class="flex flex-row gap-10"> <div class="flex flex-row gap-10">
<v-chart :autoresize=true class="chart" :option="option"/> <v-chart v-for="option in options" :key="option" :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
<v-chart :autoresize=true class="chart" :option="option"/>
</div> </div>
</div> </div>
</template> </template>

@ -1,12 +1,18 @@
<script setup> <script setup>
import {reactive, ref} from "vue"; import {reactive, ref} from "vue";
import {api} from "boot/axios";
import moment from "moment/moment";
import {useQuasar} from "quasar";
const quasar = useQuasar()
const addDialogPanelOpen = ref(false) const addDialogPanelOpen = ref(false)
/** /**
* *
* @type {{condition: number, expression: string, description: string, threshold: string, trigger: number}} * @type {{condition: number, expression: string, description: string, threshold: string, trigger: number}}
*/ */
const form = reactive({ const ruleForm = reactive({
expression: "", expression: "",
condition: undefined, condition: undefined,
threshold: "", threshold: "",
@ -14,57 +20,151 @@ const form = reactive({
description: "" description: ""
}) })
const rules = ref([])
function getRules() {
api.get('/panel/rules/list')
.then(r => {
const response = r.data
rules.value = response.data
})
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
getRules()
function addRule() {
api.post('/panel/rules/add', ruleForm)
.then(() => getRules())
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
function deleteRule(id) {
api.post('/panel/rules/delete', undefined, {
params: {
id: id
}
})
.then(() => getRules())
.catch(msg => {
quasar.dialog({
title: '啊哈',
message: `请求时出现问题:${msg}`
})
})
.finally(() => quasar.loading.hide())
quasar.loading.show({
message: '与服务器通信中...'
})
}
const deleteDialogOpen = ref(false)
const deleteId = ref()
const triggerTypeNames = {
"0": "立即计算",
"1": "定时计算",
}
const conditionNames = {
"0": "小于\t<",
"1": "小于等于\t<=",
"2": "等于\t==",
"3": "大于等于\t>=",
"4": "大于\t>",
}
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 p-2"> <div class="h-full flex flex-col gap-2 p-2">
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<q-btn unelevated color="primary" @click="addDialogPanelOpen = !addDialogPanelOpen">添加</q-btn> <q-btn unelevated color="primary" @click="addDialogPanelOpen = !addDialogPanelOpen">添加</q-btn>
</div> </div>
<el-table border style="width: 100%" height="250"> <el-table class="h-full" :data="rules" border style="width: 100%;height: 100%">
<el-table-column fixed prop="id" label="ID"/> <el-table-column fixed prop="id" label="ID"/>
<el-table-column prop="instanceId" label="实例ID"/> <el-table-column prop="expression" label="计算指标或表达式"/>
<el-table-column prop="expression" label="计算值"/> <el-table-column label="判断条件">
<el-table-column prop="condition" label="条件"/> <template #default="scope">
<span>{{ conditionNames[scope.row.condition] }}</span>
</template>
</el-table-column>
<el-table-column prop="threshold" label="阈值"/> <el-table-column prop="threshold" label="阈值"/>
<el-table-column prop="trigger" label="触发类型"/> <el-table-column label="触发类型">
<template #default="scope">
<span>{{ triggerTypeNames[scope.row.trigger] }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="备注"/> <el-table-column prop="description" label="备注"/>
<el-table-column prop="addTime" label="添加时间"/> <el-table-column label="时间">
<template #default="scope">
<span>{{ moment(scope.row.createTime).format("YYYY-MM-DD HH:mm:ss") }}</span>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<q-btn unelevated color="red" @click="deleteDialogOpen = true;deleteId = scope.row.id">删除</q-btn>
</template>
</el-table-column>
</el-table> </el-table>
<el-dialog v-model="addDialogPanelOpen" title="添加联系信息" width="500"> <el-dialog v-model="addDialogPanelOpen" title="添加联系信息" width="500">
<el-form :model="form"> <el-form :model="ruleForm">
<el-form-item label="值"> <el-form-item label="值">
<el-input v-model="form.expression" autocomplete="off" /> <el-input v-model="ruleForm.expression" autocomplete="off"/>
</el-form-item> </el-form-item>
<el-form-item label="条件"> <el-form-item label="条件">
<el-select v-model="form.condition" placeholder="选择判断条件"> <el-select v-model="ruleForm.condition" placeholder="选择判断条件">
<el-option label="<" value="0" /> <el-option v-for="(name, val) in conditionNames" :key="val" :label="name" :value="val"/>
<el-option label="<=" value="1" />
<el-option label="=" value="2" />
<el-option label=">=" value="3" />
<el-option label=">" value="4" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="阈值"> <el-form-item label="阈值">
<el-input v-model="form.threshold" autocomplete="off" /> <el-input v-model="ruleForm.threshold" autocomplete="off"/>
</el-form-item> </el-form-item>
<el-form-item label="触发类型"> <el-form-item label="触发类型">
<el-select v-model="form.trigger" placeholder="选择触发类型"> <el-select v-model="ruleForm.trigger" placeholder="选择触发类型">
<el-option label="立即计算" value="0" /> <el-option v-for="(name, val) in triggerTypeNames" :key="val" :label="name" :value="val"/>
<el-option label="定时计算" value="1" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="form.description" type="textarea"></el-input> <el-input v-model="ruleForm.description" type="textarea"></el-input>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<q-btn unelevated @click="addDialogPanelOpen = false">取消</q-btn> <q-btn unelevated @click="addDialogPanelOpen = false">取消</q-btn>
<q-btn class="ml-2" unelevated color="primary" @click="addDialogPanelOpen = false">确认</q-btn> <q-btn class="ml-2" unelevated color="primary" @click="addDialogPanelOpen = false;addRule()">确认</q-btn>
</div>
</template>
</el-dialog>
<el-dialog v-model="deleteDialogOpen" title="确认" width="500">
<span>确认删除</span>
<template #footer>
<div class="dialog-footer flex flex-row gap-2 justify-end">
<q-btn unelevated @click="deleteDialogOpen = false">取消</q-btn>
<q-btn unelevated color="red" @click="deleteDialogOpen = false;deleteRule(deleteId)">
确认
</q-btn>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>

@ -2455,6 +2455,11 @@ minimist@^1.2.6:
resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.0.tgz#b545f84af94e567386770159302ca113469c80b8" resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.0.tgz#b545f84af94e567386770159302ca113469c80b8"
integrity sha512-oGZRv2OT1lO2UF1zUcwdTb3wqUwI0kBGTgt/T7OdSj6M6N5m3o5uPf0AIW6lVxGGoiWUR7e2AwTE+xiwK8WQig== integrity sha512-oGZRv2OT1lO2UF1zUcwdTb3wqUwI0kBGTgt/T7OdSj6M6N5m3o5uPf0AIW6lVxGGoiWUR7e2AwTE+xiwK8WQig==
moment@^2.30.1:
version "2.30.1"
resolved "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
ms@2.0.0: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"

@ -40,7 +40,7 @@ func (c *Client) Report(dataItem []DataItem) (*ReportResponse, error) {
Metric: item.Metric, Metric: item.Metric,
Value: item.Value, Value: item.Value,
Tags: tags, Tags: tags,
Timestamp: time.Now().UnixMilli(), Timestamp: time.Now().Unix(),
}) })
} }
@ -49,8 +49,6 @@ func (c *Client) Report(dataItem []DataItem) (*ReportResponse, error) {
return nil, err return nil, err
} }
fmt.Println(string(reportJsonBytes))
// send report data // send report data
resp, err := http.Post(c.config.CenterServer, "application/json", bytes.NewBuffer(reportJsonBytes)) resp, err := http.Post(c.config.CenterServer, "application/json", bytes.NewBuffer(reportJsonBytes))
if err != nil { if err != nil {

@ -14,7 +14,7 @@ func main() {
diskDevice := flag.String("disk_dev", "", "监控的硬盘设备") diskDevice := flag.String("disk_dev", "", "监控的硬盘设备")
mount := flag.String("mount", "/", "监控的分区挂载位置") mount := flag.String("mount", "/", "监控的分区挂载位置")
netInterface := flag.String("net_dev", "", "监控的网卡设备") netInterface := flag.String("net_dev", "", "监控的网卡设备")
interval := flag.Int("interval", 30, "上报间隔,单位为秒") interval := flag.Int("interval", 5, "上报间隔,单位为秒")
flag.Parse() flag.Parse()
hostname, _ := os.Hostname() hostname, _ := os.Hostname()

Loading…
Cancel
Save