Skip to content

Commit

Permalink
Adding "Replay Search" bulk action for alerts/events. (#21262)
Browse files Browse the repository at this point in the history
* Initial setup of "Replay Search" bulk action, using state for event ids.

* Adding bulk endpoint for events.

* Adding reducer to handle state for selected events/status.

* Splitting up components.

* Styling improvements.

* Improving behavior on hover.

* Using type parameter to access state in `CreateProfile`.

* Show replayed search when event is selected.

* Adjusting type to include `message` field.

* Using common hooks instead of asking query client directly.

* Adding bulk actions & close buttons.

* Showing green checkmark when event is checked.

* Improving user messages.

* Disabling URL synchronization, fixing unnecessary history change.

* Adding changelog snippet.

* Adding license headers.

* Fixing up import.

* Adjusting test.

* Adding unique key, adding test case.

* Adding license headers.

* Change caption when event is marked.

* Improving button and headline styling.

* Redirecting back to original page when closing dialog.

* Fixing color of trashcan icon.
  • Loading branch information
dennisoelkers authored Jan 8, 2025
1 parent ce32b08 commit 534c3eb
Show file tree
Hide file tree
Showing 29 changed files with 953 additions and 49 deletions.
4 changes: 4 additions & 0 deletions changelog/unreleased/pr-21262.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "a"
message = "Adding 'Replay Search' Bulk action to alerts & events."

pulls = ["21262"]
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
import org.graylog2.plugin.rest.PluginRestResource;
import org.graylog2.shared.rest.resources.RestResource;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import static com.google.common.base.MoreObjects.firstNonNull;
import static org.graylog2.shared.rest.documentation.generator.Generator.CLOUD_VISIBLE;
Expand Down Expand Up @@ -69,20 +73,38 @@ public EventsSearchResult search(@ApiParam(name = "JSON body") final EventsSearc
@Path("{event_id}")
@ApiOperation("Get event by ID")
public Optional<EventsSearchResult.Event> getById(@ApiParam(name = "event_id") @PathParam("event_id") final String eventId) {
return searchByIds(List.of(eventId))
.stream()
.findFirst();
}

public record BulkEventsByIds(Collection<String> eventIds) {}

@POST
@Path("/byIds")
@ApiOperation("Get multiple events by IDs")
@NoAuditEvent("Does not change any data")
public Map<String, EventsSearchResult.Event> getByIds(@ApiParam(name = "body") BulkEventsByIds request) {
return searchByIds(request.eventIds())
.stream()
.collect(Collectors.toMap(event -> event.event().id(), event -> event));
}

private List<EventsSearchResult.Event> searchByIds(Collection<String> eventIds) {
final var query = eventIds.stream()
.map(eventId -> EventDto.FIELD_ID + ":" + eventId)
.collect(Collectors.joining(" OR "));
final EventsSearchParameters parameters = EventsSearchParameters.builder()
.page(1)
.perPage(1)
.perPage(eventIds.size())
.timerange(RelativeRange.allTime())
.query(EventDto.FIELD_ID + ":" + eventId)
.query(query)
.filter(EventsSearchFilter.empty())
.sortBy(Message.FIELD_TIMESTAMP)
.sortDirection(EventsSearchParameters.SortDirection.DESC)
.build();

final EventsSearchResult result = searchService.search(parameters, getSubject());
return result.events()
.stream()
.findFirst();
return result.events();
}
}
8 changes: 4 additions & 4 deletions graylog2-web-interface/src/components/common/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const Wrapper = styled.button<{ disabled: boolean }>(({ theme, disabled }) => cs
type Props = {
focusable?: boolean,
title: string,
onClick?: () => void,
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void,
className?: string,
name: IconName
iconType?: IconType,
Expand All @@ -54,9 +54,9 @@ type Props = {
size?: SizeProp,
};

const handleClick = (onClick: () => void | undefined) => {
const handleClick = (onClick: (e: React.MouseEvent<HTMLButtonElement>) => void | undefined, e: React.MouseEvent<HTMLButtonElement>) => {
if (typeof onClick === 'function') {
onClick();
onClick(e);
}
};

Expand All @@ -75,7 +75,7 @@ const IconButton = React.forwardRef<HTMLButtonElement, Props>(({
data-testid={dataTestId}
title={title}
aria-label={title}
onClick={() => handleClick(onClick)}
onClick={(e) => handleClick(onClick, e)}
className={className}
type="button"
disabled={disabled}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* 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 * as React from 'react';
import { useState } from 'react';
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 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';

const Container = styled.div`
display: flex;
height: 100%;
`;

const EventsListSidebar = styled.div(({ theme }) => css`
display: flex;
flex-direction: column;
flex-shrink: 0;
position: relative;
width: 20vw;
height: 100%;
top: 0;
left: 0;
overflow: auto;
padding: 5px 10px;
background: ${theme.colors.global.contentBackground};
border-right: none;
box-shadow: 3px 3px 3px ${theme.colors.global.navigationBoxShadow};
z-index: 1030;
`);

const ReplayedSearchContainer = styled.div`
width: 100%;
overflow: auto;
padding: 5px;
`;

const StyledList = styled.ul`
flex-grow: 1;
padding-inline-start: 0;
margin-top: 20px;
`;

const ActionsBar = styled(ButtonToolbar)`
align-self: flex-end;
display: flex;
justify-content: flex-end;
align-items: end;
gap: 0.25em;
`;

type Props = {
initialEventIds: Array<string>;
events: { [eventId: string]: { event: Event } };
onClose: () => void;
}

type RemainingBulkActionsProps = {
events: Event[];
completed: boolean;
}

const RemainingBulkActions = ({ completed, events }: RemainingBulkActionsProps) => {
const { actions, pluggableActionModals } = useEventBulkActions(events);

return (
<>
<DropdownButton title="Bulk actions"
bsStyle={completed ? 'success' : 'default'}
id="bulk-actions-dropdown"
disabled={!events?.length}>
{actions}
</DropdownButton>
{pluggableActionModals}
</>
);
};

const ReplayedSearch = ({ total, completed, selectedEvent }: React.PropsWithChildren<{
total: number;
completed: number;
selectedEvent: { event: Event } | undefined;
}>) => {
if (total === 0) {
return (
<Center>
You have removed all events from the list. You can now return back by clicking the &ldquo;Close&rdquo; button.
</Center>
);
}

if (!selectedEvent && total === completed) {
return (
<Center>
You are done investigating all events. You can now select a bulk action to apply to all remaining events, or close the page to return to the events list.
</Center>
);
}

if (!selectedEvent) {
return (
<Center>
You have no event selected. Please select an event from the list to replay its search.
</Center>
);
}

return <ReplaySearch key={`replaying-search-for-event-${selectedEvent.event.id}`} event={selectedEvent.event} />;
};

const Headline = styled.h2`
margin-bottom: 10px;
`;

const BulkEventReplay = ({ initialEventIds, events: _events, onClose }: Props) => {
const [events] = useState<Props['events']>(_events);
const { eventIds, selectedId, removeItem, selectItem, markItemAsDone } = useSelectedEvents(initialEventIds);
const selectedEvent = events?.[selectedId];
const total = eventIds.length;
const completed = eventIds.filter((event) => event.status === 'DONE').length;
const remainingEvents = eventIds.map((eventId) => events[eventId.id]?.event);

return (
<Container>
<EventsListSidebar>
<Headline>Replay Search</Headline>
<p>
The following list contains all of the events/alerts you selected in the previous step, allowing you to
investigate the replayed search for each of them.
</p>
<i>Investigation of {completed}/{total} events completed.</i>
<StyledList>
{eventIds.map(({ id: eventId, status }) => (
<EventListItem key={`bulk-replay-search-item-${eventId}`}
event={events?.[eventId]?.event}
selected={eventId === selectedId}
done={status === 'DONE'}
removeItem={removeItem}
onClick={() => selectItem(eventId)}
markItemAsDone={markItemAsDone} />
))}
</StyledList>
<ActionsBar>
<RemainingBulkActions events={remainingEvents} completed={total > 0 && total === completed} />
<Button onClick={onClose}>Close</Button>
</ActionsBar>
</EventsListSidebar>
<ReplayedSearchContainer>
<ReplayedSearch total={total} completed={completed} selectedEvent={selectedEvent} />
</ReplayedSearchContainer>
</Container>
);
};

export default BulkEventReplay;
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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 * as React from 'react';
import { useCallback } from 'react';
import styled, { css } from 'styled-components';

import IconButton from 'components/common/IconButton';
import ButtonGroup from 'components/bootstrap/ButtonGroup';
import type { Event } from 'components/events/events/types';

type EventListItemProps = {
event: Event,
done: boolean,
selected: boolean,
onClick: () => void,
removeItem: (id: string) => void,
markItemAsDone: (id: string) => void,
}

type StyledItemProps = {
$selected: boolean;
}
const StyledItem = styled.li<StyledItemProps>(({ theme, $selected }) => css`
display: flex;
align-items: center;
justify-content: space-between;
height: 30px;
background-color: ${$selected ? theme.colors.background.secondaryNav : 'transparent'};
cursor: pointer;
&:hover {
background-color: ${theme.colors.background.body};
}
`);

type SummaryProps = {
$done: boolean;
}

const Summary = styled.span<SummaryProps>(({ theme, $done }) => css`
margin: 10px;
color: ${$done ? theme.colors.global.textSecondary : theme.colors.global.textDefault};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
text-decoration: underline;
}
`);

const CompletedButton = styled(IconButton)<{ $done: boolean }>(({ theme, $done }) => css`
color: ${$done ? theme.colors.variant.success : theme.colors.gray[60]};
`);

const EventListItem = ({ done, event, onClick, selected, removeItem, markItemAsDone }: EventListItemProps) => {
const _removeItem = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

return removeItem(event.id);
}, [event?.id, removeItem]);
const _markItemAsDone = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

return markItemAsDone(event.id);
}, [event?.id, markItemAsDone]);

return (
<StyledItem key={`event-replay-list-${event?.id}`} $selected={selected} onClick={onClick}>
<Summary $done={done}>{event?.message ?? <i>Unknown</i>}</Summary>

<ButtonGroup>
<IconButton onClick={_removeItem} title={`Remove event "${event?.id}" from list`} name="delete" />
<CompletedButton onClick={_markItemAsDone}
title={`Mark event "${event?.id}" as ${done ? 'not' : ''} investigated`}
name="verified"
iconType={done ? 'solid' : 'regular'}
$done={done} />
</ButtonGroup>
</StyledItem>
);
};

export default EventListItem;
Loading

0 comments on commit 534c3eb

Please sign in to comment.