diff --git a/changelog/unreleased/issue-19058.toml b/changelog/unreleased/issue-19058.toml new file mode 100644 index 000000000000..b673bf48a302 --- /dev/null +++ b/changelog/unreleased/issue-19058.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "Fixing highlighting of message in message table by id." + +issues = ["19058"] +pulls = ["21389"] diff --git a/changelog/unreleased/issue-19975.toml b/changelog/unreleased/issue-19975.toml new file mode 100644 index 000000000000..eaf08be03bd1 --- /dev/null +++ b/changelog/unreleased/issue-19975.toml @@ -0,0 +1,5 @@ +type = "f" +message = "Catch and report exceptions during grok pattern matching." + +issues=["19975"] +pulls = ["21290"] diff --git a/changelog/unreleased/issue-21185.toml b/changelog/unreleased/issue-21185.toml new file mode 100644 index 000000000000..e937b69565f6 --- /dev/null +++ b/changelog/unreleased/issue-21185.toml @@ -0,0 +1,5 @@ +type = "f" +message = "Fix displaying very small percentages." + +issues = ["21185"] +pulls = ["21368"] diff --git a/changelog/unreleased/pr-21123.toml b/changelog/unreleased/pr-21123.toml new file mode 100644 index 000000000000..3bb5a7f62244 --- /dev/null +++ b/changelog/unreleased/pr-21123.toml @@ -0,0 +1,4 @@ +type = "c" +message = "Adjust Event Priority field choices for consistency with Graylog Security." + +pulls = ["21123"] diff --git a/changelog/unreleased/pr-21367.toml b/changelog/unreleased/pr-21367.toml new file mode 100644 index 000000000000..cf04c915e64e --- /dev/null +++ b/changelog/unreleased/pr-21367.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "Adding missing API descriptions on method parameters for Simple Scripting API." + +pulls = ["21367"] +issues = ["20821"] diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/db/mongodb/MongoDbPipelineStreamConnectionsService.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/db/mongodb/MongoDbPipelineStreamConnectionsService.java index dea5c3071a68..b38d7a0653fa 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/db/mongodb/MongoDbPipelineStreamConnectionsService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/db/mongodb/MongoDbPipelineStreamConnectionsService.java @@ -17,23 +17,18 @@ package org.graylog.plugins.pipelineprocessor.db.mongodb; import com.google.common.collect.ImmutableSet; -import com.mongodb.BasicDBObject; -import com.mongodb.DBObject; -import com.mongodb.MongoException; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; import com.swrve.ratelimitedlogger.RateLimitedLog; import jakarta.inject.Inject; import org.graylog.plugins.pipelineprocessor.db.PipelineStreamConnectionsService; import org.graylog.plugins.pipelineprocessor.events.PipelineConnectionsChangedEvent; import org.graylog.plugins.pipelineprocessor.rest.PipelineConnections; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; -import org.graylog2.database.MongoConnection; +import org.graylog2.database.MongoCollections; import org.graylog2.database.NotFoundException; +import org.graylog2.database.utils.MongoUtils; import org.graylog2.events.ClusterEventBus; -import org.mongojack.DBCursor; -import org.mongojack.DBQuery; -import org.mongojack.DBSort; -import org.mongojack.JacksonDBCollection; -import org.mongojack.WriteResult; import java.util.Collection; import java.util.Collections; @@ -41,6 +36,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static com.mongodb.client.model.Filters.eq; +import static com.mongodb.client.model.Filters.in; import static org.graylog.plugins.pipelineprocessor.processors.PipelineInterpreter.getRateLimitedLog; public class MongoDbPipelineStreamConnectionsService implements PipelineStreamConnectionsService { @@ -48,34 +45,31 @@ public class MongoDbPipelineStreamConnectionsService implements PipelineStreamCo private static final String COLLECTION = "pipeline_processor_pipelines_streams"; - private final JacksonDBCollection dbCollection; private final ClusterEventBus clusterBus; + private final MongoCollection collection; + private final MongoUtils mongoUtils; @Inject - public MongoDbPipelineStreamConnectionsService(MongoConnection mongoConnection, - MongoJackObjectMapperProvider mapper, - ClusterEventBus clusterBus) { - this.dbCollection = JacksonDBCollection.wrap( - mongoConnection.getDatabase().getCollection(COLLECTION), - PipelineConnections.class, - String.class, - mapper.get()); + public MongoDbPipelineStreamConnectionsService(MongoCollections mongoCollections, ClusterEventBus clusterBus) { this.clusterBus = clusterBus; - dbCollection.createIndex(DBSort.asc("stream_id"), new BasicDBObject("unique", true)); + this.collection = mongoCollections.collection(COLLECTION, PipelineConnections.class); + this.mongoUtils = mongoCollections.utils(collection); + + collection.createIndex(Indexes.ascending("stream_id"), new IndexOptions().unique(true)); } @Override public PipelineConnections save(PipelineConnections connections) { - PipelineConnections existingConnections = dbCollection.findOne(DBQuery.is("stream_id", connections.streamId())); + PipelineConnections existingConnections = collection.find(eq("stream_id", connections.streamId())) + .first(); if (existingConnections == null) { existingConnections = PipelineConnections.create(null, connections.streamId(), Collections.emptySet()); } final PipelineConnections toSave = existingConnections.toBuilder() .pipelineIds(connections.pipelineIds()).build(); - final WriteResult save = dbCollection.save(toSave); - final PipelineConnections savedConnections = save.getSavedObject(); + final PipelineConnections savedConnections = mongoUtils.save(toSave); clusterBus.post(PipelineConnectionsChangedEvent.create(savedConnections.streamId(), savedConnections.pipelineIds())); return savedConnections; @@ -83,34 +77,21 @@ public PipelineConnections save(PipelineConnections connections) { @Override public PipelineConnections load(String streamId) throws NotFoundException { - final PipelineConnections oneById = dbCollection.findOne(DBQuery.is("stream_id", streamId)); + final PipelineConnections oneById = collection.find(eq("stream_id", streamId)).first(); if (oneById == null) { - throw new NotFoundException("No pipeline connections with for stream " + streamId); + throw new NotFoundException("No pipeline connections for stream " + streamId); } return oneById; } @Override public Set loadAll() { - try (DBCursor connections = dbCollection.find()) { - return ImmutableSet.copyOf((Iterable) connections); - } catch (MongoException e) { - log.error("Unable to load pipeline connections", e); - return Collections.emptySet(); - } + return ImmutableSet.copyOf(collection.find()); } @Override public Set loadByPipelineId(String pipelineId) { - // Thanks, MongoJack! - // https://github.com/mongojack/mongojack/issues/12 - final DBObject query = new BasicDBObject("pipeline_ids", new BasicDBObject("$in", Collections.singleton(pipelineId))); - try (DBCursor pipelineConnections = dbCollection.find(query)) { - return ImmutableSet.copyOf((Iterable) pipelineConnections); - } catch (MongoException e) { - log.error("Unable to load pipeline connections for pipeline ID " + pipelineId, e); - return Collections.emptySet(); - } + return ImmutableSet.copyOf(collection.find(in("pipeline_ids", pipelineId))); } @Override @@ -119,16 +100,17 @@ public void delete(String streamId) { final PipelineConnections connections = load(streamId); final Set pipelineIds = connections.pipelineIds(); - dbCollection.removeById(connections.id()); + mongoUtils.deleteById(connections.id()); clusterBus.post(PipelineConnectionsChangedEvent.create(streamId, pipelineIds)); } catch (NotFoundException e) { - log.debug("No connections found for stream " + streamId); + log.debug("No connections found for stream {}", streamId); } } @Override public Map loadByStreamIds(Collection streamIds) { - return dbCollection.find(DBQuery.in("stream_id", streamIds)).toArray().stream() - .collect(Collectors.toMap(PipelineConnections::streamId, conn -> conn)); + try (final var stream = MongoUtils.stream(collection.find(in("stream_id", streamIds)))) { + return stream.collect(Collectors.toMap(PipelineConnections::streamId, conn -> conn)); + } } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/GrokMatch.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/GrokMatch.java index 0fbf84019103..425e07ccbdfd 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/GrokMatch.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/GrokMatch.java @@ -19,6 +19,7 @@ import com.google.common.collect.ForwardingMap; import io.krakens.grok.api.Grok; import io.krakens.grok.api.Match; +import jakarta.inject.Inject; import org.graylog.plugins.pipelineprocessor.EvaluationContext; import org.graylog.plugins.pipelineprocessor.ast.functions.AbstractFunction; import org.graylog.plugins.pipelineprocessor.ast.functions.FunctionArgs; @@ -26,8 +27,6 @@ import org.graylog.plugins.pipelineprocessor.ast.functions.ParameterDescriptor; import org.graylog2.grok.GrokPatternRegistry; -import jakarta.inject.Inject; - import java.util.Map; import static com.google.common.collect.ImmutableList.of; @@ -63,8 +62,12 @@ public GrokResult evaluate(FunctionArgs args, EvaluationContext context) { final Grok grok = grokPatternRegistry.cachedGrokForPattern(pattern, onlyNamedCaptures); - final Match match = grok.match(value); - return new GrokResult(match.captureFlattened()); + try { + final Match match = grok.match(value); + return new GrokResult(match.captureFlattened()); + } catch (StackOverflowError e) { + throw new IllegalStateException("Stack overflow during grok pattern matching"); + } } @Override diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/RegexMatch.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/RegexMatch.java index 26ca64ce0b4f..638fe572a61b 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/RegexMatch.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/functions/strings/RegexMatch.java @@ -60,10 +60,14 @@ public RegexMatchResult evaluate(FunctionArgs args, EvaluationContext context) { final List groupNames = (List) optionalGroupNames.optional(args, context).orElse(Collections.emptyList()); - final Matcher matcher = regex.matcher(value); - final boolean matches = matcher.find(); + try { + final Matcher matcher = regex.matcher(value); + final boolean matches = matcher.find(); - return new RegexMatchResult(matches, matcher.toMatchResult(), groupNames); + return new RegexMatchResult(matches, matcher.toMatchResult(), groupNames); + } catch (StackOverflowError e) { + throw new IllegalStateException("Stack overflow during regex pattern matching"); + } } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rest/PipelineConnections.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rest/PipelineConnections.java index 10524ad6b778..35b49eae048a 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rest/PipelineConnections.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rest/PipelineConnections.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; +import org.graylog2.database.BuildableMongoEntity; +import org.graylog2.database.MongoEntity; import org.mongojack.Id; import org.mongojack.ObjectId; @@ -28,7 +30,8 @@ @AutoValue @JsonAutoDetect -public abstract class PipelineConnections { +public abstract class PipelineConnections implements MongoEntity, + BuildableMongoEntity { @JsonProperty("id") @Nullable @@ -60,7 +63,7 @@ public static Builder builder() { public abstract Builder toBuilder(); @AutoValue.Builder - public abstract static class Builder { + public abstract static class Builder implements BuildableMongoEntity.Builder { public abstract PipelineConnections build(); public abstract Builder id(String id); diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/MongoDBRuleFragmentService.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/MongoDBRuleFragmentService.java index 9a41fafb1804..86dea0921c75 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/MongoDBRuleFragmentService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/MongoDBRuleFragmentService.java @@ -16,92 +16,63 @@ */ package org.graylog.plugins.pipelineprocessor.rulebuilder.db; -import com.google.common.collect.ImmutableSet; -import com.mongodb.BasicDBObject; -import com.mongodb.MongoException; -import com.swrve.ratelimitedlogger.RateLimitedLog; -import org.bson.types.ObjectId; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; -import org.graylog2.database.MongoConnection; -import org.mongojack.DBCursor; -import org.mongojack.DBQuery; -import org.mongojack.DBSort; -import org.mongojack.JacksonDBCollection; - +import com.google.common.collect.ImmutableList; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.Sorts; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.graylog2.database.MongoCollections; +import org.graylog2.database.utils.MongoUtils; import java.util.Collection; -import java.util.Collections; -import java.util.Objects; import java.util.Optional; -import static org.graylog.plugins.pipelineprocessor.processors.PipelineInterpreter.getRateLimitedLog; +import static com.mongodb.client.model.Filters.eq; @Singleton public class MongoDBRuleFragmentService implements RuleFragmentService { - - private static final RateLimitedLog log = getRateLimitedLog(MongoDBRuleFragmentService.class); - public static final String COLLECTION_NAME = "rule_fragments"; - private final JacksonDBCollection dbCollection; + private final MongoCollection collection; + private final MongoUtils mongoUtils; @Inject - public MongoDBRuleFragmentService( - final MongoJackObjectMapperProvider objectMapperProvider, - final MongoConnection mongoConnection - ) { - this(JacksonDBCollection.wrap( - mongoConnection.getDatabase().getCollection(COLLECTION_NAME), - RuleFragment.class, - ObjectId.class, - objectMapperProvider.get()) - ); - } - - - public MongoDBRuleFragmentService(JacksonDBCollection dbCollection) { - this.dbCollection = Objects.requireNonNull(dbCollection); - - this.dbCollection.createIndex(new BasicDBObject("name", 1), new BasicDBObject("unique", true)); + public MongoDBRuleFragmentService(MongoCollections mongoCollections) { + collection = mongoCollections.collection(COLLECTION_NAME, RuleFragment.class); + mongoUtils = mongoCollections.utils(collection); + collection.createIndex(Indexes.ascending("name"), new IndexOptions().unique(true)); } @Override public RuleFragment save(RuleFragment ruleFragment) { - return dbCollection.save(ruleFragment).getSavedObject(); + return mongoUtils.save(ruleFragment); } @Override public void delete(String name) { - dbCollection.remove(DBQuery.is("name", name)); + collection.deleteOne(eq("name", name)); } @Override public void deleteAll() { - dbCollection.remove(DBQuery.empty()); + collection.deleteMany(Filters.empty()); } - @Override public long count(String name) { - return dbCollection.getCount(DBQuery.is("name", name)); + return collection.countDocuments(eq("name", name)); } @Override public Optional get(String name) { - return Optional.ofNullable(dbCollection.findOne(DBQuery.is("name", name))); + return Optional.ofNullable(collection.find(eq("name", name)).first()); } @Override public Collection all() { - try (DBCursor ruleDaos = dbCollection.find().sort(DBSort.asc("title"))) { - return ImmutableSet.copyOf((Iterable) ruleDaos); - } catch (MongoException e) { - log.error("Unable to load rule fragments", e); - return Collections.emptySet(); - } + return ImmutableList.copyOf(collection.find().sort(Sorts.ascending("title"))); } - - } diff --git a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/RuleFragment.java b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/RuleFragment.java index 6afc888756de..af660bdcd53f 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/RuleFragment.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/pipelineprocessor/rulebuilder/db/RuleFragment.java @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.google.auto.value.AutoValue; import org.graylog.plugins.pipelineprocessor.ast.functions.FunctionDescriptor; +import org.graylog2.database.BuildableMongoEntity; +import org.graylog2.database.MongoEntity; import org.mongojack.Id; import org.mongojack.ObjectId; @@ -30,7 +32,7 @@ @AutoValue @JsonIgnoreProperties(value = {"name"}, allowGetters = true) -public abstract class RuleFragment { +public abstract class RuleFragment implements MongoEntity, BuildableMongoEntity { public static final String FIELD_NAME = "name"; public static final String FIELD_FRAGMENT = "fragment"; @@ -63,14 +65,12 @@ public String getName() { @JsonProperty(FIELD_DESCRIPTOR) public abstract FunctionDescriptor descriptor(); - public static Builder builder() { return new AutoValue_RuleFragment.Builder().isCondition(false); } - @AutoValue.Builder - public abstract static class Builder { + public abstract static class Builder implements BuildableMongoEntity.Builder { public abstract Builder id(String id); diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/db/SearchesCleanUpJob.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/db/SearchesCleanUpJob.java index dfacf952d881..e05d4e5aaa68 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/db/SearchesCleanUpJob.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/db/SearchesCleanUpJob.java @@ -16,6 +16,8 @@ */ package org.graylog.plugins.views.search.db; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.graylog.plugins.views.search.views.ViewResolver; import org.graylog.plugins.views.search.views.ViewSummaryDTO; import org.graylog.plugins.views.search.views.ViewSummaryService; @@ -25,13 +27,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; -import jakarta.inject.Named; - import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; public class SearchesCleanUpJob extends Periodical { private static final Logger LOG = LoggerFactory.getLogger(SearchesCleanUpJob.class); @@ -104,7 +104,9 @@ public void doRun() { private Set findReferencedSearchIds() { final HashSet toKeepViewIds = new HashSet<>(); - toKeepViewIds.addAll(viewSummaryService.streamAll().map(ViewSummaryDTO::searchId).collect(Collectors.toSet())); + try (final Stream stream = viewSummaryService.streamAll()) { + toKeepViewIds.addAll(stream.map(ViewSummaryDTO::searchId).collect(Collectors.toSet())); + } toKeepViewIds.addAll(viewResolvers .values().stream().flatMap(vr -> vr.getSearchIds().stream()).collect(Collectors.toSet())); toKeepViewIds.addAll(staticReferencedSearches.stream().map(StaticReferencedSearch::id).toList()); diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/ScriptingApiResource.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/ScriptingApiResource.java index a04cf90dcd23..bc4b5720e9cf 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/ScriptingApiResource.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/ScriptingApiResource.java @@ -117,15 +117,15 @@ public TabularResponse executeQuery(@ApiParam(name = "queryRequestSpec") @Valid @ApiOperation(value = "Execute query specified by query parameters", nickname = "messagesByQueryParameters") @Path("messages") @NoAuditEvent("Creating audit event manually in method body.") - public TabularResponse executeQuery(@ApiParam(name = "query") @QueryParam("query") String query, - @ApiParam(name = "streams") @QueryParam("streams") Set streams, - @ApiParam(name = "stream_categories") @QueryParam("stream_categories") Set streamCategories, - @ApiParam(name = "timerange") @QueryParam("timerange") String timerangeKeyword, - @ApiParam(name = "fields") @QueryParam("fields") List fields, - @ApiParam(name = "sort") @QueryParam("sort") String sort, - @ApiParam(name = "sort") @QueryParam("sortOrder") SortSpec.Direction sortOrder, - @ApiParam(name = "from") @QueryParam("from") int from, - @ApiParam(name = "size") @QueryParam("size") int size, + public TabularResponse executeQuery(@ApiParam(name = "query", value = "Query (Lucene syntax)", required = true) @QueryParam("query") String query, + @ApiParam(name = "streams", value = "Comma separated list of streams to search in") Set streams, + @ApiParam(name = "stream_categories", value = "Comma separated list of streams categories to search in") @QueryParam("stream_categories") Set streamCategories, + @ApiParam(name = "timerange", value = "Timeframe to search in. See method description.", required = true) @QueryParam("timerange") String timerangeKeyword, + @ApiParam(name = "fields", value = "Fields from the message to show as columns in result") @QueryParam("fields") List fields, + @ApiParam(name = "sort", value = "Field to sort on") @QueryParam("sort") String sort, + @ApiParam(name = "sortOrder", value = "Sort order - asc/desc") @QueryParam("sortOrder") SortSpec.Direction sortOrder, + @ApiParam(name = "from", value = "For paging results. Starting from result") @QueryParam("from") int from, + @ApiParam(name = "size", value = "Page size") @QueryParam("size") int size, @Context SearchUser searchUser) { try { @@ -171,12 +171,12 @@ public TabularResponse executeQuery(@ApiParam(name = "searchRequestSpec") @Valid @ApiOperation(value = "Execute aggregation specified by query parameters", nickname = "aggregateForQueryParameters") @Path("aggregate") @NoAuditEvent("Creating audit event manually in method body.") - public TabularResponse executeQuery(@ApiParam(name = "query") @QueryParam("query") String query, - @ApiParam(name = "streams") @QueryParam("streams") Set streams, - @ApiParam(name = "stream_categories") @QueryParam("stream_categories") Set streamCategories, - @ApiParam(name = "timerange") @QueryParam("timerange") String timerangeKeyword, - @ApiParam(name = "groups") @QueryParam("groups") List groups, - @ApiParam(name = "metrics") @QueryParam("metrics") List metrics, + public TabularResponse executeQuery(@ApiParam(name = "query", value = "Query (Lucene syntax)", required = true) @QueryParam("query") String query, + @ApiParam(name = "streams", value = "Comma separated list of streams to search in (can be empty)", required = true) @QueryParam("streams") Set streams, + @ApiParam(name = "stream_categories", value = "Comma separated list of streams categories to search in (can be empty)", required = true) @QueryParam("stream_categories") Set streamCategories, + @ApiParam(name = "timerange", value = "Timeframe to search in. See method description.", required = true) @QueryParam("timerange") String timerangeKeyword, + @ApiParam(name = "group_by", value = "Group aggregation by fields/limits.", required = true) @QueryParam("groups") List groups, + @ApiParam(name = "metrics", value = "Metrics to be used.", required = true) @QueryParam("metrics") List metrics, @Context SearchUser searchUser) { try { AggregationRequestSpec aggregationRequestSpec = queryParamsToFullRequestSpecificationMapper.simpleQueryParamsToFullRequestSpecification( diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/views/ViewSummaryService.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/views/ViewSummaryService.java index 8986d299ea7b..9e3f306e2fa9 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/views/ViewSummaryService.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/views/ViewSummaryService.java @@ -16,33 +16,34 @@ */ package org.graylog.plugins.views.search.views; +import com.google.errorprone.annotations.MustBeClosed; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.Filters; import jakarta.inject.Inject; import org.bson.conversions.Bson; import org.graylog.plugins.views.search.permissions.SearchUser; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.database.MongoCollections; -import org.graylog2.database.MongoConnection; -import org.graylog2.database.PaginatedDbService; import org.graylog2.database.PaginatedList; +import org.graylog2.database.utils.MongoUtils; import org.graylog2.rest.models.SortOrder; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; +import java.util.stream.Stream; import static com.google.common.base.Preconditions.checkNotNull; -public class ViewSummaryService extends PaginatedDbService implements ViewUtils { +public class ViewSummaryService implements ViewUtils { private static final String COLLECTION_NAME = "views"; + private final MongoCollection collection; + private final MongoUtils mongoUtils; @Inject - protected ViewSummaryService(MongoConnection mongoConnection, - MongoJackObjectMapperProvider mongoJackObjectMapperProvider, - MongoCollections mongoCollections) { - super(mongoConnection, mongoJackObjectMapperProvider, ViewSummaryDTO.class, COLLECTION_NAME); - this.collection = mongoCollections.collection(COLLECTION_NAME, ViewSummaryDTO.class); + protected ViewSummaryService(MongoCollections mongoCollections) { + collection = mongoCollections.collection(COLLECTION_NAME, ViewSummaryDTO.class); + mongoUtils = mongoCollections.utils(collection); } public PaginatedList searchPaginatedByType(SearchUser searchUser, @@ -76,7 +77,7 @@ public PaginatedList searchPaginatedByType(SearchUser searchUser .toList() : views; - final long grandTotal = db.getCount(Filters.or(Filters.eq(ViewDTO.FIELD_TYPE, type), Filters.not(Filters.exists(ViewDTO.FIELD_TYPE)))); + final long grandTotal = collection.countDocuments(Filters.or(Filters.eq(ViewDTO.FIELD_TYPE, type), Filters.not(Filters.exists(ViewDTO.FIELD_TYPE)))); return new PaginatedList<>(paginatedStreams, views.size(), page, perPage, grandTotal); } @@ -85,4 +86,13 @@ public PaginatedList searchPaginatedByType(SearchUser searchUser public MongoCollection collection() { return collection; } + + @MustBeClosed + public Stream streamAll() { + return MongoUtils.stream(collection.find()); + } + + public Optional get(String id) { + return mongoUtils.getById(id); + } } diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceService.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceService.java index 95db2a03e857..c6f1a7f97124 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceService.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceService.java @@ -17,97 +17,77 @@ package org.graylog2.contentpacks; import com.google.common.collect.ImmutableSet; -import com.mongodb.BasicDBObject; -import com.mongodb.BasicDBObjectBuilder; -import com.mongodb.DBObject; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Indexes; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.bson.types.ObjectId; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.contentpacks.model.ContentPackInstallation; import org.graylog2.contentpacks.model.ModelId; import org.graylog2.contentpacks.model.entities.NativeEntityDescriptor; -import org.graylog2.database.MongoConnection; +import org.graylog2.database.MongoCollections; +import org.graylog2.database.utils.MongoUtils; import org.graylog2.rest.models.system.contentpacks.responses.ContentPackMetadata; -import org.mongojack.DBCursor; -import org.mongojack.DBQuery; -import org.mongojack.JacksonDBCollection; -import org.mongojack.WriteResult; - -import jakarta.inject.Inject; -import jakarta.inject.Singleton; import java.util.HashMap; -import java.util.Iterator; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import static com.mongodb.client.model.Filters.and; +import static com.mongodb.client.model.Filters.eq; +import static org.graylog2.contentpacks.model.ContentPackInstallation.FIELD_CONTENT_PACK_ID; +import static org.graylog2.contentpacks.model.ContentPackInstallation.FIELD_CONTENT_PACK_REVISION; +import static org.graylog2.database.utils.MongoUtils.idEq; + @Singleton public class ContentPackInstallationPersistenceService { public static final String COLLECTION_NAME = "content_packs_installations"; - private final JacksonDBCollection dbCollection; + private final MongoCollection collection; @Inject - public ContentPackInstallationPersistenceService(final MongoJackObjectMapperProvider mapperProvider, - final MongoConnection mongoConnection) { - this(JacksonDBCollection.wrap(mongoConnection.getDatabase().getCollection(COLLECTION_NAME), - ContentPackInstallation.class, ObjectId.class, mapperProvider.get())); - } - - ContentPackInstallationPersistenceService(final JacksonDBCollection dbCollection) { - this.dbCollection = dbCollection; + public ContentPackInstallationPersistenceService(MongoCollections mongoCollections) { + this.collection = mongoCollections.nonEntityCollection(COLLECTION_NAME, ContentPackInstallation.class); - dbCollection.createIndex(new BasicDBObject(ContentPackInstallation.FIELD_CONTENT_PACK_ID, 1)); - dbCollection.createIndex(new BasicDBObject(ContentPackInstallation.FIELD_CONTENT_PACK_ID, 1).append(ContentPackInstallation.FIELD_CONTENT_PACK_REVISION, 1)); + collection.createIndex(Indexes.ascending(FIELD_CONTENT_PACK_ID)); + collection.createIndex(Indexes.ascending(FIELD_CONTENT_PACK_ID, FIELD_CONTENT_PACK_REVISION)); } public Set loadAll() { - try (final DBCursor installations = dbCollection.find()) { - return ImmutableSet.copyOf((Iterator) installations); - } + return ImmutableSet.copyOf(collection.find()); } public Set findByContentPackIds(Set ids) { - final Set stringIds = ids.stream().map(x -> x.toString()).collect(Collectors.toSet()); - final DBObject query = BasicDBObjectBuilder.start() - .push(ContentPackInstallation.FIELD_CONTENT_PACK_ID) - .append("$in", stringIds) - .get(); - final DBCursor result = dbCollection.find(query); - return ImmutableSet.copyOf((Iterable) result); + final Set stringIds = ids.stream().map(ModelId::toString).collect(Collectors.toSet()); + return ImmutableSet.copyOf(collection.find(Filters.in(FIELD_CONTENT_PACK_ID, stringIds))); } public Optional findById(ObjectId id) { - final ContentPackInstallation installation = dbCollection.findOneById(id); - return Optional.ofNullable(installation); + return Optional.ofNullable(collection.find(idEq(id)).first()); } public Set findByContentPackIdAndRevision(ModelId id, int revision) { - final DBQuery.Query query = DBQuery - .is(ContentPackInstallation.FIELD_CONTENT_PACK_ID, id) - .is(ContentPackInstallation.FIELD_CONTENT_PACK_REVISION, revision); - try (final DBCursor installations = dbCollection.find(query)) { - return ImmutableSet.copyOf((Iterator) installations); - } + final var query = and( + eq(FIELD_CONTENT_PACK_ID, id), + eq(FIELD_CONTENT_PACK_REVISION, revision)); + return ImmutableSet.copyOf(collection.find(query)); } public Set findByContentPackId(ModelId id) { - final DBQuery.Query query = DBQuery.is(ContentPackInstallation.FIELD_CONTENT_PACK_ID, id); - try (final DBCursor installations = dbCollection.find(query)) { - return ImmutableSet.copyOf((Iterator) installations); - } + return ImmutableSet.copyOf(collection.find(eq(FIELD_CONTENT_PACK_ID, id))); } public ContentPackInstallation insert(final ContentPackInstallation installation) { - final WriteResult writeResult = dbCollection.insert(installation); - return writeResult.getSavedObject(); + final var savedId = MongoUtils.insertedId(collection.insertOne(installation)); + return installation.toBuilder().id(savedId).build(); } public int deleteById(ObjectId id) { - final WriteResult writeResult = dbCollection.removeById(id); - return writeResult.getN(); + return (int) collection.deleteOne(idEq(id)).getDeletedCount(); } public Map> getInstallationMetadata(Set ids) { @@ -139,15 +119,15 @@ public Map> getInstallationMetadata(S public long countInstallationOfEntityById(ModelId entityId) { final String field = String.format(Locale.ROOT, "%s.%s", ContentPackInstallation.FIELD_ENTITIES, NativeEntityDescriptor.FIELD_META_ID); - return dbCollection.getCount(DBQuery.is(field, entityId)); + return collection.countDocuments(eq(field, entityId)); } public long countInstallationOfEntityByIdAndFoundOnSystem(ModelId entityId) { - final DBQuery.Query query = DBQuery.elemMatch(ContentPackInstallation.FIELD_ENTITIES, - DBQuery.and( - DBQuery.is(NativeEntityDescriptor.FIELD_ENTITY_FOUND_ON_SYSTEM, true), - DBQuery.is(NativeEntityDescriptor.FIELD_META_ID, entityId.id()))); + final var query = Filters.elemMatch(ContentPackInstallation.FIELD_ENTITIES, + and( + eq(NativeEntityDescriptor.FIELD_ENTITY_FOUND_ON_SYSTEM, true), + eq(NativeEntityDescriptor.FIELD_META_ID, entityId.id()))); - return dbCollection.getCount(query); + return collection.countDocuments(query); } } diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackPersistenceService.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackPersistenceService.java index aa2b78a89fba..19e25232526e 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackPersistenceService.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/ContentPackPersistenceService.java @@ -22,12 +22,16 @@ import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; -import com.mongodb.BasicDBObject; -import com.mongodb.DuplicateKeyException; +import com.google.common.primitives.Ints; +import com.mongodb.MongoException; import com.mongodb.client.AggregateIterable; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.bson.Document; import org.bson.conversions.Bson; -import org.bson.types.ObjectId; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.contentpacks.model.ContentPack; import org.graylog2.contentpacks.model.ContentPackV1; @@ -36,19 +40,15 @@ import org.graylog2.contentpacks.model.ModelTypes; import org.graylog2.contentpacks.model.Revisioned; import org.graylog2.contentpacks.model.entities.EntityV1; +import org.graylog2.database.MongoCollections; import org.graylog2.database.MongoConnection; +import org.graylog2.database.utils.MongoUtils; +import org.graylog2.plugin.streams.Stream; import org.graylog2.streams.StreamService; import org.jooq.lambda.tuple.Tuple2; -import org.mongojack.DBCursor; -import org.mongojack.DBQuery; -import org.mongojack.JacksonDBCollection; -import org.mongojack.WriteResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; - import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -58,38 +58,40 @@ import java.util.Set; import java.util.stream.Collectors; +import static com.mongodb.client.model.Filters.and; +import static com.mongodb.client.model.Filters.eq; + @Singleton public class ContentPackPersistenceService { public static final String COLLECTION_NAME = "content_packs"; - private final JacksonDBCollection dbCollection; private static final Logger LOG = LoggerFactory.getLogger(ContentPackPersistenceService.class); private final StreamService streamService; private final MongoConnection mongoConnection; + private final MongoCollection collection; @Inject public ContentPackPersistenceService(final MongoJackObjectMapperProvider mapperProvider, final MongoConnection mongoConnection, final StreamService streamService) { - this(JacksonDBCollection.wrap(mongoConnection.getDatabase().getCollection(COLLECTION_NAME), - ContentPack.class, ObjectId.class, mapperProvider.get()), streamService, mongoConnection); - } - - ContentPackPersistenceService(final JacksonDBCollection dbCollection, final StreamService streamService, - MongoConnection mongoConnection) { - this.dbCollection = dbCollection; this.streamService = streamService; this.mongoConnection = mongoConnection; + this.collection = new MongoCollections(mapperProvider, mongoConnection) + .nonEntityCollection(COLLECTION_NAME, ContentPack.class); try { - dbCollection.createIndex(new BasicDBObject(Identified.FIELD_META_ID, 1).append(Revisioned.FIELD_META_REVISION, 1), new BasicDBObject("unique", true)); - } catch (DuplicateKeyException e) { - // Ignore - this can happen if this runs before the migration of old content packs + collection.createIndex( + Indexes.ascending(Identified.FIELD_META_ID, Revisioned.FIELD_META_REVISION), + new IndexOptions().unique(true)); + } catch (MongoException e) { + // Ignore duplicate key error - this can happen if this runs before the migration of old content packs + if (!MongoUtils.isDuplicateKeyError(e)) { + throw e; + } } } public Set loadAll() { - final DBCursor contentPacks = dbCollection.find(); - return ImmutableSet.copyOf((Iterable) contentPacks); + return ImmutableSet.copyOf(collection.find()); } public Set loadAllLatest() { @@ -111,13 +113,12 @@ public Set loadAllLatest() { } public Set findAllById(ModelId id) { - final DBCursor result = dbCollection.find(DBQuery.is(Identified.FIELD_META_ID, id)); - return ImmutableSet.copyOf((Iterable) result); + return ImmutableSet.copyOf(collection.find(eq(Identified.FIELD_META_ID, id))); } public Optional findByIdAndRevision(ModelId id, int revision) { - final DBQuery.Query query = DBQuery.is(Identified.FIELD_META_ID, id).is(Revisioned.FIELD_META_REVISION, revision); - return Optional.ofNullable(dbCollection.findOne(query)); + final var query = and(eq(Identified.FIELD_META_ID, id), eq(Revisioned.FIELD_META_REVISION, revision)); + return Optional.ofNullable(collection.find(query).first()); } public Optional insert(final ContentPack pack) { @@ -125,14 +126,18 @@ public Optional insert(final ContentPack pack) { LOG.debug("Content pack already found: id: {} revision: {}. Did not insert!", pack.id(), pack.revision()); return Optional.empty(); } - final WriteResult writeResult = dbCollection.insert(pack); - return Optional.of(writeResult.getSavedObject()); + + // The ContentPack interface doesn't allow access to the _id field, therefore we don't bother trying to return + // an object with the field set. Instead, we just return the original pack. + collection.insertOne(pack); + + return Optional.of(pack); } public Optional filterMissingResourcesAndInsert(final ContentPack pack) { ContentPackV1 cpv1 = (ContentPackV1) pack; - final Set allStreams = streamService.loadAll().stream().map(stream -> stream.getTitle()).collect(Collectors.toSet()); + final Set allStreams = streamService.loadAll().stream().map(Stream::getTitle).collect(Collectors.toSet()); final Map streamsInContentPack = new HashMap<>(); cpv1.entities() @@ -170,15 +175,13 @@ public Optional filterMissingResourcesAndInsert(final ContentPack p } public int deleteById(ModelId id) { - final DBQuery.Query query = DBQuery.is(Identified.FIELD_META_ID, id); - final WriteResult writeResult = dbCollection.remove(query); - return writeResult.getN(); + final var query = eq(Identified.FIELD_META_ID, id); + return Ints.saturatedCast(collection.deleteMany(query).getDeletedCount()); } public int deleteByIdAndRevision(ModelId id, int revision) { - final DBQuery.Query query = DBQuery.is(Identified.FIELD_META_ID, id).is(Revisioned.FIELD_META_REVISION, revision); - final WriteResult writeResult = dbCollection.remove(query); - return writeResult.getN(); + final var query = and(eq(Identified.FIELD_META_ID, id), eq(Revisioned.FIELD_META_REVISION, revision)); + return Ints.saturatedCast(collection.deleteMany(query).getDeletedCount()); } public AggregateIterable aggregate(List aggregates) { diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/ViewFacade.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/ViewFacade.java index d83a5e914e4e..538c32935b67 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/ViewFacade.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/facades/ViewFacade.java @@ -22,6 +22,8 @@ import com.google.common.graph.GraphBuilder; import com.google.common.graph.ImmutableGraph; import com.google.common.graph.MutableGraph; +import com.google.errorprone.annotations.MustBeClosed; +import jakarta.inject.Inject; import org.graylog.plugins.views.search.Search; import org.graylog.plugins.views.search.db.SearchDbService; import org.graylog.plugins.views.search.views.ViewDTO; @@ -48,8 +50,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; - import java.util.LinkedHashMap; import java.util.Map; import java.util.NoSuchElementException; @@ -162,9 +162,12 @@ public void delete(ViewDTO nativeEntity) { @Override public Set listEntityExcerpts() { - return getNativeViews().map(this::createExcerpt).collect(Collectors.toSet()); + try (final Stream nativeViews = getNativeViews()) { + return nativeViews.map(this::createExcerpt).collect(Collectors.toSet()); + } } + @MustBeClosed protected Stream getNativeViews() { return viewSummaryService.streamAll().filter(v -> v.type().equals(this.getDTOType())); } diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPack.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPack.java index 1719d507ac25..543625805451 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPack.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPack.java @@ -16,11 +16,11 @@ */ package org.graylog2.contentpacks.model; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.graylog2.contentpacks.model.constraints.Constraint; -import java.util.Set; +import java.net.URI; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = Versioned.FIELD_META_VERSION, defaultImpl = LegacyContentPack.class) @JsonSubTypes({ @@ -30,4 +30,19 @@ public interface ContentPack extends Identified, Revisioned, Versioned { interface ContentPackBuilder extends IdBuilder, RevisionBuilder, VersionBuilder { } + + @JsonProperty + String name(); + + @JsonProperty + String description(); + + @JsonProperty + String summary(); + + @JsonProperty + URI url(); + + @JsonProperty + String vendor(); } diff --git a/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPackInstallation.java b/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPackInstallation.java index 31f2a8cb0e98..367d515a22d7 100644 --- a/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPackInstallation.java +++ b/graylog2-server/src/main/java/org/graylog2/contentpacks/model/ContentPackInstallation.java @@ -23,7 +23,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.bson.types.ObjectId; -import org.graylog2.contentpacks.model.entities.EntityDescriptor; import org.graylog2.contentpacks.model.entities.NativeEntityDescriptor; import org.graylog2.contentpacks.model.entities.references.ValueReference; @@ -72,11 +71,12 @@ public static Builder builder() { return new AutoValue_ContentPackInstallation.Builder(); } + public abstract Builder toBuilder(); + @AutoValue.Builder public abstract static class Builder { @JsonProperty(FIELD_ID) - @Nullable - abstract Builder id(ObjectId id); + public abstract Builder id(ObjectId id); @JsonProperty(FIELD_CONTENT_PACK_ID) public abstract Builder contentPackId(ModelId contentPackId); diff --git a/graylog2-server/src/main/java/org/mongojack/JacksonDBCollection.java b/graylog2-server/src/main/java/org/mongojack/JacksonDBCollection.java index 15ba27de416d..a45e7cc0fa89 100644 --- a/graylog2-server/src/main/java/org/mongojack/JacksonDBCollection.java +++ b/graylog2-server/src/main/java/org/mongojack/JacksonDBCollection.java @@ -61,7 +61,7 @@ * * @deprecated use {@link org.graylog2.database.MongoCollections} as an entrypoint for interacting with MongoDB. */ -@Deprecated +@Deprecated(since = "6.2.0", forRemoval = true) public class JacksonDBCollection { private final JacksonMongoCollection delegate; diff --git a/graylog2-server/src/main/resources/org/graylog2/featureflag/feature-flag.config b/graylog2-server/src/main/resources/org/graylog2/featureflag/feature-flag.config index 598fc99fe0bf..c6cd2305c6e3 100644 --- a/graylog2-server/src/main/resources/org/graylog2/featureflag/feature-flag.config +++ b/graylog2-server/src/main/resources/org/graylog2/featureflag/feature-flag.config @@ -96,3 +96,6 @@ setup_mode=off # Show security events in paginated entity data table show_security_events_in_pedt=off + +# Show executive dashboard +show_executive_dashboard_page=off diff --git a/graylog2-server/src/test/java/org/graylog/plugins/views/search/db/SearchesCleanUpJobWithDBServicesTest.java b/graylog2-server/src/test/java/org/graylog/plugins/views/search/db/SearchesCleanUpJobWithDBServicesTest.java index cde87d117cb4..673f709f9586 100644 --- a/graylog2-server/src/test/java/org/graylog/plugins/views/search/db/SearchesCleanUpJobWithDBServicesTest.java +++ b/graylog2-server/src/test/java/org/graylog/plugins/views/search/db/SearchesCleanUpJobWithDBServicesTest.java @@ -24,7 +24,6 @@ import org.graylog.testing.mongodb.MongoDBInstance; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.database.MongoCollections; -import org.graylog2.database.MongoConnection; import org.graylog2.shared.bindings.ObjectMapperModule; import org.graylog2.shared.bindings.ValidatorModule; import org.joda.time.DateTime; @@ -60,10 +59,8 @@ public class SearchesCleanUpJobWithDBServicesTest { private SearchDbService searchDbService; static class TestViewService extends ViewSummaryService { - TestViewService(MongoConnection mongoConnection, - MongoJackObjectMapperProvider mongoJackObjectMapperProvider, - MongoCollections mongoCollections) { - super(mongoConnection, mongoJackObjectMapperProvider, mongoCollections); + TestViewService(MongoCollections mongoCollections) { + super(mongoCollections); } } @@ -73,8 +70,6 @@ public void setup(MongoJackObjectMapperProvider mapperProvider) { final ViewSummaryService viewService = new TestViewService( - mongodb.mongoConnection(), - mapperProvider, new MongoCollections(mapperProvider, mongodb.mongoConnection()) ); this.searchDbService = spy( diff --git a/graylog2-server/src/test/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceServiceTest.java b/graylog2-server/src/test/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceServiceTest.java index 50130c51b93b..3d78505bf577 100644 --- a/graylog2-server/src/test/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog2/contentpacks/ContentPackInstallationPersistenceServiceTest.java @@ -25,6 +25,7 @@ import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.contentpacks.model.ContentPackInstallation; import org.graylog2.contentpacks.model.ModelId; +import org.graylog2.database.MongoCollections; import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.junit.Before; import org.junit.Rule; @@ -49,8 +50,7 @@ public void setUp() throws Exception { final MongoJackObjectMapperProvider mongoJackObjectMapperProvider = new MongoJackObjectMapperProvider(objectMapper); persistenceService = new ContentPackInstallationPersistenceService( - mongoJackObjectMapperProvider, - mongodb.mongoConnection()); + new MongoCollections(mongoJackObjectMapperProvider, mongodb.mongoConnection())); } @Test diff --git a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/DashboardV1FacadeTest.java b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/DashboardV1FacadeTest.java index a59390a97f5a..ad53586ff498 100644 --- a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/DashboardV1FacadeTest.java +++ b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/DashboardV1FacadeTest.java @@ -111,7 +111,7 @@ public void setUp() throws IOException { searchDbService = new ViewFacadeTest.TestSearchDBService(mongoConnection, mapper); final MongoCollections mongoCollections = new MongoCollections(mapper, mongoConnection); ViewFacadeTest.TestViewService viewService = new ViewFacadeTest.TestViewService(null, mongoCollections); - ViewFacadeTest.TestViewSummaryService viewSummaryService = new ViewFacadeTest.TestViewSummaryService(mongoConnection, mapper, mongoCollections); + ViewFacadeTest.TestViewSummaryService viewSummaryService = new ViewFacadeTest.TestViewSummaryService(mongoCollections); UserService userService = mock(UserService.class); final UserImpl fakeUser = new UserImpl(mock(PasswordAlgorithmFactory.class), new Permissions(ImmutableSet.of()), mock(ClusterConfigService.class), ImmutableMap.of("username", "testuser")); diff --git a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/PipelineFacadeTest.java b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/PipelineFacadeTest.java index 9c7c702dce12..43d07bc1e307 100644 --- a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/PipelineFacadeTest.java +++ b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/PipelineFacadeTest.java @@ -49,7 +49,6 @@ import org.graylog2.contentpacks.model.entities.PipelineEntity; import org.graylog2.contentpacks.model.entities.references.ValueReference; import org.graylog2.database.MongoCollections; -import org.graylog2.database.MongoConnection; import org.graylog2.database.NotFoundException; import org.graylog2.events.ClusterEventBus; import org.graylog2.plugin.streams.Stream; @@ -99,12 +98,12 @@ public class PipelineFacadeTest { @Before @SuppressForbidden("Using Executors.newSingleThreadExecutor() is okay in tests") public void setUp() throws Exception { - final MongoConnection mongoConnection = mongodb.mongoConnection(); - final MongoJackObjectMapperProvider mapperProvider = new MongoJackObjectMapperProvider(objectMapper); final ClusterEventBus clusterEventBus = new ClusterEventBus("cluster-event-bus", Executors.newSingleThreadExecutor()); - pipelineService = new MongoDbPipelineService(new MongoCollections(mapperProvider, mongoConnection), clusterEventBus); - connectionsService = new MongoDbPipelineStreamConnectionsService(mongoConnection, mapperProvider, clusterEventBus); + final MongoCollections mongoCollections = new MongoCollections(new MongoJackObjectMapperProvider(objectMapper), + mongodb.mongoConnection()); + pipelineService = new MongoDbPipelineService(mongoCollections, clusterEventBus); + connectionsService = new MongoDbPipelineStreamConnectionsService(mongoCollections, clusterEventBus); facade = new PipelineFacade(objectMapper, pipelineService, connectionsService, pipelineRuleParser, ruleService, streamService); } diff --git a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/ViewFacadeTest.java b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/ViewFacadeTest.java index dc938369e501..886aea742828 100644 --- a/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/ViewFacadeTest.java +++ b/graylog2-server/src/test/java/org/graylog2/contentpacks/facades/ViewFacadeTest.java @@ -116,11 +116,8 @@ protected TestViewService(ClusterConfigService clusterConfigService, } public static class TestViewSummaryService extends ViewSummaryService { - protected TestViewSummaryService(MongoConnection mongoConnection, - MongoJackObjectMapperProvider mongoJackObjectMapperProvider, - MongoCollections mongoCollections) { - super(mongoConnection, mongoJackObjectMapperProvider, mongoCollections); - + protected TestViewSummaryService(MongoCollections mongoCollections) { + super(mongoCollections); } } @@ -154,7 +151,7 @@ public void setUp() { final MongoCollections mongoCollections = new MongoCollections(mapper, mongoConnection); searchDbService = new TestSearchDBService(mongoConnection, mapper); viewService = new TestViewService(null, mongoCollections); - viewSummaryService = new TestViewSummaryService(mongoConnection, mapper, mongoCollections); + viewSummaryService = new TestViewSummaryService(mongoCollections); userService = mock(UserService.class); facade = new SearchFacade(objectMapper, searchDbService, viewService, viewSummaryService, userService); diff --git a/graylog2-server/src/test/java/org/graylog2/migrations/V20230601104500_AddSourcesPageV2Test.java b/graylog2-server/src/test/java/org/graylog2/migrations/V20230601104500_AddSourcesPageV2Test.java index 9d3c526cfeee..2f23421cd557 100644 --- a/graylog2-server/src/test/java/org/graylog2/migrations/V20230601104500_AddSourcesPageV2Test.java +++ b/graylog2-server/src/test/java/org/graylog2/migrations/V20230601104500_AddSourcesPageV2Test.java @@ -30,6 +30,7 @@ import org.graylog2.contentpacks.model.ContentPackInstallation; import org.graylog2.contentpacks.model.ContentPackUninstallation; import org.graylog2.contentpacks.model.entities.references.ValueReference; +import org.graylog2.database.MongoCollections; import org.graylog2.notifications.Notification; import org.graylog2.notifications.NotificationImpl; import org.graylog2.notifications.NotificationService; @@ -103,7 +104,8 @@ void setUp(MongoDBTestService mongodb) { var mapperProvider = new MongoJackObjectMapperProvider(objectMapper); var mongoConnection = mongodb.mongoConnection(); ContentPackPersistenceService contentPackPersistenceService = new ContentPackPersistenceService(mapperProvider, mongoConnection, streamService); - ContentPackInstallationPersistenceService contentPackInstallationPersistenceService = new ContentPackInstallationPersistenceService(mapperProvider, mongoConnection); + ContentPackInstallationPersistenceService contentPackInstallationPersistenceService = + new ContentPackInstallationPersistenceService(new MongoCollections(mapperProvider, mongoConnection)); ContentPackService contentPackService = new TestContentPackService(); this.migration = new V20230601104500_AddSourcesPageV2(contentPackService, objectMapper, clusterConfigService, contentPackPersistenceService, contentPackInstallationPersistenceService, mongoConnection, notificationService); diff --git a/graylog2-server/src/test/java/org/graylog2/security/encryption/EncryptedValueTest.java b/graylog2-server/src/test/java/org/graylog2/security/encryption/EncryptedValueTest.java index b1bcfff29966..73d43d4824d8 100644 --- a/graylog2-server/src/test/java/org/graylog2/security/encryption/EncryptedValueTest.java +++ b/graylog2-server/src/test/java/org/graylog2/security/encryption/EncryptedValueTest.java @@ -21,12 +21,14 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auto.value.AutoValue; +import com.mongodb.client.MongoCollection; import org.graylog.grn.GRNRegistry; import org.graylog.testing.mongodb.MongoDBExtension; import org.graylog.testing.mongodb.MongoDBTestService; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; -import org.graylog2.database.MongoConnection; -import org.graylog2.database.PaginatedDbService; +import org.graylog2.database.MongoCollections; +import org.graylog2.database.MongoEntity; +import org.graylog2.database.utils.MongoUtils; import org.graylog2.jackson.InputConfigurationBeanDeserializerModifier; import org.graylog2.shared.bindings.providers.ObjectMapperProvider; import org.junit.jupiter.api.BeforeEach; @@ -40,6 +42,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.graylog2.database.utils.MongoUtils.insertedIdAsString; @ExtendWith(MongoDBExtension.class) class EncryptedValueTest { @@ -58,7 +61,8 @@ void setUp(MongoDBTestService mongodb) { InputConfigurationBeanDeserializerModifier.withoutConfig() ).get(); - this.dbService = new TestService(mongodb.mongoConnection(), new MongoJackObjectMapperProvider(objectMapper)); + this.dbService = new TestService(new MongoCollections(new MongoJackObjectMapperProvider(objectMapper), + mongodb.mongoConnection())); } @Test @@ -154,7 +158,7 @@ void testWithDatabase() { .isDeleteValue(false) .build(); - final String savedId = dbService.save(TestDTO.create(value)).id(); + final String savedId = dbService.create(TestDTO.create(value)); final TestDTO dto = dbService.get(savedId).orElse(null); assertThat(dto).isNotNull(); @@ -167,7 +171,7 @@ void testWithDatabase() { @Test void testUnsetWithDatabase() { - final String savedId = dbService.save(TestDTO.create(EncryptedValue.createUnset())).id(); + final String savedId = dbService.create(TestDTO.create(EncryptedValue.createUnset())); final TestDTO dto = dbService.get(savedId).orElse(null); assertThat(dto).isNotNull(); @@ -178,19 +182,26 @@ void testUnsetWithDatabase() { assertThat(dto.passwordValue().salt()).isEmpty(); } - static class TestService extends PaginatedDbService { - @Override + static class TestService { + private final MongoCollection collection; + private final MongoUtils utils; + + protected TestService(MongoCollections mongoCollections) { + collection = mongoCollections.collection("test_collection", TestDTO.class); + utils = mongoCollections.utils(collection); + } + public Optional get(String id) { - return super.get(id); + return utils.getById(id); } - protected TestService(MongoConnection mongoConnection, MongoJackObjectMapperProvider mapperProvider) { - super(mongoConnection, mapperProvider, TestDTO.class, "test_collection"); + public String create(TestDTO testDTO) { + return insertedIdAsString(collection.insertOne(testDTO)); } } @AutoValue - static abstract class TestDTO { + static abstract class TestDTO implements MongoEntity { @Id @ObjectId @Nullable diff --git a/graylog2-web-interface/package.json b/graylog2-web-interface/package.json index 99e519d21223..1687aebb4807 100644 --- a/graylog2-web-interface/package.json +++ b/graylog2-web-interface/package.json @@ -178,6 +178,6 @@ "resolutions": { "@types/react": "18.3.12", "nth-check": "2.0.1", - "typescript": "5.7.2" + "typescript": "5.7.3" } } diff --git a/graylog2-web-interface/packages/eslint-config-graylog/package.json b/graylog2-web-interface/packages/eslint-config-graylog/package.json index 37b480067fe8..df185813ef1a 100644 --- a/graylog2-web-interface/packages/eslint-config-graylog/package.json +++ b/graylog2-web-interface/packages/eslint-config-graylog/package.json @@ -15,19 +15,19 @@ "dependencies": { "@babel/eslint-parser": "7.16.5", "@tanstack/eslint-plugin-query": "4.36.1", - "@typescript-eslint/eslint-plugin": "8.19.1", - "@typescript-eslint/parser": "8.19.1", + "@typescript-eslint/eslint-plugin": "8.20.0", + "@typescript-eslint/parser": "8.20.0", "eslint": "8.57.0", "eslint-config-airbnb": "19.0.4", "eslint-import-resolver-webpack": "0.13.10", "eslint-plugin-compat": "4.2.0", "eslint-plugin-graylog": "file:../eslint-plugin-graylog", "eslint-plugin-import": "2.25.3", - "eslint-plugin-jest": "28.10.0", + "eslint-plugin-jest": "28.11.0", "eslint-plugin-jest-dom": "5.5.0", "eslint-plugin-jest-formatting": "3.1.0", "eslint-plugin-jsx-a11y": "6.10.2", - "eslint-plugin-react": "7.37.2", + "eslint-plugin-react": "7.37.4", "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-testing-library": "7.1.1" } diff --git a/graylog2-web-interface/packages/graylog-web-plugin/package.json b/graylog2-web-interface/packages/graylog-web-plugin/package.json index 8787dd688fa1..af3f23f1956c 100644 --- a/graylog2-web-interface/packages/graylog-web-plugin/package.json +++ b/graylog2-web-interface/packages/graylog-web-plugin/package.json @@ -49,7 +49,7 @@ "react-router-dom": "6.27.0", "reflux": "0.2.13", "styled-components": "6.1.1", - "typescript": "5.7.2", + "typescript": "5.7.3", "use-query-params": "^2.2.0", "webpack": "5.97.1", "webpack-cli": "5.1.4", diff --git a/graylog2-web-interface/packages/stylelint-config-graylog/package.json b/graylog2-web-interface/packages/stylelint-config-graylog/package.json index 7de062dc73f1..2b53c3f370ab 100644 --- a/graylog2-web-interface/packages/stylelint-config-graylog/package.json +++ b/graylog2-web-interface/packages/stylelint-config-graylog/package.json @@ -13,9 +13,9 @@ "author": "Graylog, Inc. ", "license": "SSPL-1.0", "dependencies": { - "postcss-styled-syntax": "0.7.0", - "stylelint": "16.12.0", - "stylelint-config-recommended": "14.0.1", + "postcss-styled-syntax": "0.7.1", + "stylelint": "16.13.2", + "stylelint-config-recommended": "15.0.0", "stylelint-config-standard": "36.0.1", "stylelint-config-styled-components": "0.1.1" } diff --git a/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useTableEventHandlers.ts b/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useTableEventHandlers.ts index a3b3ffa4b9ef..7259972a65ad 100644 --- a/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useTableEventHandlers.ts +++ b/graylog2-web-interface/src/components/common/EntityDataTable/hooks/useTableEventHandlers.ts @@ -39,7 +39,7 @@ const useTableEventHandlers = ({ const sendTelemetry = useSendTelemetry(); const onPageSizeChange = useCallback((newPageSize: number) => { - sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_LIST.PAGE_SIZE_CHANGED, { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.PAGE_SIZE_CHANGED, { app_pathname: getPathnameWithoutId(pathname), app_section: appSection, app_action_value: 'page-size-select', @@ -60,7 +60,7 @@ const useTableEventHandlers = ({ }, [onSearch]); const onColumnsChange = useCallback((displayedAttributes: Array) => { - sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_LIST.COLUMNS_CHANGED, { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.COLUMNS_CHANGED, { app_pathname: getPathnameWithoutId(pathname), app_section: appSection, app_action_value: 'columns-select', @@ -71,7 +71,7 @@ const useTableEventHandlers = ({ }, [appSection, pathname, sendTelemetry, updateTableLayout]); const onSortChange = useCallback((newSort: Sort) => { - sendTelemetry(TELEMETRY_EVENT_TYPE.EVENTDEFINITION_LIST.SORT_CHANGED, { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.SORT_CHANGED, { app_pathname: getPathnameWithoutId(pathname), app_section: appSection, app_action_value: 'sort-select', diff --git a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx index fd9c55ffac16..e3fccb17d130 100644 --- a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx +++ b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx @@ -120,8 +120,8 @@ describe('', () => { }, ]; - const EntityFilters = (props: Optional, 'setUrlQueryFilters' | 'attributes'>) => ( - + const EntityFilters = (props: Optional, 'setUrlQueryFilters' | 'attributes' | 'appSection'>) => ( + ); const dropdownIsHidden = (dropdownTitle: string) => expect(screen.queryByRole('heading', { name: new RegExp(dropdownTitle, 'i') })).not.toBeInTheDocument(); diff --git a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.tsx b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.tsx index 531bff1934c2..56ecadf11f34 100644 --- a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.tsx +++ b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.tsx @@ -23,6 +23,10 @@ import type { Attributes } from 'stores/PaginationTypes'; import type { Filters, Filter, UrlQueryFilters } from 'components/common/EntityFilters/types'; import ActiveFilters from 'components/common/EntityFilters/ActiveFilters'; import useFiltersWithTitle from 'components/common/EntityFilters/hooks/useFiltersWithTitle'; +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import useLocation from 'routing/useLocation'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import { getPathnameWithoutId } from 'util/URLUtils'; import { ROW_MIN_HEIGHT } from './Constants'; @@ -40,13 +44,17 @@ const FilterCreation = styled.div` `; type Props = { - attributes: Attributes, + attributes?: Attributes, urlQueryFilters: UrlQueryFilters | undefined, setUrlQueryFilters: (urlQueryFilters: UrlQueryFilters) => void, - filterValueRenderers?: { [attributeId: string]: (value: Filter['value'], title: string) => React.ReactNode }; + filterValueRenderers?: { [attributeId: string]: (value: Filter['value'], title: string) => React.ReactNode }, + appSection: string, } -const EntityFilters = ({ attributes = [], filterValueRenderers, urlQueryFilters, setUrlQueryFilters }: Props) => { +const EntityFilters = ({ attributes = [], filterValueRenderers = undefined, urlQueryFilters, setUrlQueryFilters, appSection }: Props) => { + const { pathname } = useLocation(); + const sendTelemetry = useSendTelemetry(); + const { data: activeFilters, onChange: onChangeFiltersWithTitle, @@ -68,13 +76,27 @@ const EntityFilters = ({ attributes = [], filterValueRenderers, urlQueryFilters, }, [onChangeFiltersWithTitle, setUrlQueryFilters]); const onCreateFilter = useCallback((attributeId: string, filter: Filter) => { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.FILTER_CREATED, { + app_pathname: getPathnameWithoutId(pathname), + app_section: appSection, + app_action_value: 'filter-created', + attribute_id: attributeId, + }); + onChangeFilters(OrderedMap(activeFilters).set( attributeId, [...(activeFilters?.get(attributeId) ?? []), filter], )); - }, [activeFilters, onChangeFilters]); + }, [activeFilters, appSection, onChangeFilters, pathname, sendTelemetry]); const onDeleteFilter = useCallback((attributeId: string, filterId: string) => { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.FILTER_DELETED, { + app_pathname: getPathnameWithoutId(pathname), + app_section: appSection, + app_action_value: 'filter-deleted', + attribute_id: attributeId, + }); + const filterGroup = activeFilters.get(attributeId); const updatedFilterGroup = filterGroup.filter(({ value }) => value !== filterId); @@ -83,16 +105,23 @@ const EntityFilters = ({ attributes = [], filterValueRenderers, urlQueryFilters, } return onChangeFilters(activeFilters.remove(attributeId)); - }, [activeFilters, onChangeFilters]); + }, [activeFilters, appSection, onChangeFilters, pathname, sendTelemetry]); const onChangeFilter = useCallback((attributeId: string, prevValue: string, newFilter: Filter) => { + sendTelemetry(TELEMETRY_EVENT_TYPE.ENTITY_DATA_TABLE.FILTER_CHANGED, { + app_pathname: getPathnameWithoutId(pathname), + app_section: appSection, + app_action_value: 'filter-value-changed', + attribute_id: attributeId, + }); + const filterGroup = activeFilters.get(attributeId); const targetFilterIndex = filterGroup.findIndex(({ value }) => value === prevValue); const updatedFilterGroup = [...filterGroup]; updatedFilterGroup[targetFilterIndex] = newFilter; onChangeFilters(activeFilters.set(attributeId, updatedFilterGroup)); - }, [activeFilters, onChangeFilters]); + }, [activeFilters, appSection, onChangeFilters, pathname, sendTelemetry]); if (!filterableAttributes.length) { return null; diff --git a/graylog2-web-interface/src/components/common/PaginatedEntityTable/PaginatedEntityTable.tsx b/graylog2-web-interface/src/components/common/PaginatedEntityTable/PaginatedEntityTable.tsx index 4b02fcc43a42..c494a8792294 100644 --- a/graylog2-web-interface/src/components/common/PaginatedEntityTable/PaginatedEntityTable.tsx +++ b/graylog2-web-interface/src/components/common/PaginatedEntityTable/PaginatedEntityTable.tsx @@ -107,6 +107,8 @@ const PaginatedEntityTable = ({ setUrlQueryFilters(newUrlQueryFilters); }, [paginationQueryParameter, setUrlQueryFilters]); + const appSection = `${tableLayout.entityTableId}-list`; + const { onPageSizeChange, onSearch, @@ -114,7 +116,7 @@ const PaginatedEntityTable = ({ onColumnsChange, onSortChange, } = useTableEventHandlers({ - appSection: `${tableLayout.entityTableId}-list`, + appSection, paginationQueryParameter, updateTableLayout, setQuery, @@ -146,7 +148,8 @@ const PaginatedEntityTable = ({ + filterValueRenderers={filterValueRenderers} + appSection={appSection} /> {topRightCol} diff --git a/graylog2-web-interface/src/components/common/router.tsx b/graylog2-web-interface/src/components/common/router.tsx index fa3fa6652051..afde63f39820 100644 --- a/graylog2-web-interface/src/components/common/router.tsx +++ b/graylog2-web-interface/src/components/common/router.tsx @@ -49,7 +49,7 @@ type Props = { target?: string, }; -const isLeftClickEvent = (e: React.MouseEvent) => (e.button === 0); +const isLeftClickEvent = (e: React.MouseEvent) => (e?.button === 0); const isModifiedEvent = (e: React.MouseEvent) => !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); diff --git a/graylog2-web-interface/src/components/content-packs/ContentPackInstallEntityList.tsx b/graylog2-web-interface/src/components/content-packs/ContentPackInstallEntityList.tsx index 9f96df742cbe..ef47f6794a10 100644 --- a/graylog2-web-interface/src/components/content-packs/ContentPackInstallEntityList.tsx +++ b/graylog2-web-interface/src/components/content-packs/ContentPackInstallEntityList.tsx @@ -18,19 +18,19 @@ import React from 'react'; import Spinner from 'components/common/Spinner'; import { DataTable } from 'components/common'; - import 'components/content-packs/ContentPackDetails.css'; +import type { InstalledEntity } from 'components/content-packs/Types'; type ContentPackInstallEntityListProps = { - entities?: any[]; + entities?: InstalledEntity[]; uninstall?: boolean; }; const ContentPackInstallEntityList = ({ - entities, + entities = undefined, uninstall = false, }: ContentPackInstallEntityListProps) => { - const rowFormatter = (entity) => ({entity.title}{entity.type.name}); + const rowFormatter = (entity: InstalledEntity) => ({entity.title}{entity.type.name}); const headers = ['Title', 'Type']; const headerTitle = uninstall ? 'Entites to be uninstalled' : 'Installed Entities'; diff --git a/graylog2-web-interface/src/components/content-packs/ContentPackInstallView.tsx b/graylog2-web-interface/src/components/content-packs/ContentPackInstallView.tsx index 6c316ee3978c..50fc08d09fc1 100644 --- a/graylog2-web-interface/src/components/content-packs/ContentPackInstallView.tsx +++ b/graylog2-web-interface/src/components/content-packs/ContentPackInstallView.tsx @@ -23,13 +23,16 @@ import 'components/content-packs/ContentPackDetails.css'; import ContentPackInstallEntityList from './ContentPackInstallEntityList'; type ContentPackInstallViewProps = { - install: any; + install: { + comment: string, + created_at: string, + created_by: string, + entities: any[], + }; }; -const ContentPackInstallView = (props: ContentPackInstallViewProps) => { - const { comment } = props.install; - const createdAt = props.install.created_at; - const createdBy = props.install.created_by; +const ContentPackInstallView = ({ install }: ContentPackInstallViewProps) => { + const { comment, created_at: createdAt, created_by: createdBy, entities } = install; return (
@@ -48,7 +51,7 @@ const ContentPackInstallView = (props: ContentPackInstallViewProps) => { - +
diff --git a/graylog2-web-interface/src/components/content-packs/Types.ts b/graylog2-web-interface/src/components/content-packs/Types.ts index a18b946f857a..1e41d84773bd 100644 --- a/graylog2-web-interface/src/components/content-packs/Types.ts +++ b/graylog2-web-interface/src/components/content-packs/Types.ts @@ -16,17 +16,17 @@ */ export type ContentPackInstallation = { - created_at: string, + created_at?: string, description: string, entities?: Array, id: string, name: string, parameters?: Array, rev: number, - server_version: string, + server_version?: string, summary: string, url: string, - v: number, + v: string, vendor: string, } @@ -70,9 +70,16 @@ export interface Constraint { } export type ContentPackMetadata = { - [key: number]: { - [key: number]: { - [key: string]: number, + [string: number]: { + [string: number]: { + installation_count: number, }, }, } + +export type InstalledEntity = { + title: string, + type: { + name: string + } +} diff --git a/graylog2-web-interface/src/components/content-packs/components/ContentPackListItem.test.tsx b/graylog2-web-interface/src/components/content-packs/components/ContentPackListItem.test.tsx index 4bfba8c7cd99..31fc8d1d6d57 100644 --- a/graylog2-web-interface/src/components/content-packs/components/ContentPackListItem.test.tsx +++ b/graylog2-web-interface/src/components/content-packs/components/ContentPackListItem.test.tsx @@ -30,7 +30,7 @@ describe('', () => { server_version: '6.0.0-SNAPSHOT', summary: 'The Open Thread Exchange Lookup Table of the Threat Intel Plugin', url: 'https://github.com/Graylog2/graylog2-server', - v: 1, + v: '1', vendor: 'Graylog ', }; diff --git a/graylog2-web-interface/src/components/content-packs/hooks/useContentPackInstallations.ts b/graylog2-web-interface/src/components/content-packs/hooks/useContentPackInstallations.ts new file mode 100644 index 000000000000..221c04873f83 --- /dev/null +++ b/graylog2-web-interface/src/components/content-packs/hooks/useContentPackInstallations.ts @@ -0,0 +1,25 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import { SystemContentPacks } from '@graylog/server-api'; + +const useContentPackInstallations = (id: string) => useQuery( + ['content-packs', 'installations', id], + () => SystemContentPacks.listContentPackInstallationsById(id), +); +export default useContentPackInstallations; diff --git a/graylog2-web-interface/src/components/content-packs/hooks/useContentPackRevisions.ts b/graylog2-web-interface/src/components/content-packs/hooks/useContentPackRevisions.ts new file mode 100644 index 000000000000..ee05d1ec7527 --- /dev/null +++ b/graylog2-web-interface/src/components/content-packs/hooks/useContentPackRevisions.ts @@ -0,0 +1,43 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import { SystemContentPacks } from '@graylog/server-api'; + +import ContentPackRevisions from 'logic/content-packs/ContentPackRevisions'; +import { onError } from 'util/conditional/onError'; +import UserNotification from 'util/UserNotification'; + +const fetchContentPackRevisions = async (id: string) => { + const response = await SystemContentPacks.listContentPackRevisions(id); + const contentPackRevision = new ContentPackRevisions(response.content_pack_revisions); + const constraints = response.constraints_result; + + return { + contentPackRevisions: contentPackRevision, + selectedVersion: contentPackRevision.latestRevision, + constraints: constraints, + }; +}; + +const defaultErrorHandler = (error: Error) => UserNotification.error(`Error while fetching content pack revisions: ${error}`, 'Unable to fetch content pack'); + +const useContentPackRevisions = (id: string, onFetchError: (e: Error) => void = defaultErrorHandler) => useQuery( + ['content-packs', 'revisions', id], + () => onError(fetchContentPackRevisions(id), onFetchError), +); +export default useContentPackRevisions; diff --git a/graylog2-web-interface/src/components/content-packs/hooks/useContentPacks.ts b/graylog2-web-interface/src/components/content-packs/hooks/useContentPacks.ts new file mode 100644 index 000000000000..da94c63b4ffc --- /dev/null +++ b/graylog2-web-interface/src/components/content-packs/hooks/useContentPacks.ts @@ -0,0 +1,22 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; + +import { SystemContentPacks } from '@graylog/server-api'; + +const useContentPacks = () => useQuery(['content-packs', 'list'], () => SystemContentPacks.listContentPacks()); +export default useContentPacks; diff --git a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.tsx b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.tsx index e8595ac4692b..068395057458 100644 --- a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.tsx +++ b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDefinitionFormContainer.tsx @@ -64,7 +64,7 @@ const EventDefinitionFormContainer = ({ eventDefinition: eventDefinitionInitial = { title: '', description: '', - priority: EventDefinitionPriorityEnum.NORMAL, + priority: EventDefinitionPriorityEnum.MEDIUM, // @ts-ignore config: {}, field_spec: {}, @@ -77,12 +77,12 @@ const EventDefinitionFormContainer = ({ notifications: [], alert: false, }, - formControls, + formControls = undefined, initialStep = STEP_KEYS[0], - onCancel, - onChangeStep, + onCancel = undefined, + onChangeStep = undefined, onEventDefinitionChange = () => {}, - onSubmit, + onSubmit = undefined, }: Props) => { const [activeStep, setActiveStep] = useState(initialStep); const [eventDefinition, setEventDefinition] = useState(eventDefinitionInitial); diff --git a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDetailsForm.tsx b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDetailsForm.tsx index d18b11dad282..d41ae5cdbf76 100644 --- a/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDetailsForm.tsx +++ b/graylog2-web-interface/src/components/event-definitions/event-definition-form/EventDetailsForm.tsx @@ -34,7 +34,8 @@ import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import type { EventDefinition } from '../event-definitions-types'; import commonStyles from '../common/commonStyles.css'; -const priorityOptions = map(EventDefinitionPriorityEnum.properties, (value, key) => ({ value: key, label: upperFirst(value.name) })); +const priorityOptions = map(EventDefinitionPriorityEnum.properties, (value, key) => ({ value: key, label: upperFirst(value.name) })) + .sort((a, b) => Number(b.value) - Number(a.value)); type Props = { eventDefinition: EventDefinition, diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/AggreagtionConditions.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/AggregationConditions.tsx similarity index 77% rename from graylog2-web-interface/src/components/event-definitions/replay-search/AggreagtionConditions.tsx rename to graylog2-web-interface/src/components/event-definitions/replay-search/AggregationConditions.tsx index 1d0ab85e3748..413cfb2959cc 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/AggreagtionConditions.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/AggregationConditions.tsx @@ -21,15 +21,16 @@ import { useMemo, useCallback } from 'react'; import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor'; import { ColorPickerPopover, Icon } from 'components/common'; import { DEFAULT_CUSTOM_HIGHLIGHT_RANGE } from 'views/Constants'; -import type HighlightingRule from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { conditionToExprMapper, exprToConditionMapper } from 'views/logic/ExpressionConditionMappers'; import useAppSelector from 'stores/useAppSelector'; import { selectHighlightingRules } from 'views/logic/slices/highlightSelectors'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import { updateHighlightingRule, createHighlightingRules } from 'views/logic/slices/highlightActions'; import { randomColor } from 'views/logic/views/formatting/highlighting/HighlightingRule'; import useAppDispatch from 'stores/useAppDispatch'; import NoAttributeProvided from 'components/event-definitions/replay-search/NoAttributeProvided'; +import useReplaySearchContext from 'components/event-definitions/replay-search/hooks/useReplaySearchContext'; + +import useAlertAndEventDefinitionData from './hooks/useAlertAndEventDefinitionData'; const List = styled.div` display: flex; @@ -53,10 +54,11 @@ const useHighlightingRules = () => useAppSelector(selectHighlightingRules); const AggregationConditions = () => { const dispatch = useAppDispatch(); - const { aggregations } = useAlertAndEventDefinitionData(); + const { alertId, definitionId } = useReplaySearchContext(); + const { aggregations } = useAlertAndEventDefinitionData(alertId, definitionId); const highlightingRules = useHighlightingRules(); - const aggregationsMap = useMemo(() => new Map(aggregations.map((agg) => [ + const aggregationsMap = useMemo(() => Object.fromEntries(aggregations.map((agg) => [ `${agg.fnSeries}${agg.expr}${agg.value}`, agg, ])), [aggregations]); @@ -64,7 +66,7 @@ const AggregationConditions = () => { if (rule) { dispatch(updateHighlightingRule(rule, { color: StaticColor.create(newColor) })); } else { - const { value, fnSeries, expr } = aggregationsMap.get(condition); + const { value, fnSeries, expr } = aggregationsMap[condition]; dispatch(createHighlightingRules([ { @@ -77,33 +79,27 @@ const AggregationConditions = () => { } }, [aggregationsMap, dispatch]); - const highlightedAggregations = useMemo>(() => { - const initial = new Map(aggregations.map( - ({ fnSeries, value, expr }) => [ - `${fnSeries}${expr}${value}`, undefined, - ], - )); + const validAggregations = aggregations.map(({ fnSeries, value, expr }) => `${fnSeries}${expr}${value}`); - return highlightingRules.reduce((acc, rule) => { + const highlightedAggregations = useMemo(() => Object.fromEntries(highlightingRules + .map((rule) => { const { field, value, condition } = rule; - const expr = conditionToExprMapper?.[condition]; - let result = acc; + const expr = conditionToExprMapper[condition]; if (expr) { const key = `${field}${expr}${value}`; - if (acc.has(key)) { - result = result.set(key, rule); - } + return [key, rule] as const; } - return result; - }, initial); - }, [aggregations, highlightingRules]); + return undefined; + }) + .filter((rule) => rule !== undefined && validAggregations.includes(rule[0]))), + [highlightingRules, validAggregations]); - return highlightedAggregations?.size ? ( + return Object.keys(highlightedAggregations).length ? ( - {Array.from(highlightedAggregations).map(([condition, rule]) => { + {Object.entries(highlightedAggregations).map(([condition, rule]) => { const color = rule?.color as StaticColor; const hexColor = color?.color; diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.test.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.test.tsx index 932886b20d77..b15d52dca38c 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.test.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.test.tsx @@ -20,7 +20,6 @@ import { render, screen, fireEvent } from 'wrappedTestingLibrary'; import MockStore from 'helpers/mocking/StoreMock'; import asMock from 'helpers/mocking/AsMock'; import EventInfoBar from 'components/event-definitions/replay-search/EventInfoBar'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import { mockedMappedAggregation, mockEventData, @@ -31,6 +30,10 @@ import { selectHighlightingRules } from 'views/logic/slices/highlightSelectors'; import HighlightingRule from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor'; import useViewsPlugin from 'views/test/testViewsPlugin'; +import type { AlertType } from 'components/event-definitions/types'; +import ReplaySearchContext from 'components/event-definitions/replay-search/ReplaySearchContext'; + +import useAlertAndEventDefinitionData from './hooks/useAlertAndEventDefinitionData'; jest.mock('stores/event-notifications/EventNotificationsStore', () => ({ EventNotificationsActions: { @@ -39,7 +42,7 @@ jest.mock('stores/event-notifications/EventNotificationsStore', () => ({ EventNotificationsStore: MockStore((['getInitialState', () => ({ all: [{ id: 'email_notification_id', title: 'Email notification' }] })])), })); -jest.mock('hooks/useAlertAndEventDefinitionData'); +jest.mock('./hooks/useAlertAndEventDefinitionData'); jest.mock('views/logic/Widgets', () => ({ ...jest.requireActual('views/logic/Widgets'), @@ -51,36 +54,37 @@ jest.mock('views/logic/Widgets', () => ({ }), })); -const setMockedHookCache = ({ +const mockUseAlertAndEventDefinitionData = ({ eventData = mockEventData.event, eventDefinition = mockEventDefinitionTwoAggregations, aggregations = mockedMappedAggregation, - isEvent = false, - isEventDefinition = false, - isAlert = false, alertId = mockEventData.event.id, definitionId = mockEventDefinitionTwoAggregations.id, definitionTitle = mockEventDefinitionTwoAggregations.title, -}) => asMock(useAlertAndEventDefinitionData).mockImplementation(() => ({ +}) => asMock(useAlertAndEventDefinitionData).mockReturnValue({ eventData, eventDefinition, aggregations, - isEvent, - isEventDefinition, - isAlert, alertId, definitionId, definitionTitle, -})); + isLoading: false, +}); jest.mock('views/logic/slices/highlightSelectors', () => ({ selectHighlightingRules: jest.fn(), })); describe('', () => { - const EventInfoComponent = () => ( + const EventInfoComponent = ({ type }: { type: AlertType }) => ( - + + + ); @@ -91,16 +95,15 @@ describe('', () => { .mockReturnValue([ HighlightingRule.create('count(field1)', 500, 'greater', StaticColor.create('#fff')), HighlightingRule.create('count(field2)', 8000, 'less', StaticColor.create('#000')), - ], - ); + ]); }); - it('Always shows fields: Priority, Execute search every, Search within, Description, Notifications, Aggregation conditions', async () => { - setMockedHookCache({ - isEvent: true, - }); + beforeEach(() => { + mockUseAlertAndEventDefinitionData({}); + }); - render(); + it('Always shows fields: Priority, Execute search every, Search within, Description, Notifications, Aggregation conditions', async () => { + render(); const priority = await screen.findByTitle('Priority'); const execution = await screen.findByTitle('Execute search every'); @@ -111,7 +114,7 @@ describe('', () => { const field1Condition = await screen.findByTitle('count(field1)>500'); const field2Condition = await screen.findByTitle('count(field2)<8000'); - expect(priority).toHaveTextContent('Normal'); + expect(priority).toHaveTextContent('Medium'); expect(execution).toHaveTextContent('1 minute'); expect(searchWithin).toHaveTextContent('1 minute'); expect(description).toHaveTextContent('Test description'); @@ -123,11 +126,7 @@ describe('', () => { }); it('Shows event timestamp and event definition link for event', async () => { - setMockedHookCache({ - isEvent: true, - }); - - render(); + render(); const timestamp = await screen.findByTitle('Timestamp'); const eventDefinition = await screen.findByTitle('Event definition'); @@ -138,11 +137,7 @@ describe('', () => { }); it("Didn't Shows Event definition updated at for event definition which was updated before event", async () => { - setMockedHookCache({ - isEvent: true, - }); - - render(); + render(); const eventDefinitionUpdated = screen.queryByTitle('Event definition updated at'); @@ -150,15 +145,14 @@ describe('', () => { }); it('Shows Event definition updated at for event definition which was updated after event', async () => { - setMockedHookCache({ - isEvent: true, + mockUseAlertAndEventDefinitionData({ eventDefinition: { ...mockEventDefinitionTwoAggregations, updated_at: '2023-03-21T13:28:09.296Z', }, }); - render(); + render(); const eventDefinitionUpdated = await screen.findByTitle('Event definition updated at'); @@ -166,12 +160,11 @@ describe('', () => { }); it('Do not shows event timestamp and event definition link for event definition', async () => { - setMockedHookCache({ - isEventDefinition: true, + mockUseAlertAndEventDefinitionData({ eventData: undefined, }); - render(); + render(); const timestamp = screen.queryByTitle('Timestamp'); const eventDefinition = screen.queryByTitle('Event definition'); @@ -181,11 +174,7 @@ describe('', () => { }); it('show and hide data on button click', async () => { - setMockedHookCache({ - isEventDefinition: true, - }); - - render(); + render(); const hideButton = await screen.findByText('Hide event definition details'); const detailsContainer = await screen.findByTestId('info-container'); diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.tsx index 202cc18496b0..3e0c89d5a562 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/EventInfoBar.tsx @@ -21,9 +21,10 @@ import styled from 'styled-components'; import { Button } from 'components/bootstrap'; import { FlatContentRow, Icon } from 'components/common'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import useAttributeComponents from 'components/event-definitions/replay-search/hooks/useAttributeComponents'; import NoAttributeProvided from 'components/event-definitions/replay-search/NoAttributeProvided'; +import useReplaySearchContext from 'components/event-definitions/replay-search/hooks/useReplaySearchContext'; +import assertUnreachable from 'logic/assertUnreachable'; const Header = styled.div` display: flex; @@ -55,7 +56,7 @@ const Value = styled.div` `; const EventInfoBar = () => { - const { isEventDefinition, isEvent, isAlert } = useAlertAndEventDefinitionData(); + const { type } = useReplaySearchContext(); const [open, setOpen] = useState(true); const toggleOpen = useCallback((e: SyntheticEvent) => { @@ -66,12 +67,13 @@ const EventInfoBar = () => { const infoAttributes = useAttributeComponents(); const currentTypeText = useMemo(() => { - if (isEventDefinition) return 'event definition'; - if (isAlert) return 'alert'; - if (isEvent) return 'event'; - - return ''; - }, [isAlert, isEvent, isEventDefinition]); + switch (type) { + case 'alert': return 'alert'; + case 'event': return 'event'; + case 'event_definition': return 'event definition'; + default: return assertUnreachable(type, `Invalid replay type: ${type}`); + } + }, [type]); return ( diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/LinkToReplaySearch.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/LinkToReplaySearch.tsx index 6b3710541082..93176031905b 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/LinkToReplaySearch.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/LinkToReplaySearch.tsx @@ -21,12 +21,12 @@ import Routes from 'routing/Routes'; import { ReplaySearchButtonComponent } from 'views/components/widgets/ReplaySearchButton'; import useParams from 'routing/useParams'; -const LinkToReplaySearch = ({ isEvent = false, id }: { id?: string, isEvent?: boolean }) => { +const LinkToReplaySearch = ({ isEvent = false, id = undefined, onClick = undefined }: { id?: string, isEvent?: boolean, onClick?: () => void }) => { const { definitionId } = useParams<{ alertId?: string, definitionId?: string }>(); const searchLink = isEvent ? Routes.ALERTS.replay_search(id) : Routes.ALERTS.DEFINITIONS.replay_search(id || definitionId); return ( - Replay search + Replay search ); }; diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/Notifications.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/Notifications.tsx index 61806c7a4a8c..00fa42ff9f38 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/Notifications.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/Notifications.tsx @@ -15,31 +15,32 @@ * . */ import * as React from 'react'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Link } from 'components/common/router'; import Routes from 'routing/Routes'; import { useStore } from 'stores/connect'; -import { EventNotificationsStore } from 'stores/event-notifications/EventNotificationsStore'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; +import { EventNotificationsStore, EventNotificationsActions } from 'stores/event-notifications/EventNotificationsStore'; import NoAttributeProvided from 'components/event-definitions/replay-search/NoAttributeProvided'; +import useReplaySearchContext from 'components/event-definitions/replay-search/hooks/useReplaySearchContext'; -const Notifications = () => { - const { eventDefinition } = useAlertAndEventDefinitionData(); +import useAlertAndEventDefinitionData from './hooks/useAlertAndEventDefinitionData'; - const allNotifications = useStore(EventNotificationsStore, ({ all }) => all.reduce((res, cur) => { - res[cur.id] = cur; +const Notifications = () => { + const { alertId, definitionId } = useReplaySearchContext(); + const { eventDefinition } = useAlertAndEventDefinitionData(alertId, definitionId); - return res; - }, {})); + useEffect(() => { + EventNotificationsActions.listAll(); + }, []); - const notificationList = useMemo(() => eventDefinition.notifications.reduce((res, cur) => { - if (allNotifications[cur.notification_id]) { - res.push((allNotifications[cur.notification_id])); - } + const allNotifications = useStore(EventNotificationsStore, ({ all }) => Object.fromEntries( + (all ?? []).map((notification) => [notification.id, notification]), + )); - return res; - }, []), [eventDefinition, allNotifications]); + const notificationList = useMemo(() => eventDefinition.notifications + .flatMap(({ notification_id }) => (allNotifications[notification_id] ? [allNotifications[notification_id]] : [])), + [allNotifications, eventDefinition.notifications]); return notificationList.length ? ( <> diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/ReplaySearchContext.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/ReplaySearchContext.tsx new file mode 100644 index 000000000000..06b747c48138 --- /dev/null +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/ReplaySearchContext.tsx @@ -0,0 +1,27 @@ +/* + * 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 type { AlertType } from 'components/event-definitions/types'; + +type ReplaySearchContextType = { + alertId: string; + definitionId: string; + type: AlertType; +} +const ReplaySearchContext = React.createContext({ alertId: undefined, definitionId: undefined, type: undefined }); +export default ReplaySearchContext; diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.test.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.test.tsx new file mode 100644 index 000000000000..327b55be3585 --- /dev/null +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { renderHook } from 'wrappedTestingLibrary/hooks'; + +import { + mockedMappedAggregation, + mockEventData, + mockEventDefinitionTwoAggregations, +} from 'helpers/mocking/EventAndEventDefinitions_mock'; +import asMock from 'helpers/mocking/AsMock'; +import useAlertAndEventDefinitionData from 'components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'; +import useEventById from 'hooks/useEventById'; +import useEventDefinition from 'hooks/useEventDefinition'; + +jest.mock('hooks/useEventById'); +jest.mock('hooks/useEventDefinition'); + +const mockedHookData = { + alertId: mockEventData.event.id, + definitionId: mockEventData.event.event_definition_id, + definitionTitle: mockEventDefinitionTwoAggregations.title, + eventData: mockEventData.event, + eventDefinition: mockEventDefinitionTwoAggregations, + aggregations: mockedMappedAggregation, +}; + +const hookResultBase = { + refetch: () => {}, + isLoading: false, + isFetched: true, +} as const; + +describe('useAlertAndEventDefinitionData', () => { + beforeEach(() => { + asMock(useEventDefinition).mockReturnValue({ + ...hookResultBase, + data: { + eventDefinition: mockEventDefinitionTwoAggregations, + aggregations: mockedMappedAggregation, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return expected data for alert page', async () => { + const eventId = 'event-id-1'; + + asMock(useEventById).mockReturnValue({ + ...hookResultBase, + data: { ...mockEventData.event, id: eventId, alert: true }, + }); + + const { result } = renderHook(() => useAlertAndEventDefinitionData(eventId)); + + await expect(result.current).toEqual(mockedHookData); + }); + + it('should return expected data for event page', async () => { + const eventId = 'event-id-2'; + + asMock(useEventById).mockReturnValue({ + ...hookResultBase, + data: { ...mockEventData.event, id: eventId, alert: false }, + }); + + const { result } = renderHook(() => useAlertAndEventDefinitionData(eventId)); + + await expect(result.current).toEqual({ + ...mockedHookData, + eventData: { ...mockEventData.event, id: eventId, alert: false }, + alertId: eventId, + }); + }); + + it('should return expected data for event definition', async () => { + asMock(useEventById).mockReturnValue({ + ...hookResultBase, + data: undefined, + }); + + const { result } = renderHook(() => useAlertAndEventDefinitionData(undefined, mockEventDefinitionTwoAggregations.id)); + + await expect(result.current).toEqual({ + ...mockedHookData, + eventData: undefined, + alertId: undefined, + isLoading: false, + }); + }); +}); diff --git a/graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.tsx similarity index 62% rename from graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.tsx rename to graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.tsx index 930acc793a73..5cd2e5118eef 100644 --- a/graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData.tsx @@ -17,44 +17,36 @@ import { useMemo } from 'react'; -import useLocation from 'routing/useLocation'; -import Routes from 'routing/Routes'; -import useParams from 'routing/useParams'; import type { Event } from 'components/events/events/types'; import type { EventDefinitionAggregation } from 'hooks/useEventDefinition'; import useEventDefinition from 'hooks/useEventDefinition'; import type { EventDefinition } from 'components/event-definitions/event-definitions-types'; import useEventById from 'hooks/useEventById'; -const useAlertAndEventDefinitionData = () => { - const { pathname: path } = useLocation(); - const { alertId, definitionId } = useParams<{ alertId?: string, definitionId?: string }>(); - const { data: eventData } = useEventById(alertId); - const { data } = useEventDefinition(definitionId ?? eventData?.event_definition_id); +const useAlertAndEventDefinitionData = (alertId: string, definitionId?: string) => { + const { data: eventData, isLoading: isLoadingEvent } = useEventById(alertId); + const { data, isLoading: isLoadingEventDefinition } = useEventDefinition(definitionId ?? eventData?.event_definition_id); const eventDefinition = data?.eventDefinition; const aggregations = data?.aggregations; + const isLoading = (alertId && isLoadingEvent) || (definitionId && isLoadingEventDefinition); return useMemo<{ alertId: string, definitionId: string, definitionTitle: string, - isAlert: boolean, - isEvent: boolean, - isEventDefinition: boolean, eventData: Event, eventDefinition: EventDefinition, aggregations: Array, + isLoading: boolean, }>(() => ({ alertId, definitionId: eventDefinition?.id, definitionTitle: eventDefinition?.title, - isAlert: (path === Routes.ALERTS.replay_search(alertId)) && eventData && eventData?.alert, - isEvent: !!alertId && (path === Routes.ALERTS.replay_search(alertId)) && eventData && !eventData?.alert, - isEventDefinition: !!definitionId && (path === Routes.ALERTS.DEFINITIONS.replay_search(definitionId)) && !!eventDefinition, eventData, eventDefinition, aggregations, - }), [alertId, eventDefinition, path, eventData, definitionId, aggregations]); + isLoading, + }), [alertId, eventDefinition, eventData, aggregations, isLoading]); }; export default useAlertAndEventDefinitionData; diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAttributeComponents.tsx b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAttributeComponents.tsx index 13b8a7fcf4a5..7a354b5f7718 100644 --- a/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAttributeComponents.tsx +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useAttributeComponents.tsx @@ -21,13 +21,15 @@ import upperFirst from 'lodash/upperFirst'; import { TIME_UNITS } from 'components/event-definitions/event-definition-types/FilterForm'; import EventDefinitionPriorityEnum from 'logic/alerts/EventDefinitionPriorityEnum'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import { extractDurationAndUnit } from 'components/common/TimeUnitInput'; import { Timestamp, HoverForHelp } from 'components/common'; import { Link } from 'components/common/router'; import Routes from 'routing/Routes'; +import useReplaySearchContext from 'components/event-definitions/replay-search/hooks/useReplaySearchContext'; -import AggregationConditions from '../AggreagtionConditions'; +import useAlertAndEventDefinitionData from './useAlertAndEventDefinitionData'; + +import AggregationConditions from '../AggregationConditions'; import Notifications from '../Notifications'; const AlertTimestamp = styled(Timestamp)(({ theme }) => css` @@ -35,9 +37,12 @@ const AlertTimestamp = styled(Timestamp)(({ theme }) => css` `); const useAttributeComponents = () => { - const { eventData, eventDefinition, isEventDefinition } = useAlertAndEventDefinitionData(); + const { alertId, definitionId, type } = useReplaySearchContext(); + const { eventData, eventDefinition } = useAlertAndEventDefinitionData(alertId, definitionId); return useMemo(() => { + const isEventDefinition = type === 'event_definition'; + if (!eventDefinition) { return [ { title: 'Timestamp', content: , show: !isEventDefinition }, @@ -46,7 +51,7 @@ const useAttributeComponents = () => { const searchWithin = extractDurationAndUnit(eventDefinition.config.search_within_ms, TIME_UNITS); const executeEvery = extractDurationAndUnit(eventDefinition.config.execute_every_ms, TIME_UNITS); - const isEDUpdatedAfterEvent = !isEventDefinition && moment(eventDefinition.updated_at).diff(eventData.timestamp) > 0; + const isEDUpdatedAfterEvent = !isEventDefinition && moment(eventDefinition.updated_at).diff(eventData?.timestamp) > 0; return [ { title: 'Timestamp', content: , show: !isEventDefinition }, @@ -89,11 +94,7 @@ const useAttributeComponents = () => { content: , }, ]; - }, [ - eventData?.timestamp, - eventDefinition, - isEventDefinition, - ]); + }, [eventData?.timestamp, eventDefinition, type]); }; export default useAttributeComponents; diff --git a/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useReplaySearchContext.ts b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useReplaySearchContext.ts new file mode 100644 index 000000000000..2d8daa382a19 --- /dev/null +++ b/graylog2-web-interface/src/components/event-definitions/replay-search/hooks/useReplaySearchContext.ts @@ -0,0 +1,22 @@ +/* + * 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 { useContext } from 'react'; + +import ReplaySearchContext from '../ReplaySearchContext'; + +const useReplaySearchContext = () => useContext(ReplaySearchContext); +export default useReplaySearchContext; diff --git a/graylog2-web-interface/src/components/event-definitions/types.d.ts b/graylog2-web-interface/src/components/event-definitions/types.ts similarity index 96% rename from graylog2-web-interface/src/components/event-definitions/types.d.ts rename to graylog2-web-interface/src/components/event-definitions/types.ts index a6fc4b9a3725..e27e8c66301a 100644 --- a/graylog2-web-interface/src/components/event-definitions/types.d.ts +++ b/graylog2-web-interface/src/components/event-definitions/types.ts @@ -21,6 +21,8 @@ import type { SearchBarControl } from 'views/types'; import type User from 'logic/users/User'; import type { EventDefinition } from 'components/event-definitions/event-definitions-types'; +export type AlertType = 'alert' | 'event' | 'event_definition'; + export interface EventDefinitionValidation { errors: { config?: unknown, diff --git a/graylog2-web-interface/src/components/events/ReplaySearch.tsx b/graylog2-web-interface/src/components/events/ReplaySearch.tsx new file mode 100644 index 000000000000..fdfa570fe714 --- /dev/null +++ b/graylog2-web-interface/src/components/events/ReplaySearch.tsx @@ -0,0 +1,101 @@ +/* + * 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 { useMemo } from 'react'; + +import useAlertAndEventDefinitionData + from 'components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'; +import useCreateSearch from 'views/hooks/useCreateSearch'; +import EventInfoBar from 'components/event-definitions/replay-search/EventInfoBar'; +import SearchPageLayoutProvider from 'views/components/contexts/SearchPageLayoutProvider'; +import SearchPage from 'views/pages/SearchPage'; +import ReplaySearchContext from 'components/event-definitions/replay-search/ReplaySearchContext'; +import type { LayoutState } from 'views/components/contexts/SearchPageLayoutContext'; +import Spinner from 'components/common/Spinner'; +import type { EventDefinition } from 'components/event-definitions/event-definitions-types'; +import type { Event } from 'components/events/events/types'; +import type { EventDefinitionAggregation } from 'hooks/useEventDefinition'; +import useCreateViewForEvent from 'views/logic/views/UseCreateViewForEvent'; + +type ReplaySearchProps = { + alertId: string, + definitionId: string, + eventDefinition: EventDefinition, + event: Event, + aggregations: EventDefinitionAggregation[], + replayEventDefinition: boolean, + searchPageLayout: Partial, + forceSidebarPinned: boolean, +} + +const defaultSearchPageLayout = {}; + +const ReplaySearch = ({ + alertId, definitionId, eventDefinition, aggregations, event: eventData, replayEventDefinition, searchPageLayout, forceSidebarPinned, +}: ReplaySearchProps) => { + const _view = useCreateViewForEvent({ eventData, eventDefinition, aggregations }); + const view = useCreateSearch(_view); + const _searchPageLayout = useMemo(() => ({ + ...searchPageLayout, + infoBar: { component: EventInfoBar }, + }), [searchPageLayout]); + const replaySearchContext = useMemo(() => ({ + alertId, + definitionId, + // eslint-disable-next-line no-nested-ternary + type: replayEventDefinition ? 'event_definition' : eventData?.alert ? 'alert' : 'event', + } as const), [alertId, definitionId, eventData?.alert, replayEventDefinition]); + + return ( + + + + + + ); +}; + +type Props = { + alertId: string, + definitionId: string, + replayEventDefinition?: boolean, + searchPageLayout?: Partial, + forceSidebarPinned?: boolean, +} + +const LoadingBarrier = ({ + alertId, definitionId, replayEventDefinition = false, searchPageLayout = defaultSearchPageLayout, forceSidebarPinned = false, +}: Props) => { + const { eventDefinition, aggregations, eventData, isLoading } = useAlertAndEventDefinitionData(alertId, definitionId); + + return isLoading + ? + : ( + + ); +}; + +export default LoadingBarrier; diff --git a/graylog2-web-interface/src/components/events/bulk-replay/BulkEventReplay.tsx b/graylog2-web-interface/src/components/events/bulk-replay/BulkEventReplay.tsx index a09218917207..a50b23117379 100644 --- a/graylog2-web-interface/src/components/events/bulk-replay/BulkEventReplay.tsx +++ b/graylog2-web-interface/src/components/events/bulk-replay/BulkEventReplay.tsx @@ -20,13 +20,14 @@ import styled, { css } from 'styled-components'; import EventListItem from 'components/events/bulk-replay/EventListItem'; import useSelectedEvents from 'components/events/bulk-replay/useSelectedEvents'; -import ReplaySearch from 'components/events/bulk-replay/ReplaySearch'; +import ReplaySearch from 'components/events/ReplaySearch'; import type { Event } from 'components/events/events/types'; import Button from 'components/bootstrap/Button'; import DropdownButton from 'components/bootstrap/DropdownButton'; import useEventBulkActions from 'components/events/events/hooks/useEventBulkActions'; import Center from 'components/common/Center'; import ButtonToolbar from 'components/bootstrap/ButtonToolbar'; +import type { LayoutState } from 'views/components/contexts/SearchPageLayoutContext'; const Container = styled.div` display: flex; @@ -100,6 +101,13 @@ const RemainingBulkActions = ({ completed, events }: RemainingBulkActionsProps) ); }; +const searchPageLayout: Partial = { + sidebar: { + isShown: false, + }, + synchronizeUrl: false, +} as const; + const ReplayedSearch = ({ total, completed, selectedEvent }: React.PropsWithChildren<{ total: number; completed: number; @@ -129,7 +137,12 @@ const ReplayedSearch = ({ total, completed, selectedEvent }: React.PropsWithChil ); } - return ; + return ( + + ); }; const Headline = styled.h2` diff --git a/graylog2-web-interface/src/components/events/bulk-replay/ReplaySearch.tsx b/graylog2-web-interface/src/components/events/bulk-replay/ReplaySearch.tsx deleted file mode 100644 index f3de6a2c031a..000000000000 --- a/graylog2-web-interface/src/components/events/bulk-replay/ReplaySearch.tsx +++ /dev/null @@ -1,58 +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 { useMemo } from 'react'; - -import useCreateSearch from 'views/hooks/useCreateSearch'; -import EventInfoBar from 'components/event-definitions/replay-search/EventInfoBar'; -import SearchPageLayoutProvider from 'views/components/contexts/SearchPageLayoutProvider'; -import SearchPage from 'views/pages/SearchPage'; -import useCreateViewForEvent from 'views/logic/views/UseCreateViewForEvent'; -import useEventDefinition from 'hooks/useEventDefinition'; -import Spinner from 'components/common/Spinner'; -import type { Event } from 'components/events/events/types'; - -type Props = { - event: Event; -} - -const ViewReplaySearch = ({ eventData, eventDefinition, aggregations }) => { - const _view = useCreateViewForEvent({ eventData, eventDefinition, aggregations }); - const view = useCreateSearch(_view); - const searchPageLayout = useMemo(() => ({ - sidebar: { - isShown: false, - }, - infoBar: { component: EventInfoBar }, - synchronizeUrl: false, - } as const), []); - - return ( - - - - ); -}; - -const ReplaySearch = ({ event }: Props) => { - const { data, isLoading } = useEventDefinition(event.event_definition_id); - - return isLoading ? - : ; -}; - -export default ReplaySearch; diff --git a/graylog2-web-interface/src/components/events/bulk-replay/__tests__/BulkEventReplay.test.tsx b/graylog2-web-interface/src/components/events/bulk-replay/__tests__/BulkEventReplay.test.tsx index 9a16f662df4a..559d661a9bb8 100644 --- a/graylog2-web-interface/src/components/events/bulk-replay/__tests__/BulkEventReplay.test.tsx +++ b/graylog2-web-interface/src/components/events/bulk-replay/__tests__/BulkEventReplay.test.tsx @@ -19,7 +19,6 @@ import { render, screen, waitFor } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; import { PluginManifest } from 'graylog-web-plugin/plugin'; -import type { Event } from 'components/events/events/types'; import { usePlugin } from 'views/test/testPlugins'; import MenuItem from 'components/bootstrap/menuitem/MenuItem'; @@ -33,7 +32,7 @@ const initialEventIds = [ '01JH0029TS9PX5ED87TZ1RVRT2', ]; -jest.mock('components/events/bulk-replay/ReplaySearch', () => ({ event }: { event: Event }) => Replaying search for event {event.id}); +jest.mock('components/events/ReplaySearch', () => ({ alertId }: { alertId: string }) => Replaying search for event {alertId}); const markEventAsInvestigated = async (eventId: string) => { const markAsInvestigatedButton = await screen.findByRole('button', { name: new RegExp(`mark event "${eventId}" as investigated`, 'i') }); diff --git a/graylog2-web-interface/src/components/events/events/hooks/useEventAction.tsx b/graylog2-web-interface/src/components/events/events/hooks/useEventAction.tsx index a18259bae8ed..e26b63c147a4 100644 --- a/graylog2-web-interface/src/components/events/events/hooks/useEventAction.tsx +++ b/graylog2-web-interface/src/components/events/events/hooks/useEventAction.tsx @@ -19,16 +19,19 @@ import React, { useMemo } from 'react'; import usePluggableEventActions from 'components/events/events/hooks/usePluggableEventActions'; import { MenuItem } from 'components/bootstrap'; import LinkToReplaySearch from 'components/event-definitions/replay-search/LinkToReplaySearch'; +import useSendEventActionTelemetry from 'components/events/events/hooks/useSendEventActionTelemetry'; +import type { Event } from 'components/events/events/types'; -const useEventAction = (event) => { +const useEventAction = (event: Event) => { const { actions: pluggableActions, actionModals: pluggableActionModals } = usePluggableEventActions([event]); + const sendEventActionTelemetry = useSendEventActionTelemetry(); const hasReplayInfo = !!event.replay_info; const moreActions = useMemo(() => [ - hasReplayInfo ? : null, + hasReplayInfo ? sendEventActionTelemetry('REPLAY_SEARCH', false)} id={event.id} isEvent /> : null, pluggableActions.length && hasReplayInfo ? : null, pluggableActions.length ? pluggableActions : null, - ].filter(Boolean), [event.id, hasReplayInfo, pluggableActions]); + ].filter(Boolean), [sendEventActionTelemetry, event.id, hasReplayInfo, pluggableActions]); return { moreActions, pluggableActionModals }; }; diff --git a/graylog2-web-interface/src/components/events/events/hooks/usePluggableEventActions.tsx b/graylog2-web-interface/src/components/events/events/hooks/usePluggableEventActions.tsx index def356759d9d..07a327fa1bc7 100644 --- a/graylog2-web-interface/src/components/events/events/hooks/usePluggableEventActions.tsx +++ b/graylog2-web-interface/src/components/events/events/hooks/usePluggableEventActions.tsx @@ -30,7 +30,8 @@ const usePluggableEventActions = (events: Array, onlyBulk: boolean = fals const actions = availableActions.map(({ component: PluggableEventAction, key }: { component: React.ComponentType, key: string }) => ( modalRefs.current[key]} /> + modalRef={() => modalRefs.current[key]} + fromBulk={onlyBulk} /> )); const actionModals = availableActions @@ -38,7 +39,8 @@ const usePluggableEventActions = (events: Array, onlyBulk: boolean = fals .map(({ modal: ActionModal, key }) => ( { modalRefs.current[key] = r; }} /> + ref={(r) => { modalRefs.current[key] = r; }} + fromBulk={onlyBulk} /> )); return ({ actions, actionModals }); diff --git a/graylog2-web-interface/src/components/events/events/hooks/useSendEventActionTelemetry.ts b/graylog2-web-interface/src/components/events/events/hooks/useSendEventActionTelemetry.ts new file mode 100644 index 000000000000..35d376069c8e --- /dev/null +++ b/graylog2-web-interface/src/components/events/events/hooks/useSendEventActionTelemetry.ts @@ -0,0 +1,42 @@ +/* + * 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 } from 'react'; + +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import useActivePerspective from 'components/perspectives/hooks/useActivePerspective'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import { getPathnameWithoutId } from 'util/URLUtils'; +import useLocation from 'routing/useLocation'; + +const useSendEventActionTelemetry = () => { + const sendTelemetry = useSendTelemetry(); + const { pathname } = useLocation(); + const { activePerspective } = useActivePerspective(); + + return useCallback((actionName: string, fromBulk: boolean) => sendTelemetry(TELEMETRY_EVENT_TYPE.ALERTS_AND_EVENTS.ACTION_RAN, { + app_pathname: getPathnameWithoutId(pathname), + app_section: 'alerts-and-events', + event_details: { + actionName, + fromBulk: !!fromBulk, + perspectiveId: activePerspective.id, + }, + }), [activePerspective.id, pathname, sendTelemetry]); +}; + +export default useSendEventActionTelemetry; diff --git a/graylog2-web-interface/src/components/inputs/InputDiagnosis/NetworkStats.tsx b/graylog2-web-interface/src/components/inputs/InputDiagnosis/NetworkStats.tsx new file mode 100644 index 000000000000..c1e3917385b1 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputDiagnosis/NetworkStats.tsx @@ -0,0 +1,83 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import NumberUtils from 'util/NumberUtils'; +import { Icon } from 'components/common'; + +const InputIO = styled.span(({ theme }) => css` + margin-left: -5px; + + .total { + color: ${theme.colors.gray[70]}; + } + + .value { + font-family: ${theme.fonts.family.monospace}; + } + + .persec { + margin-left: 3px; + } + + .channel-direction { + position: relative; + left: -1px; + } + + .channel-direction-down { + position: relative; + top: 1px; + } + + .channel-direction-up { + position: relative; + top: -1px; + } +`); + +type Props = { + writtenBytes1Sec: number, + writtenBytesTotal: number, + readBytes1Sec: number, + readBytesTotal: number, +} + +const NetworkStats = ({ writtenBytes1Sec, writtenBytesTotal, readBytes1Sec, readBytesTotal }: Props) => ( + + + + {NumberUtils.formatBytes(readBytes1Sec)} + + + {NumberUtils.formatBytes(writtenBytes1Sec)} + + + + (total: + + {NumberUtils.formatBytes(readBytesTotal)} + + + {NumberUtils.formatBytes(writtenBytesTotal)} + ) + + +); + +export default NetworkStats; diff --git a/graylog2-web-interface/src/components/inputs/InputDiagnosis/ShowReceivedMessagesButton.tsx b/graylog2-web-interface/src/components/inputs/InputDiagnosis/ShowReceivedMessagesButton.tsx new file mode 100644 index 000000000000..11a114e82632 --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputDiagnosis/ShowReceivedMessagesButton.tsx @@ -0,0 +1,61 @@ +/* + * 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 { Button } from 'components/bootstrap'; +import { isPermitted } from 'util/PermissionsMixin'; +import type { Input } from 'components/messageloaders/Types'; +import { LinkContainer } from 'components/common/router'; +import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import recentMessagesTimeRange from 'util/TimeRangeHelper'; +import { getPathnameWithoutId } from 'util/URLUtils'; +import useCurrentUser from 'hooks/useCurrentUser'; +import Routes from 'routing/Routes'; +import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; +import useLocation from 'routing/useLocation'; + +type Props = { + input: Input, +} + +const ShowReceivedMessagesButton = ({ input }: Props) => { + const currentUser = useCurrentUser(); + const sendTelemetry = useSendTelemetry(); + const { pathname } = useLocation(); + + const queryField = (input?.type === 'org.graylog.plugins.forwarder.input.ForwarderServiceInput') ? 'gl2_forwarder_input' : 'gl2_source_input'; + + if (input?.id && isPermitted(currentUser.permissions, ['searches:relative'])) { + return ( + + + + ); + } + + return null; +}; + +export default ShowReceivedMessagesButton; diff --git a/graylog2-web-interface/src/components/inputs/InputDiagnosis/useInputDiagnosis.ts b/graylog2-web-interface/src/components/inputs/InputDiagnosis/useInputDiagnosis.ts new file mode 100644 index 000000000000..068df51dcc1c --- /dev/null +++ b/graylog2-web-interface/src/components/inputs/InputDiagnosis/useInputDiagnosis.ts @@ -0,0 +1,153 @@ +/* + * 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 { useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { useStore } from 'stores/connect'; +import InputStatesStore from 'stores/inputs/InputStatesStore'; +import { InputsStore, InputsActions } from 'stores/inputs/InputsStore'; +import { MetricsStore, MetricsActions } from 'stores/metrics/MetricsStore'; +import type { InputStateByNode, InputStates } from 'stores/inputs/InputStatesStore'; +import type { Input } from 'components/messageloaders/Types'; +import type { CounterMetric, GaugeMetric, Rate } from 'stores/metrics/MetricsStore'; +import { qualifyUrl } from 'util/URLUtils'; +import fetch from 'logic/rest/FetchProvider'; +import { defaultOnError } from 'util/conditional/onError'; + +export type InputDiagnosisMetrics = { + incomingMessagesTotal: number; + emptyMessages: number; + open_connections: number; + total_connections: number; + read_bytes_1sec: number; + read_bytes_total: number; + write_bytes_1sec: number; + write_bytes_total: number; + failures_indexing: any; + failures_processing: any; + failures_inputs_codecs: any; + stream_message_count: [string, number][]; +} + +export type InputNodeStateInfo = { + detailed_message: string, + node_id: string, +} + +export type InputNodeStates = { + states: { + 'RUNNING'?: InputNodeStateInfo[], + 'FAILED'?: InputNodeStateInfo[], + 'STOPPED'?: InputNodeStateInfo[], + 'STARTING'?: InputNodeStateInfo[], + 'FAILING'?: InputNodeStateInfo[], + 'SETUP'?: InputNodeStateInfo[], + } + total: number; +} + +export type InputDiagnostics = { + stream_message_count: { + [streamName: string]: number, + }, +} + +export const metricWithPrefix = (input: Input, metric: string) => `${input?.type}.${input?.id}.${metric}`; + +export const fetchInputDiagnostics = (inputId: string): Promise => fetch('GET', qualifyUrl(`system/inputs/diagnostics/${inputId}`)); + +const useInputDiagnosis = (inputId: string): { + input: Input, + inputNodeStates: InputNodeStates, + inputMetrics: InputDiagnosisMetrics, +} => { + const { input } = useStore(InputsStore); + + useEffect(() => { + InputsActions.get(inputId); + }, [inputId]); + + const { data: messageCountByStream } = useQuery( + ['input-diagnostics', inputId], + () => defaultOnError(fetchInputDiagnostics(inputId), 'Fetching Input Diagnostics failed with status', 'Could not fetch Input Diagnostics'), + { refetchInterval: 5000 }, + ); + + const { inputStates } = useStore(InputStatesStore) as { inputStates: InputStates }; + const inputStateByNode = inputStates ? inputStates[inputId] || {} : {} as InputStateByNode; + const inputNodeStates = { total: Object.keys(inputStateByNode).length, states: {} }; + + Object.values(inputStateByNode).forEach(({ state, detailed_message, message_input: { node: node_id } }) => { + if (!inputNodeStates.states[state]) { + inputNodeStates.states[state] = [{ detailed_message, node_id }]; + } else if (Array.isArray(inputNodeStates.states[state])) { + inputNodeStates.states[state].push({ detailed_message, node_id }); + } + }); + + const failures_indexing = `org.graylog2.${inputId}.failures.indexing`; + const failures_processing = `org.graylog2.${inputId}.failures.processing`; + const failures_inputs_codecs = `org.graylog2.inputs.codecs.*.${inputId}.failures`; + + const InputDiagnosisMetricNames = useMemo(() => ([ + metricWithPrefix(input, 'incomingMessages'), + metricWithPrefix(input, 'emptyMessages'), + metricWithPrefix(input, 'open_connections'), + metricWithPrefix(input, 'total_connections'), + metricWithPrefix(input, 'written_bytes_1sec'), + metricWithPrefix(input, 'written_bytes_total'), + metricWithPrefix(input, 'read_bytes_1sec'), + metricWithPrefix(input, 'read_bytes_total'), + metricWithPrefix(input, 'failures.indexing'), + metricWithPrefix(input, 'failures.processing'), + failures_indexing, + failures_processing, + failures_inputs_codecs, + ]), [input, failures_indexing, failures_processing, failures_inputs_codecs]); + + const { metrics: metricsByNode } = useStore(MetricsStore); + const nodeMetrics = (metricsByNode && input?.node) ? metricsByNode[input?.node] : {}; + + useEffect(() => { + InputDiagnosisMetricNames.forEach((metricName) => MetricsActions.addGlobal(metricName)); + + return () => { + InputDiagnosisMetricNames.forEach((metricName) => MetricsActions.removeGlobal(metricName)); + }; + }, [InputDiagnosisMetricNames]); + + return { + input, + inputNodeStates, + inputMetrics: { + incomingMessagesTotal: (nodeMetrics[metricWithPrefix(input, 'incomingMessages')]?.metric as Rate)?.rate?.total || 0, + emptyMessages: (nodeMetrics[metricWithPrefix(input, 'emptyMessages')] as CounterMetric)?.metric?.count || 0, + open_connections: (nodeMetrics[metricWithPrefix(input, 'open_connections')] as GaugeMetric)?.metric?.value, + total_connections: (nodeMetrics[metricWithPrefix(input, 'total_connections')] as GaugeMetric)?.metric?.value, + read_bytes_1sec: (nodeMetrics[metricWithPrefix(input, 'read_bytes_1sec')] as GaugeMetric)?.metric?.value, + read_bytes_total: (nodeMetrics[metricWithPrefix(input, 'read_bytes_total')] as GaugeMetric)?.metric?.value, + write_bytes_1sec: (nodeMetrics[metricWithPrefix(input, 'write_bytes_1sec')] as GaugeMetric)?.metric?.value, + write_bytes_total: (nodeMetrics[metricWithPrefix(input, 'write_bytes_total')] as GaugeMetric)?.metric?.value, + failures_indexing: (nodeMetrics[failures_indexing]?.metric as Rate)?.rate?.fifteen_minute || 0, + failures_processing: (nodeMetrics[failures_processing]?.metric as Rate)?.rate?.fifteen_minute || 0, + failures_inputs_codecs: (nodeMetrics[failures_inputs_codecs]?.metric as Rate)?.rate?.fifteen_minute || 0, + stream_message_count: Object.entries(messageCountByStream?.stream_message_count || {}), + }, + }; +}; + +export default useInputDiagnosis; diff --git a/graylog2-web-interface/src/components/inputs/InputListItem.tsx b/graylog2-web-interface/src/components/inputs/InputListItem.tsx index 725a87f88100..a918154eb40b 100644 --- a/graylog2-web-interface/src/components/inputs/InputListItem.tsx +++ b/graylog2-web-interface/src/components/inputs/InputListItem.tsx @@ -210,6 +210,19 @@ const InputListItem = ({ input, currentNode, permissions }: Props) => { disabled={definition === undefined}> Edit input + + + { + sendTelemetry(TELEMETRY_EVENT_TYPE.INPUTS.INPUT_DIAGNOSIS_CLICKED, { + app_pathname: getPathnameWithoutId(pathname), + app_action_value: 'input-diagnosis', + }); + }}> + Input Diagnosis + + + {inputSetupFeatureFlagIsEnabled && ( isInputInSetupMode(inputStates, input.id) ? ( { )} - {input.global && ( + {input.global && input.node && ( { diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/Wizard.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/Wizard.tsx index 9e117c250efe..689a44b8c1e2 100644 --- a/graylog2-web-interface/src/components/inputs/InputSetupWizard/Wizard.tsx +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/Wizard.tsx @@ -90,7 +90,7 @@ const Wizard = ({ show, input, onClose }: Props) => { ), component: ( - + onClose()} /> ), disabled: !getStepConfigOrData(stepsConfig, INPUT_WIZARD_STEPS.INPUT_DIAGNOSIS, 'enabled'), }, @@ -98,7 +98,7 @@ const Wizard = ({ show, input, onClose }: Props) => { if (enterpriseSteps) return { ...defaultSteps, ...enterpriseSteps }; return defaultSteps; - }, [enterpriseSteps, stepsConfig]); + }, [enterpriseSteps, stepsConfig, onClose]); const determineFirstStep = useCallback(() => { setActiveStep(INPUT_WIZARD_STEPS.SETUP_ROUTING); diff --git a/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx index 08980241c651..e8e961abd5bd 100644 --- a/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx +++ b/graylog2-web-interface/src/components/inputs/InputSetupWizard/steps/InputDiagnosisStep.tsx @@ -15,11 +15,57 @@ * . */ import * as React from 'react'; +import styled, { css } from 'styled-components'; -const InputDiagnosisStep = () => ( -
- Input Diagnosis -
-); +import { Button, Row, Col } from 'components/bootstrap'; +import Routes from 'routing/Routes'; +import useInputSetupWizard from 'components/inputs/InputSetupWizard/hooks/useInputSetupWizard'; + +const StepCol = styled(Col)(({ theme }) => css` + padding-left: ${theme.spacings.lg}; + padding-right: ${theme.spacings.lg}; + padding-top: ${theme.spacings.sm}; +`); + +const DescriptionCol = styled(Col)(({ theme }) => css` + margin-bottom: ${theme.spacings.md}; +`); + +const ButtonCol = styled(Col)(({ theme }) => css` + display: flex; + justify-content: flex-end; + gap: ${theme.spacings.xs}; + margin-top: ${theme.spacings.lg}; +`); + +type Props = { + onClose: () => void, +} + +const InputDiagnosisStep = ({ onClose }: Props) => { + const { wizardData: { input } } = useInputSetupWizard(); + + return ( + + + + +

Test inputs and parsing without writing any data to the search cluster.

+
+
+ {input?.id && ( + + )} + + + + + +
+
+ ); +}; export default InputDiagnosisStep; diff --git a/graylog2-web-interface/src/components/navigation/MainNavbar.test.tsx b/graylog2-web-interface/src/components/navigation/MainNavbar.test.tsx index 0752b40a2742..6a9376c18302 100644 --- a/graylog2-web-interface/src/components/navigation/MainNavbar.test.tsx +++ b/graylog2-web-interface/src/components/navigation/MainNavbar.test.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import type { PluginExports } from 'graylog-web-plugin/plugin'; import { PluginManifest, PluginStore } from 'graylog-web-plugin/plugin'; import { defaultUser } from 'defaultMockValues'; +import userEvent from '@testing-library/user-event'; import AppConfig from 'util/AppConfig'; import { asMock } from 'helpers/mocking'; @@ -27,6 +28,7 @@ import useCurrentUser from 'hooks/useCurrentUser'; import { adminUser } from 'fixtures/users'; import PerspectivesProvider from 'components/perspectives/contexts/PerspectivesProvider'; import PerspectivesBindings from 'components/perspectives/bindings'; +import { examplePerspective } from 'fixtures/perspectives'; import MainNavbar from './MainNavbar'; @@ -74,6 +76,28 @@ describe('MainNavbar', () => { { path: '/newpluginroute', description: 'New dropdown route', requiredFeatureFlag: 'enable_dropdown_nav_item' }, ], }, + { + description: 'Merged dropdown test', + path: '/', + children: [ + { path: '/another-route', description: 'Menu item for general perspective' }, + ], + }, + { + description: 'Merged dropdown test', + path: '/', + children: [ + { path: '/just-another-route', description: 'Merged item for general perspective' }, + ], + }, + { + description: 'Merged dropdown test', + path: '/', + perspective: examplePerspective.id, + children: [ + { path: '/another-route', description: 'Menu item for specific perspective' }, + ], + }, ], } as PluginExports, }; @@ -176,6 +200,25 @@ describe('MainNavbar', () => { await screen.findByRole('button', { name: /neat stuff \/ something else/i }); }); + + it('should merge navigation dropdowns when their description is equal', async () => { + render(); + + userEvent.click(await screen.findByRole('button', { name: /Merged dropdown test/i })); + + await screen.findByRole('menuitem', { name: /Menu item for general perspective/i }); + await screen.findByRole('menuitem', { name: /Merged item for general perspective/i }); + }); + + it('should not merge navigation dropdowns when their assigned perspective varies', async () => { + render(); + + userEvent.click(await screen.findByRole('button', { name: /Merged dropdown test/i })); + + await screen.findByRole('menuitem', { name: /Menu item for general perspective/i }); + + expect(screen.queryByRole('menuitem', { name: /Menu item for specific perspective/i })).not.toBeInTheDocument(); + }); }); describe('uses correct permissions:', () => { @@ -196,7 +239,7 @@ describe('MainNavbar', () => { render(); - expect(await screen.findByRole('link', { name: /enterprise/i })).toBeInTheDocument(); + await screen.findByRole('link', { name: /enterprise/i }); }); }); }); diff --git a/graylog2-web-interface/src/components/navigation/MainNavbar.tsx b/graylog2-web-interface/src/components/navigation/MainNavbar.tsx index fe222c2915ff..276bda3637c6 100644 --- a/graylog2-web-interface/src/components/navigation/MainNavbar.tsx +++ b/graylog2-web-interface/src/components/navigation/MainNavbar.tsx @@ -118,7 +118,7 @@ const _existingDropdownItemIndex = (existingNavigationItems: Array newNavigationItem.description === description && children); + return existingNavigationItems.findIndex(({ description, perspective, children }) => newNavigationItem.description === description && newNavigationItem.perspective === perspective && children); }; const mergeDuplicateDropdowns = (navigationItems: Array): Array => navigationItems.reduce((result, current) => { diff --git a/graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.test.tsx b/graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.test.tsx deleted file mode 100644 index dd5e2dc1c24e..000000000000 --- a/graylog2-web-interface/src/hooks/useAlertAndEventDefinitionData.test.tsx +++ /dev/null @@ -1,173 +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 { renderHook } from 'wrappedTestingLibrary/hooks'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { useParams } from 'react-router-dom'; -import type { Location } from 'react-router-dom'; - -import useLocation from 'routing/useLocation'; -import { - mockedMappedAggregation, - mockEventData, - mockEventDefinitionTwoAggregations, -} from 'helpers/mocking/EventAndEventDefinitions_mock'; -import asMock from 'helpers/mocking/AsMock'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; -import useEventById from 'hooks/useEventById'; - -jest.mock('logic/rest/FetchProvider', () => jest.fn(() => Promise.resolve())); -jest.mock('routing/useLocation', () => jest.fn(() => ({ pathname: '/' }))); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn(() => ({})), -})); - -jest.mock('graylog-web-plugin/plugin', () => ({ - PluginStore: { exports: jest.fn(() => [{ type: 'aggregation', defaults: {} }]) }, -})); - -jest.mock('views/logic/Widgets', () => ({ - ...jest.requireActual('views/logic/Widgets'), - widgetDefinition: () => ({ - searchTypes: () => [{ - type: 'AGGREGATION', - typeDefinition: {}, - }], - }), -})); - -jest.mock('./useEventById'); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); -const wrapper = ({ children }) => ( - - {children} - -); - -const mockedHookData = { - alertId: mockEventData.event.id, - definitionId: mockEventData.event.event_definition_id, - definitionTitle: mockEventDefinitionTwoAggregations.title, - isAlert: true, - isEvent: false, - isEventDefinition: false, - eventData: mockEventData.event, - eventDefinition: mockEventDefinitionTwoAggregations, - aggregations: mockedMappedAggregation, -}; - -const mockUseRouterForEvent = (id) => asMock(useLocation).mockImplementation(() => ({ - pathname: `/alerts/${id}/replay-search`, -} as Location)); - -const mockUseRouterForEventDefinition = (id) => asMock(useLocation).mockImplementation(() => ({ - pathname: `/alerts/definitions/${id}/replay-search`, -} as Location)); - -const baseEventResponse = { - refetch: () => {}, - isLoading: false, - isFetched: true, -}; - -describe('useAlertAndEventDefinitionData', () => { - beforeEach(() => { - queryClient.clear(); - - queryClient.setQueryData(['event-definition-by-id', mockEventDefinitionTwoAggregations.id], { - eventDefinition: mockEventDefinitionTwoAggregations, - aggregations: mockedMappedAggregation, - }); - - asMock(useEventById).mockReturnValue({ ...baseEventResponse, data: undefined }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return expected data for alert page', async () => { - const eventId = 'event-id-1'; - mockUseRouterForEvent(eventId); - - asMock(useParams).mockImplementation(() => ({ - alertId: mockEventData.event.id, - })); - - asMock(useEventById).mockReturnValue({ ...baseEventResponse, data: { ...mockEventData.event, id: eventId, alert: true } }); - const { result } = renderHook(() => useAlertAndEventDefinitionData(), { wrapper }); - - await expect(result.current).toEqual(mockedHookData); - }); - - it('should return expected data for event page', async () => { - const eventId = 'event-id-2'; - - asMock(useParams).mockImplementation(() => ({ - alertId: eventId, - })); - - mockUseRouterForEvent(eventId); - - asMock(useEventById).mockReturnValue({ - ...baseEventResponse, - data: { - ...mockEventData.event, - id: eventId, - alert: false, - }, - }); - - const { result } = renderHook(() => useAlertAndEventDefinitionData(), { wrapper }); - - await expect(result.current).toEqual({ - ...mockedHookData, - eventData: { ...mockEventData.event, id: eventId, alert: false }, - alertId: eventId, - isAlert: false, - isEvent: true, - }); - }); - - it('should return expected data for event definition', async () => { - asMock(useParams).mockImplementation(() => ({ - definitionId: mockEventDefinitionTwoAggregations.id, - })); - - mockUseRouterForEventDefinition(mockEventDefinitionTwoAggregations.id); - - const { result } = renderHook(() => useAlertAndEventDefinitionData(), { wrapper }); - - await expect(result.current).toEqual({ - ...mockedHookData, - eventData: undefined, - alertId: undefined, - isAlert: false, - isEvent: false, - isEventDefinition: true, - }); - }); -}); diff --git a/graylog2-web-interface/src/logic/alerts/EventDefinitionPriorityEnum.ts b/graylog2-web-interface/src/logic/alerts/EventDefinitionPriorityEnum.ts index c03faef52806..b4f87a52dc4d 100644 --- a/graylog2-web-interface/src/logic/alerts/EventDefinitionPriorityEnum.ts +++ b/graylog2-web-interface/src/logic/alerts/EventDefinitionPriorityEnum.ts @@ -16,12 +16,14 @@ */ const EventDefinitionPriorityEnum = { LOW: 1, - NORMAL: 2, + MEDIUM: 2, HIGH: 3, + CRITICAL: 4, properties: { 1: { name: 'low' }, - 2: { name: 'normal' }, + 2: { name: 'medium' }, 3: { name: 'high' }, + 4: { name: 'critical' }, }, }; diff --git a/graylog2-web-interface/src/logic/telemetry/Constants.ts b/graylog2-web-interface/src/logic/telemetry/Constants.ts index 591259dd7288..f4d0cdd93128 100644 --- a/graylog2-web-interface/src/logic/telemetry/Constants.ts +++ b/graylog2-web-interface/src/logic/telemetry/Constants.ts @@ -262,6 +262,7 @@ export const TELEMETRY_EVENT_TYPE = { SHOW_RECEIVED_MESSAGES_CLICKED: 'Inputs Show Received Messages Clicked', MANAGE_EXTRACTORS_CLICKED: 'Inputs Manage Extractors Clicked', SHOW_METRICS_CLICKED: 'Inputs Show Metrics Clicked', + INPUT_DIAGNOSIS_CLICKED: 'Inputs Input Diagnosis Clicked', INPUT_START_CLICKED: 'Inputs Input Start Clicked', INPUT_STOP_CLICKED: 'Inputs Input Stop Clicked', INPUT_SETUP_CLICKED: 'Inputs Input Setup Clicked', @@ -400,4 +401,15 @@ export const TELEMETRY_EVENT_TYPE = { REMOTEREINDEX_RUNNING_RETRY_CONFIRM_CLICKED: 'Datanode Migration RemoteReindex Running Retry Confirm Clicked', REMOTEREINDEX_SHUTDOWN_OLD_CLUSTER_NEXT_CLICKED: 'Datanode Migration RemoteReindex Shutdown Old Cluster Next Clicked', }, + ALERTS_AND_EVENTS: { + ACTION_RAN: 'Alerts And Events Action Ran', + }, + ENTITY_DATA_TABLE: { + COLUMNS_CHANGED: 'Entity Data Table Columns Changed', + SORT_CHANGED: 'Entity Data Table Sort Changed', + PAGE_SIZE_CHANGED: 'Entity Data Table Page Size Changed', + FILTER_CREATED: 'Entity Data Table Filter Created', + FILTER_DELETED: 'Entity Data Table Filter Deleted', + FILTER_CHANGED: 'Entity Data Table Filter Changed', + }, } as const; diff --git a/graylog2-web-interface/src/pages/ContentPacksPage.tsx b/graylog2-web-interface/src/pages/ContentPacksPage.tsx index 8324cf9c33f5..b197c0166474 100644 --- a/graylog2-web-interface/src/pages/ContentPacksPage.tsx +++ b/graylog2-web-interface/src/pages/ContentPacksPage.tsx @@ -14,7 +14,8 @@ * along with this program. If not, see * . */ -import React, { useEffect } from 'react'; +import * as React from 'react'; +import { useCallback } from 'react'; import styled, { css } from 'styled-components'; import { LinkContainer } from 'components/common/router'; @@ -25,8 +26,8 @@ import UserNotification from 'util/UserNotification'; import { DocumentTitle, PageHeader } from 'components/common'; import ContentPacksList from 'components/content-packs/ContentPacksList'; import ContentPackUploadControls from 'components/content-packs/ContentPackUploadControls'; -import { ContentPacksActions, ContentPacksStore } from 'stores/content-packs/ContentPacksStore'; -import { useStore } from 'stores/connect'; +import { ContentPacksActions } from 'stores/content-packs/ContentPacksStore'; +import useContentPacks from 'components/content-packs/hooks/useContentPacks'; const ConfigurationBundles = styled.div(({ theme }) => css` font-size: ${theme.fonts.size.body}; @@ -34,46 +35,44 @@ const ConfigurationBundles = styled.div(({ theme }) => css` margin-top: 15px; `); -const _deleteContentPack = (contentPackId: string) => { - // eslint-disable-next-line no-alert - if (window.confirm('You are about to delete this Content Pack, are you sure?')) { - ContentPacksActions.delete(contentPackId).then(() => { - UserNotification.success('Content Pack deleted successfully.', 'Success'); - ContentPacksActions.list(); - }, (error) => { - let err_message = error.message; - const err_body = error.additional.body; +const ContentPacksPage = () => { + const { data, isInitialLoading, refetch } = useContentPacks(); - if (err_body && err_body.message) { - err_message = error.additional.body.message; - } + const _deleteContentPack = useCallback((contentPackId: string) => { + // eslint-disable-next-line no-alert + if (window.confirm('You are about to delete this Content Pack, are you sure?')) { + ContentPacksActions.delete(contentPackId).then(() => { + UserNotification.success('Content Pack deleted successfully.', 'Success'); + refetch(); + }, (error) => { + let err_message = error.message; + const err_body = error.additional.body; - UserNotification.error(`Deleting bundle failed: ${err_message}`, 'Error'); - }); - } -}; + if (err_body && err_body.message) { + err_message = error.additional.body.message; + } -const _installContentPack = (contentPackId: string, contentPackRev: string, parameters: unknown) => { - ContentPacksActions.install(contentPackId, contentPackRev, parameters).then(() => { - UserNotification.success('Content Pack installed successfully.', 'Success'); - ContentPacksActions.list(); - }, (error) => { - UserNotification.error(`Installing content pack failed with status: ${error}. - Could not install Content Pack with ID: ${contentPackId}`); - }); -}; + UserNotification.error(`Deleting bundle failed: ${err_message}`, 'Error'); + }); + } + }, [refetch]); -const ContentPacksPage = () => { - const { contentPacks, contentPackMetadata } = useStore(ContentPacksStore); - - useEffect(() => { - ContentPacksActions.list(); - }, []); + const _installContentPack = useCallback((contentPackId: string, contentPackRev: string, parameters: unknown) => { + ContentPacksActions.install(contentPackId, contentPackRev, parameters).then(() => { + UserNotification.success('Content Pack installed successfully.', 'Success'); + refetch(); + }, (error) => { + UserNotification.error(`Installing content pack failed with status: ${error}. + Could not install Content Pack with ID: ${contentPackId}`); + }); + }, [refetch]); - if (!contentPacks) { + if (isInitialLoading) { return (); } + const { content_packs: contentPacks, content_packs_metadata: contentPackMetadata } = data; + return ( diff --git a/graylog2-web-interface/src/pages/InputDiagnosisPage.tsx b/graylog2-web-interface/src/pages/InputDiagnosisPage.tsx new file mode 100644 index 000000000000..4e8e22239b40 --- /dev/null +++ b/graylog2-web-interface/src/pages/InputDiagnosisPage.tsx @@ -0,0 +1,196 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { DocumentTitle, LinkToNode, PageHeader } from 'components/common'; +import useParams from 'routing/useParams'; +import { Row, Col, DropdownButton, MenuItem } from 'components/bootstrap'; +import useInputDiagnosis from 'components/inputs/InputDiagnosis/useInputDiagnosis'; +import ShowReceivedMessagesButton from 'components/inputs/InputDiagnosis/ShowReceivedMessagesButton'; +import NetworkStats from 'components/inputs/InputDiagnosis/NetworkStats'; +import Routes from 'routing/Routes'; +import { LinkContainer } from 'components/common/router'; + +const StyledDl = styled.dl` + margin: 0; + + dt { + float: left; + clear: left; + } + + dd { + margin-left: 260px; + } +`; + +const ContainerCol = styled(Col)(({ theme }) => css` + margin-left: ${theme.spacings.sm}; + margin-right: ${theme.spacings.sm}; +`); + +const InfoCol = styled(Col)(({ theme }) => css` + border: 1px solid; + border-radius: ${theme.spacings.sm}; + padding: ${theme.spacings.sm}; +`); + +const MetricsCol = styled(Col)(({ theme }) => css` + padding: ${theme.spacings.sm}; +`); + +const InputNodeInfo = styled.div` + max-width: 500px; + white-space: break-spaces; +`; + +const InputDiagnosisPage = () => { + const { inputId } = useParams(); + const { input, inputNodeStates, inputMetrics } = useInputDiagnosis(inputId); + + return ( + + + Input Diagnosis can be used to test inputs and parsing without writing any data to the search cluster. + + {input && ( + + + + + +
Input Title:
+
{input.title}
+
Input Type:
+
{input.name}
+
This Input is running on:
+
{input.global ? 'all graylog nodes' : }
+ {input.attributes?.bind_address && input.attributes?.port && ( + <> +
This Input is listening on:
+
+ Bind address {input.attributes?.bind_address}, + Port {input.attributes?.port}. +
+
This Input is listening for:
+
{('tcp_keepalive' in (input.attributes || {})) ? 'TCP Traffic.' : 'UDP Traffic.'}
+ + )} +
+
+ + {inputMetrics && ( + +
Total Messages received by Input:
+
{inputMetrics.incomingMessagesTotal} events
+
Empty Messages discarded:
+
{inputMetrics.emptyMessages}
+ {Number.isInteger(inputMetrics.open_connections) && Number.isInteger(inputMetrics.total_connections) && ( + <> +
Active Connections:
+
+ {inputMetrics.open_connections}  + ({inputMetrics.total_connections} total) +
+ + )} + {Number.isInteger(inputMetrics.read_bytes_1sec) && Number.isInteger(inputMetrics.read_bytes_total) && ( + <> +
Network I/O:
+
+ +
+ + )} +
+ )} +
+
+

+ + +

Input Test Results

+ Metrics show the last 15 minutes: + +
+

+ + +
Input State
+ {Object.keys(inputNodeStates.states).map((state) => ( + {state.toLowerCase()}: {inputNodeStates.states[state].length}/{inputNodeStates.total}} + key={state} + bsSize="xs"> + {inputNodeStates.states[state].map(({ detailed_message, node_id }) => ( + + + {node_id && ( +
Node ID: {node_id}
+ )} + {detailed_message && ( + Message: {detailed_message} + )} +
+
+ ))} +
+ ))} + + +
Message Error at Input
+
{inputMetrics.failures_inputs_codecs}
+ + +
Message Failed to process
+
{inputMetrics.failures_processing}
+ + +
Message Failed to index
+
{inputMetrics.failures_indexing}
+ +
+

+ + +

Received Message count by Stream

+ {inputMetrics.stream_message_count?.length && ( + + {inputMetrics.stream_message_count.map(([key, value]) => ( + +
{key}
+
{value}
+
+ ))} +
+ )} + + + + +
+
+
+ )} +
+ ); +}; + +export default InputDiagnosisPage; diff --git a/graylog2-web-interface/src/pages/ShowContentPackPage.tsx b/graylog2-web-interface/src/pages/ShowContentPackPage.tsx index 399d4f429e48..4220d6ad369c 100644 --- a/graylog2-web-interface/src/pages/ShowContentPackPage.tsx +++ b/graylog2-web-interface/src/pages/ShowContentPackPage.tsx @@ -15,7 +15,7 @@ * . */ import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { LinkContainer } from 'components/common/router'; import { Row, Col, Button, ButtonToolbar, BootstrapModalConfirm } from 'components/bootstrap'; @@ -27,39 +27,45 @@ import ContentPackDetails from 'components/content-packs/ContentPackDetails'; import ContentPackVersions from 'components/content-packs/ContentPackVersions'; import ContentPackInstallations from 'components/content-packs/ContentPackInstallations'; import ContentPackInstallEntityList from 'components/content-packs/ContentPackInstallEntityList'; -import { ContentPacksActions, ContentPacksStore } from 'stores/content-packs/ContentPacksStore'; -import { useStore } from 'stores/connect'; +import { ContentPacksActions } from 'stores/content-packs/ContentPacksStore'; import useHistory from 'routing/useHistory'; import useParams from 'routing/useParams'; +import useContentPackRevisions from 'components/content-packs/hooks/useContentPackRevisions'; +import useContentPackInstallations from 'components/content-packs/hooks/useContentPackInstallations'; +import type FetchError from 'logic/errors/FetchError'; import ShowContentPackStyle from './ShowContentPackPage.css'; const ShowContentPackPage = () => { - const { contentPackRevisions, installations, constraints, selectedVersion: currentVersion } = useStore(ContentPacksStore); const history = useHistory(); const params = useParams<{ contentPackId: string }>(); + const onFetchError = useCallback((error: FetchError) => { + if (error.status === 404) { + UserNotification.error( + `Cannot find Content Pack with the id ${params.contentPackId} and may have been deleted.`, + ); + } else { + UserNotification.error('An internal server error occurred. Please check your logfiles for more information'); + } + + history.push(Routes.SYSTEM.CONTENTPACKS.LIST); + }, [history, params?.contentPackId]); const [showModal, setShowModal] = useState(false); const [selectedVersion, setSelectedVersion] = useState(undefined); const [uninstallEntities, setUninstallEntities] = useState(undefined); const [uninstallContentPackId, setUninstallContentPackId] = useState(undefined); const [uninstallInstallId, setUninstallInstallId] = useState(undefined); - - useEffect(() => { - ContentPacksActions.get(params.contentPackId).catch((error) => { - if (error.status === 404) { - UserNotification.error( - `Cannot find Content Pack with the id ${params.contentPackId} and may have been deleted.`, - ); - } else { - UserNotification.error('An internal server error occurred. Please check your logfiles for more information'); - } - - history.push(Routes.SYSTEM.CONTENTPACKS.LIST); - }); - - ContentPacksActions.installList(params.contentPackId); - }, [history, params?.contentPackId]); + const { + data: contentPack, + isInitialLoading: isLoadingContentPack, + refetch: refetchContentPack, + } = useContentPackRevisions(params?.contentPackId, onFetchError); + const { + data: contentPackInstallations, + isInitialLoading: isLoadingInstallations, + refetch: refetchInstallations, + } = useContentPackInstallations(params?.contentPackId); const _onVersionChanged = (newVersion) => { setSelectedVersion(newVersion); @@ -71,13 +77,7 @@ const ShowContentPackPage = () => { ContentPacksActions.deleteRev(contentPackId, revision).then(() => { UserNotification.success('Content pack revision deleted successfully.', 'Success'); - ContentPacksActions.get(contentPackId).catch((error) => { - if (error.status !== 404) { - UserNotification.error('An internal server error occurred. Please check your logfiles for more information'); - } - - history.push(Routes.SYSTEM.CONTENTPACKS.LIST); - }); + refetchContentPack(); }, (error) => { let errMessage = error.message; @@ -108,11 +108,9 @@ const ShowContentPackPage = () => { }; const _uninstallContentPackRev = () => { - const contentPackId = uninstallContentPackId; - ContentPacksActions.uninstall(uninstallContentPackId, uninstallInstallId).then(() => { UserNotification.success('Content Pack uninstalled successfully.', 'Success'); - ContentPacksActions.installList(contentPackId); + refetchInstallations(); _clearUninstall(); }, () => { UserNotification.error('Uninstall content pack failed, please check your logs for more information.', 'Error'); @@ -122,17 +120,20 @@ const ShowContentPackPage = () => { const _installContentPack = (contentPackId: string, contentPackRev: string, parameters) => { ContentPacksActions.install(contentPackId, contentPackRev, parameters).then(() => { UserNotification.success('Content Pack installed successfully.', 'Success'); - ContentPacksActions.installList(contentPackId); + refetchInstallations(); }, (error) => { UserNotification.error(`Installing content pack failed with status: ${error}. Could not install content pack with ID: ${contentPackId}`); }); }; - if (!contentPackRevisions) { + if (isLoadingContentPack || isLoadingInstallations) { return (); } + const { contentPackRevisions, constraints, selectedVersion: currentVersion } = contentPack; + const { installations } = contentPackInstallations; + return ( @@ -174,7 +175,6 @@ const ShowContentPackPage = () => { - {/* @ts-ignore */} import('./IndexSetTemplateCre const IndexSetTemplateEditPage = loadAsync(() => import('./IndexSetTemplateEditPage')); const IndicesPage = loadAsync(() => import('./IndicesPage')); const InputsPage = loadAsync(() => import('./InputsPage')); +const InputDiagnosisPage = loadAsync(() => import('./InputDiagnosisPage')); const KeyboardShortcutsPage = loadAsync(() => import('./KeyboardShortcutsPage')); const LoadingPage = loadAsync(() => import(/* webpackChunkName: "LoadingPage" */ 'pages/LoadingPage')); const LoggersPage = loadAsync(() => import('./LoggersPage')); @@ -160,6 +161,7 @@ export { IndexSetTemplateEditPage, IndicesPage, InputsPage, + InputDiagnosisPage, KeyboardShortcutsPage, LoadingPage, LoggersPage, diff --git a/graylog2-web-interface/src/routing/AppRouter.tsx b/graylog2-web-interface/src/routing/AppRouter.tsx index cfc068100bab..3fcbfc16a63f 100644 --- a/graylog2-web-interface/src/routing/AppRouter.tsx +++ b/graylog2-web-interface/src/routing/AppRouter.tsx @@ -65,6 +65,7 @@ import { IndexSetTemplateEditPage, IndicesPage, InputsPage, + InputDiagnosisPage, KeyboardShortcutsPage, LoggersPage, LUTCachesPage, @@ -204,6 +205,7 @@ const AppRouter = () => { }, enableInputsRoute && { path: RoutePaths.SYSTEM.INPUTS, element: }, + !isCloud && { path: RoutePaths.SYSTEM.INPUT_DIAGNOSIS(':inputId'), element: }, !isCloud && { path: RoutePaths.node_inputs(':nodeId'), element: }, !isCloud && { path: RoutePaths.global_input_extractors(':inputId'), element: }, !isCloud && { path: RoutePaths.local_input_extractors(':nodeId', ':inputId'), element: }, diff --git a/graylog2-web-interface/src/routing/Routes.ts b/graylog2-web-interface/src/routing/Routes.ts index 25f14aef8a9b..3d270634b8d6 100644 --- a/graylog2-web-interface/src/routing/Routes.ts +++ b/graylog2-web-interface/src/routing/Routes.ts @@ -119,6 +119,7 @@ const Routes = { CREATE: '/system/index_sets/create', }, INPUTS: '/system/inputs', + INPUT_DIAGNOSIS: (input: string) => `/system/input/diagnosis/${input}`, LOGGING: '/system/logging', METRICS: (nodeId: string) => `/system/metrics/node/${nodeId}`, NODES: { diff --git a/graylog2-web-interface/src/stores/inputs/InputStatesStore.ts b/graylog2-web-interface/src/stores/inputs/InputStatesStore.ts index 783c620ded96..7cce67e9c95e 100644 --- a/graylog2-web-interface/src/stores/inputs/InputStatesStore.ts +++ b/graylog2-web-interface/src/stores/inputs/InputStatesStore.ts @@ -30,17 +30,19 @@ export type InputState = | 'RUNNING' | 'FAILING' | 'SETUP' -export type InputStates = { - [inputId: string]: { - [nodeId: string]: { - state: InputState, - id: string, - detailed_message: string | null, - message_input: Input, - } +export type InputStateByNode = { + [nodeId: string]: { + state: InputState, + id: string, + detailed_message: string | null, + message_input: Input, } } +export type InputStates = { + [inputId: string]: InputStateByNode, +} + export const InputStatesStore = singletonStore( 'core.InputStates', () => Reflux.createStore({ diff --git a/graylog2-web-interface/src/stores/metrics/MetricsStore.ts b/graylog2-web-interface/src/stores/metrics/MetricsStore.ts index d8bd73c306fd..cd3c2922aa77 100644 --- a/graylog2-web-interface/src/stores/metrics/MetricsStore.ts +++ b/graylog2-web-interface/src/stores/metrics/MetricsStore.ts @@ -62,7 +62,7 @@ export type GaugeMetric = { type: 'gauge', }; -type Rate = { +export type Rate = { rate: { total: number, mean: number, diff --git a/graylog2-web-interface/src/util/NumberFormatting.test.ts b/graylog2-web-interface/src/util/NumberFormatting.test.ts new file mode 100644 index 000000000000..dacc4b74e3c6 --- /dev/null +++ b/graylog2-web-interface/src/util/NumberFormatting.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { formatNumber, formatPercentage, formatTrend } from './NumberFormatting'; + +describe('NumberFormatting', () => { + describe('formatNumber', () => { + it('formats with 2 fraction digits by default', () => { + expect(formatNumber(42.23)).toEqual('42.23'); + expect(formatNumber(42)).toEqual('42'); + expect(formatNumber(137.991)).toEqual('137.99'); + expect(formatNumber(137.999)).toEqual('138'); + expect(formatNumber(137.111)).toEqual('137.11'); + expect(formatNumber(137.115)).toEqual('137.12'); + }); + }); + + describe('formatTrend', () => { + it('does show sign', () => { + expect(formatTrend(42.23)).toEqual('+42.23'); + expect(formatTrend(-42)).toEqual('-42'); + expect(formatTrend(-137.991)).toEqual('-137.99'); + expect(formatTrend(137.999)).toEqual('+138'); + expect(formatTrend(-137.111)).toEqual('-137.11'); + expect(formatTrend(137.115)).toEqual('+137.12'); + expect(formatTrend(0)).toEqual('0'); + }); + + it('does show percentage', () => { + const options = { percentage: true }; + + expect(formatTrend(42.23 / 100, options)).toEqual('+42.23%'); + expect(formatTrend(-42 / 100, options)).toEqual('-42.00%'); + expect(formatTrend(-137.991 / 100, options)).toEqual('-137.99%'); + expect(formatTrend(137.999 / 100, options)).toEqual('+138.00%'); + expect(formatTrend(-137.111 / 100, options)).toEqual('-137.11%'); + expect(formatTrend(137.115 / 100, options)).toEqual('+137.12%'); + expect(formatTrend(0 / 100, options)).toEqual('0.00%'); + }); + }); + + describe('formatPercentage', () => { + it('formats with 2 fraction digits by default', () => { + expect(formatPercentage(42.23 / 100)).toEqual('42.23%'); + expect(formatPercentage(42 / 100)).toEqual('42.00%'); + expect(formatPercentage(137.991 / 100)).toEqual('137.99%'); + expect(formatPercentage(137.999 / 100)).toEqual('138.00%'); + expect(formatPercentage(137.111 / 100)).toEqual('137.11%'); + expect(formatPercentage(137.115 / 100)).toEqual('137.12%'); + }); + }); +}); diff --git a/graylog2-web-interface/src/util/NumberFormatting.ts b/graylog2-web-interface/src/util/NumberFormatting.ts new file mode 100644 index 000000000000..bc04bcd899e2 --- /dev/null +++ b/graylog2-web-interface/src/util/NumberFormatting.ts @@ -0,0 +1,39 @@ +/* + * 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 + * . + */ +type Options = { + signDisplay?: 'auto' | 'always' | 'exceptZero', + maximumFractionDigits?: number, + minimumFractionDigits?: number, +}; + +const defaultOptions = { + maximumFractionDigits: 2, +} as const; + +const defaultPercentageOptions = { + ...defaultOptions, + minimumFractionDigits: 2, + style: 'percent', +} as const; + +export const formatNumber = (num: number, options: Options = {}) => new Intl.NumberFormat(undefined, { ...defaultOptions, ...options }).format(num); +export const formatPercentage = (num: number, options: Options = {}) => new Intl.NumberFormat(undefined, { ...defaultPercentageOptions, ...options }).format(num); + +type TrendOptions = { + percentage?: boolean, +} +export const formatTrend = (num: number, options: TrendOptions = {}) => (options.percentage === true ? formatPercentage : formatNumber)(num, { signDisplay: 'exceptZero' }); diff --git a/graylog2-web-interface/src/views/components/Search.tsx b/graylog2-web-interface/src/views/components/Search.tsx index add32f751d94..81f7b3f82e0e 100644 --- a/graylog2-web-interface/src/views/components/Search.tsx +++ b/graylog2-web-interface/src/views/components/Search.tsx @@ -51,6 +51,7 @@ import { selectCurrentQueryResults } from 'views/logic/slices/viewSelectors'; import useAppSelector from 'stores/useAppSelector'; import useParameters from 'views/hooks/useParameters'; import useSearchConfiguration from 'hooks/useSearchConfiguration'; +import useViewTitle from 'views/hooks/useViewTitle'; import ExternalValueActionsProvider from './ExternalValueActionsProvider'; @@ -84,10 +85,11 @@ const SearchArea = styled(PageContentLayout)(() => { `; }); -const ConnectedSidebar = (props: Omit, 'results'>) => { +const ConnectedSidebar = (props: Omit, 'results' | 'title'>) => { const results = useAppSelector(selectCurrentQueryResults); + const title = useViewTitle(); - return ; + return ; }; const ViewAdditionalContextProvider = ({ children }: { children: React.ReactNode }) => { diff --git a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx index 1475fe92045f..91f0cc0f86d3 100644 --- a/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx +++ b/graylog2-web-interface/src/views/components/contexts/WidgetFocusProvider.tsx @@ -160,7 +160,9 @@ const WidgetFocusProvider = ({ children }: { children: React.ReactNode }): React query, ); - history.replace(newURI); + if (newURI !== query) { + history.replace(newURI); + } }, [history, query]); const setWidgetFocusing = useCallback((widgetId: string) => updateFocusQueryParams({ diff --git a/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.test.tsx b/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.test.tsx new file mode 100644 index 000000000000..e18d10fc5361 --- /dev/null +++ b/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 PercentageField from './PercentageField'; + +describe('PercentageField', () => { + it('does not show very small values as `NaN%`', async () => { + render(); + await screen.findByText('0.00%'); + }); +}); diff --git a/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.tsx b/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.tsx index fe1ba3963c57..83f597e77b11 100644 --- a/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.tsx +++ b/graylog2-web-interface/src/views/components/fieldtypes/PercentageField.tsx @@ -16,9 +16,10 @@ */ import * as React from 'react'; import { useMemo } from 'react'; -import numeral from 'numeral'; import styled from 'styled-components'; +import { formatPercentage } from 'util/NumberFormatting'; + type Props = { value: number, } @@ -28,7 +29,7 @@ const NumberCell = styled.span` `; const PercentageField = ({ value }: Props) => { - const formatted = useMemo(() => numeral(value).format('0.00%'), [value]); + const formatted = useMemo(() => formatPercentage(value), [value]); return {formatted}; }; diff --git a/graylog2-web-interface/src/views/components/fieldtypes/StreamsField.tsx b/graylog2-web-interface/src/views/components/fieldtypes/StreamsField.tsx index 08eac9865968..cdc7a5c94469 100644 --- a/graylog2-web-interface/src/views/components/fieldtypes/StreamsField.tsx +++ b/graylog2-web-interface/src/views/components/fieldtypes/StreamsField.tsx @@ -33,7 +33,7 @@ const StreamsList = styled.span` const StreamsField = ({ value }: Props) => { const streams = useContext(StreamsContext); const streamsMap = useMemo(() => Object.fromEntries(streams.map((stream) => [stream.id, stream]) ?? []), [streams]); - const renderStream = useCallback((streamId: string) => {streamsMap[streamId]?.title ?? streamId}, [streamsMap]); + const renderStream = useCallback((streamId: string) => {streamsMap[streamId]?.title ?? streamId}, [streamsMap]); return Array.isArray(value) ? {value.map(renderStream)} diff --git a/graylog2-web-interface/src/views/components/messagelist/FormatNumber.ts b/graylog2-web-interface/src/views/components/messagelist/FormatNumber.ts index 10fb6c852654..9a52e25a84d7 100644 --- a/graylog2-web-interface/src/views/components/messagelist/FormatNumber.ts +++ b/graylog2-web-interface/src/views/components/messagelist/FormatNumber.ts @@ -14,8 +14,8 @@ * along with this program. If not, see * . */ -import numeral from 'numeral'; +import { formatNumber as _formatNumber } from 'util/NumberFormatting'; -const formatNumber = (value: number): string => numeral(value).format('0,0.[0000000]'); +const formatNumber = (value: number): string => _formatNumber(value, { maximumFractionDigits: 7 }); export default formatNumber; diff --git a/graylog2-web-interface/src/views/components/messagelist/MessageTableEntry.tsx b/graylog2-web-interface/src/views/components/messagelist/MessageTableEntry.tsx index 87ba2e523c2f..af76310a8508 100644 --- a/graylog2-web-interface/src/views/components/messagelist/MessageTableEntry.tsx +++ b/graylog2-web-interface/src/views/components/messagelist/MessageTableEntry.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { useCallback, useContext, useMemo } from 'react'; import * as Immutable from 'immutable'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { AdditionalContext } from 'views/logic/ActionContext'; import { useStore } from 'stores/connect'; @@ -51,11 +51,11 @@ export const TableBody = styled.tbody<{ $expanded?: boolean, $highlighted?: bool && { border-top: 0; - ${$expanded ? css` + ${$expanded ? ` border-left: 7px solid ${theme.colors.variant.light.info}; ` : ''} - ${$highlighted ? css` + ${$highlighted ? ` border-left: 7px solid ${theme.colors.variant.light.success}; ` : ''} } @@ -99,19 +99,19 @@ type Props = { message: Message, selectedFields?: Immutable.OrderedSet, showMessageRow?: boolean, - toggleDetail: (string) => void, + toggleDetail: (messageId: string) => void, }; -const isDecoratedField = (field, decorationStats) => decorationStats +const isDecoratedField = (field: string | number, decorationStats: Message['decoration_stats']) => decorationStats && (decorationStats.added_fields[field] !== undefined || decorationStats.changed_fields[field] !== undefined); -const fieldType = (fieldName, { decoration_stats: decorationStats }: { - decoration_stats?: any -}, fields) => (isDecoratedField(fieldName, decorationStats) - ? FieldType.Decorated - : ((fields && fields.find((f) => f.name === fieldName)) || { type: FieldType.Unknown }).type); +const fieldType = (fieldName: string, { decoration_stats: decorationStats }: Message, fields: FieldTypeMappingsList) => ( + isDecoratedField(fieldName, decorationStats) + ? FieldType.Decorated + : ((fields?.find((f) => f.name === fieldName)) ?? { type: FieldType.Unknown }).type +); -const Strong = ({ children, strong = false }: React.PropsWithChildren<{ strong: boolean }>) => (strong +const Strong = ({ children = undefined, strong }: React.PropsWithChildren<{ strong: boolean }>) => (strong ? {children} : <>{children}); diff --git a/graylog2-web-interface/src/views/components/sidebar/ContentColumn.tsx b/graylog2-web-interface/src/views/components/sidebar/ContentColumn.tsx index 03674fb55028..3564fd5c403e 100644 --- a/graylog2-web-interface/src/views/components/sidebar/ContentColumn.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/ContentColumn.tsx @@ -19,14 +19,15 @@ import styled, { css } from 'styled-components'; import type { SearchPreferencesLayout } from 'views/components/contexts/SearchPagePreferencesContext'; import { IconButton } from 'components/common'; -import useViewTitle from 'views/hooks/useViewTitle'; type Props = { children: React.ReactNode, closeSidebar: () => void, + enableSidebarPinning: boolean, + forceSideBarPinned: boolean, searchPageLayout: SearchPreferencesLayout | undefined | null, sectionTitle: string, - forceSideBarPinned: boolean, + title: string, }; export const Container = styled.div<{ $sidebarIsPinned: boolean }>(({ theme, $sidebarIsPinned }) => css` @@ -75,7 +76,7 @@ const Header = styled.div` grid-row: 1; `; -const SearchTitle = styled.div` +const TitleSection = styled.div` height: 35px; display: grid; grid-template-columns: 1fr auto; @@ -127,7 +128,7 @@ const SectionContent = styled.div` } `; -const toggleSidebarPinning = (searchPageLayout) => { +const toggleSidebarPinning = (searchPageLayout: SearchPreferencesLayout) => { if (!searchPageLayout) { return; } @@ -137,28 +138,27 @@ const toggleSidebarPinning = (searchPageLayout) => { togglePinning(); }; -const ContentColumn = ({ children, sectionTitle, closeSidebar, searchPageLayout, forceSideBarPinned }: Props) => { +const ContentColumn = ({ children, title, sectionTitle, closeSidebar, searchPageLayout, forceSideBarPinned, enableSidebarPinning }: Props) => { const sidebarIsPinned = searchPageLayout?.config.sidebar.isPinned || forceSideBarPinned; - const title = useViewTitle(); return (
- + {title} - {!forceSideBarPinned && ( - - - toggleSidebarPinning(searchPageLayout)} - title={`Display sidebar ${sidebarIsPinned ? 'as overlay' : 'inline'}`} - name="keep" /> - - + {!forceSideBarPinned && enableSidebarPinning && ( + + + toggleSidebarPinning(searchPageLayout)} + title={`Display sidebar ${sidebarIsPinned ? 'as overlay' : 'inline'}`} + name="keep" /> + + )} - + {sectionTitle}
diff --git a/graylog2-web-interface/src/views/components/sidebar/Sidebar.test.tsx b/graylog2-web-interface/src/views/components/sidebar/Sidebar.test.tsx index 6d2fe4216db7..4e5e20ae2b7d 100644 --- a/graylog2-web-interface/src/views/components/sidebar/Sidebar.test.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/Sidebar.test.tsx @@ -80,7 +80,7 @@ describe('', () => { const renderSidebar = () => render( - + , @@ -227,7 +227,7 @@ describe('', () => { await screen.findByText('Execution'); - fireEvent.click(await screen.findByText('Query Title')); + fireEvent.click(await screen.findByText('Sidebar Title')); expect(screen.queryByText('Execution')).toBeNull(); }); diff --git a/graylog2-web-interface/src/views/components/sidebar/Sidebar.tsx b/graylog2-web-interface/src/views/components/sidebar/Sidebar.tsx index 955db6b8a45d..32b8dc880e9e 100644 --- a/graylog2-web-interface/src/views/components/sidebar/Sidebar.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/Sidebar.tsx @@ -22,7 +22,6 @@ import styled, { css } from 'styled-components'; import type QueryResult from 'views/logic/QueryResult'; import type { SearchPreferencesLayout } from 'views/components/contexts/SearchPagePreferencesContext'; import SearchPagePreferencesContext from 'views/components/contexts/SearchPagePreferencesContext'; -import useActiveQueryId from 'views/hooks/useActiveQueryId'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import { getPathnameWithoutId } from 'util/URLUtils'; @@ -36,12 +35,14 @@ import type { SidebarAction } from './sidebarActions'; import sidebarActions from './sidebarActions'; type Props = { - children: React.ReactElement, + actions?: Array, + children?: React.ReactElement, + enableSidebarPinning?: boolean, + forceSideBarPinned?: boolean, results?: QueryResult searchPageLayout?: SearchPreferencesLayout, sections?: Array, - actions?: Array, - forceSideBarPinned?: boolean, + title: string, }; const Container = styled.div` @@ -77,10 +78,13 @@ const _selectSidebarSection = (sectionKey, activeSectionKey, setActiveSectionKey setActiveSectionKey(sectionKey); }; -const Sidebar = ({ searchPageLayout, results, children, sections = sidebarSections, actions = sidebarActions, forceSideBarPinned = false }: Props) => { +const Sidebar = ({ + searchPageLayout = undefined, results = undefined, children = undefined, title, + sections = sidebarSections, actions = sidebarActions, forceSideBarPinned = false, + enableSidebarPinning = true, +}: Props) => { const sendTelemetry = useSendTelemetry(); const location = useLocation(); - const queryId = useActiveQueryId(); const sidebarIsPinned = searchPageLayout?.config.sidebar.isPinned || forceSideBarPinned; const initialSectionKey = sections[0].key; const [activeSectionKey, setActiveSectionKey] = useState(searchPageLayout?.config.sidebar.isPinned ? initialSectionKey : null); @@ -109,11 +113,12 @@ const Sidebar = ({ searchPageLayout, results, children, sections = sidebarSectio actions={actions} /> {activeSection && !!SectionContent && ( @@ -126,7 +131,7 @@ const Sidebar = ({ searchPageLayout, results, children, sections = sidebarSectio ); }; -const SidebarWithContext = ({ children, ...props }: React.ComponentProps) => ( +const SidebarWithContext = ({ children = undefined, ...props }: React.ComponentProps) => ( {(searchPageLayout) => {children}} diff --git a/graylog2-web-interface/src/views/components/sidebar/SidebarNavigation.tsx b/graylog2-web-interface/src/views/components/sidebar/SidebarNavigation.tsx index 2c8cb93fea93..70be51860d68 100644 --- a/graylog2-web-interface/src/views/components/sidebar/SidebarNavigation.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/SidebarNavigation.tsx @@ -120,12 +120,16 @@ const SidebarNavigation = ({ sections, activeSection, selectSidebarSection, side ); })} -
-
- {actions.map(({ key, Component }) => ( - - ))} -
+ {actions?.length > 0 && ( + <> +
+
+ {actions.map(({ key, Component }) => ( + + ))} +
+ + )}
); }; diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.test.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.test.tsx index 1fde7d37e084..0bf298460aca 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.test.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.test.tsx @@ -28,12 +28,6 @@ import FieldTypeMapping from 'views/logic/fieldtypes/FieldTypeMapping'; import FieldType, { Properties } from 'views/logic/fieldtypes/FieldType'; import TestStoreProvider from 'views/test/TestStoreProvider'; import useViewsPlugin from 'views/test/testViewsPlugin'; -import { updateHighlightingRule } from 'views/logic/slices/highlightActions'; - -jest.mock('views/logic/slices/highlightActions', () => ({ - addHighlightingRule: jest.fn(() => () => Promise.resolve()), - updateHighlightingRule: jest.fn(() => () => Promise.resolve()), -})); const rule = HighlightingRule.builder() .color(StaticColor.create('#333333')) @@ -54,10 +48,10 @@ describe('HighlightForm', () => { all: Immutable.List([FieldTypeMapping.create('foob', FieldType.create('long', [Properties.Numeric]))]), queryFields: Immutable.Map(), }; - const HighlightFormWithContext = (props: React.ComponentProps) => ( + const SUT = (props: Partial>) => ( - + {}} rule={undefined} onSubmit={() => Promise.resolve()} {...props} /> ); @@ -70,7 +64,7 @@ describe('HighlightForm', () => { useViewsPlugin(); it('should render for edit', async () => { - const { findByText } = render( {}} rule={rule} />); + const { findByText } = render(); const form = await findByText('Edit Highlighting Rule'); const input = await screen.findByLabelText('Value'); @@ -80,15 +74,14 @@ describe('HighlightForm', () => { }); it('should render for new', async () => { - const { findByText } = render( {}} />); + const { findByText } = render(); await findByText('Create Highlighting Rule'); }); it('should fire onClose on cancel', async () => { const onClose = jest.fn(); - const { findByText } = render(); - + const { findByText } = render(); const elem = await findByText('Cancel'); fireEvent.click(elem); @@ -97,29 +90,28 @@ describe('HighlightForm', () => { }); it('should fire update action when saving a existing rule', async () => { - render( {}} rule={rule} />); + const onSubmit = jest.fn(() => Promise.resolve()); + render(); await triggerSaveButtonClick(); - await waitFor(() => expect(updateHighlightingRule) - .toHaveBeenCalledWith(rule, { field: rule.field, value: rule.value, condition: rule.condition, color: rule.color })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(rule.field, rule.value, rule.condition, rule.color)); }); it('assigns a new static color when type is selected', async () => { - render( {}} rule={rule} />); + const onSubmit = jest.fn(() => Promise.resolve()); + render(); userEvent.click(screen.getByLabelText('Static Color')); await triggerSaveButtonClick(); - await waitFor(() => expect(updateHighlightingRule) - .toHaveBeenCalledWith(rule, expect.objectContaining({ - color: expect.objectContaining({ type: 'static', color: expect.any(String) }), - }))); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(rule.field, rule.value, rule.condition, expect.objectContaining({ type: 'static', color: expect.any(String) }))); }); it('creates a new gradient when type is selected', async () => { - render( {}} rule={rule} />); + const onSubmit = jest.fn(() => Promise.resolve()); + render(); userEvent.click(screen.getByLabelText('Gradient')); @@ -129,27 +121,23 @@ describe('HighlightForm', () => { await triggerSaveButtonClick(); - await waitFor(() => expect(updateHighlightingRule) - .toHaveBeenCalledWith(rule, expect.objectContaining({ - color: expect.objectContaining({ gradient: 'Viridis' }), - }))); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(rule.field, rule.value, rule.condition, expect.objectContaining({ gradient: 'Viridis' }))); }); it('should be able to click submit when has value 0 with type number', async () => { - render( {}} rule={ruleWithValueZero} />); + const onSubmit = jest.fn(() => Promise.resolve()); + render(); await triggerSaveButtonClick(); - - await waitFor(() => expect(updateHighlightingRule) - .toHaveBeenCalledWith(ruleWithValueZero, { field: ruleWithValueZero.field, value: '0', condition: ruleWithValueZero.condition, color: ruleWithValueZero.color })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ruleWithValueZero.field, '0', ruleWithValueZero.condition, ruleWithValueZero.color)); }); - it('should be able to click submit when has value false with type boolean', async () => { - render( {}} rule={ruleWithValueFalse} />); + it('should be able to click submit when has value false with type boolean', async () => { + const onSubmit = jest.fn(() => Promise.resolve()); + render(); await triggerSaveButtonClick(); - await waitFor(() => expect(updateHighlightingRule) - .toHaveBeenCalledWith(ruleWithValueFalse, { field: ruleWithValueFalse.field, value: 'false', condition: ruleWithValueFalse.condition, color: ruleWithValueFalse.color })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ruleWithValueFalse.field, 'false', ruleWithValueFalse.condition, ruleWithValueFalse.color)); }); }); diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.tsx index 31836bc94b32..5d247a9df127 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightForm.tsx @@ -25,7 +25,13 @@ import { Input, BootstrapModalWrapper, Modal } from 'components/bootstrap'; import FieldTypesContext from 'views/components/contexts/FieldTypesContext'; import type FieldTypeMapping from 'views/logic/fieldtypes/FieldTypeMapping'; import Select from 'components/common/Select'; -import HighlightingRule, { +import type { + Value, + Condition, + Color, +} from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import type HighlightingRule from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import { ConditionLabelMap, StringConditionLabelMap, } from 'views/logic/views/formatting/highlighting/HighlightingRule'; @@ -40,8 +46,6 @@ import { StaticColor, } from 'views/logic/views/formatting/highlighting/HighlightingColor'; import { ModalSubmit } from 'components/common'; -import useAppDispatch from 'stores/useAppDispatch'; -import { addHighlightingRule, updateHighlightingRule } from 'views/logic/slices/highlightActions'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import { getPathnameWithoutId } from 'util/URLUtils'; @@ -50,9 +54,10 @@ import useLocation from 'routing/useLocation'; type Props = { onClose: () => void, rule?: HighlightingRule | null | undefined, + onSubmit: (field: string, value: Value, condition: Condition, color: Color) => Promise }; -const _isRequired = (field) => (value: string) => { +const _isRequired = (field: string) => (value: string) => { if (['', null, undefined].includes(value)) { return `${field} is required`; } @@ -107,7 +112,7 @@ const colorFromObject = (color: StaticColorObject | GradientColorObject) => { return undefined; }; -const HighlightForm = ({ onClose, rule }: Props) => { +const HighlightForm = ({ onClose, rule = undefined, onSubmit: onSubmitProp }: Props) => { const fieldTypes = useContext(FieldTypesContext); const sendTelemetry = useSendTelemetry(); const location = useLocation(); @@ -117,9 +122,8 @@ const HighlightForm = ({ onClose, rule }: Props) => { const fieldOptions = useMemo(() => fields.map(({ name }) => ({ value: name, label: name })) .sort((optA, optB) => defaultCompare(optA.label, optB.label)) .toArray(), [fields]); - const dispatch = useAppDispatch(); - const onSubmit = useCallback(({ field, value, color, condition }) => { + const onSubmit = useCallback(({ field, value, condition, color }: { field, value, condition, color }) => { const newColor = colorFromObject(color); sendTelemetry(TELEMETRY_EVENT_TYPE[`SEARCH_SIDEBAR_HIGHLIGHT_${rule ? 'UPDATED' : 'CREATED'}`], { @@ -127,12 +131,8 @@ const HighlightForm = ({ onClose, rule }: Props) => { app_action_value: 'search-sidebar-highlight', }); - if (rule) { - return dispatch(updateHighlightingRule(rule, { field, value, condition, color: newColor })).then(onClose); - } - - return dispatch(addHighlightingRule(HighlightingRule.create(field, value, condition, newColor))).then(onClose); - }, [dispatch, location.pathname, onClose, rule, sendTelemetry]); + return onSubmitProp(field, value, condition, newColor).then(onClose); + }, [location.pathname, onClose, onSubmitProp, rule, sendTelemetry]); const headerPrefix = rule ? 'Edit' : 'Create'; const submitButtonPrefix = rule ? 'Update' : 'Create'; @@ -169,7 +169,7 @@ const HighlightForm = ({ onClose, rule }: Props) => { options={fieldOptions} allowCreate value={value} - placeholder="Pick a field" /> + placeholder="Select or type field name" /> )} diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.test.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.test.tsx index 18effdae9b5f..699b329c1724 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.test.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.test.tsx @@ -21,12 +21,6 @@ import userEvent from '@testing-library/user-event'; import Rule from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor'; import useViewsPlugin from 'views/test/testViewsPlugin'; -import { asMock } from 'helpers/mocking'; -import useAppDispatch from 'stores/useAppDispatch'; -import mockDispatch from 'views/test/mockDispatch'; -import { createSearch } from 'fixtures/searches'; -import type { RootState } from 'views/types'; -import { updateHighlightingRule, removeHighlightingRule } from 'views/logic/slices/highlightActions'; import HighlightingRule from './HighlightingRule'; @@ -41,35 +35,36 @@ describe('HighlightingRule', () => { useViewsPlugin(); const rule = Rule.create('response_time', '250', undefined, StaticColor.create('#f44242')); - const view = createSearch(); - const dispatch = mockDispatch({ view: { view, activeQuery: 'query-id-1' } } as RootState); - beforeEach(() => { - asMock(useAppDispatch).mockReturnValue(dispatch); - }); + const SUT = (props: Partial>) => ( + Promise.resolve()} + onDelete={() => Promise.resolve()} + {...props} /> + ); it('should display field and value of rule', async () => { - render(); + render(); await screen.findByText('response_time'); await screen.findByText(/250/); }); it('should update rule if color was changed', async () => { - render(); + const onUpdate = jest.fn(() => Promise.resolve()); + render(); const staticColorPicker = await screen.findByTestId('static-color-preview'); userEvent.click(staticColorPicker); - userEvent.click(await screen.findByTitle(/#fbfdd8/i)); await waitFor(() => { - expect(updateHighlightingRule).toHaveBeenCalledWith(rule, { color: StaticColor.create('#fbfdd8') }); + expect(onUpdate).toHaveBeenCalledWith(rule, rule.field, rule.value, rule.condition, StaticColor.create('#fbfdd8')); }); }); it('should close popover when color was changed', async () => { - render(); + render(); const staticColorPicker = await screen.findByTestId('static-color-preview'); userEvent.click(staticColorPicker); @@ -83,7 +78,7 @@ describe('HighlightingRule', () => { describe('rule edit', () => { it('should show a edit modal', async () => { - render(); + render(); const editIcon = await screen.findByTitle('Edit this Highlighting Rule'); expect(screen.queryByText('Edit Highlighting Rule')).not.toBeInTheDocument(); @@ -96,41 +91,38 @@ describe('HighlightingRule', () => { describe('rule removal:', () => { let oldConfirm = null; - let deleteIcon; + const findDeleteIcon = () => screen.findByTitle('Remove this Highlighting Rule'); beforeEach(async () => { oldConfirm = window.confirm; window.confirm = jest.fn(() => false); - - // eslint-disable-next-line testing-library/no-render-in-setup - render(); - - deleteIcon = await screen.findByTitle('Remove this Highlighting Rule'); }); afterEach(() => { window.confirm = oldConfirm; }); - it('asks for confirmation before rule is removed', () => { - userEvent.click(deleteIcon); + it('asks for confirmation before rule is removed', async () => { + render(); + userEvent.click(await findDeleteIcon()); expect(window.confirm).toHaveBeenCalledWith('Do you really want to remove this highlighting?'); }); it('does not remove rule if confirmation was cancelled', async () => { - userEvent.click(deleteIcon); + render(); + userEvent.click(await findDeleteIcon()); await screen.findByText('response_time'); }); it('removes rule rule if confirmation was acknowledged', async () => { + const onDelete = jest.fn(() => Promise.resolve()); + render(); window.confirm = jest.fn(() => true); - userEvent.click(deleteIcon); + userEvent.click(await findDeleteIcon()); - await waitFor(() => { - expect(removeHighlightingRule).toHaveBeenCalledWith(rule); - }); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(rule)); }); }); }); diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.tsx index 0c3cbf2c9db5..c3f4d36a98c6 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRule.tsx @@ -19,15 +19,13 @@ import { forwardRef, useCallback, useState } from 'react'; import styled, { css } from 'styled-components'; import { DEFAULT_CUSTOM_HIGHLIGHT_RANGE } from 'views/Constants'; -import type Rule from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import type { Condition, Value, Color } from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import type HighlightingRuleClass from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { ConditionLabelMap } from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { ColorPickerPopover, Icon, IconButton } from 'components/common'; import HighlightForm from 'views/components/sidebar/highlighting/HighlightForm'; import type HighlightingColor from 'views/logic/views/formatting/highlighting/HighlightingColor'; import { StaticColor } from 'views/logic/views/formatting/highlighting/HighlightingColor'; -import type { AppDispatch } from 'stores/useAppDispatch'; -import useAppDispatch from 'stores/useAppDispatch'; -import { updateHighlightingRule, removeHighlightingRule } from 'views/logic/slices/highlightActions'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import { getPathnameWithoutId } from 'util/URLUtils'; @@ -69,17 +67,6 @@ const DragHandle = styled.div` width: 25px; `; -const updateColor = (rule: Rule, newColor: HighlightingColor, hidePopover: () => void) => async (dispatch: AppDispatch) => dispatch(updateHighlightingRule(rule, { color: newColor })).then(hidePopover); - -const onDelete = (rule: Rule) => async (dispatch: AppDispatch) => { - // eslint-disable-next-line no-alert - if (window.confirm('Do you really want to remove this highlighting?')) { - return dispatch(removeHighlightingRule(rule)); - } - - return Promise.resolve(); -}; - type RuleColorPreviewProps = { color: HighlightingColor, onChange: (newColor: HighlightingColor, hidePopover: () => void) => void, @@ -107,21 +94,24 @@ const RuleColorPreview = ({ color, onChange }: RuleColorPreviewProps) => { }; type Props = { - rule: Rule, + rule: HighlightingRuleClass, className?: string, draggableProps?: DraggableProps; dragHandleProps?: DragHandleProps; + onUpdate: (existingRule: HighlightingRuleClass, field: string, value: Value, condition: Condition, color: Color) => Promise, + onDelete: (rule: HighlightingRuleClass) => Promise, }; const HighlightingRule = forwardRef(({ rule, - className, - draggableProps, - dragHandleProps, + className = undefined, + draggableProps = undefined, + dragHandleProps = undefined, + onUpdate, + onDelete, }, ref) => { const { field, value, color, condition } = rule; const [showForm, setShowForm] = useState(false); - const dispatch = useAppDispatch(); const sendTelemetry = useSendTelemetry(); const location = useLocation(); @@ -131,16 +121,21 @@ const HighlightingRule = forwardRef(({ app_action_value: 'search-sidebar-highlight-color-update', }); - return dispatch(updateColor(rule, newColor, hidePopover)); - }, [dispatch, location.pathname, rule, sendTelemetry]); + return onUpdate(rule, rule.field, rule.value, rule.condition, newColor).then(hidePopover); + }, [location.pathname, onUpdate, rule, sendTelemetry]); + const _onDelete = useCallback(() => { - sendTelemetry(TELEMETRY_EVENT_TYPE.SEARCH_SIDEBAR_HIGHLIGHT_DELETED, { - app_pathname: getPathnameWithoutId(location.pathname), - app_action_value: 'search-sidebar-highlight-delete', - }); + if (window.confirm('Do you really want to remove this highlighting?')) { + sendTelemetry(TELEMETRY_EVENT_TYPE.SEARCH_SIDEBAR_HIGHLIGHT_DELETED, { + app_pathname: getPathnameWithoutId(location.pathname), + app_action_value: 'search-sidebar-highlight-delete', + }); + + return onDelete(rule); + } - return dispatch(onDelete(rule)); - }, [dispatch, location.pathname, rule, sendTelemetry]); + return Promise.resolve(); + }, [location.pathname, onDelete, rule, sendTelemetry]); return ( @@ -159,7 +154,11 @@ const HighlightingRule = forwardRef(({ )} - {showForm && setShowForm(false)} rule={rule} />} + {showForm && ( + setShowForm(false)} + rule={rule} + onSubmit={(newField, newValue, newCondition, newColor) => onUpdate(rule, newField, newValue, newCondition, newColor)} /> + )} ); }); diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.test.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.test.tsx index 190bd225d8bf..831a235c2f40 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.test.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.test.tsx @@ -38,7 +38,11 @@ const HighlightingRules = ({ rules = [] }: { rules?: Array }) return ( - + Promise.resolve()} + onCreateRule={() => Promise.resolve()} + onUpdateRule={() => Promise.resolve()} + onDeleteRule={() => Promise.resolve()} /> ); diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.tsx index 0798bc67cdfc..df070cbdd3af 100644 --- a/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/HighlightingRules.tsx @@ -15,15 +15,18 @@ * . */ import * as React from 'react'; -import { useCallback, useContext, useState } from 'react'; +import { useCallback, useContext, useState, forwardRef } from 'react'; import { DEFAULT_HIGHLIGHT_COLOR } from 'views/Constants'; import HighlightingRulesContext from 'views/components/contexts/HighlightingRulesContext'; import IconButton from 'components/common/IconButton'; import { SortableList } from 'components/common'; -import { updateHighlightingRules } from 'views/logic/slices/highlightActions'; -import useAppDispatch from 'stores/useAppDispatch'; -import type HighlightingRuleType from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import type { + Value, + Condition, + Color, +} from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import HighlightingRuleClass from 'views/logic/views/formatting/highlighting/HighlightingRule'; import type { DraggableProps, DragHandleProps } from 'components/common/SortableList'; import HighlightingRule, { Container, RuleContainer } from './HighlightingRule'; @@ -34,39 +37,65 @@ import SectionInfo from '../SectionInfo'; import SectionSubheadline from '../SectionSubheadline'; type SortableHighlightingRuleProps = { - item: { id: string, rule: HighlightingRuleType }, + item: { id: string, rule: HighlightingRuleClass }, draggableProps: DraggableProps, dragHandleProps: DragHandleProps, className?: string, - ref: React.Ref + onUpdate: (existingRule: HighlightingRuleClass, field: string, value: Value, condition: Condition, color: Color) => Promise, + onDelete: (rule: HighlightingRuleClass) => Promise, } -const SortableHighlightingRule = ({ item: { id, rule }, draggableProps, dragHandleProps, className, ref }: SortableHighlightingRuleProps) => ( + +const SortableHighlightingRule = forwardRef(({ + item: { id, rule }, draggableProps, dragHandleProps, className = undefined, + onUpdate, onDelete, +}, ref) => ( -); +)); + +type Props = { + description: string, + onUpdateRules: (newRules: Array) => Promise, + onCreateRule: (newRule: HighlightingRuleClass) => Promise, + onUpdateRule: (targetRule: HighlightingRuleClass, field: string, value: Value, condition: Condition, color: Color) => Promise, + onDeleteRule: (rule: HighlightingRuleClass) => Promise, + showSearchHighlightInfo?: boolean, +} -const HighlightingRules = () => { +const HighlightingRules = ({ description, onUpdateRules, onCreateRule: onCreateRuleProp, onUpdateRule, onDeleteRule, showSearchHighlightInfo = true }: Props) => { const [showForm, setShowForm] = useState(false); const rules = useContext(HighlightingRulesContext) ?? []; const rulesWithId = rules.map((rule) => ({ rule, id: `${rule.field}-${rule.value}-${rule.color}-${rule.condition}` })); - const dispatch = useAppDispatch(); - const updateRules = useCallback((newRulesWithId: Array<{ id: string, rule: HighlightingRuleType }>) => { + const updateRules = useCallback((newRulesWithId: Array<{ id: string, rule: HighlightingRuleClass }>) => { const newRules = newRulesWithId.map(({ rule }) => rule); - return dispatch(updateHighlightingRules(newRules)); - }, [dispatch]); + return onUpdateRules(newRules); + }, [onUpdateRules]); + + const onCreateRule = useCallback((field: string, value: Value, condition: Condition, color: Color) => ( + onCreateRuleProp(HighlightingRuleClass.create(field, value, condition, color)) + ), [onCreateRuleProp]); + + const listItemRender = useCallback((props: { + item: { id: string, rule: HighlightingRuleClass }, + draggableProps: DraggableProps, + dragHandleProps: DragHandleProps, + className?: string, + }) => ( + + ), [onDeleteRule, onUpdateRule]); return ( <> - Search terms and field values can be highlighted. Highlighting your search query in the results can be enabled/disabled in the graylog server config. - Any field value can be highlighted by clicking on the value and selecting "Highlight this value". - If a term or a value has more than one rule, the first matching rule is used. + {description} Active highlights { onClick={() => setShowForm(!showForm)} title="Add highlighting rule" /> - {showForm && setShowForm(false)} />} - - - Search terms - + {showForm && setShowForm(false)} onSubmit={onCreateRule} />} + + {showSearchHighlightInfo && ( + + + Search terms + + )} + customListItemRender={listItemRender} /> ); }; diff --git a/graylog2-web-interface/src/views/components/sidebar/highlighting/ViewsHighlightingRules.tsx b/graylog2-web-interface/src/views/components/sidebar/highlighting/ViewsHighlightingRules.tsx new file mode 100644 index 000000000000..bb3fe7da2ee5 --- /dev/null +++ b/graylog2-web-interface/src/views/components/sidebar/highlighting/ViewsHighlightingRules.tsx @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useCallback } from 'react'; + +import { + updateHighlightingRules, + addHighlightingRule, + updateHighlightingRule, removeHighlightingRule, +} from 'views/logic/slices/highlightActions'; +import HighlightingRules from 'views/components/sidebar/highlighting/HighlightingRules'; +import type { + Value, + Condition, + Color, +} from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import type HighlightingRule from 'views/logic/views/formatting/highlighting/HighlightingRule'; +import useAppDispatch from 'stores/useAppDispatch'; + +const DESCRIPTION = 'Search terms and field values can be highlighted. Highlighting your search query in the results can be enabled/disabled in the graylog server config.\n' + + 'Any field value can be highlighted by clicking on the value and selecting "Highlight this value".\n' + + 'If a term or a value has more than one rule, the first matching rule is used.'; + +const ViewsHighlightingRules = () => { + const dispatch = useAppDispatch(); + const onUpdateRules = useCallback((newRules: Array) => dispatch(updateHighlightingRules(newRules)).then(() => {}), [dispatch]); + + const onCreateRule = useCallback((newRule: HighlightingRule) => ( + dispatch(addHighlightingRule(newRule)).then(() => {}) + ), [dispatch]); + + const onUpdateRule = useCallback((targetRule: HighlightingRule, field: string, value: Value, condition: Condition, color: Color) => ( + dispatch(updateHighlightingRule(targetRule, { field, value, condition, color })).then(() => {}) + ), [dispatch]); + + const onDeleteRule = useCallback((rule: HighlightingRule) => ( + dispatch(removeHighlightingRule(rule)).then(() => {}) + ), [dispatch]); + + return ( + + ); +}; + +export default ViewsHighlightingRules; diff --git a/graylog2-web-interface/src/views/components/sidebar/sidebarActions.tsx b/graylog2-web-interface/src/views/components/sidebar/sidebarActions.tsx index 9c0ff2f73aa0..ca8896de04dc 100644 --- a/graylog2-web-interface/src/views/components/sidebar/sidebarActions.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/sidebarActions.tsx @@ -26,7 +26,7 @@ export type SidebarAction = { Component: React.ComponentType }; -const sidebarSections: Array = [ +const sidebarActions: Array = [ { key: 'undoAction', Component: UndoNavItem, @@ -37,4 +37,4 @@ const sidebarSections: Array = [ }, ]; -export default sidebarSections; +export default sidebarActions; diff --git a/graylog2-web-interface/src/views/components/sidebar/sidebarSections.tsx b/graylog2-web-interface/src/views/components/sidebar/sidebarSections.tsx index af9eaf978cd5..b98f10d32f3b 100644 --- a/graylog2-web-interface/src/views/components/sidebar/sidebarSections.tsx +++ b/graylog2-web-interface/src/views/components/sidebar/sidebarSections.tsx @@ -17,15 +17,14 @@ import * as React from 'react'; import type { IconName } from 'components/common/Icon'; +import ViewsHighlightingRules from 'views/components/sidebar/highlighting/ViewsHighlightingRules'; import ViewDescription from './description/ViewDescription'; import AddWidgetButton from './create/AddWidgetButton'; -import HighlightingRules from './highlighting/HighlightingRules'; export type SidebarSectionProps = { sidebarChildren: React.ReactElement, sidebarIsPinned: boolean, - queryId: string, results: any, toggleSidebar: () => void }; @@ -56,7 +55,7 @@ const sidebarSections: Array = [ key: 'highlighting', icon: 'format_paragraph', title: 'Highlighting', - content: () => , + content: () => , }, { key: 'fieldList', diff --git a/graylog2-web-interface/src/views/components/views/ViewHeader.tsx b/graylog2-web-interface/src/views/components/views/ViewHeader.tsx index 1cd835f75498..3d0ef19fb8a5 100644 --- a/graylog2-web-interface/src/views/components/views/ViewHeader.tsx +++ b/graylog2-web-interface/src/views/components/views/ViewHeader.tsx @@ -29,11 +29,12 @@ import useViewTitle from 'views/hooks/useViewTitle'; import useView from 'views/hooks/useView'; import useAppDispatch from 'stores/useAppDispatch'; import FavoriteIcon from 'views/components/FavoriteIcon'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import { updateView } from 'views/logic/slices/viewSlice'; import useIsNew from 'views/hooks/useIsNew'; import { createGRN } from 'logic/permissions/GRN'; import ExecutionInfo from 'views/components/views/ExecutionInfo'; +import useAlertAndEventDefinitionData from 'components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'; +import useReplaySearchContext from 'components/event-definitions/replay-search/hooks/useReplaySearchContext'; const links = { [View.Type.Dashboard]: ({ id, title }) => [{ @@ -113,7 +114,7 @@ const StyledIcon = styled(Icon)` font-size: 0.5rem; `; -const CrumbLink = ({ label, link, dataTestId }: { label: string, link: string | undefined, dataTestId?: string}) => ( +const CrumbLink = ({ label, link, dataTestId = undefined }: { label: string, link: string | undefined, dataTestId?: string}) => ( link ? {label} : {label} ); @@ -124,7 +125,8 @@ const ViewHeader = () => { const [showMetadataEdit, setShowMetadataEdit] = useState(false); const toggleMetadataEdit = useCallback(() => setShowMetadataEdit((cur) => !cur), [setShowMetadataEdit]); - const { alertId, definitionId, definitionTitle, isAlert, isEventDefinition, isEvent } = useAlertAndEventDefinitionData(); + const { alertId, definitionId, type } = useReplaySearchContext(); + const { definitionTitle } = useAlertAndEventDefinitionData(alertId, definitionId); const dispatch = useAppDispatch(); const _onSaveView = useCallback(async (updatedView: View) => { await dispatch(onSaveView(updatedView)); @@ -136,11 +138,16 @@ const ViewHeader = () => { const onChangeFavorite = useCallback((newValue: boolean) => dispatch(updateView(view.toBuilder().favorite(newValue).build())), [dispatch, view]); const breadCrumbs = useMemo(() => { - if (isAlert || isEvent) return links.alert({ id: alertId }); - if (isEventDefinition) return links.eventDefinition({ id: definitionId, title: definitionTitle }); - - return links[view.type]({ id: view.id, title }); - }, [alertId, definitionId, definitionTitle, isAlert, isEvent, isEventDefinition, view, title]); + switch (type) { + case 'alert': + case 'event': + return links.alert({ id: alertId }); + case 'event_definition': + return links.eventDefinition({ id: definitionId, title: definitionTitle }); + default: + return links[view.type]({ id: view.id, title }); + } + }, [type, alertId, definitionId, definitionTitle, view.type, view.id, title]); const showExecutionInfo = view.type === 'SEARCH'; diff --git a/graylog2-web-interface/src/views/components/widgets/FieldsConfiguration.tsx b/graylog2-web-interface/src/views/components/widgets/FieldsConfiguration.tsx index 29f80d19f86e..3070ba4fbba4 100644 --- a/graylog2-web-interface/src/views/components/widgets/FieldsConfiguration.tsx +++ b/graylog2-web-interface/src/views/components/widgets/FieldsConfiguration.tsx @@ -56,10 +56,10 @@ type Props = { const FieldsConfiguration = ({ createSelectPlaceholder = 'Add a field', displaySortableListOverlayInPortal = false, - menuPortalTarget, + menuPortalTarget = undefined, onChange, - isFieldQualified, - selectSize, + isFieldQualified = undefined, + selectSize = undefined, selectedFields, testPrefix = '', showSelectAllRest = false, @@ -92,21 +92,23 @@ const FieldsConfiguration = ({ return ( <> {_showListCollapseButton && ( - { - setShowSelectedList((cur) => !cur); - }}> - {showSelectedList ? `Hide ${selectedFields.length} selected fields` : `Show ${selectedFields.length} selected fields`} - + { + setShowSelectedList((cur) => !cur); + }}> + {showSelectedList ? `Hide ${selectedFields.length} selected fields` : `Show ${selectedFields.length} selected fields`} + )} {showSelectedList && ( - + )} > + fieldSelectMenuPortalTarget: HTMLElement | undefined, + item: { id: string, title: string }, onChange: (fieldName: string) => void, onRemove: () => void, - selectedFields: Array, selectSize: 'normal' | 'small', - testIdPrefix: string, + selectedFields: Array, showUnit: boolean, + testIdPrefix: string, } const Actions = styled.div` @@ -65,16 +67,18 @@ const Actions = styled.div` `; const ListItem = forwardRef(({ - selectSize, className, dragHandleProps, draggableProps, + fieldSelect = FieldSelect, + fieldSelectMenuPortalTarget, item, onChange, onRemove, + selectSize, selectedFields, - testIdPrefix, showUnit, + testIdPrefix, }: ListItemProps, ref) => { const [isEditing, setIsEditing] = useState(false); @@ -87,12 +91,14 @@ const ListItem = forwardRef(({ {isEditing && ( setIsEditing(false)} autoFocus openMenuOnFocus clearable={false} size={selectSize} + menuPortalTarget={fieldSelectMenuPortalTarget} excludedFields={selectedFields.filter((fieldName) => fieldName !== item.id)} ariaLabel="Fields" name="add-field-select" @@ -118,15 +124,21 @@ const ListItem = forwardRef(({ }); type Props = { - onChange: (newSelectedFields: Array) => void, displayOverlayInPortal?: boolean, - selectedFields: Array - testPrefix?: string, + fieldSelect?: React.ComponentType> + fieldSelectMenuPortalTarget?: HTMLElement, + onChange: (newSelectedFields: Array) => void, selectSize?: 'normal' | 'small', + selectedFields: Array showUnit?: boolean + testPrefix?: string, }; -const SelectedFieldsList = ({ testPrefix, selectedFields, onChange, selectSize, displayOverlayInPortal = false, showUnit = false }: Props) => { +const SelectedFieldsList = ({ + testPrefix = undefined, selectedFields, onChange, selectSize = undefined, displayOverlayInPortal = false, + showUnit = false, fieldSelect = undefined, fieldSelectMenuPortalTarget = undefined, + +}: Props) => { const fieldsForList = useMemo(() => selectedFields?.map((field) => ({ id: field, title: field })), [selectedFields]); const onChangeField = useCallback((fieldIndex: number, newFieldName: string) => { @@ -147,13 +159,15 @@ const SelectedFieldsList = ({ testPrefix, selectedFields, onChange, selectSize, selectSize={selectSize} selectedFields={selectedFields ?? []} item={item} + fieldSelectMenuPortalTarget={fieldSelectMenuPortalTarget} + fieldSelect={fieldSelect} testIdPrefix={`${testPrefix}-field-${index}`} dragHandleProps={dragHandleProps} draggableProps={draggableProps} className={className} ref={ref} showUnit={showUnit} /> - ), [selectSize, selectedFields, testPrefix, showUnit, onChangeField, onRemoveField]); + ), [selectSize, selectedFields, fieldSelectMenuPortalTarget, fieldSelect, testPrefix, showUnit, onChangeField, onRemoveField]); const onSortChange = useCallback((newFieldsList: Array<{ id: string, title: string }>) => { onChange(newFieldsList.map(({ id }) => id)); 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 f7d9aee40949..2116b9c74228 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 @@ -24,17 +24,31 @@ import usePluginEntities from 'hooks/usePluginEntities'; import useEventById from 'hooks/useEventById'; import { mockEventData, mockEventDefinitionTwoAggregations } from 'helpers/mocking/EventAndEventDefinitions_mock'; import useEventDefinition from 'components/events/events/hooks/useEventDefinition'; +import PerspectivesProvider from 'components/perspectives/contexts/PerspectivesProvider'; +import { defaultPerspective } from 'fixtures/perspectives'; import EventDetails from './EventDetails'; +jest.mock('hooks/usePluginEntities'); jest.mock('hooks/useEventById'); jest.mock('hooks/useCurrentUser'); -jest.mock('hooks/usePluginEntities'); jest.mock('components/events/events/hooks/useEventDefinition'); +const renderEventDetails = () => render( + + + , +); + describe('EventDetails', () => { beforeEach(() => { - asMock(usePluginEntities).mockReturnValue([]); + asMock(usePluginEntities).mockImplementation((entityKey) => ({ + 'views.components.widgets.events.detailsComponent': [], + 'views.components.eventActions': [], + eventDefinitionTypes: [], + perspectives: [defaultPerspective], + }[entityKey])); + asMock(useCurrentUser).mockReturnValue(adminUser); asMock(useEventDefinition).mockReturnValue({ data: undefined, isFetching: false, isInitialLoading: false }); @@ -53,16 +67,18 @@ describe('EventDetails', () => { useCondition: () => true, key: 'details-component', }], + perspectives: [defaultPerspective], }[entityKey])); - render(); + renderEventDetails(); await screen.findByText('Pluggable details component'); }); it('should render default event details', async () => { asMock(useEventDefinition).mockReturnValue({ data: mockEventDefinitionTwoAggregations, isFetching: false, isInitialLoading: false }); - render(); + + renderEventDetails(); await waitFor(() => expect(useEventDefinition).toHaveBeenCalledWith('event-definition-id-1', true)); await screen.findByText('Additional Fields'); @@ -70,7 +86,7 @@ describe('EventDetails', () => { it('should not fetch event definition when user does not have required permissions', async () => { asMock(useCurrentUser).mockReturnValue(alice); - render(); + renderEventDetails(); await waitFor(() => expect(useEventDefinition).toHaveBeenCalledWith('event-definition-id-1', false)); await screen.findByText('Additional Fields'); diff --git a/graylog2-web-interface/src/views/logic/ExpressionConditionMappers.tsx b/graylog2-web-interface/src/views/logic/ExpressionConditionMappers.tsx index ca853b7fc0ab..42d8bff4cf58 100644 --- a/graylog2-web-interface/src/views/logic/ExpressionConditionMappers.tsx +++ b/graylog2-web-interface/src/views/logic/ExpressionConditionMappers.tsx @@ -18,7 +18,7 @@ import type { ValueExpr } from 'hooks/useEventDefinition'; import type { Condition } from 'views/logic/views/formatting/highlighting/HighlightingRule'; -export const exprToConditionMapper: {[name: string]: Condition} = { +export const exprToConditionMapper: { [name: string]: Condition } = { '<': 'less', '<=': 'less_equal', '>=': 'greater_equal', @@ -26,7 +26,7 @@ export const exprToConditionMapper: {[name: string]: Condition} = { '==': 'equal', }; -export const conditionToExprMapper: {[name: string]: ValueExpr} = { +export const conditionToExprMapper: { [name: string]: ValueExpr } = { less: '<', less_equal: '<=', greater_equal: '>=', diff --git a/graylog2-web-interface/src/views/logic/queries/QueryHelper.test.ts b/graylog2-web-interface/src/views/logic/queries/QueryHelper.test.ts index af75618ff00a..0934446ee4a3 100644 --- a/graylog2-web-interface/src/views/logic/queries/QueryHelper.test.ts +++ b/graylog2-web-interface/src/views/logic/queries/QueryHelper.test.ts @@ -14,7 +14,9 @@ * along with this program. If not, see * . */ -import { concatQueryStrings, escape } from './QueryHelper'; +import { MISSING_BUCKET_NAME } from 'views/Constants'; + +import { concatQueryStrings, escape, predicate } from './QueryHelper'; describe('QueryHelper', () => { it('quotes $ in values', () => { @@ -52,4 +54,19 @@ describe('QueryHelper', () => { expect(result).toEqual('field1:value1 OR field2:value2'); }); }); + + describe('predicate', () => { + it('creates simple field/value predicate for strings', () => { + expect(predicate('foo', 'bar')).toEqual('foo:bar'); + }); + + it('creates simple field/value predicate for numbers', () => { + expect(predicate('foo', 23)).toEqual('foo:23'); + }); + + it('treats missing value bucket correctly', () => { + expect(predicate('foo', MISSING_BUCKET_NAME)).toEqual('NOT _exists_:foo'); + expect(predicate('foo', escape(MISSING_BUCKET_NAME))).toEqual('NOT _exists_:foo'); + }); + }); }); diff --git a/graylog2-web-interface/src/views/logic/queries/QueryHelper.ts b/graylog2-web-interface/src/views/logic/queries/QueryHelper.ts index eb13c6d5e498..8d03f99acf71 100644 --- a/graylog2-web-interface/src/views/logic/queries/QueryHelper.ts +++ b/graylog2-web-interface/src/views/logic/queries/QueryHelper.ts @@ -15,10 +15,14 @@ * . */ import trim from 'lodash/trim'; +import moment from 'moment-timezone'; -const isPhrase = (searchTerm: string | undefined | null) => String(searchTerm).indexOf(' ') !== -1; +import { DATE_TIME_FORMATS } from 'util/DateTime'; +import { MISSING_BUCKET_NAME } from 'views/Constants'; -const escape = (searchTerm: string | number | undefined | null) => { +export const isPhrase = (searchTerm: string | undefined | null) => String(searchTerm).indexOf(' ') !== -1; + +export const escape = (searchTerm: string | number | undefined | null) => { let escapedTerm = String(searchTerm); // Replace newlines. @@ -37,7 +41,7 @@ const escape = (searchTerm: string | number | undefined | null) => { return escapedTerm; }; -const addToQuery = (oldQuery: string, newTerm: string, operator: string = 'AND') => { +export const addToQuery = (oldQuery: string, newTerm: string, operator: string = 'AND') => { if (trim(oldQuery) === '*' || trim(oldQuery) === '') { return newTerm; } @@ -49,11 +53,21 @@ const addToQuery = (oldQuery: string, newTerm: string, operator: string = 'AND') return `${oldQuery} ${operator} ${newTerm}`; }; -const concatQueryStrings = (queryStrings: Array, { operator = 'AND', withBrackets = true } = {}): string => { +export const concatQueryStrings = (queryStrings: Array, { operator = 'AND', withBrackets = true } = {}): string => { const withRemovedEmpty = queryStrings.filter((s: string) => !!(s?.trim())); const showBracketsForChild = withBrackets && withRemovedEmpty.length > 1; return withRemovedEmpty.map((s) => (showBracketsForChild ? `(${s})` : s)).join(` ${operator} `); }; -export { isPhrase, escape, addToQuery, concatQueryStrings }; +export const formatTimestamp = (value: string | number) => { + const utc = moment(value).tz('UTC'); + + return `"${utc.format(DATE_TIME_FORMATS.internalIndexer)}"`; +}; + +export const predicate = (field: string, value: string | number) => ((value === MISSING_BUCKET_NAME || value === escape(MISSING_BUCKET_NAME)) + ? `NOT _exists_:${field}` + : `${field}:${value}`); + +export const not = (query:string) => `NOT ${query}`.replace(/^NOT NOT /, ''); diff --git a/graylog2-web-interface/src/views/logic/valueactions/AddToQueryHandler.ts b/graylog2-web-interface/src/views/logic/valueactions/AddToQueryHandler.ts index edde77f3741e..3f96f83ad403 100644 --- a/graylog2-web-interface/src/views/logic/valueactions/AddToQueryHandler.ts +++ b/graylog2-web-interface/src/views/logic/valueactions/AddToQueryHandler.ts @@ -14,32 +14,19 @@ * along with this program. If not, see * . */ -import moment from 'moment-timezone'; - -import { DATE_TIME_FORMATS } from 'util/DateTime'; -import { MISSING_BUCKET_NAME } from 'views/Constants'; import type FieldType from 'views/logic/fieldtypes/FieldType'; -import { escape, addToQuery } from 'views/logic/queries/QueryHelper'; +import { escape, addToQuery, formatTimestamp, predicate } from 'views/logic/queries/QueryHelper'; import { updateQueryString } from 'views/logic/slices/viewSlice'; import { selectQueryString } from 'views/logic/slices/viewSelectors'; import type { AppDispatch } from 'stores/useAppDispatch'; import type { RootState } from 'views/types'; -const formatTimestampForES = (value: string | number) => { - const utc = moment(value).tz('UTC'); - - return `"${utc.format(DATE_TIME_FORMATS.internalIndexer)}"`; -}; - const formatNewQuery = (oldQuery: string, field: string, value: string | number, type: FieldType) => { const predicateValue = type.type === 'date' - ? formatTimestampForES(value) + ? formatTimestamp(value) : escape(value); - const fieldPredicate = value === MISSING_BUCKET_NAME - ? `NOT _exists_:${field}` - : `${field}:${predicateValue}`; - return addToQuery(oldQuery, fieldPredicate); + return addToQuery(oldQuery, predicate(field, predicateValue)); }; type Arguments = { diff --git a/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.test.ts b/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.test.ts index b3cb7887b4a6..8bdce1712c7c 100644 --- a/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.test.ts +++ b/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.test.ts @@ -41,6 +41,10 @@ jest.mock('views/logic/slices/viewSlice', () => ({ })); describe('ExcludeFromQueryHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const defaultView = createSearch(); const createViewWithQuery = (query: Query, type: ViewType = View.Type.Search) => { diff --git a/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.ts b/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.ts index cd18837c041f..6aed942d40ed 100644 --- a/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.ts +++ b/graylog2-web-interface/src/views/logic/valueactions/ExcludeFromQueryHandler.ts @@ -14,17 +14,14 @@ * along with this program. If not, see * . */ -import { escape, addToQuery } from 'views/logic/queries/QueryHelper'; -import { MISSING_BUCKET_NAME } from 'views/Constants'; +import { escape, addToQuery, predicate, not } from 'views/logic/queries/QueryHelper'; import type { AppDispatch } from 'stores/useAppDispatch'; import type { RootState } from 'views/types'; import { updateQueryString } from 'views/logic/slices/viewSlice'; import { selectQueryString } from 'views/logic/slices/viewSelectors'; const formatNewQuery = (oldQuery: string, field: string, value: any) => { - const fieldPredicate = value === MISSING_BUCKET_NAME - ? `_exists_:${field}` - : `NOT ${field}:${escape(value)}`; + const fieldPredicate = not(predicate(field, escape(value))); return addToQuery(oldQuery, fieldPredicate); }; diff --git a/graylog2-web-interface/src/views/logic/valueactions/ShowDocumentsHandler.ts b/graylog2-web-interface/src/views/logic/valueactions/ShowDocumentsHandler.ts index 6a7f712f1551..6389cf87d688 100644 --- a/graylog2-web-interface/src/views/logic/valueactions/ShowDocumentsHandler.ts +++ b/graylog2-web-interface/src/views/logic/valueactions/ShowDocumentsHandler.ts @@ -14,8 +14,8 @@ * along with this program. If not, see * . */ -import { DEFAULT_MESSAGE_FIELDS, MISSING_BUCKET_NAME } from 'views/Constants'; -import { escape, addToQuery } from 'views/logic/queries/QueryHelper'; +import { DEFAULT_MESSAGE_FIELDS } from 'views/Constants'; +import { escape, addToQuery, predicate } from 'views/logic/queries/QueryHelper'; import TitleTypes from 'views/stores/TitleTypes'; import type { AppDispatch } from 'stores/useAppDispatch'; import type { GetState } from 'views/types'; @@ -53,7 +53,7 @@ const ShowDocumentsHandler = ({ const mergedObject = Object.fromEntries(valuePath.flatMap(Object.entries)); const widgetQuery = widget && widget.query ? widget.query.query_string : ''; const valuePathQuery = Object.entries(mergedObject) - .map(([k, v]) => (v === MISSING_BUCKET_NAME ? `NOT _exists_:${k}` : `${k}:${escape(String(v))}`)) + .map(([k, v]) => (predicate(k, escape(v)))) .reduce((prev: string, next: string) => addToQuery(prev, next), ''); const query = addToQuery(widgetQuery, valuePathQuery); const valuePathFields = extractFieldsFromValuePath(valuePath); @@ -62,7 +62,6 @@ const ShowDocumentsHandler = ({ .query(createElasticsearchQueryString(query)) .newId() .config(MessagesWidgetConfig.builder() - // @ts-ignore .fields([...messageListFields]) .showMessageRow(true).build()) .build(); diff --git a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEvent.ts b/graylog2-web-interface/src/views/logic/views/UseCreateViewForEvent.ts index 58b071c4842a..76d593c4d329 100644 --- a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEvent.ts +++ b/graylog2-web-interface/src/views/logic/views/UseCreateViewForEvent.ts @@ -19,7 +19,12 @@ import * as Immutable from 'immutable'; import uniq from 'lodash/uniq'; import View from 'views/logic/views/View'; -import type { AbsoluteTimeRange, ElasticsearchQueryString, RelativeTimeRangeStartOnly } from 'views/logic/queries/Query'; +import type { + AbsoluteTimeRange, + ElasticsearchQueryString, + RelativeTimeRangeStartOnly, + TimeRange, +} from 'views/logic/queries/Query'; import type { Event } from 'components/events/events/types'; import type { EventDefinition, SearchFilter } from 'components/event-definitions/event-definitions-types'; import QueryGenerator from 'views/logic/queries/QueryGenerator'; @@ -42,7 +47,7 @@ import SortConfig from 'views/logic/aggregationbuilder/SortConfig'; import Direction from 'views/logic/aggregationbuilder/Direction'; import type { ParameterJson } from 'views/logic/parameters/Parameter'; import Parameter from 'views/logic/parameters/Parameter'; -import { concatQueryStrings, escape } from 'views/logic/queries/QueryHelper'; +import { concatQueryStrings, escape, predicate } from 'views/logic/queries/QueryHelper'; import HighlightingRule, { randomColor } from 'views/logic/views/formatting/highlighting/HighlightingRule'; import { exprToConditionMapper } from 'views/logic/ExpressionConditionMappers'; import FormattingSettings from 'views/logic/views/formatting/FormattingSettings'; @@ -217,26 +222,32 @@ export const ViewGenerator = async ({ }; export const UseCreateViewForEvent = ( - { eventData, eventDefinition, aggregations }: { eventData: Event, eventDefinition: EventDefinition, aggregations: Array }, + { eventData, eventDefinition, aggregations }: { eventData?: Event, eventDefinition: EventDefinition, aggregations: Array }, ) => { - const queryStringFromGrouping = concatQueryStrings(Object.entries(eventData.group_by_fields).map(([field, value]) => `${field}:${escape(value)}`), { withBrackets: false }); - const eventQueryString = eventData?.replay_info?.query || ''; - const { streams, stream_categories: streamCategories } = eventData.replay_info; - const timeRange: AbsoluteTimeRange = { - type: 'absolute', - from: eventData?.replay_info?.timerange_start, - to: eventData?.replay_info?.timerange_end, - }; + const queryStringFromGrouping = concatQueryStrings(Object.entries(eventData?.group_by_fields ?? {}) + .map(([field, value]) => predicate(field, escape(value))), { withBrackets: false }); + const eventQueryString = eventData?.replay_info?.query ?? eventDefinition?.config?.query ?? ''; + const streams = eventData?.replay_info?.streams ?? eventDefinition?.config?.streams ?? []; + const streamCategories = eventData?.replay_info?.stream_categories ?? eventDefinition?.config?.stream_categories ?? []; + const timeRange: TimeRange = eventData + ? { + type: 'absolute', + from: eventData?.replay_info?.timerange_start, + to: eventData?.replay_info?.timerange_end, + } : { + type: 'relative', + range: (eventDefinition?.config?.search_within_ms ?? 0) / 1000, + }; const queryString: ElasticsearchQueryString = { type: 'elasticsearch', - query_string: concatQueryStrings([eventQueryString, queryStringFromGrouping]), + query_string: eventData ? concatQueryStrings([eventQueryString, queryStringFromGrouping]) : (eventDefinition?.config?.query ?? ''), }; - const queryParameters = eventDefinition?.config?.query_parameters || []; + const queryParameters = eventDefinition?.config?.query_parameters ?? []; const groupBy = eventDefinition?.config?.group_by ?? []; - const searchFilters = eventDefinition?.config?.filters; + const searchFilters = eventDefinition?.config?.filters ?? []; return useMemo( () => ViewGenerator({ streams, streamCategories, timeRange, queryString, aggregations, groupBy, queryParameters, searchFilters }), diff --git a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.ts b/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.ts deleted file mode 100644 index cdc10f9355e3..000000000000 --- a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.ts +++ /dev/null @@ -1,55 +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 { useMemo } from 'react'; - -import type { EventDefinition } from 'components/event-definitions/event-definitions-types'; -import type { EventDefinitionAggregation } from 'hooks/useEventDefinition'; -import type { ElasticsearchQueryString, RelativeTimeRangeStartOnly } from 'views/logic/queries/Query'; -import { ViewGenerator } from 'views/logic/views/UseCreateViewForEvent'; - -const useCreateViewForEventDefinition = ( - { - eventDefinition, - aggregations, - }: { eventDefinition: EventDefinition, aggregations: Array }, -) => { - const streams = eventDefinition?.config?.streams ?? []; - const streamCategories = eventDefinition?.config?.stream_categories ?? []; - const timeRange: RelativeTimeRangeStartOnly = { - type: 'relative', - range: (eventDefinition?.config?.search_within_ms ?? 0) / 1000, - }; - const queryString: ElasticsearchQueryString = { - type: 'elasticsearch', - query_string: eventDefinition?.config?.query || '', - }; - - const queryParameters = eventDefinition?.config?.query_parameters || []; - - const groupBy = eventDefinition?.config?.group_by ?? []; - - const searchFilters = eventDefinition?.config?.filters ?? []; - - return useMemo( - () => ViewGenerator({ streams, streamCategories, timeRange, queryString, aggregations, groupBy, queryParameters, searchFilters }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); -}; - -export default useCreateViewForEventDefinition; diff --git a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.test.ts b/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinitions.test.ts similarity index 99% rename from graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.test.ts rename to graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinitions.test.ts index 4fd1d45746c6..f6a587f015cf 100644 --- a/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinition.test.ts +++ b/graylog2-web-interface/src/views/logic/views/UseCreateViewForEventDefinitions.test.ts @@ -22,7 +22,7 @@ import { mockEventDefinitionOneAggregation, mockEventDefinitionTwoAggregations, } from 'helpers/mocking/EventAndEventDefinitions_mock'; -import UseCreateViewForEventDefinition from 'views/logic/views/UseCreateViewForEventDefinition'; +import UseCreateViewForEventDefinition from 'views/logic/views/UseCreateViewForEvent'; import generateId from 'logic/generateId'; import asMock from 'helpers/mocking/AsMock'; import type View from 'views/logic/views/View'; diff --git a/graylog2-web-interface/src/views/pages/BulkEventReplayPage.tsx b/graylog2-web-interface/src/views/pages/BulkEventReplayPage.tsx index 6dc73ce4133e..6a48dcd5a745 100644 --- a/graylog2-web-interface/src/views/pages/BulkEventReplayPage.tsx +++ b/graylog2-web-interface/src/views/pages/BulkEventReplayPage.tsx @@ -35,7 +35,7 @@ const useEventsById = (eventIds: Array) => useQuery(['events', eventIds] const BulkEventReplayPage = () => { const location = useLocation(); - const { eventIds: initialEventIds = [], returnUrl } = location.state; + const { eventIds: initialEventIds = [], returnUrl } = (location?.state ?? {}); const { data: events, isInitialLoading } = useEventsById(initialEventIds); const history = useHistory(); diff --git a/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.test.tsx b/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.test.tsx index b0672283fa9c..72a71a296996 100644 --- a/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.test.tsx +++ b/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.test.tsx @@ -21,20 +21,20 @@ import MockStore from 'helpers/mocking/StoreMock'; import asMock from 'helpers/mocking/AsMock'; import SearchComponent from 'views/components/Search'; import StreamsContext from 'contexts/StreamsContext'; -import UseCreateViewForEventDefinition from 'views/logic/views/UseCreateViewForEventDefinition'; +import UseCreateViewForEvent from 'views/logic/views/UseCreateViewForEvent'; import useProcessHooksForView from 'views/logic/views/UseProcessHooksForView'; import { createSearch } from 'fixtures/searches'; import useViewsPlugin from 'views/test/testViewsPlugin'; import SearchExecutionState from 'views/logic/search/SearchExecutionState'; import EventDefinitionReplaySearchPage, { onErrorHandler } from 'views/pages/EventDefinitionReplaySearchPage'; import useEventDefinition from 'hooks/useEventDefinition'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; import { mockedMappedAggregation, mockEventDefinitionTwoAggregations, } from 'helpers/mocking/EventAndEventDefinitions_mock'; import useParams from 'routing/useParams'; import type { Stream } from 'logic/streams/types'; +import useAlertAndEventDefinitionData from 'components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'; const mockView = createSearch(); @@ -42,12 +42,12 @@ jest.mock('views/components/Search'); jest.mock('routing/useParams'); jest.mock('views/logic/views/Actions'); -jest.mock('views/logic/views/UseCreateViewForEventDefinition'); +jest.mock('views/logic/views/UseCreateViewForEvent'); jest.mock('views/logic/views/UseProcessHooksForView'); jest.mock('views/hooks/useCreateSearch'); jest.mock('hooks/useEventDefinition'); -jest.mock('hooks/useAlertAndEventDefinitionData'); +jest.mock('components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'); jest.mock('stores/event-notifications/EventNotificationsStore', () => ({ EventNotificationsActions: { @@ -77,7 +77,7 @@ describe('EventDefinitionReplaySearchPage', () => { beforeEach(() => { asMock(useParams).mockReturnValue({ definitionId: mockEventDefinitionTwoAggregations.id }); - asMock(UseCreateViewForEventDefinition).mockReturnValue(Promise.resolve(mockView)); + asMock(UseCreateViewForEvent).mockReturnValue(Promise.resolve(mockView)); asMock(useProcessHooksForView).mockReturnValue({ status: 'loaded', view: mockView, executionState: SearchExecutionState.empty() }); asMock(SearchComponent).mockImplementation(() => Extended Search Page); @@ -90,17 +90,15 @@ describe('EventDefinitionReplaySearchPage', () => { }); it('should run useEventDefinition, UseCreateViewForEvent with correct parameters', async () => { - asMock(useAlertAndEventDefinitionData).mockImplementation(() => ({ + asMock(useAlertAndEventDefinitionData).mockReturnValue({ eventData: undefined, eventDefinition: mockEventDefinitionTwoAggregations, aggregations: mockedMappedAggregation, - isEvent: false, - isEventDefinition: true, - isAlert: false, alertId: undefined, definitionId: mockEventDefinitionTwoAggregations.id, definitionTitle: mockEventDefinitionTwoAggregations.title, - })); + isLoading: false, + }); render(); @@ -109,7 +107,7 @@ describe('EventDefinitionReplaySearchPage', () => { })); await waitFor(() => { - expect(UseCreateViewForEventDefinition).toHaveBeenCalledWith({ + expect(UseCreateViewForEvent).toHaveBeenCalledWith({ eventDefinition: mockEventDefinitionTwoAggregations, aggregations: mockedMappedAggregation, }); }); diff --git a/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.tsx b/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.tsx index bb9f1f6eb425..2454fa1ae078 100644 --- a/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.tsx +++ b/graylog2-web-interface/src/views/pages/EventDefinitionReplaySearchPage.tsx @@ -15,36 +15,15 @@ * . */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import useParams from 'routing/useParams'; import useEventDefinition from 'hooks/useEventDefinition'; import { Spinner } from 'components/common'; -import SearchPage from 'views/pages/SearchPage'; import { EventNotificationsActions } from 'stores/event-notifications/EventNotificationsStore'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; -import useCreateViewForEventDefinition from 'views/logic/views/UseCreateViewForEventDefinition'; -import EventInfoBar from 'components/event-definitions/replay-search/EventInfoBar'; import { createFromFetchError } from 'logic/errors/ReportedErrors'; import ErrorsActions from 'actions/errors/ErrorsActions'; -import useCreateSearch from 'views/hooks/useCreateSearch'; -import SearchPageLayoutProvider from 'views/components/contexts/SearchPageLayoutProvider'; - -const EventView = () => { - const { eventDefinition, aggregations } = useAlertAndEventDefinitionData(); - const _view = useCreateViewForEventDefinition({ eventDefinition, aggregations }); - const view = useCreateSearch(_view); - const searchPageLayout = useMemo(() => ({ - infoBar: { component: EventInfoBar }, - }), []); - - return ( - - - - ); -}; +import ReplaySearch from 'components/events/ReplaySearch'; export const onErrorHandler = (error) => { if (error.status === 404) { @@ -54,7 +33,7 @@ export const onErrorHandler = (error) => { const EventDefinitionReplaySearchPage = () => { const [isNotificationLoaded, setIsNotificationLoaded] = useState(false); - const { definitionId } = useParams<{ definitionId?: string }>(); + const { alertId, definitionId } = useParams<{ alertId?: string, definitionId?: string }>(); const { isLoading: EDIsLoading, isFetched: EDIsFetched } = useEventDefinition(definitionId, { onErrorHandler }); useEffect(() => { @@ -63,7 +42,9 @@ const EventDefinitionReplaySearchPage = () => { const isLoading = EDIsLoading || !EDIsFetched || !isNotificationLoaded; - return isLoading ? : ; + return isLoading + ? + : ; }; export default EventDefinitionReplaySearchPage; diff --git a/graylog2-web-interface/src/views/pages/EventReplaySearchPage.test.tsx b/graylog2-web-interface/src/views/pages/EventReplaySearchPage.test.tsx index 2fd36665f68f..8a972cfc9a24 100644 --- a/graylog2-web-interface/src/views/pages/EventReplaySearchPage.test.tsx +++ b/graylog2-web-interface/src/views/pages/EventReplaySearchPage.test.tsx @@ -29,7 +29,7 @@ import SearchExecutionState from 'views/logic/search/SearchExecutionState'; import EventReplaySearchPage, { onErrorHandler } from 'views/pages/EventReplaySearchPage'; import useEventById from 'hooks/useEventById'; import useEventDefinition from 'hooks/useEventDefinition'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; +import useAlertAndEventDefinitionData from 'components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'; import { mockedMappedAggregation, mockEventData, @@ -49,7 +49,7 @@ jest.mock('views/logic/views/UseProcessHooksForView'); jest.mock('views/hooks/useCreateSearch'); jest.mock('hooks/useEventById'); jest.mock('hooks/useEventDefinition'); -jest.mock('hooks/useAlertAndEventDefinitionData'); +jest.mock('components/event-definitions/replay-search/hooks/useAlertAndEventDefinitionData'); jest.mock('stores/event-notifications/EventNotificationsStore', () => ({ EventNotificationsActions: { @@ -103,12 +103,10 @@ describe('EventReplaySearchPage', () => { eventData: mockEventData.event, eventDefinition: mockEventDefinitionTwoAggregations, aggregations: mockedMappedAggregation, - isEvent: true, - isEventDefinition: false, - isAlert: false, alertId: mockEventData.event.id, definitionId: mockEventDefinitionTwoAggregations.id, definitionTitle: mockEventDefinitionTwoAggregations.title, + isLoading: false, })); render(); diff --git a/graylog2-web-interface/src/views/pages/EventReplaySearchPage.tsx b/graylog2-web-interface/src/views/pages/EventReplaySearchPage.tsx index 105322bd6835..c3e05da817a5 100644 --- a/graylog2-web-interface/src/views/pages/EventReplaySearchPage.tsx +++ b/graylog2-web-interface/src/views/pages/EventReplaySearchPage.tsx @@ -15,37 +15,16 @@ * . */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import useParams from 'routing/useParams'; import useEventById from 'hooks/useEventById'; import useEventDefinition from 'hooks/useEventDefinition'; import { Spinner } from 'components/common'; -import SearchPage from 'views/pages/SearchPage'; import { EventNotificationsActions } from 'stores/event-notifications/EventNotificationsStore'; -import useCreateViewForEvent from 'views/logic/views/UseCreateViewForEvent'; -import useAlertAndEventDefinitionData from 'hooks/useAlertAndEventDefinitionData'; -import EventInfoBar from 'components/event-definitions/replay-search/EventInfoBar'; import { createFromFetchError } from 'logic/errors/ReportedErrors'; import ErrorsActions from 'actions/errors/ErrorsActions'; -import useCreateSearch from 'views/hooks/useCreateSearch'; -import SearchPageLayoutProvider from 'views/components/contexts/SearchPageLayoutProvider'; - -const EventView = () => { - const { eventData, eventDefinition, aggregations } = useAlertAndEventDefinitionData(); - const _view = useCreateViewForEvent({ eventData, eventDefinition, aggregations }); - const view = useCreateSearch(_view); - const searchPageLayout = useMemo(() => ({ - infoBar: { component: EventInfoBar }, - }), []); - - return ( - - - - ); -}; +import ReplaySearch from 'components/events/ReplaySearch'; export const onErrorHandler = (error) => { if (error.status === 404) { @@ -55,7 +34,7 @@ export const onErrorHandler = (error) => { const EventReplaySearchPage = () => { const [isNotificationLoaded, setIsNotificationLoaded] = useState(false); - const { alertId } = useParams<{ alertId?: string }>(); + const { alertId, definitionId } = useParams<{ alertId?: string, definitionId?: string }>(); const { data: eventData, isLoading: eventIsLoading, isFetched: eventIsFetched } = useEventById(alertId, { onErrorHandler }); const { isLoading: EDIsLoading, isFetched: EDIsFetched } = useEventDefinition(eventData?.event_definition_id); @@ -65,7 +44,9 @@ const EventReplaySearchPage = () => { const isLoading = eventIsLoading || EDIsLoading || !eventIsFetched || !EDIsFetched || !isNotificationLoaded; - return isLoading ? : ; + return isLoading + ? + : ; }; export default EventReplaySearchPage; diff --git a/graylog2-web-interface/src/views/types.ts b/graylog2-web-interface/src/views/types.ts index 93d0c93a2f1f..17bb863e96d4 100644 --- a/graylog2-web-interface/src/views/types.ts +++ b/graylog2-web-interface/src/views/types.ts @@ -286,8 +286,9 @@ type EventWidgetActionModalProps = React.PropsWithRef<{ ref: React.LegacyRef, } -type EventActionModalProps = React.PropsWithRef<{ +export type EventActionModalProps = React.PropsWithRef<{ events: Array, + fromBulk?: boolean, }> & { ref: React.LegacyRef, } @@ -340,6 +341,7 @@ type AssetInformation = { export type EventActionComponentProps = { events: Array, modalRef: () => T, + fromBulk?: boolean, } type MessageActionComponentProps = { diff --git a/pom.xml b/pom.xml index 19f8f97a2b01..71b6b01e7304 100644 --- a/pom.xml +++ b/pom.xml @@ -103,12 +103,12 @@ 2.6.0 2.2.0 1.79 - 1.15.11 - 3.1.8 + 1.16.1 + 3.2.0 0.0.1.10 4.8.179 1.17.2 - 1.12.0 + 1.13.0 1.6.0 1.4.0 3.17.0 @@ -121,7 +121,7 @@ 1.5.1 4.2.1 0.1.9-graylog-3 - 1.69.0 + 1.69.1 2.0.0 33.4.0-jre 7.0.0 @@ -140,7 +140,7 @@ 0.4 1.0 1.0 - 3.1.9 + 3.1.10 7.0.3 2.13.0 0.9.15 @@ -150,11 +150,11 @@ 0.9.0.1-7 2.24.3 9.12.0 - 4.2.29 - 5.2.1 + 4.2.30 + 5.3.0 4.11.0 0.13 - 4.1.116.Final + 4.1.117.Final 2.0.69.Final 4.12.0 2.3 @@ -173,7 +173,7 @@ 1.6.0 2.0.16 0.8.3 - 1.6.14 + 1.6.15 0.9.61 8.3.0 7.0.2 @@ -184,14 +184,14 @@ 0.3.2 0.12.6 2.6.0 - 5.3.0 + 5.4.0 2.0.0.AM27 - 3.27.2 + 3.27.3 2.2.0 4.2.2 - 3.18 + 3.18.1 1.5 4.13.2 5.11.4