[feature] add plugin management and support plugin hot reloading (#2238)

Signed-off-by: liutianyou <tianyou.liu@qq.com>
Co-authored-by: Logic <zqr10159@dromara.org>
This commit is contained in:
liutianyou
2024-07-08 18:53:49 +08:00
committed by GitHub
co-authored by Logic
parent 9fed2c8a36
commit 2d53abdaab
25 changed files with 1748 additions and 23 deletions
@@ -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
}
@@ -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;
}
@@ -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);
}
}
@@ -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<PluginItem> items;
}
@@ -21,7 +21,6 @@ import com.google.common.collect.Maps;
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.NoticeRule;
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 @@ import org.springframework.stereotype.Component;
@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<Byte, AlertNotifyHandler> alertNotifyHandlerMap;
private final PluginService pluginService;
public DispatcherAlarm(AlerterWorkerPool workerPool,
CommonDataQueue dataQueue,
NoticeConfigService noticeConfigService,
AlertStoreHandler alertStoreHandler,
List<AlertNotifyHandler> alertNotifyHandlerList) {
CommonDataQueue dataQueue,
NoticeConfigService noticeConfigService,
AlertStoreHandler alertStoreHandler,
List<AlertNotifyHandler> 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 class DispatcherAlarm implements InitializingBean {
alertStoreHandler.store(alert);
// Notice distribution
sendNotify(alert);
// Execute the plugin
ServiceLoader<Plugin> 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 @@ public class DispatcherAlarm implements InitializingBean {
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());
}
});
});
});
});
@@ -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<Message<Void>> 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<Message<Page<PluginMetadata>>> 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<PluginMetadata> specification = (root, query, criteriaBuilder) -> {
List<Predicate> 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<PluginMetadata> alertPage = pluginService.getPlugins(specification, pageRequest);
Message<Page<PluginMetadata>> message = Message.success(alertPage);
return ResponseEntity.ok(message);
}
@DeleteMapping()
@Operation(summary = "Delete plugins based on ID", description = "Delete plugins based on ID")
public ResponseEntity<Message<Void>> deleteTags(
@Parameter(description = "Plugin IDs ", example = "6565463543") @RequestParam(required = false) List<Long> 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<Message<Void>> updatePluginStatus(@RequestBody PluginMetadata plugin) {
pluginService.updateStatus(plugin);
return ResponseEntity.ok(Message.success("Update success"));
}
}
@@ -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<PluginItem, Long>, JpaSpecificationExecutor<PluginItem> {
int countPluginItemByClassIdentifierIn(List<String> classIdentifiers);
}
@@ -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<PluginMetadata, Long>, JpaSpecificationExecutor<PluginMetadata> {
/**
* count by name
*
* @param name name
* @return count
*/
int countPluginMetadataByName(String name);
/**
* find enabled plugins
* @return plugins
*/
List<PluginMetadata> findPluginMetadataByEnableStatusTrue();
}
@@ -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<PluginMetadata> getPlugins(Specification<PluginMetadata> specification, PageRequest pageRequest);
/**
* execute plugin
* @param clazz plugin interface
* @param execute run plugin logic
* @param <T> plugin type
*/
<T> void pluginExecute(Class<T> clazz, Consumer<T> execute);
/**
* delete plugin
*
* @param ids set of plugin id
*/
void deletePlugins(Set<Long> ids);
void updateStatus(PluginMetadata plugin);
}
@@ -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<Class<?>, PluginType> PLUGIN_TYPE_MAPPING = new HashMap<>();
/**
* plugin status
*/
private static final Map<String, Boolean> PLUGIN_ENABLE_STATUS = new ConcurrentHashMap<>();
private URLClassLoader pluginClassLoader;
@Override
public void deletePlugins(Set<Long> ids) {
List<PluginMetadata> 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> 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<PluginItem> validateJarFile(File jarFile) {
List<PluginItem> 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<JarEntry> 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<PluginItem> 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<PluginMetadata> getPlugins(Specification<PluginMetadata> specification, PageRequest pageRequest) {
return metadataDao.findAll(specification, pageRequest);
}
/**
* Load all plugin enabled states into memory
*/
@PostConstruct
private void syncPluginStatus() {
List<PluginMetadata> plugins = metadataDao.findAll();
Map<String, Boolean> 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<PluginMetadata> plugins = metadataDao.findPluginMetadataByEnableStatusTrue();
List<URL> 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 <T> void pluginExecute(Class<T> clazz, Consumer<T> execute) {
ServiceLoader<T> load = ServiceLoader.load(clazz, pluginClassLoader);
for (T t : load) {
if (pluginIsEnable(t.getClass())) {
execute.accept(t);
}
}
}
}
@@ -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<Long> 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();
}
}
@@ -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<PluginItem> 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<Long> 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<PluginMetadata> spec = mock(Specification.class);
PageRequest pageRequest = PageRequest.of(0, 10);
Page<PluginMetadata> page = new PageImpl<>(Collections.singletonList(new PluginMetadata()));
when(metadataDao.findAll(any(Specification.class), any(PageRequest.class))).thenReturn(page);
Page<PluginMetadata> result = pluginService.getPlugins(spec, pageRequest);
assertFalse(result.isEmpty());
verify(metadataDao, times(1)).findAll(any(Specification.class), any(PageRequest.class));
}
}
+27
View File
@@ -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[];
}
+22
View File
@@ -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;
}
@@ -0,0 +1,152 @@
<!--
~ 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.
-->
<app-help-message-show
[help_message_content]="'plugin.help' | i18n"
[guild_link]="'plugin.help.link' | i18n"
[module_name]="'menu.advanced.plugins'"
[icon_name]="'usb'"
></app-help-message-show>
<nz-divider></nz-divider>
<app-toolbar>
<ng-template #left>
<button nz-button nzType="primary" nz-tooltip (click)="sync()" [nzTooltipTitle]="'common.refresh' | i18n">
<i nz-icon nzType="sync" nzTheme="outline"></i>
</button>
<button nz-button nzType="primary" (click)="onNewPlugin()">
<i nz-icon nzType="appstore-add" nzTheme="outline"></i>
{{ 'plugin.upload' | i18n }}
</button>
<button nz-button nzType="primary" nzDanger (click)="onDeletePlugins()">
<i nz-icon nzType="delete" nzTheme="outline"></i>
{{ 'plugin.delete' | i18n }}
</button>
</ng-template>
<ng-template #right>
<app-multi-func-input
class="mobile-hide"
groupStyle="width: 250px;"
[placeholder]="'plugin.search' | i18n"
[(value)]="search"
(valueChange)="loadPluginsTable()"
/>
</ng-template>
</app-toolbar>
<nz-table
#fixedTable
[nzData]="plugins"
[nzPageIndex]="pageIndex"
[nzPageSize]="pageSize"
[nzTotal]="total"
nzFrontPagination="false"
[nzLoading]="tableLoading"
nzShowSizeChanger
[nzShowTotal]="rangeTemplate"
[nzPageSizeOptions]="[8, 15, 25]"
(nzQueryParams)="onTablePageChange($event)"
nzShowPagination="true"
[nzScroll]="{ x: '1240px' }"
>
<thead>
<tr>
<th nzAlign="center" nzLeft nzWidth="3%" [(nzChecked)]="checkedAll" (nzCheckedChange)="onAllChecked($event)"></th>
<th nzAlign="center" nzWidth="15%">{{ 'plugin.name' | i18n }}</th>
<th nzAlign="center" nzWidth="15%">{{ 'plugin.type' | i18n }}</th>
<th nzAlign="center" nzWidth="15%">{{ 'plugin.status' | i18n }}</th>
<th nzAlign="center" nzWidth="17%">{{ 'common.edit' | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let data of fixedTable.data">
<td nzAlign="center" nzLeft [nzChecked]="checkedTagIds.has(data.id)" (nzCheckedChange)="onItemChecked(data.id, $event)"></td>
<td nzAlign="center">{{ data.name }}</td>
<td nzAlign="left">
<nz-tag *ngFor="let item of data?.items; let i = index" style="margin-top: 2px" class="hoverClass">
{{ 'plugin.type.' + item.type | i18n }}
</nz-tag>
</td>
<td nzAlign="center">
<nz-switch [(ngModel)]="data.enableStatus" (ngModelChange)="updatePluginEnableStatus(data)" name="enable"></nz-switch>
</td>
<!-- <td nzAlign="center">{{ data.enableStatus }}</td>-->
<td nzAlign="center">
<div class="actions">
<button
nz-button
nzType="primary"
nzDanger
(click)="onDeleteOnePlugin(data.id)"
nz-tooltip
[nzTooltipTitle]="'plugin.delete' | i18n"
>
<i nz-icon nzType="delete" nzTheme="outline"></i>
</button>
</div>
</td>
</tr>
</tbody>
</nz-table>
<ng-template #rangeTemplate> {{ 'common.total' | i18n }} {{ total }} </ng-template>
<!-- upload plugin pop-up window -->
<nz-modal
[(nzVisible)]="isManageModalVisible"
[nzTitle]="isManageModalAdd ? ('plugin.upload' | i18n) : ('plugin.edit' | i18n)"
(nzOnCancel)="onManageModalCancel()"
(nzOnOk)="onManageModalOk()"
nzMaskClosable="false"
nzWidth="30%"
[nzOkLoading]="isManageModalOkLoading"
>
<div *nzModalContent class="-inner-content">
<form [formGroup]="pluginForm" nz-form>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired="true" nzFor="name">{{ 'plugin.name' | i18n }}</nz-form-label>
<nz-form-control [nzSpan]="14" nzErrorTip="Please input the name">
<input nz-input formControlName="name" id="name" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired="true" nzFor="jarFile">{{ 'plugin.jar.file' | i18n }}</nz-form-label>
<nz-form-control [nzSpan]="14" nzErrorTip="Please upload a file">
<nz-upload nzAccept=".jar" nzAction="" nzListType="text" [(nzFileList)]="fileList" [nzBeforeUpload]="beforeUpload">
<button nz-button>
<i nz-icon nzType="upload"></i>
{{ 'common.file.select' | i18n }}
</button>
</nz-upload>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired="true" nzFor="enableStatus">{{ 'plugin.status' | i18n }}</nz-form-label>
<nz-form-control [nzSpan]="14">
<nz-switch formControlName="enableStatus" required></nz-switch>
</nz-form-control>
</nz-form-item>
</form>
</div>
</nz-modal>
@@ -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<SettingPluginsComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [SettingPluginsComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SettingPluginsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -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<number>();
// 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<number>();
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<number>) {
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 = [];
}
}
@@ -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' } },
@@ -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<Type<void>> = [
SystemConfigComponent,
ObjectStoreComponent,
CollectorComponent,
StatusComponent
StatusComponent,
SettingPluginsComponent
];
@NgModule({
@@ -70,7 +73,8 @@ const COMPONENTS: Array<Type<void>> = [
NzCodeEditorModule,
ClipboardModule,
NzBadgeModule,
NzRadioModule
NzRadioModule,
NzUploadComponent
],
declarations: COMPONENTS
})
@@ -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();
});
});
+89
View File
@@ -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<Message<Page<Plugin>>> {
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<Message<Page<Plugin>>>(plugin_uri, options);
}
public uploadPlugin(body: FormData): Observable<Message<any>> {
return this.http.post<Message<any>>(plugin_uri, body);
}
public updatePluginStatus(body: Plugin): Observable<Message<any>> {
body.enableStatus = !body.enableStatus;
return this.http.put<Message<any>>(plugin_uri, body);
}
public newTags(body: Tag[]): Observable<Message<any>> {
return this.http.post<Message<any>>(plugin_uri, body);
}
public newTag(body: Tag): Observable<Message<any>> {
const tags = [];
tags.push(body);
return this.http.post<Message<any>>(plugin_uri, tags);
}
public editTag(body: Tag): Observable<Message<any>> {
return this.http.put<Message<any>>(plugin_uri, body);
}
public deletePlugins(pluginIds: Set<number>): Observable<Message<any>> {
let httpParams = new HttpParams();
pluginIds.forEach(pluginId => {
httpParams = httpParams.append('ids', pluginId);
});
const options = { params: httpParams };
return this.http.delete<Message<any>>(plugin_uri, options);
}
}
+6
View File
@@ -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"
}
]
},
+13 -1
View File
@@ -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. <br>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.<br>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. <br> You can also click \"New Monitor Type\" to custom define an new type. Currently supported protocols include<a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-http'> HTTP</a>, <a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jdbc'>JDBC</a>, <a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-ssh'>SSH</a>, <a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jmx'>JMX</a>, <a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-snmp'> SNMP</a>. <a class='help_module_content' href='https://hertzbeat.apache.org/zh-cn/docs/template'>Monitor Templates</a>.",
"define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/",
"define.save-apply": "Save And Apply",
+13 -1
View File
@@ -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 中无处不在,我们可以应用标签在资源分组,规则下的标签匹配等场景。标签管理用于对标签的统一管理维护,包含新增,删除,编辑等操作。<br>例如:您可以使用标签对监控资源进行分类管理,给资源分别绑定生产环境、测试环境的标签,在告警通知时通过标签匹配不同的通知人。",
"tag.help.link": "https://hertzbeat.apache.org/zh-cn/docs/",
"plugin.help": "在HertzBeat中,我们可以通过插件机制在告警后执行一些除通知以外的其他操作。插件管理用于对插件的统一管理,包括上传和,启用禁用等操作。<br>例如:您可以通过插件机制实现在告警出现后,执行特定的脚本或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": "监控模版定义每一个监控类型,类型的参数变量,指标信息,采集协议等。您可根据需求在下拉菜单中选择已有监控模板修改。左下区域为对照区,右下区域为编辑区。<br>您也可以点击“<i>新增监控类型</i>”来自定义新的的监控类型,目前支持 <a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-http'> HTTP 协议</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jdbc'>JDBC协议</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-ssh'>SSH协议</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jmx'> JMX 协议</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-snmp'> SNMP 协议</a><a class='help_module_content' href='https://hertzbeat.apache.org/zh-cn/docs/template'>点击查看监控模板</a>。\n",
"define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/",
"define.save-apply": "保存并应用",
+13 -1
View File
@@ -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 中無處不在,我們可以應用標簽在資源分組,規則下的標簽匹配等場景。標簽管理用于對標簽的統壹管理維護,包含新增,刪除,編輯等操作。<br>例如:您可以使用標簽對監控資源進行分類管理,給資源分別綁定生産環境、測試環境的標簽,在告警通知時通過標簽匹配不同的通知人。",
"tag.help.link": "https://hertzbeat.apache.org/zh-cn/docs/",
"plugin.help": "在HertzBeat中,我們可以透過外掛機制在警告後執行一些除通知以外的其他操作。外掛程式管理用於對外掛程式的統一管理,包括上傳和,啟用停用等操作。<br>例如:您可以透過外掛機制實現在警告出現後,執行特定的腳本或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": "監控模版定義每一個監控類型,類型的參數變量,指標信息,採集協議等。您可根據需求在下拉功能表中選擇已有監控模版進行修改。右下區域為編輯區,左下區域為對照區。<br>您也可以點擊“<i>新增監控類型</i>”來自定義新的的監控類型,現支持<a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-http'> HTTP協議</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jdbc'>JDBC協定</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-ssh'>SSH協定</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-jmx'> JMX協定</a><a href='https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-snmp'> SNMP協定</a><a href='https://hertzbeat.apache.org/zh-cn/docs/template'>點擊查看監控範本</a>。",
"define.help.link": "https://hertzbeat.apache.org/zh-cn/docs/advanced/extend-point/",
"define.save-apply": "保存並應用",