mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Session view alerts loading improvements, and other polish / bug fixes. (#131773)
* sort and cursor plumbing for alertsclient * process events route will now grab alerts for the page of events being requested. range / cursor support added to alerts client. * handling of missing event.action in some edge case process events * fixed to fake session leader overwriting original event it was based on * deduping added for children, alerts. fix to alerts route * fake process creation cleaned up, will now try and create fake parents from widened event context. this mitigates the number of potentially orphaned processes in the tree * fixed infinite loop regression :) * tests fixed * tweaks to inline alert details and test fixes * type fix * added test for new "sort" property in AlertsClient.find * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * pagination added to alerts tab * test fixes * addressed awp team comments * || -> ?? * e2e tests added for sort and search_after options * fixed test / type check * fixed import issue * restored whitespace Co-authored-by: mitodrummer <karlgodard@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
6edf3dc80b
commit
dc9f0f9388
34 changed files with 1002 additions and 557 deletions
|
@ -97,6 +97,7 @@ interface SingleSearchAfterAndAudit {
|
|||
track_total_hits?: boolean | undefined;
|
||||
size?: number | undefined;
|
||||
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
|
||||
sort?: estypes.SortOptions[] | undefined;
|
||||
lastSortIds?: Array<string | number> | undefined;
|
||||
}
|
||||
|
||||
|
@ -224,6 +225,7 @@ export class AlertsClient {
|
|||
size,
|
||||
index,
|
||||
operation,
|
||||
sort,
|
||||
lastSortIds = [],
|
||||
}: SingleSearchAfterAndAudit) {
|
||||
try {
|
||||
|
@ -243,7 +245,7 @@ export class AlertsClient {
|
|||
_source,
|
||||
track_total_hits: trackTotalHits,
|
||||
size,
|
||||
sort: [
|
||||
sort: sort || [
|
||||
{
|
||||
'@timestamp': {
|
||||
order: 'asc',
|
||||
|
@ -605,6 +607,8 @@ export class AlertsClient {
|
|||
track_total_hits: trackTotalHits,
|
||||
size,
|
||||
index,
|
||||
sort,
|
||||
search_after: searchAfter,
|
||||
}: {
|
||||
query?: object | undefined;
|
||||
aggs?: object | undefined;
|
||||
|
@ -612,6 +616,8 @@ export class AlertsClient {
|
|||
track_total_hits?: boolean | undefined;
|
||||
_source?: string[] | undefined;
|
||||
size?: number | undefined;
|
||||
sort?: estypes.SortOptions[] | undefined;
|
||||
search_after?: Array<string | number> | undefined;
|
||||
}) {
|
||||
try {
|
||||
// first search for the alert by id, then use the alert info to check if user has access to it
|
||||
|
@ -623,6 +629,8 @@ export class AlertsClient {
|
|||
size,
|
||||
index,
|
||||
operation: ReadOperations.Find,
|
||||
sort,
|
||||
lastSortIds: searchAfter,
|
||||
});
|
||||
|
||||
if (alertsSearchResponse == null) {
|
||||
|
|
|
@ -198,6 +198,136 @@ describe('find()', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
test('allows custom sort', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
esClientMock.search.mockResponseOnce({
|
||||
took: 5,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
hits: {
|
||||
total: 1,
|
||||
max_score: 999,
|
||||
hits: [
|
||||
{
|
||||
// @ts-expect-error incorrect fields
|
||||
found: true,
|
||||
_type: 'alert',
|
||||
_index: '.alerts-observability.apm.alerts',
|
||||
_id: 'NoxgpHkBqbdrfX07MqXV',
|
||||
_version: 1,
|
||||
_seq_no: 362,
|
||||
_primary_term: 2,
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
message: 'hello world 1',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_WORKFLOW_STATUS]: 'open',
|
||||
[SPACE_IDS]: ['test_default_space_id'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await alertsClient.find({
|
||||
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
|
||||
index: '.alerts-observability.apm.alerts',
|
||||
sort: [
|
||||
{
|
||||
'@timestamp': 'desc',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 1,
|
||||
"total": 1,
|
||||
},
|
||||
"hits": Object {
|
||||
"hits": Array [
|
||||
Object {
|
||||
"_id": "NoxgpHkBqbdrfX07MqXV",
|
||||
"_index": ".alerts-observability.apm.alerts",
|
||||
"_primary_term": 2,
|
||||
"_seq_no": 362,
|
||||
"_source": Object {
|
||||
"kibana.alert.rule.consumer": "apm",
|
||||
"kibana.alert.rule.rule_type_id": "apm.error_rate",
|
||||
"kibana.alert.workflow_status": "open",
|
||||
"kibana.space_ids": Array [
|
||||
"test_default_space_id",
|
||||
],
|
||||
"message": "hello world 1",
|
||||
},
|
||||
"_type": "alert",
|
||||
"_version": 1,
|
||||
"found": true,
|
||||
},
|
||||
],
|
||||
"max_score": 999,
|
||||
"total": 1,
|
||||
},
|
||||
"timed_out": false,
|
||||
"took": 5,
|
||||
}
|
||||
`);
|
||||
expect(esClientMock.search).toHaveBeenCalledTimes(1);
|
||||
expect(esClientMock.search.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"body": Object {
|
||||
"_source": undefined,
|
||||
"aggs": undefined,
|
||||
"fields": Array [
|
||||
"kibana.alert.rule.rule_type_id",
|
||||
"kibana.alert.rule.consumer",
|
||||
"kibana.alert.workflow_status",
|
||||
"kibana.space_ids",
|
||||
],
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {},
|
||||
Object {
|
||||
"term": Object {
|
||||
"kibana.space_ids": "test_default_space_id",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [
|
||||
Object {
|
||||
"match": Object {
|
||||
"kibana.alert.workflow_status": "open",
|
||||
},
|
||||
},
|
||||
],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
},
|
||||
"size": undefined,
|
||||
"sort": Array [
|
||||
Object {
|
||||
"@timestamp": "desc",
|
||||
},
|
||||
],
|
||||
"track_total_hits": undefined,
|
||||
},
|
||||
"ignore_unavailable": true,
|
||||
"index": ".alerts-observability.apm.alerts",
|
||||
"seq_no_primary_term": true,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('logs successful event in audit logger', async () => {
|
||||
const alertsClient = new AlertsClient(alertsClientParams);
|
||||
esClientMock.search.mockResponseOnce({
|
||||
|
|
|
@ -9,6 +9,7 @@ import { IRouter } from '@kbn/core/server';
|
|||
import * as t from 'io-ts';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import { PositiveInteger } from '@kbn/securitysolution-io-ts-types';
|
||||
import { SortOptions } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { RacRequestHandlerContext } from '../types';
|
||||
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
|
||||
|
@ -30,6 +31,8 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
|
|||
t.record(t.string, metricsAggsSchemas),
|
||||
t.undefined,
|
||||
]),
|
||||
sort: t.union([t.array(t.object), t.undefined]),
|
||||
search_after: t.union([t.array(t.number), t.array(t.string), t.undefined]),
|
||||
size: t.union([PositiveInteger, t.undefined]),
|
||||
track_total_hits: t.union([t.boolean, t.undefined]),
|
||||
_source: t.union([t.array(t.string), t.undefined]),
|
||||
|
@ -44,11 +47,11 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
|
|||
async (context, request, response) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { query, aggs, _source, track_total_hits, size, index } = request.body;
|
||||
const { query, aggs, _source, track_total_hits, size, index, sort, search_after } =
|
||||
request.body;
|
||||
|
||||
const racContext = await context.rac;
|
||||
const alertsClient = await racContext.getAlertsClient();
|
||||
|
||||
const alerts = await alertsClient.find({
|
||||
query,
|
||||
aggs,
|
||||
|
@ -56,6 +59,8 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
|
|||
track_total_hits,
|
||||
size,
|
||||
index,
|
||||
sort: sort as SortOptions[],
|
||||
search_after,
|
||||
});
|
||||
if (alerts == null) {
|
||||
return response.notFound({
|
||||
|
|
|
@ -14,34 +14,16 @@ export const PREVIEW_ALERTS_INDEX = '.preview.alerts-security.alerts-default';
|
|||
export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id';
|
||||
export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid';
|
||||
export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ HH:mm:ss.SSS';
|
||||
export const ALERT_ORIGINAL_TIME_PROPERTY = 'kibana.alert.original_time';
|
||||
export const ALERT_STATUS = {
|
||||
OPEN: 'open',
|
||||
ACKNOWLEDGED: 'acknowledged',
|
||||
CLOSED: 'closed',
|
||||
};
|
||||
|
||||
// We fetch a large number of events per page to mitigate a few design caveats in session viewer
|
||||
// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there
|
||||
// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page
|
||||
// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing
|
||||
// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite
|
||||
// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used
|
||||
// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user.
|
||||
// We may need to include this trick as part of this implementation as well.
|
||||
// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser.
|
||||
// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands
|
||||
// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the
|
||||
// search functionality will instead use a separate ES backend search to avoid this.
|
||||
// 3. Fewer round trips to the backend!
|
||||
export const PROCESS_EVENTS_PER_PAGE = 1000;
|
||||
|
||||
// As an initial approach, we won't be implementing pagination for alerts.
|
||||
// Instead we will load this fixed amount of alerts as a maximum for a session.
|
||||
// This could cause an edge case, where a noisy rule that alerts on every process event
|
||||
// causes a session to only list and highlight up to 1000 alerts, even though there could
|
||||
// be far greater than this amount. UX should be added to let the end user know this is
|
||||
// happening and to revise their rule to be more specific.
|
||||
export const ALERTS_PER_PAGE = 501;
|
||||
export const PROCESS_EVENTS_PER_PAGE = 200;
|
||||
export const ALERTS_PER_PROCESS_EVENTS_PAGE = 600;
|
||||
export const ALERTS_PER_PAGE = 100;
|
||||
|
||||
// when showing the count of alerts in details panel tab, if the number
|
||||
// exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 500+
|
||||
|
|
|
@ -1263,6 +1263,7 @@ export const childProcessMock: Process = {
|
|||
orphans: [],
|
||||
addEvent: (_) => undefined,
|
||||
addAlert: (_) => undefined,
|
||||
addChild: (_) => undefined,
|
||||
clearSearch: () => undefined,
|
||||
getChildren: () => [],
|
||||
hasOutput: () => false,
|
||||
|
@ -1348,6 +1349,7 @@ export const processMock: Process = {
|
|||
orphans: [],
|
||||
addEvent: (_) => undefined,
|
||||
addAlert: (_) => undefined,
|
||||
addChild: (_) => undefined,
|
||||
clearSearch: () => undefined,
|
||||
getChildren: () => [],
|
||||
hasOutput: () => false,
|
||||
|
@ -1553,6 +1555,7 @@ export const mockProcessMap = mockEvents.reduce(
|
|||
orphans: [],
|
||||
addEvent: (_) => undefined,
|
||||
addAlert: (_) => undefined,
|
||||
addChild: (_) => undefined,
|
||||
clearSearch: () => undefined,
|
||||
getChildren: () => [],
|
||||
hasOutput: () => false,
|
||||
|
|
|
@ -170,6 +170,7 @@ export interface Process {
|
|||
searchMatched: string | null; // either false, or set to searchQuery
|
||||
addEvent(event: ProcessEvent): void;
|
||||
addAlert(alert: ProcessEvent): void;
|
||||
addChild(child: Process): void;
|
||||
clearSearch(): void;
|
||||
hasOutput(): boolean;
|
||||
hasAlerts(): boolean;
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui';
|
||||
import { ProcessEvent } from '../../../common/types/process_tree';
|
||||
import { ALERT_COUNT_THRESHOLD } from '../../../common/constants';
|
||||
import { dataOrDash } from '../../utils/data_or_dash';
|
||||
import { useStyles } from '../detail_panel_alert_list_item/styles';
|
||||
import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item';
|
||||
|
@ -31,10 +30,7 @@ export const DetailPanelAlertGroupItem = ({
|
|||
onShowAlertDetails,
|
||||
}: DetailPanelAlertsGroupItemDeps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const alertsCount = useMemo(() => {
|
||||
return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length;
|
||||
}, [alerts]);
|
||||
const alertsCount = alerts.length.toLocaleString();
|
||||
|
||||
if (!alerts[0].kibana) {
|
||||
return null;
|
||||
|
|
|
@ -31,24 +31,26 @@ describe('DetailPanelAlertTab component', () => {
|
|||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
let mockedContext: AppContextTestRender;
|
||||
let mockOnJumpToEvent = jest.fn((process) => process);
|
||||
let mockShowAlertDetails = jest.fn((alertId) => alertId);
|
||||
|
||||
const props = {
|
||||
alerts: mockAlerts,
|
||||
onJumpToEvent: jest.fn((process) => process),
|
||||
onShowAlertDetails: jest.fn((alertId) => alertId),
|
||||
isFetchingAlerts: false,
|
||||
hasNextPageAlerts: false,
|
||||
fetchNextPageAlerts: jest.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
mockOnJumpToEvent = jest.fn((process) => process);
|
||||
mockShowAlertDetails = jest.fn((alertId) => alertId);
|
||||
props.onJumpToEvent.mockReset();
|
||||
props.onShowAlertDetails.mockReset();
|
||||
props.fetchNextPageAlerts.mockReset();
|
||||
});
|
||||
|
||||
describe('When DetailPanelAlertTab is mounted', () => {
|
||||
it('renders a list of alerts for the session (defaulting to list view mode)', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length);
|
||||
expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy();
|
||||
|
@ -61,13 +63,7 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('renders a list of alerts grouped by rule when group-view clicked', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
|
||||
|
||||
|
@ -82,13 +78,10 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('renders a sticky investigated alert (outside of main list) if one is set', async () => {
|
||||
const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid;
|
||||
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
investigatedAlertId={mockAlerts[0].kibana?.alert?.uuid}
|
||||
/>
|
||||
<DetailPanelAlertTab {...props} investigatedAlertId={investigatedAlertId} />
|
||||
);
|
||||
|
||||
expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy();
|
||||
|
@ -99,13 +92,10 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('investigated alert should be collapsible', async () => {
|
||||
const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid;
|
||||
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
investigatedAlertId={mockAlerts[0].kibana?.alert?.uuid}
|
||||
/>
|
||||
<DetailPanelAlertTab {...props} investigatedAlertId={investigatedAlertId} />
|
||||
);
|
||||
|
||||
expect(
|
||||
|
@ -132,13 +122,7 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('non investigated alert should NOT be collapsible', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
expect(
|
||||
renderResult
|
||||
|
@ -164,13 +148,7 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
|
||||
|
||||
|
@ -198,13 +176,7 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('each alert list item should show a timestamp and process arguments', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent(
|
||||
mockAlerts[0]['@timestamp']!
|
||||
|
@ -216,13 +188,7 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('each alert group should show a rule title and alert count', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={mockAlerts}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
|
||||
|
||||
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
|
||||
|
||||
|
@ -233,15 +199,18 @@ describe('DetailPanelAlertTab component', () => {
|
|||
});
|
||||
|
||||
it('renders an empty state when there are no alerts', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab
|
||||
alerts={[]}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} alerts={[]} />);
|
||||
|
||||
expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a load more button when there are more pages of alerts', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<DetailPanelAlertTab {...props} hasNextPageAlerts={true} />
|
||||
);
|
||||
expect(renderResult.queryByTestId('alerts-details-load-more')).toBeTruthy();
|
||||
renderResult.queryByTestId('alerts-details-load-more')?.click();
|
||||
expect(props.fetchNextPageAlerts.mock.calls.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui';
|
||||
import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule, EuiButton } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { groupBy } from 'lodash';
|
||||
|
@ -20,6 +20,9 @@ export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode';
|
|||
|
||||
interface DetailPanelAlertTabDeps {
|
||||
alerts: ProcessEvent[];
|
||||
isFetchingAlerts: boolean;
|
||||
hasNextPageAlerts?: boolean;
|
||||
fetchNextPageAlerts: () => void;
|
||||
onJumpToEvent: (event: ProcessEvent) => void;
|
||||
onShowAlertDetails: (alertId: string) => void;
|
||||
investigatedAlertId?: string;
|
||||
|
@ -33,6 +36,9 @@ const VIEW_MODE_GROUP = 'groupView';
|
|||
*/
|
||||
export const DetailPanelAlertTab = ({
|
||||
alerts,
|
||||
isFetchingAlerts,
|
||||
hasNextPageAlerts,
|
||||
fetchNextPageAlerts,
|
||||
onJumpToEvent,
|
||||
onShowAlertDetails,
|
||||
investigatedAlertId,
|
||||
|
@ -147,6 +153,21 @@ export const DetailPanelAlertTab = ({
|
|||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{hasNextPageAlerts && (
|
||||
<EuiButton
|
||||
color="primary"
|
||||
isLoading={isFetchingAlerts}
|
||||
onClick={fetchNextPageAlerts}
|
||||
css={styles.loadMoreBtn}
|
||||
data-test-subj="alerts-details-load-more"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.sessionView.alertsLoadMoreButton"
|
||||
defaultMessage="Load more alerts"
|
||||
/>
|
||||
</EuiButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -30,10 +30,16 @@ export const useStyles = () => {
|
|||
margin: size.base,
|
||||
};
|
||||
|
||||
const loadMoreBtn: CSSObject = {
|
||||
margin: size.m,
|
||||
width: `calc(100% - ${size.m} * 2)`,
|
||||
};
|
||||
|
||||
return {
|
||||
container,
|
||||
stickyItem,
|
||||
viewMode,
|
||||
loadMoreBtn,
|
||||
};
|
||||
}, [euiTheme]);
|
||||
|
||||
|
|
|
@ -141,7 +141,14 @@ export const getDetailPanelProcess = (process: Process | null): DetailPanelProce
|
|||
}
|
||||
});
|
||||
if (!processData.executable.length) {
|
||||
processData.executable = DEFAULT_PROCESS_DATA.executable;
|
||||
// if there were no forks, execs (due to bad data), check if we at least have an executable for some event
|
||||
const executable = process.getDetails().process?.executable;
|
||||
|
||||
if (executable) {
|
||||
processData.executable.push([executable]);
|
||||
} else {
|
||||
processData.executable = DEFAULT_PROCESS_DATA.executable;
|
||||
}
|
||||
}
|
||||
|
||||
processData.entryLeader = getDetailPanelProcessLeader(details?.process?.entry_leader);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import uuid from 'uuid';
|
||||
import { sortProcesses } from '../../../common/utils/sort_processes';
|
||||
import {
|
||||
AlertStatusEventEntityIdMap,
|
||||
|
@ -11,9 +12,43 @@ import {
|
|||
Process,
|
||||
ProcessEvent,
|
||||
ProcessMap,
|
||||
ProcessFields,
|
||||
} from '../../../common/types/process_tree';
|
||||
import { ProcessImpl } from './hooks';
|
||||
|
||||
// Creates an instance of Process, from a nested leader process fieldset
|
||||
// This is used to ensure we always have a record for a session leader, as well as
|
||||
// a parent record for potentially orphaned processes
|
||||
export function inferProcessFromLeaderInfo(sourceEvent?: ProcessEvent, leader?: ProcessFields) {
|
||||
const entityId = leader?.entity_id || uuid.v4();
|
||||
const process = new ProcessImpl(entityId);
|
||||
|
||||
if (sourceEvent && leader) {
|
||||
const event = {
|
||||
...sourceEvent,
|
||||
process: {
|
||||
...sourceEvent.process,
|
||||
...leader,
|
||||
},
|
||||
user: leader.user,
|
||||
group: leader.group,
|
||||
event: {
|
||||
...sourceEvent.event,
|
||||
id: `fake-${entityId}`,
|
||||
},
|
||||
};
|
||||
|
||||
// won't be accurate, so removing
|
||||
if (sourceEvent.process?.parent === leader) {
|
||||
delete event.process?.parent;
|
||||
}
|
||||
|
||||
process.addEvent(event);
|
||||
}
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
// if given event is an alert, and it exist in updatedAlertsStatus, update the alert's status
|
||||
// with the updated status value in updatedAlertsStatus Map
|
||||
export const updateAlertEventStatus = (
|
||||
|
@ -87,28 +122,35 @@ export const buildProcessTree = (
|
|||
events.forEach((event) => {
|
||||
const { entity_id: id, parent } = event.process ?? {};
|
||||
const process = processMap[id ?? ''];
|
||||
const parentProcess = processMap[parent?.entity_id ?? ''];
|
||||
let parentProcess = processMap[parent?.entity_id ?? ''];
|
||||
|
||||
// if either entity_id or parent does not exist, return
|
||||
// if session leader, or process already has a parent, return
|
||||
if (!id || !parent || process.id === sessionEntityId || process.parent) {
|
||||
// if process already has a parent, return
|
||||
if (!id || !parent || process.parent || id === sessionEntityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentProcess) {
|
||||
// infer a fake process for the parent, incase we don't end up loading any parent events (due to filtering or jumpToCursor pagination)
|
||||
const parentFields = event?.process?.parent;
|
||||
|
||||
if (parentFields?.entity_id && !processMap[parentFields.entity_id]) {
|
||||
parentProcess = inferProcessFromLeaderInfo(event, parentFields);
|
||||
processMap[parentProcess.id] = parentProcess;
|
||||
|
||||
if (!orphans.includes(parentProcess)) {
|
||||
orphans.push(parentProcess);
|
||||
}
|
||||
} else {
|
||||
if (!orphans.includes(process)) {
|
||||
orphans.push(process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parentProcess) {
|
||||
process.parent = parentProcess; // handy for recursive operations (like auto expand)
|
||||
|
||||
if (backwardDirection) {
|
||||
parentProcess.children.unshift(process);
|
||||
} else {
|
||||
parentProcess.children.push(process);
|
||||
}
|
||||
} else if (!orphans?.includes(process)) {
|
||||
// if no parent process, process is probably orphaned
|
||||
if (backwardDirection) {
|
||||
orphans?.unshift(process);
|
||||
} else {
|
||||
orphans?.push(process);
|
||||
}
|
||||
parentProcess.addChild(process);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -120,13 +162,16 @@ export const buildProcessTree = (
|
|||
|
||||
if (parentProcessId) {
|
||||
const parentProcess = processMap[parentProcessId];
|
||||
process.parent = parentProcess; // handy for recursive operations (like auto expand)
|
||||
if (parentProcess !== undefined) {
|
||||
parentProcess.children.push(process);
|
||||
|
||||
if (parentProcess) {
|
||||
process.parent = parentProcess;
|
||||
parentProcess.addChild(process);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newOrphans.push(process);
|
||||
}
|
||||
|
||||
newOrphans.push(process);
|
||||
});
|
||||
|
||||
return newOrphans;
|
||||
|
@ -158,7 +203,7 @@ export const searchProcessTree = (
|
|||
}
|
||||
|
||||
const event = process.getDetails();
|
||||
const { working_directory: workingDirectory, args } = event.process || {};
|
||||
const { working_directory: workingDirectory, args } = event.process ?? {};
|
||||
|
||||
// TODO: the text we search is the same as what we render.
|
||||
// in future we may support KQL searches to match against any property
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { sortedUniqBy } from 'lodash';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AlertStatusEventEntityIdMap,
|
||||
EventAction,
|
||||
|
@ -16,18 +17,17 @@ import {
|
|||
ProcessEventsPage,
|
||||
} from '../../../common/types/process_tree';
|
||||
import {
|
||||
inferProcessFromLeaderInfo,
|
||||
updateAlertEventStatus,
|
||||
processNewEvents,
|
||||
searchProcessTree,
|
||||
autoExpandProcessTree,
|
||||
updateProcessMap,
|
||||
} from './helpers';
|
||||
import { sortProcesses } from '../../../common/utils/sort_processes';
|
||||
|
||||
interface UseProcessTreeDeps {
|
||||
sessionEntityId: string;
|
||||
data: ProcessEventsPage[];
|
||||
alerts: ProcessEvent[];
|
||||
searchQuery?: string;
|
||||
updatedAlertsStatus: AlertStatusEventEntityIdMap;
|
||||
verboseMode: boolean;
|
||||
|
@ -55,8 +55,6 @@ export class ProcessImpl implements Process {
|
|||
}
|
||||
|
||||
addEvent(newEvent: ProcessEvent) {
|
||||
// rather than push new events on the array, we return a new one
|
||||
// this helps the below memoizeOne functions to behave correctly.
|
||||
const exists = this.events.find((event) => {
|
||||
return event.event?.id === newEvent.event?.id;
|
||||
});
|
||||
|
@ -67,7 +65,17 @@ export class ProcessImpl implements Process {
|
|||
}
|
||||
|
||||
addAlert(alert: ProcessEvent) {
|
||||
this.alerts = this.alerts.concat(alert);
|
||||
const exists = this.alerts.find((event) => {
|
||||
return event.event?.id === alert.event?.id;
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
this.alerts = this.alerts.concat(alert);
|
||||
}
|
||||
}
|
||||
|
||||
addChild(newChild: Process) {
|
||||
this.children = this.children.concat(newChild);
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
|
@ -75,11 +83,17 @@ export class ProcessImpl implements Process {
|
|||
}
|
||||
|
||||
getChildren(verboseMode: boolean) {
|
||||
let children = this.children;
|
||||
return this.getChildrenMemo(this.children, this.orphans, verboseMode);
|
||||
}
|
||||
|
||||
getChildrenMemo = memoizeOne((children: Process[], orphans: Process[], verboseMode: boolean) => {
|
||||
if (children.length === 0 && orphans.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// if there are orphans, we just render them inline with the other child processes (currently only session leader does this)
|
||||
if (this.orphans.length) {
|
||||
children = [...children, ...this.orphans];
|
||||
if (orphans.length) {
|
||||
children = [...children, ...orphans];
|
||||
}
|
||||
// When verboseMode is false, we filter out noise via a few techniques.
|
||||
// This option is driven by the "verbose mode" toggle in SessionView/index.tsx
|
||||
|
@ -102,8 +116,8 @@ export class ProcessImpl implements Process {
|
|||
});
|
||||
}
|
||||
|
||||
return children.sort(sortProcesses);
|
||||
}
|
||||
return sortedUniqBy(children.sort(sortProcesses), (child) => child.id);
|
||||
});
|
||||
|
||||
isVerbose() {
|
||||
const {
|
||||
|
@ -235,6 +249,12 @@ export class ProcessImpl implements Process {
|
|||
return actionsToFind.includes(processEvent.event?.action);
|
||||
});
|
||||
|
||||
// there are some anomalous processes which are omitting event.action
|
||||
// we return whatever we have regardless so we at least render something in process tree
|
||||
if (filtered.length === 0 && events.length > 0) {
|
||||
return events[events.length - 1];
|
||||
}
|
||||
|
||||
// because events is already ordered by @timestamp we take the last event
|
||||
// which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event.
|
||||
// If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time)
|
||||
|
@ -259,29 +279,17 @@ export class ProcessImpl implements Process {
|
|||
export const useProcessTree = ({
|
||||
sessionEntityId,
|
||||
data,
|
||||
alerts,
|
||||
searchQuery,
|
||||
updatedAlertsStatus,
|
||||
verboseMode,
|
||||
jumpToEntityId,
|
||||
}: UseProcessTreeDeps) => {
|
||||
// initialize map, as well as a placeholder for session leader process
|
||||
// we add a fake session leader event, sourced from wide event data.
|
||||
// this is because we might not always have a session leader event
|
||||
// especially if we are paging in reverse from deep within a large session
|
||||
const fakeLeaderEvent = data[0].events?.find?.((event) => event.event?.kind === EventKind.event);
|
||||
const sessionLeaderProcess = new ProcessImpl(sessionEntityId);
|
||||
const firstEvent = data[0]?.events?.[0];
|
||||
const sessionLeaderProcess = useMemo(() => {
|
||||
const entryLeader = firstEvent?.process?.entry_leader;
|
||||
|
||||
if (fakeLeaderEvent) {
|
||||
fakeLeaderEvent.user = fakeLeaderEvent?.process?.entry_leader?.user;
|
||||
fakeLeaderEvent.group = fakeLeaderEvent?.process?.entry_leader?.group;
|
||||
fakeLeaderEvent.process = {
|
||||
...fakeLeaderEvent.process,
|
||||
...fakeLeaderEvent.process?.entry_leader,
|
||||
parent: fakeLeaderEvent.process?.parent,
|
||||
};
|
||||
sessionLeaderProcess.events.push(fakeLeaderEvent);
|
||||
}
|
||||
return inferProcessFromLeaderInfo(firstEvent, entryLeader);
|
||||
}, [firstEvent]);
|
||||
|
||||
const initializedProcessMap: ProcessMap = {
|
||||
[sessionEntityId]: sessionLeaderProcess,
|
||||
|
@ -289,7 +297,6 @@ export const useProcessTree = ({
|
|||
|
||||
const [processMap, setProcessMap] = useState(initializedProcessMap);
|
||||
const [processedPages, setProcessedPages] = useState<ProcessEventsPage[]>([]);
|
||||
const [alertsProcessed, setAlertsProcessed] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Process[]>([]);
|
||||
const [orphans, setOrphans] = useState<Process[]>([]);
|
||||
|
||||
|
@ -327,16 +334,6 @@ export const useProcessTree = ({
|
|||
}
|
||||
}, [data, processMap, orphans, processedPages, sessionEntityId, jumpToEntityId]);
|
||||
|
||||
useEffect(() => {
|
||||
// currently we are loading a single page of alerts, with no pagination
|
||||
// so we only need to add these alert events to processMap once.
|
||||
if (!alertsProcessed) {
|
||||
const updatedProcessMap = updateProcessMap(processMap, alerts);
|
||||
setProcessMap({ ...updatedProcessMap });
|
||||
setAlertsProcessed(true);
|
||||
}
|
||||
}, [processMap, alerts, alertsProcessed]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResults(searchProcessTree(processMap, searchQuery, verboseMode));
|
||||
}, [searchQuery, processMap, verboseMode]);
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
mockData,
|
||||
mockAlerts,
|
||||
nullMockData,
|
||||
deepNullMockData,
|
||||
} from '../../../common/mocks/constants/session_view_process.mock';
|
||||
|
@ -24,7 +23,6 @@ describe('ProcessTree component', () => {
|
|||
const props: ProcessTreeDeps = {
|
||||
sessionEntityId: sessionLeader.process!.entity_id!,
|
||||
data: mockData,
|
||||
alerts: mockAlerts,
|
||||
isFetching: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
AlertStatusEventEntityIdMap,
|
||||
Process,
|
||||
ProcessEventsPage,
|
||||
ProcessEvent,
|
||||
} from '../../../common/types/process_tree';
|
||||
import { useScroll } from '../../hooks/use_scroll';
|
||||
import { useStyles } from './styles';
|
||||
|
@ -41,7 +40,6 @@ export interface ProcessTreeDeps {
|
|||
sessionEntityId: string;
|
||||
|
||||
data: ProcessEventsPage[];
|
||||
alerts: ProcessEvent[];
|
||||
|
||||
jumpToEntityId?: string;
|
||||
investigatedAlertId?: string;
|
||||
|
@ -69,7 +67,6 @@ export interface ProcessTreeDeps {
|
|||
export const ProcessTree = ({
|
||||
sessionEntityId,
|
||||
data,
|
||||
alerts,
|
||||
jumpToEntityId,
|
||||
investigatedAlertId,
|
||||
isFetching,
|
||||
|
@ -93,7 +90,6 @@ export const ProcessTree = ({
|
|||
const { sessionLeader, processMap, searchResults } = useProcessTree({
|
||||
sessionEntityId,
|
||||
data,
|
||||
alerts,
|
||||
searchQuery,
|
||||
updatedAlertsStatus,
|
||||
verboseMode,
|
||||
|
|
|
@ -6,93 +6,171 @@ Object {
|
|||
"baseElement": <body>
|
||||
<div>
|
||||
<div
|
||||
class="euiText euiText--small css-vxlbjf-EuiText"
|
||||
css="[object Object]"
|
||||
data-id="6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
>
|
||||
<button
|
||||
aria-label="expand"
|
||||
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
|
||||
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="expand"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
color="danger"
|
||||
data-euiicon-type="alert"
|
||||
/>
|
||||
<div
|
||||
class="euiText euiText--small css-lps5t-EuiText"
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
|
||||
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
>
|
||||
cmd test alert
|
||||
</div>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
||||
title="open"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<button
|
||||
aria-label="expand"
|
||||
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
|
||||
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="expand"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
color="danger"
|
||||
data-euiicon-type="alert"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small css-slhc61-EuiText"
|
||||
>
|
||||
open
|
||||
cmd test alert
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
||||
title="open"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
open
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
|
||||
title="exec"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
exec
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>,
|
||||
"container": <div>
|
||||
<div
|
||||
class="euiText euiText--small css-vxlbjf-EuiText"
|
||||
css="[object Object]"
|
||||
data-id="6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
>
|
||||
<button
|
||||
aria-label="expand"
|
||||
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
|
||||
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="expand"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
color="danger"
|
||||
data-euiicon-type="alert"
|
||||
/>
|
||||
<div
|
||||
class="euiText euiText--small css-lps5t-EuiText"
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
|
||||
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
>
|
||||
cmd test alert
|
||||
</div>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
||||
title="open"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<button
|
||||
aria-label="expand"
|
||||
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
|
||||
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="expand"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
color="danger"
|
||||
data-euiicon-type="alert"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small css-slhc61-EuiText"
|
||||
>
|
||||
open
|
||||
cmd test alert
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
|
||||
title="open"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
open
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
|
||||
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
|
||||
title="exec"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
exec
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
"debug": [Function],
|
||||
|
|
|
@ -40,6 +40,7 @@ describe('ProcessTreeAlerts component', () => {
|
|||
expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy();
|
||||
expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy();
|
||||
expect(renderResult.queryByText(ALERT_STATUS!)).toBeTruthy();
|
||||
|
||||
expect(renderResult).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui';
|
||||
import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree';
|
||||
import { dataOrDash } from '../../utils/data_or_dash';
|
||||
import { getBadgeColorFromAlertStatus } from './helpers';
|
||||
|
@ -31,6 +31,7 @@ export const ProcessTreeAlert = ({
|
|||
}: ProcessTreeAlertDeps) => {
|
||||
const styles = useStyles({ isInvestigated, isSelected });
|
||||
|
||||
const { event } = alert;
|
||||
const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -58,27 +59,39 @@ export const ProcessTreeAlert = ({
|
|||
const { name } = rule;
|
||||
|
||||
return (
|
||||
<EuiText
|
||||
key={uuid}
|
||||
size="s"
|
||||
css={styles.alert}
|
||||
data-id={uuid}
|
||||
data-test-subj={`sessionView:sessionViewAlertDetail-${uuid}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
iconType="expand"
|
||||
aria-label="expand"
|
||||
data-test-subj={`sessionView:sessionViewAlertDetailExpand-${uuid}`}
|
||||
onClick={handleExpandClick}
|
||||
/>
|
||||
<EuiIcon type="alert" color="danger" />
|
||||
<EuiText css={styles.alertName} size="s">
|
||||
{dataOrDash(name)}
|
||||
</EuiText>
|
||||
<EuiBadge color={getBadgeColorFromAlertStatus(status)} css={styles.alertStatus}>
|
||||
{dataOrDash(status)}
|
||||
</EuiBadge>
|
||||
</EuiText>
|
||||
<div key={uuid} css={styles.alert} data-id={uuid}>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
wrap
|
||||
onClick={handleClick}
|
||||
data-test-subj={`sessionView:sessionViewAlertDetail-${uuid}`}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="expand"
|
||||
aria-label="expand"
|
||||
data-test-subj={`sessionView:sessionViewAlertDetailExpand-${uuid}`}
|
||||
onClick={handleExpandClick}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="alert" color="danger" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText css={styles.alertName} size="s">
|
||||
{dataOrDash(name)}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge color={getBadgeColorFromAlertStatus(status)} css={styles.alertStatus}>
|
||||
{dataOrDash(status)}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge css={styles.actionBadge}>{event?.action}</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,11 +43,7 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
|
|||
|
||||
const alert: CSSObject = {
|
||||
fontFamily: font.family,
|
||||
display: 'flex',
|
||||
gap: size.s,
|
||||
alignItems: 'center',
|
||||
padding: `0 ${size.base}`,
|
||||
boxSizing: 'content-box',
|
||||
padding: `0 ${size.m}`,
|
||||
cursor: 'pointer',
|
||||
'&:not(:last-child)': {
|
||||
marginBottom: size.s,
|
||||
|
@ -58,11 +54,15 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
|
|||
},
|
||||
'&& button': {
|
||||
flexShrink: 0,
|
||||
marginRight: size.s,
|
||||
marginRight: size.xs,
|
||||
'&:hover, &:focus, &:focus-within': {
|
||||
backgroundColor: transparentize(euiVars.buttonsBackgroundNormalDefaultPrimary, 0.2),
|
||||
},
|
||||
},
|
||||
'&& .euiFlexItem': {
|
||||
marginTop: size.xxs,
|
||||
marginBottom: size.xxs,
|
||||
},
|
||||
};
|
||||
|
||||
const alertStatus: CSSObject = {
|
||||
|
@ -70,14 +70,18 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
|
|||
};
|
||||
|
||||
const alertName: CSSObject = {
|
||||
padding: `${size.xs} 0`,
|
||||
color: colors.title,
|
||||
};
|
||||
|
||||
const actionBadge: CSSObject = {
|
||||
textTransform: 'capitalize',
|
||||
};
|
||||
|
||||
return {
|
||||
alert,
|
||||
alertStatus,
|
||||
alertName,
|
||||
actionBadge,
|
||||
};
|
||||
}, [euiTheme, isInvestigated, isSelected, euiVars]);
|
||||
|
||||
|
|
|
@ -202,17 +202,7 @@ export function ProcessTreeNode({
|
|||
}
|
||||
}, [hasExec, process.parent]);
|
||||
|
||||
const children = useMemo(() => {
|
||||
if (searchResults) {
|
||||
// noop
|
||||
// Only used to break cache on this memo when search changes. We need this ref
|
||||
// to avoid complaints from the useEffect dependency eslint rule.
|
||||
// This fixes an issue when verbose mode is OFF and there are matching results on
|
||||
// hidden processes.
|
||||
}
|
||||
|
||||
return process.getChildren(verboseMode);
|
||||
}, [process, verboseMode, searchResults]);
|
||||
const children = process.getChildren(verboseMode);
|
||||
|
||||
if (!processDetails?.process) {
|
||||
return null;
|
||||
|
@ -229,7 +219,7 @@ export function ProcessTreeNode({
|
|||
user,
|
||||
} = processDetails.process;
|
||||
|
||||
const shouldRenderChildren = childrenExpanded && children?.length > 0;
|
||||
const shouldRenderChildren = isSessionLeader || (childrenExpanded && children?.length > 0);
|
||||
const childrenTreeDepth = depth + 1;
|
||||
|
||||
const showUserEscalation = !isSessionLeader && !!user?.name && user.name !== parent?.user?.name;
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
ALERTS_ROUTE,
|
||||
PROCESS_EVENTS_ROUTE,
|
||||
PROCESS_EVENTS_PER_PAGE,
|
||||
ALERTS_PER_PAGE,
|
||||
ALERT_STATUS_ROUTE,
|
||||
QUERY_KEY_PROCESS_EVENTS,
|
||||
QUERY_KEY_ALERTS,
|
||||
|
@ -58,7 +59,7 @@ export const useFetchSessionViewProcessEvents = (
|
|||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) {
|
||||
if (lastPage.events.length >= PROCESS_EVENTS_PER_PAGE) {
|
||||
return {
|
||||
cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'],
|
||||
forward: true,
|
||||
|
@ -95,27 +96,45 @@ export const useFetchSessionViewProcessEvents = (
|
|||
return query;
|
||||
};
|
||||
|
||||
export const useFetchSessionViewAlerts = (sessionEntityId: string) => {
|
||||
export const useFetchSessionViewAlerts = (
|
||||
sessionEntityId: string,
|
||||
investigatedAlertId?: string
|
||||
) => {
|
||||
const { http } = useKibana<CoreStart>().services;
|
||||
const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId];
|
||||
const query = useQuery(
|
||||
const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId, investigatedAlertId];
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
cachingKeys,
|
||||
async () => {
|
||||
async ({ pageParam = {} }) => {
|
||||
const { cursor } = pageParam;
|
||||
|
||||
const res = await http.get<ProcessEventResults>(ALERTS_ROUTE, {
|
||||
query: {
|
||||
sessionEntityId,
|
||||
investigatedAlertId,
|
||||
cursor,
|
||||
},
|
||||
});
|
||||
|
||||
const events = res.events?.map((event: any) => event._source as ProcessEvent) ?? [];
|
||||
|
||||
return events;
|
||||
return {
|
||||
events,
|
||||
cursor,
|
||||
total: res.total,
|
||||
};
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.events.length >= ALERTS_PER_PAGE) {
|
||||
return {
|
||||
cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'],
|
||||
};
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
cacheTime: 0,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -118,12 +118,34 @@ export const SessionView = ({
|
|||
hasPreviousPage,
|
||||
} = useFetchSessionViewProcessEvents(sessionEntityId, currentJumpToCursor);
|
||||
|
||||
const alertsQuery = useFetchSessionViewAlerts(sessionEntityId);
|
||||
const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery;
|
||||
const {
|
||||
data: alertsData,
|
||||
fetchNextPage: fetchNextPageAlerts,
|
||||
isFetching: isFetchingAlerts,
|
||||
hasNextPage: hasNextPageAlerts,
|
||||
error: alertsError,
|
||||
} = useFetchSessionViewAlerts(sessionEntityId, investigatedAlertId);
|
||||
|
||||
const alerts = useMemo(() => {
|
||||
let events: ProcessEvent[] = [];
|
||||
|
||||
if (alertsData) {
|
||||
alertsData.pages.forEach((page) => {
|
||||
events = events.concat(page.events);
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}, [alertsData]);
|
||||
|
||||
const alertsCount = useMemo(() => {
|
||||
return alertsData?.pages?.[0].total || 0;
|
||||
}, [alertsData]);
|
||||
|
||||
const hasData = alerts && data && data.pages?.[0].events.length > 0;
|
||||
const hasError = error || alertsError;
|
||||
const renderIsLoading = (isFetching || alertsFetching) && !(data && alerts);
|
||||
const dataLoaded = data && data.pages?.length > (jumpToCursor ? 1 : 0);
|
||||
const renderIsLoading = isFetching && !dataLoaded;
|
||||
const hasData = dataLoaded && data.pages[0].events.length > 0;
|
||||
const { data: newUpdatedAlertsStatus } = useFetchAlertStatus(
|
||||
updatedAlertsStatus,
|
||||
fetchAlertStatus[0] ?? ''
|
||||
|
@ -276,7 +298,6 @@ export const SessionView = ({
|
|||
key={sessionEntityId + currentJumpToCursor}
|
||||
sessionEntityId={sessionEntityId}
|
||||
data={data.pages}
|
||||
alerts={alerts}
|
||||
searchQuery={searchQuery}
|
||||
selectedProcess={selectedProcess}
|
||||
onProcessSelected={onProcessSelected}
|
||||
|
@ -307,6 +328,10 @@ export const SessionView = ({
|
|||
>
|
||||
<SessionViewDetailPanel
|
||||
alerts={alerts}
|
||||
alertsCount={alertsCount}
|
||||
isFetchingAlerts={isFetchingAlerts}
|
||||
hasNextPageAlerts={hasNextPageAlerts}
|
||||
fetchNextPageAlerts={fetchNextPageAlerts}
|
||||
investigatedAlertId={investigatedAlertId}
|
||||
selectedProcess={selectedProcess}
|
||||
onJumpToEvent={onJumpToEvent}
|
||||
|
|
|
@ -17,47 +17,40 @@ describe('SessionView component', () => {
|
|||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
let mockedContext: AppContextTestRender;
|
||||
let mockOnJumpToEvent = jest.fn((process) => process);
|
||||
let mockShowAlertDetails = jest.fn((alertId) => alertId);
|
||||
|
||||
const props = {
|
||||
alerts: [],
|
||||
alertsCount: 0,
|
||||
selectedProcess: sessionViewBasicProcessMock,
|
||||
isFetchingAlerts: false,
|
||||
hasNextPageAlerts: false,
|
||||
fetchNextPageAlerts: jest.fn(() => true),
|
||||
onJumpToEvent: jest.fn((process) => process),
|
||||
onShowAlertDetails: jest.fn((alertId) => alertId),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
mockOnJumpToEvent = jest.fn((process) => process);
|
||||
mockShowAlertDetails = jest.fn((alertId) => alertId);
|
||||
props.onJumpToEvent.mockReset();
|
||||
props.onShowAlertDetails.mockReset();
|
||||
props.fetchNextPageAlerts.mockReset();
|
||||
});
|
||||
|
||||
describe('When SessionViewDetailPanel is mounted', () => {
|
||||
it('shows process detail by default', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<SessionViewDetailPanel
|
||||
selectedProcess={sessionViewBasicProcessMock}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
|
||||
expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should should default state with selectedProcess null', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<SessionViewDetailPanel
|
||||
selectedProcess={null}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
<SessionViewDetailPanel {...props} selectedProcess={null} />
|
||||
);
|
||||
expect(renderResult.queryAllByText('entity_id').length).toBe(5);
|
||||
});
|
||||
|
||||
it('can switch tabs to show host details', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<SessionViewDetailPanel
|
||||
selectedProcess={sessionViewBasicProcessMock}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
|
||||
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
|
||||
renderResult.queryByText('Metadata')?.click();
|
||||
expect(renderResult.queryByText('hostname')).toBeVisible();
|
||||
expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2);
|
||||
|
@ -65,27 +58,13 @@ describe('SessionView component', () => {
|
|||
|
||||
it('can switch tabs to show alert details', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<SessionViewDetailPanel
|
||||
alerts={mockAlerts}
|
||||
selectedProcess={sessionViewBasicProcessMock}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
<SessionViewDetailPanel {...props} alerts={mockAlerts} alertsCount={mockAlerts.length} />
|
||||
);
|
||||
|
||||
renderResult.queryByText('Alerts')?.click();
|
||||
expect(renderResult.queryByText('List view')).toBeVisible();
|
||||
});
|
||||
it('alert tab disabled when no alerts', async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<SessionViewDetailPanel
|
||||
alerts={[]}
|
||||
selectedProcess={sessionViewBasicProcessMock}
|
||||
onJumpToEvent={mockOnJumpToEvent}
|
||||
onShowAlertDetails={mockShowAlertDetails}
|
||||
/>
|
||||
);
|
||||
|
||||
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
|
||||
renderResult.queryByText('Alerts')?.click();
|
||||
expect(renderResult.queryByText('List view')).toBeFalsy();
|
||||
});
|
||||
|
|
|
@ -19,6 +19,10 @@ import { ALERT_COUNT_THRESHOLD } from '../../../common/constants';
|
|||
interface SessionViewDetailPanelDeps {
|
||||
selectedProcess: Process | null;
|
||||
alerts?: ProcessEvent[];
|
||||
alertsCount: number;
|
||||
isFetchingAlerts: boolean;
|
||||
hasNextPageAlerts?: boolean;
|
||||
fetchNextPageAlerts: () => void;
|
||||
investigatedAlertId?: string;
|
||||
onJumpToEvent: (event: ProcessEvent) => void;
|
||||
onShowAlertDetails: (alertId: string) => void;
|
||||
|
@ -29,6 +33,10 @@ interface SessionViewDetailPanelDeps {
|
|||
*/
|
||||
export const SessionViewDetailPanel = ({
|
||||
alerts,
|
||||
alertsCount,
|
||||
isFetchingAlerts,
|
||||
hasNextPageAlerts,
|
||||
fetchNextPageAlerts,
|
||||
selectedProcess,
|
||||
investigatedAlertId,
|
||||
onJumpToEvent,
|
||||
|
@ -36,13 +44,9 @@ export const SessionViewDetailPanel = ({
|
|||
}: SessionViewDetailPanelDeps) => {
|
||||
const [selectedTabId, setSelectedTabId] = useState('process');
|
||||
|
||||
const alertsCount = useMemo(() => {
|
||||
if (!alerts) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length;
|
||||
}, [alerts]);
|
||||
const alertsCountStr = useMemo(() => {
|
||||
return alertsCount >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alertsCount + '';
|
||||
}, [alertsCount]);
|
||||
|
||||
const tabs: EuiTabProps[] = useMemo(() => {
|
||||
const hasAlerts = !!alerts?.length;
|
||||
|
@ -75,12 +79,15 @@ export const SessionViewDetailPanel = ({
|
|||
}),
|
||||
append: hasAlerts && (
|
||||
<EuiNotificationBadge className="eui-alignCenter" size="m">
|
||||
{alertsCount}
|
||||
{alertsCountStr}
|
||||
</EuiNotificationBadge>
|
||||
),
|
||||
content: alerts && (
|
||||
<DetailPanelAlertTab
|
||||
alerts={alerts}
|
||||
isFetchingAlerts={isFetchingAlerts}
|
||||
hasNextPageAlerts={hasNextPageAlerts}
|
||||
fetchNextPageAlerts={fetchNextPageAlerts}
|
||||
onJumpToEvent={onJumpToEvent}
|
||||
onShowAlertDetails={onShowAlertDetails}
|
||||
investigatedAlertId={investigatedAlertId}
|
||||
|
@ -90,8 +97,11 @@ export const SessionViewDetailPanel = ({
|
|||
];
|
||||
}, [
|
||||
alerts,
|
||||
alertsCountStr,
|
||||
fetchNextPageAlerts,
|
||||
hasNextPageAlerts,
|
||||
isFetchingAlerts,
|
||||
selectedProcess,
|
||||
alertsCount,
|
||||
onJumpToEvent,
|
||||
onShowAlertDetails,
|
||||
investigatedAlertId,
|
||||
|
|
|
@ -13,21 +13,7 @@ import {
|
|||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { searchAlertByUuid } from './alert_status_route';
|
||||
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
|
||||
|
||||
import {
|
||||
AlertsClient,
|
||||
ConstructorOptions,
|
||||
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
|
||||
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
|
||||
|
||||
const alertingAuthMock = alertingAuthorizationMock.create();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
|
||||
const DEFAULT_SPACE = 'test_default_space_id';
|
||||
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
|
||||
|
||||
const getEmptyResponse = async () => {
|
||||
return {
|
||||
|
@ -65,57 +51,16 @@ const getResponse = async () => {
|
|||
};
|
||||
};
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
|
||||
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
logger: loggingSystemMock.create().get(),
|
||||
authorization: alertingAuthMock,
|
||||
auditLogger,
|
||||
ruleDataService: ruleDataServiceMock.create(),
|
||||
esClient: esClientMock,
|
||||
};
|
||||
|
||||
describe('alert_status_route.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
|
||||
Promise.resolve({ filter: [] })
|
||||
);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
|
||||
const authorizedRuleTypes = new Set();
|
||||
authorizedRuleTypes.add({ producer: 'apm' });
|
||||
return Promise.resolve({ authorizedRuleTypes });
|
||||
});
|
||||
|
||||
alertingAuthMock.ensureAuthorized.mockImplementation(
|
||||
// @ts-expect-error
|
||||
async ({
|
||||
ruleTypeId,
|
||||
consumer,
|
||||
operation,
|
||||
entity,
|
||||
}: {
|
||||
ruleTypeId: string;
|
||||
consumer: string;
|
||||
operation: string;
|
||||
entity: typeof AlertingAuthorizationEntity.Alert;
|
||||
}) => {
|
||||
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
|
||||
}
|
||||
);
|
||||
resetAlertingAuthMock();
|
||||
});
|
||||
|
||||
describe('searchAlertByUuid(client, alertUuid)', () => {
|
||||
it('should return an empty events array for a non existant alert uuid', async () => {
|
||||
const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
|
||||
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
|
||||
const alertsClient = getAlertsClientMockInstance(esClient);
|
||||
const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!);
|
||||
|
||||
expect(body.events.length).toBe(0);
|
||||
|
@ -123,7 +68,7 @@ describe('alert_status_route.ts', () => {
|
|||
|
||||
it('returns results for a particular alert uuid', async () => {
|
||||
const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
|
||||
const alertsClient = getAlertsClientMockInstance(esClient);
|
||||
const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!);
|
||||
|
||||
expect(body.events.length).toBe(1);
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertsClient,
|
||||
ConstructorOptions,
|
||||
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
|
||||
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import {
|
||||
ALERT_RULE_CONSUMER,
|
||||
ALERT_RULE_TYPE_ID,
|
||||
SPACE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
|
||||
|
||||
export const alertingAuthMock = alertingAuthorizationMock.create();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
|
||||
const DEFAULT_SPACE = 'test_default_space_id';
|
||||
|
||||
const getResponse = async () => {
|
||||
return {
|
||||
hits: {
|
||||
total: mockAlerts.length,
|
||||
hits: mockAlerts.map((event) => {
|
||||
return {
|
||||
found: true,
|
||||
_type: 'alert',
|
||||
_index: '.alerts-security',
|
||||
_id: 'NoxgpHkBqbdrfX07MqXV',
|
||||
_version: 1,
|
||||
_seq_no: 362,
|
||||
_primary_term: 2,
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
message: 'hello world 1',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_WORKFLOW_STATUS]: 'open',
|
||||
[SPACE_IDS]: ['test_default_space_id'],
|
||||
...event,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
logger: loggingSystemMock.create().get(),
|
||||
authorization: alertingAuthMock,
|
||||
auditLogger,
|
||||
ruleDataService: ruleDataServiceMock.create(),
|
||||
esClient: esClientMock,
|
||||
};
|
||||
|
||||
export function getAlertsClientMockInstance(esClient?: ElasticsearchClient) {
|
||||
esClient = esClient || elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
|
||||
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
|
||||
|
||||
return alertsClient;
|
||||
}
|
||||
|
||||
export function resetAlertingAuthMock() {
|
||||
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
|
||||
Promise.resolve({ filter: [] })
|
||||
);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
|
||||
const authorizedRuleTypes = new Set();
|
||||
authorizedRuleTypes.add({ producer: 'apm' });
|
||||
return Promise.resolve({ authorizedRuleTypes });
|
||||
});
|
||||
|
||||
alertingAuthMock.ensureAuthorized.mockImplementation(
|
||||
// @ts-expect-error
|
||||
async ({
|
||||
ruleTypeId,
|
||||
consumer,
|
||||
operation,
|
||||
entity,
|
||||
}: {
|
||||
ruleTypeId: string;
|
||||
consumer: string;
|
||||
operation: string;
|
||||
entity: typeof AlertingAuthorizationEntity.Alert;
|
||||
}) => {
|
||||
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// this is only here because the above imports complain if they aren't declared as part of a test file.
|
||||
describe('alerts_route_mock.test.ts', () => {
|
||||
it('does nothing', () => undefined);
|
||||
});
|
|
@ -4,30 +4,10 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import {
|
||||
ALERT_RULE_CONSUMER,
|
||||
ALERT_RULE_TYPE_ID,
|
||||
SPACE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
} from '@kbn/rule-data-utils';
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { doSearch } from './alerts_route';
|
||||
import { mockEvents } from '../../common/mocks/constants/session_view_process.mock';
|
||||
|
||||
import {
|
||||
AlertsClient,
|
||||
ConstructorOptions,
|
||||
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
|
||||
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
|
||||
|
||||
const alertingAuthMock = alertingAuthorizationMock.create();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
|
||||
const DEFAULT_SPACE = 'test_default_space_id';
|
||||
import { searchAlerts } from './alerts_route';
|
||||
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
|
||||
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
|
||||
|
||||
const getEmptyResponse = async () => {
|
||||
return {
|
||||
|
@ -38,96 +18,46 @@ const getEmptyResponse = async () => {
|
|||
};
|
||||
};
|
||||
|
||||
const getResponse = async () => {
|
||||
return {
|
||||
hits: {
|
||||
total: mockEvents.length,
|
||||
hits: mockEvents.map((event) => {
|
||||
return {
|
||||
found: true,
|
||||
_type: 'alert',
|
||||
_index: '.alerts-security',
|
||||
_id: 'NoxgpHkBqbdrfX07MqXV',
|
||||
_version: 1,
|
||||
_seq_no: 362,
|
||||
_primary_term: 2,
|
||||
_source: {
|
||||
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
|
||||
message: 'hello world 1',
|
||||
[ALERT_RULE_CONSUMER]: 'apm',
|
||||
[ALERT_WORKFLOW_STATUS]: 'open',
|
||||
[SPACE_IDS]: ['test_default_space_id'],
|
||||
...event,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
|
||||
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
logger: loggingSystemMock.create().get(),
|
||||
authorization: alertingAuthMock,
|
||||
auditLogger,
|
||||
ruleDataService: ruleDataServiceMock.create(),
|
||||
esClient: esClientMock,
|
||||
};
|
||||
|
||||
describe('alerts_route.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
|
||||
Promise.resolve({ filter: [] })
|
||||
);
|
||||
// @ts-expect-error
|
||||
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
|
||||
const authorizedRuleTypes = new Set();
|
||||
authorizedRuleTypes.add({ producer: 'apm' });
|
||||
return Promise.resolve({ authorizedRuleTypes });
|
||||
});
|
||||
|
||||
alertingAuthMock.ensureAuthorized.mockImplementation(
|
||||
// @ts-expect-error
|
||||
async ({
|
||||
ruleTypeId,
|
||||
consumer,
|
||||
operation,
|
||||
entity,
|
||||
}: {
|
||||
ruleTypeId: string;
|
||||
consumer: string;
|
||||
operation: string;
|
||||
entity: typeof AlertingAuthorizationEntity.Alert;
|
||||
}) => {
|
||||
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
|
||||
}
|
||||
);
|
||||
resetAlertingAuthMock();
|
||||
});
|
||||
|
||||
describe('doSearch(client, sessionEntityId)', () => {
|
||||
describe('searchAlerts(client, sessionEntityId)', () => {
|
||||
it('should return an empty events array for a non existant entity_id', async () => {
|
||||
const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
|
||||
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
|
||||
const body = await doSearch(alertsClient, 'asdf');
|
||||
const alertsClient = getAlertsClientMockInstance(esClient);
|
||||
const body = await searchAlerts(alertsClient, 'asdf', 100);
|
||||
|
||||
expect(body.events.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns results for a particular session entity_id', async () => {
|
||||
const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
|
||||
const alertsClient = getAlertsClientMockInstance();
|
||||
|
||||
const body = await doSearch(alertsClient, 'asdf');
|
||||
const body = await searchAlerts(alertsClient, 'asdf', 100);
|
||||
|
||||
expect(body.events.length).toBe(mockEvents.length);
|
||||
expect(body.events.length).toBe(mockAlerts.length);
|
||||
});
|
||||
|
||||
it('takes an investigatedAlertId', async () => {
|
||||
const alertsClient = getAlertsClientMockInstance();
|
||||
|
||||
const body = await searchAlerts(alertsClient, 'asdf', 100, mockAlerts[0].kibana?.alert?.uuid);
|
||||
|
||||
expect(body.events.length).toBe(mockAlerts.length + 1);
|
||||
});
|
||||
|
||||
it('takes a range', async () => {
|
||||
const alertsClient = getAlertsClientMockInstance();
|
||||
|
||||
const start = '2021-11-23T15:25:04.210Z';
|
||||
const end = '2021-20-23T15:25:04.210Z';
|
||||
const body = await searchAlerts(alertsClient, 'asdf', 100, undefined, [start, end]);
|
||||
|
||||
expect(body.events.length).toBe(mockAlerts.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,8 +14,11 @@ import {
|
|||
ALERTS_ROUTE,
|
||||
ALERTS_PER_PAGE,
|
||||
ENTRY_SESSION_ENTITY_ID_PROPERTY,
|
||||
ALERT_UUID_PROPERTY,
|
||||
ALERT_ORIGINAL_TIME_PROPERTY,
|
||||
PREVIEW_ALERTS_INDEX,
|
||||
} from '../../common/constants';
|
||||
|
||||
import { expandDottedObject } from '../../common/utils/expand_dotted_object';
|
||||
|
||||
export const registerAlertsRoute = (
|
||||
|
@ -28,20 +31,37 @@ export const registerAlertsRoute = (
|
|||
validate: {
|
||||
query: schema.object({
|
||||
sessionEntityId: schema.string(),
|
||||
investigatedAlertId: schema.maybe(schema.string()),
|
||||
cursor: schema.maybe(schema.string()),
|
||||
range: schema.maybe(schema.arrayOf(schema.string())),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (_context, request, response) => {
|
||||
const client = await ruleRegistry.getRacClientWithRequest(request);
|
||||
const { sessionEntityId } = request.query;
|
||||
const body = await doSearch(client, sessionEntityId);
|
||||
const { sessionEntityId, investigatedAlertId, range, cursor } = request.query;
|
||||
const body = await searchAlerts(
|
||||
client,
|
||||
sessionEntityId,
|
||||
ALERTS_PER_PAGE,
|
||||
investigatedAlertId,
|
||||
range,
|
||||
cursor
|
||||
);
|
||||
|
||||
return response.ok({ body });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const doSearch = async (client: AlertsClient, sessionEntityId: string) => {
|
||||
export const searchAlerts = async (
|
||||
client: AlertsClient,
|
||||
sessionEntityId: string,
|
||||
size: number,
|
||||
investigatedAlertId?: string,
|
||||
range?: string[],
|
||||
cursor?: string
|
||||
) => {
|
||||
const indices = (await client.getAuthorizedAlertsIndices(['siem']))?.filter(
|
||||
(index) => index !== PREVIEW_ALERTS_INDEX
|
||||
);
|
||||
|
@ -52,15 +72,49 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) =>
|
|||
|
||||
const results = await client.find({
|
||||
query: {
|
||||
match: {
|
||||
[ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId,
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
term: {
|
||||
[ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId,
|
||||
},
|
||||
},
|
||||
range && {
|
||||
range: {
|
||||
[ALERT_ORIGINAL_TIME_PROPERTY]: {
|
||||
gte: range[0],
|
||||
lte: range[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
].filter((item) => !!item),
|
||||
},
|
||||
},
|
||||
track_total_hits: false,
|
||||
size: ALERTS_PER_PAGE,
|
||||
track_total_hits: true,
|
||||
size,
|
||||
index: indices.join(','),
|
||||
sort: [{ '@timestamp': 'asc' }],
|
||||
search_after: cursor ? [cursor] : undefined,
|
||||
});
|
||||
|
||||
// if an alert is being investigated, fetch it on it's own, as it's not guaranteed to come back in the above request.
|
||||
// we only need to do this for the first page of alerts.
|
||||
if (!cursor && investigatedAlertId) {
|
||||
const investigatedAlertSearch = await client.find({
|
||||
query: {
|
||||
match: {
|
||||
[ALERT_UUID_PROPERTY]: investigatedAlertId,
|
||||
},
|
||||
},
|
||||
size: 1,
|
||||
index: indices.join(','),
|
||||
});
|
||||
|
||||
if (investigatedAlertSearch.hits.hits.length > 0) {
|
||||
results.hits.hits.unshift(investigatedAlertSearch.hits.hits[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const events = results.hits.hits.map((hit: any) => {
|
||||
// the alert indexes flattens many properties. this util unflattens them as session view expects structured json.
|
||||
hit._source = expandDottedObject(hit._source);
|
||||
|
@ -68,5 +122,8 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) =>
|
|||
return hit;
|
||||
});
|
||||
|
||||
return { events };
|
||||
const total =
|
||||
typeof results.hits.total === 'number' ? results.hits.total : results.hits.total?.value;
|
||||
|
||||
return { total, events };
|
||||
};
|
||||
|
|
|
@ -9,11 +9,9 @@ import { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/serve
|
|||
import { registerProcessEventsRoute } from './process_events_route';
|
||||
import { registerAlertsRoute } from './alerts_route';
|
||||
import { registerAlertStatusRoute } from './alert_status_route';
|
||||
import { sessionEntryLeadersRoute } from './session_entry_leaders_route';
|
||||
|
||||
export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => {
|
||||
registerProcessEventsRoute(router);
|
||||
sessionEntryLeadersRoute(router);
|
||||
registerProcessEventsRoute(router, ruleRegistry);
|
||||
registerAlertsRoute(router, ruleRegistry);
|
||||
registerAlertStatusRoute(router, ruleRegistry);
|
||||
};
|
||||
|
|
|
@ -5,8 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||
import { doSearch } from './process_events_route';
|
||||
import { mockEvents } from '../../common/mocks/constants/session_view_process.mock';
|
||||
import { fetchEventsAndScopedAlerts } from './process_events_route';
|
||||
import { mockEvents, mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
|
||||
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
|
||||
import { EventKind, ProcessEvent } from '../../common/types/process_tree';
|
||||
|
||||
const getEmptyResponse = async () => {
|
||||
return {
|
||||
|
@ -29,11 +31,18 @@ const getResponse = async () => {
|
|||
};
|
||||
|
||||
describe('process_events_route.ts', () => {
|
||||
describe('doSearch(client, entityId, cursor, forward)', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
resetAlertingAuthMock();
|
||||
});
|
||||
|
||||
describe('fetchEventsAndScopedAlerts(client, entityId, cursor, forward)', () => {
|
||||
it('should return an empty events array for a non existant entity_id', async () => {
|
||||
const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
|
||||
const alertsClient = getAlertsClientMockInstance(client);
|
||||
|
||||
const body = await doSearch(client, 'asdf', undefined);
|
||||
const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'asdf', undefined);
|
||||
|
||||
expect(body.events.length).toBe(0);
|
||||
expect(body.total).toBe(0);
|
||||
|
@ -41,17 +50,34 @@ describe('process_events_route.ts', () => {
|
|||
|
||||
it('returns results for a particular session entity_id', async () => {
|
||||
const client = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
const alertsClient = getAlertsClientMockInstance();
|
||||
|
||||
const body = await doSearch(client, 'mockId', undefined);
|
||||
const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'mockId', undefined);
|
||||
|
||||
expect(body.events.length).toBe(mockEvents.length);
|
||||
expect(body.total).toBe(body.events.length);
|
||||
expect(body.events.length).toBe(mockEvents.length + mockAlerts.length);
|
||||
|
||||
const eventsOnly = body.events.filter(
|
||||
(event) => (event._source as ProcessEvent)?.event?.kind === EventKind.event
|
||||
);
|
||||
const alertsOnly = body.events.filter(
|
||||
(event) => (event._source as ProcessEvent)?.event?.kind === EventKind.signal
|
||||
);
|
||||
expect(eventsOnly.length).toBe(mockEvents.length);
|
||||
expect(alertsOnly.length).toBe(mockAlerts.length);
|
||||
expect(body.total).toBe(mockEvents.length);
|
||||
});
|
||||
|
||||
it('returns hits in reverse order when paginating backwards', async () => {
|
||||
const client = elasticsearchServiceMock.createElasticsearchClient(getResponse());
|
||||
const alertsClient = getAlertsClientMockInstance();
|
||||
|
||||
const body = await doSearch(client, 'mockId', undefined, false);
|
||||
const body = await fetchEventsAndScopedAlerts(
|
||||
client,
|
||||
alertsClient,
|
||||
'mockId',
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]);
|
||||
});
|
||||
|
|
|
@ -5,16 +5,27 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import _ from 'lodash';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
import type {
|
||||
AlertsClient,
|
||||
RuleRegistryPluginStartContract,
|
||||
} from '@kbn/rule-registry-plugin/server';
|
||||
import {
|
||||
ALERTS_PER_PROCESS_EVENTS_PAGE,
|
||||
PROCESS_EVENTS_ROUTE,
|
||||
PROCESS_EVENTS_PER_PAGE,
|
||||
PROCESS_EVENTS_INDEX,
|
||||
ENTRY_SESSION_ENTITY_ID_PROPERTY,
|
||||
} from '../../common/constants';
|
||||
import { ProcessEvent } from '../../common/types/process_tree';
|
||||
import { searchAlerts } from './alerts_route';
|
||||
|
||||
export const registerProcessEventsRoute = (router: IRouter) => {
|
||||
export const registerProcessEventsRoute = (
|
||||
router: IRouter,
|
||||
ruleRegistry: RuleRegistryPluginStartContract
|
||||
) => {
|
||||
router.get(
|
||||
{
|
||||
path: PROCESS_EVENTS_ROUTE,
|
||||
|
@ -28,20 +39,30 @@ export const registerProcessEventsRoute = (router: IRouter) => {
|
|||
},
|
||||
async (context, request, response) => {
|
||||
const client = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
const alertsClient = await ruleRegistry.getRacClientWithRequest(request);
|
||||
const { sessionEntityId, cursor, forward = true } = request.query;
|
||||
const body = await doSearch(client, sessionEntityId, cursor, forward);
|
||||
const body = await fetchEventsAndScopedAlerts(
|
||||
client,
|
||||
alertsClient,
|
||||
sessionEntityId,
|
||||
cursor,
|
||||
forward
|
||||
);
|
||||
|
||||
return response.ok({ body });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const doSearch = async (
|
||||
export const fetchEventsAndScopedAlerts = async (
|
||||
client: ElasticsearchClient,
|
||||
alertsClient: AlertsClient,
|
||||
sessionEntityId: string,
|
||||
cursor: string | undefined,
|
||||
forward = true
|
||||
) => {
|
||||
const cursorMillis = cursor && new Date(cursor).getTime() + (forward ? -1 : 1);
|
||||
|
||||
const search = await client.search({
|
||||
index: [PROCESS_EVENTS_INDEX],
|
||||
body: {
|
||||
|
@ -52,11 +73,11 @@ export const doSearch = async (
|
|||
},
|
||||
size: PROCESS_EVENTS_PER_PAGE,
|
||||
sort: [{ '@timestamp': forward ? 'asc' : 'desc' }],
|
||||
search_after: cursor ? [cursor] : undefined,
|
||||
search_after: cursorMillis ? [cursorMillis] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const events = search.hits.hits;
|
||||
let events = search.hits.hits;
|
||||
|
||||
if (!forward) {
|
||||
events.reverse();
|
||||
|
@ -65,6 +86,28 @@ export const doSearch = async (
|
|||
const total =
|
||||
typeof search.hits.total === 'number' ? search.hits.total : search.hits.total?.value;
|
||||
|
||||
if (events.length > 0) {
|
||||
// go grab any alerts which happened in this page of events.
|
||||
const firstEvent = _.first(events)?._source as ProcessEvent;
|
||||
const lastEvent = _.last(events)?._source as ProcessEvent;
|
||||
|
||||
let range;
|
||||
|
||||
if (firstEvent?.['@timestamp'] && lastEvent?.['@timestamp']) {
|
||||
range = [firstEvent['@timestamp'], lastEvent['@timestamp']];
|
||||
}
|
||||
|
||||
const alertsBody = await searchAlerts(
|
||||
alertsClient,
|
||||
sessionEntityId,
|
||||
ALERTS_PER_PROCESS_EVENTS_PAGE,
|
||||
undefined,
|
||||
range
|
||||
);
|
||||
|
||||
events = [...events, ...alertsBody.events];
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
events,
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants';
|
||||
|
||||
export const sessionEntryLeadersRoute = (router: IRouter) => {
|
||||
router.get(
|
||||
{
|
||||
path: SESSION_ENTRY_LEADERS_ROUTE,
|
||||
validate: {
|
||||
query: schema.object({
|
||||
id: schema.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
const client = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
const { id } = request.query;
|
||||
|
||||
const result = await client.get({
|
||||
index: PROCESS_EVENTS_INDEX,
|
||||
id,
|
||||
});
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
session_entry_leader: result?._source,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
|
@ -39,7 +39,7 @@
|
|||
"id": "space2alert",
|
||||
"source": {
|
||||
"event.kind" : "signal",
|
||||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"@timestamp": "2020-12-16T15:16:19.570Z",
|
||||
"kibana.alert.rule.rule_type_id": "apm.error_rate",
|
||||
"message": "hello world 1",
|
||||
"kibana.alert.rule.consumer": "apm",
|
||||
|
@ -56,7 +56,7 @@
|
|||
"id": "020202",
|
||||
"source": {
|
||||
"event.kind" : "signal",
|
||||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"@timestamp": "2020-12-16T15:16:20.570Z",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"message": "hello world security",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
|
@ -73,7 +73,7 @@
|
|||
"id": "020204",
|
||||
"source": {
|
||||
"event.kind" : "signal",
|
||||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"@timestamp": "2020-12-16T15:16:21.570Z",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"message": "hello world security",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
|
@ -89,7 +89,7 @@
|
|||
"index": ".alerts-security.alerts",
|
||||
"id": "space1securityalert",
|
||||
"source": {
|
||||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"@timestamp": "2020-12-16T15:16:22.570Z",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"message": "hello world security",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
|
@ -105,7 +105,7 @@
|
|||
"index": ".alerts-security.alerts",
|
||||
"id": "space2securityalert",
|
||||
"source": {
|
||||
"@timestamp": "2020-12-16T15:16:18.570Z",
|
||||
"@timestamp": "2020-12-16T15:16:23.570Z",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"message": "hello world security",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
|
|
|
@ -158,6 +158,93 @@ export default ({ getService }: FtrProviderContext) => {
|
|||
expect(found.body.hits.total.value).to.be.above(0);
|
||||
});
|
||||
|
||||
it(`${superUser.username} should allow a custom sort and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
|
||||
const found = await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
|
||||
.auth(superUser.username, superUser.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({
|
||||
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
|
||||
sort: [
|
||||
{
|
||||
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
|
||||
},
|
||||
],
|
||||
index: SECURITY_SOLUTION_ALERT_INDEX,
|
||||
});
|
||||
expect(found.statusCode).to.eql(200);
|
||||
expect(found.body.hits.total.value).to.be.above(0);
|
||||
|
||||
let lastSort = Infinity;
|
||||
|
||||
found.body.hits.hits.forEach((hit: any) => {
|
||||
expect(hit.sort).to.be.above(0);
|
||||
|
||||
if (hit.sort > lastSort) {
|
||||
throw new Error('sort by timestamp desc failed.');
|
||||
}
|
||||
|
||||
lastSort = hit.sort;
|
||||
});
|
||||
});
|
||||
|
||||
it(`${superUser.username} should handle an invalid custom sort`, async () => {
|
||||
const found = await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
|
||||
.auth(superUser.username, superUser.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({
|
||||
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
|
||||
sort: [
|
||||
{
|
||||
asdf: 'invalid',
|
||||
},
|
||||
],
|
||||
index: SECURITY_SOLUTION_ALERT_INDEX,
|
||||
});
|
||||
expect(found.statusCode).to.eql(404);
|
||||
});
|
||||
|
||||
it(`${superUser.username} should allow a custom sort (using search_after) and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
|
||||
const firstSearch = await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
|
||||
.auth(superUser.username, superUser.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({
|
||||
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
|
||||
sort: [
|
||||
{
|
||||
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
|
||||
},
|
||||
],
|
||||
index: SECURITY_SOLUTION_ALERT_INDEX,
|
||||
});
|
||||
|
||||
// grab second to last event cursor
|
||||
const hits = firstSearch.body.hits.hits;
|
||||
const cursor = hits[hits.length - 2].sort[0];
|
||||
|
||||
const secondSearch = await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
|
||||
.auth(superUser.username, superUser.password)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({
|
||||
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
|
||||
sort: [
|
||||
{
|
||||
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
|
||||
},
|
||||
],
|
||||
search_after: [cursor],
|
||||
index: SECURITY_SOLUTION_ALERT_INDEX,
|
||||
});
|
||||
|
||||
expect(secondSearch.body.hits.hits.length).equal(1);
|
||||
|
||||
// there should only be one result, as we are searching after the second to last record of the first search
|
||||
expect(secondSearch.body.hits.hits[0].sort).to.be.below(cursor); // below since we are paging backwards in time
|
||||
});
|
||||
|
||||
it(`${superUser.username} should allow cardinality aggs in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
|
||||
const found = await supertestWithoutAuth
|
||||
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue