From 2d53abdaab3716cd8b5ad278a2a9ea53b540dbcf Mon Sep 17 00:00:00 2001 From: liutianyou Date: Mon, 8 Jul 2024 18:53:49 +0800 Subject: [PATCH] [feature] add plugin management and support plugin hot reloading (#2238) Signed-off-by: liutianyou Co-authored-by: Logic --- .../common/constants/PluginType.java | 29 ++ .../common/entity/dto/PluginUpload.java | 43 +++ .../common/entity/manager/PluginItem.java | 94 ++++++ .../common/entity/manager/PluginMetadata.java | 103 +++++++ .../component/alerter/DispatcherAlarm.java | 36 +-- .../manager/controller/PluginController.java | 110 +++++++ .../hertzbeat/manager/dao/PluginItemDao.java | 31 ++ .../manager/dao/PluginMetadataDao.java | 44 +++ .../manager/service/PluginService.java | 73 +++++ .../service/impl/PluginServiceImpl.java | 268 ++++++++++++++++++ .../controller/PluginControllerTest.java | 116 ++++++++ .../manager/service/PluginServiceTest.java | 135 +++++++++ web-app/src/app/pojo/Plugin.ts | 27 ++ web-app/src/app/pojo/PluginItem.ts | 22 ++ .../setting/plugins/plugin.component.html | 152 ++++++++++ .../setting/plugins/plugin.component.spec.ts | 43 +++ .../setting/plugins/plugin.component.ts | 263 +++++++++++++++++ .../routes/setting/setting-routing.module.ts | 2 + .../src/app/routes/setting/setting.module.ts | 8 +- .../src/app/service/plugin.service.spec.ts | 35 +++ web-app/src/app/service/plugin.service.ts | 89 ++++++ web-app/src/assets/app-data.json | 6 + web-app/src/assets/i18n/en-US.json | 14 +- web-app/src/assets/i18n/zh-CN.json | 14 +- web-app/src/assets/i18n/zh-TW.json | 14 +- 25 files changed, 1748 insertions(+), 23 deletions(-) create mode 100644 common/src/main/java/org/apache/hertzbeat/common/constants/PluginType.java create mode 100644 common/src/main/java/org/apache/hertzbeat/common/entity/dto/PluginUpload.java create mode 100644 common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginItem.java create mode 100644 common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginMetadata.java create mode 100644 manager/src/main/java/org/apache/hertzbeat/manager/controller/PluginController.java create mode 100644 manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginItemDao.java create mode 100644 manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginMetadataDao.java create mode 100644 manager/src/main/java/org/apache/hertzbeat/manager/service/PluginService.java create mode 100644 manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java create mode 100644 manager/src/test/java/org/apache/hertzbeat/manager/controller/PluginControllerTest.java create mode 100644 manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java create mode 100644 web-app/src/app/pojo/Plugin.ts create mode 100644 web-app/src/app/pojo/PluginItem.ts create mode 100644 web-app/src/app/routes/setting/plugins/plugin.component.html create mode 100644 web-app/src/app/routes/setting/plugins/plugin.component.spec.ts create mode 100644 web-app/src/app/routes/setting/plugins/plugin.component.ts create mode 100644 web-app/src/app/service/plugin.service.spec.ts create mode 100644 web-app/src/app/service/plugin.service.ts diff --git a/common/src/main/java/org/apache/hertzbeat/common/constants/PluginType.java b/common/src/main/java/org/apache/hertzbeat/common/constants/PluginType.java new file mode 100644 index 00000000000..d586889cec6 --- /dev/null +++ b/common/src/main/java/org/apache/hertzbeat/common/constants/PluginType.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.constants; + +/** + * plugin type + */ +public enum PluginType { + + /** + * do something after alter + */ + POST_ALERT +} diff --git a/common/src/main/java/org/apache/hertzbeat/common/entity/dto/PluginUpload.java b/common/src/main/java/org/apache/hertzbeat/common/entity/dto/PluginUpload.java new file mode 100644 index 00000000000..426834c5fc9 --- /dev/null +++ b/common/src/main/java/org/apache/hertzbeat/common/entity/dto/PluginUpload.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.dto; + + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +/** + * data transfer class for uploading plugins + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PluginUpload { + + @NotNull + private MultipartFile jarFile; + + @NotNull(message = "Plugin name is required") + private String name; + + @NotNull(message = "Enable status is required") + private Boolean enableStatus; +} diff --git a/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginItem.java b/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginItem.java new file mode 100644 index 00000000000..a0db48e08c8 --- /dev/null +++ b/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginItem.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.manager; + +import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY; +import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.hertzbeat.common.constants.PluginType; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * Plugin Entity + */ +@Entity +@Table(name = "hzb_plugin_item") +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "PluginItem Entity") +@EntityListeners(AuditingEntityListener.class) +public class PluginItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(title = "Plugin Primary key index ID", example = "87584674384", accessMode = READ_ONLY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "metadata_id") + @JsonIgnore + private PluginMetadata pluginMetadata; + + @Schema(title = "Plugin implementation class full path", example = "org.apache.hertzbeat.plugin.impl.DemoPluginImpl", accessMode = READ_WRITE) + private String classIdentifier; + + @Schema(title = "Plugin type", example = "POST_ALERT", accessMode = READ_WRITE) + @Enumerated(EnumType.STRING) + private PluginType type; + + public PluginItem(String classIdentifier, PluginType type) { + this.classIdentifier = classIdentifier; + this.type = type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PluginItem that = (PluginItem) o; + return Objects.equals(id, that.id) && Objects.equals(classIdentifier, that.classIdentifier) && type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(id, classIdentifier, type); + } +} diff --git a/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginMetadata.java b/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginMetadata.java new file mode 100644 index 00000000000..f88b98a675c --- /dev/null +++ b/common/src/main/java/org/apache/hertzbeat/common/entity/manager/PluginMetadata.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.common.entity.manager; + +import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY; +import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * Plugin Entity + */ +@Entity +@Table(name = "hzb_plugin_metadata") +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Plugin Entity") +@EntityListeners(AuditingEntityListener.class) +public class PluginMetadata { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(title = "Plugin Primary key index ID", example = "87584674384", accessMode = READ_ONLY) + private Long id; + + @Schema(title = "plugin name", example = "notification plugin", accessMode = READ_WRITE) + @NotNull + private String name; + + @Schema(title = "Plugin activation status", example = "true", accessMode = READ_WRITE) + private Boolean enableStatus; + + @Schema(title = "Jar file path", example = "true", accessMode = READ_WRITE) + private String jarFilePath; + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PluginMetadata that = (PluginMetadata) o; + return Objects.equals(id, that.id) && Objects.equals(name, that.name) && Objects.equals(enableStatus, that.enableStatus) && Objects.equals(jarFilePath, + that.jarFilePath) && Objects.equals(creator, that.creator) && Objects.equals(gmtCreate, that.gmtCreate); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, enableStatus, jarFilePath, creator, gmtCreate); + } + + @Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY) + @CreatedBy + private String creator; + + @Schema(title = "Record create time", example = "1612198922000", accessMode = READ_ONLY) + @CreatedDate + private LocalDateTime gmtCreate; + + @OneToMany(targetEntity = PluginItem.class, cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @JoinColumn(name = "metadata_id", referencedColumnName = "id") + private List items; + +} diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/component/alerter/DispatcherAlarm.java b/manager/src/main/java/org/apache/hertzbeat/manager/component/alerter/DispatcherAlarm.java index f9cee2a5dbd..101a4c44034 100644 --- a/manager/src/main/java/org/apache/hertzbeat/manager/component/alerter/DispatcherAlarm.java +++ b/manager/src/main/java/org/apache/hertzbeat/manager/component/alerter/DispatcherAlarm.java @@ -21,7 +21,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.ServiceLoader; import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.alert.AlerterWorkerPool; import org.apache.hertzbeat.common.entity.alerter.Alert; @@ -30,6 +29,7 @@ import org.apache.hertzbeat.common.entity.manager.NoticeTemplate; import org.apache.hertzbeat.common.queue.CommonDataQueue; import org.apache.hertzbeat.manager.service.NoticeConfigService; +import org.apache.hertzbeat.manager.service.PluginService; import org.apache.hertzbeat.manager.support.exception.AlertNoticeException; import org.apache.hertzbeat.manager.support.exception.IgnoreException; import org.apache.hertzbeat.plugin.Plugin; @@ -42,6 +42,7 @@ @Component @Slf4j public class DispatcherAlarm implements InitializingBean { + private static final int DISPATCH_THREADS = 3; private final AlerterWorkerPool workerPool; @@ -49,16 +50,18 @@ public class DispatcherAlarm implements InitializingBean { private final NoticeConfigService noticeConfigService; private final AlertStoreHandler alertStoreHandler; private final Map alertNotifyHandlerMap; + private final PluginService pluginService; public DispatcherAlarm(AlerterWorkerPool workerPool, - CommonDataQueue dataQueue, - NoticeConfigService noticeConfigService, - AlertStoreHandler alertStoreHandler, - List alertNotifyHandlerList) { + CommonDataQueue dataQueue, + NoticeConfigService noticeConfigService, + AlertStoreHandler alertStoreHandler, + List alertNotifyHandlerList, PluginService pluginService) { this.workerPool = workerPool; this.dataQueue = dataQueue; this.noticeConfigService = noticeConfigService; this.alertStoreHandler = alertStoreHandler; + this.pluginService = pluginService; alertNotifyHandlerMap = Maps.newHashMapWithExpectedSize(alertNotifyHandlerList.size()); alertNotifyHandlerList.forEach(r -> alertNotifyHandlerMap.put(r.type(), r)); } @@ -127,11 +130,8 @@ public void run() { alertStoreHandler.store(alert); // Notice distribution sendNotify(alert); - // Execute the plugin - ServiceLoader loader = ServiceLoader.load(Plugin.class, Plugin.class.getClassLoader()); - for (Plugin plugin : loader) { - plugin.alert(alert); - } + // Execute the plugin if enable + pluginService.pluginExecute(Plugin.class, plugin -> plugin.alert(alert)); } } catch (IgnoreException ignored) { } catch (InterruptedException e) { @@ -147,14 +147,14 @@ private void sendNotify(Alert alert) { noticeRules.forEach(rule -> { workerPool.executeNotify(() -> { rule.getReceiverId() - .forEach(receiverId -> { - try { - sendNoticeMsg(getOneReceiverById(receiverId), - getOneTemplateById(rule.getTemplateId()), alert); - } catch (AlertNoticeException e) { - log.warn("DispatchTask sendNoticeMsg error, message: {}", e.getMessage()); - } - }); + .forEach(receiverId -> { + try { + sendNoticeMsg(getOneReceiverById(receiverId), + getOneTemplateById(rule.getTemplateId()), alert); + } catch (AlertNoticeException e) { + log.warn("DispatchTask sendNoticeMsg error, message: {}", e.getMessage()); + } + }); }); }); }); diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/controller/PluginController.java b/manager/src/main/java/org/apache/hertzbeat/manager/controller/PluginController.java new file mode 100644 index 00000000000..b75f6973841 --- /dev/null +++ b/manager/src/main/java/org/apache/hertzbeat/manager/controller/PluginController.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.controller; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.persistence.criteria.Predicate; +import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.common.entity.dto.PluginUpload; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.apache.hertzbeat.manager.service.PluginService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * plugin management API + */ +@io.swagger.v3.oas.annotations.tags.Tag(name = "Plugin Manage API") +@RestController +@RequestMapping(path = "/api/plugin", produces = {APPLICATION_JSON_VALUE}) +@RequiredArgsConstructor +public class PluginController { + + private final PluginService pluginService; + + @PostMapping + @Operation(summary = "upload plugin", description = "upload plugin") + public ResponseEntity> uploadNewPlugin(@Valid PluginUpload pluginUpload) { + pluginService.savePlugin(pluginUpload); + return ResponseEntity.ok(Message.success("Add success")); + } + + + @GetMapping() + @Operation(summary = "Get Plugins information", description = "Obtain plugins information based on conditions") + public ResponseEntity>> getPlugins( + @Parameter(description = "plugin name search", example = "status") @RequestParam(required = false) String search, + @Parameter(description = "List current page", example = "0") @RequestParam(defaultValue = "0") int pageIndex, + @Parameter(description = "Number of list pagination", example = "8") @RequestParam(defaultValue = "8") int pageSize) { + // Get tag information + Specification specification = (root, query, criteriaBuilder) -> { + List andList = new ArrayList<>(); + if (search != null && !search.isEmpty()) { + Predicate predicateApp = criteriaBuilder.like(root.get("name"), "%" + search + "%"); + andList.add(predicateApp); + } + Predicate[] andPredicates = new Predicate[andList.size()]; + Predicate andPredicate = criteriaBuilder.and(andList.toArray(andPredicates)); + + if (andPredicates.length == 0) { + return query.where().getRestriction(); + } else { + return andPredicate; + } + }; + PageRequest pageRequest = PageRequest.of(pageIndex, pageSize); + Page alertPage = pluginService.getPlugins(specification, pageRequest); + Message> message = Message.success(alertPage); + return ResponseEntity.ok(message); + } + + @DeleteMapping() + @Operation(summary = "Delete plugins based on ID", description = "Delete plugins based on ID") + public ResponseEntity> deleteTags( + @Parameter(description = "Plugin IDs ", example = "6565463543") @RequestParam(required = false) List ids) { + if (ids != null && !ids.isEmpty()) { + pluginService.deletePlugins(new HashSet<>(ids)); + } + return ResponseEntity.ok(Message.success("Delete success")); + } + + + @PutMapping() + @Operation(summary = "Update enable status", description = "Delete plugins based on ID") + public ResponseEntity> updatePluginStatus(@RequestBody PluginMetadata plugin) { + pluginService.updateStatus(plugin); + return ResponseEntity.ok(Message.success("Update success")); + } +} diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginItemDao.java b/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginItemDao.java new file mode 100644 index 00000000000..d77ec2b333b --- /dev/null +++ b/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginItemDao.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.dao; + +import java.util.List; +import org.apache.hertzbeat.common.entity.manager.PluginItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * plugin metadata repository + */ +public interface PluginItemDao extends JpaRepository, JpaSpecificationExecutor { + + int countPluginItemByClassIdentifierIn(List classIdentifiers); +} diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginMetadataDao.java b/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginMetadataDao.java new file mode 100644 index 00000000000..bf24081e305 --- /dev/null +++ b/manager/src/main/java/org/apache/hertzbeat/manager/dao/PluginMetadataDao.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.dao; + +import java.util.List; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * plugin metadata repository + */ +public interface PluginMetadataDao extends JpaRepository, JpaSpecificationExecutor { + + /** + * count by name + * + * @param name name + * @return count + */ + int countPluginMetadataByName(String name); + + + /** + * find enabled plugins + * @return plugins + */ + List findPluginMetadataByEnableStatusTrue(); +} diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/service/PluginService.java b/manager/src/main/java/org/apache/hertzbeat/manager/service/PluginService.java new file mode 100644 index 00000000000..2fe3e5041c5 --- /dev/null +++ b/manager/src/main/java/org/apache/hertzbeat/manager/service/PluginService.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.service; + +import java.util.Set; +import java.util.function.Consumer; +import org.apache.hertzbeat.common.entity.dto.PluginUpload; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; + +/** + * plugin service + */ +public interface PluginService { + + /** + * save plugin + */ + void savePlugin(PluginUpload pluginUpload); + + /** + * Determine whether the plugin is enabled + * + * @param clazz plugin Class + * @return return true if enabled + */ + boolean pluginIsEnable(Class clazz); + + + /** + * get plugin page list + * + * @param specification Query condition + * @param pageRequest Paging condition + * @return Plugins + */ + Page getPlugins(Specification specification, PageRequest pageRequest); + + /** + * execute plugin + * @param clazz plugin interface + * @param execute run plugin logic + * @param plugin type + */ + void pluginExecute(Class clazz, Consumer execute); + + /** + * delete plugin + * + * @param ids set of plugin id + */ + void deletePlugins(Set ids); + + void updateStatus(PluginMetadata plugin); + +} diff --git a/manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java b/manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java new file mode 100644 index 00000000000..d5de0e5889f --- /dev/null +++ b/manager/src/main/java/org/apache/hertzbeat/manager/service/impl/PluginServiceImpl.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.service.impl; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import javax.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; +import org.apache.hertzbeat.common.constants.PluginType; +import org.apache.hertzbeat.common.entity.dto.PluginUpload; +import org.apache.hertzbeat.common.entity.manager.PluginItem; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.apache.hertzbeat.common.support.exception.CommonException; +import org.apache.hertzbeat.manager.dao.PluginItemDao; +import org.apache.hertzbeat.manager.dao.PluginMetadataDao; +import org.apache.hertzbeat.manager.service.PluginService; +import org.apache.hertzbeat.plugin.Plugin; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +/** + * plugin service + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PluginServiceImpl implements PluginService { + + private final PluginMetadataDao metadataDao; + + private final PluginItemDao itemDao; + + public static Map, PluginType> PLUGIN_TYPE_MAPPING = new HashMap<>(); + + /** + * plugin status + */ + private static final Map PLUGIN_ENABLE_STATUS = new ConcurrentHashMap<>(); + + + private URLClassLoader pluginClassLoader; + + @Override + public void deletePlugins(Set ids) { + List plugins = metadataDao.findAllById(ids); + for (PluginMetadata plugin : plugins) { + try { + // delete jar file + File jarFile = new File(plugin.getJarFilePath()); + if (jarFile.exists()) { + FileUtils.delete(jarFile); + } + // delete metadata + metadataDao.deleteById(plugin.getId()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + syncPluginStatus(); + // reload classloader + loadJarToClassLoader(); + } + + @Override + public void updateStatus(PluginMetadata plugin) { + Optional pluginMetadata = metadataDao.findById(plugin.getId()); + if (pluginMetadata.isPresent()) { + PluginMetadata metadata = pluginMetadata.get(); + metadata.setEnableStatus(plugin.getEnableStatus()); + metadataDao.save(metadata); + syncPluginStatus(); + } else { + throw new IllegalArgumentException("The plugin is not existed"); + } + } + + + static { + PLUGIN_TYPE_MAPPING.put(Plugin.class, PluginType.POST_ALERT); + } + + + /** + * verify the type of the jar package + * + * @param jarFile jar file + * @return return the full path of the Plugin interface implementation class + */ + public List validateJarFile(File jarFile) { + List pluginItems = new ArrayList<>(); + try { + URL jarUrl = new URL("file:" + jarFile.getAbsolutePath()); + try (URLClassLoader classLoader = new URLClassLoader(new URL[]{jarUrl}, this.getClass().getClassLoader()); + JarFile jar = new JarFile(jarFile)) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().endsWith(".class")) { + String className = entry.getName().replace("/", ".").replace(".class", ""); + try { + Class cls = classLoader.loadClass(className); + if (!cls.isInterface()) { + PLUGIN_TYPE_MAPPING.forEach((clazz, type) -> { + if (clazz.isAssignableFrom(cls)) { + pluginItems.add(new PluginItem(className, type)); + } + }); + } + } catch (ClassNotFoundException e) { + System.err.println("Failed to load class: " + className); + } + } + } + if (pluginItems.isEmpty()) { + throw new CommonException("Illegal plug-ins, please refer to https://hertzbeat.apache.org/docs/help/plugin/"); + } + } catch (IOException e) { + log.error("Error reading JAR file:{}", jarFile.getAbsoluteFile(), e); + throw new CommonException("Error reading JAR file: " + jarFile.getAbsolutePath()); + } + } catch (MalformedURLException e) { + log.error("Invalid JAR file URL: {}", jarFile.getAbsoluteFile(), e); + throw new CommonException("Invalid JAR file URL: " + jarFile.getAbsolutePath()); + } + return pluginItems; + } + + private void validateMetadata(PluginMetadata metadata) { + if (metadataDao.countPluginMetadataByName(metadata.getName()) != 0) { + throw new CommonException("A plugin named " + metadata.getName() + " already exists"); + } + if (itemDao.countPluginItemByClassIdentifierIn((metadata.getItems().stream().map(PluginItem::getClassIdentifier).collect(Collectors.toList()))) != 0) { + throw new CommonException("Plugin already exists"); + } + } + + @Override + @SneakyThrows + public void savePlugin(PluginUpload pluginUpload) { + String jarPath = new File(this.getClass().getProtectionDomain().getCodeSource().getLocation().getPath()).getAbsolutePath(); + Path extLibPath = Paths.get(new File(jarPath).getParent(), "plugin-lib"); + File extLibDir = extLibPath.toFile(); + + String fileName = pluginUpload.getJarFile().getOriginalFilename(); + if (fileName == null) { + throw new CommonException("Failed to upload plugin"); + } + File destFile = new File(extLibDir, fileName); + FileUtils.createParentDirectories(destFile); + pluginUpload.getJarFile().transferTo(destFile); + List pluginItems = validateJarFile(destFile); + + // save plugin metadata + PluginMetadata pluginMetadata = PluginMetadata.builder() + .name(pluginUpload.getName()) + .enableStatus(true) + .items(pluginItems).jarFilePath(destFile.getAbsolutePath()) + .gmtCreate(LocalDateTime.now()) + .build(); + validateMetadata(pluginMetadata); + metadataDao.save(pluginMetadata); + itemDao.saveAll(pluginItems); + // load jar to classloader + loadJarToClassLoader(); + // sync enabled status + syncPluginStatus(); + } + + @Override + public boolean pluginIsEnable(Class clazz) { + return Boolean.TRUE.equals(PLUGIN_ENABLE_STATUS.get(clazz.getName())); + } + + @Override + public Page getPlugins(Specification specification, PageRequest pageRequest) { + return metadataDao.findAll(specification, pageRequest); + } + + /** + * Load all plugin enabled states into memory + */ + @PostConstruct + private void syncPluginStatus() { + List plugins = metadataDao.findAll(); + Map statusMap = new HashMap<>(); + for (PluginMetadata plugin : plugins) { + for (PluginItem item : plugin.getItems()) { + statusMap.put(item.getClassIdentifier(), plugin.getEnableStatus()); + } + } + PLUGIN_ENABLE_STATUS.clear(); + PLUGIN_ENABLE_STATUS.putAll(statusMap); + } + + /** + * load jar to classloader + */ + @PostConstruct + private void loadJarToClassLoader() { + try { + if (pluginClassLoader != null) { + pluginClassLoader.close(); + } + List plugins = metadataDao.findPluginMetadataByEnableStatusTrue(); + List urls = new ArrayList<>(); + for (PluginMetadata metadata : plugins) { + URL url = new File(metadata.getJarFilePath()).toURI().toURL(); + urls.add(url); + } + pluginClassLoader = new URLClassLoader(urls.toArray(new URL[0])); + } catch (MalformedURLException e) { + log.error("Failed to load plugin:{}", e.getMessage()); + throw new CommonException("Failed to load plugin:" + e.getMessage()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void pluginExecute(Class clazz, Consumer execute) { + ServiceLoader load = ServiceLoader.load(clazz, pluginClassLoader); + for (T t : load) { + if (pluginIsEnable(t.getClass())) { + execute.accept(t); + } + } + } +} diff --git a/manager/src/test/java/org/apache/hertzbeat/manager/controller/PluginControllerTest.java b/manager/src/test/java/org/apache/hertzbeat/manager/controller/PluginControllerTest.java new file mode 100644 index 00000000000..eb3ad88a94e --- /dev/null +++ b/manager/src/test/java/org/apache/hertzbeat/manager/controller/PluginControllerTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.controller; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.ArrayList; +import java.util.List; +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.apache.hertzbeat.common.util.JsonUtil; +import org.apache.hertzbeat.manager.service.PluginService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * test case for plugin controller + */ +@ExtendWith(MockitoExtension.class) +class PluginControllerTest { + + private MockMvc mockMvc; + + @InjectMocks + private PluginController pluginController; + + @Mock + private PluginService pluginService; + + @BeforeEach + void setUp() { + this.mockMvc = MockMvcBuilders.standaloneSetup(pluginController).build(); + } + + @Test + void uploadNewPlugin() throws Exception { + MockMultipartFile jarFile = new MockMultipartFile( + "jarFile", + "plugin-test.jar", + "application/java-archive", + "This is the file content".getBytes() + ); + + this.mockMvc.perform(MockMvcRequestBuilders.multipart("/api/plugin") + .file(jarFile) + .contentType(MediaType.MULTIPART_FORM_DATA) + .param("name", "test-plugin") + .param("enableStatus", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.msg").value("Add success")) + .andReturn(); + } + + @Test + void getPlugins() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders.get("/api/plugin?&search={search}", "test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andReturn(); + } + + @Test + void deleteTags() throws Exception { + List ids = new ArrayList<>(); + ids.add(6565463543L); + + this.mockMvc.perform(MockMvcRequestBuilders.delete("/api/plugin") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.toJson(ids))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.msg").value("Delete success")) + .andReturn(); + } + + @Test + void updatePluginStatus() throws Exception { + PluginMetadata metadata = new PluginMetadata(); + metadata.setId(6565463543L); + metadata.setEnableStatus(true); + + this.mockMvc.perform(MockMvcRequestBuilders.put("/api/plugin") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.toJson(metadata))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE)) + .andExpect(jsonPath("$.msg").value("Update success")) + .andReturn(); + } + +} diff --git a/manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java b/manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java new file mode 100644 index 00000000000..238b47b068e --- /dev/null +++ b/manager/src/test/java/org/apache/hertzbeat/manager/service/PluginServiceTest.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.apache.hertzbeat.common.constants.PluginType; +import org.apache.hertzbeat.common.entity.dto.PluginUpload; +import org.apache.hertzbeat.common.entity.manager.PluginItem; +import org.apache.hertzbeat.common.entity.manager.PluginMetadata; +import org.apache.hertzbeat.manager.dao.PluginItemDao; +import org.apache.hertzbeat.manager.dao.PluginMetadataDao; +import org.apache.hertzbeat.manager.service.impl.PluginServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.mock.web.MockMultipartFile; + +/** + * Test case for {@link PluginService} + */ +@ExtendWith(MockitoExtension.class) +class PluginServiceTest { + + @InjectMocks + private PluginServiceImpl pluginService; + @Mock + private PluginMetadataDao metadataDao; + + @Mock + private PluginItemDao itemDao; + + + @BeforeEach + void setUp() { + pluginService = new PluginServiceImpl(metadataDao, itemDao); + } + + @Test + void testSavePlugin(){ + + List pluginItems = Collections.singletonList(new PluginItem("org.apache.hertzbear.PluginTest", PluginType.POST_ALERT)); + PluginServiceImpl service = spy(pluginService); + doReturn(pluginItems).when(service).validateJarFile(any()); + + MockMultipartFile mockFile = new MockMultipartFile("file", "test-plugin.jar", "application/java-archive", "plugin-content".getBytes()); + PluginUpload pluginUpload = new PluginUpload(mockFile, "Test Plugin", true); + + when(metadataDao.save(any(PluginMetadata.class))).thenReturn(new PluginMetadata()); + when(itemDao.saveAll(anyList())).thenReturn(Collections.emptyList()); + + service.savePlugin(pluginUpload); + verify(metadataDao, times(1)).save(any(PluginMetadata.class)); + verify(itemDao, times(1)).saveAll(anyList()); + + } + + @Test + void testUpdateStatus() { + PluginMetadata plugin = new PluginMetadata(); + plugin.setId(1L); + plugin.setEnableStatus(true); + plugin.setName("test-plugin"); + + when(metadataDao.findById(1L)).thenReturn(Optional.of(plugin)); + when(metadataDao.save(any(PluginMetadata.class))).thenReturn(plugin); + assertDoesNotThrow(() -> pluginService.updateStatus(plugin)); + } + + @Test + void testDeletePlugins() { + PluginMetadata plugin = new PluginMetadata(); + plugin.setId(1L); + plugin.setJarFilePath("path/to/plugin.jar"); + Set ids = new HashSet<>(Collections.singletonList(1L)); + + when(metadataDao.findAllById(ids)).thenReturn(Collections.singletonList(plugin)); + doNothing().when(metadataDao).deleteById(anyLong()); + + pluginService.deletePlugins(ids); + verify(metadataDao, times(1)).deleteById(1L); + } + + @Test + void testGetPlugins() { + Specification spec = mock(Specification.class); + PageRequest pageRequest = PageRequest.of(0, 10); + Page page = new PageImpl<>(Collections.singletonList(new PluginMetadata())); + + when(metadataDao.findAll(any(Specification.class), any(PageRequest.class))).thenReturn(page); + + Page result = pluginService.getPlugins(spec, pageRequest); + assertFalse(result.isEmpty()); + verify(metadataDao, times(1)).findAll(any(Specification.class), any(PageRequest.class)); + } + +} diff --git a/web-app/src/app/pojo/Plugin.ts b/web-app/src/app/pojo/Plugin.ts new file mode 100644 index 00000000000..99b68fa36cb --- /dev/null +++ b/web-app/src/app/pojo/Plugin.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginItem } from './PluginItem'; + +export class Plugin { + id!: number; + name!: string; + enableStatus!: boolean; + items!: PluginItem[]; +} diff --git a/web-app/src/app/pojo/PluginItem.ts b/web-app/src/app/pojo/PluginItem.ts new file mode 100644 index 00000000000..50d8a515fc3 --- /dev/null +++ b/web-app/src/app/pojo/PluginItem.ts @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export class PluginItem { + type!: string; +} diff --git a/web-app/src/app/routes/setting/plugins/plugin.component.html b/web-app/src/app/routes/setting/plugins/plugin.component.html new file mode 100644 index 00000000000..3eec55b2462 --- /dev/null +++ b/web-app/src/app/routes/setting/plugins/plugin.component.html @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + {{ 'plugin.name' | i18n }} + {{ 'plugin.type' | i18n }} + {{ 'plugin.status' | i18n }} + {{ 'common.edit' | i18n }} + + + + + + {{ data.name }} + + + {{ 'plugin.type.' + item.type | i18n }} + + + + + + + + +
+ +
+ + + +
+ + {{ 'common.total' | i18n }} {{ total }} + + + +
+
+ + {{ 'plugin.name' | i18n }} + + + + + + + {{ 'plugin.jar.file' | i18n }} + + + + + + + + + {{ 'plugin.status' | i18n }} + + + + +
+
+
diff --git a/web-app/src/app/routes/setting/plugins/plugin.component.spec.ts b/web-app/src/app/routes/setting/plugins/plugin.component.spec.ts new file mode 100644 index 00000000000..ffc816aa588 --- /dev/null +++ b/web-app/src/app/routes/setting/plugins/plugin.component.spec.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SettingPluginsComponent } from './plugin.component'; + +describe('SettingPluginsComponent', () => { + let component: SettingPluginsComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [SettingPluginsComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SettingPluginsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/routes/setting/plugins/plugin.component.ts b/web-app/src/app/routes/setting/plugins/plugin.component.ts new file mode 100644 index 00000000000..b07239abb8f --- /dev/null +++ b/web-app/src/app/routes/setting/plugins/plugin.component.ts @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { I18NService } from '@core'; +import { ALAIN_I18N_TOKEN } from '@delon/theme'; +import { NzModalService } from 'ng-zorro-antd/modal'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { NzTableQueryParams } from 'ng-zorro-antd/table'; +import { NzUploadFile } from 'ng-zorro-antd/upload'; +import { finalize } from 'rxjs/operators'; + +import { Plugin } from '../../../pojo/Plugin'; +import { PluginService } from '../../../service/plugin.service'; + +@Component({ + selector: 'app-setting-plugins', + templateUrl: './plugin.component.html' +}) +export class SettingPluginsComponent implements OnInit { + constructor( + private notifySvc: NzNotificationService, + private modal: NzModalService, + private pluginService: PluginService, + private fb: FormBuilder, + @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService + ) { + this.pluginForm = this.fb.group({ + name: [null, [Validators.required]], + jarFile: [null, [Validators.required]], + enableStatus: [true, [Validators.required]] + }); + } + + pageIndex: number = 1; + pageSize: number = 8; + total: number = 0; + plugins!: Plugin[]; + tableLoading: boolean = false; + checkedTagIds = new Set(); + // search by name + search: string | undefined; + fileList: NzUploadFile[] = []; + pluginForm: FormGroup; + + ngOnInit(): void { + this.loadPluginsTable(); + } + + sync() { + this.loadPluginsTable(); + } + + beforeUpload = (file: NzUploadFile): boolean => { + this.fileList = [file]; + this.pluginForm.patchValue({ + jarFile: file + }); + return false; + }; + + loadPluginsTable() { + this.tableLoading = true; + let pluginsInit$ = this.pluginService.loadPlugins(this.search, 1, this.pageIndex - 1, this.pageSize).subscribe( + message => { + this.tableLoading = false; + this.checkedAll = false; + this.checkedTagIds.clear(); + if (message.code === 0) { + let page = message.data; + this.plugins = page.content; + this.pageIndex = page.number + 1; + this.total = page.totalElements; + } else { + console.warn(message.msg); + } + pluginsInit$.unsubscribe(); + }, + error => { + this.tableLoading = false; + pluginsInit$.unsubscribe(); + console.error(error.msg); + } + ); + } + + updatePluginEnableStatus(plugin: Plugin) { + plugin.enableStatus = !plugin.enableStatus; + this.tableLoading = true; + const updateDefine$ = this.pluginService + .updatePluginStatus(plugin) + .pipe( + finalize(() => { + updateDefine$.unsubscribe(); + this.tableLoading = false; + }) + ) + .subscribe( + message => { + if (message.code === 0) { + this.notifySvc.success(this.i18nSvc.fanyi('common.notify.edit-success'), ''); + } else { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.edit-fail'), message.msg); + } + this.loadPluginsTable(); + this.tableLoading = false; + }, + error => { + this.tableLoading = false; + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.edit-fail'), error.msg); + } + ); + } + + onDeletePlugins() { + if (this.checkedTagIds == null || this.checkedTagIds.size === 0) { + this.notifySvc.warning(this.i18nSvc.fanyi('common.notify.no-select-delete'), ''); + return; + } + this.modal.confirm({ + nzTitle: this.i18nSvc.fanyi('common.confirm.delete-batch'), + nzOkText: this.i18nSvc.fanyi('common.button.ok'), + nzCancelText: this.i18nSvc.fanyi('common.button.cancel'), + nzOkDanger: true, + nzOkType: 'primary', + nzClosable: false, + nzOnOk: () => this.deletePlugins(this.checkedTagIds) + }); + } + + onDeleteOnePlugin(pluginId: number) { + let alerts = new Set(); + alerts.add(pluginId); + this.modal.confirm({ + nzTitle: this.i18nSvc.fanyi('common.confirm.delete'), + nzOkText: this.i18nSvc.fanyi('common.button.ok'), + nzCancelText: this.i18nSvc.fanyi('common.button.cancel'), + nzOkDanger: true, + nzOkType: 'primary', + nzClosable: false, + nzOnOk: () => this.deletePlugins(alerts) + }); + } + + deletePlugins(pluginIds: Set) { + this.tableLoading = true; + const deleteTags$ = this.pluginService.deletePlugins(pluginIds).subscribe( + message => { + deleteTags$.unsubscribe(); + if (message.code === 0) { + this.notifySvc.success(this.i18nSvc.fanyi('common.notify.delete-success'), ''); + this.updatePageIndex(pluginIds.size); + this.loadPluginsTable(); + } else { + this.tableLoading = false; + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.delete-fail'), message.msg); + } + }, + error => { + this.tableLoading = false; + deleteTags$.unsubscribe(); + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.delete-fail'), error.msg); + } + ); + } + + updatePageIndex(delSize: number) { + const lastPage = Math.max(1, Math.ceil((this.total - delSize) / this.pageSize)); + this.pageIndex = this.pageIndex > lastPage ? lastPage : this.pageIndex; + } + + // begin: 列表多选分页逻辑 + checkedAll: boolean = false; + + onAllChecked(checked: boolean) { + if (checked) { + this.plugins.forEach(monitor => this.checkedTagIds.add(monitor.id)); + } else { + this.checkedTagIds.clear(); + } + } + + onItemChecked(monitorId: number, checked: boolean) { + if (checked) { + this.checkedTagIds.add(monitorId); + } else { + this.checkedTagIds.delete(monitorId); + } + } + + onTablePageChange(params: NzTableQueryParams) { + const { pageSize, pageIndex, sort, filter } = params; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.loadPluginsTable(); + } + + isManageModalVisible = false; + isManageModalOkLoading = false; + isManageModalAdd = true; + + onNewPlugin() { + this.isManageModalVisible = true; + this.isManageModalAdd = true; + } + + onManageModalCancel() { + this.isManageModalVisible = false; + } + + onManageModalOk() { + this.isManageModalOkLoading = true; + if (this.pluginForm.valid) { + const formData = new FormData(); + formData.append('name', this.pluginForm.get('name')?.value); + formData.append('jarFile', this.fileList[0] as any); + formData.append('enableStatus', this.pluginForm.get('enableStatus')?.value); + this.pluginService.uploadPlugin(formData).subscribe((message: any) => { + if (message.code === 0) { + this.isManageModalVisible = false; + this.resetForm(); + this.notifySvc.success(this.i18nSvc.fanyi('common.notify.new-success'), ''); + this.loadPluginsTable(); + } else { + this.notifySvc.error(this.i18nSvc.fanyi('common.notify.new-fail'), message.msg); + } + this.isManageModalOkLoading = false; + }); + } else { + Object.values(this.pluginForm.controls).forEach(control => { + if (control.invalid) { + control.markAsDirty(); + control.updateValueAndValidity({ onlySelf: true }); + } + }); + } + } + + resetForm(): void { + this.pluginForm.reset({ + name: null, + enableStatus: true + }); + this.fileList = []; + } +} diff --git a/web-app/src/app/routes/setting/setting-routing.module.ts b/web-app/src/app/routes/setting/setting-routing.module.ts index 9825584c8c2..cbc5b66cdc0 100644 --- a/web-app/src/app/routes/setting/setting-routing.module.ts +++ b/web-app/src/app/routes/setting/setting-routing.module.ts @@ -22,6 +22,7 @@ import { RouterModule, Routes } from '@angular/router'; import { CollectorComponent } from './collector/collector.component'; import { DefineComponent } from './define/define.component'; +import { SettingPluginsComponent } from './plugins/plugin.component'; import { MessageServerComponent } from './settings/message-server/message-server.component'; import { ObjectStoreComponent } from './settings/object-store/object-store.component'; import { SettingsComponent } from './settings/settings.component'; @@ -31,6 +32,7 @@ import { SettingTagsComponent } from './tags/tags.component'; const routes: Routes = [ { path: 'tags', component: SettingTagsComponent }, + { path: 'plugins', component: SettingPluginsComponent }, { path: 'collector', component: CollectorComponent }, { path: 'status', component: StatusComponent }, { path: 'define', component: DefineComponent, data: { titleI18n: 'menu.advanced.define' } }, diff --git a/web-app/src/app/routes/setting/setting.module.ts b/web-app/src/app/routes/setting/setting.module.ts index 003653eaa6a..08d23546e73 100644 --- a/web-app/src/app/routes/setting/setting.module.ts +++ b/web-app/src/app/routes/setting/setting.module.ts @@ -31,10 +31,12 @@ import { NzListModule } from 'ng-zorro-antd/list'; import { NzRadioModule } from 'ng-zorro-antd/radio'; import { NzSwitchModule } from 'ng-zorro-antd/switch'; import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzUploadComponent } from 'ng-zorro-antd/upload'; import { ColorPickerModule } from 'ngx-color-picker'; import { CollectorComponent } from './collector/collector.component'; import { DefineComponent } from './define/define.component'; +import { SettingPluginsComponent } from './plugins/plugin.component'; import { SettingRoutingModule } from './setting-routing.module'; import { MessageServerComponent } from './settings/message-server/message-server.component'; import { ObjectStoreComponent } from './settings/object-store/object-store.component'; @@ -51,7 +53,8 @@ const COMPONENTS: Array> = [ SystemConfigComponent, ObjectStoreComponent, CollectorComponent, - StatusComponent + StatusComponent, + SettingPluginsComponent ]; @NgModule({ @@ -70,7 +73,8 @@ const COMPONENTS: Array> = [ NzCodeEditorModule, ClipboardModule, NzBadgeModule, - NzRadioModule + NzRadioModule, + NzUploadComponent ], declarations: COMPONENTS }) diff --git a/web-app/src/app/service/plugin.service.spec.ts b/web-app/src/app/service/plugin.service.spec.ts new file mode 100644 index 00000000000..8855934fea8 --- /dev/null +++ b/web-app/src/app/service/plugin.service.spec.ts @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from '@angular/core/testing'; + +import { PluginService } from './plugin.service'; + +describe('PluginService', () => { + let service: PluginService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PluginService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/web-app/src/app/service/plugin.service.ts b/web-app/src/app/service/plugin.service.ts new file mode 100644 index 00000000000..88c858e4c47 --- /dev/null +++ b/web-app/src/app/service/plugin.service.ts @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { Message } from '../pojo/Message'; +import { Page } from '../pojo/Page'; +import { Plugin } from '../pojo/Plugin'; +import { Tag } from '../pojo/Tag'; + +const plugin_uri = '/plugin'; + +@Injectable({ + providedIn: 'root' +}) +export class PluginService { + constructor(private http: HttpClient) {} + + public loadPlugins( + search: string | undefined, + type: number | undefined, + pageIndex: number, + pageSize: number + ): Observable>> { + pageIndex = pageIndex ? pageIndex : 0; + pageSize = pageSize ? pageSize : 8; + // 注意HttpParams是不可变对象 需要保存set后返回的对象为最新对象 + let httpParams = new HttpParams(); + httpParams = httpParams.appendAll({ + pageIndex: pageIndex, + pageSize: pageSize + }); + if (search != undefined && search != '' && search.trim() != '') { + httpParams = httpParams.append('search', search.trim()); + } + const options = { params: httpParams }; + return this.http.get>>(plugin_uri, options); + } + + public uploadPlugin(body: FormData): Observable> { + return this.http.post>(plugin_uri, body); + } + + public updatePluginStatus(body: Plugin): Observable> { + body.enableStatus = !body.enableStatus; + return this.http.put>(plugin_uri, body); + } + + public newTags(body: Tag[]): Observable> { + return this.http.post>(plugin_uri, body); + } + + public newTag(body: Tag): Observable> { + const tags = []; + tags.push(body); + return this.http.post>(plugin_uri, tags); + } + + public editTag(body: Tag): Observable> { + return this.http.put>(plugin_uri, body); + } + + public deletePlugins(pluginIds: Set): Observable> { + let httpParams = new HttpParams(); + pluginIds.forEach(pluginId => { + httpParams = httpParams.append('ids', pluginId); + }); + const options = { params: httpParams }; + return this.http.delete>(plugin_uri, options); + } +} diff --git a/web-app/src/assets/app-data.json b/web-app/src/assets/app-data.json index a7251fbccb9..294d9922ff6 100644 --- a/web-app/src/assets/app-data.json +++ b/web-app/src/assets/app-data.json @@ -199,6 +199,12 @@ "i18n": "menu.advanced.tags", "icon": "anticon-tags", "link": "/setting/tags" + }, + { + "text": "plugins", + "i18n": "menu.advanced.plugins", + "icon": "anticon-usb", + "link": "/setting/plugins" } ] }, diff --git a/web-app/src/assets/i18n/en-US.json b/web-app/src/assets/i18n/en-US.json index cdc2f7c1995..9869a02dd19 100644 --- a/web-app/src/assets/i18n/en-US.json +++ b/web-app/src/assets/i18n/en-US.json @@ -48,7 +48,8 @@ "collector": "Collector Cluster", "tags": "Tags Manage", "define": "Monitor Template", - "status": "Status Page" + "status": "Status Page", + "plugins": "Plugins Manage" }, "extras": { "": "More", @@ -491,6 +492,7 @@ "common.week.5": "Friday", "common.week.6": "Saturday", "common.time.unit.second": "Seconds", + "common.file.select": "Select File", "validation.email.invalid": "Invalid email!", "validation.phone.invalid": "Invalid phone number!", "validation.verification-code.invalid": "Invalid verification code, should be 6 digits!", @@ -530,6 +532,16 @@ "tag.bind.tip": "You can use tags for classification management.eg: assign tags to resources in production environment and test environment.", "tag.help": "Tags are everywhere in HertzBeat. We can apply tags in resource grouping, tag matching under rules and others. [Tag Manage] is used for unified management of tags, including adding, deleting, editing, etc.
You can use tags to classify and manage monitoring resources, such as binding labels for production and testing environments separately.", "tag.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "plugin.help": "In HertzBeat, we can use the plugin mechanism to perform some other operations after the alarm except notification. Plugin management is used for unified management of plugins, including upload and enable/disable operations.
For example, you can use the plugin mechanism to execute specific scripts or SQL after the alarm occurs.", + "plugin.help.link": "https://hertzbeat.apache.org/docs/help/plugin", + "plugin.upload": "Upload Plugin", + "plugin.name": "Plugin Name", + "plugin.type": "Plugin Type", + "plugin.status": "Enabled Status", + "plugin.jar.file": "Jar File", + "plugin.delete": "Delete Plugin", + "plugin.type.POST_ALERT": "POST ALERT", + "plugin.search": "Search plugins", "define.help": "The monitor templates define each monitoring type, parameter variable, metrics info, collection protocol, etc. You can select an existing monitoring template from the drop-down menu then make modifications according to your own needs. The bottom-left area is the compare area and the bottom-right area is the editing place.
You can also click \"New Monitor Type\" to custom define an new type. Currently supported protocols include HTTP, JDBC, SSH, JMX, SNMP. Monitor Templates.", "define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/", "define.save-apply": "Save And Apply", diff --git a/web-app/src/assets/i18n/zh-CN.json b/web-app/src/assets/i18n/zh-CN.json index f4ef47a5043..37db596648b 100644 --- a/web-app/src/assets/i18n/zh-CN.json +++ b/web-app/src/assets/i18n/zh-CN.json @@ -48,7 +48,8 @@ "collector": "采集集群", "tags": "标签管理", "define": "监控模版", - "status": "状态页面" + "status": "状态页面", + "plugins": "插件管理" }, "extras": { "": "更多", @@ -492,6 +493,7 @@ "common.week.5": "星期五", "common.week.6": "星期六", "common.time.unit.second": "秒", + "common.file.select": "选择文件", "app.theme.default": "浅色主题", "app.theme.dark": "深色主题", "app.theme.compact": "紧凑主题", @@ -529,6 +531,16 @@ "tag.bind.tip": "您可以使用标签进行监控资源的分类管理, 例如给资源分别绑定生产环境、测试环境的标签。", "tag.help": "标签在 HertzBeat 中无处不在,我们可以应用标签在资源分组,规则下的标签匹配等场景。标签管理用于对标签的统一管理维护,包含新增,删除,编辑等操作。
例如:您可以使用标签对监控资源进行分类管理,给资源分别绑定生产环境、测试环境的标签,在告警通知时通过标签匹配不同的通知人。", "tag.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "plugin.help": "在HertzBeat中,我们可以通过插件机制在告警后执行一些除通知以外的其他操作。插件管理用于对插件的统一管理,包括上传和,启用禁用等操作。
例如:您可以通过插件机制实现在告警出现后,执行特定的脚本或SQL等操作。", + "plugin.help.link": "https://hertzbeat.apache.org/zh-cn/docs/help/plugin", + "plugin.upload": "上传插件", + "plugin.name": "插件名称", + "plugin.status": "启用状态", + "plugin.jar.file": "Jar包", + "plugin.delete": "刪除插件", + "plugin.type": "插件类型", + "plugin.type.POST_ALERT": "告警后", + "plugin.search": "搜索插件", "define.help": "监控模版定义每一个监控类型,类型的参数变量,指标信息,采集协议等。您可根据需求在下拉菜单中选择已有监控模板修改。左下区域为对照区,右下区域为编辑区。
您也可以点击“新增监控类型”来自定义新的的监控类型,目前支持 HTTP 协议JDBC协议SSH协议 JMX 协议 SNMP 协议点击查看监控模板。\n", "define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/", "define.save-apply": "保存并应用", diff --git a/web-app/src/assets/i18n/zh-TW.json b/web-app/src/assets/i18n/zh-TW.json index 9bd0fc536b5..7ebb6c00b6e 100644 --- a/web-app/src/assets/i18n/zh-TW.json +++ b/web-app/src/assets/i18n/zh-TW.json @@ -48,7 +48,8 @@ "collector": "採集集群", "tags": "標簽管理", "define": "監控模版", - "status": "狀態頁面" + "status": "狀態頁面", + "plugins": "外掛管理" }, "extras": { "": "更多", @@ -491,6 +492,7 @@ "common.button.setting": "配置", "common.button.delete": "刪除", "common.time.unit.second": "秒", + "common.file.select": "選擇文件", "app.theme.default": "淺色主題", "app.theme.dark": "深色主題", "app.theme.compact": "緊湊主題", @@ -528,6 +530,16 @@ "tag.bind.tip": "您可以使用標簽進行監控資源的分類管理, 例如給資源分別綁定生産環境、測試環境的標簽。", "tag.help": "標簽在 HertzBeat 中無處不在,我們可以應用標簽在資源分組,規則下的標簽匹配等場景。標簽管理用于對標簽的統壹管理維護,包含新增,刪除,編輯等操作。
例如:您可以使用標簽對監控資源進行分類管理,給資源分別綁定生産環境、測試環境的標簽,在告警通知時通過標簽匹配不同的通知人。", "tag.help.link": "https://hertzbeat.apache.org/zh-cn/docs/", + "plugin.help": "在HertzBeat中,我們可以透過外掛機制在警告後執行一些除通知以外的其他操作。外掛程式管理用於對外掛程式的統一管理,包括上傳和,啟用停用等操作。
例如:您可以透過外掛機制實現在警告出現後,執行特定的腳本或SQL等操作。", + "plugin.help.link": "https://hertzbeat.apache.org/zh-cn/docs/help/plugin", + "plugin.upload": "上傳外掛", + "plugin.name": "插件名稱", + "plugin.type": "插件類型", + "plugin.status": "啟用狀態", + "plugin.jar.file": "Jar包", + "plugin.delete": "刪除插件", + "plugin.type.POST_ALERT": "告警後", + "plugin.search": "搜尋插件", "define.help": "監控模版定義每一個監控類型,類型的參數變量,指標信息,採集協議等。您可根據需求在下拉功能表中選擇已有監控模版進行修改。右下區域為編輯區,左下區域為對照區。
您也可以點擊“新增監控類型”來自定義新的的監控類型,現支持 HTTP協議JDBC協定SSH協定 JMX協定 SNMP協定點擊查看監控範本。", "define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/", "define.save-apply": "保存並應用",