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 <jan.heise@graylog.com>
This commit is contained in:
Linus Pahl
2026-03-11 20:37:35 +01:00
committed by GitHub
parent 90a797f8ff
commit 8ff9bedb87
11 changed files with 357 additions and 59 deletions

View File

@@ -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<Slice> count(String query, TimeRange timeRange, Subject subject, SearchUser searchUser, final String slicingColumn) {
public Optional<Slice> 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<Object> 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<Slice> handleAlertColumn(final List<Slice> 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<Slice> handlePriorityColumn(final List<Slice> 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);

View File

@@ -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";
}

View File

@@ -70,7 +70,7 @@ const SliceList = ({
onClick={() => onChangeSlicing(sliceCol, String(slice.value))}
active={String(activeSlice) === String(slice.value)}>
<SliceInner>
<Title>{sliceRenderers?.[sliceCol]?.render?.(slice.value) ?? slice.title ?? String(slice.value)}</Title>
<Title>{sliceRenderers?.[sliceCol]?.render?.(slice) ?? slice.title ?? String(slice.value)}</Title>
<CountBadge title={String(slice.count)}>{formatReadableNumber(slice.count)}</CountBadge>
</SliceInner>

View File

@@ -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<React.SetStateAction<boolean>>;
setEmptyPage: React.Dispatch<React.SetStateAction<number>>;
};
const useAutoExpandEmptySlices = ({
activeSlice,
showEmptySlices,
visibleEmptySlices,
setShowEmptySlices,
setEmptyPage,
}: UseAutoExpandEmptySlicesArgs) => {
const lastAutoExpandedSliceRef = useRef<string | undefined>(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 <Spinner />;
}
@@ -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}
/>
<Slices>
<SliceList
slices={visibleNonEmptySlices}
activeSlice={activeSlice}
sliceCol={sliceCol}
onChangeSlicing={onChangeSlicing}
sliceRenderers={sliceRenderers}
listTestId="slices-list"
/>
<SlicesLists>
<PaginatedList
activePage={nonEmptyPage}
pageSize={SLICES_PAGE_SIZE}
totalItems={visibleNonEmptySlices.length}
showPageSizeSelect={false}
hideFirstAndLastPageLinks
useQueryParameter={false}
onChange={(newPage, pageSize) => {
void pageSize;
setNonEmptyPage(newPage);
}}>
<SliceList
slices={currentNonEmptySlices}
activeSlice={activeSlice}
sliceCol={sliceCol}
onChangeSlicing={onSliceSelection}
sliceRenderers={sliceRenderers}
listTestId="slices-list"
/>
</PaginatedList>
<EmptySlicesHeader>
{hasEmptySlices ? (
<Button
@@ -140,17 +234,29 @@ const SlicesOverview = ({
)}
</EmptySlicesHeader>
{showEmptySlices && visibleEmptySlices.length > 0 && (
<SliceList
slices={visibleEmptySlices}
activeSlice={activeSlice}
sliceCol={sliceCol}
onChangeSlicing={onChangeSlicing}
sliceRenderers={sliceRenderers}
keyPrefix="empty-"
listTestId="empty-slices-list"
/>
<PaginatedList
activePage={emptyPage}
hideFirstAndLastPageLinks
pageSize={SLICES_PAGE_SIZE}
totalItems={visibleEmptySlices.length}
showPageSizeSelect={false}
useQueryParameter={false}
onChange={(newPage, pageSize) => {
void pageSize;
setEmptyPage(newPage);
}}>
<SliceList
slices={currentEmptySlices}
activeSlice={activeSlice}
sliceCol={sliceCol}
onChangeSlicing={onSliceSelection}
sliceRenderers={sliceRenderers}
keyPrefix="empty-"
listTestId="empty-slices-list"
/>
</PaginatedList>
)}
</Slices>
</SlicesLists>
</>
);
};

View File

@@ -34,9 +34,9 @@ describe('Slicing', () => {
const renderSUT = (
props: Partial<React.ComponentProps<typeof Slicing>> = {},
contextOverrides: Partial<ContextValue> & { searchParams?: Partial<SearchParams> } = {},
contextOverrides: { searchParams?: Partial<SearchParams> } = {},
) => {
const searchParams = {
const searchParams: SearchParams = {
page: 1,
pageSize: 10,
query: '',
@@ -51,7 +51,6 @@ describe('Slicing', () => {
refetch: jest.fn(),
attributes: [],
entityTableId: 'test-entity-table',
...contextOverrides,
};
return render(
@@ -114,7 +113,7 @@ describe('Slicing', () => {
await screen.findByText('Alpha');
await userEvent.type(screen.getByPlaceholderText(/filter status/i), 'alp');
await userEvent.type(screen.getByPlaceholderText(/filter/i), 'alp');
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
@@ -147,6 +146,56 @@ describe('Slicing', () => {
});
});
it('paginates non-empty slices', async () => {
renderSUT({
fetchSlices: () =>
Promise.resolve({
slices: Array.from({ length: 11 }, (_, index) => ({
value: `Slice-${String(index + 1).padStart(2, '0')}`,
count: 1,
})),
}),
});
await screen.findByText('Slice-01');
expect(within(screen.getByTestId('slices-list')).getByText('Slice-01')).toBeInTheDocument();
expect(within(screen.getByTestId('slices-list')).queryByText('Slice-11')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /open page 2/i }));
await waitFor(() => expect(within(screen.getByTestId('slices-list')).getByText('Slice-11')).toBeInTheDocument());
expect(within(screen.getByTestId('slices-list')).queryByText('Slice-01')).not.toBeInTheDocument();
});
it('paginates empty slices', async () => {
renderSUT({
fetchSlices: () =>
Promise.resolve({
slices: [
{ value: 'Alpha', count: 1 },
...Array.from({ length: 11 }, (_, index) => ({
value: `Empty-${String(index + 1).padStart(2, '0')}`,
count: 0,
})),
],
}),
});
await screen.findByText('Alpha');
await userEvent.click(screen.getByRole('button', { name: /show empty slices/i }));
expect(within(screen.getByTestId('empty-slices-list')).getByText('Empty-01')).toBeInTheDocument();
expect(within(screen.getByTestId('empty-slices-list')).queryByText('Empty-11')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /open page 2/i }));
await waitFor(() =>
expect(within(screen.getByTestId('empty-slices-list')).getByText('Empty-11')).toBeInTheDocument(),
);
expect(within(screen.getByTestId('empty-slices-list')).queryByText('Empty-01')).not.toBeInTheDocument();
});
it('shows empty slices when toggled', async () => {
renderSUT({
fetchSlices: () =>
@@ -166,4 +215,85 @@ describe('Slicing', () => {
expect(await screen.findByText('Gamma')).toBeInTheDocument();
});
it('auto-expands empty slices when active slice becomes empty', async () => {
renderSUT(
{
fetchSlices: () =>
Promise.resolve({
slices: [
{ value: 'Alpha', count: 1 },
{ value: 'Gamma', count: 0 },
],
}),
},
{ searchParams: { slice: 'Gamma' } },
);
await screen.findByText('Alpha');
expect(screen.getByRole('button', { name: /hide empty slices/i })).toBeInTheDocument();
expect(within(screen.getByTestId('empty-slices-list')).getByText('Gamma')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /hide empty slices/i }));
expect(screen.getByRole('button', { name: /show empty slices/i })).toBeInTheDocument();
expect(screen.queryByTestId('empty-slices-list')).not.toBeInTheDocument();
});
it('shows selected slice in empty list when backend does not return it', async () => {
renderSUT(
{
fetchSlices: () =>
Promise.resolve({
slices: [{ value: 'Alpha', count: 1 }],
}),
},
{ searchParams: { slice: 'Missing-Slice' } },
);
await screen.findByText('Alpha');
expect(screen.getByRole('button', { name: /hide empty slices/i })).toBeInTheDocument();
expect(within(screen.getByTestId('empty-slices-list')).getByText('Missing-Slice')).toBeInTheDocument();
});
it('does not show selected slice when it does not match active filter', async () => {
renderSUT(
{
fetchSlices: () =>
Promise.resolve({
slices: [{ value: 'Alpha', count: 1 }],
}),
},
{ searchParams: { slice: 'Missing-Slice' } },
);
await screen.findByText('Alpha');
expect(within(screen.getByTestId('empty-slices-list')).getByText('Missing-Slice')).toBeInTheDocument();
await userEvent.type(screen.getByPlaceholderText(/filter/i), 'alp');
await waitFor(() => expect(screen.queryByText('Missing-Slice')).not.toBeInTheDocument());
expect(screen.getByText('Empty slices (0)')).toBeInTheDocument();
});
it('refetches slices when selecting a slice', async () => {
const fetchSlices = jest.fn(() =>
Promise.resolve({
slices: [
{ value: 'Alpha', count: 2 },
{ value: 'Beta', count: 1 },
],
}),
);
renderSUT({ fetchSlices });
await screen.findByText('Alpha');
expect(fetchSlices).toHaveBeenCalledTimes(1);
await userEvent.click(within(screen.getByTestId('slices-list')).getByRole('button', { name: /beta/i }));
await waitFor(() => expect(fetchSlices.mock.calls.length).toBeGreaterThanOrEqual(2));
});
});

View File

@@ -27,10 +27,10 @@ import { type SortMode } from './SliceFilters';
import SlicesOverview from './SlicesOverview';
import type { FetchSlices } from './useFetchSlices';
export type Slice = { value: string | number; count: number; title?: string };
export type Slice = { value: string | number; count: number; title?: string; type?: unknown };
export type SliceRenderer = {
extendSlices?: (slices: Array<Slice>) => Array<Slice>;
render?: (value: string | number) => React.ReactNode;
render?: (slice: Slice) => React.ReactNode;
};
export type SliceRenderers = { [col: string]: SliceRenderer };
export type Slices = Array<Slice>;

View File

@@ -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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import type { UrlQueryFilters } from 'components/common/EntityFilters/types';
export const SLICING_QUERY_KEY = 'slicing';
export const slicesQueryKeyForColumn = (sliceCol: string | undefined) => [SLICING_QUERY_KEY, sliceCol] as const;
export const slicesQueryKey = (sliceCol: string | undefined, query: string | undefined, filters: UrlQueryFilters) =>
[...slicesQueryKeyForColumn(sliceCol), query, filters] as const;

View File

@@ -22,6 +22,8 @@ import { defaultOnError } from 'util/conditional/onError';
import TableFetchContext from 'components/common/PaginatedEntityTable/TableFetchContext';
import type { Slice, SliceRenderers } from 'components/common/PaginatedEntityTable/slicing/Slicing';
import type { UrlQueryFilters } from 'components/common/EntityFilters/types';
import { slicesQueryKey } from 'components/common/PaginatedEntityTable/slicing/queryKeys';
import useOnRefresh from 'components/common/PaginatedEntityTable/useOnRefresh';
export type FetchSlices = (
column: string,
@@ -34,8 +36,8 @@ const useFetchSlices = (fetchSlices: FetchSlices, sliceRenderers?: SliceRenderer
searchParams: { sliceCol, query, filters },
} = useContext(TableFetchContext);
const { data, isLoading } = useQuery({
queryKey: ['slicing', sliceCol, query, filters],
const { data, isLoading, refetch } = useQuery({
queryKey: slicesQueryKey(sliceCol, query, filters),
queryFn: () =>
defaultOnError(
fetchSlices(sliceCol, query, filters).then(
@@ -44,8 +46,9 @@ const useFetchSlices = (fetchSlices: FetchSlices, sliceRenderers?: SliceRenderer
'Error fetching table slices',
),
});
useOnRefresh(refetch);
return { slices: data ?? [], isLoading };
return { slices: data ?? [], isLoading, refetchSlices: refetch };
};
export default useFetchSlices;

View File

@@ -50,19 +50,39 @@ const matchesQuery = (slice: Slice, query: string) => {
type Props = {
fetchSlices: FetchSlices;
activeSlice: string | undefined;
searchQuery: string;
sortMode: SortMode;
sliceRenderers?: SliceRenderers;
};
const useSlices = ({ fetchSlices, searchQuery, sortMode, sliceRenderers = undefined }: Props) => {
const { slices, isLoading } = useFetchSlices(fetchSlices, sliceRenderers);
const filteredSlices = slices.filter((slice) => matchesQuery(slice, searchQuery));
const isSelectedSlice = (slice: Slice, activeSlice: string | undefined) =>
activeSlice !== undefined && String(slice.value) === String(activeSlice);
const addMissingSelectedSlice = (slices: Slices, activeSlice: string | undefined): Slices => {
if (activeSlice === undefined) {
return slices;
}
const selectedSliceExists = slices.some((slice) => isSelectedSlice(slice, activeSlice));
if (selectedSliceExists) {
return slices;
}
return [...slices, { value: activeSlice, count: 0 }];
};
const useSlices = ({ fetchSlices, activeSlice, searchQuery, sortMode, sliceRenderers = undefined }: Props) => {
const { slices, isLoading, refetchSlices } = useFetchSlices(fetchSlices, sliceRenderers);
const slicesWithSelected = addMissingSelectedSlice(slices, activeSlice);
const filteredSlices = slicesWithSelected.filter((slice) => matchesQuery(slice, searchQuery));
const nonEmptySlices = filteredSlices.filter((slice) => slice.count > 0);
const emptySlices = filteredSlices.filter((slice) => slice.count === 0);
return {
isLoading,
refetchSlices,
hasEmptySlices: emptySlices.length > 0,
emptySliceCount: emptySlices.length,
visibleNonEmptySlices: sortSlices(nonEmptySlices, sortMode, getSliceLabel),

View File

@@ -53,7 +53,7 @@ const EventsEntityTable = () => {
const { filter, timerange } = parseFilters(filters);
return Events.slices({
include_all: false,
include_all: true,
slice_column: column,
query: getConcatenatedQuery(query, streamId as string),
filter,

View File

@@ -50,11 +50,11 @@ const extendTypeSlices = (slices: Array<Slice>) => {
const sliceRenderers: SliceRenderers = {
priority: {
extendSlices: extendPrioritySlices,
render: (value) => <PriorityName priority={value} />,
render: ({ value }) => <PriorityName priority={value} />,
},
alert: {
extendSlices: extendTypeSlices,
render: (value) => <EventTypeLabel isAlert={String(value) === 'true'} />,
render: ({ value }) => <EventTypeLabel isAlert={String(value) === 'true'} />,
},
};