From 9fca2eebda5c9afbeaa7c2dfb475e3cf31f11408 Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Mon, 9 Dec 2024 11:34:05 +0100 Subject: [PATCH] Adding additional filters to alerts & events list. (#21056) * Allow adding event definition filter. * Allowing to filter by `priority`. * Use better way to show priority labels. * Allow filtering by aggregation timerange. * Adding generic input filter for `key` property. * Supporting multiple filters. * Supporting multiple `key` filters. * Adding license headers. * Adding changelog snippet. * Fixing linter hint. * Adding tests for generic filter input. * Handle non-absolute time ranges in aggregation time range filter as well. * Inverting condition. * Improve filtering logic for aggregation time range. * Fixing timestamp filter parsing for all time/now. --- changelog/unreleased/pr-21056.toml | 5 + .../events/processor/EventDefinition.java | 3 + .../graylog/events/rest/EventsResource.java | 18 ++- .../events/search/EventsSearchFilter.java | 29 ++++- .../events/search/EventsSearchService.java | 110 +++++++++++++++-- .../searches/timeranges/TimeRange.java | 5 + .../EntityFilters/EntityFilters.test.tsx | 116 +++++++++++------- .../FilterConfiguration.tsx | 49 +++++--- .../GenericFilterInput.tsx | 77 ++++++++++++ .../src/components/events/Constants.ts | 12 +- .../components/events/EventsEntityTable.tsx | 6 +- .../events/FilterValueRenderers.tsx | 2 + .../components/events/events/PriorityName.tsx | 1 - .../src/components/events/fetchEvents.ts | 66 +++++++++- 14 files changed, 406 insertions(+), 93 deletions(-) create mode 100644 changelog/unreleased/pr-21056.toml create mode 100644 graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/GenericFilterInput.tsx diff --git a/changelog/unreleased/pr-21056.toml b/changelog/unreleased/pr-21056.toml new file mode 100644 index 000000000000..955df7ba413a --- /dev/null +++ b/changelog/unreleased/pr-21056.toml @@ -0,0 +1,5 @@ +type = "a" +message = "Allowing to filter events based on event definition, priority, key & aggregation time range." + +issues = ["21055"] +pulls = ["21056"] diff --git a/graylog2-server/src/main/java/org/graylog/events/processor/EventDefinition.java b/graylog2-server/src/main/java/org/graylog/events/processor/EventDefinition.java index 7f14a8226614..53e7fef7fce7 100644 --- a/graylog2-server/src/main/java/org/graylog/events/processor/EventDefinition.java +++ b/graylog2-server/src/main/java/org/graylog/events/processor/EventDefinition.java @@ -23,11 +23,14 @@ import org.graylog.events.notifications.EventNotificationHandler; import org.graylog.events.notifications.EventNotificationSettings; import org.graylog.events.processor.storage.EventStorageHandler; +import org.graylog2.database.DbEntity; +import org.graylog2.shared.security.RestPermissions; import org.joda.time.DateTime; import javax.annotation.Nullable; import java.util.Set; +@DbEntity(readPermission = RestPermissions.EVENT_DEFINITIONS_READ, collection = DBEventDefinitionService.COLLECTION_NAME) public interface EventDefinition { enum State { ENABLED, diff --git a/graylog2-server/src/main/java/org/graylog/events/rest/EventsResource.java b/graylog2-server/src/main/java/org/graylog/events/rest/EventsResource.java index 197fb4946522..2b17ec7ef5d8 100644 --- a/graylog2-server/src/main/java/org/graylog/events/rest/EventsResource.java +++ b/graylog2-server/src/main/java/org/graylog/events/rest/EventsResource.java @@ -19,6 +19,14 @@ import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.graylog.events.event.EventDto; import org.graylog.events.search.EventsSearchFilter; @@ -31,16 +39,6 @@ import org.graylog2.plugin.rest.PluginRestResource; import org.graylog2.shared.rest.resources.RestResource; -import jakarta.inject.Inject; - -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - import java.util.Optional; import static com.google.common.base.MoreObjects.firstNonNull; diff --git a/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchFilter.java b/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchFilter.java index c51522edc816..bd749c12b1f6 100644 --- a/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchFilter.java +++ b/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchFilter.java @@ -20,8 +20,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.google.auto.value.AutoValue; +import org.graylog2.plugin.indexer.searches.timeranges.TimeRange; import java.util.Collections; +import java.util.Optional; import java.util.Set; @AutoValue @@ -29,6 +31,9 @@ public abstract class EventsSearchFilter { private static final String FIELD_ALERTS = "alerts"; private static final String FIELD_EVENT_DEFINITIONS = "event_definitions"; + private static final String FIELD_PRIORITY = "priority"; + private static final String FIELD_AGGREGATION_TIMERANGE = "aggregation_timerange"; + private static final String FIELD_KEY = "key"; public enum Alerts { @JsonProperty("include") @@ -45,6 +50,15 @@ public enum Alerts { @JsonProperty(FIELD_EVENT_DEFINITIONS) public abstract Set eventDefinitions(); + @JsonProperty(FIELD_PRIORITY) + public abstract Set priority(); + + @JsonProperty(FIELD_AGGREGATION_TIMERANGE) + public abstract Optional aggregationTimerange(); + + @JsonProperty(FIELD_KEY) + public abstract Set key(); + public static EventsSearchFilter empty() { return builder().build(); } @@ -61,7 +75,9 @@ public static abstract class Builder { public static Builder create() { return new AutoValue_EventsSearchFilter.Builder() .alerts(Alerts.INCLUDE) - .eventDefinitions(Collections.emptySet()); + .eventDefinitions(Collections.emptySet()) + .priority(Collections.emptySet()) + .key(Collections.emptySet()); } @JsonProperty(FIELD_ALERTS) @@ -70,6 +86,15 @@ public static Builder create() { @JsonProperty(FIELD_EVENT_DEFINITIONS) public abstract Builder eventDefinitions(Set eventDefinitions); + @JsonProperty(FIELD_PRIORITY) + public abstract Builder priority(Set priority); + + @JsonProperty(FIELD_AGGREGATION_TIMERANGE) + public abstract Builder aggregationTimerange(TimeRange aggregationTimerange); + + @JsonProperty(FIELD_KEY) + public abstract Builder key(Set key); + public abstract EventsSearchFilter build(); } -} \ No newline at end of file +} diff --git a/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchService.java b/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchService.java index 6ff0dc964edc..6a20dbb6afea 100644 --- a/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchService.java +++ b/graylog2-server/src/main/java/org/graylog/events/search/EventsSearchService.java @@ -18,6 +18,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableSet; +import jakarta.inject.Inject; +import org.apache.lucene.search.TermRangeQuery; import org.apache.shiro.subject.Subject; import org.graylog.events.event.EventDto; import org.graylog.events.processor.DBEventDefinitionService; @@ -25,11 +27,12 @@ import org.graylog2.database.NotFoundException; import org.graylog2.indexer.IndexMapping; import org.graylog2.plugin.database.Persisted; +import org.graylog2.plugin.indexer.searches.timeranges.TimeRange; import org.graylog2.shared.security.RestPermissions; import org.graylog2.streams.StreamService; -import jakarta.inject.Inject; - +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -37,12 +40,16 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collector; import java.util.stream.Collectors; +import static org.graylog2.plugin.Tools.ES_DATE_FORMAT_FORMATTER; import static org.graylog2.plugin.streams.Stream.DEFAULT_EVENTS_STREAM_ID; import static org.graylog2.plugin.streams.Stream.DEFAULT_SYSTEM_EVENTS_STREAM_ID; public class EventsSearchService { + private static final Collector joiningQueriesWithAND = Collectors.joining(" AND "); + private static final Collector joiningQueriesWithOR = Collectors.joining(" OR "); private final MoreSearch moreSearch; private final StreamService streamService; private final DBEventDefinitionService eventDefinitionService; @@ -64,17 +71,34 @@ private String eventDefinitionFilter(String id) { } public EventsSearchResult search(EventsSearchParameters parameters, Subject subject) { - final ImmutableSet.Builder filterBuilder = ImmutableSet.builder() - // Make sure we only filter for actual events and ignore anything else that might be in the event - // indices. (fixes an issue when users store non-event messages in event indices) - .add("_exists_:" + EventDto.FIELD_EVENT_DEFINITION_ID); + final var filterBuilder = new ArrayList(); + // Make sure we only filter for actual events and ignore anything else that might be in the event + // indices. (fixes an issue when users store non-event messages in event indices) + filterBuilder.add("_exists_:" + EventDto.FIELD_EVENT_DEFINITION_ID); if (!parameters.filter().eventDefinitions().isEmpty()) { final String eventDefinitionFilter = parameters.filter().eventDefinitions().stream() .map(this::eventDefinitionFilter) - .collect(Collectors.joining(" OR ")); + .collect(joiningQueriesWithOR); - filterBuilder.addAll(Collections.singleton("(" + eventDefinitionFilter + ")")); + filterBuilder.add(eventDefinitionFilter); + } + + if (!parameters.filter().priority().isEmpty()) { + filterBuilder.add(parameters.filter().priority().stream() + .map(this::mapPriority) + .map(priority -> EventDto.FIELD_PRIORITY + ":" + priority) + .collect(joiningQueriesWithOR)); + } + + parameters.filter().aggregationTimerange() + .filter(range -> !range.isAllMessages()) + .ifPresent(aggregationTimerange -> filterBuilder.add(createTimeRangeFilter(aggregationTimerange))); + + if (!parameters.filter().key().isEmpty()) { + filterBuilder.add(parameters.filter().key().stream() + .map(keyFilter -> EventDto.FIELD_KEY_TUPLE + ":" + quote(keyFilter)) + .collect(joiningQueriesWithOR)); } switch (parameters.filter().alerts()) { @@ -89,7 +113,9 @@ public EventsSearchResult search(EventsSearchParameters parameters, Subject subj break; } - final String filter = String.join(" AND ", filterBuilder.build()); + final String filter = filterBuilder.stream() + .map(query -> "(" + query + ")") + .collect(joiningQueriesWithAND); final ImmutableSet eventStreams = ImmutableSet.of(DEFAULT_EVENTS_STREAM_ID, DEFAULT_SYSTEM_EVENTS_STREAM_ID); final MoreSearch.Result result = moreSearch.eventSearch(parameters, filter, eventStreams, forbiddenSourceStreams(subject)); @@ -121,6 +147,72 @@ public EventsSearchResult search(EventsSearchParameters parameters, Subject subj .build(); } + private String createTimeRangeFilter(TimeRange aggregationTimerange) { + final var formattedFrom = aggregationTimerange.getFrom().toString(ES_DATE_FORMAT_FORMATTER); + final var formattedTo = aggregationTimerange.getTo().toString(ES_DATE_FORMAT_FORMATTER); + return or( + group( + or( + TermRangeQuery.newStringRange( + EventDto.FIELD_TIMERANGE_START, + quote(formattedFrom), + quote(formattedTo), + true, + true).toString() + , + TermRangeQuery.newStringRange( + EventDto.FIELD_TIMERANGE_END, + quote(formattedFrom), + quote(formattedTo), + true, + true).toString() + ) + ), + group( + and( + TermRangeQuery.newStringRange( + EventDto.FIELD_TIMERANGE_START, + quote("1970-01-01 00:00:00.000"), + quote(formattedFrom), + true, + true).toString() + , + TermRangeQuery.newStringRange( + EventDto.FIELD_TIMERANGE_END, + quote(formattedTo), + quote("2038-01-01 00:00:00.000"), + true, + true).toString() + ) + ) + ); + } + + private String quote(String s) { + return "\"" + s + "\""; + } + + private String group(String s) { + return "(" + s + ")"; + } + + private String or(String... queries) { + return Arrays.stream(queries).collect(joiningQueriesWithOR); + } + + private String and(String... queries) { + return Arrays.stream(queries).collect(joiningQueriesWithAND); + } + + private long mapPriority(String priorityFilter) { + return switch (priorityFilter) { + case "high", "3" -> 3; + case "normal", "2" -> 2; + case "low", "1" -> 1; + default -> throw new IllegalStateException("Invalid priority: " + priorityFilter); + }; + } + // TODO: Loading all streams for a user is not very efficient. Not sure if we can find an alternative that is // more efficient. Doing a separate ES query to get all source streams that would be in the result is // most probably not more efficient. diff --git a/graylog2-server/src/main/java/org/graylog2/plugin/indexer/searches/timeranges/TimeRange.java b/graylog2-server/src/main/java/org/graylog2/plugin/indexer/searches/timeranges/TimeRange.java index 7fb2de4506d9..5ebf0ecbea58 100644 --- a/graylog2-server/src/main/java/org/graylog2/plugin/indexer/searches/timeranges/TimeRange.java +++ b/graylog2-server/src/main/java/org/graylog2/plugin/indexer/searches/timeranges/TimeRange.java @@ -42,4 +42,9 @@ public abstract class TimeRange { public abstract DateTime getTo(); public abstract TimeRange withReferenceDate(DateTime now); + + @JsonIgnore + public boolean isAllMessages() { + return false; + } } 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 238a4050208d..d185e96945e8 100644 --- a/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx +++ b/graylog2-web-interface/src/components/common/EntityFilters/EntityFilters.test.tsx @@ -18,13 +18,14 @@ import React from 'react'; import { render, screen, waitFor, within } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; import { OrderedMap } from 'immutable'; +import type { Optional } from 'utility-types'; import type { Attributes } from 'stores/PaginationTypes'; import { asMock } from 'helpers/mocking'; import useFilterValueSuggestions from 'components/common/EntityFilters/hooks/useFilterValueSuggestions'; import useFiltersWithTitle from 'components/common/EntityFilters/hooks/useFiltersWithTitle'; -import EntityFilters from './EntityFilters'; +import OriginalEntityFilters from './EntityFilters'; const mockedUnixTime = 1577836800000; // 2020-01-01 00:00:00.000 @@ -37,6 +38,7 @@ jest.mock('components/common/EntityFilters/hooks/useFiltersWithTitle'); describe('', () => { const onChangeFiltersWithTitle = jest.fn(); + const setUrlQueryFilters = jest.fn(); const attributes = [ { id: 'title', title: 'Title', sortable: true }, { id: 'description', title: 'Description', sortable: true }, @@ -80,8 +82,18 @@ describe('', () => { title: 'Created at', type: 'DATE', }, + { + id: 'generic', + filterable: true, + title: 'Generic Attribute', + type: 'STRING', + }, ] as Attributes; + const EntityFilters = (props: Optional, 'setUrlQueryFilters' | 'attributes'>) => ( + + ); + const dropdownIsHidden = (dropdownTitle: string) => expect(screen.queryByRole('heading', { name: new RegExp(dropdownTitle, 'i') })).not.toBeInTheDocument(); beforeEach(() => { @@ -96,12 +108,8 @@ describe('', () => { describe('boolean attribute', () => { it('should create filter', async () => { - const setUrlQueryFilters = jest.fn(); - render( - , + , ); userEvent.click(await screen.findByRole('button', { @@ -131,8 +139,6 @@ describe('', () => { }); it('should update active filter on click', async () => { - const setUrlQueryFilters = jest.fn(); - asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ disabled: [{ title: 'Running', value: 'false' }] }), onChange: onChangeFiltersWithTitle, @@ -140,9 +146,7 @@ describe('', () => { }); render( - , + , ); const activeFilter = await screen.findByTestId('disabled-filter-false'); @@ -169,9 +173,7 @@ describe('', () => { }); render( - {}} - urlQueryFilters={OrderedMap({ disabled: ['false'] })} />, + , ); await screen.findByTestId('disabled-filter-false'); @@ -203,10 +205,8 @@ describe('', () => { }); it('should create filter', async () => { - const setUrlQueryFilters = jest.fn(); - render( - , + , ); userEvent.click(await screen.findByRole('button', { @@ -231,8 +231,6 @@ describe('', () => { }); it('should update active filter', async () => { - const setUrlQueryFilters = jest.fn(); - asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ index_set_id: [ @@ -244,9 +242,7 @@ describe('', () => { }); render( - , + , ); const activeFilter = await screen.findByTestId('index_set_id-filter-index-set-1'); @@ -273,12 +269,8 @@ describe('', () => { describe('date attribute', () => { it('should create filter', async () => { - const setUrlQueryFilters = jest.fn(); - render( - , + , ); userEvent.click(await screen.findByRole('button', { @@ -315,8 +307,6 @@ describe('', () => { }); it('should update active filter', async () => { - const setUrlQueryFilters = jest.fn(); - asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ created_at: [{ @@ -329,9 +319,7 @@ describe('', () => { }); render( - <'] })} />, + <'] })} />, ); const activeFilter = await screen.findByTestId('created_at-filter-2019-12-31T23:55:00.001+00:00'); @@ -366,8 +354,6 @@ describe('', () => { describe('string attribute', () => { it('should prevent creating same filter multiple times', async () => { - const setUrlQueryFilters = jest.fn(); - asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ type: [{ title: 'String', value: 'string' }] }), onChange: onChangeFiltersWithTitle, @@ -375,9 +361,7 @@ describe('', () => { }); render( - , + , ); await screen.findByTestId('type-filter-string'); @@ -394,9 +378,53 @@ describe('', () => { }); }); - it('should display active filters', async () => { - const setUrlQueryFilters = jest.fn(); + describe('generic attribute', () => { + it('provides text input to create filter', async () => { + render( + , + ); + userEvent.click(await screen.findByRole('button', { name: /create filter/i })); + + userEvent.click(await screen.findByRole('menuitem', { name: /generic/i })); + + const filterInput = await screen.findByPlaceholderText('Enter value to filter for'); + userEvent.type(filterInput, 'foo'); + + const form = await screen.findByTestId('generic-filter-form'); + userEvent.click(await within(form).findByRole('button', { name: /create filter/i })); + + await waitFor(() => { + expect(setUrlQueryFilters).toHaveBeenCalledWith(OrderedMap({ generic: ['foo'] })); + }); + }); + + it('allows changing filter', async () => { + asMock(useFiltersWithTitle).mockReturnValue({ + data: OrderedMap({ generic: [{ title: 'foo', value: 'foo' }] }), + onChange: onChangeFiltersWithTitle, + isInitialLoading: false, + }); + + render( + , + ); + + userEvent.click(await screen.findByText('foo')); + + const filterInput = await screen.findByPlaceholderText('Enter value to filter for'); + userEvent.type(filterInput, '{selectall}bar'); + + const form = await screen.findByTestId('generic-filter-form'); + userEvent.click(await within(form).findByRole('button', { name: /update filter/i })); + + await waitFor(() => { + expect(setUrlQueryFilters).toHaveBeenCalledWith(OrderedMap({ generic: ['bar'] })); + }); + }); + }); + + it('should display active filters', async () => { asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ disabled: [{ title: 'Running', value: 'false' }], @@ -406,17 +434,13 @@ describe('', () => { }); render( - , + , ); await screen.findByTestId('disabled-filter-false'); }); it('should delete an active filter', async () => { - const setUrlQueryFilters = jest.fn(); - asMock(useFiltersWithTitle).mockReturnValue({ data: OrderedMap({ disabled: [{ title: 'Running', value: 'false' }], @@ -426,9 +450,7 @@ describe('', () => { }); render( - , + , ); const activeFilter = await screen.findByTestId('disabled-filter-false'); diff --git a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx index 6b46cdeb973c..7919cea70546 100644 --- a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx +++ b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/FilterConfiguration.tsx @@ -23,6 +23,7 @@ import { isAttributeWithFilterOptions, isAttributeWithRelatedCollection, isDateAttribute, } from 'components/common/EntityFilters/helpers/AttributeIdentification'; +import GenericFilterInput from 'components/common/EntityFilters/FilterConfiguration/GenericFilterInput'; import StaticOptionsList from './StaticOptionsList'; import SuggestionsList from './SuggestionsList'; @@ -36,32 +37,50 @@ type Props = { allActiveFilters: Filters | undefined, } -export const FilterConfiguration = ({ - allActiveFilters, - attribute, - filter, - filterValueRenderer, - onSubmit, -}: Props) => ( - <> - {filter ? 'Edit' : 'Create'} {attribute.title.toLowerCase()} filter - {isAttributeWithFilterOptions(attribute) && ( +const FilterComponent = ({ allActiveFilters, attribute, filter, filterValueRenderer, onSubmit }: Pick) => { + if (isAttributeWithFilterOptions(attribute)) { + return ( - )} - {isAttributeWithRelatedCollection(attribute) && ( + ); + } + + if (isAttributeWithRelatedCollection(attribute)) { + return ( - )} - {isDateAttribute(attribute) && ( + ); + } + + if (isDateAttribute(attribute)) { + return ( - )} + ); + } + + return ; +}; + +export const FilterConfiguration = ({ + allActiveFilters, + attribute, + filter = undefined, + filterValueRenderer, + onSubmit, +}: Props) => ( + <> + {filter ? 'Edit' : 'Create'} {attribute.title.toLowerCase()} filter + ); diff --git a/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/GenericFilterInput.tsx b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/GenericFilterInput.tsx new file mode 100644 index 000000000000..2c31ba996412 --- /dev/null +++ b/graylog2-web-interface/src/components/common/EntityFilters/FilterConfiguration/GenericFilterInput.tsx @@ -0,0 +1,77 @@ +/* + * 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 { useCallback } from 'react'; +import styled from 'styled-components'; +import trim from 'lodash/trim'; +import { Formik, Form } from 'formik'; + +import type { Filter } from 'components/common/EntityFilters/types'; +import { ModalSubmit, FormikInput } from 'components/common'; + +type Props = { + filter?: Filter, + onSubmit: (filter: { title: string, value: string }, closeDropdown?: boolean) => void, +} + +const FilterInput = styled(FormikInput)` + margin-bottom: 5px; +`; +const Container = styled.div` + padding: 3px 10px; + max-width: 250px; +`; + +type FormValues = { + value: string | undefined, +} + +const validate = ({ value }: FormValues) => { + if (!value || trim(value) === '') { + return { value: 'Must not be empty.' }; + } + + return {}; +}; + +const GenericFilterInput = ({ filter = undefined, onSubmit }: Props) => { + const initialValues = { value: filter?.value }; + const createFilter = useCallback(({ value }: FormValues) => onSubmit({ title: value, value }, true), [onSubmit]); + + return ( + + + {({ isValid }) => ( +
+ + + + )} +
+
+ ); +}; + +export default GenericFilterInput; diff --git a/graylog2-web-interface/src/components/events/Constants.ts b/graylog2-web-interface/src/components/events/Constants.ts index cb86cff1d72c..74fe0cfbe7bc 100644 --- a/graylog2-web-interface/src/components/events/Constants.ts +++ b/graylog2-web-interface/src/components/events/Constants.ts @@ -14,8 +14,8 @@ * along with this program. If not, see * . */ - import type { Sort, Attribute } from 'stores/PaginationTypes'; +import EventDefinitionPriorityEnum from 'logic/alerts/EventDefinitionPriorityEnum'; export const EVENTS_ENTITY_TABLE_ID = 'events'; @@ -32,6 +32,9 @@ export const detailsAttributes: Array = [ type: 'STRING', sortable: true, searchable: false, + filterable: true, + filter_options: Object.keys(EventDefinitionPriorityEnum.properties) + .map((num) => ({ value: num, title: num })), }, { id: 'timestamp', @@ -46,6 +49,8 @@ export const detailsAttributes: Array = [ type: 'STRING', sortable: false, searchable: false, + filterable: true, + related_collection: 'event_definitions', }, { id: 'event_definition_type', @@ -62,13 +67,16 @@ export const detailsAttributes: Array = [ id: 'timerange_start', title: 'Aggregation time range', sortable: true, + type: 'DATE', + filterable: true, }, { id: 'key', title: 'Key', type: 'STRING', sortable: true, - searchable: false, + searchable: true, + filterable: true, }, { id: 'fields', diff --git a/graylog2-web-interface/src/components/events/EventsEntityTable.tsx b/graylog2-web-interface/src/components/events/EventsEntityTable.tsx index 7e04dac30eee..04f45af4c770 100644 --- a/graylog2-web-interface/src/components/events/EventsEntityTable.tsx +++ b/graylog2-web-interface/src/components/events/EventsEntityTable.tsx @@ -28,6 +28,10 @@ import useColumnRenderers from 'components/events/events/ColumnRenderers'; import EventsRefreshControls from 'components/events/events/EventsRefreshControls'; import QueryHelper from 'components/common/QueryHelper'; +const additionalSearchFields = { + key: 'The key of the event', +}; + const EventsEntityTable = () => { const { stream_id: streamId } = useQuery(); @@ -38,7 +42,7 @@ const EventsEntityTable = () => { return ( humanName="events" columnsOrder={eventsTableElements.columnOrder} - queryHelpComponent={} + queryHelpComponent={} entityActions={entityActions} tableLayout={eventsTableElements.defaultLayout} fetchEntities={_fetchEvents} diff --git a/graylog2-web-interface/src/components/events/FilterValueRenderers.tsx b/graylog2-web-interface/src/components/events/FilterValueRenderers.tsx index c5d3f0b02c7e..f30b1cfc8276 100644 --- a/graylog2-web-interface/src/components/events/FilterValueRenderers.tsx +++ b/graylog2-web-interface/src/components/events/FilterValueRenderers.tsx @@ -17,11 +17,13 @@ import * as React from 'react'; import EventTypeLabel from 'components/events/events/EventTypeLabel'; +import PriorityName from 'components/events/events/PriorityName'; const FilterValueRenderers = { alert: (value: 'true' | 'false') => ( ), + priority: (value: string) => , }; export default FilterValueRenderers; diff --git a/graylog2-web-interface/src/components/events/events/PriorityName.tsx b/graylog2-web-interface/src/components/events/events/PriorityName.tsx index 827ba5992289..69ad71f9d112 100644 --- a/graylog2-web-interface/src/components/events/events/PriorityName.tsx +++ b/graylog2-web-interface/src/components/events/events/PriorityName.tsx @@ -23,7 +23,6 @@ type Props = { priority: number | string, } const PriorityName = ({ priority }: Props) => ( - <>{StringUtils.capitalizeFirstLetter(EventDefinitionPriorityEnum.properties[priority].name)} ); diff --git a/graylog2-web-interface/src/components/events/fetchEvents.ts b/graylog2-web-interface/src/components/events/fetchEvents.ts index 2e14c1059f8c..552e84ac02e0 100644 --- a/graylog2-web-interface/src/components/events/fetchEvents.ts +++ b/graylog2-web-interface/src/components/events/fetchEvents.ts @@ -16,6 +16,7 @@ */ import moment from 'moment'; +import trim from 'lodash/trim'; import * as URLUtils from 'util/URLUtils'; import { adjustFormat } from 'util/DateTime'; @@ -29,19 +30,72 @@ import type { UrlQueryFilters } from 'components/common/EntityFilters/types'; const url = URLUtils.qualifyUrl('/events/search'); -type FiltersResult = { filter: { alerts?: string }, timerange?: { from?: string, to?: string, type: string, range?: number}}; +type FiltersResult = { + filter: { + alerts?: string, + event_definitions?: Array, + priority?: Array, + aggregation_timerange?: { from?: string, to?: string, type: string, range?: number }, + key?: Array, + }, + timerange?: { from?: string, to?: string, type: string, range?: number }, +}; + +const allTimesRange = { type: 'relative', range: 0 }; + +const isNullOrBlank = (s: string | undefined) => { + if (!s) { + return true; + } + + if (trim(s) === '') { + return true; + } + + return false; +}; + +const parseTimestampFilter = (timestamp: string | undefined) => { + if (!timestamp) { + return allTimesRange; + } + + const [from, to] = extractRangeFromString(timestamp); + + if (!from && !to) { + return allTimesRange; + } + + return { + type: 'absolute', + from: isNullOrBlank(from) ? adjustFormat(moment(0).utc(), 'internal') : from, + to: isNullOrBlank(to) ? adjustFormat(moment().utc(), 'internal') : to, + }; +}; const parseFilters = (filters: UrlQueryFilters) => { const result: FiltersResult = { filter: {} }; - if (filters.get('timestamp')?.[0]) { - const [from, to] = extractRangeFromString(filters.get('timestamp')[0]); + result.timerange = parseTimestampFilter(filters.get('timestamp')?.[0]); + + if (filters.get('timerange_start')?.[0]) { + const [from, to] = extractRangeFromString(filters.get('timerange_start')[0]); - result.timerange = from + result.filter.aggregation_timerange = from ? { from, to: to || adjustFormat(moment().utc(), 'internal'), type: 'absolute' } : { type: 'relative', range: 0 }; - } else { - result.timerange = { type: 'relative', range: 0 }; + } + + if (filters.get('key')?.length > 0) { + result.filter.key = filters.get('key'); + } + + if (filters.get('event_definition_id')?.length > 0) { + result.filter.event_definitions = filters.get('event_definition_id'); + } + + if (filters.get('priority')?.length > 0) { + result.filter.priority = filters.get('priority'); } switch (filters?.get('alert')?.[0]) {