diff --git a/changelog/unreleased/issue-19139.toml b/changelog/unreleased/issue-19139.toml index 2ab14a19298b..fe3021ecf3cd 100644 --- a/changelog/unreleased/issue-19139.toml +++ b/changelog/unreleased/issue-19139.toml @@ -1,5 +1,5 @@ type="a" -message="Adds export into csv, json, yaml and xml actions for aggregation widget. Also we change placment of message widget export action" +message="Change placement of message widget export action" issues=["19139"] pulls=["19140"] diff --git a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeClusterIT.java b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeClusterIT.java index 9980d1349be3..830023fcc3c7 100644 --- a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeClusterIT.java +++ b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeClusterIT.java @@ -16,21 +16,18 @@ */ package org.graylog.datanode.integration; -import com.github.joschi.jadconfig.util.Duration; import com.github.rholder.retry.RetryException; -import jakarta.inject.Provider; import jakarta.validation.constraints.NotNull; import org.apache.commons.lang3.RandomStringUtils; import org.graylog.datanode.configuration.variants.KeystoreInformation; -import org.graylog.datanode.restoperations.DatanodeOpensearchWait; -import org.graylog.datanode.restoperations.DatanodeRestApiWait; -import org.graylog.datanode.restoperations.DatanodeStatusChangeOperation; -import org.graylog.datanode.restoperations.OpensearchTestIndexCreation; -import org.graylog.datanode.restoperations.RestOperationParameters; +import org.graylog.testing.restoperations.DatanodeOpensearchWait; +import org.graylog.testing.restoperations.DatanodeRestApiWait; +import org.graylog.testing.restoperations.DatanodeStatusChangeOperation; +import org.graylog.testing.restoperations.OpensearchTestIndexCreation; +import org.graylog.testing.restoperations.RestOperationParameters; import org.graylog.datanode.testinfra.DatanodeContainerizedBackend; import org.graylog.testing.containermatrix.MongodbServer; import org.graylog.testing.mongodb.MongoDBTestService; -import org.graylog2.security.IndexerJwtAuthTokenProvider; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -41,7 +38,6 @@ import org.testcontainers.containers.Network; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; diff --git a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeLifecycleIT.java b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeLifecycleIT.java index f3e64a6bb7e3..425662e1dab9 100644 --- a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeLifecycleIT.java +++ b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeLifecycleIT.java @@ -16,16 +16,13 @@ */ package org.graylog.datanode.integration; -import com.github.joschi.jadconfig.util.Duration; -import com.github.rholder.retry.RetryException; import org.apache.commons.lang3.RandomStringUtils; import org.graylog.datanode.configuration.variants.KeystoreInformation; -import org.graylog.datanode.restoperations.DatanodeRestApiWait; -import org.graylog.datanode.restoperations.DatanodeStatusChangeOperation; -import org.graylog.datanode.restoperations.RestOperationParameters; +import org.graylog.testing.restoperations.DatanodeRestApiWait; +import org.graylog.testing.restoperations.DatanodeStatusChangeOperation; +import org.graylog.testing.restoperations.RestOperationParameters; import org.graylog.datanode.testinfra.DatanodeContainerizedBackend; import org.graylog2.plugin.Tools; -import org.graylog2.security.IndexerJwtAuthTokenProvider; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,10 +34,8 @@ import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; -import java.util.concurrent.ExecutionException; import static org.graylog.datanode.testinfra.DatanodeContainerizedBackend.IMAGE_WORKING_DIR; -import static org.graylog.testing.completebackend.ContainerizedGraylogBackend.ROOT_PASSWORD_PLAINTEXT; public class DatanodeLifecycleIT { private static final Logger LOG = LoggerFactory.getLogger(DatanodeLifecycleIT.class); diff --git a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSearchableSnapshotsIT.java b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSearchableSnapshotsIT.java index abcd2560595a..30b2ea68b0f2 100644 --- a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSearchableSnapshotsIT.java +++ b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSearchableSnapshotsIT.java @@ -19,8 +19,8 @@ import com.github.rholder.retry.RetryException; import io.restassured.http.ContentType; import io.restassured.response.ValidatableResponse; -import org.graylog.datanode.restoperations.DatanodeOpensearchWait; -import org.graylog.datanode.restoperations.RestOperationParameters; +import org.graylog.testing.restoperations.DatanodeOpensearchWait; +import org.graylog.testing.restoperations.RestOperationParameters; import org.graylog.datanode.testinfra.DatanodeContainerizedBackend; import org.graylog.testing.completebackend.S3MinioContainer; import org.graylog.testing.containermatrix.MongodbServer; diff --git a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSecuritySetupIT.java b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSecuritySetupIT.java index 25ec6d4e9219..3d1e552fa6dc 100644 --- a/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSecuritySetupIT.java +++ b/data-node/src/test/java/org/graylog/datanode/integration/DatanodeSecuritySetupIT.java @@ -16,16 +16,14 @@ */ package org.graylog.datanode.integration; -import com.github.joschi.jadconfig.util.Duration; import com.github.rholder.retry.RetryException; import io.restassured.response.ValidatableResponse; import org.apache.commons.lang3.RandomStringUtils; import org.graylog.datanode.configuration.variants.KeystoreInformation; -import org.graylog.datanode.restoperations.DatanodeRestApiWait; -import org.graylog.datanode.restoperations.RestOperationParameters; +import org.graylog.testing.restoperations.DatanodeRestApiWait; +import org.graylog.testing.restoperations.RestOperationParameters; import org.graylog.datanode.testinfra.DatanodeContainerizedBackend; import org.graylog2.plugin.Tools; -import org.graylog2.security.IndexerJwtAuthTokenProvider; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -35,7 +33,6 @@ import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; diff --git a/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java b/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java index 140c9d3cf85a..d785ae2db231 100644 --- a/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java +++ b/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java @@ -17,6 +17,7 @@ package org.graylog.datanode; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.joschi.jadconfig.util.Duration; import com.github.rholder.retry.RetryException; import com.github.rholder.retry.RetryerBuilder; import com.github.rholder.retry.StopStrategies; @@ -29,20 +30,20 @@ import org.apache.commons.lang.RandomStringUtils; import org.apache.http.HttpStatus; import org.assertj.core.api.Assertions; -import org.graylog.datanode.restoperations.DatanodeOpensearchWait; -import org.graylog.datanode.restoperations.RestOperationParameters; -import org.graylog.datanode.testinfra.DatanodeContainerizedBackend; -import org.graylog.datanode.testinfra.DatanodeDevContainerBuilder; import org.graylog.security.certutil.CertConstants; import org.graylog.security.certutil.CertutilCa; import org.graylog.security.certutil.console.TestableConsole; +import org.graylog.testing.completebackend.ContainerizedGraylogBackend; import org.graylog.testing.completebackend.Lifecycle; import org.graylog.testing.completebackend.apis.GraylogApis; import org.graylog.testing.containermatrix.SearchServer; import org.graylog.testing.containermatrix.annotations.ContainerMatrixTest; import org.graylog.testing.containermatrix.annotations.ContainerMatrixTestsConfiguration; +import org.graylog.testing.restoperations.DatanodeOpensearchWait; +import org.graylog.testing.restoperations.RestOperationParameters; import org.graylog2.cluster.nodes.DataNodeStatus; import org.graylog2.cluster.preflight.DataNodeProvisioningConfig; +import org.graylog2.security.IndexerJwtAuthTokenProvider; import org.junit.jupiter.api.io.TempDir; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,7 +69,7 @@ @ContainerMatrixTestsConfiguration(serverLifecycle = Lifecycle.CLASS, searchVersions = SearchServer.DATANODE_DEV, additionalConfigurationParameters = { - @ContainerMatrixTestsConfiguration.ConfigurationParameter(key = DatanodeDevContainerBuilder.ENV_INSECURE_STARTUP, value = "false"), + @ContainerMatrixTestsConfiguration.ConfigurationParameter(key = "GRAYLOG_DATANODE_INSECURE_STARTUP", value = "false"), @ContainerMatrixTestsConfiguration.ConfigurationParameter(key = "GRAYLOG_ELASTICSEARCH_HOSTS", value = ""), }) public class DatanodeProvisioningIT { @@ -114,7 +115,7 @@ private void testEncryptedConnectionToOpensearch(KeyStore truststore) throws Exe new DatanodeOpensearchWait(RestOperationParameters.builder() .port(getOpensearchPort()) .truststore(truststore) - .jwtTokenProvider(DatanodeContainerizedBackend.JWT_AUTH_TOKEN_PROVIDER) + .jwtTokenProvider(new IndexerJwtAuthTokenProvider(ContainerizedGraylogBackend.PASSWORD_SECRET, Duration.seconds(120), Duration.seconds(60))) .build()) .waitForNodesCount(1); } catch (Exception e) { diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CAResource.java b/graylog2-server/src/main/java/org/graylog/security/rest/CAResource.java similarity index 98% rename from graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CAResource.java rename to graylog2-server/src/main/java/org/graylog/security/rest/CAResource.java index 1b80aed2e23f..b3516cc145f4 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CAResource.java +++ b/graylog2-server/src/main/java/org/graylog/security/rest/CAResource.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog2.bootstrap.preflight.web.resources; +package org.graylog.security.rest; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -77,6 +77,7 @@ public CAResource(final CaService caService, @GET @ApiOperation("Returns the CA") + @RequiresPermissions(RestPermissions.GRAYLOG_CA_READ) public CA get() throws KeyStoreStorageException { return caService.get(); } diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalResource.java b/graylog2-server/src/main/java/org/graylog/security/rest/CertificateRenewalResource.java similarity index 97% rename from graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalResource.java rename to graylog2-server/src/main/java/org/graylog/security/rest/CertificateRenewalResource.java index fe03e29c0c06..f7828f72f38d 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalResource.java +++ b/graylog2-server/src/main/java/org/graylog/security/rest/CertificateRenewalResource.java @@ -14,26 +14,24 @@ * along with this program. If not, see * . */ -package org.graylog2.bootstrap.preflight.web.resources; +package org.graylog.security.rest; import io.swagger.annotations.Api; import io.swagger.annotations.ApiParam; -import org.apache.shiro.authz.annotation.RequiresAuthentication; -import org.apache.shiro.authz.annotation.RequiresPermissions; -import org.graylog.security.certutil.CertRenewalService; -import org.graylog2.audit.AuditEventTypes; -import org.graylog2.audit.jersey.AuditEvent; -import org.graylog2.plugin.rest.PluginRestResource; -import org.graylog2.shared.security.RestPermissions; - import jakarta.inject.Inject; - import jakarta.ws.rs.GET; 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 org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.graylog.security.certutil.CertRenewalService; +import org.graylog2.audit.AuditEventTypes; +import org.graylog2.audit.jersey.AuditEvent; +import org.graylog2.plugin.rest.PluginRestResource; +import org.graylog2.shared.security.RestPermissions; import java.util.List; diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/PreflightWebModule.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/PreflightWebModule.java index b01705ea41e6..fd61edfa592a 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/PreflightWebModule.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/PreflightWebModule.java @@ -59,6 +59,9 @@ public class PreflightWebModule extends Graylog2Module { public static final String FEATURE_FLAG_PREFLIGHT_WEB_ENABLED = "preflight_web"; + public static final String PERMISSION_PREFLIGHT_ONLY = "preflight:only"; + // this permission is never checked during preflight, but makes sure that the rest resources are not accidentally + // bound during regular startup of Graylog and available without permissions. private final Configuration configuration; diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalPolicyResource.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalPolicyResource.java index 43e4ca78db50..00f7b79ad323 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalPolicyResource.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/CertificateRenewalPolicyResource.java @@ -16,20 +16,19 @@ */ package org.graylog2.bootstrap.preflight.web.resources; -import org.graylog2.audit.jersey.NoAuditEvent; -import org.graylog2.bootstrap.preflight.PreflightConstants; -import org.graylog2.plugin.certificates.RenewalPolicy; -import org.graylog2.plugin.cluster.ClusterConfigService; - import jakarta.inject.Inject; - import jakarta.validation.constraints.NotNull; - import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.graylog2.audit.jersey.NoAuditEvent; +import org.graylog2.bootstrap.preflight.PreflightConstants; +import org.graylog2.bootstrap.preflight.PreflightWebModule; +import org.graylog2.plugin.certificates.RenewalPolicy; +import org.graylog2.plugin.cluster.ClusterConfigService; @Path(PreflightConstants.API_PREFIX + "renewal_policy") @Produces(MediaType.APPLICATION_JSON) @@ -42,12 +41,14 @@ public CertificateRenewalPolicyResource(final ClusterConfigService clusterConfig } @GET + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public RenewalPolicy get() { return this.clusterConfigService.get(RenewalPolicy.class); } @POST - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public void set(@NotNull RenewalPolicy renewalPolicy) { this.clusterConfigService.write(renewalPolicy); } diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/DataNodeProvisioningResource.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/DataNodeProvisioningResource.java deleted file mode 100644 index 49332cd6aaac..000000000000 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/DataNodeProvisioningResource.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.graylog2.bootstrap.preflight.web.resources; - - -import io.swagger.annotations.Api; -import jakarta.inject.Inject; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import org.apache.shiro.authz.annotation.RequiresAuthentication; -import org.graylog2.audit.jersey.NoAuditEvent; -import org.graylog2.cluster.nodes.DataNodeDto; -import org.graylog2.cluster.nodes.NodeService; -import org.graylog2.cluster.preflight.DataNodeProvisioningConfig; -import org.graylog2.cluster.preflight.DataNodeProvisioningService; - -import java.util.Map; - -@Api(value = "Certificate Provisioning for data node") -@Path("/datanode/provision") -@Produces(MediaType.APPLICATION_JSON) -@RequiresAuthentication -public class DataNodeProvisioningResource { - - private final NodeService nodeService; - private final DataNodeProvisioningService dataNodeProvisioningService; - - @Inject - public DataNodeProvisioningResource(NodeService nodeService, DataNodeProvisioningService dataNodeProvisioningService) { - this.nodeService = nodeService; - this.dataNodeProvisioningService = dataNodeProvisioningService; - } - - @POST - @Path("/generate") - @NoAuditEvent("No Audit Event needed") - public void generate() { - final Map activeDataNodes = nodeService.allActive(); - activeDataNodes.values().forEach(node -> dataNodeProvisioningService.changeState(node.getNodeId(), DataNodeProvisioningConfig.State.CONFIGURED)); - } - - -} diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightAssetsResource.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightAssetsResource.java index e90fdc64272a..ff6defefb112 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightAssetsResource.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightAssetsResource.java @@ -22,6 +22,7 @@ import com.google.common.hash.HashCode; import com.google.common.hash.Hashing; import com.google.common.io.Resources; +import org.apache.shiro.authz.annotation.RequiresPermissions; import org.graylog2.bootstrap.preflight.PreflightConstants; import javax.activation.MimetypesFileTypeMap; @@ -40,6 +41,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; +import org.graylog2.bootstrap.preflight.PreflightWebModule; import java.io.FileNotFoundException; import java.io.IOException; @@ -87,12 +89,14 @@ public FileSystem load(@Nonnull URI key) throws Exception { @Produces(MediaType.TEXT_HTML) @GET + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public Response index(@Context Request request) { return this.get(request, "index.html"); } @Path("/{filename}") @GET + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public Response get(@Context Request request, @PathParam("filename") String filename) { try { final URL resourceUrl = getResourceUri(filename); diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightResource.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightResource.java index cc652cb7351d..d12a8de34fe6 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightResource.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightResource.java @@ -29,6 +29,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.apache.shiro.authz.annotation.RequiresPermissions; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.glassfish.jersey.media.multipart.FormDataBodyPart; import org.glassfish.jersey.media.multipart.FormDataParam; @@ -38,6 +39,7 @@ import org.graylog.security.certutil.ca.exceptions.KeyStoreStorageException; import org.graylog2.audit.jersey.NoAuditEvent; import org.graylog2.bootstrap.preflight.PreflightConstants; +import org.graylog2.bootstrap.preflight.PreflightWebModule; import org.graylog2.bootstrap.preflight.web.resources.model.CA; import org.graylog2.bootstrap.preflight.web.resources.model.CertParameters; import org.graylog2.bootstrap.preflight.web.resources.model.CreateCARequest; @@ -89,6 +91,7 @@ record DataNode(String nodeId, String transportAddress, DataNodeProvisioningConf @GET @Path("/data_nodes") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public List listDataNodes() { final Map activeDataNodes = nodeService.allActive(); final var preflightDataNodes = dataNodeProvisioningService.streamAll().collect(Collectors.toMap(DataNodeProvisioningConfig::nodeId, Function.identity())); @@ -106,6 +109,7 @@ public List listDataNodes() { @GET @Path("/ca") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public CA get() throws KeyStoreStorageException { return caService.get(); } @@ -113,6 +117,7 @@ public CA get() throws KeyStoreStorageException { @GET @Path("/ca/certificate") @Produces(MediaType.TEXT_PLAIN) + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public String getCaCertificate() { try { return caService.loadKeyStore().map(ks -> { @@ -139,7 +144,8 @@ private static String encode(final Object o) throws IOException { @POST @Path("/ca/create") - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public Response createCA(@NotNull @Valid CreateCARequest request) throws CACreationException, KeyStoreStorageException, KeyStoreException, NoSuchAlgorithmException { // TODO: get validity from preflight UI final CA ca = caService.create(request.organization(), CaService.DEFAULT_VALIDITY, passwordSecret.toCharArray()); @@ -149,7 +155,8 @@ public Response createCA(@NotNull @Valid CreateCARequest request) throws CACreat @POST @Consumes(MediaType.MULTIPART_FORM_DATA) @Path("/ca/upload") - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public Response uploadCA(@FormDataParam("password") String password, @FormDataParam("files") List bodyParts) { try { caService.upload(password, bodyParts); @@ -161,7 +168,8 @@ public Response uploadCA(@FormDataParam("password") String password, @FormDataPa @DELETE @Path("/startOver") - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public void startOver() { caService.startOver(); clusterConfigService.remove(RenewalPolicy.class); @@ -170,7 +178,8 @@ public void startOver() { @DELETE @Path("/startOver/{nodeID}") - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public void startOver(@PathParam("nodeID") String nodeID) { //TODO: reset a specific datanode dataNodeProvisioningService.delete(nodeID); @@ -178,7 +187,8 @@ public void startOver(@PathParam("nodeID") String nodeID) { @POST @Path("/generate") - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public void generate() { final Map activeDataNodes = nodeService.allActive(); activeDataNodes.values().forEach(node -> dataNodeProvisioningService.changeState(node.getNodeId(), DataNodeProvisioningConfig.State.CONFIGURED)); @@ -187,7 +197,8 @@ public void generate() { @POST @Path("/{nodeID}") @Consumes(MediaType.APPLICATION_JSON) - @NoAuditEvent("No Audit Event needed") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public void addParameters(@PathParam("nodeID") String nodeID, @NotNull CertParameters params) { var cfg = dataNodeProvisioningService.getPreflightConfigFor(nodeID); diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightStatusResource.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightStatusResource.java index 9b22b884b235..9ace7a41cc26 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightStatusResource.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/web/resources/PreflightStatusResource.java @@ -16,22 +16,22 @@ */ package org.graylog2.bootstrap.preflight.web.resources; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.apache.shiro.authz.annotation.RequiresPermissions; import org.graylog2.audit.jersey.NoAuditEvent; import org.graylog2.bootstrap.preflight.ConfigurationStatus; import org.graylog2.bootstrap.preflight.PreflightConfig; import org.graylog2.bootstrap.preflight.PreflightConfigResult; import org.graylog2.bootstrap.preflight.PreflightConfigService; import org.graylog2.bootstrap.preflight.PreflightConstants; +import org.graylog2.bootstrap.preflight.PreflightWebModule; import org.graylog2.plugin.Version; -import jakarta.inject.Inject; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - @Path(PreflightConstants.API_PREFIX + "status") @Produces(MediaType.APPLICATION_JSON) @@ -46,20 +46,23 @@ public PreflightStatusResource(PreflightConfigService preflightConfigService) { } @GET + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) public ConfigurationStatus status() { return new ConfigurationStatus(version.toString()); } - @NoAuditEvent("No audit event yet") @POST @Path("/finish-config") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public PreflightConfig finishConfig() { return preflightConfigService.setConfigResult(PreflightConfigResult.FINISHED); } - @NoAuditEvent("No audit event yet") @POST @Path("/skip-config") + @RequiresPermissions(PreflightWebModule.PERMISSION_PREFLIGHT_ONLY) + @NoAuditEvent("No Auditing during preflight") public PreflightConfig skipConfig() { return preflightConfigService.setConfigResult(PreflightConfigResult.SKIPPED); } diff --git a/graylog2-server/src/main/java/org/graylog2/migrations/V20231107164300_CreateDataNodeManagerRole.java b/graylog2-server/src/main/java/org/graylog2/migrations/V20231107164300_CreateDataNodeManagerRole.java index 827e4efc3133..b3fc42ee8ffe 100644 --- a/graylog2-server/src/main/java/org/graylog2/migrations/V20231107164300_CreateDataNodeManagerRole.java +++ b/graylog2-server/src/main/java/org/graylog2/migrations/V20231107164300_CreateDataNodeManagerRole.java @@ -16,9 +16,8 @@ */ package org.graylog2.migrations; -import org.graylog2.shared.security.RestPermissions; - import jakarta.inject.Inject; +import org.graylog2.shared.security.RestPermissions; import java.time.ZonedDateTime; import java.util.Set; @@ -39,6 +38,6 @@ public ZonedDateTime createdAt() { @Override public void upgrade() { helpers.ensureBuiltinRole("Data Node Manager", "Grants control to manage the data node cluster (built-in)", - Set.of(RestPermissions.DATANODE_REMOVE, RestPermissions.DATANODE_RESET, RestPermissions.DATANODE_STOP, RestPermissions.DATANODE_START)); + Set.of(RestPermissions.DATANODE_READ, RestPermissions.DATANODE_REMOVE, RestPermissions.DATANODE_RESET, RestPermissions.DATANODE_STOP, RestPermissions.DATANODE_START)); } } diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java index fac7ecb5467a..31635f6b1002 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/RestResourcesModule.java @@ -18,10 +18,9 @@ import org.graylog.plugins.views.search.engine.monitoring.data.histogram.rest.HistogramResponseWriter; import org.graylog.plugins.views.storage.migration.RemoteReindexResource; +import org.graylog.security.rest.CAResource; +import org.graylog.security.rest.CertificateRenewalResource; import org.graylog2.Configuration; -import org.graylog2.bootstrap.preflight.web.resources.CAResource; -import org.graylog2.bootstrap.preflight.web.resources.CertificateRenewalResource; -import org.graylog2.bootstrap.preflight.web.resources.DataNodeProvisioningResource; import org.graylog2.contentstream.rest.ContentStreamResource; import org.graylog2.plugin.inject.Graylog2Module; import org.graylog2.rest.resources.cluster.ClusterDeflectorResource; @@ -163,7 +162,6 @@ protected void configure() { addSystemRestResource(DataNodeManagementResource.class); addSystemRestResource(RemoteReindexResource.class); addSystemRestResource(CAResource.class); - addSystemRestResource(DataNodeProvisioningResource.class); } private void addDebugResources() { 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 ef380d6ce5d0..f8b9f73ad9f8 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 @@ -92,6 +92,7 @@ protected DataNodeManagementResource(DataNodeService dataNodeService, @GET @Path("configured") @ApiOperation("Returns whether this Graylog is running against a data node search backend") + @RequiresPermissions(RestPermissions.DATANODE_READ) public Boolean runsWithDataNode() { return runsWithDataNode; } @@ -99,6 +100,7 @@ public Boolean runsWithDataNode() { @GET @Path("{nodeId}") @ApiOperation("Get data node information") + @RequiresPermissions(RestPermissions.DATANODE_READ) public DataNodeDto getDataNode(@ApiParam(name = "nodeId", required = true) @PathParam("nodeId") String nodeId) { try { return certRenewalService.addProvisioningInformation(nodeService.byNodeId(nodeId)); diff --git a/graylog2-server/src/main/java/org/graylog2/shared/security/CertificateRenewalBindings.java b/graylog2-server/src/main/java/org/graylog2/shared/security/CertificateRenewalBindings.java index 85b21ad4d074..c358549c58ee 100644 --- a/graylog2-server/src/main/java/org/graylog2/shared/security/CertificateRenewalBindings.java +++ b/graylog2-server/src/main/java/org/graylog2/shared/security/CertificateRenewalBindings.java @@ -22,7 +22,7 @@ import org.graylog.security.certutil.CertRenewalService; import org.graylog.security.certutil.CertRenewalServiceImpl; import org.graylog.security.certutil.CheckForCertRenewalJob; -import org.graylog2.bootstrap.preflight.web.resources.CertificateRenewalResource; +import org.graylog.security.rest.CertificateRenewalResource; import org.graylog2.plugin.PluginModule; public class CertificateRenewalBindings extends PluginModule { diff --git a/graylog2-server/src/main/java/org/graylog2/shared/security/RestPermissions.java b/graylog2-server/src/main/java/org/graylog2/shared/security/RestPermissions.java index 74c3169948f3..235da954e342 100644 --- a/graylog2-server/src/main/java/org/graylog2/shared/security/RestPermissions.java +++ b/graylog2-server/src/main/java/org/graylog2/shared/security/RestPermissions.java @@ -41,6 +41,8 @@ public class RestPermissions implements PluginPermissions { public static final String AUTH_SERVICE_GLOBAL_CONFIG_EDIT = "authserviceglobalconfig:edit"; public static final String AUTH_SERVICE_TEST_BACKEND_EXECUTE = "authservicetestbackend:execute"; public static final String BUFFERS_READ = "buffers:read"; + public static final String GRAYLOG_CA_CLIENTCERT_CREATE = "ca_clientcert:create"; + public static final String GRAYLOG_CA_CLIENTCERT_DELETE = "ca_clientcert:delete"; public static final String CATALOG_LIST = "catalog:list"; public static final String CATALOG_RESOLVE = "catalog:resolve"; public static final String CLUSTER_CONFIG_ENTRY_CREATE = "clusterconfigentry:create"; @@ -52,6 +54,14 @@ public class RestPermissions implements PluginPermissions { public static final String CONTENT_PACK_READ = "contentpack:read"; public static final String CONTENT_PACK_INSTALL = "contentpack:install"; public static final String CONTENT_PACK_UNINSTALL = "contentpack:uninstall"; + public static final String DATANODE_OPENSEARCH_PROXY = "datanode:opensearchproxy"; + public static final String DATANODE_READ = "datanode:read"; + public static final String DATANODE_REMOVE = "datanode:remove"; + public static final String DATANODE_RESET = "datanode:reset"; + public static final String DATANODE_REST_PROXY = "datanode:restproxy"; + public static final String DATANODE_STOP = "datanode:stop"; + public static final String DATANODE_START = "datanode:start"; + public static final String DATANODE_MIGRATION = "datanode:migration"; public static final String DASHBOARDS_CREATE = "dashboards:create"; public static final String DASHBOARDS_EDIT = "dashboards:edit"; public static final String DASHBOARDS_READ = "dashboards:read"; @@ -72,6 +82,8 @@ public class RestPermissions implements PluginPermissions { public static final String EVENT_NOTIFICATIONS_READ = "eventnotifications:read"; public static final String FIELDNAMES_READ = "fieldnames:read"; public static final String GRANTS_OVERVIEW_READ = "grantsoverview:read"; + public static final String GRAYLOG_CA_CREATE = "graylog_ca:create"; + public static final String GRAYLOG_CA_READ = "graylog_ca:read"; public static final String INDEXERCLUSTER_READ = "indexercluster:read"; public static final String INDEXRANGES_READ = "indexranges:read"; public static final String INDEXRANGES_REBUILD = "indexranges:rebuild"; @@ -146,7 +158,6 @@ public class RestPermissions implements PluginPermissions { public static final String THREADS_DUMP = "threads:dump"; public static final String PROCESSBUFFER_DUMP = "processbuffer:dump"; public static final String THROUGHPUT_READ = "throughput:read"; - public static final String TYPE_MAPPINGS_CREATE = "typemappings:create"; public static final String TYPE_MAPPINGS_DELETE = "typemappings:delete"; public static final String TYPE_MAPPINGS_EDIT = "typemappings:edit"; @@ -168,19 +179,6 @@ public class RestPermissions implements PluginPermissions { public static final String USERS_TOKENLIST = "users:tokenlist"; public static final String USERS_TOKENREMOVE = "users:tokenremove"; - public static final String DATANODE_REMOVE = "datanode:remove"; - public static final String DATANODE_RESET = "datanode:reset"; - public static final String DATANODE_STOP = "datanode:stop"; - public static final String DATANODE_START = "datanode:start"; - public static final String DATANODE_MIGRATION = "datanode:migration"; - - public static final String DATANODE_OPENSEARCH_PROXY = "datanode:opensearchproxy"; - public static final String DATANODE_REST_PROXY = "datanode:restproxy"; - - public static final String GRAYLOG_CA_CREATE = "graylog_ca:create"; - public static final String GRAYLOG_CA_CLIENTCERT_CREATE = "ca_clientcert:create"; - public static final String GRAYLOG_CA_CLIENTCERT_DELETE = "ca_clientcert:delete"; - // This is a special permission that ONLY works with GRNs as ID/target // TODO does this belong here? public static final String ENTITY_OWN = "entity:own"; @@ -208,6 +206,7 @@ public class RestPermissions implements PluginPermissions { .add(create(DASHBOARDS_CREATE, "")) .add(create(DASHBOARDS_EDIT, "")) .add(create(DASHBOARDS_READ, "")) + .add(create(DATANODE_READ, "")) .add(create(DATANODE_REMOVE, "")) .add(create(DATANODE_RESET, "")) .add(create(DATANODE_STOP, "")) @@ -216,6 +215,7 @@ public class RestPermissions implements PluginPermissions { .add(create(DATANODE_OPENSEARCH_PROXY, "")) .add(create(DATANODE_REST_PROXY, "")) .add(create(GRAYLOG_CA_CREATE, "")) + .add(create(GRAYLOG_CA_READ, "")) .add(create(GRAYLOG_CA_CLIENTCERT_CREATE, "")) .add(create(GRAYLOG_CA_CLIENTCERT_DELETE, "")) .add(create(DECORATORS_CREATE, "")) diff --git a/graylog2-server/src/main/resources/prometheus-exporter.yml b/graylog2-server/src/main/resources/prometheus-exporter.yml index f9bf83ba5259..1d5be0b9e4c7 100644 --- a/graylog2-server/src/main/resources/prometheus-exporter.yml +++ b/graylog2-server/src/main/resources/prometheus-exporter.yml @@ -1264,3 +1264,33 @@ metric_mappings: match_pattern: "org.graylog2.shared.bindings.providers.ProxiedRequestsExecutorService.http-proxied-requests-executor.*" wildcard_extract_labels: - "type" + + - metric_name: "data_warehouse_s3_client_head_object" + match_pattern: "org.graylog.plugins.datawarehouse.s3client.*.HeadObject" + wildcard_extract_labels: + - "head_object" + + - metric_name: "data_warehouse_s3_client_get_object" + match_pattern: "org.graylog.plugins.datawarehouse.s3client.*.GetObject" + wildcard_extract_labels: + - "get_object" + + - metric_name: "data_warehouse_s3_client_put_object" + match_pattern: "org.graylog.plugins.datawarehouse.s3client.*.PutObject" + wildcard_extract_labels: + - "put_object" + + - metric_name: "data_warehouse_s3_client_delete_object" + match_pattern: "org.graylog.plugins.datawarehouse.s3client.*.DeleteObject" + wildcard_extract_labels: + - "delete_object" + + - metric_name: "data_warehouse_iceberg_file_io_read" + match_pattern: "org.graylog.iceberg.read.*" + wildcard_extract_labels: + - "read" + + - metric_name: "data_warehouse_iceberg_file_io_write" + match_pattern: "org.graylog.iceberg.write.*" + wildcard_extract_labels: + - "write" diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeOpensearchWait.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeOpensearchWait.java similarity index 97% rename from data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeOpensearchWait.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeOpensearchWait.java index 90156cc501e1..938351b13898 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeOpensearchWait.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeOpensearchWait.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.github.rholder.retry.RetryException; import io.restassured.response.ValidatableResponse; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeRestApiWait.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeRestApiWait.java similarity index 97% rename from data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeRestApiWait.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeRestApiWait.java index 723d61dc71bc..6132482adc5d 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeRestApiWait.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeRestApiWait.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.github.rholder.retry.RetryException; import io.restassured.response.ValidatableResponse; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeStatusChangeOperation.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeStatusChangeOperation.java similarity index 97% rename from data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeStatusChangeOperation.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeStatusChangeOperation.java index 3fc20f0e4538..852f16365994 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/DatanodeStatusChangeOperation.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/DatanodeStatusChangeOperation.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import jakarta.ws.rs.HttpMethod; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreation.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreation.java similarity index 98% rename from data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreation.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreation.java index 4ec5cf306fe4..80183eb6a7f6 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreation.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreation.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.github.rholder.retry.RetryException; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreationWait.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreationWait.java similarity index 97% rename from data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreationWait.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreationWait.java index 865778fb58b9..ae4de5093433 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/OpensearchTestIndexCreationWait.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/OpensearchTestIndexCreationWait.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.github.rholder.retry.RetryException; import io.restassured.response.ValidatableResponse; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/RestOperation.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperation.java similarity index 98% rename from data-node/src/test/java/org/graylog/datanode/restoperations/RestOperation.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperation.java index 585d18246794..b7724a6cb5a3 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/RestOperation.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperation.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import io.restassured.RestAssured; import io.restassured.http.ContentType; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/RestOperationParameters.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperationParameters.java similarity index 97% rename from data-node/src/test/java/org/graylog/datanode/restoperations/RestOperationParameters.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperationParameters.java index d19abaa28bbd..c94829848f02 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/RestOperationParameters.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/RestOperationParameters.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.google.auto.value.AutoValue; import io.restassured.specification.RequestSpecification; diff --git a/data-node/src/test/java/org/graylog/datanode/restoperations/WaitingRestOperation.java b/graylog2-server/src/test/java/org/graylog/testing/restoperations/WaitingRestOperation.java similarity index 99% rename from data-node/src/test/java/org/graylog/datanode/restoperations/WaitingRestOperation.java rename to graylog2-server/src/test/java/org/graylog/testing/restoperations/WaitingRestOperation.java index fbdf35a879c9..03674efdca4b 100644 --- a/data-node/src/test/java/org/graylog/datanode/restoperations/WaitingRestOperation.java +++ b/graylog2-server/src/test/java/org/graylog/testing/restoperations/WaitingRestOperation.java @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -package org.graylog.datanode.restoperations; +package org.graylog.testing.restoperations; import com.github.rholder.retry.Attempt; import com.github.rholder.retry.RetryException; diff --git a/graylog2-web-interface/package.json b/graylog2-web-interface/package.json index fd5ef8ff26d6..afb254a9170c 100644 --- a/graylog2-web-interface/package.json +++ b/graylog2-web-interface/package.json @@ -51,7 +51,7 @@ "@openfonts/source-sans-pro_latin": "^1.44.2", "@openfonts/ubuntu-mono_latin": "^1.44.1", "@react-bootstrap/pagination": "^1.0.0", - "@react-hook/resize-observer": "^1.2.5", + "@react-hook/resize-observer": "^2.0.1", "@reduxjs/toolkit": "^2.2.0", "@tanstack/query-sync-storage-persister": "^4.33.0", "@tanstack/react-query-persist-client": "^4.33.0", diff --git a/graylog2-web-interface/packages/graylog-web-plugin/package.json b/graylog2-web-interface/packages/graylog-web-plugin/package.json index afad8de4ef21..00907a927e7c 100644 --- a/graylog2-web-interface/packages/graylog-web-plugin/package.json +++ b/graylog2-web-interface/packages/graylog-web-plugin/package.json @@ -29,7 +29,7 @@ "extends": "graylog" }, "dependencies": { - "@graylog/sawmill": "2.0.16", + "@graylog/sawmill": "2.0.17", "@tanstack/react-query": "4.36.1", "@types/create-react-class": "15.6.8", "@types/jquery": "3.5.30", diff --git a/graylog2-web-interface/packages/jest-preset-graylog/lib/setup-files/mock-IntersectionObserver.js b/graylog2-web-interface/packages/jest-preset-graylog/lib/setup-files/mock-IntersectionObserver.js index ff20188e45e2..38a40a9ebcfc 100644 --- a/graylog2-web-interface/packages/jest-preset-graylog/lib/setup-files/mock-IntersectionObserver.js +++ b/graylog2-web-interface/packages/jest-preset-graylog/lib/setup-files/mock-IntersectionObserver.js @@ -14,10 +14,8 @@ * along with this program. If not, see * . */ -const observe = jest.fn(); -const unobserve = jest.fn(); - window.IntersectionObserver = jest.fn(() => ({ - observe, - unobserve, + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), })); diff --git a/graylog2-web-interface/src/components/common/Card.tsx b/graylog2-web-interface/src/components/common/Card.tsx index e6de53001aa5..8cf02ee85af1 100644 --- a/graylog2-web-interface/src/components/common/Card.tsx +++ b/graylog2-web-interface/src/components/common/Card.tsx @@ -22,25 +22,40 @@ import { Card as MantineCard } from '@mantine/core'; const Container = styled(MantineCard)(({ theme }) => css` background-color: ${theme.colors.cards.background}; border-color: ${theme.colors.cards.border}; + + &:focus { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + } `); type Props = React.PropsWithChildren<{ className?: string, padding?: 'sm', + id?: string, + tabIndex?: number }> /** * Simple card component. */ -const Card = ({ children, className, padding }: Props) => ( - +const Card = ({ children, className, padding, id, tabIndex }: Props) => ( + {children} ); Card.defaultProps = { className: undefined, + id: undefined, padding: undefined, + tabIndex: undefined, }; export default Card; diff --git a/graylog2-web-interface/src/components/common/Carousel/Carousel.tsx b/graylog2-web-interface/src/components/common/Carousel/Carousel.tsx index 94b690448df5..b0222761ebd6 100644 --- a/graylog2-web-interface/src/components/common/Carousel/Carousel.tsx +++ b/graylog2-web-interface/src/components/common/Carousel/Carousel.tsx @@ -20,6 +20,8 @@ import styled from 'styled-components'; import CarouselSlide from './CarouselSlide'; import CarouselContext from './CarouselContext'; +export const CAROUSEL_CONTAINER_CLASS_NAME = 'carousel-container'; + const useCarouselRef = (carouselId: string) => { const carouselContext = useContext(CarouselContext); @@ -35,12 +37,15 @@ const useCarouselRef = (carouselId: string) => { }; /* - * Carousel component based on embla carousel. Needs to be wrapped in CarouselProvider. - * The CarouselProvider also allows configuring the carousel. + * Carousel component based on embla carousel. It needs to be wrapped with the CarouselProvider. + * The CarouselProvider allows accessing the carouselApi object in all children, like the carousel navigation components. + * The CarouselProvider also maintains the carousel configuration options. */ type Props = { children: React.ReactNode, + className?: string + containerRef?: React.Ref carouselId: string }; @@ -48,7 +53,7 @@ const StyledDiv = styled.div` &.carousel { overflow: hidden; - .carousel-container { + .${CAROUSEL_CONTAINER_CLASS_NAME} { backface-visibility: hidden; display: flex; flex-direction: row; @@ -57,17 +62,22 @@ const StyledDiv = styled.div` } `; -const Carousel = ({ children, carouselId }: Props) => { +const Carousel = ({ children, className, containerRef, carouselId }: Props) => { const carouselRef = useCarouselRef(carouselId); return ( - -
+ +
{children}
); }; +Carousel.defaultProps = { + className: undefined, + containerRef: undefined, +}; + Carousel.Slide = CarouselSlide; export default Carousel; diff --git a/graylog2-web-interface/src/components/common/Carousel/CarouselProvider.tsx b/graylog2-web-interface/src/components/common/Carousel/CarouselProvider.tsx index 6c53e9f43513..205c5f394931 100644 --- a/graylog2-web-interface/src/components/common/Carousel/CarouselProvider.tsx +++ b/graylog2-web-interface/src/components/common/Carousel/CarouselProvider.tsx @@ -21,12 +21,13 @@ import useEmblaCarousel from 'embla-carousel-react'; import CarouselContext from './CarouselContext'; type Props = React.PropsWithChildren<{ - carouselId: string + carouselId: string, + options?: Partial<{ align: 'start' }> }> -const CarouselProvider = ({ carouselId, children } : Props) => { +const CarouselProvider = ({ carouselId, children, options } : Props) => { const existingContextValue = useContext(CarouselContext); - const [ref, api] = useEmblaCarousel({ containScroll: 'trimSnaps' }); + const [ref, api] = useEmblaCarousel(options); const value = useMemo(() => ({ ...(existingContextValue ?? {}), @@ -40,4 +41,8 @@ const CarouselProvider = ({ carouselId, children } : Props) => { ); }; +CarouselProvider.defaultProps = { + options: {}, +}; + export default CarouselProvider; diff --git a/graylog2-web-interface/src/components/common/Carousel/CarouselSlide.tsx b/graylog2-web-interface/src/components/common/Carousel/CarouselSlide.tsx index aaae0390e28e..9456b12c2bde 100644 --- a/graylog2-web-interface/src/components/common/Carousel/CarouselSlide.tsx +++ b/graylog2-web-interface/src/components/common/Carousel/CarouselSlide.tsx @@ -20,10 +20,9 @@ import styled, { css } from 'styled-components'; export interface CarouselSlideProps extends React.ComponentPropsWithoutRef<'div'> { children?: React.ReactNode; - - size?: string | number; - + className?: string, gap?: number; + size?: string | number; } const StyledSlide = styled.div<{ $size?: string | number, $gap?: number }>(({ $size, $gap, theme }: { @@ -38,16 +37,17 @@ const StyledSlide = styled.div<{ $size?: string | number, $gap?: number }>(({ $s position: relative; `); -const CarouselSlide = ({ children, size, gap }: CarouselSlideProps) => ( - +const CarouselSlide = ({ children, size, gap, className }: CarouselSlideProps) => ( + {children} ); CarouselSlide.defaultProps = { children: undefined, - size: undefined, + className: undefined, gap: undefined, + size: undefined, }; export default CarouselSlide; diff --git a/graylog2-web-interface/src/components/common/Carousel/hooks/useCarouselActions.ts b/graylog2-web-interface/src/components/common/Carousel/hooks/useCarouselActions.ts index 1ff159203b8e..5354ade40fed 100644 --- a/graylog2-web-interface/src/components/common/Carousel/hooks/useCarouselActions.ts +++ b/graylog2-web-interface/src/components/common/Carousel/hooks/useCarouselActions.ts @@ -21,29 +21,27 @@ import useCarouselApi from './useCarouselApi'; const useCarouselActions = (carouselId: string) => { const carouselApi = useCarouselApi(carouselId); - const canScrollPrev = useCallback(() => !!carouselApi?.canScrollPrev(), [carouselApi]); - const canScrollNext = useCallback(() => !!carouselApi?.canScrollNext(), [carouselApi]); - - const [nextBtnDisabled, setNextBtnDisabled] = useState(false); - const [prevBtnDisabled, setPrevBtnDisabled] = useState(false); + const [prevBtnDisabled, setPrevBtnDisabled] = useState(true); + const [nextBtnDisabled, setNextBtnDisabled] = useState(true); const onSelect = useCallback(() => { - setPrevBtnDisabled(!canScrollPrev()); - setNextBtnDisabled(!canScrollNext()); - }, [canScrollNext, canScrollPrev]); + setPrevBtnDisabled(!carouselApi.canScrollPrev()); + setNextBtnDisabled(!carouselApi.canScrollNext()); + }, [carouselApi]); useEffect(() => { - if (carouselApi) { - carouselApi.on('reInit', onSelect); - carouselApi.on('select', onSelect); - } + if (!carouselApi) return; + + onSelect(); + carouselApi.on('reInit', onSelect); + carouselApi.on('select', onSelect); }, [carouselApi, onSelect]); return { + prevBtnDisabled, + nextBtnDisabled, scrollNext: carouselApi?.scrollNext ? carouselApi.scrollNext : () => {}, scrollPrev: carouselApi?.scrollPrev ? carouselApi.scrollPrev : () => {}, - nextBtnDisabled, - prevBtnDisabled, }; }; diff --git a/graylog2-web-interface/src/components/common/Carousel/hooks/useSlidesInView.ts b/graylog2-web-interface/src/components/common/Carousel/hooks/useSlidesInView.ts new file mode 100644 index 000000000000..876307b99f66 --- /dev/null +++ b/graylog2-web-interface/src/components/common/Carousel/hooks/useSlidesInView.ts @@ -0,0 +1,51 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; + +import useCarouselApi from './useCarouselApi'; + +// Hook which returns all slide indices which have been in the view at least once. +const useSlidesInView = (carouselId: string) => { + const carouselApi = useCarouselApi(carouselId); + const [slidesInView, setSlidesInView] = useState>([]); + + const updateSlidesInView = useCallback(() => { + setSlidesInView((cur) => { + if (cur.length === carouselApi.slideNodes().length) { + carouselApi.off('slidesInView', updateSlidesInView); + } + + const inView = carouselApi + .slidesInView() + .filter((index) => !cur.includes(index)); + + return cur.concat(inView); + }); + }, [carouselApi]); + + useEffect(() => { + if (!carouselApi) return; + + updateSlidesInView(); + carouselApi.on('slidesInView', updateSlidesInView); + carouselApi.on('reInit', updateSlidesInView); + }, [carouselApi, updateSlidesInView]); + + return slidesInView; +}; + +export default useSlidesInView; diff --git a/graylog2-web-interface/src/components/common/Carousel/index.ts b/graylog2-web-interface/src/components/common/Carousel/index.ts index a5933e239f43..72fb5d47fd19 100644 --- a/graylog2-web-interface/src/components/common/Carousel/index.ts +++ b/graylog2-web-interface/src/components/common/Carousel/index.ts @@ -15,9 +15,12 @@ * . */ -import Carousel from './Carousel'; +import Carousel, { CAROUSEL_CONTAINER_CLASS_NAME } from './Carousel'; + +export { CAROUSEL_CONTAINER_CLASS_NAME }; export { default as useCarouselApi } from './hooks/useCarouselApi'; +export { default as useSlidesInView } from './hooks/useSlidesInView'; export { default as useCarouselActions } from './hooks/useCarouselActions'; export { default as CarouselProvider } from './CarouselProvider'; diff --git a/graylog2-web-interface/src/components/events/events/EventFields.tsx b/graylog2-web-interface/src/components/events/events/EventFields.tsx index 5f213c39eb38..af1e68a7d201 100644 --- a/graylog2-web-interface/src/components/events/events/EventFields.tsx +++ b/graylog2-web-interface/src/components/events/events/EventFields.tsx @@ -17,7 +17,7 @@ import React from 'react'; type Props = { - fields: Object[] | {[key: string]: string}, + fields: Record, }; const EventFields = ({ fields }: Props) => { diff --git a/graylog2-web-interface/src/components/events/events/hooks/useEventDefinition.tsx b/graylog2-web-interface/src/components/events/events/hooks/useEventDefinition.tsx index 2f25b629d98d..c0d213160a42 100644 --- a/graylog2-web-interface/src/components/events/events/hooks/useEventDefinition.tsx +++ b/graylog2-web-interface/src/components/events/events/hooks/useEventDefinition.tsx @@ -26,7 +26,7 @@ export const fetchEventDefinitionDetails = async (eventDefinitionId: string): Pr ); const useEventDefinition = (eventDefId: string, enabled = true) => { - const { data, isFetching } = useQuery({ + const { data, isFetching, isInitialLoading } = useQuery({ queryKey: ['get-event-definition-details', eventDefId], queryFn: () => fetchEventDefinitionDetails(eventDefId), onError: (errorThrown) => { @@ -37,7 +37,7 @@ const useEventDefinition = (eventDefId: string, enabled = true) => { enabled: !!eventDefId && enabled, }); - return { data, isFetching }; + return { data, isFetching, isInitialLoading }; }; export default useEventDefinition; diff --git a/graylog2-web-interface/src/components/events/events/types.ts b/graylog2-web-interface/src/components/events/events/types.ts index 3f0b2febff7d..9f32fbc2cad6 100644 --- a/graylog2-web-interface/src/components/events/events/types.ts +++ b/graylog2-web-interface/src/components/events/events/types.ts @@ -31,7 +31,7 @@ export type Event = { timerange_start: string, timerange_end: string, key: string, - fields: Object[], + fields: Record, group_by_fields: {[key: string]: string}, source_streams: string[], replay_info: EventReplayInfo | undefined, diff --git a/graylog2-web-interface/src/logic/telemetry/Constants.ts b/graylog2-web-interface/src/logic/telemetry/Constants.ts index 4778b9f28299..c9657442ea02 100644 --- a/graylog2-web-interface/src/logic/telemetry/Constants.ts +++ b/graylog2-web-interface/src/logic/telemetry/Constants.ts @@ -285,6 +285,9 @@ export const TELEMETRY_EVENT_TYPE = { INVESTIGATION_NOTE_SAVED: 'Security Investigation Note Saved', INVESTIGATION_NOTE_EDIT: 'Security Investigation Note Updated', INVESTIGATION_NOTE_DELETED: 'Security Investigation Note Deleted', + INVESTIGATION_DETAILS_SECTION_OPENED: 'Security Investigation Details Section Opened', + INVESTIGATION_TIMELINE_FILTER_UPDATED: 'Security Investigation Timeline Filter Updated', + INVESTIGATION_TIMELINE_CAROUSEL_NAVIGATED: 'Security Investigation Timeline Carousel Navigated', SIGMA_IMPORT_RULES_OPENED: 'Security Sigma Rules Import Opened', SIGMA_IMPORT_RULES_IMPORTED: 'Security Sigma Rules Imported', SIGMA_RULE_UPDATED: 'Security Sigma Rules Update', diff --git a/graylog2-web-interface/src/util/AggregationWidgetExportUtils.ts b/graylog2-web-interface/src/util/AggregationWidgetExportUtils.ts deleted file mode 100644 index ab01343e1fde..000000000000 --- a/graylog2-web-interface/src/util/AggregationWidgetExportUtils.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 { fetchFileWithBlob } from 'util/FileDownloadUtils'; -import { qualifyUrl } from 'util/URLUtils'; -import type { Rows } from 'views/logic/searchtypes/pivot/PivotHandler'; -import type { AbsoluteTimeRange } from 'views/logic/queries/Query'; -import { escape } from 'views/logic/queries/QueryHelper'; - -export type Extension = 'csv' | 'json' | 'yaml' | 'xml'; - -export type Result = { - total: number, - rows: Rows, - effective_timerange: AbsoluteTimeRange, -}; - -const mimeTypeMapper: Record = { - csv: 'text/csv', - json: 'application/json', - yaml: 'application/yaml', - xml: 'application/xml', -}; - -const getUrl = (fileName: string) => qualifyUrl(`views/search/pivot/export/${escape(fileName)}`); - -export const exportWidget = (widgetTitle: string, widgetResults: Result, extension: Extension) => { - const fileName = `${widgetTitle}_${widgetResults.effective_timerange.from}-${widgetResults.effective_timerange.to}.${extension}`; - const mimeType = mimeTypeMapper[extension]; - - return fetchFileWithBlob('POST', getUrl(fileName), widgetResults, mimeType, fileName); -}; diff --git a/graylog2-web-interface/src/util/DateTime.ts b/graylog2-web-interface/src/util/DateTime.ts index b9f280c23ff7..be2f55df0b60 100644 --- a/graylog2-web-interface/src/util/DateTime.ts +++ b/graylog2-web-interface/src/util/DateTime.ts @@ -33,6 +33,7 @@ export const DATE_TIME_FORMATS = { internalIndexer: 'YYYY-MM-DDTHH:mm:ss.SSS[Z]', // ISO 8601, used for ES search queries, when a timestamp has to be reformatted date: 'YYYY-MM-DD', hourAndMinute: 'HH:mm', + dateHourAndMinute: 'YYYY-MM-DD HH:mm', }; const DEFAULT_OUTPUT_TZ = 'UTC'; diff --git a/graylog2-web-interface/src/views/Constants.ts b/graylog2-web-interface/src/views/Constants.ts index d7aea0d43890..ef71b2b727c2 100644 --- a/graylog2-web-interface/src/views/Constants.ts +++ b/graylog2-web-interface/src/views/Constants.ts @@ -20,7 +20,6 @@ import type { TimeRange, RelativeTimeRangeWithEnd, RelativeTimeRange } from 'vie import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor'; import type { ArrayElement } from 'views/types'; import type { AutoTimeConfig } from 'views/logic/aggregationbuilder/Pivot'; -import type { Extension } from 'util/AggregationWidgetExportUtils'; export type SearchBarFormValues = { timerange: TimeRange, @@ -163,10 +162,3 @@ export const VISUALIZATION_TABLE_HEADER_HEIGHT = 28; export const keySeparator = '\u2E31'; export const humanSeparator = '-'; - -export const supportedAggregationExportFormats: Array<{id: Extension, title: string}> = [ - { id: 'csv', title: 'CSV' }, - { id: 'json', title: 'JSON' }, - { id: 'yaml', title: 'YAML' }, - { id: 'xml', title: 'XML' }, -]; diff --git a/graylog2-web-interface/src/views/bindings.tsx b/graylog2-web-interface/src/views/bindings.tsx index 19b39de5689a..75b7c89f0d46 100644 --- a/graylog2-web-interface/src/views/bindings.tsx +++ b/graylog2-web-interface/src/views/bindings.tsx @@ -108,6 +108,8 @@ import EventsWidgetEdit from 'views/components/widgets/events/EventsWidgetEdit'; import EventsWidget from 'views/logic/widgets/events/EventsWidget'; import eventsAttributes from 'views/components/widgets/events/eventsAttributes'; import WarmTierQueryValidation from 'views/components/searchbar/queryvalidation/WarmTierQueryValidation'; +import ExportMessageWidgetAction from 'views/components/widgets/ExportWidgetAction/ExportMessageWidgetAction'; +import ExportWidgetAction from 'views/components/widgets/ExportWidgetAction/ExportWidgetAction'; import type { ActionHandlerArguments } from './components/actions/ActionHandler'; import NumberVisualizationConfig from './logic/aggregationbuilder/visualizations/NumberVisualizationConfig'; @@ -457,6 +459,7 @@ const exports: PluginExports = { 'views.components.widgets.events.attributes': eventsAttributes, 'views.reducers': viewsReducers, 'views.elements.validationErrorExplanation': [WarmTierQueryValidation], + 'views.widgets.actions': [ExportMessageWidgetAction, ExportWidgetAction], }; export default exports; diff --git a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx index 15a6c13a7caf..36b28bcf52c8 100644 --- a/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx +++ b/graylog2-web-interface/src/views/components/visualizations/GenericPlot.tsx @@ -19,6 +19,7 @@ import PropTypes from 'prop-types'; import type { DefaultTheme } from 'styled-components'; import styled, { css, withTheme } from 'styled-components'; import merge from 'lodash/merge'; +import type * as Plotly from 'plotly.js'; import Plot from 'views/components/visualizations/plotly/AsyncPlot'; import type ColorMapper from 'views/components/visualizations/ColorMapper'; @@ -48,6 +49,18 @@ const StyledPlot = styled(Plot)(({ theme }) => css` } `); +export type OnClickMarkerEvent = { + x: string, + y: string, +} + +export type OnHoverMarkerEvent = { + positionX: number, + positionY: number, + x: string, + y: string, +} + type LegendConfig = { name: string, target: HTMLElement, @@ -82,6 +95,9 @@ type Props = { layout: {}, onZoom: (from: string, to: string) => boolean, setChartColor?: (data: ChartConfig, color: ColorMapper) => ChartColor, + onClickMarker?: (event: OnClickMarkerEvent) => void + onHoverMarker?: (event: OnHoverMarkerEvent) => void, + onUnhoverMarker?: () => void, }; type GenericPlotProps = Props & { theme: DefaultTheme }; @@ -116,6 +132,9 @@ class GenericPlot extends React.Component { layout: {}, onZoom: () => true, setChartColor: undefined, + onClickMarker: (_event) => {}, + onHoverMarker: (_event) => {}, + onUnhoverMarker: () => {}, }; constructor(props: GenericPlotProps) { @@ -144,7 +163,7 @@ class GenericPlot extends React.Component { }; render() { - const { chartData, layout, setChartColor, theme } = this.props; + const { chartData, layout, setChartColor, theme, onClickMarker, onHoverMarker, onUnhoverMarker } = this.props; const fontSettings = { color: theme.colors.global.textDefault, size: ROOT_FONT_SIZE * Number(theme.fonts.size.small.replace(/rem|em/i, '')), @@ -191,6 +210,24 @@ class GenericPlot extends React.Component { }; const plotLayout = merge({}, defaultLayout, layout); + const _onHoverMarker = (event: unknown) => { + const { points } = event as { points: Array<{ bbox: { x0: number, y0: number }, y: string, x: string }> }; + + onHoverMarker?.({ + positionX: points[0].bbox.x0, + positionY: points[0].bbox.y0, + x: points[0].x, + y: points[0].y, + }); + }; + + const _onMarkerClick = ({ points }: Readonly) => { + onClickMarker?.({ + x: points[0].x as string, + y: points[0].y as string, + }); + }; + return ( {({ colors }) => { @@ -233,7 +270,9 @@ class GenericPlot extends React.Component { layout={interactive ? plotLayout : merge({}, nonInteractiveLayout, plotLayout)} style={style} onAfterPlot={onRenderComplete} - onClick={interactive ? null : () => false} + onClick={interactive ? _onMarkerClick : () => false} + onHover={_onHoverMarker} + onUnhover={onUnhoverMarker} onRelayout={interactive ? this._onRelayout : () => false} config={config} /> )} diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.test.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.test.tsx deleted file mode 100644 index c8815bd266a6..000000000000 --- a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 { render, screen, waitFor } from 'wrappedTestingLibrary'; -import userEvent from '@testing-library/user-event'; - -import { MenuItem } from 'components/bootstrap'; - -import AggregationWidgetExportDropdown from './AggregationWidgetExportDropdown'; - -describe('AggregationWidgetExportDropdown', () => { - it('opens menu when trigger element is clicked', async () => { - render(( - - CSV - - )); - - const menuButton = await screen.findByRole('button', { name: /open export widget options/i }); - - expect(screen.queryByText('CSV')).not.toBeInTheDocument(); - - userEvent.click(menuButton); - - await screen.findByRole('menuitem', { name: 'CSV' }); - }); - - it('closes menu when MenuItem is clicked', async () => { - const onSelect = jest.fn(); - - render(( - - CSV - - )); - - const menuButton = await screen.findByRole('button', { name: /open export widget options/i }); - userEvent.click(menuButton); - - const fooAction = await screen.findByRole('menuitem', { name: 'CSV' }); - userEvent.click(fooAction); - - await waitFor(() => { - expect(screen.queryByText('CSV')).not.toBeInTheDocument(); - }); - - expect(onSelect).toHaveBeenCalled(); - }); -}); diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetAction.ts b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetAction.ts new file mode 100644 index 000000000000..8a5ad8c749e3 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetAction.ts @@ -0,0 +1,30 @@ +/* + * 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 type Widget from 'views/logic/widgets/Widget'; +import type { WidgetActionType } from 'views/components/widgets/Types'; +import MessagesWidget from 'views/logic/widgets/MessagesWidget'; +import ExportMessageWidgetActionComponent + from 'views/components/widgets/ExportWidgetAction/ExportMessageWidgetActionComponent'; + +const ExportMessageWidgetAction: WidgetActionType = { + type: 'export-messages-widget-action', + position: 'menu', + isHidden: (w: Widget) => (w.type !== MessagesWidget.type), + component: ExportMessageWidgetActionComponent, +}; + +export default ExportMessageWidgetAction; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetActionComponent.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetActionComponent.tsx new file mode 100644 index 000000000000..50ffa050c216 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportMessageWidgetActionComponent.tsx @@ -0,0 +1,41 @@ +/* + * 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 React, { useState } from 'react'; + +import ExportModal from 'views/components/export/ExportModal'; +import type { WidgetMenuActionComponentProps } from 'views/components/widgets/Types'; +import useView from 'views/hooks/useView'; +import { IconButton } from 'components/common'; + +const ExportMessageWidgetActionComponent = ({ widget, disabled }: WidgetMenuActionComponentProps) => { + const [showExport, setShowExport] = useState(false); + const view = useView(); + const showMessageExportModal = () => setShowExport(true); + + return ( + <> + + {showExport && ( + setShowExport(false)} /> + )} + + ); +}; + +export default ExportMessageWidgetActionComponent; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.ts b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.ts new file mode 100644 index 000000000000..fff6923b6728 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.ts @@ -0,0 +1,30 @@ +/* + * 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 AggregationWidget from 'views/logic/aggregationbuilder/AggregationWidget'; +import type Widget from 'views/logic/widgets/Widget'; +import type { WidgetActionType } from 'views/components/widgets/Types'; +import ExportWidgetActionDelegate from 'views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate'; + +const ExportWidgetAction: WidgetActionType = { + type: 'export-widget-action', + position: 'menu', + isHidden: (w: Widget) => (w.type !== AggregationWidget.type), + component: ExportWidgetActionDelegate, +}; + +export default ExportWidgetAction; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.tsx deleted file mode 100644 index 647f8480585c..000000000000 --- a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetAction.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 React from 'react'; - -import AggregationWidgetExportDropdown from 'views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown'; -import { supportedAggregationExportFormats } from 'views/Constants'; -import { MenuItem } from 'components/bootstrap'; -import type Widget from 'views/logic/widgets/Widget'; -import AggregationWidget from 'views/logic/aggregationbuilder/AggregationWidget'; -import useWidgetResults from 'views/components/useWidgetResults'; -import type { Extension } from 'util/AggregationWidgetExportUtils'; -import MessagesWidget from 'views/logic/widgets/MessagesWidget'; -import { IconButton } from 'components/common'; - -type Props = { - widget: Widget, - onExportAggregationWidget: (extension: Extension) => void; - showMessageExportModal: () => void; -} - -const ExportWidgetAction = ({ widget, onExportAggregationWidget, showMessageExportModal }: Props) => { - const { error: errors, widgetData: widgetResult } = useWidgetResults(widget.id); - const showExportAggregationWidgetAction = widgetResult && widget.type === AggregationWidget.type && !errors?.length; - const showExportMessageWidgetAction = widget.type === MessagesWidget.type && widget.isExportable; - - return ( - <> - {showExportAggregationWidgetAction && ( - - { - supportedAggregationExportFormats.map(({ id: extension, title: extensionTitle }) => ( - onExportAggregationWidget(extension)}> - {extensionTitle} - - )) - } - - )} - {showExportMessageWidgetAction && ( - - )} - - ); -}; - -export default ExportWidgetAction; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.test.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.test.tsx new file mode 100644 index 000000000000..7d03ba9f74df --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { render, screen } from 'wrappedTestingLibrary'; +import userEvent from '@testing-library/user-event'; + +import asMock from 'helpers/mocking/AsMock'; +import OriginalExportWidgetActionDelegate from 'views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate'; +import useWidgetExportActionComponent from 'views/components/widgets/useWidgetExportActionComponent'; +import AggregationWidget from 'views/logic/aggregationbuilder/AggregationWidget'; + +jest.mock('views/components/widgets/useWidgetExportActionComponent'); +const renderExportWidgetActionDelegate = () => render( + , +); + +describe('ExtraMenuWidgetActions', () => { + const plugExplanation = /export aggregation widget feature is available for the enterprise version\. graylog provides option to export your data into most popular file formats such as csv, json, yaml, xml etc\./i; + + it('Render plug when there is no WidgetExportActionComponent', async () => { + asMock(useWidgetExportActionComponent).mockReturnValue(null); + + renderExportWidgetActionDelegate(); + const exportButton = await screen.findByRole('button', { name: /export widget/i }); + userEvent.click(exportButton); + + const plugText = screen.queryByText(plugExplanation); + + expect(plugText).toBeNull(); + }); + + it('Render original WidgetExportActionComponent without a plug', async () => { + asMock(useWidgetExportActionComponent).mockReturnValue(() => ); + + renderExportWidgetActionDelegate(); + const exportButton = await screen.findByRole('button', { name: /dummy export action/i }); + const plugExportButton = screen.queryByRole('button', { name: /export widget/i }); + userEvent.click(exportButton); + + const plugText = screen.queryByText(plugExplanation); + + expect(plugText).toBeNull(); + expect(plugExportButton).toBeNull(); + }); +}); diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.tsx new file mode 100644 index 000000000000..c89eb67d0791 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetActionDelegate.tsx @@ -0,0 +1,30 @@ +/* + * 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 React from 'react'; + +import type { WidgetMenuActionComponentProps } from 'views/components/widgets/Types'; +import ExportWidgetPlug from 'views/components/widgets/ExportWidgetAction/ExportWidgetPlug'; +import useWidgetExportActionComponent from 'views/components/widgets/useWidgetExportActionComponent'; + +const ExportWidgetActionDelegate = ({ widget, contexts, disabled }: WidgetMenuActionComponentProps) => { + const ExportActionComponent = useWidgetExportActionComponent(); + if (!ExportActionComponent) return ; + + return ; +}; + +export default ExportWidgetActionDelegate; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlug.tsx b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlug.tsx new file mode 100644 index 000000000000..557bc5c58600 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlug.tsx @@ -0,0 +1,35 @@ +/* + * 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 React from 'react'; + +import { IconButton, OverlayTrigger } from 'components/common'; + +const title = 'Export widget'; +const Explanation = () => ( + Export aggregation widget feature is available for the enterprise version. + Graylog provides options to export your data into most popular file formats such as + CSV, JSON, YAML, XML etc. + +); + +const ExportWidgetPlug = () => ( + } placement="bottom"> + + +); + +export default ExportWidgetPlug; diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlugAction.ts b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlugAction.ts new file mode 100644 index 000000000000..51a2c95dc857 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/ExportWidgetPlugAction.ts @@ -0,0 +1,26 @@ +/* + * 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 type { WidgetActionType } from 'views/components/widgets/Types'; +import ExportWidgetPlug from 'views/components/widgets/ExportWidgetAction/ExportWidgetPlug'; + +const ExportMessageWidgetAction: WidgetActionType = { + type: 'export-widget-action-plug', + position: 'menu', + component: ExportWidgetPlug, +}; + +export default ExportMessageWidgetAction; diff --git a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx b/graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.test.tsx similarity index 82% rename from graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx rename to graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.test.tsx index 204b7eda9cd9..d0c5a44cf659 100644 --- a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.test.tsx @@ -19,12 +19,13 @@ import { render, screen, waitFor } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; import asMock from 'helpers/mocking/AsMock'; -import OriginalExtraWidgetActions from 'views/components/widgets/ExtraWidgetActions'; +import OriginalExtraWidgetActions from 'views/components/widgets/ExtraDropdownWidgetActions'; import Widget from 'views/logic/widgets/Widget'; import TestStoreProvider from 'views/test/TestStoreProvider'; import useViewsPlugin from 'views/test/testViewsPlugin'; import useWidgetActions from 'views/components/widgets/useWidgetActions'; import wrapWithMenu from 'helpers/components/wrapWithMenu'; +import type { WidgetActionType } from 'views/components/widgets/Types'; jest.mock('views/components/widgets/useWidgetActions'); @@ -38,7 +39,7 @@ const ExtraWidgetActions = wrapWithMenu(ExtraWidgetActionsWithoutMenu); describe('ExtraWidgetActions', () => { const widget = Widget.empty(); - const dummyActionWithoutIsHidden = { + const dummyActionWithoutIsHidden: WidgetActionType = { type: 'dummy-action', title: () => 'Dummy Action', action: jest.fn(() => async () => {}), @@ -55,10 +56,15 @@ describe('ExtraWidgetActions', () => { ...dummyActionWithoutIsHidden, disabled: jest.fn(() => true), }; - + const dummyActionWithMenuPosition: WidgetActionType = { + position: 'menu', + type: 'dummy-action', + title: () => 'Dummy Action', + action: jest.fn(() => async () => {}), + }; useViewsPlugin(); - it('does not render menu items, when no action is configured', () => { + it('does not render dropdown items, when no action is configured', () => { asMock(useWidgetActions).mockReturnValue([]); render(); @@ -66,7 +72,7 @@ describe('ExtraWidgetActions', () => { expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); }); - it('does not render menu items, if no action is hidden', () => { + it('does not render dropdown items, if no action is hidden', () => { asMock(useWidgetActions).mockReturnValue([dummyActionWhichIsHidden]); render(); @@ -90,7 +96,7 @@ describe('ExtraWidgetActions', () => { await screen.findByText('Dummy Action'); }); - it('clicking menu item triggers action', async () => { + it('clicking dropdown item triggers action', async () => { asMock(useWidgetActions).mockReturnValue([dummyActionWhichIsNotHidden]); render(); @@ -126,4 +132,12 @@ describe('ExtraWidgetActions', () => { expect(menuItem).toBeDisabled(); }); + + it('does not render dropdown items, when action has menu position', () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWithMenuPosition]); + + render(); + + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.tsx b/graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.tsx similarity index 91% rename from graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.tsx rename to graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.tsx index ed0531926303..2977b62e4841 100644 --- a/graylog2-web-interface/src/views/components/widgets/ExtraWidgetActions.tsx +++ b/graylog2-web-interface/src/views/components/widgets/ExtraDropdownWidgetActions.tsx @@ -31,14 +31,14 @@ type Props = { widget: Widget, }; -const ExtraWidgetActions = ({ widget }: Props) => { +const ExtraDropdownWidgetActions = ({ widget }: Props) => { const widgetFocusContext = useContext(WidgetFocusContext); const pluginWidgetActions = useWidgetActions(); const dispatch = useAppDispatch(); const sendTelemetry = useSendTelemetry(); const { pathname } = useLocation(); const extraWidgetActions = useMemo(() => pluginWidgetActions - .filter(({ isHidden = () => false }) => !isHidden(widget)) + .filter(({ isHidden = () => false, position }) => !isHidden(widget) && (position === 'dropdown' || position === undefined)) .map(({ title, action, type, disabled = () => false }) => { const _onSelect = () => { sendTelemetry(TELEMETRY_EVENT_TYPE.SEARCH_WIDGET_ACTION.SEARCH_WIDGET_EXTRA_ACTION, { @@ -64,4 +64,4 @@ const ExtraWidgetActions = ({ widget }: Props) => { : null; }; -export default ExtraWidgetActions; +export default ExtraDropdownWidgetActions; diff --git a/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.test.tsx b/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.test.tsx new file mode 100644 index 000000000000..6bb5c0452b4f --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { render, screen } from 'wrappedTestingLibrary'; + +import asMock from 'helpers/mocking/AsMock'; +import OriginalExtraMenuWidgetActions from 'views/components/widgets/ExtraMenuWidgetActions'; +import Widget from 'views/logic/widgets/Widget'; +import TestStoreProvider from 'views/test/TestStoreProvider'; +import useViewsPlugin from 'views/test/testViewsPlugin'; +import useWidgetActions from 'views/components/widgets/useWidgetActions'; +import type { WidgetActionType } from 'views/components/widgets/Types'; + +jest.mock('views/components/widgets/useWidgetActions'); + +const ExtraMenuWidgetActions = (props: React.ComponentProps) => ( + + + +); + +describe('ExtraMenuWidgetActions', () => { + const widget = Widget.empty(); + const dummyActionWithoutIsHidden: WidgetActionType = { + position: 'menu', + type: 'dummy-action', + component: ({ disabled }) => , + }; + const dummyActionWhichIsHidden = { + ...dummyActionWithoutIsHidden, + isHidden: jest.fn(() => true), + }; + const dummyActionWhichIsNotHidden = { + ...dummyActionWithoutIsHidden, + isHidden: jest.fn(() => false), + }; + const dummyActionWhichIsDisabled = { + ...dummyActionWithoutIsHidden, + disabled: jest.fn(() => true), + }; + const dummyActionWithDropdownPosition: WidgetActionType = { + ...dummyActionWithoutIsHidden, + position: 'dropdown', + }; + useViewsPlugin(); + + it('renders action which has no `isHidden`', async () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWithoutIsHidden]); + + render(); + await screen.findByRole('button', { name: /dummy action/i }); + }); + + it('renders action where `isHidden` returns `false`', async () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWhichIsNotHidden]); + + render(); + + await screen.findByRole('button', { name: /dummy action/i }); + }); + + it('use disabled props from action in component', async () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWhichIsDisabled]); + + render(); + const actionButton = await screen.findByRole('button', { name: /dummy action/i }); + + expect(actionButton).toBeDisabled(); + }); + + it('does not render menu items, when action has dropdown position', async () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWithDropdownPosition]); + + render(); + + const actionButton = screen.queryByRole('button', { name: /dummy action/i }); + + expect(actionButton).toBeNull(); + }); + + it('does not render hidden item', async () => { + asMock(useWidgetActions).mockReturnValue([dummyActionWhichIsHidden]); + + render(); + + const actionButton = screen.queryByRole('button', { name: /dummy action/i }); + + expect(actionButton).toBeNull(); + }); +}); diff --git a/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.tsx b/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.tsx new file mode 100644 index 000000000000..b214cef85de6 --- /dev/null +++ b/graylog2-web-interface/src/views/components/widgets/ExtraMenuWidgetActions.tsx @@ -0,0 +1,48 @@ +/* + * 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 { useContext, useMemo } from 'react'; + +import type Widget from 'views/logic/widgets/Widget'; +import WidgetFocusContext from 'views/components/contexts/WidgetFocusContext'; +import useWidgetActions from 'views/components/widgets/useWidgetActions'; +import type { WidgetActionType } from 'views/components/widgets/Types'; + +type Props = { + widget: Widget, +}; + +const ExtraMenuWidgetActions = ({ widget }: Props) => { + const widgetFocusContext = useContext(WidgetFocusContext); + const pluginWidgetActions = useWidgetActions(); + + const extraWidgetActions = useMemo>(() => pluginWidgetActions + .filter(({ isHidden = () => false, position }) => !isHidden(widget) && position === 'menu'), + [pluginWidgetActions, widget]); + + return ( + <>{extraWidgetActions.map(({ component: Component, type, disabled = () => false }) => ( + + ))} + + ); +}; + +export default ExtraMenuWidgetActions; diff --git a/graylog2-web-interface/src/views/components/widgets/Types.ts b/graylog2-web-interface/src/views/components/widgets/Types.ts index 2a8c5427feff..3881b0d59bca 100644 --- a/graylog2-web-interface/src/views/components/widgets/Types.ts +++ b/graylog2-web-interface/src/views/components/widgets/Types.ts @@ -27,10 +27,28 @@ export type Contexts = { export type WidgetAction = (w: Widget, contexts: Contexts) => (dispatch: AppDispatch, getState: GetState) => Promise; -export type WidgetActionType = { +type WidgetActionPositionType = 'menu' | 'dropdown'; + +type WidgetBaseActionType = { type: string, - title: (w: Widget) => React.ReactNode, isHidden?: (w: Widget) => boolean, - action: WidgetAction, disabled?: () => boolean, +} + +type WidgetDropdownActionType = { + title: (w: Widget) => React.ReactNode, + action: WidgetAction, + position?: WidgetActionPositionType, + component?: never }; + +export type WidgetMenuActionComponentProps = {disabled?: boolean, widget: Widget, contexts?: Contexts} + +export type WidgetMenuActionType = { + component: React.ComponentType, + position: WidgetActionPositionType + title?: never, + action?: never, +}; + +export type WidgetActionType = (WidgetDropdownActionType | WidgetMenuActionType) & WidgetBaseActionType; diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx index d33ff00423a9..42cc79994a64 100644 --- a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.test.tsx @@ -40,7 +40,6 @@ import createSearch from 'views/logic/slices/createSearch'; import { duplicateWidget, removeWidget } from 'views/logic/slices/widgetActions'; import useViewType from 'views/hooks/useViewType'; import fetchSearch from 'views/logic/views/fetchSearch'; -import AggregationWidget from 'views/logic/aggregationbuilder/AggregationWidget'; import useWidgetResults from 'views/components/useWidgetResults'; import WidgetActionsMenu from './WidgetActionsMenu'; @@ -429,29 +428,5 @@ describe('', () => { /* eslint-enable no-console */ }); }); - - describe('Export aggregation widget', () => { - it('does not display export aggregation action if widget is an aggregation', async () => { - const messagesWidget = MessagesWidget.builder() - .id('widgetId') - .config({}) - .build(); - render(); - const exportButton = screen.queryByRole('button', { name: /open export widget options/i }); - - expect(exportButton).toBeNull(); - }); - - it('allows export for aggregation widget', async () => { - const aggregationWidget = AggregationWidget.builder() - .id('widgetId') - .config({}) - .build(); - - render(); - - await screen.findByRole('button', { name: /open export widget options/i }); - }); - }); }); }); diff --git a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx index b635fc381be5..4dfbfe0b5c51 100644 --- a/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx +++ b/graylog2-web-interface/src/views/components/widgets/WidgetActionsMenu.tsx @@ -49,13 +49,10 @@ import useLocation from 'routing/useLocation'; import useParameters from 'views/hooks/useParameters'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import ExtractWidgetIntoNewView from 'views/logic/views/ExtractWidgetIntoNewView'; -import type { Extension, Result } from 'util/AggregationWidgetExportUtils'; -import { exportWidget } from 'util/AggregationWidgetExportUtils'; -import useWidgetResults from 'views/components/useWidgetResults'; -import ExportWidgetAction from 'views/components/widgets/ExportWidgetAction/ExportWidgetAction'; +import ExtraMenuWidgetActions from 'views/components/widgets/ExtraMenuWidgetActions'; import ReplaySearchButton from './ReplaySearchButton'; -import ExtraWidgetActions from './ExtraWidgetActions'; +import ExtraDropdownWidgetActions from './ExtraDropdownWidgetActions'; import CopyToDashboard from './CopyToDashboardForm'; import MoveWidgetToTabModal from './MoveWidgetToTabModal'; import WidgetActionDropdown from './WidgetActionDropdown'; @@ -158,7 +155,6 @@ const WidgetActionsMenu = ({ }: Props) => { const widget = useContext(WidgetContext); const view = useView(); - const { widgetData: widgetResult } = useWidgetResults(widget.id); const { query, timerange, streams } = useContext(DrilldownContext); const { setWidgetFocusing, unsetWidgetFocusing } = useContext(WidgetFocusContext); const [showCopyToDashboard, setShowCopyToDashboard] = useState(false); @@ -227,20 +223,6 @@ const WidgetActionsMenu = ({ return setWidgetFocusing(widget.id); }, [pathname, sendTelemetry, setWidgetFocusing, widget.id]); - const onExportAggregationWidget = useCallback((extension: Extension) => { - sendTelemetry(TELEMETRY_EVENT_TYPE.SEARCH_WIDGET_ACTION.EXPORT, { - app_pathname: getPathnameWithoutId(pathname), - app_section: 'search-widget', - app_action_value: { extension }, - }); - - const widgetTitle = view.getWidgetTitleByWidget(widget); - - return exportWidget(widgetTitle, (widgetResult as {chart: Result}).chart as Result, extension); - }, [view, widget, widgetResult, sendTelemetry, pathname]); - - const showMessageExportModal = () => setShowExport(true); - return ( @@ -251,7 +233,7 @@ const WidgetActionsMenu = ({ parameterBindings={parameterBindings} parameters={parameters} /> - + {isFocused && ( - + Delete diff --git a/graylog2-web-interface/src/views/components/widgets/events/EventsList/EventDetails.test.tsx b/graylog2-web-interface/src/views/components/widgets/events/EventsList/EventDetails.test.tsx index a01b0f9ae1d2..a3457d031824 100644 --- a/graylog2-web-interface/src/views/components/widgets/events/EventsList/EventDetails.test.tsx +++ b/graylog2-web-interface/src/views/components/widgets/events/EventsList/EventDetails.test.tsx @@ -36,7 +36,7 @@ describe('EventDetails', () => { beforeEach(() => { asMock(usePluginEntities).mockReturnValue([]); asMock(useCurrentUser).mockReturnValue(adminUser); - asMock(useEventDefinition).mockReturnValue({ data: undefined, isFetching: false }); + asMock(useEventDefinition).mockReturnValue({ data: undefined, isFetching: false, isInitialLoading: false }); asMock(useEventById).mockImplementation(() => ({ data: mockEventData.event, diff --git a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.tsx b/graylog2-web-interface/src/views/components/widgets/useWidgetExportActionComponent.ts similarity index 55% rename from graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.tsx rename to graylog2-web-interface/src/views/components/widgets/useWidgetExportActionComponent.ts index 9ae482aac4b7..9e85bebc646e 100644 --- a/graylog2-web-interface/src/views/components/widgets/ExportWidgetAction/AggregationWidgetExportDropdown.tsx +++ b/graylog2-web-interface/src/views/components/widgets/useWidgetExportActionComponent.ts @@ -14,19 +14,12 @@ * along with this program. If not, see * . */ -import * as React from 'react'; +import usePluginEntities from 'hooks/usePluginEntities'; -import ActionDropdown from 'views/components/common/ActionDropdown'; -import { IconButton } from 'components/common'; +const useWidgetExportActionComponent = () => { + const exportAction = usePluginEntities('views.components.widgets.exportAction')?.[0]; -const AggregationWidgetExportDropdown = ({ children }: React.PropsWithChildren) => { - const widgetActionDropdownCaret = ; - - return ( - - {children} - - ); + return exportAction && exportAction(); }; -export default AggregationWidgetExportDropdown; +export default useWidgetExportActionComponent; diff --git a/graylog2-web-interface/src/views/types.ts b/graylog2-web-interface/src/views/types.ts index 4a985273a320..121869ad7ac7 100644 --- a/graylog2-web-interface/src/views/types.ts +++ b/graylog2-web-interface/src/views/types.ts @@ -22,7 +22,7 @@ import type { Reducer, AnyAction } from '@reduxjs/toolkit'; import type Widget from 'views/logic/widgets/Widget'; import type { ActionDefinition } from 'views/components/actions/ActionHandler'; import type { VisualizationComponent } from 'views/components/aggregationbuilder/AggregationBuilder'; -import type { WidgetActionType } from 'views/components/widgets/Types'; +import type { WidgetActionType, WidgetMenuActionComponentProps } from 'views/components/widgets/Types'; import type { Creator } from 'views/components/sidebar/create/AddWidgetButton'; import type { ViewHook } from 'views/logic/hooks/ViewHook'; import type WidgetConfig from 'views/logic/widgets/WidgetConfig'; @@ -474,6 +474,7 @@ declare module 'graylog-web-plugin/plugin' { key: string, }>; 'views.components.widgets.events.actions'?: Array; + 'views.components.widgets.exportAction'?: Array<() => React.ComponentType | null>; 'views.components.searchActions'?: Array; 'views.components.searchBar'?: Array<() => SearchBarControl | null>; 'views.components.saveViewForm'?: Array<() => SaveViewControls | null>; diff --git a/graylog2-web-interface/test/helpers/mocking/EventAndEventDefinitions_mock.ts b/graylog2-web-interface/test/helpers/mocking/EventAndEventDefinitions_mock.ts index 91f318f4fc91..c78371985caf 100644 --- a/graylog2-web-interface/test/helpers/mocking/EventAndEventDefinitions_mock.ts +++ b/graylog2-web-interface/test/helpers/mocking/EventAndEventDefinitions_mock.ts @@ -58,7 +58,7 @@ export const mockEventData = { key_tuple: [], key: null, priority: 2, - fields: [], + fields: {}, replay_info: { timerange_start: '2023-03-02T13:42:21.266Z', timerange_end: '2023-03-02T13:43:21.266Z', diff --git a/graylog2-web-interface/yarn.lock b/graylog2-web-interface/yarn.lock index 7359b5e97c63..fea8090c9b44 100644 --- a/graylog2-web-interface/yarn.lock +++ b/graylog2-web-interface/yarn.lock @@ -1993,10 +1993,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== -"@graylog/sawmill@2.0.16": - version "2.0.16" - resolved "https://registry.yarnpkg.com/@graylog/sawmill/-/sawmill-2.0.16.tgz#3d657d5713c62aa10db9423dec3d48d5c1e350f5" - integrity sha512-q/aLuEhdBSM1huQPRcmLBYmAdxzrnI6uEye7gf1fZbyov4k94VrFzJMdQ7Bvbop+X7R9w6LHoG+PgT+U2Y94ag== +"@graylog/sawmill@2.0.17": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@graylog/sawmill/-/sawmill-2.0.17.tgz#32f14647422436c1854d50724bf455784e4287ec" + integrity sha512-+RYHn30ZIYV16mqqhS/2nKtBsLBtwaplqQaPe2eOOHq45orP70BrRbi6yD1Fg3wDFlVZ+g0fzBYxP+eiMsZ7nA== dependencies: "@openfonts/dm-sans_latin" "^1.0.2" "@openfonts/source-sans-pro_latin" "^1.44.2" @@ -2771,10 +2771,10 @@ resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz#c06dac2d011f36d61259aa1c6df4f0d5e28bc55e" integrity sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg== -"@react-hook/resize-observer@^1.2.5": - version "1.2.6" - resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz#9a8cf4c5abb09becd60d1d65f6bf10eec211e291" - integrity sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA== +"@react-hook/resize-observer@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-2.0.1.tgz#b1b090be25c74d89b371183e5e2bc0d03f94986f" + integrity sha512-9PCX9grWfxdPizY8ohr+X4IkV1JhGMWr2Nm4ngbg6IcAIv0WBs7YoJcNBqYl22OqPHr5eOMItGcStZrmj2mbmQ== dependencies: "@juggle/resize-observer" "^3.3.1" "@react-hook/latest" "^1.0.2" @@ -6270,10 +6270,10 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -devtools-protocol@0.0.1273771: - version "0.0.1273771" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1273771.tgz#46aeb5db41417e2c2ad3d8367c598c975290b1a5" - integrity sha512-QDbb27xcTVReQQW/GHJsdQqGKwYBE7re7gxehj467kKP2DKuYBUj6i2k5LRiAC66J1yZG/9gsxooz/s9pcm0Og== +devtools-protocol@0.0.1286932: + version "0.0.1286932" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1286932.tgz#2303707034426fe0b39012713d4b7339f7dbc815" + integrity sha512-wu58HMQll9voDjR4NlPyoDEw1syfzaBNHymMMZ/QOXiHRNluOnDgu9hp1yHOKYoMlxCh4lSSiugLITe6Fvu1eA== diff-match-patch@^1.0.5: version "1.0.5" @@ -8631,7 +8631,7 @@ graphemer@^1.4.0: "graylog-web-plugin@file:packages/graylog-web-plugin": version "6.1.0-SNAPSHOT" dependencies: - "@graylog/sawmill" "2.0.16" + "@graylog/sawmill" "2.0.17" "@tanstack/react-query" "4.36.1" "@types/create-react-class" "15.6.8" "@types/jquery" "3.5.30" @@ -12723,26 +12723,26 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -puppeteer-core@22.8.1: - version "22.8.1" - resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-22.8.1.tgz#757ec8983ca38486dad8e5464e744f4b8aff5a13" - integrity sha512-m1F6ZSTw1xrJ6xD4B+HonkSNVQmMrRMaqca/ivRcZYJ6jqzOnfEh3QgO9HpNPj6heiAZ2+4IPAU3jdZaTIDnSA== +puppeteer-core@22.9.0: + version "22.9.0" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-22.9.0.tgz#df7ef6c285f4eed938911322184f9cd21d892b39" + integrity sha512-Q2SYVZ1SIE7jCd/Pp+1/mNLFtdJfGvAF+CqOTDG8HcCNCiBvoXfopXfOfMHQ/FueXhGfJW/I6DartWv6QzpNGg== dependencies: "@puppeteer/browsers" "2.2.3" chromium-bidi "0.5.19" debug "4.3.4" - devtools-protocol "0.0.1273771" + devtools-protocol "0.0.1286932" ws "8.17.0" puppeteer@^22.0.0: - version "22.8.1" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-22.8.1.tgz#d0b96cd722f62a157804dcc3b0d4909e3620bf1d" - integrity sha512-CFgPSKV+iydjO/8/hJVj251Hqp2PLcIa70j6H7sYqkwM8YJ+D3CA74Ufuj+yKtvDIntQPB/nLw4EHrHPcHOPjw== + version "22.9.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-22.9.0.tgz#ef404de6c1f6a5b32e01ede20258acf4aa826bf9" + integrity sha512-yNux2cm6Sfik4lNLNjJ25Cdn9spJRbMXxl1YZtVZCEhEeej1sFlCvZ/Cr64LhgyJOuvz3iq2uk+RLFpQpGwrjw== dependencies: "@puppeteer/browsers" "2.2.3" cosmiconfig "9.0.0" - devtools-protocol "0.0.1273771" - puppeteer-core "22.8.1" + devtools-protocol "0.0.1286932" + puppeteer-core "22.9.0" pure-rand@^6.0.0: version "6.0.1"