From 8ff9bedb87c62cddc457c8691ee530aa698f28bc Mon Sep 17 00:00:00 2001 From: Linus Pahl <46300478+linuspahl@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:37:35 +0100 Subject: [PATCH] Collection of improvements for slice by functionality in events table. (#25276) * Provide complete slice for slices render * Improve reusability of slizes query keys. * Refetch slices when auto refetching security events. * adding type to the slice class * DIsplay paginated list for slices overview. * Expand emtpy slics list when currently slice becomes empty. * Ensure that we always display the selected slice in the slices list. * Refetch slices list when selecting slice, to ensure slice count is correct and matches search results. * Fixing TS error * Only refetch slices on slice select when we actually fetch new events. --------- Co-authored-by: Jan Heise --- .../events/search/EventsSearchService.java | 33 ++-- .../rest/resources/entities/Slice.java | 3 +- .../slicing/SliceList.tsx | 2 +- .../slicing/SlicesOverview.tsx | 170 ++++++++++++++---- .../slicing/Slicing.test.tsx | 138 +++++++++++++- .../PaginatedEntityTable/slicing/Slicing.tsx | 4 +- .../PaginatedEntityTable/slicing/queryKeys.ts | 25 +++ .../slicing/useFetchSlices.ts | 9 +- .../PaginatedEntityTable/slicing/useSlices.ts | 26 ++- .../components/events/EventsEntityTable.tsx | 2 +- .../src/components/events/SliceRenderers.tsx | 4 +- 11 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/queryKeys.ts 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 8476bf4d24..91e442d7d7 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 @@ -49,6 +49,7 @@ import java.util.stream.Collectors; import static org.graylog.events.event.EventDto.FIELD_ALERT; import static org.graylog.events.event.EventDto.FIELD_PRIORITY; +import static org.graylog.events.event.EventDto.FIELD_SCORES; import static org.graylog2.plugin.streams.Stream.DEFAULT_EVENTS_STREAM_ID; public class EventsSearchService extends AbstractEventsSearchService { @@ -124,7 +125,7 @@ public class EventsSearchService extends AbstractEventsSearchService { /** * finding the overall count for one column for MongoDB based queries so we can calculate the "empty" case */ - public Optional count(String query, TimeRange timeRange, Subject subject, SearchUser searchUser, final String slicingColumn) { + public Optional count(String query, TimeRange timeRange, Subject subject, SearchUser searchUser, final String type) { try { return scriptingApiService.executeAggregation( new AggregationRequestSpec(query, allowedEventStreams(subject), Set.of(), timeRange, List.of(), List.of(new Metric("count", null))), @@ -132,29 +133,39 @@ public class EventsSearchService extends AbstractEventsSearchService { ) .datarows() .stream() - .map(r -> new Slice(r.getFirst().toString(), r.getFirst().toString(), Integer.valueOf(r.getLast().toString()))) + .map(r -> new Slice(r.getFirst().toString(), r.getFirst().toString(), type, Integer.valueOf(r.getLast().toString()))) .findFirst(); } catch (QueryFailedException e) { throw new RuntimeException(e); } } + public String getTypeBySliceColumn(final String column) { + return switch (column) { + case FIELD_PRIORITY -> FIELD_PRIORITY; + case FIELD_ALERT -> FIELD_ALERT; + default -> null; + }; + } + /** * In the Open Source part, we only map priority and type, both of which are augmented in the FE regarding the title. * So we only need a simple mapping function here. */ public Slice mapAggregationResultsToSlice(final String slicingColumn, final List result) { - return new Slice(result.getFirst().toString(), null, Integer.valueOf(result.getLast().toString())); + return new Slice(result.getFirst().toString(), null, getTypeBySliceColumn(slicingColumn), Integer.valueOf(result.getLast().toString())); } // the alert can either be true or false List handleAlertColumn(final List slices) { + final var type = getTypeBySliceColumn(FIELD_ALERT); + if (slices.size() == 2) { return slices; } - final var TRUE = new Slice("true", null, 0); - final var FALSE = new Slice("false", null, 0); + final var TRUE = new Slice("true", null, type, 0); + final var FALSE = new Slice("false", null, type, 0); if (slices.isEmpty()) { return List.of(TRUE, FALSE); @@ -169,15 +180,17 @@ public class EventsSearchService extends AbstractEventsSearchService { // priority can be 0 (info) to 4 (critical), see EventDefinitionPriorityEnum.ts List handlePriorityColumn(final List slices) { + final var type = getTypeBySliceColumn(FIELD_PRIORITY); + if (slices.size() == 5) { return slices; } - final var INFO = new Slice("0", null, 0); - final var LOW = new Slice("1", null, 0); - final var MEDIUM = new Slice("2", null, 0); - final var HIGH = new Slice("3", null, 0); - final var CRITICAL = new Slice("4", null, 0); + final var INFO = new Slice("0", null, type, 0); + final var LOW = new Slice("1", null, type, 0); + final var MEDIUM = new Slice("2", null, type, 0); + final var HIGH = new Slice("3", null, type, 0); + final var CRITICAL = new Slice("4", null, type, 0); if (slices.isEmpty()) { return List.of(INFO, LOW, MEDIUM, HIGH, CRITICAL); diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/entities/Slice.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/entities/Slice.java index e98aa40d06..baa02888b5 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/entities/Slice.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/entities/Slice.java @@ -18,8 +18,9 @@ package org.graylog2.rest.resources.entities; import com.fasterxml.jackson.annotation.JsonProperty; -public record Slice(@JsonProperty(FIELD_ID) String value, @JsonProperty(FIELD_TITLE) String title, @JsonProperty(FIELD_COUNT) Integer count) { +public record Slice(@JsonProperty(FIELD_ID) String value, @JsonProperty(FIELD_TITLE) String title, @JsonProperty(FIELD_TYPE) String type, @JsonProperty(FIELD_COUNT) Integer count) { private static final String FIELD_ID = "value"; private static final String FIELD_TITLE = "title"; + private static final String FIELD_TYPE = "type"; private static final String FIELD_COUNT = "count"; } diff --git a/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SliceList.tsx b/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SliceList.tsx index bb00bd5f8f..7fc0d93dd0 100644 --- a/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SliceList.tsx +++ b/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SliceList.tsx @@ -70,7 +70,7 @@ const SliceList = ({ onClick={() => onChangeSlicing(sliceCol, String(slice.value))} active={String(activeSlice) === String(slice.value)}> - {sliceRenderers?.[sliceCol]?.render?.(slice.value) ?? slice.title ?? String(slice.value)} + {sliceRenderers?.[sliceCol]?.render?.(slice) ?? slice.title ?? String(slice.value)} {formatReadableNumber(slice.count)} diff --git a/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SlicesOverview.tsx b/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SlicesOverview.tsx index 5b337f65d9..87216a81f7 100644 --- a/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SlicesOverview.tsx +++ b/graylog2-web-interface/src/components/common/PaginatedEntityTable/slicing/SlicesOverview.tsx @@ -16,18 +16,18 @@ */ import * as React from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import styled, { css } from 'styled-components'; import { Button } from 'components/bootstrap'; -import { Spinner } from 'components/common'; +import { PaginatedList, Spinner } from 'components/common'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; import SliceFilters, { type SortMode } from './SliceFilters'; import SliceList from './SliceList'; import useSlices from './useSlices'; -import type { SliceRenderers } from './Slicing'; +import type { SliceRenderers, Slices } from './Slicing'; import type { FetchSlices } from './useFetchSlices'; const EmptySlicesHeader = styled.div( @@ -48,11 +48,19 @@ const EmptySlicesLabel = styled.span` gap: 4px; `; -const Slices = styled.div` +const SlicesLists = styled.div` max-height: 700px; overflow: auto; `; +const SLICES_PAGE_SIZE = 10; + +const paginatedSlices = (slices: Slices, page: number, pageSize: number) => { + const from = (page - 1) * pageSize; + + return slices.slice(from, from + pageSize); +}; + type Props = { appSection: string; sliceCol: string | undefined; @@ -65,6 +73,47 @@ type Props = { onSortModeChange: (mode: SortMode) => void; }; +type UseAutoExpandEmptySlicesArgs = { + activeSlice: string | undefined; + showEmptySlices: boolean; + visibleEmptySlices: Slices; + setShowEmptySlices: React.Dispatch>; + setEmptyPage: React.Dispatch>; +}; + +const useAutoExpandEmptySlices = ({ + activeSlice, + showEmptySlices, + visibleEmptySlices, + setShowEmptySlices, + setEmptyPage, +}: UseAutoExpandEmptySlicesArgs) => { + const lastAutoExpandedSliceRef = useRef(undefined); + + useEffect(() => { + if (!activeSlice || showEmptySlices) { + return; + } + + const activeSliceValue = String(activeSlice); + const activeSliceMovedToEmpty = visibleEmptySlices.some((slice) => String(slice.value) === activeSliceValue); + + if (!activeSliceMovedToEmpty) { + lastAutoExpandedSliceRef.current = undefined; + + return; + } + + if (lastAutoExpandedSliceRef.current === activeSliceValue) { + return; + } + + lastAutoExpandedSliceRef.current = activeSliceValue; + setShowEmptySlices(true); + setEmptyPage(1); + }, [activeSlice, showEmptySlices, visibleEmptySlices, setShowEmptySlices, setEmptyPage]); +}; + const SlicesOverview = ({ appSection, sliceCol, @@ -78,13 +127,34 @@ const SlicesOverview = ({ }: Props) => { const [showEmptySlices, setShowEmptySlices] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [nonEmptyPage, setNonEmptyPage] = useState(1); + const [emptyPage, setEmptyPage] = useState(1); const sendTelemetry = useSendTelemetry(); - const { isLoading, hasEmptySlices, emptySliceCount, visibleNonEmptySlices, visibleEmptySlices } = useSlices({ - fetchSlices, - searchQuery, - sortMode, - sliceRenderers, - }); + const { isLoading, refetchSlices, hasEmptySlices, emptySliceCount, visibleNonEmptySlices, visibleEmptySlices } = + useSlices({ + fetchSlices, + activeSlice, + searchQuery, + sortMode, + sliceRenderers, + }); + + useAutoExpandEmptySlices({ activeSlice, showEmptySlices, visibleEmptySlices, setShowEmptySlices, setEmptyPage }); + + const currentNonEmptySlices = paginatedSlices(visibleNonEmptySlices, nonEmptyPage, SLICES_PAGE_SIZE); + const currentEmptySlices = paginatedSlices(visibleEmptySlices, emptyPage, SLICES_PAGE_SIZE); + + const onSearchQueryChange = (newQuery: string) => { + setSearchQuery(newQuery); + setNonEmptyPage(1); + setEmptyPage(1); + }; + const onSortModeUpdate = (mode: SortMode) => { + onSortModeChange(mode); + setNonEmptyPage(1); + setEmptyPage(1); + }; + const onToggleEmptySlices = () => { setShowEmptySlices((current) => { const next = !current; @@ -97,10 +167,22 @@ const SlicesOverview = ({ }, }); + if (next) { + setEmptyPage(1); + } + return next; }); }; + const onSliceSelection = (newSliceCol: string | undefined, newSlice?: string | undefined) => { + onChangeSlicing(newSliceCol, newSlice); + + if (newSlice !== undefined && newSlice !== activeSlice) { + void refetchSlices(); + } + }; + if (isLoading) { return ; } @@ -112,20 +194,32 @@ const SlicesOverview = ({ activeColumnTitle={activeColumnTitle} sliceCol={sliceCol} searchQuery={searchQuery} - onSearchQueryChange={setSearchQuery} - onSearchReset={() => setSearchQuery('')} + onSearchQueryChange={onSearchQueryChange} + onSearchReset={() => onSearchQueryChange('')} sortMode={sortMode} - onSortModeChange={onSortModeChange} + onSortModeChange={onSortModeUpdate} /> - - + + { + void pageSize; + setNonEmptyPage(newPage); + }}> + + {hasEmptySlices ? (