From 76ee11666577bf2d35fa9835e94785068ab1e891 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld <33032967+moesterheld@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:53:34 +0100 Subject: [PATCH] Bulk actions for data node (#18121) * add action queue for data nodes and queued removal * implement wait for completed bus event * add change log * add queue status to frontend * add bulk start/stop * adjust test for removing multiple nodes * add queue field to entity test * integrated bulk action endpoints * action_queue status styling * cleanup mocks * add tests for removing multiple * fixed requested wordings and typos --------- Co-authored-by: Mohamed Ould Hocine Co-authored-by: Mohamed OULD HOCINE <106236152+gally47@users.noreply.github.com> Co-authored-by: Laura --- changelog/unreleased/pr-18121.toml | 6 + .../management/ClusterNodeStateTracer.java | 62 ++++++++++ .../management/OpensearchProcessService.java | 12 +- .../management/OpensearchRemovalTracer.java | 11 +- .../OpensearchRemovalTracerTest.java | 8 +- .../cluster/nodes/AbstractNodeService.java | 11 +- .../graylog2/cluster/nodes/DataNodeDto.java | 29 ++++- .../cluster/nodes/DataNodeEntity.java | 10 ++ .../org/graylog2/cluster/nodes/NodeDto.java | 5 +- .../graylog2/cluster/nodes/NodeService.java | 2 + .../datanode/DataNodeLifecycleTrigger.java | 2 +- .../datanode/DataNodeServiceImpl.java | 59 ++++++++-- .../datanodes/DataNodeManagementResource.java | 108 +++++++++++++++--- .../cluster/nodes/DataNodeEntityTest.java | 2 +- .../nodes/TestDataNodeNodeClusterService.java | 7 +- .../datanode/DataNodeServiceImplTest.java | 50 +++++++- .../DataNodeManagementResourceTest.java | 19 ++- .../DataNodeList/DataNodeBulkActions.tsx | 69 +++++++++++ .../datanode/DataNodeList/DataNodeList.tsx | 3 +- .../DataNodeList/DataNodeStatusCell.tsx | 19 ++- .../components/datanode/hooks/useDataNodes.ts | 72 +++++++++++- graylog2-web-interface/src/preflight/types.ts | 1 + 22 files changed, 508 insertions(+), 59 deletions(-) create mode 100644 changelog/unreleased/pr-18121.toml create mode 100644 data-node/src/main/java/org/graylog/datanode/management/ClusterNodeStateTracer.java create mode 100644 graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBulkActions.tsx diff --git a/changelog/unreleased/pr-18121.toml b/changelog/unreleased/pr-18121.toml new file mode 100644 index 000000000000..5535f41c39e9 --- /dev/null +++ b/changelog/unreleased/pr-18121.toml @@ -0,0 +1,6 @@ +type = "a" +message = "add bulk management capabilities for data nodes" + +issues = ["17732"] +pulls = ["18121"] + diff --git a/data-node/src/main/java/org/graylog/datanode/management/ClusterNodeStateTracer.java b/data-node/src/main/java/org/graylog/datanode/management/ClusterNodeStateTracer.java new file mode 100644 index 000000000000..81cd5b678574 --- /dev/null +++ b/data-node/src/main/java/org/graylog/datanode/management/ClusterNodeStateTracer.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.datanode.management; + +import org.graylog.datanode.process.ProcessEvent; +import org.graylog.datanode.process.ProcessState; +import org.graylog.datanode.process.StateMachineTracer; +import org.graylog2.cluster.NodeNotFoundException; +import org.graylog2.cluster.nodes.DataNodeDto; +import org.graylog2.cluster.nodes.NodeService; +import org.graylog2.datanode.DataNodeLifecycleTrigger; +import org.graylog2.plugin.system.NodeId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ClusterNodeStateTracer implements StateMachineTracer { + + private final Logger log = LoggerFactory.getLogger(ClusterNodeStateTracer.class); + + private final NodeService nodeService; + private final NodeId nodeId; + + public ClusterNodeStateTracer(NodeService nodeService, NodeId nodeId) { + this.nodeService = nodeService; + this.nodeId = nodeId; + } + + @Override + public void trigger(ProcessEvent processEvent) { + } + + @Override + public void transition(ProcessEvent processEvent, ProcessState source, ProcessState destination) { + try { + if (!source.equals(destination)) { + log.info("Updating cluster node {} from {} to {} (reason: {})", nodeId.getNodeId(), + source.getDataNodeStatus(), destination.getDataNodeStatus(), processEvent.name()); + DataNodeDto node = nodeService.byNodeId(nodeId); + nodeService.update(node.toBuilder() + .setDataNodeStatus(destination.getDataNodeStatus()) + .setActionQueue(DataNodeLifecycleTrigger.CLEAR) + .build()); + } + } catch (NodeNotFoundException e) { + throw new RuntimeException("Node not registered, this should not happen."); + } + } +} diff --git a/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessService.java b/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessService.java index f49a01be8c8f..762eb7939d3e 100644 --- a/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessService.java +++ b/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessService.java @@ -35,6 +35,7 @@ import org.graylog2.cluster.preflight.DataNodeProvisioningStateChangeEvent; import org.graylog2.datanode.DataNodeLifecycleEvent; import org.graylog2.datanode.RemoteReindexAllowlistEvent; +import org.graylog2.events.ClusterEventBus; import org.graylog2.indexer.fieldtypes.IndexFieldTypesService; import org.graylog2.plugin.system.NodeId; import org.graylog2.security.CustomCAX509TrustManager; @@ -53,11 +54,14 @@ public class OpensearchProcessService extends AbstractIdleService implements Pro private final OpensearchProcess process; private final Provider configurationProvider; private final EventBus eventBus; + private final NodeService nodeService; private final NodeId nodeId; private final DataNodeProvisioningService dataNodeProvisioningService; private final IndexFieldTypesService indexFieldTypesService; private final ObjectMapper objectMapper; private final ProcessStateMachine processStateMachine; + private final ClusterEventBus clusterEventBus; + @Inject public OpensearchProcessService(final DatanodeConfiguration datanodeConfiguration, @@ -70,14 +74,17 @@ public OpensearchProcessService(final DatanodeConfiguration datanodeConfiguratio final NodeId nodeId, final IndexFieldTypesService indexFieldTypesService, final ObjectMapper objectMapper, - final ProcessStateMachine processStateMachine) { + final ProcessStateMachine processStateMachine, + final ClusterEventBus clusterEventBus) { this.configurationProvider = configurationProvider; this.eventBus = eventBus; + this.nodeService = nodeService; this.nodeId = nodeId; this.dataNodeProvisioningService = dataNodeProvisioningService; this.objectMapper = objectMapper; this.indexFieldTypesService = indexFieldTypesService; this.processStateMachine = processStateMachine; + this.clusterEventBus = clusterEventBus; this.process = createOpensearchProcess(datanodeConfiguration, trustManager, configuration, nodeService, objectMapper, processStateMachine); eventBus.register(this); } @@ -88,8 +95,9 @@ private OpensearchProcess createOpensearchProcess(final DatanodeConfiguration da final ProcessWatchdog watchdog = new ProcessWatchdog(process, WATCHDOG_RESTART_ATTEMPTS); process.addStateMachineTracer(watchdog); process.addStateMachineTracer(new StateMachineTransitionLogger()); - process.addStateMachineTracer(new OpensearchRemovalTracer(process, configuration.getDatanodeNodeName())); + process.addStateMachineTracer(new OpensearchRemovalTracer(process, configuration.getDatanodeNodeName(), nodeId, clusterEventBus)); process.addStateMachineTracer(new ConfigureMetricsIndexSettings(process, configuration, indexFieldTypesService, objectMapper)); + process.addStateMachineTracer(new ClusterNodeStateTracer(nodeService, nodeId)); return process; } diff --git a/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java b/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java index 163ff7f73ef7..8bcb868bc2a1 100644 --- a/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java +++ b/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java @@ -16,6 +16,7 @@ */ package org.graylog.datanode.management; +import com.google.common.eventbus.EventBus; import com.google.common.util.concurrent.ThreadFactoryBuilder; import org.graylog.datanode.process.ProcessEvent; import org.graylog.datanode.process.ProcessState; @@ -31,6 +32,9 @@ import org.graylog.shaded.opensearch2.org.opensearch.client.RequestOptions; import org.graylog.shaded.opensearch2.org.opensearch.client.RestHighLevelClient; import org.graylog.shaded.opensearch2.org.opensearch.common.settings.Settings; +import org.graylog2.datanode.DataNodeLifecycleEvent; +import org.graylog2.datanode.DataNodeLifecycleTrigger; +import org.graylog2.plugin.system.NodeId; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,12 +56,16 @@ public class OpensearchRemovalTracer implements StateMachineTracer { private final OpensearchProcess process; private final String nodeName; + private final NodeId nodeId; + private final EventBus eventBus; boolean allocationExcludeChecked = false; ScheduledExecutorService executorService; - public OpensearchRemovalTracer(OpensearchProcess process, String nodeName) { + public OpensearchRemovalTracer(OpensearchProcess process, String nodeName, NodeId nodeId, EventBus eventBus) { this.process = process; this.nodeName = nodeName; + this.nodeId = nodeId; + this.eventBus = eventBus; } @@ -130,6 +138,7 @@ void checkRemovalStatus() { if (health.getRelocatingShards() == 0) { process.stop(); executorService.shutdown(); + eventBus.post(DataNodeLifecycleEvent.create(nodeId.getNodeId(), DataNodeLifecycleTrigger.REMOVED)); } } catch (IOException | OpenSearchStatusException e) { process.onEvent(ProcessEvent.HEALTH_CHECK_FAILED); diff --git a/data-node/src/test/java/org/graylog/datanode/management/OpensearchRemovalTracerTest.java b/data-node/src/test/java/org/graylog/datanode/management/OpensearchRemovalTracerTest.java index 2113ac8a8ae0..c8a74cd5b494 100644 --- a/data-node/src/test/java/org/graylog/datanode/management/OpensearchRemovalTracerTest.java +++ b/data-node/src/test/java/org/graylog/datanode/management/OpensearchRemovalTracerTest.java @@ -16,6 +16,7 @@ */ package org.graylog.datanode.management; +import com.google.common.eventbus.EventBus; import org.graylog.datanode.process.ProcessEvent; import org.graylog.datanode.process.ProcessState; import org.graylog.shaded.opensearch2.org.opensearch.action.admin.cluster.health.ClusterHealthResponse; @@ -26,6 +27,8 @@ import org.graylog.shaded.opensearch2.org.opensearch.client.RequestOptions; import org.graylog.shaded.opensearch2.org.opensearch.client.RestHighLevelClient; import org.graylog.shaded.opensearch2.org.opensearch.common.settings.Settings; +import org.graylog2.plugin.system.NodeId; +import org.graylog2.plugin.system.SimpleNodeId; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -53,18 +56,21 @@ public class OpensearchRemovalTracerTest { private OpensearchRemovalTracer classUnderTest; private final String NODENAME = "datanode1"; + private final NodeId nodeId = new SimpleNodeId(NODENAME); @Mock private OpensearchProcess process; @Mock RestHighLevelClient restClient; @Mock ClusterClient clusterClient; + @Mock + EventBus eventBus; @Before public void setUp() { when(process.restClient()).thenReturn(Optional.of(restClient)); when(restClient.cluster()).thenReturn(clusterClient); - this.classUnderTest = new OpensearchRemovalTracer(process, NODENAME); + this.classUnderTest = new OpensearchRemovalTracer(process, NODENAME, nodeId, eventBus); } @Test diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/AbstractNodeService.java b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/AbstractNodeService.java index d6f35d07fa2e..a2a68afd7603 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/AbstractNodeService.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/AbstractNodeService.java @@ -21,6 +21,7 @@ import com.mongodb.BasicDBObject; import com.mongodb.DBObject; import com.mongodb.WriteResult; +import jakarta.inject.Inject; import org.bson.types.ObjectId; import org.graylog2.Configuration; import org.graylog2.cluster.Node; @@ -31,8 +32,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; - import java.lang.reflect.InvocationTargetException; import java.util.Collection; import java.util.Iterator; @@ -228,4 +227,12 @@ public void ping(NodeDto dto) { LOG.warn("Caught exception during node ping.", e); } } + + @Override + public void update(NodeDto dto) { + BasicDBObject query = new BasicDBObject("node_id", dto.getNodeId()); + final BasicDBObject update = new BasicDBObject(Map.of("$set", dto.toEntityParameters())); + super.collection(nodeClass).update(query, update); + } + } diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeDto.java b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeDto.java index 1fd79a512a50..106016e21fc6 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeDto.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeDto.java @@ -24,9 +24,11 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.auto.value.AutoValue; import org.graylog.security.certutil.CertRenewalService; +import org.graylog2.datanode.DataNodeLifecycleTrigger; import javax.annotation.Nullable; import java.util.Map; +import java.util.Objects; @AutoValue @JsonIgnoreProperties(ignoreUnknown = true) @@ -45,6 +47,10 @@ public abstract class DataNodeDto extends NodeDto { @JsonProperty("data_node_status") public abstract DataNodeStatus getDataNodeStatus(); + @Nullable + @JsonProperty("action_queue") + public abstract DataNodeLifecycleTrigger getActionQueue(); + @Nullable @JsonUnwrapped public abstract CertRenewalService.ProvisioningInformation getProvisioningInformation(); @@ -52,9 +58,22 @@ public abstract class DataNodeDto extends NodeDto { @Override public Map toEntityParameters() { final Map params = super.toEntityParameters(); - params.put("cluster_address", getClusterAddress()); - params.put("rest_api_address", getRestApiAddress()); - params.put("datanode_status", getDataNodeStatus()); + if (Objects.nonNull(getClusterAddress())) { + params.put("cluster_address", getClusterAddress()); + } + if (Objects.nonNull(getRestApiAddress())) { + params.put("rest_api_address", getRestApiAddress()); + } + if (Objects.nonNull(getDataNodeStatus())) { + params.put("datanode_status", getDataNodeStatus()); + } + if (Objects.nonNull(getActionQueue())) { + if (getActionQueue() == DataNodeLifecycleTrigger.CLEAR) { + params.put("action_queue", null); + } else { + params.put("action_queue", getActionQueue()); + } + } return params; } @@ -78,8 +97,12 @@ public static Builder builder() { @JsonProperty("datanode_status") public abstract Builder setDataNodeStatus(DataNodeStatus dataNodeStatus); + @JsonProperty("action_queue") + public abstract Builder setActionQueue(DataNodeLifecycleTrigger trigger); + public abstract Builder setProvisioningInformation(CertRenewalService.ProvisioningInformation provisioningInformation); + public abstract DataNodeDto build(); diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeEntity.java b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeEntity.java index 6009a58147b1..13965dd1896e 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeEntity.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeEntity.java @@ -19,8 +19,10 @@ import com.fasterxml.jackson.annotation.JsonCreator; import org.bson.types.ObjectId; import org.graylog2.database.DbEntity; +import org.graylog2.datanode.DataNodeLifecycleTrigger; import java.util.Map; +import java.util.Objects; @DbEntity(collection = "datanodes", titleField = "node_id") public class DataNodeEntity extends AbstractNode { @@ -42,6 +44,13 @@ public String getRestApiAddress() { return (String) fields.get("rest_api_address"); } + public DataNodeLifecycleTrigger getActionQueue() { + if (!fields.containsKey("action_queue") || Objects.isNull(fields.get("action_queue"))) { + return null; + } + return DataNodeLifecycleTrigger.valueOf(fields.get("action_queue").toString()); + } + public DataNodeStatus getDataNodeStatus() { if (!fields.containsKey("datanode_status")) { return null; @@ -61,6 +70,7 @@ public DataNodeDto toDto() { .setClusterAddress(this.getClusterAddress()) .setDataNodeStatus(this.getDataNodeStatus()) .setRestApiAddress(this.getRestApiAddress()) + .setActionQueue(this.getActionQueue()) .build(); } diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeDto.java b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeDto.java index aaeddd84ba09..0d850a1cef63 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeDto.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeDto.java @@ -25,6 +25,7 @@ import javax.annotation.Nullable; import java.util.HashMap; import java.util.Map; +import java.util.Objects; public abstract class NodeDto implements Node { @@ -64,7 +65,9 @@ public Map toEntityParameters() { params.put("node_id", getNodeId()); params.put("transport_address", getTransportAddress()); params.put("is_leader", isLeader()); - params.put("hostname", getHostname()); + if (Objects.nonNull(getHostname())) { + params.put("hostname", getHostname()); + } return params; } diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeService.java b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeService.java index 8f45492a57b6..721f7c9d6296 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeService.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/nodes/NodeService.java @@ -48,4 +48,6 @@ public interface NodeService { * @param dto Dto of the node to be marked as alive */ void ping(NodeDto dto); + + void update(NodeDto dto); } diff --git a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeLifecycleTrigger.java b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeLifecycleTrigger.java index 04b345bb11d4..821b84314268 100644 --- a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeLifecycleTrigger.java +++ b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeLifecycleTrigger.java @@ -17,5 +17,5 @@ package org.graylog2.datanode; public enum DataNodeLifecycleTrigger { - REMOVE, RESET, STOP, START + REMOVE, RESET, STOP, START, REMOVED, STOPPED, STARTED, CLEAR } diff --git a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java index f5439dcda960..c6966c179d63 100644 --- a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java +++ b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java @@ -16,6 +16,9 @@ */ package org.graylog2.datanode; +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import jakarta.inject.Inject; import org.graylog2.cluster.NodeNotFoundException; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.DataNodeStatus; @@ -24,8 +27,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; - public class DataNodeServiceImpl implements DataNodeService { private static final Logger LOG = LoggerFactory.getLogger(DataNodeServiceImpl.class); @@ -34,25 +35,26 @@ public class DataNodeServiceImpl implements DataNodeService { private final NodeService nodeService; @Inject - public DataNodeServiceImpl(ClusterEventBus clusterEventBus, NodeService nodeService) { + public DataNodeServiceImpl(ClusterEventBus clusterEventBus, NodeService nodeService, EventBus eventBus) { this.clusterEventBus = clusterEventBus; this.nodeService = nodeService; + eventBus.register(this); } @Override public DataNodeDto removeNode(String nodeId) throws NodeNotFoundException { final DataNodeDto node = nodeService.byNodeId(nodeId); - if (nodeService.allActive().size() <= 1) { - throw new IllegalArgumentException("Cannot remove last data node in the cluster."); - } - if (nodeService.allActive().values().stream().anyMatch(n -> n.getDataNodeStatus() == DataNodeStatus.REMOVING)) { - throw new IllegalArgumentException("Only one data node can be removed at a time."); - } if (node.getDataNodeStatus() != DataNodeStatus.AVAILABLE) { throw new IllegalArgumentException("Only running data nodes can be removed from the cluster."); } - DataNodeLifecycleEvent e = DataNodeLifecycleEvent.create(node.getNodeId(), DataNodeLifecycleTrigger.REMOVE); - clusterEventBus.post(e); + if (nodeService.allActive().values().stream() + .filter(n -> n.getDataNodeStatus() == DataNodeStatus.AVAILABLE && n.getActionQueue() == null) + .count() <= 1) { + throw new IllegalArgumentException("Cannot remove last data node in the cluster."); + } + DataNodeLifecycleTrigger trigger = DataNodeLifecycleTrigger.REMOVE; + DataNodeStatus lockingStatus = DataNodeStatus.REMOVING; + addToQueue(node, trigger, lockingStatus); return node; } @@ -89,4 +91,39 @@ public DataNodeDto startNode(String nodeId) throws NodeNotFoundException { return node; } + private void addToQueue(DataNodeDto node, DataNodeLifecycleTrigger trigger, DataNodeStatus lockingStatus) { + nodeService.update(node.toBuilder() + .setActionQueue(trigger) + .build()); + if (!otherNodeHasStatus(node.getNodeId(), lockingStatus, trigger)) { // post event to bus if no other node is currently performing or waiting + DataNodeLifecycleEvent e = DataNodeLifecycleEvent.create(node.getNodeId(), trigger); + clusterEventBus.post(e); + } + } + + private boolean otherNodeHasStatus(String nodeId, DataNodeStatus status, DataNodeLifecycleTrigger trigger) { + return nodeService.allActive().values().stream() + .anyMatch(n -> + !n.getNodeId().equals(nodeId) && + (n.getDataNodeStatus() == status || n.getActionQueue() == trigger) + ); + } + + @Subscribe + @SuppressWarnings("unused") + public void handleDataNodeLifeCycleEvent(DataNodeLifecycleEvent event) { + switch (event.trigger()) { + case REMOVED -> handleNextNode(DataNodeLifecycleTrigger.REMOVE); + case STOPPED -> handleNextNode(DataNodeLifecycleTrigger.STOP); + } + } + + private void handleNextNode(DataNodeLifecycleTrigger trigger) { + nodeService.allActive().values().stream() + .filter(node -> node.getActionQueue() == trigger) + .findFirst().ifPresent(node -> { + clusterEventBus.post(DataNodeLifecycleEvent.create(node.getNodeId(), trigger)); + }); + } + } diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResource.java index 20acd1178faa..0437a069faaa 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResource.java @@ -16,31 +16,42 @@ */ package org.graylog2.rest.resources.datanodes; +import com.codahale.metrics.annotation.Timed; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.graylog.security.UserContext; import org.graylog.security.certutil.CertRenewalService; +import org.graylog2.audit.AuditEventSender; import org.graylog2.audit.jersey.AuditEvent; +import org.graylog2.audit.jersey.NoAuditEvent; import org.graylog2.cluster.NodeNotFoundException; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.NodeService; import org.graylog2.datanode.DataNodeService; +import org.graylog2.rest.bulk.AuditParams; +import org.graylog2.rest.bulk.BulkExecutor; +import org.graylog2.rest.bulk.SequentialBulkExecutor; +import org.graylog2.rest.bulk.model.BulkOperationRequest; +import org.graylog2.rest.bulk.model.BulkOperationResponse; import org.graylog2.shared.rest.resources.RestResource; import org.graylog2.shared.security.RestPermissions; -import jakarta.inject.Inject; - -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - import static org.graylog2.audit.AuditEventTypes.DATANODE_REMOVE; import static org.graylog2.audit.AuditEventTypes.DATANODE_RESET; import static org.graylog2.audit.AuditEventTypes.DATANODE_START; @@ -56,12 +67,20 @@ public class DataNodeManagementResource extends RestResource { private final DataNodeService dataNodeService; private final NodeService nodeService; private final CertRenewalService certRenewalService; + private final BulkExecutor bulkRemovalExecutor; + private final BulkExecutor bulkStopExecutor; + private final BulkExecutor bulkStartExecutor; @Inject - protected DataNodeManagementResource(DataNodeService dataNodeService, NodeService nodeService, CertRenewalService certRenewalService) { + protected DataNodeManagementResource(DataNodeService dataNodeService, + NodeService nodeService, + CertRenewalService certRenewalService, AuditEventSender auditEventSender, ObjectMapper objectMapper) { this.dataNodeService = dataNodeService; this.nodeService = nodeService; this.certRenewalService = certRenewalService; + bulkRemovalExecutor = new SequentialBulkExecutor<>(this::removeNode, auditEventSender, objectMapper); + bulkStopExecutor = new SequentialBulkExecutor<>(this::stopNode, auditEventSender, objectMapper); + bulkStartExecutor = new SequentialBulkExecutor<>(this::startNode, auditEventSender, objectMapper); } @GET @@ -80,7 +99,8 @@ public DataNodeDto getDataNode(@ApiParam(name = "nodeId", required = true) @Path @ApiOperation("Remove node from cluster") @AuditEvent(type = DATANODE_REMOVE) @RequiresPermissions(RestPermissions.DATANODE_REMOVE) - public DataNodeDto removeNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId) { + public DataNodeDto removeNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId, + @Context UserContext userContext) { try { return dataNodeService.removeNode(nodeId); } catch (NodeNotFoundException e) { @@ -88,6 +108,24 @@ public DataNodeDto removeNode(@ApiParam(name = "nodeId", required = true) @PathP } } + @POST + @Path("/bulk_remove") + @Consumes(MediaType.APPLICATION_JSON) + @Timed + @ApiOperation(value = "Remove multiple nodes from the cluster", response = BulkOperationResponse.class) + @NoAuditEvent("Audit events triggered manually") + public Response bulkRemove(@ApiParam(name = "Entities to remove", required = true) final BulkOperationRequest bulkOperationRequest, + @Context UserContext userContext) { + + final BulkOperationResponse response = bulkRemovalExecutor.executeBulkOperation(bulkOperationRequest, + userContext, + new AuditParams(DATANODE_REMOVE, "nodeId", DataNodeDto.class)); + + return Response.status(Response.Status.OK) + .entity(response) + .build(); + } + @POST @Path("{nodeId}/reset") @ApiOperation("Reset a removed node to rejoin the cluster") @@ -106,7 +144,8 @@ public DataNodeDto resetNode(@ApiParam(name = "nodeId", required = true) @PathPa @ApiOperation("Stop the OpenSearch process of a data node") @AuditEvent(type = DATANODE_STOP) @RequiresPermissions(RestPermissions.DATANODE_STOP) - public DataNodeDto stopNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId) { + public DataNodeDto stopNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId, + @Context UserContext userContext) { try { return dataNodeService.stopNode(nodeId); } catch (NodeNotFoundException e) { @@ -114,16 +153,55 @@ public DataNodeDto stopNode(@ApiParam(name = "nodeId", required = true) @PathPar } } + + @POST + @Path("/bulk_stop") + @Consumes(MediaType.APPLICATION_JSON) + @Timed + @ApiOperation(value = "Stop multiple nodes in the cluster", response = BulkOperationResponse.class) + @NoAuditEvent("Audit events triggered manually") + public Response bulkStop(@ApiParam(name = "Entities to stop", required = true) final BulkOperationRequest bulkOperationRequest, + @Context UserContext userContext) { + + final BulkOperationResponse response = bulkStopExecutor.executeBulkOperation(bulkOperationRequest, + userContext, + new AuditParams(DATANODE_STOP, "nodeId", DataNodeDto.class)); + + return Response.status(Response.Status.OK) + .entity(response) + .build(); + } + @POST @Path("{nodeId}/start") @ApiOperation("Start the OpenSearch process of a data node") @AuditEvent(type = DATANODE_START) @RequiresPermissions(RestPermissions.DATANODE_START) - public DataNodeDto startNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId) { + public DataNodeDto startNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId, + @Context UserContext userContext) { try { return dataNodeService.startNode(nodeId); } catch (NodeNotFoundException e) { throw new NotFoundException("Node " + nodeId + " not found"); } } + + @POST + @Path("/bulk_start") + @Consumes(MediaType.APPLICATION_JSON) + @Timed + @ApiOperation(value = "Start multiple nodes in the cluster", response = BulkOperationResponse.class) + @NoAuditEvent("Audit events triggered manually") + public Response bulkStart(@ApiParam(name = "Entities to start", required = true) final BulkOperationRequest bulkOperationRequest, + @Context UserContext userContext) { + + final BulkOperationResponse response = bulkStartExecutor.executeBulkOperation(bulkOperationRequest, + userContext, + new AuditParams(DATANODE_START, "nodeId", DataNodeDto.class)); + + return Response.status(Response.Status.OK) + .entity(response) + .build(); + } + } diff --git a/graylog2-server/src/test/java/org/graylog2/cluster/nodes/DataNodeEntityTest.java b/graylog2-server/src/test/java/org/graylog2/cluster/nodes/DataNodeEntityTest.java index 6e71cec6687f..143c6acc1cb5 100644 --- a/graylog2-server/src/test/java/org/graylog2/cluster/nodes/DataNodeEntityTest.java +++ b/graylog2-server/src/test/java/org/graylog2/cluster/nodes/DataNodeEntityTest.java @@ -58,7 +58,7 @@ void serialize() throws Exception { final JsonNode jsonNode = mapper.readTree(mapper.writeValueAsString(node)); - assertThat(jsonNode.size()).isEqualTo(11); + assertThat(jsonNode.size()).isEqualTo(12); assertThat(ZonedDateTime.parse(jsonNode.path("last_seen").asText())).isEqualTo(lastSeen); assertThat(jsonNode.path("node_id").asText()).isEqualTo(nodeId); diff --git a/graylog2-server/src/test/java/org/graylog2/cluster/nodes/TestDataNodeNodeClusterService.java b/graylog2-server/src/test/java/org/graylog2/cluster/nodes/TestDataNodeNodeClusterService.java index 6e9cf5c8c2fa..648c56b8e978 100644 --- a/graylog2-server/src/test/java/org/graylog2/cluster/nodes/TestDataNodeNodeClusterService.java +++ b/graylog2-server/src/test/java/org/graylog2/cluster/nodes/TestDataNodeNodeClusterService.java @@ -30,7 +30,7 @@ public class TestDataNodeNodeClusterService implements NodeService { - private final List nodes = new LinkedList<>(); + private List nodes = new LinkedList<>(); @Override @@ -73,6 +73,11 @@ public void ping(NodeDto dto) { throw new UnsupportedOperationException("Unsupported operation"); } + @Override + public void update(NodeDto dto) { + nodes = nodes.stream().map(node -> Objects.equals(node.getNodeId(), dto.getNodeId()) ? (DataNodeDto) dto : node).collect(Collectors.toList()); + } + @Override public void dropOutdated() { throw new UnsupportedOperationException("Unsupported operation"); diff --git a/graylog2-server/src/test/java/org/graylog2/datanode/DataNodeServiceImplTest.java b/graylog2-server/src/test/java/org/graylog2/datanode/DataNodeServiceImplTest.java index 0bfa39326015..c8843129c331 100644 --- a/graylog2-server/src/test/java/org/graylog2/datanode/DataNodeServiceImplTest.java +++ b/graylog2-server/src/test/java/org/graylog2/datanode/DataNodeServiceImplTest.java @@ -16,6 +16,7 @@ */ package org.graylog2.datanode; +import com.google.common.eventbus.EventBus; import org.graylog2.cluster.NodeNotFoundException; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.DataNodeStatus; @@ -30,6 +31,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -38,6 +41,8 @@ public class DataNodeServiceImplTest { @Mock private ClusterEventBus clusterEventBus; + @Mock + private EventBus eventBus; private NodeService nodeService; private DataNodeServiceImpl classUnderTest; @@ -45,7 +50,7 @@ public class DataNodeServiceImplTest { @Before public void setUp() { this.nodeService = new TestDataNodeNodeClusterService(); - this.classUnderTest = new DataNodeServiceImpl(clusterEventBus, nodeService); + this.classUnderTest = new DataNodeServiceImpl(clusterEventBus, nodeService, eventBus); } private DataNodeDto buildTestNode(String nodeId, DataNodeStatus status) { @@ -72,7 +77,7 @@ public void removeNodeFailsForLastNode() throws NodeNotFoundException { } @Test - public void removeNodeFailsWhenRemovingAnother() throws NodeNotFoundException { + public void removeNodeFailsWhenRemovingAllSequentially() throws NodeNotFoundException { final String testNodeId = "node"; nodeService.registerServer(buildTestNode(testNodeId, DataNodeStatus.AVAILABLE)); nodeService.registerServer(buildTestNode("othernode", DataNodeStatus.REMOVING)); @@ -80,10 +85,31 @@ public void removeNodeFailsWhenRemovingAnother() throws NodeNotFoundException { Exception e = assertThrows(IllegalArgumentException.class, () -> { classUnderTest.removeNode(testNodeId); }); - assertEquals("Only one data node can be removed at a time.", e.getMessage()); + assertEquals("Cannot remove last data node in the cluster.", e.getMessage()); verifyNoMoreInteractions(clusterEventBus); } + @Test + public void removeNodesPostsFirstToEventBus() throws NodeNotFoundException { + String node1 = "node1"; + String node2 = "node2"; + nodeService.registerServer(buildTestNode(node1, DataNodeStatus.AVAILABLE)); + nodeService.registerServer(buildTestNode(node2, DataNodeStatus.AVAILABLE)); + nodeService.registerServer(buildTestNode("node3", DataNodeStatus.AVAILABLE)); + + classUnderTest.removeNode(node1); + verify(clusterEventBus).post(DataNodeLifecycleEvent.create(node1, DataNodeLifecycleTrigger.REMOVE)); + + classUnderTest.removeNode(node2); + verifyNoMoreInteractions(clusterEventBus); + + long removeCount = nodeService.allActive().values().stream() + .filter(dto -> dto.getActionQueue() == DataNodeLifecycleTrigger.REMOVE) + .count(); + + assertEquals(removeCount, 2); + } + @Test public void removeNodePublishesClusterEvent() throws NodeNotFoundException { final String testNodeId = "node"; @@ -151,4 +177,22 @@ public void startNodePublishesClusterEvent() throws NodeNotFoundException { verify(clusterEventBus).post(DataNodeLifecycleEvent.create(testNodeId, DataNodeLifecycleTrigger.START)); } + @Test + public void removedLifecycleEventRemovesNextNode() { + DataNodeDto node1 = buildTestNode("node1", DataNodeStatus.REMOVING); + nodeService.registerServer(node1); + DataNodeDto node2 = buildTestNode("node2", DataNodeStatus.AVAILABLE); + nodeService.registerServer(node2); + DataNodeDto node3 = buildTestNode("node3", DataNodeStatus.AVAILABLE); + nodeService.registerServer(node3); + + nodeService.update(node2.toBuilder().setActionQueue(DataNodeLifecycleTrigger.REMOVE).build()); + nodeService.update(node3.toBuilder().setActionQueue(DataNodeLifecycleTrigger.REMOVE).build()); + + classUnderTest.handleDataNodeLifeCycleEvent(DataNodeLifecycleEvent.create("node1", DataNodeLifecycleTrigger.REMOVED)); + + verify(clusterEventBus, times(1)).post(any()); + + } + } diff --git a/graylog2-server/src/test/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResourceTest.java b/graylog2-server/src/test/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResourceTest.java index 552c9298bddb..c97127457b95 100644 --- a/graylog2-server/src/test/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResourceTest.java +++ b/graylog2-server/src/test/java/org/graylog2/rest/resources/datanodes/DataNodeManagementResourceTest.java @@ -16,19 +16,22 @@ */ package org.graylog2.rest.resources.datanodes; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.NotFoundException; +import org.graylog.security.UserContext; import org.graylog.security.certutil.CertRenewalService; +import org.graylog2.audit.AuditEventSender; import org.graylog2.cluster.NodeNotFoundException; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.NodeService; import org.graylog2.datanode.DataNodeService; +import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import jakarta.ws.rs.NotFoundException; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doThrow; @@ -46,16 +49,22 @@ public class DataNodeManagementResourceTest { private NodeService nodeService; @Mock private CertRenewalService certRenewalService; + @Mock + private UserContext userContext; + @Mock + AuditEventSender auditEventSender; + private final ObjectMapper objectMapper = new ObjectMapperProvider().get(); + @Before public void setUp() { - classUnderTest = new DataNodeManagementResource(dataNodeService, nodeService, certRenewalService); + classUnderTest = new DataNodeManagementResource(dataNodeService, nodeService, certRenewalService, auditEventSender, objectMapper); } @Test public void removeUnavailableNode_throwsNotFoundException() throws NodeNotFoundException { doThrow(NodeNotFoundException.class).when(dataNodeService).removeNode(NODEID); - Exception e = assertThrows(NotFoundException.class, () -> classUnderTest.removeNode(NODEID)); + Exception e = assertThrows(NotFoundException.class, () -> classUnderTest.removeNode(NODEID, userContext)); assertEquals("Node " + NODEID + " not found", e.getMessage()); } @@ -68,7 +77,7 @@ public void resetUnavailableNode_throwsNotFoundException() throws NodeNotFoundEx @Test public void verifyRemoveServiceCalled() throws NodeNotFoundException { - classUnderTest.removeNode(NODEID); + classUnderTest.removeNode(NODEID, userContext); verify(dataNodeService).removeNode(NODEID); } diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBulkActions.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBulkActions.tsx new file mode 100644 index 000000000000..7e513f8fd884 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBulkActions.tsx @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useState } from 'react'; + +import MenuItem from 'components/bootstrap/MenuItem'; +import BulkActionsDropdown from 'components/common/EntityDataTable/BulkActionsDropdown'; +import useSelectedEntities from 'components/common/EntityDataTable/hooks/useSelectedEntities'; +import ConfirmDialog from 'components/common/ConfirmDialog'; + +import { bulkRemoveDataNode, bulkStartDataNode, bulkStopDataNode } from '../hooks/useDataNodes'; + +const DataNodeBulkActions = () => { + const { selectedEntities, setSelectedEntities } = useSelectedEntities(); + const [showDialogType, setShowDialogType] = useState<'REMOVE'|'STOP'|null>(null); + + const CONFIRM_DIALOG = { + REMOVE: { + dialogTitle: 'Remove Data Nodes', + dialogBody: `Are you sure you want to remove the selected ${selectedEntities.length > 1 ? `${selectedEntities.length} Data Nodes` : 'Data Node'}?`, + handleConfirm: () => { + bulkRemoveDataNode(selectedEntities, setSelectedEntities); + setShowDialogType(null); + }, + }, + STOP: { + dialogTitle: 'Stop Data Nodes', + dialogBody: `Are you sure you want to stop the selected ${selectedEntities.length > 1 ? `${selectedEntities.length} Data Nodes` : 'Data Node'}?`, + handleConfirm: () => { + bulkStopDataNode(selectedEntities, setSelectedEntities); + setShowDialogType(null); + }, + }, + }; + + return ( + <> + + bulkStartDataNode(selectedEntities, setSelectedEntities)}>Start + setShowDialogType('STOP')}>Stop + setShowDialogType('REMOVE')}>Remove + + {showDialogType && ( + setShowDialogType(null)}> + {CONFIRM_DIALOG[showDialogType].dialogBody} + + )} + + ); +}; + +export default DataNodeBulkActions; diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx index 0ad8ab8d4511..4c954a589895 100644 --- a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx @@ -32,6 +32,7 @@ import useTableEventHandlers from 'hooks/useTableEventHandlers'; import { Link } from 'components/common/router'; import Routes from 'routing/Routes'; +import DataNodeBulkActions from './DataNodeBulkActions'; import DataNodeActions from './DataNodeActions'; import DataNodeStatusCell from './DataNodeStatusCell'; @@ -149,7 +150,7 @@ const DataNodeList = () => { onSortChange={onSortChange} onPageSizeChange={onPageSizeChange} pageSize={layoutConfig.pageSize} - bulkSelection={{}} + bulkSelection={{ actions: }} activeSort={layoutConfig.sort} rowActions={entityActions} actionsCellWidth={160} diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx index 54bad4bd0050..6ec0f0a288c5 100644 --- a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx @@ -34,11 +34,20 @@ const DataNodeStatusCell = ({ dataNode }: Props) => { const datanodeDisabled = dataNode.data_node_status !== 'AVAILABLE'; return ( - - {dataNode.data_node_status} - + <> + + {dataNode.data_node_status} +   + {dataNode.action_queue && ( + + queued for {dataNode.action_queue} + + )} + ); }; diff --git a/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts b/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts index 5657a6200501..88bfef7ac741 100644 --- a/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts +++ b/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts @@ -23,11 +23,71 @@ import UserNotification from 'util/UserNotification'; import fetch from 'logic/rest/FetchProvider'; import type { Attribute, PaginatedListJSON, SearchParams } from 'stores/PaginationTypes'; +export const bulkRemoveDataNode = async (entity_ids: string[], selectBackFailedEntities: (entity_ids: string[]) => void) => { + try { + const { failures, successfully_performed } = await fetch('POST', qualifyUrl('/datanode/bulk_remove'), { entity_ids }); + + if (failures?.length) { + selectBackFailedEntities(failures.map(({ entity_id }) => entity_id)); + } + + if (failures?.length === entity_ids.length) { + UserNotification.error(`Removing Data Node failed with status: ${JSON.stringify(failures)}`, 'Could not remove Data Nodes.'); + } + + if (successfully_performed) { + UserNotification.success(`${successfully_performed} Data Node${successfully_performed > 1 ? 's' : ''} removed successfully.`); + } + } catch (errorThrown) { + UserNotification.error(`Removing Data Node failed with status: ${errorThrown}`, 'Could not remove Data Nodes.'); + } +}; + +export const bulkStartDataNode = async (entity_ids: string[], selectBackFailedEntities: (entity_ids: string[]) => void) => { + try { + const { failures, successfully_performed } = await fetch('POST', qualifyUrl('/datanode/bulk_start'), { entity_ids }); + + if (failures?.length) { + selectBackFailedEntities(failures.map(({ entity_id }) => entity_id)); + } + + if (failures?.length === entity_ids.length) { + UserNotification.error(`Starting Data Node failed with status: ${JSON.stringify(failures)}`, 'Could not start Data Nodes.'); + } + + if (successfully_performed) { + UserNotification.success(`${successfully_performed} Data Node${successfully_performed > 1 ? 's' : ''} started successfully.`); + } + } catch (errorThrown) { + UserNotification.error(`Starting Data Node failed with status: ${errorThrown}`, 'Could not start Data Nodes.'); + } +}; + +export const bulkStopDataNode = async (entity_ids: string[], selectBackFailedEntities: (entity_ids: string[]) => void) => { + try { + const { failures, successfully_performed } = await fetch('POST', qualifyUrl('/datanode/bulk_stop'), { entity_ids }); + + if (failures?.length) { + selectBackFailedEntities(failures.map(({ entity_id }) => entity_id)); + } + + if (failures?.length === entity_ids.length) { + UserNotification.error(`Stopping Data Node failed with status: ${JSON.stringify(failures)}`, 'Could not stop Data Nodes.'); + } + + if (successfully_performed) { + UserNotification.success(`${successfully_performed} Data Node${successfully_performed > 1 ? 's' : ''} stopped successfully.`); + } + } catch (errorThrown) { + UserNotification.error(`Stopping Data Node failed with status: ${errorThrown}`, 'Could not stop Data Nodes.'); + } +}; + export const removeDataNode = async (datanodeId: string) => { try { await fetch('DELETE', qualifyUrl(`/datanode/${datanodeId}`)); - UserNotification.success(`Data Node "${datanodeId}" removed successfully`); + UserNotification.success(`Data Node "${datanodeId}" removed successfully.`); } catch (errorThrown) { UserNotification.error(`Removing Data Node failed with status: ${errorThrown}`, 'Could not remove the Data Node.'); } @@ -37,7 +97,7 @@ export const startDataNode = async (datanodeId: string) => { try { await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/start`)); - UserNotification.success(`Data Node "${datanodeId}" started successfully`); + UserNotification.success(`Data Node "${datanodeId}" started successfully.`); } catch (errorThrown) { UserNotification.error(`Starting Data Node failed with status: ${errorThrown}`, 'Could not start the Data Node.'); } @@ -47,7 +107,7 @@ export const stopDataNode = async (datanodeId: string) => { try { await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/stop`)); - UserNotification.success(`Data Node "${datanodeId}" stopped successfully`); + UserNotification.success(`Data Node "${datanodeId}" stopped successfully.`); } catch (errorThrown) { UserNotification.error(`Stopping Data Node failed with status: ${errorThrown}`, 'Could not stop the Data Node.'); } @@ -57,7 +117,7 @@ export const rejoinDataNode = async (datanodeId: string) => { try { await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/reset`)); - UserNotification.success(`Data Node "${datanodeId}" rejoined successfully`); + UserNotification.success(`Data Node "${datanodeId}" rejoined successfully.`); } catch (errorThrown) { UserNotification.error(`Rejoining Data Node failed with status: ${errorThrown}`, 'Could not rejoin the Data Node.'); } @@ -69,7 +129,7 @@ type Options = { export const renewDatanodeCertificate = (nodeId: string) => fetch('POST', qualifyUrl(`/certrenewal/${nodeId}`)) .then(() => { - UserNotification.success('Certificate renewed successfully'); + UserNotification.success('Certificate renewed successfully.'); }) .catch((error) => { UserNotification.error(`Certificate renewal failed with error: ${error}`); @@ -96,7 +156,7 @@ const useDataNodes = (params: SearchParams, { enabled }: Options = { enabled: tr { onError: (errorThrown) => { UserNotification.error(`Loading Data Nodes failed with status: ${errorThrown}`, - 'Could not load Data Nodes'); + 'Could not load Data Nodes.'); }, notifyOnChangeProps: ['data', 'error'], refetchInterval: 5000, diff --git a/graylog2-web-interface/src/preflight/types.ts b/graylog2-web-interface/src/preflight/types.ts index bec33b68ba71..20e968fcb816 100644 --- a/graylog2-web-interface/src/preflight/types.ts +++ b/graylog2-web-interface/src/preflight/types.ts @@ -30,6 +30,7 @@ export type DataNode = { type: string, status: DataNodeStatus, data_node_status?: string, + action_queue?: string, cert_valid_until: string | null, error_msg?: string, }