mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Endpoint][Response Actions] Response actions log table view (#134520)
* use hit total for paging fixes elastic/security-team/issues/3871 * use management default page and pageSize fixes elastic/security-team/issues/3871 * augment fake data * add response actions page and component fixes elastic/security-team/issues/3871 * Use component in responder flyout fixes elastic/security-team/issues/3871 * review changes fixes elastic/security-team/issues/3871 fixes elastic/security-team/issues/3896 * removes duration column as per new mocks - update status texts - rename command placed at column name * date filters with local storage fixes elastic/security-team/issues/4037 * review changes * fix type in request query object as a first param is always present in the request * use 1-based paging on API requests review changes refs751e3145e5
* update schema to handle single agent ids correctly review change * remove faker stuff refsb584ada8f5
* memoized callback review changes * update test refs898bdc4949
* update tray font styles as per mock refs elastic/security-team/issues/4218 * show paged ranges on the table refs elastic/security-team/issues/4218 * Remove local storage also auto refresh data when enabled * cleanup * output section styles fixes elastic/security-team/issues/4218 * fix paging info fixes elastic/security-team/issues/4218 * Add tests fixes elastic/security-team/issues/3896 fixes elastic/security-team/issues/4037 fixes elastic/security-team/issues/4218 * review changes * update tests refs6e36228c4e
This commit is contained in:
parent
06d7c1c91f
commit
35191f0182
27 changed files with 1572 additions and 89 deletions
|
@ -105,6 +105,7 @@ export enum SecurityPageName {
|
|||
networkTls = 'network-tls',
|
||||
overview = 'overview',
|
||||
policies = 'policy',
|
||||
responseActions = 'response_actions',
|
||||
rules = 'rules',
|
||||
rulesCreate = 'rules-create',
|
||||
timelines = 'timelines',
|
||||
|
@ -149,6 +150,7 @@ export const EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/event_filters` as const;
|
|||
export const HOST_ISOLATION_EXCEPTIONS_PATH =
|
||||
`${MANAGEMENT_PATH}/host_isolation_exceptions` as const;
|
||||
export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const;
|
||||
export const RESPONSE_ACTIONS_PATH = `${MANAGEMENT_PATH}/response_actions` as const;
|
||||
|
||||
export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const;
|
||||
export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const;
|
||||
|
@ -174,6 +176,7 @@ export const APP_EVENT_FILTERS_PATH = `${APP_PATH}${EVENT_FILTERS_PATH}` as cons
|
|||
export const APP_HOST_ISOLATION_EXCEPTIONS_PATH =
|
||||
`${APP_PATH}${HOST_ISOLATION_EXCEPTIONS_PATH}` as const;
|
||||
export const APP_BLOCKLIST_PATH = `${APP_PATH}${BLOCKLIST_PATH}` as const;
|
||||
export const APP_RESPONSE_ACTIONS_PATH = `${APP_PATH}${RESPONSE_ACTIONS_PATH}` as const;
|
||||
|
||||
// cloud logs to exclude from default index pattern
|
||||
export const EXCLUDE_ELASTIC_CLOUD_INDICES = ['-*elastic-cloud-logs-*'];
|
||||
|
|
|
@ -24,9 +24,7 @@ import {
|
|||
export class EndpointActionGenerator extends BaseDataGenerator {
|
||||
/** Generate a random endpoint Action request (isolate or unisolate) */
|
||||
generate(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
|
||||
const timeStamp = overrides['@timestamp']
|
||||
? new Date(overrides['@timestamp'])
|
||||
: new Date(this.randomPastDate());
|
||||
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
|
||||
|
||||
return merge(
|
||||
{
|
||||
|
@ -76,6 +74,14 @@ export class EndpointActionGenerator extends BaseDataGenerator {
|
|||
): LogsEndpointActionResponse {
|
||||
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
|
||||
|
||||
const startedAtTimes: number[] = [];
|
||||
[2, 3, 5, 8, 13, 21].forEach((n) => {
|
||||
startedAtTimes.push(
|
||||
timeStamp.setMinutes(-this.randomN(n)),
|
||||
timeStamp.setSeconds(-this.randomN(n))
|
||||
);
|
||||
});
|
||||
|
||||
return merge(
|
||||
{
|
||||
'@timestamp': timeStamp.toISOString(),
|
||||
|
@ -90,7 +96,8 @@ export class EndpointActionGenerator extends BaseDataGenerator {
|
|||
comment: '',
|
||||
parameters: undefined,
|
||||
},
|
||||
started_at: this.randomPastDate(),
|
||||
// randomly before a few hours/minutes/seconds later
|
||||
started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(),
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
|
|
|
@ -22,9 +22,7 @@ import {
|
|||
export class FleetActionGenerator extends BaseDataGenerator {
|
||||
/** Generate a random endpoint Action (isolate or unisolate) */
|
||||
generate(overrides: DeepPartial<EndpointAction> = {}): EndpointAction {
|
||||
const timeStamp = overrides['@timestamp']
|
||||
? new Date(overrides['@timestamp'])
|
||||
: new Date(this.randomPastDate());
|
||||
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
|
||||
|
||||
return merge(
|
||||
{
|
||||
|
@ -65,6 +63,14 @@ export class FleetActionGenerator extends BaseDataGenerator {
|
|||
generateResponse(overrides: DeepPartial<EndpointActionResponse> = {}): EndpointActionResponse {
|
||||
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
|
||||
|
||||
const startedAtTimes: number[] = [];
|
||||
[2, 3, 5, 8, 13, 21].forEach((n) => {
|
||||
startedAtTimes.push(
|
||||
timeStamp.setMinutes(-this.randomN(n)),
|
||||
timeStamp.setSeconds(-this.randomN(n))
|
||||
);
|
||||
});
|
||||
|
||||
return merge(
|
||||
{
|
||||
action_data: {
|
||||
|
@ -74,9 +80,9 @@ export class FleetActionGenerator extends BaseDataGenerator {
|
|||
},
|
||||
action_id: this.seededUUIDv4(),
|
||||
agent_id: this.seededUUIDv4(),
|
||||
started_at: this.randomPastDate(),
|
||||
started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(),
|
||||
completed_at: timeStamp.toISOString(),
|
||||
error: 'some error happened',
|
||||
error: undefined,
|
||||
'@timestamp': timeStamp.toISOString(),
|
||||
},
|
||||
overrides
|
||||
|
|
|
@ -77,35 +77,36 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
)
|
||||
.catch(wrapErrorAndRejectPromise);
|
||||
|
||||
if (fleetActionGenerator.randomFloat() < 0.4) {
|
||||
const endpointActionsBody = {
|
||||
EndpointActions: {
|
||||
...action,
|
||||
'@timestamp': undefined,
|
||||
user_id: undefined,
|
||||
},
|
||||
agent: {
|
||||
id: [agentId],
|
||||
},
|
||||
'@timestamp': action['@timestamp'],
|
||||
user: {
|
||||
id: action.user_id,
|
||||
},
|
||||
const endpointActionsBody: LogsEndpointAction & {
|
||||
EndpointActions: LogsEndpointAction['EndpointActions'] & {
|
||||
'@timestamp': undefined;
|
||||
user_id: undefined;
|
||||
};
|
||||
} = {
|
||||
EndpointActions: {
|
||||
...action,
|
||||
'@timestamp': undefined,
|
||||
user_id: undefined,
|
||||
},
|
||||
agent: {
|
||||
id: [agentId],
|
||||
},
|
||||
'@timestamp': action['@timestamp'],
|
||||
user: {
|
||||
id: action.user_id,
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
indexFleetActions,
|
||||
esClient
|
||||
.index({
|
||||
index: ENDPOINT_ACTIONS_INDEX,
|
||||
body: endpointActionsBody,
|
||||
refresh: 'wait_for',
|
||||
})
|
||||
.catch(wrapErrorAndRejectPromise),
|
||||
]);
|
||||
} else {
|
||||
await indexFleetActions;
|
||||
}
|
||||
await Promise.all([
|
||||
indexFleetActions,
|
||||
esClient
|
||||
.index({
|
||||
index: ENDPOINT_ACTIONS_INDEX,
|
||||
body: endpointActionsBody,
|
||||
refresh: 'wait_for',
|
||||
})
|
||||
.catch(wrapErrorAndRejectPromise),
|
||||
]);
|
||||
|
||||
const randomFloat = fleetActionGenerator.randomFloat();
|
||||
// Create an action response for the above
|
||||
|
@ -114,12 +115,12 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
agent_id: agentId,
|
||||
action_response: {
|
||||
endpoint: {
|
||||
// add ack to 2/5th of fleet response
|
||||
ack: randomFloat < 0.4 ? true : undefined,
|
||||
// add ack to 4/5th of fleet response
|
||||
ack: randomFloat < 0.8 ? true : undefined,
|
||||
},
|
||||
},
|
||||
// error for 3/10th of responses
|
||||
error: randomFloat < 0.3 ? 'some error happened' : undefined,
|
||||
// error for 1/10th of responses
|
||||
error: randomFloat < 0.1 ? 'some error happened' : undefined,
|
||||
});
|
||||
|
||||
const indexFleetResponses = esClient
|
||||
|
@ -133,7 +134,8 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
)
|
||||
.catch(wrapErrorAndRejectPromise);
|
||||
|
||||
if (randomFloat < 0.4) {
|
||||
// 70% has endpoint response
|
||||
if (randomFloat < 0.7) {
|
||||
const endpointActionResponseBody = {
|
||||
EndpointActions: {
|
||||
...actionResponse,
|
||||
|
@ -146,13 +148,13 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
agent: {
|
||||
id: agentId,
|
||||
},
|
||||
// error for 3/10th of responses
|
||||
// error for 1/10th of responses
|
||||
error:
|
||||
randomFloat < 0.3
|
||||
? undefined
|
||||
: {
|
||||
randomFloat < 0.1
|
||||
? {
|
||||
message: actionResponse.error,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
'@timestamp': actionResponse['@timestamp'],
|
||||
};
|
||||
|
||||
|
@ -167,6 +169,7 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
.catch(wrapErrorAndRejectPromise),
|
||||
]);
|
||||
} else {
|
||||
// 30% has only fleet response
|
||||
await indexFleetResponses;
|
||||
}
|
||||
|
||||
|
@ -174,24 +177,23 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
response.actionResponses.push(actionResponse);
|
||||
}
|
||||
|
||||
// Add edge cases (maybe)
|
||||
// Add edge case fleet actions (maybe)
|
||||
if (fleetActionGenerator.randomFloat() < 0.3) {
|
||||
const randomFloat = fleetActionGenerator.randomFloat();
|
||||
|
||||
// 60% of the time just add either an Isolate -OR- an UnIsolate action
|
||||
if (randomFloat < 0.6) {
|
||||
const actionStartedAt = {
|
||||
'@timestamp': new Date().toISOString(),
|
||||
};
|
||||
// 70% of the time just add either an Isolate -OR- an UnIsolate action
|
||||
if (randomFloat < 0.7) {
|
||||
let action: EndpointAction;
|
||||
|
||||
if (randomFloat < 0.3) {
|
||||
// add a pending isolation
|
||||
action = fleetActionGenerator.generateIsolateAction({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
});
|
||||
action = fleetActionGenerator.generateIsolateAction(actionStartedAt);
|
||||
} else {
|
||||
// add a pending UN-isolation
|
||||
action = fleetActionGenerator.generateUnIsolateAction({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
});
|
||||
action = fleetActionGenerator.generateUnIsolateAction(actionStartedAt);
|
||||
}
|
||||
|
||||
action.agents = [agentId];
|
||||
|
@ -209,13 +211,9 @@ export const indexEndpointAndFleetActionsForHost = async (
|
|||
|
||||
response.actions.push(action);
|
||||
} else {
|
||||
// Else (40% of the time) add a pending isolate AND pending un-isolate
|
||||
const action1 = fleetActionGenerator.generateIsolateAction({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
});
|
||||
const action2 = fleetActionGenerator.generateUnIsolateAction({
|
||||
'@timestamp': new Date().toISOString(),
|
||||
});
|
||||
// Else (30% of the time) add a pending isolate AND pending un-isolate
|
||||
const action1 = fleetActionGenerator.generateIsolateAction(actionStartedAt);
|
||||
const action2 = fleetActionGenerator.generateUnIsolateAction(actionStartedAt);
|
||||
|
||||
action1.agents = [agentId];
|
||||
action2.agents = [agentId];
|
||||
|
|
|
@ -27,10 +27,10 @@ describe('actions schemas', () => {
|
|||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not accept an agent ID if not in an array', () => {
|
||||
it('should accept an agent ID if not in an array', () => {
|
||||
expect(() => {
|
||||
EndpointActionListRequestSchema.query.validate({ agentIds: uuid.v4() });
|
||||
}).toThrow();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept an agent ID in an array', () => {
|
||||
|
@ -68,9 +68,21 @@ describe('actions schemas', () => {
|
|||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not work without allowed page and pageSize params', () => {
|
||||
it('should not work with invalid value for `page` query param', () => {
|
||||
expect(() => {
|
||||
EndpointActionListRequestSchema.query.validate({ pageSize: 101 });
|
||||
EndpointActionListRequestSchema.query.validate({ page: -1 });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
EndpointActionListRequestSchema.query.validate({ page: 0 });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not work with invalid value for `pageSize` query param', () => {
|
||||
expect(() => {
|
||||
EndpointActionListRequestSchema.query.validate({ pageSize: 100001 });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
EndpointActionListRequestSchema.query.validate({ pageSize: 0 });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../constants';
|
||||
|
||||
const BaseActionRequestSchema = {
|
||||
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
|
||||
|
@ -70,14 +71,29 @@ export const ActionDetailsRequestSchema = {
|
|||
export const EndpointActionListRequestSchema = {
|
||||
query: schema.object({
|
||||
agentIds: schema.maybe(
|
||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 })
|
||||
schema.oneOf([
|
||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }),
|
||||
schema.string({ minLength: 1 }),
|
||||
])
|
||||
),
|
||||
commands: schema.maybe(
|
||||
schema.oneOf([
|
||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||
schema.string({ minLength: 1 }),
|
||||
])
|
||||
),
|
||||
commands: schema.maybe(schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })),
|
||||
page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })),
|
||||
pageSize: schema.maybe(schema.number({ defaultValue: 10, min: 1, max: 100 })),
|
||||
pageSize: schema.maybe(
|
||||
schema.number({ defaultValue: ENDPOINT_DEFAULT_PAGE_SIZE, min: 1, max: 10000 })
|
||||
),
|
||||
startDate: schema.maybe(schema.string()), // date ISO strings or moment date
|
||||
endDate: schema.maybe(schema.string()), // date ISO strings or moment date
|
||||
userIds: schema.maybe(schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })),
|
||||
userIds: schema.maybe(
|
||||
schema.oneOf([
|
||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||
schema.string({ minLength: 1 }),
|
||||
])
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
GETTING_STARTED,
|
||||
DASHBOARDS,
|
||||
CREATE_NEW_RULE,
|
||||
RESPONSE_ACTIONS,
|
||||
} from '../translations';
|
||||
import {
|
||||
OVERVIEW_PATH,
|
||||
|
@ -60,6 +61,7 @@ import {
|
|||
USERS_PATH,
|
||||
KUBERNETES_PATH,
|
||||
RULES_CREATE_PATH,
|
||||
RESPONSE_ACTIONS_PATH,
|
||||
} from '../../../common/constants';
|
||||
import { ExperimentalFeatures } from '../../../common/experimental_features';
|
||||
import { subscribeAppLinks } from '../../common/links';
|
||||
|
@ -469,6 +471,11 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
|
|||
title: BLOCKLIST,
|
||||
path: BLOCKLIST_PATH,
|
||||
},
|
||||
{
|
||||
id: SecurityPageName.responseActions,
|
||||
title: RESPONSE_ACTIONS,
|
||||
path: RESPONSE_ACTIONS_PATH,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
APP_USERS_PATH,
|
||||
APP_KUBERNETES_PATH,
|
||||
APP_LANDING_PATH,
|
||||
APP_RESPONSE_ACTIONS_PATH,
|
||||
} from '../../../common/constants';
|
||||
|
||||
export const navTabs: SecurityNav = {
|
||||
|
@ -168,6 +169,13 @@ export const navTabs: SecurityNav = {
|
|||
disabled: false,
|
||||
urlKey: 'administration',
|
||||
},
|
||||
[SecurityPageName.responseActions]: {
|
||||
id: SecurityPageName.responseActions,
|
||||
name: i18n.RESPONSE_ACTIONS,
|
||||
href: APP_RESPONSE_ACTIONS_PATH,
|
||||
disabled: false,
|
||||
urlKey: 'administration',
|
||||
},
|
||||
};
|
||||
|
||||
export const securityNavGroup: SecurityNavGroup = {
|
||||
|
|
|
@ -110,6 +110,13 @@ export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.block
|
|||
defaultMessage: 'Blocklist',
|
||||
});
|
||||
|
||||
export const RESPONSE_ACTIONS = i18n.translate(
|
||||
'xpack.securitySolution.navigation.responseActions',
|
||||
{
|
||||
defaultMessage: 'Response Actions',
|
||||
}
|
||||
);
|
||||
|
||||
export const CREATE_NEW_RULE = i18n.translate('xpack.securitySolution.navigation.newRuleTitle', {
|
||||
defaultMessage: 'Create new rule',
|
||||
});
|
||||
|
|
|
@ -58,6 +58,7 @@ export const securityNavKeys = [
|
|||
SecurityPageName.hosts,
|
||||
SecurityPageName.network,
|
||||
SecurityPageName.overview,
|
||||
SecurityPageName.responseActions,
|
||||
SecurityPageName.rules,
|
||||
SecurityPageName.timelines,
|
||||
SecurityPageName.trustedApps,
|
||||
|
|
|
@ -9,7 +9,7 @@ import { ChromeBreadcrumb } from '@kbn/core/public';
|
|||
import { AdministrationSubTab } from '../types';
|
||||
import { ENDPOINTS_TAB, EVENT_FILTERS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from './translations';
|
||||
import { AdministrationRouteSpyState } from '../../common/utils/route/types';
|
||||
import { HOST_ISOLATION_EXCEPTIONS, BLOCKLIST } from '../../app/translations';
|
||||
import { HOST_ISOLATION_EXCEPTIONS, BLOCKLIST, RESPONSE_ACTIONS } from '../../app/translations';
|
||||
|
||||
const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = {
|
||||
[AdministrationSubTab.endpoints]: ENDPOINTS_TAB,
|
||||
|
@ -18,6 +18,7 @@ const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = {
|
|||
[AdministrationSubTab.eventFilters]: EVENT_FILTERS_TAB,
|
||||
[AdministrationSubTab.hostIsolationExceptions]: HOST_ISOLATION_EXCEPTIONS,
|
||||
[AdministrationSubTab.blocklist]: BLOCKLIST,
|
||||
[AdministrationSubTab.responseActions]: RESPONSE_ACTIONS,
|
||||
};
|
||||
|
||||
export function getTrailingBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] {
|
||||
|
|
|
@ -22,6 +22,7 @@ export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName
|
|||
export const MANAGEMENT_ROUTING_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.eventFilters})`;
|
||||
export const MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.hostIsolationExceptions})`;
|
||||
export const MANAGEMENT_ROUTING_BLOCKLIST_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.blocklist})`;
|
||||
export const MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.responseActions})`;
|
||||
|
||||
// --[ STORE ]---------------------------------------------------------------------------
|
||||
/** The SIEM global store namespace where the management state will be mounted */
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
*/
|
||||
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { EuiButton, EuiFlyout } from '@elastic/eui';
|
||||
import { EuiButton, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EndpointResponderExtensionComponentProps } from './types';
|
||||
import { ResponseActionsList } from '../../pages/response_actions/view/response_actions_list';
|
||||
import { UX_MESSAGES } from '../../pages/response_actions/translations';
|
||||
|
||||
export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((props) => {
|
||||
const [showActionLogFlyout, setShowActionLogFlyout] = useState<boolean>(false);
|
||||
|
@ -27,7 +29,16 @@ export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((p
|
|||
/>
|
||||
</EuiButton>
|
||||
{showActionLogFlyout && (
|
||||
<EuiFlyout onClose={toggleActionLog}>{'TODO: flyout content will go here'}</EuiFlyout>
|
||||
<EuiFlyout onClose={toggleActionLog} size="l">
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle size="s">
|
||||
<h2>{UX_MESSAGES.flyoutTitle(props.meta.endpoint.host.hostname)}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<ResponseActionsList hideHeader agentIds={props.meta.endpoint.agent.id} />
|
||||
</EuiFlyoutBody>
|
||||
</EuiFlyout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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 {
|
||||
AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
ReactQueryHookRenderer,
|
||||
} from '../../../common/mock/endpoint';
|
||||
import { useGetEndpointActionList } from './use_get_endpoint_action_list';
|
||||
import { ENDPOINTS_ACTION_LIST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { useQuery as _useQuery } from 'react-query';
|
||||
import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks';
|
||||
|
||||
const useQueryMock = _useQuery as jest.Mock;
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actualReactQueryModule = jest.requireActual('react-query');
|
||||
|
||||
return {
|
||||
...actualReactQueryModule,
|
||||
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useGetEndpointActionList hook', () => {
|
||||
let renderReactQueryHook: ReactQueryHookRenderer<
|
||||
Parameters<typeof useGetEndpointActionList>,
|
||||
ReturnType<typeof useGetEndpointActionList>
|
||||
>;
|
||||
let http: AppContextTestRender['coreStart']['http'];
|
||||
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testContext = createAppRootMockRenderer();
|
||||
|
||||
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
|
||||
http = testContext.coreStart.http;
|
||||
|
||||
apiMocks = responseActionsHttpMocks(http);
|
||||
});
|
||||
|
||||
it('should call the proper API', async () => {
|
||||
await renderReactQueryHook(() =>
|
||||
useGetEndpointActionList({
|
||||
agentIds: ['123', '456'],
|
||||
userIds: ['elastic', 'citsale'],
|
||||
commands: ['isolate', 'unisolate'],
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
startDate: 'now-5d',
|
||||
endDate: 'now',
|
||||
})
|
||||
);
|
||||
|
||||
expect(apiMocks.responseProvider.actionList).toHaveBeenCalledWith({
|
||||
path: `${ENDPOINTS_ACTION_LIST_ROUTE}`,
|
||||
query: {
|
||||
agentIds: ['123', '456'],
|
||||
commands: ['isolate', 'unisolate'],
|
||||
endDate: 'now',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
startDate: 'now-5d',
|
||||
userIds: ['elastic', 'citsale'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow custom options to be used', async () => {
|
||||
await renderReactQueryHook(
|
||||
() =>
|
||||
useGetEndpointActionList(
|
||||
{},
|
||||
{
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
}
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 type { UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import type { HttpFetchError } from '@kbn/core/public';
|
||||
import { useQuery } from 'react-query';
|
||||
import { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions';
|
||||
import { useHttp } from '../../../common/lib/kibana';
|
||||
import { ENDPOINTS_ACTION_LIST_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import type { ActionListApiResponse } from '../../../../common/endpoint/types';
|
||||
|
||||
export const useGetEndpointActionList = (
|
||||
query: EndpointActionListRequestQuery,
|
||||
options: UseQueryOptions<ActionListApiResponse, HttpFetchError> = {}
|
||||
): UseQueryResult<ActionListApiResponse, HttpFetchError> => {
|
||||
const http = useHttp();
|
||||
|
||||
return useQuery<ActionListApiResponse, HttpFetchError>({
|
||||
queryKey: ['get-action-list', query],
|
||||
...options,
|
||||
queryFn: async () => {
|
||||
return http.get<ActionListApiResponse>(ENDPOINTS_ACTION_LIST_ROUTE, {
|
||||
query: {
|
||||
agentIds: query.agentIds,
|
||||
commands: query.commands,
|
||||
endDate: query.endDate,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
startDate: query.startDate,
|
||||
userIds: query.userIds,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export { useGetEndpointDetails } from './endpoint/use_get_endpoint_details';
|
||||
export { useWithShowEndpointResponder } from './endpoint/use_with_show_endpoint_responder';
|
||||
export { useGetEndpointActionList } from './endpoint/use_get_endpoint_action_list';
|
||||
|
|
|
@ -10,6 +10,7 @@ import { EndpointActionGenerator } from '../../../common/endpoint/data_generator
|
|||
import {
|
||||
ACTION_DETAILS_ROUTE,
|
||||
ACTION_STATUS_ROUTE,
|
||||
ENDPOINTS_ACTION_LIST_ROUTE,
|
||||
ISOLATE_HOST_ROUTE,
|
||||
UNISOLATE_HOST_ROUTE,
|
||||
} from '../../../common/endpoint/constants';
|
||||
|
@ -19,6 +20,7 @@ import {
|
|||
} from '../../common/mock/endpoint/http_handler_mock_factory';
|
||||
import {
|
||||
ActionDetailsApiResponse,
|
||||
ActionListApiResponse,
|
||||
HostIsolationResponse,
|
||||
PendingActionsResponse,
|
||||
} from '../../../common/endpoint/types';
|
||||
|
@ -30,6 +32,8 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
|
|||
|
||||
actionDetails: (options: HttpFetchOptionsWithPath) => ActionDetailsApiResponse;
|
||||
|
||||
actionList: (options: HttpFetchOptionsWithPath) => ActionListApiResponse;
|
||||
|
||||
agentPendingActionsSummary: (options: HttpFetchOptionsWithPath) => PendingActionsResponse;
|
||||
}>;
|
||||
|
||||
|
@ -63,6 +67,26 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt
|
|||
return { data: response };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actionList',
|
||||
path: ENDPOINTS_ACTION_LIST_ROUTE,
|
||||
method: 'get',
|
||||
handler: (): ActionListApiResponse => {
|
||||
const response = new EndpointActionGenerator('seed').generateActionDetails();
|
||||
|
||||
return {
|
||||
elasticAgentIds: ['agent-a'],
|
||||
commands: ['isolate'],
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
startDate: 'now-10d',
|
||||
endDate: 'now',
|
||||
data: [response],
|
||||
userIds: ['elastic'],
|
||||
total: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'agentPendingActionsSummary',
|
||||
path: ACTION_STATUS_ROUTE,
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
MANAGEMENT_ROUTING_POLICIES_PATH,
|
||||
MANAGEMENT_ROUTING_TRUSTED_APPS_PATH,
|
||||
MANAGEMENT_ROUTING_BLOCKLIST_PATH,
|
||||
MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH,
|
||||
} from '../common/constants';
|
||||
import { NotFoundPage } from '../../app/404';
|
||||
import { EndpointsContainer } from './endpoint_hosts';
|
||||
|
@ -30,6 +31,7 @@ import { useUserPrivileges } from '../../common/components/user_privileges';
|
|||
import { HostIsolationExceptionsContainer } from './host_isolation_exceptions';
|
||||
import { BlocklistContainer } from './blocklist';
|
||||
import { NoPermissions } from '../components/no_permissons';
|
||||
import { ResponseActionsContainer } from './response_actions';
|
||||
|
||||
const EndpointTelemetry = () => (
|
||||
<TrackApplicationView viewId={SecurityPageName.endpoints}>
|
||||
|
@ -62,6 +64,12 @@ const HostIsolationExceptionsTelemetry = () => (
|
|||
</TrackApplicationView>
|
||||
);
|
||||
|
||||
const ResponseActionsTelemetry = () => (
|
||||
<TrackApplicationView viewId={SecurityPageName.responseActions}>
|
||||
<ResponseActionsContainer />
|
||||
</TrackApplicationView>
|
||||
);
|
||||
|
||||
export const ManagementContainer = memo(() => {
|
||||
const { loading, canAccessEndpointManagement } = useUserPrivileges().endpointPrivileges;
|
||||
|
||||
|
@ -90,6 +98,7 @@ export const ManagementContainer = memo(() => {
|
|||
component={HostIsolationExceptionsTelemetry}
|
||||
/>
|
||||
<Route path={MANAGEMENT_ROUTING_BLOCKLIST_PATH} component={BlocklistContainer} />
|
||||
<Route path={MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH} component={ResponseActionsTelemetry} />
|
||||
<Route path={MANAGEMENT_PATH} exact>
|
||||
<Redirect to={getEndpointListPath({ name: 'endpointList' })} />
|
||||
</Route>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 { Switch } from 'react-router-dom';
|
||||
import { Route } from '@kbn/kibana-react-plugin/public';
|
||||
import React, { memo } from 'react';
|
||||
import { MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH } from '../../common/constants';
|
||||
import { NotFoundPage } from '../../../app/404';
|
||||
import { ResponseActionsList } from './view/response_actions_list';
|
||||
|
||||
export const ResponseActionsContainer = memo(() => {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH}
|
||||
exact
|
||||
component={ResponseActionsList}
|
||||
/>
|
||||
<Route path="*" component={NotFoundPage} />
|
||||
</Switch>
|
||||
);
|
||||
});
|
||||
|
||||
ResponseActionsContainer.displayName = 'ResponseActionsContainer';
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const OUTPUT_MESSAGES = Object.freeze({
|
||||
hasExpired: (command: string) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.list.item.hasExpired', {
|
||||
defaultMessage: `{command} failed: action expired`,
|
||||
values: { command },
|
||||
}),
|
||||
wasSuccessful: (command: string) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.list.item.wasSuccessful', {
|
||||
defaultMessage: `{command} completed successfully`,
|
||||
values: { command },
|
||||
}),
|
||||
isPending: (command: string) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.list.item.isPending', {
|
||||
defaultMessage: `{command} is pending`,
|
||||
values: { command },
|
||||
}),
|
||||
hasFailed: (command: string) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.list.item.hasFailed', {
|
||||
defaultMessage: `{command} failed`,
|
||||
values: { command },
|
||||
}),
|
||||
expandSection: {
|
||||
placedAt: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.placedAt',
|
||||
{
|
||||
defaultMessage: 'Command placed',
|
||||
}
|
||||
),
|
||||
input: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.input',
|
||||
{
|
||||
defaultMessage: 'Input',
|
||||
}
|
||||
),
|
||||
output: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.output',
|
||||
{
|
||||
defaultMessage: 'Output',
|
||||
}
|
||||
),
|
||||
startedAt: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.startedAt',
|
||||
{
|
||||
defaultMessage: 'Execution started on',
|
||||
}
|
||||
),
|
||||
parameters: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.parameters',
|
||||
{
|
||||
defaultMessage: 'Parameters',
|
||||
}
|
||||
),
|
||||
completedAt: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.expandSection.completedAt',
|
||||
{
|
||||
defaultMessage: 'Execution completed',
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export const TABLE_COLUMN_NAMES = Object.freeze({
|
||||
time: i18n.translate('xpack.securitySolution.responseActionsList.list.time', {
|
||||
defaultMessage: 'Time',
|
||||
}),
|
||||
command: i18n.translate('xpack.securitySolution.responseActionsList.list.command', {
|
||||
defaultMessage: 'Command/action',
|
||||
}),
|
||||
user: i18n.translate('xpack.securitySolution.responseActionsList.list.user', {
|
||||
defaultMessage: 'User',
|
||||
}),
|
||||
host: i18n.translate('xpack.securitySolution.responseActionsList.list.host', {
|
||||
defaultMessage: 'Host',
|
||||
}),
|
||||
comments: i18n.translate('xpack.securitySolution.responseActionsList.list.comments', {
|
||||
defaultMessage: 'Comments',
|
||||
}),
|
||||
status: i18n.translate('xpack.securitySolution.responseActionsList.list.status', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
});
|
||||
|
||||
export const UX_MESSAGES = Object.freeze({
|
||||
flyoutTitle: (hostname: string) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.flyout.title', {
|
||||
defaultMessage: `Action log - {hostname} `,
|
||||
values: { hostname },
|
||||
}),
|
||||
pageTitle: i18n.translate('xpack.securitySolution.responseActionsList.list.title', {
|
||||
defaultMessage: 'Response actions',
|
||||
}),
|
||||
fetchError: i18n.translate('xpack.securitySolution.responseActionsList.list.errorMessage', {
|
||||
defaultMessage: 'Error while retrieving response actions',
|
||||
}),
|
||||
badge: {
|
||||
completed: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.item.badge.completed',
|
||||
{
|
||||
defaultMessage: 'Completed',
|
||||
}
|
||||
),
|
||||
failed: i18n.translate('xpack.securitySolution.responseActionsList.list.item.badge.failed', {
|
||||
defaultMessage: 'Failed',
|
||||
}),
|
||||
pending: i18n.translate('xpack.securitySolution.responseActionsList.list.item.badge.pending', {
|
||||
defaultMessage: 'Pending',
|
||||
}),
|
||||
},
|
||||
screenReaderExpand: i18n.translate(
|
||||
'xpack.securitySolution.responseActionsList.list.screenReader.expand',
|
||||
{
|
||||
defaultMessage: 'Expand rows',
|
||||
}
|
||||
),
|
||||
recordsLabel: (totalItemCount: number) =>
|
||||
i18n.translate('xpack.securitySolution.responseActionsList.list.recordRangeLabel', {
|
||||
defaultMessage: '{records, plural, one {response action} other {response actions}}',
|
||||
values: {
|
||||
records: totalItemCount,
|
||||
},
|
||||
}),
|
||||
});
|
|
@ -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 React, { memo, useState, useMemo } from 'react';
|
||||
import dateMath from '@kbn/datemath';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker } from '@elastic/eui';
|
||||
import { euiStyled } from '@kbn/kibana-react-plugin/common';
|
||||
import type { EuiSuperDatePickerRecentRange } from '@elastic/eui';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { DurationRange, OnRefreshChangeProps } from '@elastic/eui/src/components/date_picker/types';
|
||||
import { useUiSetting$ } from '../../../../../common/lib/kibana';
|
||||
import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/constants';
|
||||
|
||||
export interface DateRangePickerValues {
|
||||
autoRefreshOptions: {
|
||||
enabled: boolean;
|
||||
duration: number;
|
||||
};
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[];
|
||||
}
|
||||
interface Range {
|
||||
from: string;
|
||||
to: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
const DatePickerWrapper = euiStyled.div`
|
||||
width: ${(props) => props.theme.eui.fractions.single.percentage};
|
||||
`;
|
||||
const StickyFlexItem = euiStyled(EuiFlexItem).attrs({ grow: false })`
|
||||
background: ${(props) => `${props.theme.eui.euiHeaderBackgroundColor}`};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: ${(props) => `${props.theme.eui.euiSizeL}`};
|
||||
padding-left: ${(props) => `${props.theme.eui.euiSizeM}`};
|
||||
`;
|
||||
|
||||
export const ActionListDateRangePicker = memo(
|
||||
({
|
||||
dateRangePickerState,
|
||||
isDataLoading,
|
||||
onRefresh,
|
||||
onRefreshChange,
|
||||
onTimeChange,
|
||||
}: {
|
||||
dateRangePickerState: DateRangePickerValues;
|
||||
isDataLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onRefreshChange: (evt: OnRefreshChangeProps) => void;
|
||||
onTimeChange: ({ start, end }: DurationRange) => void;
|
||||
}) => {
|
||||
const { uiSettings } = useKibana().services;
|
||||
const [quickRanges] = useUiSetting$<Range[]>(DEFAULT_TIMEPICKER_QUICK_RANGES);
|
||||
const [dateFormat] = useState(() => uiSettings?.get('dateFormat'));
|
||||
const commonlyUsedRanges = !quickRanges.length
|
||||
? []
|
||||
: quickRanges.map(({ from, to, display }) => ({
|
||||
start: from,
|
||||
end: to,
|
||||
label: display,
|
||||
}));
|
||||
|
||||
const end = useMemo(
|
||||
() =>
|
||||
dateRangePickerState.endDate
|
||||
? dateMath.parse(dateRangePickerState.endDate)?.toISOString()
|
||||
: undefined,
|
||||
[dateRangePickerState]
|
||||
);
|
||||
|
||||
const start = useMemo(
|
||||
() =>
|
||||
dateRangePickerState.startDate
|
||||
? dateMath.parse(dateRangePickerState.startDate)?.toISOString()
|
||||
: undefined,
|
||||
[dateRangePickerState]
|
||||
);
|
||||
|
||||
return (
|
||||
<StickyFlexItem>
|
||||
<EuiFlexGroup justifyContent="flexStart" responsive>
|
||||
<DatePickerWrapper data-test-subj="actionListSuperDatePicker">
|
||||
<EuiFlexItem>
|
||||
<EuiSuperDatePicker
|
||||
updateButtonProps={{ iconOnly: true, fill: false }}
|
||||
isLoading={isDataLoading}
|
||||
dateFormat={dateFormat}
|
||||
commonlyUsedRanges={commonlyUsedRanges}
|
||||
end={end}
|
||||
isPaused={!dateRangePickerState.autoRefreshOptions.enabled}
|
||||
onTimeChange={onTimeChange}
|
||||
onRefreshChange={onRefreshChange}
|
||||
refreshInterval={dateRangePickerState.autoRefreshOptions.duration}
|
||||
onRefresh={onRefresh}
|
||||
recentlyUsedRanges={dateRangePickerState.recentlyUsedDateRanges}
|
||||
start={start}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</DatePickerWrapper>
|
||||
</EuiFlexGroup>
|
||||
</StickyFlexItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionListDateRangePicker.displayName = 'ActionListDateRangePicker';
|
|
@ -0,0 +1,325 @@
|
|||
/*
|
||||
* 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 uuid from 'uuid';
|
||||
import React from 'react';
|
||||
import * as reactTestingLibrary from '@testing-library/react';
|
||||
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
|
||||
import { ResponseActionsList } from './response_actions_list';
|
||||
import { ActionDetails, ActionListApiResponse } from '../../../../../common/endpoint/types';
|
||||
import { useKibana, useUiSetting$ } from '../../../../common/lib/kibana';
|
||||
import { createUseUiSetting$Mock } from '../../../../common/lib/kibana/kibana_react.mock';
|
||||
import { DEFAULT_TIMEPICKER_QUICK_RANGES, MANAGEMENT_PATH } from '../../../../../common/constants';
|
||||
import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
let mockUseGetEndpointActionList: {
|
||||
isFetched?: boolean;
|
||||
isFetching?: boolean;
|
||||
error?: null;
|
||||
data?: ActionListApiResponse;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
jest.mock('../../../hooks/endpoint/use_get_endpoint_action_list', () => {
|
||||
const original = jest.requireActual('../../../hooks/endpoint/use_get_endpoint_action_list');
|
||||
return {
|
||||
...original,
|
||||
useGetEndpointActionList: () => mockUseGetEndpointActionList,
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseUiSetting$ = useUiSetting$ as jest.Mock;
|
||||
const timepickerRanges = [
|
||||
{
|
||||
from: 'now/d',
|
||||
to: 'now/d',
|
||||
display: 'Today',
|
||||
},
|
||||
{
|
||||
from: 'now/w',
|
||||
to: 'now/w',
|
||||
display: 'This week',
|
||||
},
|
||||
{
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
display: 'Last 15 minutes',
|
||||
},
|
||||
{
|
||||
from: 'now-30m',
|
||||
to: 'now',
|
||||
display: 'Last 30 minutes',
|
||||
},
|
||||
{
|
||||
from: 'now-1h',
|
||||
to: 'now',
|
||||
display: 'Last 1 hour',
|
||||
},
|
||||
{
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
display: 'Last 24 hours',
|
||||
},
|
||||
{
|
||||
from: 'now-7d',
|
||||
to: 'now',
|
||||
display: 'Last 7 days',
|
||||
},
|
||||
{
|
||||
from: 'now-30d',
|
||||
to: 'now',
|
||||
display: 'Last 30 days',
|
||||
},
|
||||
{
|
||||
from: 'now-90d',
|
||||
to: 'now',
|
||||
display: 'Last 90 days',
|
||||
},
|
||||
{
|
||||
from: 'now-1y',
|
||||
to: 'now',
|
||||
display: 'Last 1 year',
|
||||
},
|
||||
];
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('Response Actions List', () => {
|
||||
const testPrefix = 'response-actions-list';
|
||||
|
||||
let render: (
|
||||
props: React.ComponentProps<typeof ResponseActionsList>
|
||||
) => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
let history: AppContextTestRender['history'];
|
||||
let mockedContext: AppContextTestRender;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedContext = createAppRootMockRenderer();
|
||||
({ history } = mockedContext);
|
||||
render = (props: React.ComponentProps<typeof ResponseActionsList>) =>
|
||||
(renderResult = mockedContext.render(<ResponseActionsList {...props} />));
|
||||
reactTestingLibrary.act(() => {
|
||||
history.push(`${MANAGEMENT_PATH}/response_actions`);
|
||||
});
|
||||
(useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices });
|
||||
mockUseUiSetting$.mockImplementation((key, defaultValue) => {
|
||||
const useUiSetting$Mock = createUseUiSetting$Mock();
|
||||
|
||||
return key === DEFAULT_TIMEPICKER_QUICK_RANGES
|
||||
? [timepickerRanges, jest.fn()]
|
||||
: useUiSetting$Mock(key, defaultValue);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('', () => {
|
||||
const refetchFunction = jest.fn();
|
||||
const baseMockedActionList = {
|
||||
isFetched: true,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: refetchFunction,
|
||||
};
|
||||
beforeEach(async () => {
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
data: await getActionListMock({ actionCount: 13 }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Table View', () => {
|
||||
it('should show date filters', () => {
|
||||
render({});
|
||||
expect(renderResult.getByTestId('actionListSuperDatePicker')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty state when there is no data', async () => {
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
data: await getActionListMock({ actionCount: 0 }),
|
||||
};
|
||||
render({});
|
||||
expect(renderResult.getByTestId(`${testPrefix}-empty-prompt`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show table when there is data', async () => {
|
||||
render({});
|
||||
expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy();
|
||||
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
|
||||
'Showing 1-10 of 13 response actions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show expected column names on the table', async () => {
|
||||
render({});
|
||||
expect(
|
||||
Array.from(
|
||||
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((col) => col.textContent)
|
||||
).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']);
|
||||
});
|
||||
|
||||
it('should paginate table when there is data', async () => {
|
||||
render({});
|
||||
|
||||
expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy();
|
||||
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
|
||||
'Showing 1-10 of 13 response actions'
|
||||
);
|
||||
|
||||
const page2 = renderResult.getByTestId('pagination-button-1');
|
||||
userEvent.click(page2);
|
||||
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
|
||||
'Showing 11-13 of 13 response actions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show 1-1 record label when only 1 record', async () => {
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
data: await getActionListMock({ actionCount: 1 }),
|
||||
};
|
||||
render({});
|
||||
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
|
||||
'Showing 1-1 of 1 response action'
|
||||
);
|
||||
});
|
||||
|
||||
it('should expand each row to show details', async () => {
|
||||
render({});
|
||||
|
||||
const expandButtons = renderResult.getAllByTestId(`${testPrefix}-expand-button`);
|
||||
expandButtons.map((button) => userEvent.click(button));
|
||||
const trays = renderResult.getAllByTestId(`${testPrefix}-output-section`);
|
||||
expect(trays).toBeTruthy();
|
||||
expect(trays.length).toEqual(13);
|
||||
|
||||
expandButtons.map((button) => userEvent.click(button));
|
||||
const noTrays = renderResult.queryAllByTestId(`${testPrefix}-output-section`);
|
||||
expect(noTrays).toEqual([]);
|
||||
});
|
||||
|
||||
it('should refresh data when autoRefresh is toggled on', async () => {
|
||||
render({});
|
||||
const quickMenu = renderResult.getByTestId('superDatePickerToggleQuickMenuButton');
|
||||
userEvent.click(quickMenu);
|
||||
|
||||
const toggle = renderResult.getByTestId('superDatePickerToggleRefreshButton');
|
||||
const intervalInput = renderResult.getByTestId('superDatePickerRefreshIntervalInput');
|
||||
|
||||
userEvent.click(toggle);
|
||||
reactTestingLibrary.fireEvent.change(intervalInput, { target: { value: 1 } });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
expect(refetchFunction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Without agentIds filter', () => {
|
||||
it('should show a host column', async () => {
|
||||
render({});
|
||||
expect(renderResult.getByTestId(`tableHeaderCell_agents_3`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('With agentIds filter', () => {
|
||||
it('should NOT show a host column when a single agentId', async () => {
|
||||
const agentIds = uuid.v4();
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
data: await getActionListMock({ actionCount: 2, agentIds: [agentIds] }),
|
||||
};
|
||||
render({ agentIds });
|
||||
expect(
|
||||
Array.from(
|
||||
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((col) => col.textContent)
|
||||
).toEqual(['Time', 'Command/action', 'User', 'Comments', 'Status']);
|
||||
});
|
||||
|
||||
it('should show a host column when multiple agentIds', async () => {
|
||||
const agentIds = [uuid.v4(), uuid.v4()];
|
||||
mockUseGetEndpointActionList = {
|
||||
...baseMockedActionList,
|
||||
data: await getActionListMock({ actionCount: 2, agentIds }),
|
||||
};
|
||||
render({ agentIds });
|
||||
expect(
|
||||
Array.from(
|
||||
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((col) => col.textContent)
|
||||
).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// mock API response
|
||||
const getActionListMock = async ({
|
||||
agentIds: _agentIds,
|
||||
commands,
|
||||
actionCount = 0,
|
||||
endDate,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
startDate,
|
||||
userIds,
|
||||
}: {
|
||||
agentIds?: string[];
|
||||
commands?: string[];
|
||||
actionCount?: number;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startDate?: string;
|
||||
userIds?: string[];
|
||||
}): Promise<ActionListApiResponse> => {
|
||||
const endpointActionGenerator = new EndpointActionGenerator('seed');
|
||||
|
||||
const agentIds = _agentIds ?? [uuid.v4()];
|
||||
|
||||
const data: ActionDetails[] = agentIds.map((id) => {
|
||||
const actionIds = Array(actionCount)
|
||||
.fill(1)
|
||||
.map(() => uuid.v4());
|
||||
|
||||
const actionDetails: ActionDetails[] = actionIds.map((actionId) => {
|
||||
return endpointActionGenerator.generateActionDetails({
|
||||
agents: [id],
|
||||
id: actionId,
|
||||
});
|
||||
});
|
||||
return actionDetails;
|
||||
})[0];
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
elasticAgentIds: agentIds,
|
||||
commands,
|
||||
data,
|
||||
userIds,
|
||||
total: data.length ?? 0,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,632 @@
|
|||
/*
|
||||
* 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 dateMath from '@kbn/datemath';
|
||||
import {
|
||||
CriteriaWithPagination,
|
||||
EuiAvatar,
|
||||
EuiBadge,
|
||||
EuiBasicTable,
|
||||
EuiButtonIcon,
|
||||
EuiDescriptionList,
|
||||
EuiEmptyPrompt,
|
||||
EuiFacetButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiScreenReaderOnly,
|
||||
EuiI18nNumber,
|
||||
EuiText,
|
||||
EuiCodeBlock,
|
||||
EuiToolTip,
|
||||
RIGHT_ALIGNMENT,
|
||||
EuiFlexGrid,
|
||||
} from '@elastic/eui';
|
||||
import { euiStyled, css } from '@kbn/kibana-react-plugin/common';
|
||||
|
||||
import type {
|
||||
DurationRange,
|
||||
OnRefreshChangeProps,
|
||||
} from '@elastic/eui/src/components/date_picker/types';
|
||||
import type { HorizontalAlignment } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
import { FormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { ActionDetails } from '../../../../../common/endpoint/types';
|
||||
import type { EndpointActionListRequestQuery } from '../../../../../common/endpoint/schema/actions';
|
||||
import { AdministrationListPage } from '../../../components/administration_list_page';
|
||||
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
|
||||
import { useGetEndpointActionList } from '../../../hooks';
|
||||
import { OUTPUT_MESSAGES, TABLE_COLUMN_NAMES, UX_MESSAGES } from '../translations';
|
||||
import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
|
||||
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
|
||||
import { ActionListDateRangePicker } from './components/action_list_date_range_picker';
|
||||
import type { DateRangePickerValues } from './components/action_list_date_range_picker';
|
||||
|
||||
const emptyValue = getEmptyValue();
|
||||
const defaultDateRangeOptions = Object.freeze({
|
||||
autoRefreshOptions: {
|
||||
enabled: false,
|
||||
duration: 10000,
|
||||
},
|
||||
startDate: 'now-1d',
|
||||
endDate: 'now',
|
||||
recentlyUsedDateRanges: [],
|
||||
});
|
||||
|
||||
const getCommand = (
|
||||
command: ActionDetails['command']
|
||||
): Exclude<ActionDetails['command'], 'unisolate'> | 'release' =>
|
||||
command === 'unisolate' ? 'release' : command;
|
||||
|
||||
// Truncated usernames
|
||||
const StyledFacetButton = euiStyled(EuiFacetButton)`
|
||||
.euiText {
|
||||
margin-top: 0.38rem;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const customDescriptionListCss = css`
|
||||
dt,
|
||||
dd {
|
||||
color: ${(props) => props.theme.eui.euiColorDarkShade} !important;
|
||||
font-size: ${(props) => props.theme.eui.euiFontSizeXS} !important;
|
||||
}
|
||||
dt {
|
||||
font-weight: ${(props) => props.theme.eui.euiFontWeightSemiBold};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDescriptionList = euiStyled(EuiDescriptionList).attrs({ compressed: true })`
|
||||
${customDescriptionListCss}
|
||||
`;
|
||||
|
||||
// output section styles
|
||||
const topSpacingCss = css`
|
||||
${(props) => `${props.theme.eui.euiCodeBlockPaddingModifiers.paddingMedium} 0`}
|
||||
`;
|
||||
const dashedBorderCss = css`
|
||||
${(props) => `1px dashed ${props.theme.eui.euiColorDisabled}`};
|
||||
`;
|
||||
const StyledDescriptionListOutput = euiStyled(EuiDescriptionList).attrs({ compressed: true })`
|
||||
${customDescriptionListCss}
|
||||
dd {
|
||||
margin: ${topSpacingCss};
|
||||
padding: ${topSpacingCss};
|
||||
border-top: ${dashedBorderCss};
|
||||
border-bottom: ${dashedBorderCss};
|
||||
}
|
||||
`;
|
||||
|
||||
// code block styles
|
||||
const StyledEuiCodeBlock = euiStyled(EuiCodeBlock).attrs({
|
||||
transparentBackground: true,
|
||||
paddingSize: 'none',
|
||||
})`
|
||||
code {
|
||||
color: ${(props) => props.theme.eui.euiColorDarkShade} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ResponseActionsList = memo<
|
||||
Pick<EndpointActionListRequestQuery, 'agentIds' | 'commands' | 'userIds'> & {
|
||||
hideHeader?: boolean;
|
||||
hideHostNameColumn?: boolean;
|
||||
}
|
||||
>(({ agentIds, commands, userIds, hideHeader = false }) => {
|
||||
const getTestId = useTestIdGenerator('response-actions-list');
|
||||
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{
|
||||
[k: ActionDetails['id']]: React.ReactNode;
|
||||
}>({});
|
||||
|
||||
const [queryParams, setQueryParams] = useState<EndpointActionListRequestQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
agentIds,
|
||||
commands,
|
||||
userIds,
|
||||
});
|
||||
|
||||
// date range picker settings
|
||||
const [dateRangePickerState, setDateRangePickerState] =
|
||||
useState<DateRangePickerValues>(defaultDateRangeOptions);
|
||||
|
||||
// initial fetch of list data
|
||||
const {
|
||||
error,
|
||||
data: actionList,
|
||||
isFetching,
|
||||
isFetched,
|
||||
refetch: reFetchEndpointActionList,
|
||||
} = useGetEndpointActionList({
|
||||
...queryParams,
|
||||
startDate: dateRangePickerState.startDate,
|
||||
endDate: dateRangePickerState.endDate,
|
||||
});
|
||||
|
||||
const updateActionListDateRanges = useCallback(
|
||||
({ start, end }) => {
|
||||
setDateRangePickerState((prevState) => ({
|
||||
...prevState,
|
||||
startDate: dateMath.parse(start)?.toISOString(),
|
||||
endDate: dateMath.parse(end)?.toISOString(),
|
||||
}));
|
||||
},
|
||||
[setDateRangePickerState]
|
||||
);
|
||||
|
||||
const updateActionListRecentlyUsedDateRanges = useCallback(
|
||||
(recentlyUsedDateRanges) => {
|
||||
setDateRangePickerState((prevState) => ({
|
||||
...prevState,
|
||||
recentlyUsedDateRanges,
|
||||
}));
|
||||
},
|
||||
[setDateRangePickerState]
|
||||
);
|
||||
|
||||
// update refresh timer
|
||||
const onRefreshChange = useCallback(
|
||||
(evt: OnRefreshChangeProps) => {
|
||||
setDateRangePickerState((prevState) => ({
|
||||
...prevState,
|
||||
autoRefreshOptions: { enabled: !evt.isPaused, duration: evt.refreshInterval },
|
||||
}));
|
||||
},
|
||||
[setDateRangePickerState]
|
||||
);
|
||||
|
||||
// auto refresh data
|
||||
const onRefresh = useCallback(() => {
|
||||
if (dateRangePickerState.autoRefreshOptions.enabled) {
|
||||
reFetchEndpointActionList();
|
||||
}
|
||||
}, [dateRangePickerState.autoRefreshOptions.enabled, reFetchEndpointActionList]);
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
({ start: newStart, end: newEnd }: DurationRange) => {
|
||||
const newRecentlyUsedDateRanges = [
|
||||
{ start: newStart, end: newEnd },
|
||||
...dateRangePickerState.recentlyUsedDateRanges
|
||||
.filter(
|
||||
(recentlyUsedRange: DurationRange) =>
|
||||
!(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd)
|
||||
)
|
||||
.slice(0, 9),
|
||||
];
|
||||
|
||||
// update date ranges
|
||||
updateActionListDateRanges({ start: newStart, end: newEnd });
|
||||
// update recently used date ranges
|
||||
updateActionListRecentlyUsedDateRanges(newRecentlyUsedDateRanges);
|
||||
},
|
||||
[
|
||||
dateRangePickerState.recentlyUsedDateRanges,
|
||||
updateActionListDateRanges,
|
||||
updateActionListRecentlyUsedDateRanges,
|
||||
]
|
||||
);
|
||||
|
||||
// total actions
|
||||
const totalItemCount = useMemo(() => actionList?.total ?? 0, [actionList]);
|
||||
|
||||
// expanded tray contents
|
||||
const toggleDetails = useCallback(
|
||||
(item: ActionDetails) => {
|
||||
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
|
||||
if (itemIdToExpandedRowMapValues[item.id]) {
|
||||
delete itemIdToExpandedRowMapValues[item.id];
|
||||
} else {
|
||||
const {
|
||||
startedAt,
|
||||
completedAt,
|
||||
isCompleted,
|
||||
wasSuccessful,
|
||||
isExpired,
|
||||
command: _command,
|
||||
parameters,
|
||||
} = item;
|
||||
|
||||
const command = getCommand(_command);
|
||||
const descriptionListLeft = [
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.placedAt,
|
||||
description: `${startedAt}`,
|
||||
},
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.input,
|
||||
description: `${command}`,
|
||||
},
|
||||
];
|
||||
|
||||
const descriptionListCenter = [
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.startedAt,
|
||||
description: `${startedAt}`,
|
||||
},
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.parameters,
|
||||
description: parameters ? parameters : emptyValue,
|
||||
},
|
||||
];
|
||||
|
||||
const descriptionListRight = [
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.completedAt,
|
||||
description: `${completedAt ?? emptyValue}`,
|
||||
},
|
||||
];
|
||||
|
||||
const outputList = [
|
||||
{
|
||||
title: OUTPUT_MESSAGES.expandSection.output,
|
||||
description: (
|
||||
// codeblock for output
|
||||
<StyledEuiCodeBlock>
|
||||
{isExpired
|
||||
? OUTPUT_MESSAGES.hasExpired(command)
|
||||
: isCompleted
|
||||
? wasSuccessful
|
||||
? OUTPUT_MESSAGES.wasSuccessful(command)
|
||||
: OUTPUT_MESSAGES.hasFailed(command)
|
||||
: OUTPUT_MESSAGES.isPending(command)}
|
||||
</StyledEuiCodeBlock>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
itemIdToExpandedRowMapValues[item.id] = (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
data-test-subj={getTestId('output-section')}
|
||||
direction="column"
|
||||
style={{ maxHeight: 270, overflowY: 'auto' }}
|
||||
className="eui-yScrollWithShadows"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGrid columns={3}>
|
||||
{[descriptionListLeft, descriptionListCenter, descriptionListRight].map(
|
||||
(_list, i) => {
|
||||
const list = _list.map((l) => {
|
||||
const isParameters = l.title === OUTPUT_MESSAGES.expandSection.parameters;
|
||||
return {
|
||||
title: l.title,
|
||||
description: isParameters ? (
|
||||
// codeblock for parameters
|
||||
<StyledEuiCodeBlock>{l.description}</StyledEuiCodeBlock>
|
||||
) : (
|
||||
l.description
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexItem key={i}>
|
||||
<StyledDescriptionList listItems={list} />
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</EuiFlexGrid>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<StyledDescriptionListOutput listItems={outputList} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
|
||||
},
|
||||
[getTestId, itemIdToExpandedRowMap]
|
||||
);
|
||||
// memoized callback for toggleDetails
|
||||
const onClickCallback = useCallback(
|
||||
(data: ActionDetails) => () => toggleDetails(data),
|
||||
[toggleDetails]
|
||||
);
|
||||
|
||||
// table column
|
||||
const responseActionListColumns = useMemo(() => {
|
||||
const columns = [
|
||||
{
|
||||
field: 'startedAt',
|
||||
name: TABLE_COLUMN_NAMES.time,
|
||||
width: '15%',
|
||||
truncateText: true,
|
||||
render: (startedAt: ActionDetails['startedAt']) => {
|
||||
return (
|
||||
<FormattedDate
|
||||
fieldName={TABLE_COLUMN_NAMES.time}
|
||||
value={startedAt}
|
||||
className="eui-textTruncate"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'command',
|
||||
name: TABLE_COLUMN_NAMES.command,
|
||||
width: '10%',
|
||||
truncateText: true,
|
||||
render: (_command: ActionDetails['command']) => {
|
||||
const command = getCommand(_command);
|
||||
return (
|
||||
<EuiToolTip content={command} anchorClassName="eui-textTruncate">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.responseActionsList.list.item.command"
|
||||
defaultMessage="{command}"
|
||||
values={{ command }}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createdBy',
|
||||
name: TABLE_COLUMN_NAMES.user,
|
||||
width: '14%',
|
||||
truncateText: true,
|
||||
render: (userId: ActionDetails['createdBy']) => {
|
||||
return (
|
||||
<StyledFacetButton
|
||||
icon={
|
||||
<EuiAvatar
|
||||
name={userId}
|
||||
data-test-subj={getTestId('column-user-avatar')}
|
||||
size="s"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiToolTip content={userId} anchorClassName="eui-textTruncate">
|
||||
<EuiText
|
||||
size="s"
|
||||
className="eui-textTruncate eui-fullWidth"
|
||||
data-test-subj={getTestId('column-user-name')}
|
||||
>
|
||||
{userId}
|
||||
</EuiText>
|
||||
</EuiToolTip>
|
||||
</StyledFacetButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
// conditional hostname column
|
||||
{
|
||||
field: 'agents',
|
||||
name: TABLE_COLUMN_NAMES.host,
|
||||
width: '20%',
|
||||
truncateText: true,
|
||||
render: (agents: ActionDetails['agents']) => {
|
||||
// TODO: compute host names later with hostMetadata? (using agent Ids for now)
|
||||
const hostname = agents?.[0] ?? '';
|
||||
return (
|
||||
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
|
||||
<EuiText
|
||||
size="s"
|
||||
className="eui-textTruncate eui-fullWidth"
|
||||
data-test-subj={getTestId('column-hostname')}
|
||||
>
|
||||
{hostname}
|
||||
</EuiText>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'comment',
|
||||
name: TABLE_COLUMN_NAMES.comments,
|
||||
width: '30%',
|
||||
truncateText: true,
|
||||
render: (comment: ActionDetails['comment']) => {
|
||||
return (
|
||||
<EuiToolTip content={comment} anchorClassName="eui-textTruncate">
|
||||
<EuiText
|
||||
size="s"
|
||||
className="eui-textTruncate eui-fullWidth"
|
||||
data-test-subj={getTestId('column-comments')}
|
||||
>
|
||||
{comment ?? emptyValue}
|
||||
</EuiText>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'isCompleted',
|
||||
name: TABLE_COLUMN_NAMES.status,
|
||||
width: '10%',
|
||||
render: (isCompleted: ActionDetails['isCompleted'], data: ActionDetails) => {
|
||||
const status = data.isExpired
|
||||
? UX_MESSAGES.badge.failed
|
||||
: isCompleted
|
||||
? data.wasSuccessful
|
||||
? UX_MESSAGES.badge.completed
|
||||
: UX_MESSAGES.badge.failed
|
||||
: UX_MESSAGES.badge.pending;
|
||||
|
||||
return (
|
||||
<EuiToolTip content={status} anchorClassName="eui-textTruncate">
|
||||
<EuiBadge
|
||||
data-test-subj={getTestId('column-status')}
|
||||
color={
|
||||
data.isExpired
|
||||
? 'danger'
|
||||
: isCompleted
|
||||
? data.wasSuccessful
|
||||
? 'success'
|
||||
: 'danger'
|
||||
: 'warning'
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.responseActionsList.list.item.status"
|
||||
defaultMessage="{status}"
|
||||
values={{ status }}
|
||||
/>
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: '',
|
||||
align: RIGHT_ALIGNMENT as HorizontalAlignment,
|
||||
width: '40px',
|
||||
isExpander: true,
|
||||
name: (
|
||||
<EuiScreenReaderOnly>
|
||||
<span>{UX_MESSAGES.screenReaderExpand}</span>
|
||||
</EuiScreenReaderOnly>
|
||||
),
|
||||
render: (data: ActionDetails) => {
|
||||
return (
|
||||
<EuiButtonIcon
|
||||
data-test-subj={getTestId('expand-button')}
|
||||
onClick={onClickCallback(data)}
|
||||
aria-label={itemIdToExpandedRowMap[data.id] ? 'Collapse' : 'Expand'}
|
||||
iconType={itemIdToExpandedRowMap[data.id] ? 'arrowUp' : 'arrowDown'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
// filter out the host column
|
||||
if (typeof agentIds === 'string') {
|
||||
return columns.filter((column) => column.field !== 'agents');
|
||||
}
|
||||
return columns;
|
||||
}, [agentIds, getTestId, itemIdToExpandedRowMap, onClickCallback]);
|
||||
|
||||
// table pagination
|
||||
const tablePagination = useMemo(() => {
|
||||
return {
|
||||
// this controls the table UI page
|
||||
// to match 0-based table paging
|
||||
pageIndex: (queryParams.page || 1) - 1,
|
||||
pageSize: queryParams.pageSize || 10,
|
||||
totalItemCount,
|
||||
pageSizeOptions: MANAGEMENT_PAGE_SIZE_OPTIONS as number[],
|
||||
};
|
||||
}, [queryParams, totalItemCount]);
|
||||
|
||||
// handle onChange
|
||||
const handleTableOnChange = useCallback(
|
||||
({ page: _page }: CriteriaWithPagination<ActionDetails>) => {
|
||||
// table paging is 0 based
|
||||
const { index, size } = _page;
|
||||
setQueryParams((prevState) => ({
|
||||
...prevState,
|
||||
// adjust the page to conform to
|
||||
// 1-based API page
|
||||
page: index + 1,
|
||||
pageSize: size,
|
||||
}));
|
||||
reFetchEndpointActionList();
|
||||
},
|
||||
[reFetchEndpointActionList, setQueryParams]
|
||||
);
|
||||
|
||||
// compute record ranges
|
||||
const pagedResultsCount = useMemo(() => {
|
||||
const page = queryParams.page ?? 1;
|
||||
const perPage = queryParams?.pageSize ?? 10;
|
||||
|
||||
const totalPages = Math.ceil(totalItemCount / perPage);
|
||||
const fromCount = perPage * page - perPage + 1;
|
||||
const toCount =
|
||||
page === totalPages || totalPages === 1 ? totalItemCount : fromCount + perPage - 1;
|
||||
return { fromCount, toCount };
|
||||
}, [queryParams.page, queryParams.pageSize, totalItemCount]);
|
||||
|
||||
// create range label to display
|
||||
const recordRangeLabel = useMemo(
|
||||
() => (
|
||||
<EuiText color="subdued" size="xs" data-test-subj={getTestId('endpointListTableTotal')}>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.responseActionsList.list.recordRange"
|
||||
defaultMessage="Showing {range} of {total} {recordsLabel}"
|
||||
values={{
|
||||
range: (
|
||||
<strong>
|
||||
<EuiI18nNumber value={pagedResultsCount.fromCount} />
|
||||
{'-'}
|
||||
<EuiI18nNumber value={pagedResultsCount.toCount} />
|
||||
</strong>
|
||||
),
|
||||
total: <EuiI18nNumber value={totalItemCount} />,
|
||||
recordsLabel: <strong>{UX_MESSAGES.recordsLabel(totalItemCount)}</strong>,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
[getTestId, pagedResultsCount.fromCount, pagedResultsCount.toCount, totalItemCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdministrationListPage
|
||||
data-test-subj="responseActionsPage"
|
||||
title={hideHeader ? undefined : UX_MESSAGES.pageTitle}
|
||||
>
|
||||
<ActionListDateRangePicker
|
||||
dateRangePickerState={dateRangePickerState}
|
||||
isDataLoading={isFetching}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshChange={onRefreshChange}
|
||||
onTimeChange={onTimeChange}
|
||||
/>
|
||||
{isFetched && !totalItemCount ? (
|
||||
<ManagementEmptyStateWrapper>
|
||||
<EuiFlexItem data-test-subj={getTestId('empty-prompt')}>
|
||||
<EuiEmptyPrompt
|
||||
iconType="editorUnorderedList"
|
||||
titleSize="s"
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.responseActionsList.empty.title"
|
||||
defaultMessage="No response actions log"
|
||||
/>
|
||||
</h2>
|
||||
}
|
||||
body={
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.responseActionsList.empty.body"
|
||||
defaultMessage="Try a different set of filters"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
data-test-subj="responseActions-empty"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</ManagementEmptyStateWrapper>
|
||||
) : (
|
||||
<>
|
||||
{recordRangeLabel}
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
<EuiBasicTable
|
||||
data-test-subj={getTestId('table-view')}
|
||||
items={actionList?.data || []}
|
||||
columns={responseActionListColumns}
|
||||
itemId="id"
|
||||
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
|
||||
isExpandable={true}
|
||||
pagination={tablePagination}
|
||||
onChange={handleTableOnChange}
|
||||
loading={isFetching}
|
||||
error={error !== null ? UX_MESSAGES.fetchError : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AdministrationListPage>
|
||||
);
|
||||
});
|
||||
|
||||
ResponseActionsList.displayName = 'ResponseActionsList';
|
|
@ -31,6 +31,7 @@ export enum AdministrationSubTab {
|
|||
eventFilters = 'event_filters',
|
||||
hostIsolationExceptions = 'host_isolation_exceptions',
|
||||
blocklist = 'blocklist',
|
||||
responseActions = 'response_actions',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,6 +18,9 @@ import type { SecuritySolutionRequestHandlerContext } from '../../../types';
|
|||
import type { EndpointAppContext } from '../../types';
|
||||
import { errorHandler } from '../error_handler';
|
||||
|
||||
const formatStringIds = (value: string | string[] | undefined): undefined | string[] =>
|
||||
typeof value === 'string' ? [value] : value;
|
||||
|
||||
export const actionListHandler = (
|
||||
endpointContext: EndpointAppContext
|
||||
): RequestHandler<
|
||||
|
@ -36,14 +39,14 @@ export const actionListHandler = (
|
|||
|
||||
try {
|
||||
const body = await getActionList({
|
||||
commands,
|
||||
commands: formatStringIds(commands),
|
||||
esClient,
|
||||
elasticAgentIds,
|
||||
elasticAgentIds: formatStringIds(elasticAgentIds),
|
||||
page,
|
||||
pageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
userIds: formatStringIds(userIds),
|
||||
logger,
|
||||
});
|
||||
return res.ok({
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('When using `getActionList()', () => {
|
|||
it('should return expected output', async () => {
|
||||
const doc = actionRequests.hits.hits[0]._source;
|
||||
await expect(getActionList({ esClient, logger, page: 1, pageSize: 10 })).resolves.toEqual({
|
||||
page: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
commands: undefined,
|
||||
userIds: undefined,
|
||||
|
@ -153,6 +153,7 @@ describe('When using `getActionList()', () => {
|
|||
|
||||
it('should return an empty array if no actions are found', async () => {
|
||||
actionRequests.hits.hits = [];
|
||||
(actionRequests.hits.total as estypes.SearchTotalHits).value = 0;
|
||||
(actionResponses.hits.total as estypes.SearchTotalHits).value = 0;
|
||||
actionRequests = endpointActionGenerator.toEsSearchResponse([]);
|
||||
|
||||
|
@ -162,8 +163,8 @@ describe('When using `getActionList()', () => {
|
|||
data: [],
|
||||
elasticAgentIds: undefined,
|
||||
endDate: undefined,
|
||||
page: 0,
|
||||
pageSize: undefined,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
startDate: undefined,
|
||||
total: 0,
|
||||
userIds: undefined,
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
import { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../../common/endpoint/constants';
|
||||
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
|
||||
import type { ActionDetails, ActionListApiResponse } from '../../../../common/endpoint/types';
|
||||
|
||||
|
@ -42,12 +44,12 @@ export const getActionList = async ({
|
|||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
}): Promise<ActionListApiResponse> => {
|
||||
const size = pageSize ?? 10;
|
||||
const page = (_page ?? 1) - 1;
|
||||
const size = pageSize ?? ENDPOINT_DEFAULT_PAGE_SIZE;
|
||||
const page = _page ?? 1;
|
||||
// # of hits to skip
|
||||
const from = page * size;
|
||||
const from = (page - 1) * size;
|
||||
|
||||
const data = await getActionDetailsList({
|
||||
const { actionDetails, totalRecords } = await getActionDetailsList({
|
||||
commands,
|
||||
elasticAgentIds,
|
||||
esClient,
|
||||
|
@ -61,14 +63,14 @@ export const getActionList = async ({
|
|||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
pageSize: size,
|
||||
startDate,
|
||||
endDate,
|
||||
elasticAgentIds,
|
||||
userIds,
|
||||
commands,
|
||||
data,
|
||||
total: data.length,
|
||||
data: actionDetails,
|
||||
total: totalRecords,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -88,7 +90,10 @@ const getActionDetailsList = async ({
|
|||
size,
|
||||
startDate,
|
||||
userIds,
|
||||
}: GetActionDetailsListParam): Promise<ActionDetails[]> => {
|
||||
}: GetActionDetailsListParam): Promise<{
|
||||
actionDetails: ActionDetails[];
|
||||
totalRecords: number;
|
||||
}> => {
|
||||
let actionRequests;
|
||||
let actionReqIds;
|
||||
let actionResponses;
|
||||
|
@ -119,10 +124,11 @@ const getActionDetailsList = async ({
|
|||
}
|
||||
|
||||
// return empty details array
|
||||
if (!actionRequests?.body?.hits?.hits) return [];
|
||||
if (!actionRequests?.body?.hits?.hits) return { actionDetails: [], totalRecords: 0 };
|
||||
|
||||
// format endpoint actions into { type, item } structure
|
||||
const formattedActionRequests = formatEndpointActionResults(actionRequests?.body?.hits?.hits);
|
||||
const totalRecords = (actionRequests?.body?.hits?.total as unknown as SearchTotalHits).value;
|
||||
|
||||
// normalized actions with a flat structure to access relevant values
|
||||
const normalizedActionRequests: Array<ReturnType<typeof mapToNormalizedActionRequest>> =
|
||||
|
@ -182,5 +188,5 @@ const getActionDetailsList = async ({
|
|||
};
|
||||
});
|
||||
|
||||
return actionDetails;
|
||||
return { actionDetails, totalRecords };
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue