mirror of
https://github.com/Graylog2/graylog2-server.git
synced 2026-03-13 09:32:21 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'} />,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user