[ResponseOps][OnWeek] Connector logs view (#148291)

Resolves https://github.com/elastic/kibana/issues/147795

## Summary

Adds a connectors event log tab that gives the ability to see historical
activity of connectors.


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### To verify

- Create a rule with a connector, create a few to help test the search
capability
- Go to
http://localhost:5601/app/management/insightsAndAlerting/triggersActionsConnectors/logs
- Verify that you can see the execution logs for the connector
- Verify that you can search
- Verify that you can filter by error/success

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ying Mao <ying.mao@elastic.co>
This commit is contained in:
Alexi Doak 2023-01-23 17:33:01 -05:00 committed by GitHub
parent f7b25f5e46
commit 77742b8a9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 5273 additions and 1050 deletions

View file

@ -0,0 +1,61 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
export interface IExecutionLog {
id: string;
timestamp: string;
duration_ms: number;
status: string;
message: string;
version: string;
schedule_delay_ms: number;
space_ids: string[];
connector_name: string;
connector_id: string;
timed_out: boolean;
}
export interface IExecutionLogResult {
total: number;
data: IExecutionLog[];
}
export interface GetGlobalExecutionLogParams {
dateStart: string;
dateEnd?: string;
filter?: string;
page: number;
perPage: number;
sort: estypes.Sort;
namespaces?: Array<string | undefined>;
}
export interface GetGlobalExecutionKPIParams {
dateStart: string;
dateEnd?: string;
filter?: string;
namespaces?: Array<string | undefined>;
}
export const EMPTY_EXECUTION_KPI_RESULT = {
success: 0,
unknown: 0,
failure: 0,
warning: 0,
};
export type IExecutionKPIResult = typeof EMPTY_EXECUTION_KPI_RESULT;
export const executionLogSortableColumns = [
'timestamp',
'execution_duration',
'schedule_delay',
] as const;
export type ExecutionLogSortFields = typeof executionLogSortableColumns[number];

View file

@ -14,6 +14,7 @@ export * from './rewrite_request_case';
export * from './mustache_template';
export * from './validate_email_addresses';
export * from './connector_feature_config';
export * from './execution_log_types';
export const BASE_ACTION_API_PATH = '/api/actions';
export const INTERNAL_BASE_ACTION_API_PATH = '/internal/actions';

View file

@ -27,6 +27,8 @@ const createActionsClientMock = () => {
listTypes: jest.fn(),
isActionTypeEnabled: jest.fn(),
isPreconfigured: jest.fn(),
getGlobalExecutionKpiWithAuth: jest.fn(),
getGlobalExecutionLogWithAuth: jest.fn(),
};
return mocked;
};

View file

@ -44,6 +44,8 @@ import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock';
import { getOAuthJwtAccessToken } from './lib/get_oauth_jwt_access_token';
import { getOAuthClientCredentialsAccessToken } from './lib/get_oauth_client_credentials_access_token';
import { OAuthParams } from './routes/get_oauth_access_token';
import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock';
import { GetGlobalExecutionKPIParams, GetGlobalExecutionLogParams } from '../common';
jest.mock('@kbn/core-saved-objects-utils-server', () => {
const actual = jest.requireActual('@kbn/core-saved-objects-utils-server');
@ -81,6 +83,10 @@ jest.mock('./lib/get_oauth_client_credentials_access_token', () => ({
getOAuthClientCredentialsAccessToken: jest.fn(),
}));
jest.mock('uuid', () => ({
v4: () => 'uuidv4',
}));
const defaultKibanaIndex = '.kibana';
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
@ -96,6 +102,8 @@ const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const mockTaskManager = taskManagerMock.createSetup();
const configurationUtilities = actionsConfigMock.create();
const eventLogClient = eventLogClientMock.create();
const getEventLogClient = jest.fn();
let actionsClient: ActionsClient;
let mockedLicenseState: jest.Mocked<ILicenseState>;
@ -139,11 +147,13 @@ beforeEach(() => {
auditLogger,
usageCounter: mockUsageCounter,
connectorTokenClient,
getEventLogClient,
});
(getOAuthJwtAccessToken as jest.Mock).mockResolvedValue(`Bearer jwttokentokentoken`);
(getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValue(
`Bearer clienttokentokentoken`
);
getEventLogClient.mockResolvedValue(eventLogClient);
});
describe('create()', () => {
@ -566,6 +576,7 @@ describe('create()', () => {
request,
authorization: authorization as unknown as ActionsAuthorization,
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const savedObjectCreateResult = {
@ -687,6 +698,7 @@ describe('get()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
await actionsClient.get({ id: 'testPreconfigured' });
@ -747,6 +759,7 @@ describe('get()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
authorization.ensureAuthorized.mockRejectedValue(
@ -869,6 +882,7 @@ describe('get()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.get({ id: 'testPreconfigured' });
@ -942,6 +956,7 @@ describe('getAll()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getAll();
}
@ -1084,6 +1099,7 @@ describe('getAll()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getAll();
expect(result).toEqual([
@ -1166,6 +1182,7 @@ describe('getBulk()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getBulk(['1', 'testPreconfigured']);
}
@ -1302,6 +1319,7 @@ describe('getBulk()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
const result = await actionsClient.getBulk(['1', 'testPreconfigured']);
expect(result).toEqual([
@ -1361,6 +1379,7 @@ describe('getOAuthAccessToken()', () => {
},
],
connectorTokenClient: connectorTokenClientMock.create(),
getEventLogClient,
});
return actionsClient.getOAuthAccessToken(requestBody, configurationUtilities);
}
@ -2194,6 +2213,7 @@ describe('execute()', () => {
test('calls the actionExecutor with the appropriate parameters', async () => {
const actionId = uuidv4();
const actionExecutionId = uuidv4();
actionExecutor.execute.mockResolvedValue({ status: 'ok', actionId });
await expect(
actionsClient.execute({
@ -2210,6 +2230,7 @@ describe('execute()', () => {
params: {
name: 'my name',
},
actionExecutionId,
});
await expect(
@ -2241,6 +2262,7 @@ describe('execute()', () => {
type: 'some-type',
},
],
actionExecutionId,
});
await expect(
@ -2274,6 +2296,7 @@ describe('execute()', () => {
namespace: 'some-namespace',
},
],
actionExecutionId,
});
});
});
@ -2525,6 +2548,7 @@ describe('isPreconfigured()', () => {
encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(),
logger,
}),
getEventLogClient,
});
expect(actionsClient.isPreconfigured('testPreconfigured')).toEqual(true);
@ -2563,8 +2587,125 @@ describe('isPreconfigured()', () => {
encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(),
logger,
}),
getEventLogClient,
});
expect(actionsClient.isPreconfigured(uuidv4())).toEqual(false);
});
});
describe('getGlobalExecutionLogWithAuth()', () => {
const opts: GetGlobalExecutionLogParams = {
dateStart: '2023-01-09T08:55:56-08:00',
dateEnd: '2023-01-10T08:55:56-08:00',
page: 1,
perPage: 50,
sort: [{ timestamp: { order: 'desc' } }],
};
const results = {
aggregations: {
executionLogAgg: {
doc_count: 5,
executionUuid: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
executionUuidCardinality: { doc_count: 5, executionUuidCardinality: { value: 5 } },
},
},
};
describe('authorization', () => {
test('ensures user is authorised to access logs', async () => {
eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue(results);
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
await actionsClient.getGlobalExecutionLogWithAuth(opts);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
test('throws when user is not authorised to access logs', async () => {
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
authorization.ensureAuthorized.mockRejectedValue(new Error(`Unauthorized to access logs`));
await expect(actionsClient.getGlobalExecutionLogWithAuth(opts)).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to access logs]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
});
test('calls the eventLogClient with the appropriate parameters', async () => {
eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue(results);
await expect(actionsClient.getGlobalExecutionLogWithAuth(opts)).resolves.toMatchInlineSnapshot(`
Object {
"data": Array [],
"total": 5,
}
`);
expect(eventLogClient.aggregateEventsWithAuthFilter).toHaveBeenCalled();
});
});
describe('getGlobalExecutionKpiWithAuth()', () => {
const opts: GetGlobalExecutionKPIParams = {
dateStart: '2023-01-09T08:55:56-08:00',
dateEnd: '2023-01-10T08:55:56-08:00',
};
const results = {
aggregations: {
executionKpiAgg: {
doc_count: 5,
executionUuid: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
},
},
};
describe('authorization', () => {
test('ensures user is authorised to access kpi', async () => {
eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue(results);
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
await actionsClient.getGlobalExecutionKpiWithAuth(opts);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
test('throws when user is not authorised to access kpi', async () => {
(getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
return AuthorizationMode.RBAC;
});
authorization.ensureAuthorized.mockRejectedValue(new Error(`Unauthorized to access kpi`));
await expect(actionsClient.getGlobalExecutionKpiWithAuth(opts)).rejects.toMatchInlineSnapshot(
`[Error: Unauthorized to access kpi]`
);
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get');
});
});
test('calls the eventLogClient with the appropriate parameters', async () => {
eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue(results);
await expect(actionsClient.getGlobalExecutionKpiWithAuth(opts)).resolves.toMatchInlineSnapshot(`
Object {
"failure": 0,
"success": 0,
"unknown": 0,
"warning": 0,
}
`);
expect(eventLogClient.aggregateEventsWithAuthFilter).toHaveBeenCalled();
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import Boom from '@hapi/boom';
import url from 'url';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -23,7 +24,14 @@ import {
} from '@kbn/core/server';
import { AuditLogger } from '@kbn/security-plugin/server';
import { RunNowResult } from '@kbn/task-manager-plugin/server';
import { ActionType } from '../common';
import { IEventLogClient } from '@kbn/event-log-plugin/server';
import { KueryNode } from '@kbn/es-query';
import {
ActionType,
GetGlobalExecutionKPIParams,
GetGlobalExecutionLogParams,
IExecutionLogResult,
} from '../common';
import { ActionTypeRegistry } from './action_type_registry';
import {
validateConfig,
@ -31,6 +39,7 @@ import {
ActionExecutorContract,
validateConnector,
ActionExecutionSource,
parseDate,
} from './lib';
import {
ActionResult,
@ -72,6 +81,13 @@ import {
GetOAuthClientCredentialsConfig,
GetOAuthClientCredentialsSecrets,
} from './lib/get_oauth_client_credentials_access_token';
import {
ACTION_FILTER,
formatExecutionKPIResult,
formatExecutionLogResult,
getExecutionKPIAggregation,
getExecutionLogAggregation,
} from './lib/get_execution_log_aggregation';
// We are assuming there won't be many actions. This is why we will load
// all the actions in advance and assume the total count to not go over 10000.
@ -108,6 +124,7 @@ interface ConstructorOptions {
auditLogger?: AuditLogger;
usageCounter?: UsageCounter;
connectorTokenClient: ConnectorTokenClientContract;
getEventLogClient: () => Promise<IEventLogClient>;
}
export interface UpdateOptions {
@ -131,6 +148,7 @@ export class ActionsClient {
private readonly auditLogger?: AuditLogger;
private readonly usageCounter?: UsageCounter;
private readonly connectorTokenClient: ConnectorTokenClientContract;
private readonly getEventLogClient: () => Promise<IEventLogClient>;
constructor({
logger,
@ -148,6 +166,7 @@ export class ActionsClient {
auditLogger,
usageCounter,
connectorTokenClient,
getEventLogClient,
}: ConstructorOptions) {
this.logger = logger;
this.actionTypeRegistry = actionTypeRegistry;
@ -164,6 +183,7 @@ export class ActionsClient {
this.auditLogger = auditLogger;
this.usageCounter = usageCounter;
this.connectorTokenClient = connectorTokenClient;
this.getEventLogClient = getEventLogClient;
}
/**
@ -647,7 +667,9 @@ export class ActionsClient {
params,
source,
relatedSavedObjects,
}: Omit<ExecuteOptions, 'request'>): Promise<ActionTypeExecutorResult<unknown>> {
}: Omit<ExecuteOptions, 'request' | 'actionExecutionId'>): Promise<
ActionTypeExecutorResult<unknown>
> {
if (
(await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) ===
AuthorizationMode.RBAC
@ -656,12 +678,14 @@ export class ActionsClient {
} else {
trackLegacyRBACExemption('execute', this.usageCounter);
}
return this.actionExecutor.execute({
actionId,
params,
source,
request: this.request,
relatedSavedObjects,
actionExecutionId: uuidv4(),
});
}
@ -729,6 +753,126 @@ export class ActionsClient {
public isPreconfigured(connectorId: string): boolean {
return !!this.preconfiguredActions.find((preconfigured) => preconfigured.id === connectorId);
}
public async getGlobalExecutionLogWithAuth({
dateStart,
dateEnd,
filter,
page,
perPage,
sort,
namespaces,
}: GetGlobalExecutionLogParams): Promise<IExecutionLogResult> {
this.logger.debug(`getGlobalExecutionLogWithAuth(): getting global execution log`);
const authorizationTuple = {} as KueryNode;
try {
await this.authorization.ensureAuthorized('get');
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
action: ConnectorAuditAction.GET_GLOBAL_EXECUTION_LOG,
error,
})
);
throw error;
}
this.auditLogger?.log(
connectorAuditEvent({
action: ConnectorAuditAction.GET_GLOBAL_EXECUTION_LOG,
})
);
const dateNow = new Date();
const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow);
const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow);
const eventLogClient = await this.getEventLogClient();
try {
const aggResult = await eventLogClient.aggregateEventsWithAuthFilter(
'action',
authorizationTuple,
{
start: parsedDateStart.toISOString(),
end: parsedDateEnd.toISOString(),
aggs: getExecutionLogAggregation({
filter: filter ? `${filter} AND (${ACTION_FILTER})` : ACTION_FILTER,
page,
perPage,
sort,
}),
},
namespaces,
true
);
return formatExecutionLogResult(aggResult);
} catch (err) {
this.logger.debug(
`actionsClient.getGlobalExecutionLogWithAuth(): error searching global event log: ${err.message}`
);
throw err;
}
}
public async getGlobalExecutionKpiWithAuth({
dateStart,
dateEnd,
filter,
namespaces,
}: GetGlobalExecutionKPIParams) {
this.logger.debug(`getGlobalExecutionKpiWithAuth(): getting global execution KPI`);
const authorizationTuple = {} as KueryNode;
try {
await this.authorization.ensureAuthorized('get');
} catch (error) {
this.auditLogger?.log(
connectorAuditEvent({
action: ConnectorAuditAction.GET_GLOBAL_EXECUTION_KPI,
error,
})
);
throw error;
}
this.auditLogger?.log(
connectorAuditEvent({
action: ConnectorAuditAction.GET_GLOBAL_EXECUTION_KPI,
})
);
const dateNow = new Date();
const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow);
const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow);
const eventLogClient = await this.getEventLogClient();
try {
const aggResult = await eventLogClient.aggregateEventsWithAuthFilter(
'action',
authorizationTuple,
{
start: parsedDateStart.toISOString(),
end: parsedDateEnd.toISOString(),
aggs: getExecutionKPIAggregation(
filter ? `${filter} AND (${ACTION_FILTER})` : ACTION_FILTER
),
},
namespaces,
true
);
return formatExecutionKPIResult(aggResult);
} catch (err) {
this.logger.debug(
`actionsClient.getGlobalExecutionKpiWithAuth(): error searching global execution KPI: ${err.message}`
);
throw err;
}
}
}
function actionFromSavedObject(

View file

@ -32,6 +32,7 @@ const executeParams = {
},
executionId: '123abc',
request: {} as KibanaRequest,
actionExecutionId: '2',
};
const spacesMock = spacesServiceMock.createStartContract();
@ -139,6 +140,13 @@ test('successfully executes', async () => {
"kind": "action",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "1",
"name": "1",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -170,6 +178,13 @@ test('successfully executes', async () => {
"outcome": "success",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "1",
"name": "1",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -240,6 +255,13 @@ test('successfully executes with preconfigured connector', async () => {
"kind": "action",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "preconfigured",
"name": "Preconfigured",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -252,6 +274,7 @@ test('successfully executes with preconfigured connector', async () => {
"id": "preconfigured",
"namespace": "some-namespace",
"rel": "primary",
"space_agnostic": true,
"type": "action",
"type_id": "test",
},
@ -271,6 +294,13 @@ test('successfully executes with preconfigured connector', async () => {
"outcome": "success",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "preconfigured",
"name": "Preconfigured",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -283,6 +313,7 @@ test('successfully executes with preconfigured connector', async () => {
"id": "preconfigured",
"namespace": "some-namespace",
"rel": "primary",
"space_agnostic": true,
"type": "action",
"type_id": "test",
},
@ -692,6 +723,13 @@ test('should not throw error if action is preconfigured and isESOCanEncrypt is f
"kind": "action",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "preconfigured",
"name": "Preconfigured",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -704,6 +742,7 @@ test('should not throw error if action is preconfigured and isESOCanEncrypt is f
"id": "preconfigured",
"namespace": "some-namespace",
"rel": "primary",
"space_agnostic": true,
"type": "action",
"type_id": "test",
},
@ -723,6 +762,13 @@ test('should not throw error if action is preconfigured and isESOCanEncrypt is f
"outcome": "success",
},
"kibana": Object {
"action": Object {
"execution": Object {
"uuid": "2",
},
"id": "preconfigured",
"name": "Preconfigured",
},
"alert": Object {
"rule": Object {
"execution": Object {
@ -735,6 +781,7 @@ test('should not throw error if action is preconfigured and isESOCanEncrypt is f
"id": "preconfigured",
"namespace": "some-namespace",
"rel": "primary",
"space_agnostic": true,
"type": "action",
"type_id": "test",
},
@ -815,6 +862,7 @@ test('writes to event log for execute timeout', async () => {
consumer: 'test-consumer',
relatedSavedObjects: [],
request: {} as KibanaRequest,
actionExecutionId: '2',
});
expect(eventLogger.logEvent).toHaveBeenCalledTimes(1);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, {
@ -823,6 +871,13 @@ test('writes to event log for execute timeout', async () => {
kind: 'action',
},
kibana: {
action: {
execution: {
uuid: '2',
},
name: undefined,
id: 'action1',
},
alert: {
rule: {
consumer: 'test-consumer',
@ -833,16 +888,17 @@ test('writes to event log for execute timeout', async () => {
},
saved_objects: [
{
id: 'action1',
namespace: 'some-namespace',
rel: 'primary',
type: 'action',
id: 'action1',
type_id: 'test',
namespace: 'some-namespace',
},
],
space_ids: ['some-namespace'],
},
message: `action: test:action1: 'action-1' execution cancelled due to timeout - exceeded default timeout of "5m"`,
message:
'action: test:action1: \'action-1\' execution cancelled due to timeout - exceeded default timeout of "5m"',
});
});
@ -860,6 +916,13 @@ test('writes to event log for execute and execute start', async () => {
kind: 'action',
},
kibana: {
action: {
execution: {
uuid: '2',
},
name: 'action-1',
id: '1',
},
alert: {
rule: {
execution: {
@ -869,44 +932,17 @@ test('writes to event log for execute and execute start', async () => {
},
saved_objects: [
{
id: '1',
namespace: 'some-namespace',
rel: 'primary',
type: 'action',
id: '1',
type_id: 'test',
namespace: 'some-namespace',
},
],
space_ids: ['some-namespace'],
},
message: 'action started: test:1: action-1',
});
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, {
event: {
action: 'execute',
kind: 'action',
outcome: 'success',
},
kibana: {
alert: {
rule: {
execution: {
uuid: '123abc',
},
},
},
saved_objects: [
{
rel: 'primary',
type: 'action',
id: '1',
type_id: 'test',
namespace: 'some-namespace',
},
],
space_ids: ['some-namespace'],
},
message: 'action executed: test:1: action-1',
});
});
test('writes to event log for execute and execute start when consumer and related saved object are defined', async () => {
@ -933,6 +969,13 @@ test('writes to event log for execute and execute start when consumer and relate
kind: 'action',
},
kibana: {
action: {
execution: {
uuid: '2',
},
name: 'action-1',
id: '1',
},
alert: {
rule: {
consumer: 'test-consumer',
@ -944,16 +987,17 @@ test('writes to event log for execute and execute start when consumer and relate
},
saved_objects: [
{
id: '1',
namespace: 'some-namespace',
rel: 'primary',
type: 'action',
id: '1',
type_id: 'test',
namespace: 'some-namespace',
},
{
id: '12',
namespace: undefined,
rel: 'primary',
type: 'alert',
id: '12',
type_id: '.rule-type',
},
],
@ -961,41 +1005,6 @@ test('writes to event log for execute and execute start when consumer and relate
},
message: 'action started: test:1: action-1',
});
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, {
event: {
action: 'execute',
kind: 'action',
outcome: 'success',
},
kibana: {
alert: {
rule: {
consumer: 'test-consumer',
execution: {
uuid: '123abc',
},
rule_type_id: '.rule-type',
},
},
saved_objects: [
{
rel: 'primary',
type: 'action',
id: '1',
type_id: 'test',
namespace: 'some-namespace',
},
{
rel: 'primary',
type: 'alert',
id: '12',
type_id: '.rule-type',
},
],
space_ids: ['some-namespace'],
},
message: 'action executed: test:1: action-1',
});
});
function setupActionExecutorMock() {

View file

@ -59,6 +59,7 @@ export interface TaskInfo {
export interface ExecuteOptions<Source = unknown> {
actionId: string;
actionExecutionId: string;
isEphemeral?: boolean;
request: KibanaRequest;
params: Record<string, unknown>;
@ -100,6 +101,7 @@ export class ActionExecutor {
executionId,
consumer,
relatedSavedObjects,
actionExecutionId,
}: ExecuteOptions): Promise<ActionTypeExecutorResult<unknown>> {
if (!this.isInitialized) {
throw new Error('ActionExecutor not initialized');
@ -189,6 +191,9 @@ export class ActionExecutor {
},
],
relatedSavedObjects,
name,
actionExecutionId,
isPreconfigured: this.actionInfo.isPreconfigured,
});
eventLogger.startTiming(event);
@ -297,8 +302,10 @@ export class ActionExecutor {
executionId,
taskInfo,
consumer,
actionExecutionId,
}: {
actionId: string;
actionExecutionId: string;
request: KibanaRequest;
taskInfo?: TaskInfo;
executionId?: string;
@ -357,6 +364,8 @@ export class ActionExecutor {
},
],
relatedSavedObjects,
actionExecutionId,
isPreconfigured: this.actionInfo.isPreconfigured,
});
eventLogger.logEvent(event);
@ -369,6 +378,7 @@ interface ActionInfo {
config: unknown;
secrets: unknown;
actionId: string;
isPreconfigured?: boolean;
}
async function getActionInfoInternal<Source = unknown>(
@ -395,6 +405,7 @@ async function getActionInfoInternal<Source = unknown>(
config: pcAction.config,
secrets: pcAction.secrets,
actionId,
isPreconfigured: true,
};
}

View file

@ -15,6 +15,8 @@ export enum ConnectorAuditAction {
DELETE = 'connector_delete',
FIND = 'connector_find',
EXECUTE = 'connector_execute',
GET_GLOBAL_EXECUTION_LOG = 'connector_get_global_execution_log',
GET_GLOBAL_EXECUTION_KPI = 'connector_get_global_execution_kpi',
}
type VerbsTuple = [string, string, string];
@ -26,6 +28,8 @@ const eventVerbs: Record<ConnectorAuditAction, VerbsTuple> = {
connector_delete: ['delete', 'deleting', 'deleted'],
connector_find: ['access', 'accessing', 'accessed'],
connector_execute: ['execute', 'executing', 'executed'],
connector_get_global_execution_log: ['access', 'accessing', 'accessed'],
connector_get_global_execution_kpi: ['access', 'accessing', 'accessed'],
};
const eventTypes: Record<ConnectorAuditAction, EcsEventType | undefined> = {
@ -35,6 +39,8 @@ const eventTypes: Record<ConnectorAuditAction, EcsEventType | undefined> = {
connector_delete: 'deletion',
connector_find: 'access',
connector_execute: undefined,
connector_get_global_execution_log: 'access',
connector_get_global_execution_kpi: 'access',
};
export interface ConnectorAuditEventParams {

View file

@ -29,6 +29,8 @@ describe('createActionEventLogRecordObject', () => {
},
],
spaceId: 'default',
name: 'test name',
actionExecutionId: '123abc',
})
).toStrictEqual({
'@timestamp': '1970-01-01T00:00:00.000Z',
@ -58,6 +60,13 @@ describe('createActionEventLogRecordObject', () => {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
},
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
});
});
@ -80,6 +89,7 @@ describe('createActionEventLogRecordObject', () => {
relation: 'primary',
},
],
actionExecutionId: '123abc',
})
).toStrictEqual({
event: {
@ -104,6 +114,13 @@ describe('createActionEventLogRecordObject', () => {
type_id: '.email',
},
],
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
message: 'action execution start',
});
@ -125,6 +142,7 @@ describe('createActionEventLogRecordObject', () => {
relation: 'primary',
},
],
actionExecutionId: '123abc',
})
).toStrictEqual({
event: {
@ -141,6 +159,13 @@ describe('createActionEventLogRecordObject', () => {
type_id: '.email',
},
],
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
message: 'action execution start',
});
@ -163,6 +188,8 @@ describe('createActionEventLogRecordObject', () => {
relation: 'primary',
},
],
name: 'test name',
actionExecutionId: '123abc',
})
).toStrictEqual({
event: {
@ -189,6 +216,13 @@ describe('createActionEventLogRecordObject', () => {
schedule_delay: undefined,
scheduled: '1970-01-01T00:00:00.000Z',
},
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
});
});
@ -218,6 +252,7 @@ describe('createActionEventLogRecordObject', () => {
id: '123',
},
],
actionExecutionId: '123abc',
})
).toStrictEqual({
event: {
@ -250,6 +285,70 @@ describe('createActionEventLogRecordObject', () => {
type_id: '.rule-type',
},
],
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
message: 'action execution start',
});
});
test('created action event "execute" for preconfigured connector with space_agnostic true', async () => {
expect(
createActionEventLogRecordObject({
actionId: '1',
name: 'test name',
action: 'execute',
message: 'action execution start',
namespace: 'default',
executionId: '123abc',
consumer: 'test-consumer',
savedObjects: [
{
id: '2',
type: 'action',
typeId: '.email',
relation: 'primary',
},
],
actionExecutionId: '123abc',
isPreconfigured: true,
})
).toStrictEqual({
event: {
action: 'execute',
kind: 'action',
},
kibana: {
alert: {
rule: {
consumer: 'test-consumer',
execution: {
uuid: '123abc',
},
},
},
saved_objects: [
{
id: '2',
namespace: 'default',
rel: 'primary',
type: 'action',
type_id: '.email',
space_agnostic: true,
},
],
action: {
name: 'test name',
id: '1',
execution: {
uuid: '123abc',
},
},
},
message: 'action execution start',
});

View file

@ -14,6 +14,7 @@ export type Event = Exclude<IEvent, undefined>;
interface CreateActionEventLogRecordParams {
actionId: string;
action: string;
actionExecutionId: string;
name?: string;
message?: string;
namespace?: string;
@ -32,11 +33,24 @@ interface CreateActionEventLogRecordParams {
relation?: string;
}>;
relatedSavedObjects?: RelatedSavedObjects;
isPreconfigured?: boolean;
}
export function createActionEventLogRecordObject(params: CreateActionEventLogRecordParams): Event {
const { action, message, task, namespace, executionId, spaceId, consumer, relatedSavedObjects } =
params;
const {
action,
message,
task,
namespace,
executionId,
spaceId,
consumer,
relatedSavedObjects,
name,
actionExecutionId,
isPreconfigured,
actionId,
} = params;
const kibanaAlertRule = {
...(consumer ? { consumer } : {}),
@ -62,10 +76,19 @@ export function createActionEventLogRecordObject(params: CreateActionEventLogRec
type: so.type,
id: so.id,
type_id: so.typeId,
// set space_agnostic to true for preconfigured connectors
...(so.type === 'action' && isPreconfigured ? { space_agnostic: isPreconfigured } : {}),
...(namespace ? { namespace } : {}),
})),
...(spaceId ? { space_ids: [spaceId] } : {}),
...(task ? { task: { scheduled: task.scheduled, schedule_delay: task.scheduleDelay } } : {}),
action: {
...(name ? { name } : {}),
id: actionId,
execution: {
uuid: actionExecutionId,
},
},
},
...(message ? { message } : {}),
};

View file

@ -0,0 +1,945 @@
/*
* 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 { fromKueryExpression } from '@kbn/es-query';
import {
getExecutionLogAggregation,
formatExecutionLogResult,
ExecutionUuidAggResult,
getExecutionKPIAggregation,
formatExecutionKPIResult,
} from './get_execution_log_aggregation';
describe('getExecutionLogAggregation', () => {
test('should throw error when given bad sort field', () => {
expect(() => {
getExecutionLogAggregation({
page: 1,
perPage: 10,
sort: [{ notsortable: { order: 'asc' } }],
});
}).toThrowErrorMatchingInlineSnapshot(
`"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,schedule_delay]"`
);
});
test('should throw error when given one bad sort field', () => {
expect(() => {
getExecutionLogAggregation({
page: 1,
perPage: 10,
sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }],
});
}).toThrowErrorMatchingInlineSnapshot(
`"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,schedule_delay]"`
);
});
test('should throw error when given bad page field', () => {
expect(() => {
getExecutionLogAggregation({
page: 0,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }],
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid page field \\"0\\" - must be greater than 0"`);
});
test('should throw error when given bad perPage field', () => {
expect(() => {
getExecutionLogAggregation({
page: 1,
perPage: 0,
sort: [{ timestamp: { order: 'asc' } }],
});
}).toThrowErrorMatchingInlineSnapshot(
`"Invalid perPage field \\"0\\" - must be greater than 0"`
);
});
test('should correctly generate aggregation', () => {
expect(
getExecutionLogAggregation({
page: 2,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }],
})
).toEqual({
executionLogAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
executeStartTime: {
min: {
field: 'event.start',
},
},
executionDuration: {
max: {
field: 'event.duration',
},
},
outcomeAndMessage: {
top_hits: {
_source: {
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'kibana.space_ids',
'kibana.action.name',
'kibana.action.id',
],
},
},
},
scheduleDelay: {
max: {
field: 'kibana.task.schedule_delay',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 10,
gap_policy: 'insert_zeros',
size: 10,
sort: [
{
'actionExecution>executeStartTime': {
order: 'asc',
},
},
{
'actionExecution>executionDuration': {
order: 'desc',
},
},
],
},
},
timeoutMessage: {
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute-timeout',
},
},
],
},
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'asc',
},
{
'actionExecution>executionDuration': 'desc',
},
],
size: 1000,
},
},
executionUuidCardinality: {
aggs: {
executionUuidCardinality: {
cardinality: {
field: 'kibana.action.execution.uuid',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a string', () => {
expect(
getExecutionLogAggregation({
page: 2,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }],
filter: 'test:test',
})
).toEqual({
executionLogAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
executeStartTime: {
min: {
field: 'event.start',
},
},
executionDuration: {
max: {
field: 'event.duration',
},
},
outcomeAndMessage: {
top_hits: {
_source: {
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'kibana.space_ids',
'kibana.action.name',
'kibana.action.id',
],
},
},
},
scheduleDelay: {
max: {
field: 'kibana.task.schedule_delay',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 10,
gap_policy: 'insert_zeros',
size: 10,
sort: [
{
'actionExecution>executeStartTime': {
order: 'asc',
},
},
{
'actionExecution>executionDuration': {
order: 'desc',
},
},
],
},
},
timeoutMessage: {
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute-timeout',
},
},
],
},
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'asc',
},
{
'actionExecution>executionDuration': 'desc',
},
],
size: 1000,
},
},
executionUuidCardinality: {
aggs: {
executionUuidCardinality: {
cardinality: {
field: 'kibana.action.execution.uuid',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a KueryNode', () => {
expect(
getExecutionLogAggregation({
page: 2,
perPage: 10,
sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }],
filter: fromKueryExpression('test:test'),
})
).toEqual({
executionLogAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
executeStartTime: {
min: {
field: 'event.start',
},
},
executionDuration: {
max: {
field: 'event.duration',
},
},
outcomeAndMessage: {
top_hits: {
_source: {
includes: [
'event.outcome',
'message',
'error.message',
'kibana.version',
'kibana.space_ids',
'kibana.action.name',
'kibana.action.id',
],
},
},
},
scheduleDelay: {
max: {
field: 'kibana.task.schedule_delay',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 10,
gap_policy: 'insert_zeros',
size: 10,
sort: [
{
'actionExecution>executeStartTime': {
order: 'asc',
},
},
{
'actionExecution>executionDuration': {
order: 'desc',
},
},
],
},
},
timeoutMessage: {
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute-timeout',
},
},
],
},
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'asc',
},
{
'actionExecution>executionDuration': 'desc',
},
],
size: 1000,
},
},
executionUuidCardinality: {
aggs: {
executionUuidCardinality: {
cardinality: {
field: 'kibana.action.execution.uuid',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
},
},
},
});
});
});
describe('formatExecutionLogResult', () => {
test('should return empty results if aggregations are undefined', () => {
expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({
total: 0,
data: [],
});
});
test('should return empty results if aggregations.executionLogAgg are undefined', () => {
expect(
formatExecutionLogResult({
aggregations: { executionLogAgg: undefined as unknown as ExecutionUuidAggResult },
})
).toEqual({
total: 0,
data: [],
});
});
test('should format results correctly', () => {
const results = {
aggregations: {
executionLogAgg: {
doc_count: 5,
executionUuid: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
doc_count: 1,
actionExecution: {
doc_count: 1,
scheduleDelay: { value: 2783000000 },
outcomeAndMessage: {
hits: {
total: { value: 1, relation: 'eq' },
max_score: 3.033605,
hits: [
{
_index: '.kibana-event-log-8.7.0-000001',
_id: '5SmlgoUBAOza-PIJVKGD',
_score: 3.033605,
_source: {
event: { outcome: 'success' },
kibana: {
space_ids: ['default'],
version: '8.7.0',
action: { name: 'test connector', id: '1' },
},
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
},
},
],
},
},
executionDuration: { value: 1000000 },
executeStartTime: {
value: 1672934150495,
value_as_string: '2023-01-05T15:55:50.495Z',
},
},
},
],
},
executionUuidCardinality: { doc_count: 1, executionUuidCardinality: { value: 1 } },
},
},
};
expect(formatExecutionLogResult(results)).toEqual({
data: [
{
connector_name: 'test connector',
connector_id: '1',
duration_ms: 1,
id: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
schedule_delay_ms: 2783,
space_ids: ['default'],
status: 'success',
timestamp: '2023-01-05T15:55:50.495Z',
version: '8.7.0',
timed_out: false,
},
],
total: 1,
});
});
test('should format results correctly with action execution errors', () => {
const results = {
aggregations: {
executionLogAgg: {
doc_count: 10,
executionUuid: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'fdf9cadb-4568-4d22-afd2-437e4efbe767',
doc_count: 1,
actionExecution: {
doc_count: 1,
scheduleDelay: { value: 2946000000 },
outcomeAndMessage: {
hits: {
total: { value: 1, relation: 'eq' },
max_score: 3.1420498,
hits: [
{
_index: '.kibana-event-log-8.7.0-000001',
_id: 'CSm_goUBAOza-PIJBaIn',
_score: 3.1420498,
_source: {
event: { outcome: 'failure' },
kibana: {
space_ids: ['default'],
version: '8.7.0',
action: { name: 'test', id: '1' },
},
message:
'action execution failure: .email:e020c620-8d14-11ed-bae5-bd32cbc9eaaa: test',
error: {
message:
'action execution failure: .email:e020c620-8d14-11ed-bae5-bd32cbc9eaaa: test',
},
},
},
],
},
},
executionDuration: { value: 441000000 },
executeStartTime: {
value: 1672935833813,
value_as_string: '2023-01-05T16:23:53.813Z',
},
},
},
{
key: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
doc_count: 1,
actionExecution: {
doc_count: 1,
scheduleDelay: { value: 2783000000 },
outcomeAndMessage: {
hits: {
total: { value: 1, relation: 'eq' },
max_score: 3.1420498,
hits: [
{
_index: '.kibana-event-log-8.7.0-000001',
_id: '5SmlgoUBAOza-PIJVKGD',
_score: 3.1420498,
_source: {
event: { outcome: 'success' },
kibana: {
space_ids: ['default'],
version: '8.7.0',
action: { name: 'test connector', id: '1' },
},
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
},
},
],
},
},
executionDuration: { value: 1000000 },
executeStartTime: {
value: 1672934150495,
value_as_string: '2023-01-05T15:55:50.495Z',
},
},
},
],
},
executionUuidCardinality: { doc_count: 2, executionUuidCardinality: { value: 2 } },
},
},
};
expect(formatExecutionLogResult(results)).toEqual({
data: [
{
connector_name: 'test',
connector_id: '1',
duration_ms: 441,
id: 'fdf9cadb-4568-4d22-afd2-437e4efbe767',
message:
'action execution failure: .email:e020c620-8d14-11ed-bae5-bd32cbc9eaaa: test - action execution failure: .email:e020c620-8d14-11ed-bae5-bd32cbc9eaaa: test',
schedule_delay_ms: 2946,
space_ids: ['default'],
status: 'failure',
timestamp: '2023-01-05T16:23:53.813Z',
version: '8.7.0',
timed_out: false,
},
{
connector_name: 'test connector',
connector_id: '1',
duration_ms: 1,
id: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
schedule_delay_ms: 2783,
space_ids: ['default'],
status: 'success',
timestamp: '2023-01-05T15:55:50.495Z',
version: '8.7.0',
timed_out: false,
},
],
total: 2,
});
});
});
describe('getExecutionKPIAggregation', () => {
test('should correctly generate aggregation', () => {
expect(getExecutionKPIAggregation()).toEqual({
executionKpiAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
actionExecutionOutcomes: {
terms: {
field: 'event.outcome',
size: 3,
},
},
executeStartTime: {
min: {
field: 'event.start',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 0,
gap_policy: 'insert_zeros',
size: 10000,
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'desc',
},
],
size: 10000,
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a string', () => {
expect(getExecutionKPIAggregation('test:test')).toEqual({
executionKpiAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
actionExecutionOutcomes: {
terms: {
field: 'event.outcome',
size: 3,
},
},
executeStartTime: {
min: {
field: 'event.start',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 0,
gap_policy: 'insert_zeros',
size: 10000,
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'desc',
},
],
size: 10000,
},
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
},
},
},
});
});
test('should correctly generate aggregation with a defined filter in the form of a KueryNode', () => {
expect(getExecutionKPIAggregation(fromKueryExpression('test:test'))).toEqual({
executionKpiAgg: {
aggs: {
executionUuid: {
aggs: {
actionExecution: {
aggs: {
actionExecutionOutcomes: {
terms: {
field: 'event.outcome',
size: 3,
},
},
executeStartTime: {
min: {
field: 'event.start',
},
},
},
filter: {
bool: {
must: [
{
match: {
'event.action': 'execute',
},
},
],
},
},
},
executionUuidSorted: {
bucket_sort: {
from: 0,
gap_policy: 'insert_zeros',
size: 10000,
},
},
},
terms: {
field: 'kibana.action.execution.uuid',
order: [
{
'actionExecution>executeStartTime': 'desc',
},
],
size: 10000,
},
},
},
filter: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
},
},
},
});
});
});
describe('formatExecutionKPIAggBuckets', () => {
test('should return empty results if aggregations are undefined', () => {
expect(
formatExecutionKPIResult({
aggregations: undefined,
})
).toEqual({ failure: 0, success: 0, unknown: 0, warning: 0 });
});
test('should format results correctly', () => {
const results = {
aggregations: {
executionKpiAgg: {
doc_count: 21,
executionUuid: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
doc_count: 1,
actionExecution: {
doc_count: 1,
actionExecutionOutcomes: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [{ key: 'success', doc_count: 1 }],
},
executeStartTime: {
value: 1672934150495,
value_as_string: '2023-01-05T15:55:50.495Z',
},
},
},
],
},
},
},
};
expect(formatExecutionKPIResult(results)).toEqual({
failure: 0,
success: 1,
unknown: 0,
warning: 0,
});
});
});

View file

@ -0,0 +1,424 @@
/*
* 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 { KueryNode } from '@kbn/es-query';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import Boom from '@hapi/boom';
import { flatMap, get, isEmpty } from 'lodash';
import { AggregateEventsBySavedObjectResult } from '@kbn/event-log-plugin/server';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { IExecutionLog, IExecutionLogResult, EMPTY_EXECUTION_KPI_RESULT } from '../../common';
const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions
const DEFAULT_MAX_KPI_BUCKETS_LIMIT = 10000;
const SPACE_ID_FIELD = 'kibana.space_ids';
const ACTION_NAME_FIELD = 'kibana.action.name';
const ACTION_ID_FIELD = 'kibana.action.id';
const START_FIELD = 'event.start';
const ACTION_FIELD = 'event.action';
const OUTCOME_FIELD = 'event.outcome';
const DURATION_FIELD = 'event.duration';
const MESSAGE_FIELD = 'message';
const VERSION_FIELD = 'kibana.version';
const ERROR_MESSAGE_FIELD = 'error.message';
const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay';
const EXECUTION_UUID_FIELD = 'kibana.action.execution.uuid';
const Millis2Nanos = 1000 * 1000;
export const ACTION_FILTER = 'event.provider: actions AND NOT event.action: execute-start';
export const EMPTY_EXECUTION_LOG_RESULT = {
total: 0,
data: [],
};
interface IActionExecution
extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> {
buckets: Array<{ key: string; doc_count: number }>;
}
interface IExecutionUuidKpiAggBucket extends estypes.AggregationsStringTermsBucketKeys {
actionExecution: {
doc_count: number;
actionExecutionOutcomes: IActionExecution;
};
}
interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketKeys {
timeoutMessage: estypes.AggregationsMultiBucketBase;
actionExecution: {
executeStartTime: estypes.AggregationsMinAggregate;
executionDuration: estypes.AggregationsMaxAggregate;
scheduleDelay: estypes.AggregationsMaxAggregate;
outcomeAndMessage: estypes.AggregationsTopHitsAggregate;
};
}
export interface ExecutionUuidAggResult extends estypes.AggregationsAggregateBase {
buckets: IExecutionUuidAggBucket[];
}
export interface ExecutionUuidKPIAggResult extends estypes.AggregationsAggregateBase {
buckets: IExecutionUuidKpiAggBucket[];
}
interface ExecutionLogAggResult extends estypes.AggregationsAggregateBase {
executionUuid: ExecutionUuidAggResult;
executionUuidCardinality: {
executionUuidCardinality: estypes.AggregationsCardinalityAggregate;
};
}
interface ExecutionKpiAggResult extends estypes.AggregationsAggregateBase {
executionUuid: ExecutionUuidKPIAggResult;
}
export interface IExecutionLogAggOptions {
filter?: string | KueryNode;
page: number;
perPage: number;
sort: estypes.Sort;
}
const ExecutionLogSortFields: Record<string, string> = {
timestamp: 'actionExecution>executeStartTime',
execution_duration: 'actionExecution>executionDuration',
schedule_delay: 'actionExecution>scheduleDelay',
};
export const getExecutionKPIAggregation = (filter?: IExecutionLogAggOptions['filter']) => {
const dslFilterQuery: estypes.QueryDslBoolQuery['filter'] = buildDslFilterQuery(filter);
return {
executionKpiAgg: {
...(dslFilterQuery ? { filter: { bool: { filter: dslFilterQuery } } } : {}),
aggs: {
executionUuid: {
// Bucket by execution UUID
terms: {
field: EXECUTION_UUID_FIELD,
size: DEFAULT_MAX_KPI_BUCKETS_LIMIT,
order: formatSortForTermSort([{ timestamp: { order: 'desc' } }]),
},
aggs: {
executionUuidSorted: {
bucket_sort: {
from: 0,
size: DEFAULT_MAX_KPI_BUCKETS_LIMIT,
gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy,
},
},
actionExecution: {
filter: {
bool: {
must: [
{
match: {
[ACTION_FIELD]: 'execute',
},
},
],
},
},
aggs: {
executeStartTime: {
min: {
field: START_FIELD,
},
},
actionExecutionOutcomes: {
terms: {
size: 3,
field: OUTCOME_FIELD,
},
},
},
},
},
},
},
},
};
};
export function getExecutionLogAggregation({
filter,
page,
perPage,
sort,
}: IExecutionLogAggOptions) {
// Check if valid sort fields
const sortFields = flatMap(sort as estypes.SortCombinations[], (s) => Object.keys(s));
for (const field of sortFields) {
if (!Object.keys(ExecutionLogSortFields).includes(field)) {
throw Boom.badRequest(
`Invalid sort field "${field}" - must be one of [${Object.keys(ExecutionLogSortFields).join(
','
)}]`
);
}
}
// Check if valid page value
if (page <= 0) {
throw Boom.badRequest(`Invalid page field "${page}" - must be greater than 0`);
}
// Check if valid page value
if (perPage <= 0) {
throw Boom.badRequest(`Invalid perPage field "${perPage}" - must be greater than 0`);
}
const dslFilterQuery: estypes.QueryDslBoolQuery['filter'] = buildDslFilterQuery(filter);
return {
executionLogAgg: {
...(dslFilterQuery ? { filter: { bool: { filter: dslFilterQuery } } } : {}),
aggs: {
// Get total number of executions
executionUuidCardinality: {
filter: {
bool: {
must: [
{
match: {
[ACTION_FIELD]: 'execute',
},
},
],
},
},
aggs: {
executionUuidCardinality: {
cardinality: {
field: EXECUTION_UUID_FIELD,
},
},
},
},
executionUuid: {
// Bucket by execution UUID
terms: {
field: EXECUTION_UUID_FIELD,
size: DEFAULT_MAX_BUCKETS_LIMIT,
order: formatSortForTermSort(sort),
},
aggs: {
// Bucket sort to allow paging through executions
executionUuidSorted: {
bucket_sort: {
sort: formatSortForBucketSort(sort),
from: (page - 1) * perPage,
size: perPage,
gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy,
},
},
// Filter by action execute doc and get information from this event
actionExecution: {
filter: {
bool: {
must: [
{
match: {
[ACTION_FIELD]: 'execute',
},
},
],
},
},
aggs: {
executeStartTime: {
min: {
field: START_FIELD,
},
},
scheduleDelay: {
max: {
field: SCHEDULE_DELAY_FIELD,
},
},
executionDuration: {
max: {
field: DURATION_FIELD,
},
},
outcomeAndMessage: {
top_hits: {
_source: {
includes: [
OUTCOME_FIELD,
MESSAGE_FIELD,
ERROR_MESSAGE_FIELD,
VERSION_FIELD,
SPACE_ID_FIELD,
ACTION_NAME_FIELD,
ACTION_ID_FIELD,
],
},
},
},
},
},
// If there was a timeout, this filter will return non-zero doc count
timeoutMessage: {
filter: {
bool: {
must: [
{
match: {
[ACTION_FIELD]: 'execute-timeout',
},
},
],
},
},
},
},
},
},
},
};
}
function buildDslFilterQuery(filter: IExecutionLogAggOptions['filter']) {
try {
const filterKueryNode = typeof filter === 'string' ? fromKueryExpression(filter) : filter;
return filterKueryNode ? toElasticsearchQuery(filterKueryNode) : undefined;
} catch (err) {
throw Boom.badRequest(`Invalid kuery syntax for filter ${filter}`);
}
}
function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutionLog {
const durationUs = bucket?.actionExecution?.executionDuration?.value
? bucket.actionExecution.executionDuration.value
: 0;
const scheduleDelayUs = bucket?.actionExecution?.scheduleDelay?.value
? bucket.actionExecution.scheduleDelay.value
: 0;
const outcomeAndMessage =
bucket?.actionExecution?.outcomeAndMessage?.hits?.hits[0]?._source ?? {};
let status = outcomeAndMessage.kibana?.alerting?.outcome ?? '';
if (isEmpty(status)) {
status = outcomeAndMessage.event?.outcome ?? '';
}
const outcomeMessage = outcomeAndMessage.message ?? '';
const outcomeErrorMessage = outcomeAndMessage.error?.message ?? '';
const message =
status === 'failure' ? `${outcomeMessage} - ${outcomeErrorMessage}` : outcomeMessage;
const version = outcomeAndMessage.kibana?.version ?? '';
const spaceIds = outcomeAndMessage?.kibana?.space_ids ?? [];
const connectorName = outcomeAndMessage?.kibana?.action?.name ?? '';
const connectorId = outcomeAndMessage?.kibana?.action?.id ?? '';
const timedOut = (bucket?.timeoutMessage?.doc_count ?? 0) > 0;
return {
id: bucket?.key ?? '',
timestamp: bucket?.actionExecution?.executeStartTime.value_as_string ?? '',
duration_ms: durationUs / Millis2Nanos,
status,
message,
version,
schedule_delay_ms: scheduleDelayUs / Millis2Nanos,
space_ids: spaceIds,
connector_name: connectorName,
connector_id: connectorId,
timed_out: timedOut,
};
}
function formatExecutionKPIAggBuckets(buckets: IExecutionUuidKpiAggBucket[]) {
const objToReturn = {
success: 0,
unknown: 0,
failure: 0,
warning: 0,
};
buckets.forEach((bucket) => {
const actionExecutionOutcomes = bucket?.actionExecution?.actionExecutionOutcomes?.buckets ?? [];
const actionExecutionCount = bucket?.actionExecution?.doc_count ?? 0;
const outcomes = {
successActionExecution: 0,
failureActionExecution: 0,
warningActionExecution: 0,
};
actionExecutionOutcomes.reduce((acc, subBucket) => {
const key = subBucket.key;
if (key === 'success') {
acc.successActionExecution = subBucket.doc_count ?? 0;
} else if (key === 'failure') {
acc.failureActionExecution = subBucket.doc_count ?? 0;
} else if (key === 'warning') {
acc.warningActionExecution = subBucket.doc_count ?? 0;
}
return acc;
}, outcomes);
objToReturn.success += outcomes.successActionExecution;
objToReturn.unknown +=
actionExecutionCount -
(outcomes.successActionExecution +
outcomes.failureActionExecution +
outcomes.warningActionExecution);
objToReturn.failure += outcomes.failureActionExecution;
objToReturn.warning += outcomes.warningActionExecution;
});
return objToReturn;
}
export function formatExecutionKPIResult(results: AggregateEventsBySavedObjectResult) {
const { aggregations } = results;
if (!aggregations || !aggregations.executionKpiAgg) {
return EMPTY_EXECUTION_KPI_RESULT;
}
const aggs = aggregations.executionKpiAgg as ExecutionKpiAggResult;
const buckets = aggs.executionUuid.buckets;
return formatExecutionKPIAggBuckets(buckets);
}
export function formatExecutionLogResult(
results: AggregateEventsBySavedObjectResult
): IExecutionLogResult {
const { aggregations } = results;
if (!aggregations || !aggregations.executionLogAgg) {
return EMPTY_EXECUTION_LOG_RESULT;
}
const aggs = aggregations.executionLogAgg as ExecutionLogAggResult;
const total = aggs.executionUuidCardinality.executionUuidCardinality.value;
const buckets = aggs.executionUuid.buckets;
return {
total,
data: buckets.map((bucket: IExecutionUuidAggBucket) => formatExecutionLogAggBucket(bucket)),
};
}
export function formatSortForBucketSort(sort: estypes.Sort) {
return (sort as estypes.SortCombinations[]).map((s) =>
Object.keys(s).reduce(
(acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, curr) }),
{}
)
);
}
export function formatSortForTermSort(sort: estypes.Sort) {
return (sort as estypes.SortCombinations[]).map((s) =>
Object.keys(s).reduce(
(acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, `${curr}.order`) }),
{}
)
);
}

View file

@ -34,3 +34,4 @@ export {
isHttpRequestExecutionSource,
} from './action_execution_source';
export { validateEmptyStrings } from './validate_empty_strings';
export { parseDate } from './parse_date';

View file

@ -0,0 +1,51 @@
/*
* 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 { parseDate, parseIsoOrRelativeDate } from './parse_date';
describe('parseDate', () => {
test('returns valid parsed date', () => {
const date = new Date(Date.now() - 1 * 60 * 60 * 1000);
const parsedDate = parseDate(date.toISOString(), 'dateStart', new Date());
expect(parsedDate?.valueOf()).toBe(date.valueOf());
});
test('returns default value if date is undefined', () => {
const date = new Date();
const parsedDate = parseDate(undefined, 'dateStart', date);
expect(parsedDate?.valueOf()).toBe(date.valueOf());
});
test('throws an error for invalid date strings', () => {
expect(() =>
parseDate('this shall not pass', 'dateStart', new Date())
).toThrowErrorMatchingInlineSnapshot(
`"Invalid date for parameter dateStart: \\"this shall not pass\\""`
);
});
});
describe('parseIsoOrRelativeDate', () => {
test('handles ISO dates', () => {
const date = new Date();
const parsedDate = parseIsoOrRelativeDate(date.toISOString());
expect(parsedDate?.valueOf()).toBe(date.valueOf());
});
test('handles relative dates', () => {
const hoursDiff = 1;
const date = new Date(Date.now() - hoursDiff * 60 * 60 * 1000);
const parsedDate = parseIsoOrRelativeDate(`${hoursDiff}h`);
const diff = Math.abs(parsedDate!.valueOf() - date.valueOf());
expect(diff).toBeLessThan(1000);
});
test('returns undefined for invalid date strings', () => {
const parsedDate = parseIsoOrRelativeDate('this shall not pass');
expect(parsedDate).toBeUndefined();
});
});

View file

@ -0,0 +1,90 @@
/*
* 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 Boom from '@hapi/boom';
import { i18n } from '@kbn/i18n';
const SECONDS_REGEX = /^[1-9][0-9]*s$/;
const MINUTES_REGEX = /^[1-9][0-9]*m$/;
const HOURS_REGEX = /^[1-9][0-9]*h$/;
const DAYS_REGEX = /^[1-9][0-9]*d$/;
export function parseDate(
dateString: string | undefined,
propertyName: string,
defaultValue: Date
): Date {
if (dateString === undefined) {
return defaultValue;
}
const parsedDate = parseIsoOrRelativeDate(dateString);
if (parsedDate === undefined) {
throw Boom.badRequest(
i18n.translate('xpack.actions.actionsClient.invalidDate', {
defaultMessage: 'Invalid date for parameter {field}: "{dateValue}"',
values: {
field: propertyName,
dateValue: dateString,
},
})
);
}
return parsedDate;
}
/**
* Parse an ISO date or NNx duration string as a Date
*
* @param dateString an ISO date or NNx "duration" string representing now-duration
* @returns a Date or undefined if the dateString was not valid
*/
export function parseIsoOrRelativeDate(dateString: string): Date | undefined {
const epochMillis = Date.parse(dateString);
if (!isNaN(epochMillis)) return new Date(epochMillis);
let millis: number;
try {
millis = parseDuration(dateString);
} catch (err) {
return;
}
return new Date(Date.now() - millis);
}
export function parseDuration(duration: string): number {
const parsed = parseInt(duration, 10);
if (isSeconds(duration)) {
return parsed * 1000;
} else if (isMinutes(duration)) {
return parsed * 60 * 1000;
} else if (isHours(duration)) {
return parsed * 60 * 60 * 1000;
} else if (isDays(duration)) {
return parsed * 24 * 60 * 60 * 1000;
}
throw new Error(
`Invalid duration "${duration}". Durations must be of the form {number}x. Example: 5s, 5m, 5h or 5d"`
);
}
function isSeconds(duration: string) {
return SECONDS_REGEX.test(duration);
}
function isMinutes(duration: string) {
return MINUTES_REGEX.test(duration);
}
function isHours(duration: string) {
return HOURS_REGEX.test(duration);
}
function isDays(duration: string) {
return DAYS_REGEX.test(duration);
}

View file

@ -19,7 +19,17 @@ import { ActionTypeDisabledError } from './errors';
import { actionsClientMock } from '../mocks';
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
import { IN_MEMORY_METRICS } from '../monitoring';
import { pick } from 'lodash';
const executeParamsFields = [
'actionId',
'isEphemeral',
'params',
'relatedSavedObjects',
'executionId',
'request.headers',
'taskInfo',
];
const spaceIdToNamespace = jest.fn();
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockedEncryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient();
@ -136,25 +146,25 @@ test('executes the task by calling the executor with proper parameters, using gi
{ namespace: 'namespace-test' }
);
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
relatedSavedObjects: [],
executionId: '123abc',
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
},
});
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
executeParams.request,
'/s/test'
@ -196,25 +206,25 @@ test('executes the task by calling the executor with proper parameters, using st
{ namespace: 'namespace-test' }
);
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '9',
isEphemeral: false,
params: { baz: true },
executionId: '123abc',
relatedSavedObjects: [],
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
},
});
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
executeParams.request,
'/s/test'
@ -251,26 +261,26 @@ test('executes the task by calling the executor with proper parameters when cons
{ namespace: 'namespace-test' }
);
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, [...executeParamsFields, 'consumer'])).toEqual({
actionId: '2',
consumer: 'test-consumer',
isEphemeral: false,
params: { baz: true },
relatedSavedObjects: [],
executionId: '123abc',
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
},
});
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
executeParams.request,
'/s/test'
@ -444,25 +454,25 @@ test('uses API key when provided', async () => {
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
executionId: '123abc',
relatedSavedObjects: [],
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
},
});
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
executeParams.request,
'/s/test'
@ -502,7 +512,8 @@ test('uses relatedSavedObjects merged with references when provided', async () =
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
@ -513,12 +524,12 @@ test('uses relatedSavedObjects merged with references when provided', async () =
type: 'some-type',
},
],
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
@ -554,7 +565,8 @@ test('uses relatedSavedObjects as is when references are empty', async () => {
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
@ -566,12 +578,12 @@ test('uses relatedSavedObjects as is when references are empty', async () => {
namespace: 'yo',
},
],
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
@ -611,16 +623,18 @@ test('sanitizes invalid relatedSavedObjects when provided', async () => {
});
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
request: expect.objectContaining({
request: {
headers: {
// base64 encoded "123:abc"
authorization: 'ApiKey MTIzOmFiYw==',
},
}),
},
executionId: '123abc',
relatedSavedObjects: [],
taskInfo: {
@ -656,22 +670,22 @@ test(`doesn't use API key when not provided`, async () => {
await taskRunner.run();
expect(mockedActionExecutor.execute).toHaveBeenCalledWith({
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(pick(executeParams, executeParamsFields)).toEqual({
actionId: '2',
isEphemeral: false,
params: { baz: true },
executionId: '123abc',
relatedSavedObjects: [],
request: expect.objectContaining({
request: {
headers: {},
}),
},
taskInfo: {
scheduled: new Date(),
attempts: 0,
},
});
const [executeParams] = mockedActionExecutor.execute.mock.calls[0];
expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith(
executeParams.request,
'/s/test'

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import { pick } from 'lodash';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, fromNullable, getOrElse } from 'fp-ts/lib/Option';
@ -84,6 +85,7 @@ export class TaskRunnerFactory {
scheduled: taskInstance.runAt,
attempts: taskInstance.attempts,
};
const actionExecutionId = uuidv4();
return {
async run() {
@ -122,6 +124,7 @@ export class TaskRunnerFactory {
executionId,
consumer,
relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects),
actionExecutionId,
});
} catch (e) {
logger.error(
@ -208,6 +211,7 @@ export class TaskRunnerFactory {
executionId,
relatedSavedObjects: (relatedSavedObjects || []) as RelatedSavedObjects,
...getSourceFromReferences(references),
actionExecutionId,
});
inMemoryMetrics.increment(IN_MEMORY_METRICS.ACTION_TIMEOUTS);

View file

@ -302,6 +302,7 @@ describe('Actions Plugin', () => {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
eventLog: eventLogMock.createStart(),
};
});
@ -380,6 +381,7 @@ describe('Actions Plugin', () => {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
eventLog: eventLogMock.createStart(),
};
}

View file

@ -33,7 +33,11 @@ import { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugi
import { SpacesPluginStart, SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { SecurityPluginSetup } from '@kbn/security-plugin/server';
import { IEventLogger, IEventLogService } from '@kbn/event-log-plugin/server';
import {
IEventLogClientService,
IEventLogger,
IEventLogService,
} from '@kbn/event-log-plugin/server';
import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server';
import {
ensureCleanupFailedExecutionsTaskScheduled,
@ -171,6 +175,7 @@ export interface ActionsPluginsStart {
encryptedSavedObjects: EncryptedSavedObjectsPluginStart;
taskManager: TaskManagerStartContract;
licensing: LicensingPluginStart;
eventLog: IEventLogClientService;
spaces?: SpacesPluginStart;
}
@ -458,6 +463,9 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
encryptedSavedObjectsClient,
logger: this.logger,
}),
async getEventLogClient() {
return plugins.eventLog.getClient(request);
},
});
};
@ -620,7 +628,7 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
} = this;
return async function actionsRouteHandlerContext(context, request) {
const [{ savedObjects }, { taskManager, encryptedSavedObjects }] =
const [{ savedObjects }, { taskManager, encryptedSavedObjects, eventLog }] =
await core.getStartServices();
const coreContext = await context.core;
@ -672,6 +680,9 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
}),
logger,
}),
async getEventLogClient() {
return eventLog.getClient(request);
},
});
},
listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!),

View file

@ -0,0 +1,68 @@
/*
* 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 { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './legacy/_mock_handler_arguments';
import { actionsClientMock } from '../actions_client.mock';
import { getGlobalExecutionKPIRoute } from './get_global_execution_kpi';
import { verifyAccessAndContext } from './verify_access_and_context';
const actionsClient = actionsClientMock.create();
jest.mock('./verify_access_and_context', () => ({
verifyAccessAndContext: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
(verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler);
});
describe('getGlobalExecutionKPIRoute', () => {
const dateString = new Date().toISOString();
const mockedExecutionLog = {
success: 3,
unknown: 0,
failure: 0,
warning: 0,
};
it('gets global execution KPI', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getGlobalExecutionKPIRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(
`"/internal/actions/_global_connector_execution_kpi"`
);
actionsClient.getGlobalExecutionKpiWithAuth.mockResolvedValue(mockedExecutionLog);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
body: {
date_start: dateString,
},
},
['ok']
);
await handler(context, req, res);
expect(actionsClient.getGlobalExecutionKpiWithAuth).toHaveBeenCalledTimes(1);
expect(actionsClient.getGlobalExecutionKpiWithAuth.mock.calls[0]).toEqual([
{
dateStart: dateString,
},
]);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import {
GetGlobalExecutionKPIParams,
INTERNAL_BASE_ACTION_API_PATH,
RewriteRequestCase,
} from '../../common';
import { verifyAccessAndContext } from './verify_access_and_context';
import { ActionsRequestHandlerContext } from '../types';
import { ILicenseState } from '../lib';
import { rewriteNamespaces } from './rewrite_namespaces';
const bodySchema = schema.object({
date_start: schema.string(),
date_end: schema.maybe(schema.string()),
filter: schema.maybe(schema.string()),
namespaces: schema.maybe(schema.arrayOf(schema.string())),
});
const rewriteReq: RewriteRequestCase<GetGlobalExecutionKPIParams> = ({
date_start: dateStart,
date_end: dateEnd,
namespaces,
...rest
}) => ({
...rest,
namespaces: rewriteNamespaces(namespaces),
dateStart,
dateEnd,
});
export const getGlobalExecutionKPIRoute = (
router: IRouter<ActionsRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ACTION_API_PATH}/_global_connector_execution_kpi`,
validate: {
body: bodySchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const actionsClient = (await context.actions).getActionsClient();
return res.ok({
body: await actionsClient.getGlobalExecutionKpiWithAuth(rewriteReq(req.body)),
});
})
)
);
};

View file

@ -0,0 +1,88 @@
/*
* 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 { getGlobalExecutionLogRoute } from './get_global_execution_logs';
import { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './legacy/_mock_handler_arguments';
import { actionsClientMock } from '../actions_client.mock';
import { IExecutionLogResult } from '../../common';
import { verifyAccessAndContext } from './verify_access_and_context';
const actionsClient = actionsClientMock.create();
jest.mock('./verify_access_and_context', () => ({
verifyAccessAndContext: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
(verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler);
});
describe('getRuleExecutionLogRoute', () => {
const dateString = new Date().toISOString();
const mockedExecutionLog: IExecutionLogResult = {
data: [
{
connector_name: 'test connector',
connector_id: '1',
duration_ms: 1,
id: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
schedule_delay_ms: 2783,
space_ids: ['default'],
status: 'success',
timestamp: '2023-01-05T15:55:50.495Z',
version: '8.7.0',
timed_out: false,
},
],
total: 1,
};
it('gets global execution logs', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getGlobalExecutionLogRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(
`"/internal/actions/_global_connector_execution_logs"`
);
actionsClient.getGlobalExecutionLogWithAuth.mockResolvedValue(mockedExecutionLog);
const [context, req, res] = mockHandlerArguments(
{ actionsClient },
{
body: {
date_start: dateString,
per_page: 10,
page: 1,
sort: [{ timestamp: { order: 'desc' } }],
},
},
['ok']
);
await handler(context, req, res);
expect(actionsClient.getGlobalExecutionLogWithAuth).toHaveBeenCalledTimes(1);
expect(actionsClient.getGlobalExecutionLogWithAuth.mock.calls[0]).toEqual([
{
dateStart: dateString,
page: 1,
perPage: 10,
sort: [{ timestamp: { order: 'desc' } }],
},
]);
expect(res.ok).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { ActionsRequestHandlerContext } from '../types';
import {
GetGlobalExecutionLogParams,
INTERNAL_BASE_ACTION_API_PATH,
RewriteRequestCase,
} from '../../common';
import { verifyAccessAndContext } from './verify_access_and_context';
import { rewriteNamespaces } from './rewrite_namespaces';
const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]);
const sortFieldSchema = schema.oneOf([
schema.object({ timestamp: schema.object({ order: sortOrderSchema }) }),
schema.object({ execution_duration: schema.object({ order: sortOrderSchema }) }),
schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }),
]);
const sortFieldsSchema = schema.arrayOf(sortFieldSchema, {
defaultValue: [{ timestamp: { order: 'desc' } }],
});
const bodySchema = schema.object({
date_start: schema.string(),
date_end: schema.maybe(schema.string()),
filter: schema.maybe(schema.string()),
per_page: schema.number({ defaultValue: 10, min: 1 }),
page: schema.number({ defaultValue: 1, min: 1 }),
sort: sortFieldsSchema,
namespaces: schema.maybe(schema.arrayOf(schema.string())),
});
const rewriteBodyReq: RewriteRequestCase<GetGlobalExecutionLogParams> = ({
date_start: dateStart,
date_end: dateEnd,
per_page: perPage,
namespaces,
...rest
}) => ({ ...rest, namespaces: rewriteNamespaces(namespaces), dateStart, dateEnd, perPage });
export const getGlobalExecutionLogRoute = (
router: IRouter<ActionsRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ACTION_API_PATH}/_global_connector_execution_logs`,
validate: {
body: bodySchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const actionsClient = (await context.actions).getActionsClient();
return res.ok({
body: await actionsClient.getGlobalExecutionLogWithAuth(rewriteBodyReq(req.body)),
});
})
)
);
};

View file

@ -19,6 +19,8 @@ import { updateActionRoute } from './update';
import { getOAuthAccessToken } from './get_oauth_access_token';
import { defineLegacyRoutes } from './legacy';
import { ActionsConfigurationUtilities } from '../actions_config';
import { getGlobalExecutionLogRoute } from './get_global_execution_logs';
import { getGlobalExecutionKPIRoute } from './get_global_execution_kpi';
export interface RouteOptions {
router: IRouter<ActionsRequestHandlerContext>;
@ -39,6 +41,8 @@ export function defineRoutes(opts: RouteOptions) {
updateActionRoute(router, licenseState);
connectorTypesRoute(router, licenseState);
executeActionRoute(router, licenseState);
getGlobalExecutionLogRoute(router, licenseState);
getGlobalExecutionKPIRoute(router, licenseState);
getOAuthAccessToken(router, licenseState, actionsConfigUtils);
}

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export const rewriteNamespaces = (namespaces?: Array<string | undefined>) =>
namespaces
? namespaces.map((id: string | undefined) => (id === 'default' ? undefined : id))
: undefined;

View file

@ -31,7 +31,7 @@
"@kbn/std",
"@kbn/logging",
"@kbn/logging-mocks",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/core-elasticsearch-client-server-mocks"
],
"exclude": [
"target/**/*",

View file

@ -392,6 +392,9 @@
"type_id": {
"type": "keyword",
"ignore_above": 1024
},
"space_agnostic": {
"type": "boolean"
}
}
},
@ -404,6 +407,26 @@
},
"version": {
"type": "version"
},
"action": {
"properties": {
"name": {
"ignore_above": 1024,
"type": "keyword"
},
"id": {
"type": "keyword",
"ignore_above": 1024
},
"execution": {
"properties": {
"uuid": {
"ignore_above": 1024,
"type": "keyword"
}
}
}
}
}
}
}

View file

@ -170,11 +170,23 @@ export const EventSchema = schema.maybe(
id: ecsString(),
type: ecsString(),
type_id: ecsString(),
space_agnostic: ecsBoolean(),
})
)
),
space_ids: ecsStringMulti(),
version: ecsVersion(),
action: schema.maybe(
schema.object({
name: ecsString(),
id: ecsString(),
execution: schema.maybe(
schema.object({
uuid: ecsString(),
})
),
})
),
})
),
})

View file

@ -178,6 +178,9 @@ exports.EcsCustomPropertyMappings = {
type: 'keyword',
ignore_above: 1024,
},
space_agnostic: {
type: 'boolean',
},
},
},
space_ids: {
@ -187,6 +190,26 @@ exports.EcsCustomPropertyMappings = {
version: {
type: 'version',
},
action: {
properties: {
name: {
ignore_above: 1024,
type: 'keyword',
},
id: {
type: 'keyword',
ignore_above: 1024,
},
execution: {
properties: {
uuid: {
ignore_above: 1024,
type: 'keyword',
},
},
},
},
},
},
},
};

View file

@ -827,6 +827,154 @@ describe('aggregateEventsWithAuthFilter', () => {
},
});
});
test('should call cluster with correct options when includeSpaceAgnostic is true', async () => {
clusterClient.search.mockResponse({
aggregations: {
genericAgg: {
buckets: [
{
key: 'execute',
doc_count: 10,
},
{
key: 'execute-start',
doc_count: 10,
},
{
key: 'new-instance',
doc_count: 2,
},
],
},
},
hits: {
hits: [],
total: { relation: 'eq', value: 0 },
},
took: 0,
timed_out: false,
_shards: {
failed: 0,
successful: 0,
total: 0,
skipped: 0,
},
});
const options: AggregateEventsWithAuthFilter = {
index: 'index-name',
namespaces: ['namespace'],
type: 'saved-object-type',
aggregateOptions: DEFAULT_OPTIONS as AggregateOptionsType,
authFilter: fromKueryExpression('test:test'),
includeSpaceAgnostic: true,
};
const result = await clusterClientAdapter.aggregateEventsWithAuthFilter(options);
const [query] = clusterClient.search.mock.calls[0];
expect(query).toEqual({
index: 'index-name',
body: {
size: 0,
query: {
bool: {
filter: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
test: 'test',
},
},
],
},
},
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
},
},
},
{
bool: {
should: [
{
bool: {
should: [
{
term: {
'kibana.saved_objects.namespace': {
value: 'namespace',
},
},
},
],
},
},
{
match: {
'kibana.saved_objects.space_agnostic': true,
},
},
],
},
},
],
},
},
},
},
],
},
},
aggs: {
genericAgg: {
term: {
field: 'event.action',
size: 10,
},
},
},
},
});
expect(result).toEqual({
aggregations: {
genericAgg: {
buckets: [
{
key: 'execute',
doc_count: 10,
},
{
key: 'execute-start',
doc_count: 10,
},
{
key: 'new-instance',
doc_count: 2,
},
],
},
},
});
});
});
describe('getQueryBody', () => {

View file

@ -64,6 +64,7 @@ export interface AggregateEventsWithAuthFilter {
type: string;
authFilter: KueryNode;
aggregateOptions: AggregateOptionsType;
includeSpaceAgnostic?: boolean;
}
export type FindEventsOptionsWithAuthFilter = QueryOptionsEventsWithAuthFilter & {
@ -85,6 +86,7 @@ export interface AggregateEventsBySavedObjectResult {
type GetQueryBodyWithAuthFilterOpts =
| (FindEventsOptionsWithAuthFilter & {
namespaces: AggregateEventsWithAuthFilter['namespaces'];
includeSpaceAgnostic?: AggregateEventsWithAuthFilter['includeSpaceAgnostic'];
})
| AggregateEventsWithAuthFilter;
@ -527,7 +529,7 @@ export function getQueryBodyWithAuthFilter(
opts: GetQueryBodyWithAuthFilterOpts,
queryOptions: QueryOptionsType
) {
const { namespaces, type, authFilter } = opts;
const { namespaces, type, authFilter, includeSpaceAgnostic } = opts;
const { start, end, filter } = queryOptions ?? {};
const ids = 'ids' in opts ? opts.ids : [];
@ -568,7 +570,22 @@ export function getQueryBodyWithAuthFilter(
},
{
bool: {
should: namespaceQuery,
...(includeSpaceAgnostic
? {
should: [
{
bool: {
should: namespaceQuery,
},
},
{
match: {
['kibana.saved_objects.space_agnostic']: true,
},
},
],
}
: { should: namespaceQuery }),
},
},
];

View file

@ -251,9 +251,15 @@ describe('EventLogStart', () => {
});
test('calls aggregateEventsWithAuthFilter with given aggregation', async () => {
savedObjectGetter.mockResolvedValueOnce(expectedSavedObject);
await eventLogClient.aggregateEventsWithAuthFilter('saved-object-type', testAuthFilter, {
aggs: { myAgg: {} },
});
await eventLogClient.aggregateEventsWithAuthFilter(
'saved-object-type',
testAuthFilter,
{
aggs: { myAgg: {} },
},
undefined,
true
);
expect(esContext.esAdapter.aggregateEventsWithAuthFilter).toHaveBeenCalledWith({
index: esContext.esNames.indexPattern,
namespaces: [undefined],
@ -270,6 +276,7 @@ describe('EventLogStart', () => {
},
],
},
includeSpaceAgnostic: true,
});
});
});

View file

@ -168,7 +168,8 @@ export class EventLogClient implements IEventLogClient {
type: string,
authFilter: KueryNode,
options?: AggregateOptionsType,
namespaces?: Array<string | undefined>
namespaces?: Array<string | undefined>,
includeSpaceAgnostic?: boolean
) {
if (!authFilter) {
throw new Error('No authorization filter defined!');
@ -188,6 +189,7 @@ export class EventLogClient implements IEventLogClient {
type,
authFilter,
aggregateOptions: { ...aggregateOptions, aggs } as AggregateOptionsType,
includeSpaceAgnostic,
});
}

View file

@ -74,7 +74,8 @@ export interface IEventLogClient {
type: string,
authFilter: KueryNode,
options?: Partial<AggregateOptionsType>,
namespaces?: Array<string | undefined>
namespaces?: Array<string | undefined>,
includeSpaceAgnostic?: boolean
): Promise<AggregateEventsBySavedObjectResult>;
}

View file

@ -34858,15 +34858,9 @@
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.failure": "Impossible de mettre à jour {property} pour {failure, plural, one {# règle} other {# règles}}.",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.someSuccess": "{property} mise à jour pour {success, plural, one {# règle} other {# règles}}, erreurs rencontrées pour {failure, plural, one {# règle} other {# règles}}.",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.success": "{property} mise à jour pour {total, plural, one {# règle} other {# règles}}.",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.prompt": "Voici les {visibleDocumentSize} premiers documents correspondant à votre recherche. Veuillez l'affiner pour en voir plus.",
"xpack.triggersActionsUI.sections.ruleDetails.rule.statusPanel.totalExecutions": "{executions, plural, one {# exécution} other {# exécutions}} au cours des dernières 24 heures",
"xpack.triggersActionsUI.sections.ruleDetails.ruleActionErrorLogFlyout.actionErrorsPlural": "{value, plural, one {action avec erreur} other {actions avec erreur}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleDetailsTitle": "{ruleName}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsCellPopover": "{value, plural, one {action avec erreur} other {actions avec erreur}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsTooltip": "{value, plural, one {# action avec erreur} other {# actions avec erreur}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResults": "Affichage de {range} sur {total, number} {type}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRange": "{start, number} - {end, number}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsType": "{total, plural, one {entrée} other {entrées}} de log",
"xpack.triggersActionsUI.sections.ruleDetails.ruleExecutionSummaryAndChart.loadSummaryError": "Impossible de charger le résumé de la règle : {message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRuleMessage": "Impossible de charger la règle : {message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRulesMessage": "Impossible de charger les règles : {message}",
@ -35181,37 +35175,26 @@
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts": "Alertes actives",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError": "Impossible de récupérer l'historique d'exécution",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration": "Durée",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActions": "Actions comportant des erreurs",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActionsToolTip": "Nombre d'actions ayant échoué.",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration": "Durée de la recherche ES",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id": "Id",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message": "Message",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts": "Nouvelles alertes",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.openActionErrorsFlyout": "Ouvrir le menu volant des erreurs daction",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts": "Alertes récupérées",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.response": "Réponse",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId": "ID règle",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName": "Règle",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActions": "Actions générées",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActionsToolTip": "Nombre total d'actions générées lors de l'exécution de la règle.",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay": "Retard sur la planification",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.searchPlaceholder": "Rechercher message de log dévénements",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showAll": "Afficher tout",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showOnlyFailures": "Afficher les échecs uniquement",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.spaceIds": "Espace",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActions": "Actions ayant réussi",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActionsToolTip": "Nombre d'actions ayant entièrement réussi.",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut": "Expiré",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp": "Horodatage",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration": "Durée de la recherche totale",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActions": "Actions déclenchées",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActionsToolTip": "Sous-ensemble des actions générées qui seront exécutées.",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.viewActionErrors": "Afficher les erreurs des actions",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogStatusFilterLabel": "Réponse",
"xpack.triggersActionsUI.sections.ruleDetails.manageLicensePlanBannerLinkTitle": "Gérer la licence",
"xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle": "Actions",
"xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun": "règle",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.backToTop": "Revenir en haut de la page.",
"xpack.triggersActionsUI.sections.ruleDetails.rule.alertsTabText": "Alertes",
"xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText": "Historique",
"xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel": "Récupéré",
@ -35223,7 +35206,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.alertsTooltip": "Statuts des alertes pour un maximum de 10 000 exécutions de règles les plus récentes.",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "Impossible de récupérer le KPI du log d'événements.",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "Réponses pour un maximum de 10 000 exécutions de règles les plus récentes.",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRangeNoResult": "0",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "Dernière réponse",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "Actif",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "Récupéré",

View file

@ -34828,15 +34828,9 @@
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.failure": "{failure, plural, other {# ルール}}の{property}を更新できませんでした。",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.someSuccess": "{success, plural, other {# ルール}}, {failure, plural, other {# ルール}}エラーの{property}が更新されました。",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.success": "{total, plural, other {# ルール}}の{property}が更新されました。",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.prompt": "これらは検索条件に一致した初めの{visibleDocumentSize}件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
"xpack.triggersActionsUI.sections.ruleDetails.rule.statusPanel.totalExecutions": "過去24時間で{executions, plural, other {# 件の実行}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleActionErrorLogFlyout.actionErrorsPlural": "{value, plural, other {個のエラーが発生したアクション}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleDetailsTitle": "{ruleName}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsCellPopover": "{value, plural, other {個のエラーが発生したアクション}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsTooltip": "{value, plural, other {# 個のエラーが発生したアクション}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResults": "{total, number} {type}件中{range}を表示しています",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRange": "{start, number} - {end, number}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsType": "ログ{total, plural, other {エントリ}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleExecutionSummaryAndChart.loadSummaryError": "ルール概要を読み込めません:{message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRuleMessage": "ルールを読み込めません:{message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRulesMessage": "ルールを読み込めません:{message}",
@ -35150,37 +35144,26 @@
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts": "アクティブアラート",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError": "実行履歴を取得できませんでした",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration": "期間",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActions": "エラーのアクション",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActionsToolTip": "失敗したアクションの数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration": "ES検索期間",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id": "Id",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message": "メッセージ",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts": "新しいアラート",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.openActionErrorsFlyout": "アクションエラーフライアウトを開く",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts": "回復されたアラート",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.response": "応答",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId": "ルールID",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName": "ルール",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActions": "生成されたアクション",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActionsToolTip": "ルールの実行時に生成されたアクションの合計数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay": "遅延をスケジュール",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.searchPlaceholder": "検索イベントログメッセージ",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showAll": "すべて表示",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showOnlyFailures": "失敗のみを表示",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.spaceIds": "スペース",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActions": "成功したアクション",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActionsToolTip": "正常に完了したアクションの数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut": "タイムアウトしました",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp": "タイムスタンプ",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration": "合計検索期間",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActions": "トリガーされたアクション",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActionsToolTip": "実行される生成済みアクションのサブセット。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.viewActionErrors": "アクションエラーを表示",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogStatusFilterLabel": "応答",
"xpack.triggersActionsUI.sections.ruleDetails.manageLicensePlanBannerLinkTitle": "ライセンスの管理",
"xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle": "アクション",
"xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun": "ルール",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.backToTop": "最上部へ戻る。",
"xpack.triggersActionsUI.sections.ruleDetails.rule.alertsTabText": "アラート",
"xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText": "履歴",
"xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel": "回復済み",
@ -35192,7 +35175,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.alertsTooltip": "最大10,000件の直近のルール実行のアラートステータス。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "イベントログKPIを取得できませんでした。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "最大10,000件の直近のルール実行の応答。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRangeNoResult": "0",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "前回の応答",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "アクティブ",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "回復済み",

View file

@ -34863,15 +34863,9 @@
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.failure": "无法更新 {failure, plural, other {# 个规则}}的 {property}。",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.someSuccess": "已更新 {success, plural, other {# 规则}}的 {property}{failure, plural, other {# 个规则}}遇到错误。",
"xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.success": "已更新 {total, plural, other {# 个规则}}的 {property}。",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.prompt": "下面是与您的搜索匹配的前 {visibleDocumentSize} 个文档,请优化您的搜索以查看其他文档。",
"xpack.triggersActionsUI.sections.ruleDetails.rule.statusPanel.totalExecutions": "过去 24 小时中的 {executions, plural, other {# 次执行}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleActionErrorLogFlyout.actionErrorsPlural": "{value, plural, other {个错误操作}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleDetailsTitle": "{ruleName}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsCellPopover": "{value, plural, other {个错误操作}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsTooltip": "{value, plural, other {# 个错误操作}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResults": "正在显示第 {range} 个(共 {total, number} 个){type}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRange": "{start, number} - {end, number}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsType": "日志{total, plural, other {条目}}",
"xpack.triggersActionsUI.sections.ruleDetails.ruleExecutionSummaryAndChart.loadSummaryError": "无法加载规则摘要:{message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRuleMessage": "无法加载规则:{message}",
"xpack.triggersActionsUI.sections.ruleDetails.unableToLoadRulesMessage": "无法加载规则:{message}",
@ -35186,37 +35180,26 @@
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts": "活动告警",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.apiError": "无法提取执行历史记录",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration": "持续时间",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActions": "错误操作",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActionsToolTip": "失败的操作数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration": "ES 搜索持续时间",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id": "ID",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message": "消息",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts": "新告警",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.openActionErrorsFlyout": "打开操作错误浮出控件",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts": "已恢复告警",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.response": "响应",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId": "规则 ID",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName": "规则",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActions": "生成的操作",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActionsToolTip": "运行规则时生成的总操作数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay": "计划延迟",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.searchPlaceholder": "搜索事件日志消息",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showAll": "全部显示",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showOnlyFailures": "仅显示失败次数",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.spaceIds": "工作区",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActions": "成功的操作",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActionsToolTip": "已成功完成的操作数。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut": "已超时",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp": "时间戳",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration": "搜索持续时间总计",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActions": "已触发操作",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActionsToolTip": "将运行的所生成操作的子集。",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.viewActionErrors": "查看操作错误",
"xpack.triggersActionsUI.sections.ruleDetails.eventLogStatusFilterLabel": "响应",
"xpack.triggersActionsUI.sections.ruleDetails.manageLicensePlanBannerLinkTitle": "管理许可证",
"xpack.triggersActionsUI.sections.ruleDetails.popoverButtonTitle": "操作",
"xpack.triggersActionsUI.sections.ruleDetails.redirectObjectNoun": "规则",
"xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.backToTop": "返回顶部。",
"xpack.triggersActionsUI.sections.ruleDetails.rule.alertsTabText": "告警",
"xpack.triggersActionsUI.sections.ruleDetails.rule.eventLogTabText": "历史记录",
"xpack.triggersActionsUI.sections.ruleDetails.rule.ruleSummary.recoveredLabel": "已恢复",
@ -35228,7 +35211,6 @@
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.alertsTooltip": "多达 10,000 次最近规则运行的告警状态。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.apiError": "无法提取事件日志 KPI。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogListKpi.responseTooltip": "多达 10,000 次最近规则运行的响应。",
"xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRangeNoResult": "0",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.ruleLastExecutionDescription": "上次响应",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.active": "活动",
"xpack.triggersActionsUI.sections.ruleDetails.rulesList.status.inactive": "已恢复",

View file

@ -6,7 +6,7 @@
*/
import React, { lazy } from 'react';
import { Switch, Route, Router } from 'react-router-dom';
import { Switch, Route, Router, Redirect } from 'react-router-dom';
import { ChromeBreadcrumb, CoreStart, CoreTheme, ScopedHistory } from '@kbn/core/public';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nProvider } from '@kbn/i18n-react';
@ -35,9 +35,10 @@ import {
import { setDataViewsService } from '../common/lib/data_apis';
import { KibanaContextProvider, useKibana } from '../common/lib/kibana';
import { ConnectorProvider } from './context/connector_context';
import { Section } from './constants';
const ActionsConnectorsList = lazy(
() => import('./sections/actions_connectors_list/components/actions_connectors_list')
const ActionsConnectorsHome = lazy(
() => import('./sections/actions_connectors_list/components/actions_connectors_home')
);
export interface TriggersAndActionsUiServices extends CoreStart {
@ -72,6 +73,8 @@ export const renderApp = (deps: TriggersAndActionsUiServices) => {
export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => {
const { dataViews, uiSettings, theme$ } = deps;
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
const sections: Section[] = ['connectors', 'logs'];
const sectionsRegex = sections.join('|');
setDataViewsService(dataViews);
return (
@ -80,7 +83,7 @@ export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => {
<KibanaThemeProvider theme$={theme$}>
<KibanaContextProvider services={{ ...deps }}>
<Router history={deps.history}>
<AppWithoutRouter />
<AppWithoutRouter sectionsRegex={sectionsRegex} />
</Router>
</KibanaContextProvider>
</KibanaThemeProvider>
@ -89,7 +92,7 @@ export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => {
);
};
export const AppWithoutRouter = () => {
export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) => {
const {
actions: { validateEmailAddresses },
} = useKibana().services;
@ -97,7 +100,12 @@ export const AppWithoutRouter = () => {
return (
<ConnectorProvider value={{ services: { validateEmailAddresses } }}>
<Switch>
<Route path={'/'} component={suspendedComponentWithProps(ActionsConnectorsList, 'xl')} />
<Route
path={`/:section(${sectionsRegex})`}
component={suspendedComponentWithProps(ActionsConnectorsHome, 'xl')}
/>
<Redirect from={'/'} to="connectors" />
</Switch>
</ConnectorProvider>
);

View file

@ -88,3 +88,22 @@ export const LOCKED_COLUMNS = [
export const RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [...LOCKED_COLUMNS.slice(1)];
export const GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = ['rule_name', ...LOCKED_COLUMNS];
export const DEFAULT_NUMBER_FORMAT = 'format:number:defaultPattern';
export const CONNECTOR_EXECUTION_LOG_COLUMN_IDS = [
'connector_id',
'space_ids',
'id',
'timestamp',
'status',
'connector_name',
'message',
'execution_duration',
'schedule_delay',
'timed_out',
] as const;
export const CONNECTOR_LOCKED_COLUMNS = ['timestamp', 'status', 'connector_name', 'message'];
export const GLOBAL_CONNECTOR_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS = [
...CONNECTOR_LOCKED_COLUMNS,
];

View file

@ -0,0 +1,60 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { useSpacesData } from '../../common/lib/kibana';
interface UseMultipleSpacesProps {
setShowFromAllSpaces: React.Dispatch<React.SetStateAction<boolean>>;
showFromAllSpaces: boolean;
visibleColumns: string[];
setVisibleColumns: React.Dispatch<React.SetStateAction<string[]>>;
}
export const useMultipleSpaces = (props: UseMultipleSpacesProps) => {
const { setShowFromAllSpaces, showFromAllSpaces, visibleColumns, setVisibleColumns } = props;
const spacesData = useSpacesData();
const onShowAllSpacesChange = useCallback(() => {
setShowFromAllSpaces((prev) => !prev);
const nextShowFromAllSpaces = !showFromAllSpaces;
if (nextShowFromAllSpaces && !visibleColumns.includes('space_ids')) {
const connectorNameIndex = visibleColumns.findIndex((c) => c === 'connector_name');
const newVisibleColumns = [...visibleColumns];
newVisibleColumns.splice(connectorNameIndex + 1, 0, 'space_ids');
setVisibleColumns(newVisibleColumns);
} else if (!nextShowFromAllSpaces && visibleColumns.includes('space_ids')) {
setVisibleColumns(visibleColumns.filter((c) => c !== 'space_ids'));
}
}, [setShowFromAllSpaces, showFromAllSpaces, visibleColumns, setVisibleColumns]);
const accessibleSpaceIds = useMemo(
() => (spacesData ? [...spacesData.spacesMap.values()].map((e) => e.id) : []),
[spacesData]
);
const canAccessMultipleSpaces = useMemo(
() => accessibleSpaceIds.length > 1,
[accessibleSpaceIds]
);
const namespaces = useMemo(
() => (showFromAllSpaces && spacesData ? accessibleSpaceIds : undefined),
[showFromAllSpaces, spacesData, accessibleSpaceIds]
);
const activeSpace = useMemo(
() => spacesData?.spacesMap.get(spacesData?.activeSpaceId),
[spacesData]
);
return {
onShowAllSpacesChange,
canAccessMultipleSpaces,
namespaces,
activeSpace,
};
};

View file

@ -11,3 +11,7 @@ export { createActionConnector } from './create';
export { deleteActions } from './delete';
export { executeAction } from './execute';
export { updateActionConnector } from './update';
export type { LoadGlobalConnectorExecutionLogAggregationsProps } from './load_execution_log_aggregations';
export { loadGlobalConnectorExecutionLogAggregations } from './load_execution_log_aggregations';
export type { LoadGlobalConnectorExecutionKPIAggregationsProps } from './load_execution_kpi_aggregations';
export { loadGlobalConnectorExecutionKPIAggregations } from './load_execution_kpi_aggregations';

View file

@ -0,0 +1,45 @@
/*
* 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 { httpServiceMock } from '@kbn/core/public/mocks';
import { loadGlobalConnectorExecutionKPIAggregations } from './load_execution_kpi_aggregations';
const http = httpServiceMock.createStartContract();
const mockResponse = {
failure: 0,
success: 1,
unknown: 0,
warning: 0,
};
describe('loadGlobalConnectorExecutionKPIAggregations', () => {
test('should call load execution kpi aggregation API', async () => {
http.post.mockResolvedValueOnce(mockResponse);
const result = await loadGlobalConnectorExecutionKPIAggregations({
dateStart: '2022-03-23T16:17:53.482Z',
dateEnd: '2022-03-23T16:17:53.482Z',
outcomeFilter: ['success', 'warning'],
message: 'test-message',
http,
});
expect(result).toEqual({
...mockResponse,
});
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/actions/_global_connector_execution_kpi",
Object {
"body": "{\\"filter\\":\\"(message: \\\\\\"test-message\\\\\\" OR error.message: \\\\\\"test-message\\\\\\") and (kibana.alerting.outcome:success OR (event.outcome: success AND NOT kibana.alerting.outcome:*) OR kibana.alerting.outcome: warning)\\",\\"date_start\\":\\"2022-03-23T16:17:53.482Z\\",\\"date_end\\":\\"2022-03-23T16:17:53.482Z\\"}",
},
]
`);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { HttpSetup } from '@kbn/core/public';
import { IExecutionKPIResult } from '@kbn/actions-plugin/common';
import { INTERNAL_BASE_ACTION_API_PATH } from '../../constants';
import { getFilter } from '../rule_api';
export interface LoadGlobalConnectorExecutionKPIAggregationsProps {
outcomeFilter?: string[];
message?: string;
dateStart: string;
dateEnd?: string;
namespaces?: Array<string | undefined>;
}
export const loadGlobalConnectorExecutionKPIAggregations = ({
http,
outcomeFilter,
message,
dateStart,
dateEnd,
namespaces,
}: LoadGlobalConnectorExecutionKPIAggregationsProps & { http: HttpSetup }) => {
const filter = getFilter({ outcomeFilter, message });
return http.post<IExecutionKPIResult>(
`${INTERNAL_BASE_ACTION_API_PATH}/_global_connector_execution_kpi`,
{
body: JSON.stringify({
filter: filter.length ? filter.join(' and ') : undefined,
date_start: dateStart,
date_end: dateEnd,
namespaces: namespaces ? JSON.stringify(namespaces) : namespaces,
}),
}
);
};

View file

@ -0,0 +1,81 @@
/*
* 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 { httpServiceMock } from '@kbn/core/public/mocks';
import {
loadGlobalConnectorExecutionLogAggregations,
SortField,
} from './load_execution_log_aggregations';
const http = httpServiceMock.createStartContract();
const mockResponse = {
data: [
{
connector_name: 'test connector',
duration_ms: 1,
id: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
message: 'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
schedule_delay_ms: 2783,
space_ids: ['default'],
status: 'success',
timestamp: '2023-01-05T15:55:50.495Z',
version: '8.7.0',
},
],
total: 1,
};
describe('loadGlobalConnectorExecutionLogAggregations', () => {
test('should call load execution log aggregation API', async () => {
http.post.mockResolvedValueOnce(mockResponse);
const sortTimestamp = {
timestamp: {
order: 'asc',
},
} as SortField;
const result = await loadGlobalConnectorExecutionLogAggregations({
dateStart: '2022-03-23T16:17:53.482Z',
dateEnd: '2022-03-23T16:17:53.482Z',
outcomeFilter: ['success', 'warning'],
message: 'test-message',
perPage: 10,
page: 0,
sort: [sortTimestamp],
http,
});
expect(result).toEqual({
...mockResponse,
data: [
{
connector_name: 'test connector',
execution_duration: 1,
id: '8b3af07e-7593-4c40-b704-9c06d3b06e58',
message:
'action executed: .server-log:6709f660-8d11-11ed-bae5-bd32cbc9eaaa: test connector',
schedule_delay: 2783,
space_ids: ['default'],
status: 'success',
timestamp: '2023-01-05T15:55:50.495Z',
version: '8.7.0',
},
],
});
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/actions/_global_connector_execution_logs",
Object {
"body": "{\\"date_start\\":\\"2022-03-23T16:17:53.482Z\\",\\"date_end\\":\\"2022-03-23T16:17:53.482Z\\",\\"filter\\":\\"(message: \\\\\\"test-message\\\\\\" OR error.message: \\\\\\"test-message\\\\\\") and (kibana.alerting.outcome:success OR (event.outcome: success AND NOT kibana.alerting.outcome:*) OR kibana.alerting.outcome: warning)\\",\\"per_page\\":10,\\"page\\":1,\\"sort\\":\\"[{\\\\\\"timestamp\\\\\\":{\\\\\\"order\\\\\\":\\\\\\"asc\\\\\\"}}]\\"}",
},
]
`);
});
});

View file

@ -0,0 +1,87 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import { HttpSetup } from '@kbn/core/public';
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
IExecutionLog,
ExecutionLogSortFields,
IExecutionLogResult,
AsApiContract,
INTERNAL_BASE_ACTION_API_PATH,
RewriteRequestCase,
} from '@kbn/actions-plugin/common';
import { getFilter } from '../rule_api';
const getRenamedLog = (data: IExecutionLog) => {
const { duration_ms, schedule_delay_ms, ...rest } = data;
return {
execution_duration: data.duration_ms,
schedule_delay: data.schedule_delay_ms,
...rest,
};
};
const rewriteBodyRes: RewriteRequestCase<IExecutionLogResult> = ({ data, ...rest }: any) => ({
data: data.map((log: IExecutionLog) => getRenamedLog(log)),
...rest,
});
export type SortField = Record<
ExecutionLogSortFields,
{
order: SortOrder;
}
>;
export interface LoadGlobalConnectorExecutionLogAggregationsProps {
dateStart: string;
dateEnd?: string;
outcomeFilter?: string[];
message?: string;
perPage?: number;
page?: number;
sort?: SortField[];
namespaces?: Array<string | undefined>;
}
export const loadGlobalConnectorExecutionLogAggregations = async ({
http,
dateStart,
dateEnd,
outcomeFilter,
message,
perPage = 10,
page = 0,
sort = [],
namespaces,
}: LoadGlobalConnectorExecutionLogAggregationsProps & { http: HttpSetup }) => {
const sortField: any[] = sort;
const filter = getFilter({ outcomeFilter, message });
const result = await http.post<AsApiContract<IExecutionLogResult>>(
`${INTERNAL_BASE_ACTION_API_PATH}/_global_connector_execution_logs`,
{
body: JSON.stringify({
date_start: dateStart,
date_end: dateEnd,
filter: filter.length ? filter.join(' and ') : undefined,
per_page: perPage,
// Need to add the + 1 for pages because APIs are 1 indexed,
// whereas data grid sorts are 0 indexed.
page: page + 1,
sort: sortField.length ? JSON.stringify(sortField) : undefined,
namespaces: namespaces ? JSON.stringify(namespaces) : undefined,
}),
}
);
return rewriteBodyRes(result);
};

View file

@ -46,5 +46,6 @@ export { runSoon } from './run_soon';
export { bulkDeleteRules } from './bulk_delete';
export { bulkEnableRules } from './bulk_enable';
export { bulkDisableRules } from './bulk_disable';
export { getFilter } from './get_filter';
export { getFlappingSettings } from './get_flapping_settings';
export { updateFlappingSettings } from './update_flapping_settings';

View file

@ -0,0 +1,133 @@
/*
* 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 from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { loadGlobalConnectorExecutionKPIAggregations } from '../../../lib/action_connector_api/load_execution_kpi_aggregations';
import { ConnectorEventLogListKPI } from './actions_connectors_event_log_list_kpi';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
jest.mock('../../../../common/lib/kibana', () => ({
useKibana: jest.fn().mockReturnValue({
services: {
notifications: { toast: { addDanger: jest.fn() } },
},
}),
}));
jest.mock('../../../lib/action_connector_api/load_execution_kpi_aggregations', () => ({
loadGlobalConnectorExecutionKPIAggregations: jest.fn(),
}));
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
const mockKpiResponse = {
success: 4,
unknown: 0,
failure: 60,
warning: 10,
};
const loadGlobalExecutionKPIAggregationsMock =
loadGlobalConnectorExecutionKPIAggregations as unknown as jest.MockedFunction<any>;
describe('actions_connectors_event_log_list_kpi', () => {
beforeEach(() => {
jest.clearAllMocks();
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
loadGlobalExecutionKPIAggregationsMock.mockResolvedValue(mockKpiResponse);
});
it('renders correctly', async () => {
const wrapper = mountWithIntl(
<ConnectorEventLogListKPI
dateStart="now-24h"
dateEnd="now"
loadGlobalConnectorExecutionKPIAggregations={loadGlobalExecutionKPIAggregationsMock}
/>
);
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-successOutcome"] .euiStat__title')
.first()
.text()
).toEqual('--');
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-warningOutcome"] .euiStat__title')
.first()
.text()
).toEqual('--');
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-failureOutcome"] .euiStat__title')
.first()
.text()
).toEqual('--');
// Let the load resolve
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadGlobalConnectorExecutionKPIAggregations).toHaveBeenCalledWith(
expect.objectContaining({
message: undefined,
outcomeFilter: undefined,
})
);
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-successOutcome"] .euiStat__title')
.first()
.text()
).toEqual(`${mockKpiResponse.success}`);
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-warningOutcome"] .euiStat__title')
.first()
.text()
).toEqual(`${mockKpiResponse.warning}`);
expect(
wrapper
.find('[data-test-subj="connectorEventLogKpi-failureOutcome"] .euiStat__title')
.first()
.text()
).toEqual(`${mockKpiResponse.failure}`);
});
it('calls KPI API with filters', async () => {
const wrapper = mountWithIntl(
<ConnectorEventLogListKPI
dateStart="now-24h"
dateEnd="now"
message="test"
outcomeFilter={['status: 123', 'test:456']}
loadGlobalConnectorExecutionKPIAggregations={loadGlobalExecutionKPIAggregationsMock}
/>
);
// Let the load resolve
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadGlobalExecutionKPIAggregationsMock).toHaveBeenCalledWith(
expect.objectContaining({
message: 'test',
outcomeFilter: ['status: 123', 'test:456'],
})
);
});
});

View file

@ -0,0 +1,187 @@
/*
* 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, { useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiSpacer } from '@elastic/eui';
import { IExecutionKPIResult } from '@kbn/actions-plugin/common';
import {
ComponentOpts as ConnectorApis,
withActionOperations,
} from '../../common/components/with_actions_api_operations';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
import { EventLogListStatus, EventLogStat } from '../../common/components/event_log';
const getParsedDate = (date: string) => {
if (date.includes('now')) {
return datemath.parse(date)?.format() || date;
}
return date;
};
const API_FAILED_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogListKpi.apiError',
{
defaultMessage: 'Failed to fetch event log KPI.',
}
);
const RESPONSE_TOOLTIP = i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogListKpi.responseTooltip',
{
defaultMessage: 'The responses for up to 10,000 most recent actions triggered.',
}
);
export type ConnectorEventLogListKPIProps = {
dateStart: string;
dateEnd: string;
outcomeFilter?: string[];
message?: string;
refreshToken?: number;
namespaces?: Array<string | undefined>;
} & Pick<ConnectorApis, 'loadGlobalConnectorExecutionKPIAggregations'>;
export const ConnectorEventLogListKPI = (props: ConnectorEventLogListKPIProps) => {
const {
dateStart,
dateEnd,
outcomeFilter,
message,
refreshToken,
namespaces,
loadGlobalConnectorExecutionKPIAggregations,
} = props;
const {
notifications: { toasts },
} = useKibana().services;
const isInitialized = useRef(false);
const isUsingExecutionStatus = getIsExperimentalFeatureEnabled('ruleUseExecutionStatus');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [kpi, setKpi] = useState<IExecutionKPIResult>();
const loadKPIFn = useMemo(() => {
return loadGlobalConnectorExecutionKPIAggregations;
}, [loadGlobalConnectorExecutionKPIAggregations]);
const loadKPIs = async () => {
setIsLoading(true);
try {
const newKpi = await loadKPIFn({
dateStart: getParsedDate(dateStart),
dateEnd: getParsedDate(dateEnd),
outcomeFilter,
message,
...(namespaces ? { namespaces } : {}),
});
setKpi(newKpi);
} catch (e) {
toasts.addDanger({
title: API_FAILED_MESSAGE,
text: e.body?.message ?? e,
});
}
setIsLoading(false);
};
useEffect(() => {
loadKPIs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStart, dateEnd, outcomeFilter, message, namespaces]);
useEffect(() => {
if (isInitialized.current) {
loadKPIs();
}
isInitialized.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken]);
const isLoadingData = useMemo(() => isLoading || !kpi, [isLoading, kpi]);
const getStatDescription = (element: React.ReactNode) => {
return (
<>
{element}
<EuiSpacer size="s" />
</>
);
};
return (
<EuiFlexGroup>
<EuiFlexItem grow={4}>
<EventLogStat title="Responses" tooltip={RESPONSE_TOOLTIP}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiStat
data-test-subj="connectorEventLogKpi-successOutcome"
description={getStatDescription(
<EventLogListStatus
status="success"
useExecutionStatus={isUsingExecutionStatus}
/>
)}
titleSize="s"
title={kpi?.success ?? 0}
isLoading={isLoadingData}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="connectorEventLogKpi-warningOutcome"
description={getStatDescription(
<EventLogListStatus
status="warning"
useExecutionStatus={isUsingExecutionStatus}
/>
)}
titleSize="s"
title={kpi?.warning ?? 0}
isLoading={isLoadingData}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="connectorEventLogKpi-failureOutcome"
description={getStatDescription(
<EventLogListStatus
status="failure"
useExecutionStatus={isUsingExecutionStatus}
/>
)}
titleSize="s"
title={kpi?.failure ?? 0}
isLoading={isLoadingData}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
data-test-subj="connectorEventLogKpi-unknownOutcome"
description={getStatDescription(
<EventLogListStatus
status="unknown"
useExecutionStatus={isUsingExecutionStatus}
/>
)}
titleSize="s"
title={kpi?.unknown ?? 0}
isLoading={isLoadingData}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EventLogStat>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const ConnectorEventLogListKPIWithApi = withActionOperations(ConnectorEventLogListKPI);

View file

@ -0,0 +1,119 @@
/*
* 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 from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { loadGlobalConnectorExecutionLogAggregations } from '../../../lib/action_connector_api/load_execution_log_aggregations';
import { ConnectorEventLogListTable } from './actions_connectors_event_log_list_table';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
jest.mock('../../../../common/lib/kibana', () => ({
...jest.requireActual('../../../../common/lib/kibana'),
useKibana: jest.fn().mockReturnValue({
services: {
notifications: { toasts: { addDanger: jest.fn() } },
},
}),
}));
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
jest.mock('../../../lib/action_connector_api/load_execution_log_aggregations', () => ({
loadGlobalConnectorExecutionLogAggregations: jest.fn(),
}));
const mockResponse = {
total: 1,
data: [
{
connector_id: '86020b10-9b3b-11ed-8422-2f5a388a317d',
connector_name: 'test connector',
duration_ms: 0,
id: '3895f21e-8de8-416f-9ab2-6ca5f8a4b294',
message: 'action executed: .server-log:86020b10-9b3b-11ed-8422-2f5a388a317d: test',
schedule_delay_ms: 2923,
space_ids: ['default'],
status: 'success',
timed_out: false,
timestamp: '2023-01-23T16:41:01.260Z',
version: '8.7.0',
},
],
};
const loadGlobalExecutionLogAggregationsMock =
loadGlobalConnectorExecutionLogAggregations as unknown as jest.MockedFunction<any>;
describe('actions_connectors_event_log_list_table', () => {
beforeEach(() => {
jest.clearAllMocks();
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
loadGlobalExecutionLogAggregationsMock.mockResolvedValue(mockResponse);
});
it('renders correctly', async () => {
const wrapper = mountWithIntl(
<ConnectorEventLogListTable
refreshToken={0}
initialPageSize={50}
hasConnectorNames={true}
hasAllSpaceSwitch={true}
loadGlobalConnectorExecutionLogAggregations={loadGlobalExecutionLogAggregationsMock}
/>
);
expect(wrapper.find('[data-test-subj="connectorEventLogListProgressBar"]')).toBeTruthy();
expect(wrapper.find('[data-test-subj="connectorEventLogListDatePicker"]')).toBeTruthy();
expect(wrapper.find('[data-test-subj="connectorEventLogListKpi"]')).toBeTruthy();
// Let the load resolve
await act(async () => {
await nextTick();
wrapper.update();
});
expect(loadGlobalConnectorExecutionLogAggregations).toHaveBeenCalledWith(
expect.objectContaining({
message: '',
namespaces: undefined,
outcomeFilter: [],
page: 0,
perPage: 50,
sort: [],
})
);
expect(wrapper.find('[data-test-subj="connectorEventLogListProgressBar"]')).toEqual({});
expect(
wrapper
.find('[data-gridcell-column-id="timestamp"] .euiDataGridRowCell__truncate')
.first()
.text()
).toBeTruthy();
expect(
wrapper
.find('[data-gridcell-column-id="status"] .euiDataGridRowCell__truncate')
.first()
.text()
).toEqual('succeeded');
expect(
wrapper
.find('[data-gridcell-column-id="connector_name"] .euiDataGridRowCell__truncate')
.first()
.text()
).toEqual('test connector');
expect(
wrapper
.find('[data-gridcell-column-id="message"] .euiDataGridRowCell__truncate')
.first()
.text()
).toEqual('action executed: .server-log:86020b10-9b3b-11ed-8422-2f5a388a317d: test');
});
});

View file

@ -0,0 +1,597 @@
/*
* 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, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import {
EuiFieldSearch,
EuiFlexItem,
EuiFlexGroup,
EuiProgress,
EuiSpacer,
EuiDataGridSorting,
Pagination,
EuiSuperDatePicker,
OnTimeChangeProps,
EuiSwitch,
EuiDataGridColumn,
} from '@elastic/eui';
import { SpacesContextProps } from '@kbn/spaces-plugin/public';
import { IExecutionLog } from '@kbn/actions-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import {
GLOBAL_CONNECTOR_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
CONNECTOR_LOCKED_COLUMNS,
} from '../../../constants';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { LoadGlobalConnectorExecutionLogAggregationsProps } from '../../../lib/action_connector_api';
import {
ComponentOpts as ConnectorApis,
withActionOperations,
} from '../../common/components/with_actions_api_operations';
import { RefineSearchPrompt } from '../../common/components/refine_search_prompt';
import { ConnectorEventLogListKPIWithApi as ConnectorEventLogListKPI } from './actions_connectors_event_log_list_kpi';
import {
EventLogDataGrid,
EventLogListStatusFilter,
getIsColumnSortable,
} from '../../common/components/event_log';
import { useMultipleSpaces } from '../../../hooks/use_multiple_spaces';
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
const getParsedDate = (date: string) => {
if (date.includes('now')) {
return datemath.parse(date)?.format() || date;
}
return date;
};
const API_FAILED_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.apiError',
{
defaultMessage: 'Failed to fetch execution history',
}
);
const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.searchPlaceholder',
{
defaultMessage: 'Search event log message',
}
);
const CONNECTOR_EVENT_LOG_LIST_STORAGE_KEY =
'xpack.triggersActionsUI.connectorEventLogList.initialColumns';
const getDefaultColumns = (columns: string[]) => {
const columnsWithoutLockedColumn = columns.filter(
(column) => !CONNECTOR_LOCKED_COLUMNS.includes(column)
);
return [...CONNECTOR_LOCKED_COLUMNS, ...columnsWithoutLockedColumn];
};
const ALL_SPACES_LABEL = i18n.translate(
'xpack.triggersActionsUI.connectorEventLogList.showAllSpacesToggle',
{
defaultMessage: 'Show connectors from all spaces',
}
);
const updateButtonProps = {
iconOnly: true,
fill: false,
};
const MAX_RESULTS = 1000;
export type ConnectorEventLogListOptions = 'stackManagement' | 'default';
export type ConnectorEventLogListCommonProps = {
localStorageKey?: string;
refreshToken?: number;
initialPageSize?: number;
hasConnectorNames?: boolean;
hasAllSpaceSwitch?: boolean;
} & Pick<ConnectorApis, 'loadGlobalConnectorExecutionLogAggregations'>;
export type ConnectorEventLogListTableProps<T extends ConnectorEventLogListOptions = 'default'> =
T extends 'default'
? ConnectorEventLogListCommonProps
: T extends 'stackManagement'
? ConnectorEventLogListCommonProps
: never;
export const ConnectorEventLogListTable = <T extends ConnectorEventLogListOptions>(
props: ConnectorEventLogListTableProps<T>
) => {
const {
localStorageKey = CONNECTOR_EVENT_LOG_LIST_STORAGE_KEY,
refreshToken,
loadGlobalConnectorExecutionLogAggregations,
initialPageSize = 10,
hasConnectorNames = false,
hasAllSpaceSwitch = false,
} = props;
const { uiSettings, notifications } = useKibana().services;
const [searchText, setSearchText] = useState<string>('');
const [search, setSearch] = useState<string>('');
const [internalRefreshToken, setInternalRefreshToken] = useState<number | undefined>(
refreshToken
);
const [showFromAllSpaces, setShowFromAllSpaces] = useState(false);
// Data grid states
const [logs, setLogs] = useState<IExecutionLog[]>();
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
return getDefaultColumns(GLOBAL_CONNECTOR_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS);
});
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [filter, setFilter] = useState<string[]>([]);
const [actualTotalItemCount, setActualTotalItemCount] = useState<number>(0);
const [pagination, setPagination] = useState<Pagination>({
pageIndex: 0,
pageSize: initialPageSize,
totalItemCount: 0,
});
// Date related states
const [isLoading, setIsLoading] = useState<boolean>(false);
const [dateStart, setDateStart] = useState<string>('now-24h');
const [dateEnd, setDateEnd] = useState<string>('now');
const [dateFormat] = useState(() => uiSettings?.get('dateFormat'));
const [commonlyUsedRanges] = useState(() => {
return (
uiSettings
?.get('timepicker:quickRanges')
?.map(({ from, to, display }: { from: string; to: string; display: string }) => ({
start: from,
end: to,
label: display,
})) || []
);
});
const { onShowAllSpacesChange, canAccessMultipleSpaces, namespaces } = useMultipleSpaces({
setShowFromAllSpaces,
showFromAllSpaces,
visibleColumns,
setVisibleColumns,
});
const isInitialized = useRef(false);
const isOnLastPage = useMemo(() => {
const { pageIndex, pageSize } = pagination;
return (pageIndex + 1) * pageSize >= MAX_RESULTS;
}, [pagination]);
// Formats the sort columns to be consumed by the API endpoint
const formattedSort = useMemo(() => {
return sortingColumns.map(({ id: sortId, direction }) => ({
[sortId]: {
order: direction,
},
}));
}, [sortingColumns]);
const loadEventLogs = async () => {
if (!loadGlobalConnectorExecutionLogAggregations) {
return;
}
setIsLoading(true);
try {
const result = await loadGlobalConnectorExecutionLogAggregations({
sort: formattedSort as LoadGlobalConnectorExecutionLogAggregationsProps['sort'],
outcomeFilter: filter,
message: searchText,
dateStart: getParsedDate(dateStart),
dateEnd: getParsedDate(dateEnd),
page: pagination.pageIndex,
perPage: pagination.pageSize,
namespaces,
});
setLogs(result.data);
setPagination({
...pagination,
totalItemCount: Math.min(result.total, MAX_RESULTS),
});
setActualTotalItemCount(result.total);
} catch (e) {
notifications.toasts.addDanger({
title: API_FAILED_MESSAGE,
text: e.body?.message ?? e,
});
}
setIsLoading(false);
};
const onChangeItemsPerPage = useCallback(
(pageSize: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
pageSize,
}));
},
[setPagination]
);
const onChangePage = useCallback(
(pageIndex: number) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex,
}));
},
[setPagination]
);
const onTimeChange = useCallback(
({ start, end, isInvalid }: OnTimeChangeProps) => {
if (isInvalid) {
return;
}
setDateStart(start);
setDateEnd(end);
},
[setDateStart, setDateEnd]
);
const onRefresh = () => {
setInternalRefreshToken(Date.now());
loadEventLogs();
};
const onFilterChange = useCallback(
(newFilter: string[]) => {
setPagination((prevPagination) => ({
...prevPagination,
pageIndex: 0,
}));
setFilter(newFilter);
},
[setPagination, setFilter]
);
const onSearchChange = useCallback(
(e) => {
if (e.target.value === '') {
setSearchText('');
}
setSearch(e.target.value);
},
[setSearchText, setSearch]
);
const onKeyUp = useCallback(
(e) => {
if (e.key === 'Enter') {
setSearchText(search);
}
},
[search, setSearchText]
);
const columns: EuiDataGridColumn[] = useMemo(
() => [
{
id: 'connector_id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.connectorId',
{
defaultMessage: 'Connector Id',
}
),
isSortable: getIsColumnSortable('connector_id'),
},
{
id: 'id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.id',
{
defaultMessage: 'Execution Id',
}
),
isSortable: getIsColumnSortable('id'),
},
{
id: 'timestamp',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.timestamp',
{
defaultMessage: 'Timestamp',
}
),
isSortable: getIsColumnSortable('timestamp'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 250,
},
{
id: 'status',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.response',
{
defaultMessage: 'Response',
}
),
actions: {
showHide: false,
showSortAsc: false,
showSortDesc: false,
additional: [
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.showOnlyFailures',
{
defaultMessage: 'Show only failures',
}
),
onClick: () => onFilterChange(['failure']),
size: 'xs',
},
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.showAll',
{
defaultMessage: 'Show all',
}
),
onClick: () => onFilterChange([]),
size: 'xs',
},
],
},
isSortable: getIsColumnSortable('status'),
isResizable: false,
initialWidth: 150,
},
...(hasConnectorNames
? [
{
id: 'connector_name',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.connectorName',
{
defaultMessage: 'Connector',
}
),
isSortable: getIsColumnSortable('connector_name'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
{
id: 'message',
actions: {
showSortAsc: false,
showSortDesc: false,
},
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.message',
{
defaultMessage: 'Message',
}
),
isSortable: getIsColumnSortable('message'),
cellActions: [],
},
{
id: 'execution_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.duration',
{
defaultMessage: 'Duration',
}
),
isSortable: getIsColumnSortable('execution_duration'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 100,
},
{
id: 'schedule_delay',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.scheduleDelay',
{
defaultMessage: 'Schedule delay',
}
),
isSortable: getIsColumnSortable('schedule_delay'),
},
{
id: 'timed_out',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.timedOut',
{
defaultMessage: 'Timed out',
}
),
isSortable: getIsColumnSortable('timed_out'),
},
...(showFromAllSpaces
? [
{
id: 'space_ids',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.connectorEventLogList.eventLogColumn.spaceIds',
{
defaultMessage: 'Space',
}
),
isSortable: getIsColumnSortable('space_ids'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
],
[onFilterChange, hasConnectorNames, showFromAllSpaces]
);
const renderList = () => {
if (!logs) {
return <CenterJustifiedSpinner />;
}
return (
<>
{isLoading && (
<EuiProgress size="xs" color="accent" data-test-subj="connectorEventLogListProgressBar" />
)}
<EventLogDataGrid
columns={columns}
logs={logs}
pagination={pagination}
sortingColumns={sortingColumns}
visibleColumns={visibleColumns}
dateFormat={dateFormat}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={onChangePage}
setVisibleColumns={setVisibleColumns}
setSortingColumns={setSortingColumns}
/>
</>
);
};
useEffect(() => {
loadEventLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sortingColumns,
dateStart,
dateEnd,
filter,
pagination.pageIndex,
pagination.pageSize,
searchText,
showFromAllSpaces,
]);
useEffect(() => {
if (isInitialized.current) {
loadEventLogs();
}
isInitialized.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshToken]);
useEffect(() => {
localStorage.setItem(localStorageKey, JSON.stringify(visibleColumns));
}, [localStorageKey, visibleColumns]);
useEffect(() => {
setInternalRefreshToken(refreshToken);
}, [refreshToken]);
return (
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiFieldSearch
fullWidth
isClearable
value={search}
onChange={onSearchChange}
onKeyUp={onKeyUp}
placeholder={SEARCH_PLACEHOLDER}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EventLogListStatusFilter selectedOptions={filter} onChange={onFilterChange} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
data-test-subj="connectorEventLogListDatePicker"
width="auto"
isLoading={isLoading}
start={dateStart}
end={dateEnd}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
updateButtonProps={updateButtonProps}
/>
</EuiFlexItem>
{hasAllSpaceSwitch && canAccessMultipleSpaces && (
<EuiFlexItem data-test-subj="showAllSpacesSwitch">
<EuiSwitch
label={ALL_SPACES_LABEL}
checked={showFromAllSpaces}
onChange={onShowAllSpacesChange}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ConnectorEventLogListKPI
data-test-subj="connectorEventLogListKpi"
dateStart={dateStart}
dateEnd={dateEnd}
outcomeFilter={filter}
message={searchText}
refreshToken={internalRefreshToken}
namespaces={namespaces}
/>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem>
{renderList()}
{isOnLastPage && (
<RefineSearchPrompt
documentSize={actualTotalItemCount}
visibleDocumentSize={MAX_RESULTS}
backToTopAnchor="logs"
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
const ConnectorEventLogListTableWithSpaces: React.FC<ConnectorEventLogListTableProps> = (props) => {
const { spaces } = useKibana().services;
// eslint-disable-next-line react-hooks/exhaustive-deps
const SpacesContextWrapper = useCallback(
spaces ? spaces.ui.components.getSpacesContextProvider : getEmptyFunctionComponent,
[spaces]
);
return (
<SpacesContextWrapper feature="triggersActions">
<ConnectorEventLogListTable {...props} />
</SpacesContextWrapper>
);
};
export const ConnectorEventLogListTableWithApi = withActionOperations(
ConnectorEventLogListTableWithSpaces
);
// eslint-disable-next-line import/no-default-export
export { ConnectorEventLogListTableWithApi as default };

View file

@ -0,0 +1,137 @@
/*
* 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, { lazy, useCallback, useEffect } from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiButtonEmpty, EuiPageHeader, EuiPageTemplate } from '@elastic/eui';
import { routeToConnectors, routeToLogs, Section } from '../../../constants';
import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb';
import { getCurrentDocTitle } from '../../../lib/doc_title';
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
import { HealthContextProvider } from '../../../context/health_context';
import { HealthCheck } from '../../../components/health_check';
import { useKibana } from '../../../../common/lib/kibana';
import ConnectorEventLogListTableWithApi from './actions_connectors_event_log_list_table';
const ConnectorsList = lazy(() => import('./actions_connectors_list'));
export interface MatchParams {
section: Section;
}
export const ActionsConnectorsHome: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { section },
},
history,
}) => {
const { chrome, setBreadcrumbs, docLinks } = useKibana().services;
const tabs: Array<{
id: Section;
name: React.ReactNode;
}> = [];
tabs.push({
id: 'connectors',
name: (
<FormattedMessage
id="xpack.triggersActionsUI.connectors.home.connectorsTabTitle"
defaultMessage="Connectors"
/>
),
});
tabs.push({
id: 'logs',
name: (
<FormattedMessage
id="xpack.triggersActionsUI.connectors.home.logsTabTitle"
defaultMessage="Logs"
/>
),
});
const onSectionChange = (newSection: Section) => {
history.push(`/${newSection}`);
};
// Set breadcrumb and page title
useEffect(() => {
setBreadcrumbs([getAlertingSectionBreadcrumb(section || 'connectors')]);
chrome.docTitle.change(getCurrentDocTitle(section || 'connectors'));
}, [section, chrome, setBreadcrumbs]);
const renderLogsList = useCallback(() => {
return (
<EuiPageTemplate.Section grow={false} paddingSize="none">
{suspendedComponentWithProps(
ConnectorEventLogListTableWithApi,
'xl'
)({
refreshToken: 0,
initialPageSize: 50,
hasConnectorNames: true,
hasAllSpaceSwitch: true,
})}
</EuiPageTemplate.Section>
);
}, []);
return (
<>
<EuiPageHeader
bottomBorder
paddingSize="none"
pageTitle={i18n.translate('xpack.triggersActionsUI.connectors.home.appTitle', {
defaultMessage: 'Connectors',
})}
description={i18n.translate('xpack.triggersActionsUI.connectors.home.description', {
defaultMessage: 'Connect third-party software with your alerting data.',
})}
rightSideItems={[
<EuiButtonEmpty
data-test-subj="documentationButton"
key="documentation-button"
href={docLinks.links.alerting.actionTypes}
iconType="help"
>
<FormattedMessage
id="xpack.triggersActionsUI.connectors.home.documentationButtonLabel"
defaultMessage="Documentation"
/>
</EuiButtonEmpty>,
]}
tabs={tabs.map((tab) => ({
label: tab.name,
onClick: () => onSectionChange(tab.id),
isSelected: tab.id === section,
key: tab.id,
'data-test-subj': `${tab.id}Tab`,
}))}
/>
<EuiSpacer size="l" />
<HealthContextProvider>
<HealthCheck waitForCheck={true}>
<Switch>
<Route exact path={routeToLogs} component={renderLogsList} />
<Route
exact
path={routeToConnectors}
component={suspendedComponentWithProps(ConnectorsList, 'xl')}
/>
</Switch>
</HealthCheck>
</HealthContextProvider>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { ActionsConnectorsHome as default };

View file

@ -9,7 +9,6 @@ import { ClassNames } from '@emotion/react';
import React, { useState, useEffect } from 'react';
import {
EuiInMemoryTable,
EuiSpacer,
EuiButton,
EuiLink,
EuiIconTip,
@ -412,57 +411,41 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
options: actionTypesList,
},
],
toolsLeft:
selectedItems.length === 0 || !canDelete
? []
: [
<EuiButton
key="delete"
iconType="trash"
color="danger"
data-test-subj="bulkDelete"
onClick={() => onDelete(selectedItems)}
title={
canDelete
? undefined
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle',
{ defaultMessage: 'Unable to delete connectors' }
)
}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteLabel"
defaultMessage="Delete {count}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>,
],
}}
/>
);
return (
<>
{actionConnectorTableItems.length !== 0 && (
<EuiPageTemplate.Header
paddingSize="none"
pageTitle={i18n.translate('xpack.triggersActionsUI.connectors.home.appTitle', {
defaultMessage: 'Connectors',
})}
description={i18n.translate('xpack.triggersActionsUI.connectors.home.description', {
defaultMessage: 'Connect third-party software with your alerting data.',
})}
rightSideItems={(canSave
toolsLeft: (selectedItems.length === 0 || !canDelete
? []
: [
<EuiButton
key="delete"
iconType="trash"
color="danger"
data-test-subj="bulkDelete"
onClick={() => onDelete(selectedItems)}
title={
canDelete
? undefined
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle',
{ defaultMessage: 'Unable to delete connectors' }
)
}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteLabel"
defaultMessage="Delete {count}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>,
]
).concat(
canSave
? [
<EuiButton
data-test-subj="createActionButton"
key="create-action"
fill
onClick={() => setAddFlyoutVisibility(true)}
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.addActionButtonLabel"
@ -471,21 +454,13 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
</EuiButton>,
]
: []
).concat([
<EuiButtonEmpty
data-test-subj="documentationButton"
key="documentation-button"
href={docLinks.links.alerting.connectors}
iconType="help"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionsConnectorsList.documentationButtonLabel"
defaultMessage="Documentation"
/>
</EuiButtonEmpty>,
])}
/>
)}
),
}}
/>
);
return (
<>
<EuiPageTemplate.Section
paddingSize="none"
data-test-subj="actionsList"
@ -535,7 +510,6 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
setIsLoadingState={(isLoading: boolean) => setIsLoadingActionTypes(isLoading)}
/>
<EuiSpacer size="m" />
{/* Render the view based on if there's data or if they can save */}
{(isLoadingActions || isLoadingActionTypes) && <CenterJustifiedSpinner />}
{actionConnectorTableItems.length !== 0 && table}

View file

@ -0,0 +1,373 @@
/*
* 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, { useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiDataGrid,
EuiDataGridStyle,
Pagination,
EuiDataGridCellValueElementProps,
EuiDataGridSorting,
EuiDataGridColumn,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiBadge,
EuiDataGridCellPopoverElementProps,
useEuiTheme,
EuiToolTip,
EuiText,
EuiIcon,
} from '@elastic/eui';
import {
IExecutionLog,
executionLogSortableColumns,
ExecutionLogSortFields,
} from '@kbn/alerting-plugin/common';
import { IExecutionLog as IConnectorsExecutionLog } from '@kbn/actions-plugin/common';
import { get } from 'lodash';
import { getIsExperimentalFeatureEnabled } from '../../../../../common/get_experimental_features';
import { EventLogListCellRenderer, ColumnId, EventLogPaginationStatus } from '.';
import { RuleActionErrorBadge } from '../../../rule_details/components/rule_action_error_badge';
import './event_log_list.scss';
export const getIsColumnSortable = (columnId: string) => {
return executionLogSortableColumns.includes(columnId as ExecutionLogSortFields);
};
const getErroredActionsTranslation = (errors: number) => {
return i18n.translate('xpack.triggersActionsUI.sections.eventLogDataGrid.erroredActionsTooltip', {
defaultMessage: '{value, plural, one {# errored action} other {# errored actions}}',
values: { value: errors },
});
};
const PAGE_SIZE_OPTIONS = [10, 50, 100];
type ExecutionLog = IExecutionLog | IConnectorsExecutionLog;
export interface EventLogDataGrid {
columns: EuiDataGridColumn[];
logs: ExecutionLog[];
pagination: Pagination;
sortingColumns: EuiDataGridSorting['columns'];
visibleColumns: string[];
dateFormat: string;
pageSizeOptions?: number[];
selectedRunLog?: ExecutionLog;
onChangeItemsPerPage: (pageSize: number) => void;
onChangePage: (pageIndex: number) => void;
onFlyoutOpen?: (runLog: IExecutionLog) => void;
setVisibleColumns: (visibleColumns: string[]) => void;
setSortingColumns: (sortingColumns: EuiDataGridSorting['columns']) => void;
}
export const numTriggeredActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.triggeredActions',
{
defaultMessage: 'Triggered actions',
}
);
const numTriggeredActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.triggeredActionsToolTip',
{
defaultMessage: 'The subset of generated actions that will run.',
}
);
export const numGeneratedActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.scheduledActions',
{
defaultMessage: 'Generated actions',
}
);
const numGeneratedActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.scheduledActionsToolTip',
{
defaultMessage: 'The total number of actions generated when the rule ran.',
}
);
export const numSucceededActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.succeededActions',
{
defaultMessage: 'Succeeded actions',
}
);
const numSucceededActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.succeededActionsToolTip',
{
defaultMessage: 'The number of actions that were completed successfully.',
}
);
export const numErroredActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.erroredActions',
{
defaultMessage: 'Errored actions',
}
);
const numErroredActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.erroredActionsToolTip',
{
defaultMessage: 'The number of failed actions.',
}
);
const columnsWithToolTipMap: Record<string, Record<string, string>> = {
num_triggered_actions: {
display: numTriggeredActionsDisplay,
toolTip: numTriggeredActionsToolTip,
},
num_generated_actions: {
display: numGeneratedActionsDisplay,
toolTip: numGeneratedActionsToolTip,
},
num_succeeded_actions: {
display: numSucceededActionsDisplay,
toolTip: numSucceededActionsToolTip,
},
num_errored_actions: {
display: numErroredActionsDisplay,
toolTip: numErroredActionsToolTip,
},
};
export const ColumnHeaderWithToolTip = ({ id }: { id: string }) => {
return (
<EuiToolTip content={columnsWithToolTipMap[id].toolTip}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem>{columnsWithToolTipMap[id].display}</EuiFlexItem>
<EuiFlexItem>
<EuiIcon size="s" color="subdued" type="questionInCircle" className="eui-alignTop" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiToolTip>
);
};
export const EventLogDataGrid = (props: EventLogDataGrid) => {
const {
columns,
logs = [],
sortingColumns,
pageSizeOptions = PAGE_SIZE_OPTIONS,
pagination,
dateFormat,
visibleColumns,
selectedRunLog,
setVisibleColumns,
setSortingColumns,
onChangeItemsPerPage,
onChangePage,
onFlyoutOpen,
} = props;
const { euiTheme } = useEuiTheme();
const isRuleUsingExecutionStatus = getIsExperimentalFeatureEnabled('ruleUseExecutionStatus');
const getPaginatedRowIndex = useCallback(
(rowIndex: number) => {
const { pageIndex, pageSize } = pagination;
return rowIndex - pageIndex * pageSize;
},
[pagination]
);
const columnVisibilityProps = useMemo(() => {
return {
visibleColumns,
setVisibleColumns,
};
}, [visibleColumns, setVisibleColumns]);
const sortingProps = useMemo(
() => ({
onSort: setSortingColumns,
columns: sortingColumns,
}),
[setSortingColumns, sortingColumns]
);
const paginationProps = useMemo(
() => ({
...pagination,
pageSizeOptions,
onChangeItemsPerPage,
onChangePage,
}),
[pagination, pageSizeOptions, onChangeItemsPerPage, onChangePage]
);
const rowClasses = useMemo(() => {
if (!selectedRunLog) {
return {};
}
const index = logs.findIndex((log) => log.id === selectedRunLog.id);
return {
[index]: 'ruleEventLogDataGrid--rowClassSelected',
};
}, [selectedRunLog, logs]);
const gridStyles: EuiDataGridStyle = useMemo(() => {
return {
border: 'horizontal',
header: 'underline',
rowClasses,
};
}, [rowClasses]);
const renderMessageWithActionError = (
columnId: string,
errors: number,
showTooltip: boolean = false
) => {
if (columnId !== 'message') {
return null;
}
if (!errors) {
return null;
}
return (
<EuiFlexItem grow={false}>
{showTooltip ? (
<EuiToolTip content={getErroredActionsTranslation(errors)}>
<RuleActionErrorBadge totalErrors={errors} showIcon />
</EuiToolTip>
) : (
<RuleActionErrorBadge totalErrors={errors} showIcon />
)}
</EuiFlexItem>
);
};
// Renders the cell popover for runs with errored actions
const renderCellPopover = (cellPopoverProps: EuiDataGridCellPopoverElementProps) => {
const { columnId, rowIndex, cellActions, DefaultCellPopover } = cellPopoverProps;
if (columnId !== 'message') {
return <DefaultCellPopover {...cellPopoverProps} />;
}
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const runLog = logs[pagedRowIndex];
if (!runLog) {
return null;
}
const value = runLog[columnId as keyof ExecutionLog] as string;
const actionErrors = get(runLog, 'num_errored_actions', 0 as number);
return (
<div style={{ width: '100%' }}>
<EuiSpacer size="s" />
<div>
<EuiText size="m">{value}</EuiText>
</div>
<EuiSpacer size="s" />
{actionErrors > 0 && (
<>
<EuiSpacer size="l" />
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
{renderMessageWithActionError(columnId, actionErrors)}
</EuiFlexItem>
<EuiFlexItem>
&nbsp;
<FormattedMessage
id="xpack.triggersActionsUI.sections.eventLogDataGrid.erroredActionsCellPopover"
defaultMessage="{value, plural, one {errored action} other {errored actions}}"
values={{
value: actionErrors,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{cellActions}
</>
)}
</div>
);
};
// Main cell renderer, renders durations, statuses, etc.
const renderCell = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const runLog = logs[pagedRowIndex];
const value = logs[pagedRowIndex]?.[columnId as keyof ExecutionLog] as string;
const actionErrors = get(logs[pagedRowIndex], 'num_errored_actions', 0 as number);
const version = logs?.[pagedRowIndex]?.version;
const ruleId = get(runLog, 'rule_id');
const spaceIds = runLog?.space_ids;
if (columnId === 'num_errored_actions' && runLog && onFlyoutOpen) {
return (
<EuiBadge
data-test-subj="eventLogDataGridErroredActionBadge"
style={{
cursor: 'pointer',
borderRadius: euiTheme.border.radius.medium,
}}
color="hollow"
onClick={() => onFlyoutOpen(runLog as IExecutionLog)}
onClickAriaLabel={i18n.translate(
'xpack.triggersActionsUI.sections.eventLogColumn.openActionErrorsFlyout',
{
defaultMessage: 'Open action errors flyout',
}
)}
>
{value}
</EuiBadge>
);
}
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{renderMessageWithActionError(columnId, actionErrors, true)}
<EuiFlexItem>
<EventLogListCellRenderer
columnId={columnId as ColumnId}
value={value}
version={version}
dateFormat={dateFormat}
ruleId={ruleId}
spaceIds={spaceIds}
useExecutionStatus={isRuleUsingExecutionStatus}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<>
<EventLogPaginationStatus
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
totalItemCount={pagination.totalItemCount}
/>
<EuiDataGrid
aria-label="event log"
data-test-subj="eventLogList"
columns={columns}
rowCount={pagination.totalItemCount}
renderCellValue={renderCell}
columnVisibility={columnVisibilityProps}
sorting={sortingProps}
pagination={paginationProps}
gridStyle={gridStyles}
renderCellPopover={renderCellPopover}
/>
</>
);
};

View file

@ -9,12 +9,9 @@ import React from 'react';
import moment from 'moment';
import { EuiIcon, EuiLink } from '@elastic/eui';
import { shallow, mount } from 'enzyme';
import {
RuleEventLogListCellRenderer,
DEFAULT_DATE_FORMAT,
} from './rule_event_log_list_cell_renderer';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format';
import { EventLogListCellRenderer, DEFAULT_DATE_FORMAT } from './event_log_list_cell_renderer';
import { EventLogListStatus } from './event_log_list_status';
import { RuleDurationFormat } from '../../../rules_list/components/rule_duration_format';
jest.mock('react-router-dom', () => ({
useHistory: () => ({
@ -24,7 +21,7 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('../../../../common/lib/kibana', () => ({
jest.mock('../../../../../common/lib/kibana', () => ({
useSpacesData: () => ({
spacesMap: new Map([
['space1', { id: 'space1' }],
@ -64,20 +61,20 @@ describe('rule_event_log_list_cell_renderer', () => {
});
it('renders primitive values correctly', () => {
const wrapper = mount(<RuleEventLogListCellRenderer columnId="message" value="test" />);
const wrapper = mount(<EventLogListCellRenderer columnId="message" value="test" />);
expect(wrapper.text()).toEqual('test');
});
it('renders undefined correctly', () => {
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="message" />);
const wrapper = shallow(<EventLogListCellRenderer columnId="message" />);
expect(wrapper.text()).toBeFalsy();
});
it('renders date duration correctly', () => {
const wrapper = shallow(
<RuleEventLogListCellRenderer columnId="execution_duration" value="100000" />
<EventLogListCellRenderer columnId="execution_duration" value="100000" />
);
expect(wrapper.find(RuleDurationFormat).exists()).toBeTruthy();
@ -86,7 +83,7 @@ describe('rule_event_log_list_cell_renderer', () => {
it('renders alert count correctly', () => {
const wrapper = shallow(
<RuleEventLogListCellRenderer columnId="num_new_alerts" value="3" version="8.3.0" />
<EventLogListCellRenderer columnId="num_new_alerts" value="3" version="8.3.0" />
);
expect(wrapper.text()).toEqual('3');
@ -94,29 +91,29 @@ describe('rule_event_log_list_cell_renderer', () => {
it('renders timestamps correctly', () => {
const time = '2022-03-20T07:40:44-07:00';
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="timestamp" value={time} />);
const wrapper = shallow(<EventLogListCellRenderer columnId="timestamp" value={time} />);
expect(wrapper.text()).toEqual(moment(time).format(DEFAULT_DATE_FORMAT));
});
it('renders alert status correctly', () => {
const wrapper = shallow(<RuleEventLogListCellRenderer columnId="status" value="success" />);
const wrapper = shallow(<EventLogListCellRenderer columnId="status" value="success" />);
expect(wrapper.find(RuleEventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(RuleEventLogListStatus).props().status).toEqual('success');
expect(wrapper.find(EventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(EventLogListStatus).props().status).toEqual('success');
});
it('unaccounted status will still render, but with the unknown color', () => {
const wrapper = mount(<RuleEventLogListCellRenderer columnId="status" value="newOutcome" />);
const wrapper = mount(<EventLogListCellRenderer columnId="status" value="newOutcome" />);
expect(wrapper.find(RuleEventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(RuleEventLogListStatus).text()).toEqual('newOutcome');
expect(wrapper.find(EventLogListStatus).exists()).toBeTruthy();
expect(wrapper.find(EventLogListStatus).text()).toEqual('newOutcome');
expect(wrapper.find(EuiIcon).props().color).toEqual('gray');
});
it('links to rules on the correct space', () => {
const wrapper1 = shallow(
<RuleEventLogListCellRenderer
<EventLogListCellRenderer
columnId="rule_name"
value="Rule"
ruleId="1"
@ -126,7 +123,7 @@ describe('rule_event_log_list_cell_renderer', () => {
// @ts-ignore data-href is not a native EuiLink prop
expect(wrapper1.find(EuiLink).props()['data-href']).toEqual('/rule/1');
const wrapper2 = shallow(
<RuleEventLogListCellRenderer
<EventLogListCellRenderer
columnId="rule_name"
value="Rule"
ruleId="1"

View file

@ -11,21 +11,24 @@ import { EuiLink } from '@elastic/eui';
import { RuleAlertingOutcome } from '@kbn/alerting-plugin/common';
import { useHistory } from 'react-router-dom';
import { getRuleDetailsRoute } from '@kbn/rule-data-utils';
import { formatRuleAlertCount } from '../../../../common/lib/format_rule_alert_count';
import { useKibana, useSpacesData } from '../../../../common/lib/kibana';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format';
import { formatRuleAlertCount } from '../../../../../common/lib/format_rule_alert_count';
import { useKibana, useSpacesData } from '../../../../../common/lib/kibana';
import { EventLogListStatus } from './event_log_list_status';
import { RuleDurationFormat } from '../../../rules_list/components/rule_duration_format';
import {
RULE_EXECUTION_LOG_COLUMN_IDS,
RULE_EXECUTION_LOG_DURATION_COLUMNS,
RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS,
} from '../../../constants';
CONNECTOR_EXECUTION_LOG_COLUMN_IDS,
} from '../../../../constants';
export const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS';
export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number];
export type ColumnId =
| typeof RULE_EXECUTION_LOG_COLUMN_IDS[number]
| typeof CONNECTOR_EXECUTION_LOG_COLUMN_IDS[number];
interface RuleEventLogListCellRendererProps {
interface EventLogListCellRendererProps {
columnId: ColumnId;
version?: string;
value?: string | string[];
@ -35,7 +38,7 @@ interface RuleEventLogListCellRendererProps {
useExecutionStatus?: boolean;
}
export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => {
export const EventLogListCellRenderer = (props: EventLogListCellRendererProps) => {
const {
columnId,
value,
@ -97,7 +100,7 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer
if (columnId === 'status') {
return (
<RuleEventLogListStatus
<EventLogListStatus
status={value as RuleAlertingOutcome}
useExecutionStatus={useExecutionStatus}
/>
@ -129,5 +132,9 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer
return <RuleDurationFormat duration={parseInt(value as string, 10)} />;
}
if (columnId === 'timed_out') {
return <>{value ? 'true' : 'false'}</>;
}
return <>{value}</>;
};

View file

@ -13,9 +13,9 @@ import {
RULE_LAST_RUN_OUTCOME_FAILED,
RULE_LAST_RUN_OUTCOME_WARNING,
ALERT_STATUS_UNKNOWN,
} from '../../rules_list/translations';
} from '../../../rules_list/translations';
interface RuleEventLogListStatusProps {
interface EventLogListStatusProps {
status: RuleAlertingOutcome;
useExecutionStatus?: boolean;
}
@ -44,7 +44,7 @@ const STATUS_TO_OUTCOME: Record<RuleAlertingOutcome, string> = {
unknown: ALERT_STATUS_UNKNOWN,
};
export const RuleEventLogListStatus = (props: RuleEventLogListStatusProps) => {
export const EventLogListStatus = (props: EventLogListStatusProps) => {
const { status, useExecutionStatus = true } = props;
const color = STATUS_TO_COLOR[status] || 'gray';

View file

@ -8,10 +8,10 @@
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { EventLogListStatusFilter } from './event_log_list_status_filter';
import { getIsExperimentalFeatureEnabled } from '../../../../../common/get_experimental_features';
jest.mock('../../../../common/get_experimental_features', () => ({
jest.mock('../../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
@ -21,14 +21,14 @@ beforeEach(() => {
const onChangeMock = jest.fn();
describe('rule_event_log_list_status_filter', () => {
describe('event_log_list_status_filter', () => {
beforeEach(() => {
onChangeMock.mockReset();
});
it('renders correctly', () => {
const wrapper = mountWithIntl(
<RuleEventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
<EventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
);
expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy();
@ -39,7 +39,7 @@ describe('rule_event_log_list_status_filter', () => {
it('can open the popover correctly', () => {
const wrapper = mountWithIntl(
<RuleEventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
<EventLogListStatusFilter selectedOptions={[]} onChange={onChangeMock} />
);
wrapper.find(EuiFilterButton).simulate('click');

View file

@ -9,17 +9,17 @@ import React, { useState, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { RuleAlertingOutcome } from '@kbn/alerting-plugin/common';
import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { getIsExperimentalFeatureEnabled } from '../../../../../common/get_experimental_features';
import { EventLogListStatus } from './event_log_list_status';
const statusFilters: RuleAlertingOutcome[] = ['success', 'failure', 'warning', 'unknown'];
interface RuleEventLogListStatusFilterProps {
interface EventLogListStatusFilterProps {
selectedOptions: string[];
onChange: (selectedValues: string[]) => void;
}
export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilterProps) => {
export const EventLogListStatusFilter = (props: EventLogListStatusFilterProps) => {
const { selectedOptions = [], onChange = () => {} } = props;
const isRuleUsingExecutionStatus = getIsExperimentalFeatureEnabled('ruleUseExecutionStatus');
@ -48,7 +48,7 @@ export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilter
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiFilterButton
data-test-subj="ruleEventLogStatusFilterButton"
data-test-subj="eventLogStatusFilterButton"
iconType="arrowDown"
hasActiveFilters={selectedOptions.length > 0}
numActiveFilters={selectedOptions.length}
@ -56,7 +56,7 @@ export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilter
onClick={onClick}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.eventLogStatusFilterLabel"
id="xpack.triggersActionsUI.sections.eventLogStatusFilterLabel"
defaultMessage="Response"
/>
</EuiFilterButton>
@ -67,11 +67,11 @@ export const RuleEventLogListStatusFilter = (props: RuleEventLogListStatusFilter
return (
<EuiFilterSelectItem
key={status}
data-test-subj={`ruleEventLogStatusFilter-${status}`}
data-test-subj={`eventLogStatusFilter-${status}`}
onClick={onFilterItemClick(status)}
checked={selectedOptions.includes(status) ? 'on' : undefined}
>
<RuleEventLogListStatus
<EventLogListStatus
status={status}
useExecutionStatus={isRuleUsingExecutionStatus}
/>

View file

@ -9,12 +9,12 @@ import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Pagination, EuiText } from '@elastic/eui';
export type RuleEventLogPaginationStatusProps = Pick<
export type EventLogPaginationStatusProps = Pick<
Pagination,
'pageIndex' | 'pageSize' | 'totalItemCount'
>;
export const RuleEventLogPaginationStatus = (props: RuleEventLogPaginationStatusProps) => {
export const EventLogPaginationStatus = (props: EventLogPaginationStatusProps) => {
const { pageIndex, pageSize, totalItemCount } = props;
const paginationStatusRange = useMemo(() => {
@ -22,7 +22,7 @@ export const RuleEventLogPaginationStatus = (props: RuleEventLogPaginationStatus
return (
<strong>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRangeNoResult"
id="xpack.triggersActionsUI.sections.eventLogPaginationStatus.paginationResultsRangeNoResult"
defaultMessage="0"
/>
</strong>
@ -33,7 +33,7 @@ export const RuleEventLogPaginationStatus = (props: RuleEventLogPaginationStatus
return (
<strong>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsRange"
id="xpack.triggersActionsUI.sections.eventLogPaginationStatus.paginationResultsRange"
defaultMessage="{start, number} - {end, number}"
values={{
start: pageIndex * pageSize + 1,
@ -45,9 +45,9 @@ export const RuleEventLogPaginationStatus = (props: RuleEventLogPaginationStatus
}, [pageSize, pageIndex, totalItemCount]);
return (
<EuiText data-test-subj="ruleEventLogPaginationStatus" size="xs">
<EuiText data-test-subj="eventLogPaginationStatus" size="xs">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResults"
id="xpack.triggersActionsUI.sections.eventLogPaginationStatus.paginationResults"
defaultMessage="Showing {range} of {total, number} {type}"
values={{
range: paginationStatusRange,
@ -55,7 +55,7 @@ export const RuleEventLogPaginationStatus = (props: RuleEventLogPaginationStatus
type: (
<strong>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogPaginationStatus.paginationResultsType"
id="xpack.triggersActionsUI.sections.eventLogPaginationStatus.paginationResultsType"
defaultMessage="log {total, plural, one {entry} other {entries}}"
values={{ total: totalItemCount }}
/>

View file

@ -0,0 +1,34 @@
/*
* 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 from 'react';
import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSpacer } from '@elastic/eui';
export const EventLogStat = ({
title,
tooltip,
children,
}: {
title: string;
tooltip: string;
children?: JSX.Element;
}) => {
return (
<EuiPanel color="subdued">
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<b>{title}</b>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip content={tooltip} position="top" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{children}
</EuiPanel>
);
};

View file

@ -0,0 +1,22 @@
/*
* 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.
*/
export { EventLogListCellRenderer } from './event_log_list_cell_renderer';
export type { ColumnId } from './event_log_list_cell_renderer';
export { EventLogListStatusFilter } from './event_log_list_status_filter';
export { EventLogListStatus } from './event_log_list_status';
export { EventLogPaginationStatus } from './event_log_pagination_status';
export {
EventLogDataGrid,
getIsColumnSortable,
ColumnHeaderWithToolTip,
numTriggeredActionsDisplay,
numGeneratedActionsDisplay,
numSucceededActionsDisplay,
numErroredActionsDisplay,
} from './event_log_data_grid';
export { EventLogStat } from './event_log_stat';

View file

@ -42,14 +42,14 @@ export const RefineSearchPrompt = (props: RefineSearchFooterProps) => {
return (
<EuiText style={textStyles} textAlign="center" size="s">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.prompt"
id="xpack.triggersActionsUI.sections.refineSearchPrompt.prompt"
defaultMessage="These are the first {visibleDocumentSize} documents matching your search, refine your search to see others."
values={{ visibleDocumentSize }}
/>
&nbsp;
<EuiLink href={`#${backToTopAnchor}`}>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.refineSearchPrompt.backToTop"
id="xpack.triggersActionsUI.sections.refineSearchPrompt.backToTop"
defaultMessage="Back to top."
/>
</EuiLink>

View file

@ -7,12 +7,25 @@
import React from 'react';
import { IExecutionLogResult, IExecutionKPIResult } from '@kbn/actions-plugin/common';
import { ActionType } from '../../../../types';
import { loadActionTypes } from '../../../lib/action_connector_api';
import {
loadActionTypes,
LoadGlobalConnectorExecutionLogAggregationsProps,
loadGlobalConnectorExecutionLogAggregations,
LoadGlobalConnectorExecutionKPIAggregationsProps,
loadGlobalConnectorExecutionKPIAggregations,
} from '../../../lib/action_connector_api';
import { useKibana } from '../../../../common/lib/kibana';
export interface ComponentOpts {
loadActionTypes: () => Promise<ActionType[]>;
loadGlobalConnectorExecutionLogAggregations: (
props: LoadGlobalConnectorExecutionLogAggregationsProps
) => Promise<IExecutionLogResult>;
loadGlobalConnectorExecutionKPIAggregations: (
props: LoadGlobalConnectorExecutionKPIAggregationsProps
) => Promise<IExecutionKPIResult>;
}
export type PropsWithOptionalApiHandlers<T> = Omit<T, keyof ComponentOpts> & Partial<ComponentOpts>;
@ -23,7 +36,26 @@ export function withActionOperations<T>(
return (props: PropsWithOptionalApiHandlers<T>) => {
const { http } = useKibana().services;
return (
<WrappedComponent {...(props as T)} loadActionTypes={async () => loadActionTypes({ http })} />
<WrappedComponent
{...(props as T)}
loadActionTypes={async () => loadActionTypes({ http })}
loadGlobalConnectorExecutionLogAggregations={async (
loadProps: LoadGlobalConnectorExecutionLogAggregationsProps
) =>
loadGlobalConnectorExecutionLogAggregations({
...loadProps,
http,
})
}
loadGlobalConnectorExecutionKPIAggregations={async (
loadGlobalExecutionKPIAggregationsProps: LoadGlobalConnectorExecutionKPIAggregationsProps
) =>
loadGlobalConnectorExecutionKPIAggregations({
...loadGlobalExecutionKPIAggregationsProps,
http,
})
}
/>
);
};
}

View file

@ -12,7 +12,7 @@ import { useKibana } from '../../../../common/lib/kibana';
import { EuiSuperDatePicker } from '@elastic/eui';
import { Rule } from '../../../../types';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { RefineSearchPrompt } from '../../common/components/refine_search_prompt';
import { RuleErrorLog } from './rule_error_log';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;

View file

@ -24,12 +24,12 @@ import {
import { IExecutionErrors } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { RefineSearchPrompt } from '../../common/components/refine_search_prompt';
import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
import { RuleEventLogListCellRenderer } from './rule_event_log_list_cell_renderer';
import { EventLogListCellRenderer } from '../../common/components/event_log';
const getParsedDate = (date: string) => {
if (date.includes('now')) {
@ -203,7 +203,7 @@ export const RuleErrorLog = (props: RuleErrorLogProps) => {
}
),
render: (date: string) => (
<RuleEventLogListCellRenderer columnId="timestamp" value={date} dateFormat={dateFormat} />
<EventLogListCellRenderer columnId="timestamp" value={date} dateFormat={dateFormat} />
),
sortable: true,
width: '250px',

View file

@ -1,655 +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 React, { useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiDataGrid,
EuiDataGridStyle,
Pagination,
EuiDataGridCellValueElementProps,
EuiDataGridSorting,
EuiDataGridColumn,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiBadge,
EuiDataGridCellPopoverElementProps,
useEuiTheme,
EuiToolTip,
EuiText,
EuiIcon,
} from '@elastic/eui';
import {
IExecutionLog,
executionLogSortableColumns,
ExecutionLogSortFields,
} from '@kbn/alerting-plugin/common';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { RuleEventLogListCellRenderer, ColumnId } from './rule_event_log_list_cell_renderer';
import { RuleEventLogPaginationStatus } from './rule_event_log_pagination_status';
import { RuleActionErrorBadge } from './rule_action_error_badge';
import './rule_event_log_list.scss';
const getIsColumnSortable = (columnId: string) => {
return executionLogSortableColumns.includes(columnId as ExecutionLogSortFields);
};
const getErroredActionsTranslation = (errors: number) => {
return i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsTooltip',
{
defaultMessage: '{value, plural, one {# errored action} other {# errored actions}}',
values: { value: errors },
}
);
};
const PAGE_SIZE_OPTIONS = [10, 50, 100];
export interface RuleEventLogDataGrid {
logs: IExecutionLog[];
pagination: Pagination;
sortingColumns: EuiDataGridSorting['columns'];
visibleColumns: string[];
dateFormat: string;
pageSizeOptions?: number[];
selectedRunLog?: IExecutionLog;
showRuleNameAndIdColumns?: boolean;
showSpaceColumns?: boolean;
onChangeItemsPerPage: (pageSize: number) => void;
onChangePage: (pageIndex: number) => void;
onFilterChange: (filter: string[]) => void;
onFlyoutOpen: (runLog: IExecutionLog) => void;
setVisibleColumns: (visibleColumns: string[]) => void;
setSortingColumns: (sortingColumns: EuiDataGridSorting['columns']) => void;
}
const numTriggeredActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActions',
{
defaultMessage: 'Triggered actions',
}
);
const numTriggeredActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.triggeredActionsToolTip',
{
defaultMessage: 'The subset of generated actions that will run.',
}
);
const numGeneratedActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActions',
{
defaultMessage: 'Generated actions',
}
);
const numGeneratedActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduledActionsToolTip',
{
defaultMessage: 'The total number of actions generated when the rule ran.',
}
);
const numSucceededActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActions',
{
defaultMessage: 'Succeeded actions',
}
);
const numSucceededActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.succeededActionsToolTip',
{
defaultMessage: 'The number of actions that were completed successfully.',
}
);
const numErroredActionsDisplay = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActions',
{
defaultMessage: 'Errored actions',
}
);
const numErroredActionsToolTip = i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.erroredActionsToolTip',
{
defaultMessage: 'The number of failed actions.',
}
);
const columnsWithToolTipMap: Record<string, Record<string, string>> = {
num_triggered_actions: {
display: numTriggeredActionsDisplay,
toolTip: numTriggeredActionsToolTip,
},
num_generated_actions: {
display: numGeneratedActionsDisplay,
toolTip: numGeneratedActionsToolTip,
},
num_succeeded_actions: {
display: numSucceededActionsDisplay,
toolTip: numSucceededActionsToolTip,
},
num_errored_actions: {
display: numErroredActionsDisplay,
toolTip: numErroredActionsToolTip,
},
};
const ColumnHeaderWithToolTip = ({ id }: { id: string }) => {
return (
<EuiToolTip content={columnsWithToolTipMap[id].toolTip}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexItem>{columnsWithToolTipMap[id].display}</EuiFlexItem>
<EuiFlexItem>
<EuiIcon size="s" color="subdued" type="questionInCircle" className="eui-alignTop" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiToolTip>
);
};
export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
const {
logs = [],
sortingColumns,
pageSizeOptions = PAGE_SIZE_OPTIONS,
pagination,
dateFormat,
visibleColumns,
selectedRunLog,
showRuleNameAndIdColumns = false,
showSpaceColumns = false,
setVisibleColumns,
setSortingColumns,
onChangeItemsPerPage,
onChangePage,
onFilterChange,
onFlyoutOpen,
} = props;
const { euiTheme } = useEuiTheme();
const isRuleUsingExecutionStatus = getIsExperimentalFeatureEnabled('ruleUseExecutionStatus');
const getPaginatedRowIndex = useCallback(
(rowIndex: number) => {
const { pageIndex, pageSize } = pagination;
return rowIndex - pageIndex * pageSize;
},
[pagination]
);
const columns: EuiDataGridColumn[] = useMemo(
() => [
...(showRuleNameAndIdColumns
? [
{
id: 'rule_id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId',
{
defaultMessage: 'Rule Id',
}
),
isSortable: getIsColumnSortable('rule_id'),
actions: {
showSortAsc: false,
showSortDesc: false,
},
},
{
id: 'rule_name',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName',
{
defaultMessage: 'Rule',
}
),
isSortable: getIsColumnSortable('rule_name'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
...(showSpaceColumns
? [
{
id: 'space_ids',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.spaceIds',
{
defaultMessage: 'Space',
}
),
isSortable: getIsColumnSortable('space_ids'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
{
id: 'id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id',
{
defaultMessage: 'Id',
}
),
isSortable: getIsColumnSortable('id'),
},
{
id: 'timestamp',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp',
{
defaultMessage: 'Timestamp',
}
),
isSortable: getIsColumnSortable('timestamp'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 250,
},
{
id: 'execution_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration',
{
defaultMessage: 'Duration',
}
),
isSortable: getIsColumnSortable('execution_duration'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 100,
},
{
id: 'status',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.response',
{
defaultMessage: 'Response',
}
),
actions: {
showHide: false,
showSortAsc: false,
showSortDesc: false,
additional: [
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showOnlyFailures',
{
defaultMessage: 'Show only failures',
}
),
onClick: () => onFilterChange(['failure']),
size: 'xs',
},
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showAll',
{
defaultMessage: 'Show all',
}
),
onClick: () => onFilterChange([]),
size: 'xs',
},
],
},
isSortable: getIsColumnSortable('status'),
isResizable: false,
initialWidth: 150,
},
{
id: 'message',
actions: {
showSortAsc: false,
showSortDesc: false,
},
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message',
{
defaultMessage: 'Message',
}
),
isSortable: getIsColumnSortable('message'),
cellActions: [
({ rowIndex, Component }) => {
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const runLog = logs[pagedRowIndex];
const actionErrors = runLog?.num_errored_actions as number;
if (actionErrors) {
return (
<Component onClick={() => onFlyoutOpen(runLog)} iconType="alert">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.viewActionErrors"
defaultMessage="View action errors"
/>
</Component>
);
}
return null;
},
],
},
{
id: 'num_active_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts',
{
defaultMessage: 'Active alerts',
}
),
initialWidth: 140,
isSortable: getIsColumnSortable('num_active_alerts'),
},
{
id: 'num_new_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts',
{
defaultMessage: 'New alerts',
}
),
initialWidth: 140,
isSortable: getIsColumnSortable('num_new_alerts'),
},
{
id: 'num_recovered_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts',
{
defaultMessage: 'Recovered alerts',
}
),
isSortable: getIsColumnSortable('num_recovered_alerts'),
},
{
id: 'num_triggered_actions',
displayAsText: numTriggeredActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_triggered_actions" />,
isSortable: getIsColumnSortable('num_triggered_actions'),
},
{
id: 'num_generated_actions',
displayAsText: numGeneratedActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_generated_actions" />,
isSortable: getIsColumnSortable('num_generated_actions'),
},
{
id: 'num_succeeded_actions',
displayAsText: numSucceededActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_succeeded_actions" />,
isSortable: getIsColumnSortable('num_succeeded_actions'),
},
{
id: 'num_errored_actions',
actions: {
showSortAsc: false,
showSortDesc: false,
},
displayAsText: numErroredActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_errored_actions" />,
isSortable: getIsColumnSortable('num_errored_actions'),
},
{
id: 'total_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration',
{
defaultMessage: 'Total search duration',
}
),
isSortable: getIsColumnSortable('total_search_duration'),
},
{
id: 'es_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration',
{
defaultMessage: 'ES search duration',
}
),
isSortable: getIsColumnSortable('es_search_duration'),
},
{
id: 'schedule_delay',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay',
{
defaultMessage: 'Schedule delay',
}
),
isSortable: getIsColumnSortable('schedule_delay'),
},
{
id: 'timed_out',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut',
{
defaultMessage: 'Timed out',
}
),
isSortable: getIsColumnSortable('timed_out'),
},
],
[
getPaginatedRowIndex,
onFlyoutOpen,
onFilterChange,
showRuleNameAndIdColumns,
showSpaceColumns,
logs,
]
);
const columnVisibilityProps = useMemo(() => {
return {
visibleColumns,
setVisibleColumns,
};
}, [visibleColumns, setVisibleColumns]);
const sortingProps = useMemo(
() => ({
onSort: setSortingColumns,
columns: sortingColumns,
}),
[setSortingColumns, sortingColumns]
);
const paginationProps = useMemo(
() => ({
...pagination,
pageSizeOptions,
onChangeItemsPerPage,
onChangePage,
}),
[pagination, pageSizeOptions, onChangeItemsPerPage, onChangePage]
);
const rowClasses = useMemo(() => {
if (!selectedRunLog) {
return {};
}
const index = logs.findIndex((log) => log.id === selectedRunLog.id);
return {
[index]: 'ruleEventLogDataGrid--rowClassSelected',
};
}, [selectedRunLog, logs]);
const gridStyles: EuiDataGridStyle = useMemo(() => {
return {
border: 'horizontal',
header: 'underline',
rowClasses,
};
}, [rowClasses]);
const renderMessageWithActionError = (
columnId: string,
errors: number,
showTooltip: boolean = false
) => {
if (columnId !== 'message') {
return null;
}
if (!errors) {
return null;
}
return (
<EuiFlexItem grow={false}>
{showTooltip ? (
<EuiToolTip content={getErroredActionsTranslation(errors)}>
<RuleActionErrorBadge totalErrors={errors} showIcon />
</EuiToolTip>
) : (
<RuleActionErrorBadge totalErrors={errors} showIcon />
)}
</EuiFlexItem>
);
};
// Renders the cell popover for runs with errored actions
const renderCellPopover = (cellPopoverProps: EuiDataGridCellPopoverElementProps) => {
const { columnId, rowIndex, cellActions, DefaultCellPopover } = cellPopoverProps;
if (columnId !== 'message') {
return <DefaultCellPopover {...cellPopoverProps} />;
}
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const runLog = logs[pagedRowIndex];
if (!runLog) {
return null;
}
const value = runLog[columnId as keyof IExecutionLog] as string;
const actionErrors = runLog.num_errored_actions || (0 as number);
return (
<div style={{ width: '100%' }}>
<EuiSpacer size="s" />
<div>
<EuiText size="m">{value}</EuiText>
</div>
<EuiSpacer size="s" />
{actionErrors > 0 && (
<>
<EuiSpacer size="l" />
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
{renderMessageWithActionError(columnId, actionErrors)}
</EuiFlexItem>
<EuiFlexItem>
&nbsp;
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.ruleEventLogDataGrid.erroredActionsCellPopover"
defaultMessage="{value, plural, one {errored action} other {errored actions}}"
values={{
value: actionErrors,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{cellActions}
</>
)}
</div>
);
};
// Main cell renderer, renders durations, statuses, etc.
const renderCell = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const runLog = logs[pagedRowIndex];
const value = logs[pagedRowIndex]?.[columnId as keyof IExecutionLog] as string;
const actionErrors = logs[pagedRowIndex]?.num_errored_actions || (0 as number);
const version = logs?.[pagedRowIndex]?.version;
const ruleId = runLog?.rule_id;
const spaceIds = runLog?.space_ids;
if (columnId === 'num_errored_actions' && runLog) {
return (
<EuiBadge
data-test-subj="ruleEventLogDataGridErroredActionBadge"
style={{
cursor: 'pointer',
borderRadius: euiTheme.border.radius.medium,
}}
color="hollow"
onClick={() => onFlyoutOpen(runLog)}
onClickAriaLabel={i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.openActionErrorsFlyout',
{
defaultMessage: 'Open action errors flyout',
}
)}
>
{value}
</EuiBadge>
);
}
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{renderMessageWithActionError(columnId, actionErrors, true)}
<EuiFlexItem>
<RuleEventLogListCellRenderer
columnId={columnId as ColumnId}
value={value}
version={version}
dateFormat={dateFormat}
ruleId={ruleId}
spaceIds={spaceIds}
useExecutionStatus={isRuleUsingExecutionStatus}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<>
<RuleEventLogPaginationStatus
pageIndex={pagination.pageIndex}
pageSize={pagination.pageSize}
totalItemCount={pagination.totalItemCount}
/>
<EuiDataGrid
aria-label="rule event log"
data-test-subj="ruleEventLogList"
columns={columns}
rowCount={pagination.totalItemCount}
renderCellValue={renderCell}
columnVisibility={columnVisibilityProps}
sorting={sortingProps}
pagination={paginationProps}
gridStyle={gridStyles}
renderCellPopover={renderCellPopover}
/>
</>
);
};

View file

@ -12,9 +12,9 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionGroup, ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { EuiSuperDatePicker, EuiDataGrid } from '@elastic/eui';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { EventLogListStatusFilter } from '../../common/components/event_log';
import { RuleEventLogList } from './rule_event_log_list';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { RefineSearchPrompt } from '../../common/components/refine_search_prompt';
import {
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
@ -124,7 +124,7 @@ describe.skip('rule_event_log_list', () => {
expect(wrapper.find(EuiSuperDatePicker).props().isLoading).toBeFalsy();
expect(wrapper.find(RuleEventLogListStatusFilter).exists()).toBeTruthy();
expect(wrapper.find(EventLogListStatusFilter).exists()).toBeTruthy();
expect(wrapper.find('[data-gridcell-column-id="timestamp"]').length).toEqual(5);
expect(wrapper.find(EuiDataGrid).props().rowCount).toEqual(mockLogResponse.total);
});
@ -252,9 +252,9 @@ describe.skip('rule_event_log_list', () => {
});
// Filter by success
wrapper.find('[data-test-subj="ruleEventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="eventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="ruleEventLogStatusFilter-success"]').at(0).simulate('click');
wrapper.find('[data-test-subj="eventLogStatusFilter-success"]').at(0).simulate('click');
await act(async () => {
await nextTick();
@ -272,9 +272,9 @@ describe.skip('rule_event_log_list', () => {
);
// Filter by failure as well
wrapper.find('[data-test-subj="ruleEventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="eventLogStatusFilterButton"]').at(0).simulate('click');
wrapper.find('[data-test-subj="ruleEventLogStatusFilter-failure"]').at(0).simulate('click');
wrapper.find('[data-test-subj="eventLogStatusFilter-failure"]').at(0).simulate('click');
await act(async () => {
await nextTick();
@ -549,7 +549,7 @@ describe.skip('rule_event_log_list', () => {
wrapper.update();
});
expect(wrapper.find('[data-test-subj="ruleEventLogPaginationStatus"]').first().text()).toEqual(
expect(wrapper.find('[data-test-subj="eventLogPaginationStatus"]').first().text()).toEqual(
'Showing 0 of 0 log entries'
);
});
@ -576,7 +576,7 @@ describe.skip('rule_event_log_list', () => {
wrapper.update();
});
expect(wrapper.find('[data-test-subj="ruleEventLogPaginationStatus"]').first().text()).toEqual(
expect(wrapper.find('[data-test-subj="eventLogPaginationStatus"]').first().text()).toEqual(
'Showing 1 - 1 of 1 log entry'
);
});
@ -603,7 +603,7 @@ describe.skip('rule_event_log_list', () => {
wrapper.update();
});
expect(wrapper.find('[data-test-subj="ruleEventLogPaginationStatus"]').first().text()).toEqual(
expect(wrapper.find('[data-test-subj="eventLogPaginationStatus"]').first().text()).toEqual(
'Showing 1 - 10 of 85 log entries'
);
@ -614,7 +614,7 @@ describe.skip('rule_event_log_list', () => {
wrapper.update();
});
expect(wrapper.find('[data-test-subj="ruleEventLogPaginationStatus"]').first().text()).toEqual(
expect(wrapper.find('[data-test-subj="eventLogPaginationStatus"]').first().text()).toEqual(
'Showing 11 - 20 of 85 log entries'
);
@ -625,7 +625,7 @@ describe.skip('rule_event_log_list', () => {
wrapper.update();
});
expect(wrapper.find('[data-test-subj="ruleEventLogPaginationStatus"]').first().text()).toEqual(
expect(wrapper.find('[data-test-subj="eventLogPaginationStatus"]').first().text()).toEqual(
'Showing 81 - 85 of 85 log entries'
);
});
@ -674,10 +674,7 @@ describe.skip('rule_event_log_list', () => {
expect(wrapper.find('[data-test-subj="ruleActionErrorBadge"]').first().text()).toEqual('4');
// Click to open flyout
wrapper
.find('[data-test-subj="ruleEventLogDataGridErroredActionBadge"]')
.first()
.simulate('click');
wrapper.find('[data-test-subj="eventLogDataGridErroredActionBadge"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="ruleActionErrorLogFlyout"]').exists()).toBeTruthy();
});

View file

@ -8,7 +8,7 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import datemath from '@kbn/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiIconTip, EuiStat, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiSpacer } from '@elastic/eui';
import { IExecutionKPIResult } from '@kbn/alerting-plugin/common';
import {
ComponentOpts as RuleApis,
@ -16,7 +16,7 @@ import {
} from '../../common/components/with_bulk_rule_api_operations';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { EventLogListStatus, EventLogStat } from '../../common/components/event_log';
const getParsedDate = (date: string) => {
if (date.includes('now')) {
@ -53,31 +53,6 @@ const ACTIONS_TOOLTIP = i18n.translate(
}
);
const Stat = ({
title,
tooltip,
children,
}: {
title: string;
tooltip: string;
children?: JSX.Element;
}) => {
return (
<EuiPanel color="subdued">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<b>{title}</b>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip content={tooltip} position="top" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{children}
</EuiPanel>
);
};
export type RuleEventLogListKPIProps = {
ruleId: string;
dateStart: string;
@ -165,13 +140,13 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
return (
<EuiFlexGroup>
<EuiFlexItem grow={4}>
<Stat title="Response" tooltip={RESPONSE_TOOLTIP}>
<EventLogStat title="Responses" tooltip={RESPONSE_TOOLTIP}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiStat
data-test-subj="ruleEventLogKpi-successOutcome"
description={getStatDescription(
<RuleEventLogListStatus
<EventLogListStatus
status="success"
useExecutionStatus={isRuleUsingExecutionStatus}
/>
@ -185,7 +160,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
<EuiStat
data-test-subj="ruleEventLogKpi-warningOutcome"
description={getStatDescription(
<RuleEventLogListStatus
<EventLogListStatus
status="warning"
useExecutionStatus={isRuleUsingExecutionStatus}
/>
@ -199,7 +174,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
<EuiStat
data-test-subj="ruleEventLogKpi-failureOutcome"
description={getStatDescription(
<RuleEventLogListStatus
<EventLogListStatus
status="failure"
useExecutionStatus={isRuleUsingExecutionStatus}
/>
@ -210,10 +185,10 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
/>
</EuiFlexItem>
</EuiFlexGroup>
</Stat>
</EventLogStat>
</EuiFlexItem>
<EuiFlexItem grow={4}>
<Stat title="Alerts" tooltip={ALERTS_TOOLTIP}>
<EventLogStat title="Alerts" tooltip={ALERTS_TOOLTIP}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiStat
@ -243,10 +218,10 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
/>
</EuiFlexItem>
</EuiFlexGroup>
</Stat>
</EventLogStat>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<Stat title="Actions" tooltip={ACTIONS_TOOLTIP}>
<EventLogStat title="Actions" tooltip={ACTIONS_TOOLTIP}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiStat
@ -267,7 +242,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
/>
</EuiFlexItem>
</EuiFlexGroup>
</Stat>
</EventLogStat>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import datemath from '@kbn/datemath';
import {
EuiFieldSearch,
@ -19,20 +20,29 @@ import {
EuiSuperDatePicker,
OnTimeChangeProps,
EuiSwitch,
EuiDataGridColumn,
} from '@elastic/eui';
import { IExecutionLog } from '@kbn/alerting-plugin/common';
import { SpacesContextProps } from '@kbn/spaces-plugin/public';
import { useKibana, useSpacesData } from '../../../../common/lib/kibana';
import { useKibana } from '../../../../common/lib/kibana';
import {
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
LOCKED_COLUMNS,
} from '../../../constants';
import { RuleEventLogListStatusFilter } from './rule_event_log_list_status_filter';
import { RuleEventLogDataGrid } from './rule_event_log_data_grid';
import {
EventLogDataGrid,
getIsColumnSortable,
ColumnHeaderWithToolTip,
numTriggeredActionsDisplay,
numGeneratedActionsDisplay,
numSucceededActionsDisplay,
numErroredActionsDisplay,
EventLogListStatusFilter,
} from '../../common/components/event_log';
import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner';
import { RuleActionErrorLogFlyout } from './rule_action_error_log_flyout';
import { RefineSearchPrompt } from '../refine_search_prompt';
import { RefineSearchPrompt } from '../../common/components/refine_search_prompt';
import { RulesListDocLink } from '../../rules_list/components/rules_list_doc_link';
import { LoadExecutionLogAggregationsProps } from '../../../lib/rule_api';
import { RuleEventLogListKPIWithApi as RuleEventLogListKPI } from './rule_event_log_list_kpi';
@ -40,6 +50,7 @@ import {
ComponentOpts as RuleApis,
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
import { useMultipleSpaces } from '../../../hooks/use_multiple_spaces';
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
@ -170,23 +181,13 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
);
});
const spacesData = useSpacesData();
const accessibleSpaceIds = useMemo(
() => (spacesData ? [...spacesData.spacesMap.values()].map((e) => e.id) : []),
[spacesData]
);
const areMultipleSpacesAccessible = useMemo(
() => accessibleSpaceIds.length > 1,
[accessibleSpaceIds]
);
const namespaces = useMemo(
() => (showFromAllSpaces && spacesData ? accessibleSpaceIds : undefined),
[showFromAllSpaces, spacesData, accessibleSpaceIds]
);
const activeSpace = useMemo(
() => spacesData?.spacesMap.get(spacesData?.activeSpaceId),
[spacesData]
);
const { onShowAllSpacesChange, canAccessMultipleSpaces, namespaces, activeSpace } =
useMultipleSpaces({
setShowFromAllSpaces,
showFromAllSpaces,
visibleColumns,
setVisibleColumns,
});
const isInitialized = useRef(false);
@ -255,6 +256,14 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
setIsLoading(false);
};
const getPaginatedRowIndex = useCallback(
(rowIndex: number) => {
const { pageIndex, pageSize } = pagination;
return rowIndex - pageIndex * pageSize;
},
[pagination]
);
const onChangeItemsPerPage = useCallback(
(pageSize: number) => {
setPagination((prevPagination) => ({
@ -332,19 +341,277 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
[search, setSearchText]
);
const onShowAllSpacesChange = useCallback(() => {
setShowFromAllSpaces((prev) => !prev);
const nextShowFromAllSpaces = !showFromAllSpaces;
if (nextShowFromAllSpaces && !visibleColumns.includes('space_ids')) {
const ruleNameIndex = visibleColumns.findIndex((c) => c === 'rule_name');
const newVisibleColumns = [...visibleColumns];
newVisibleColumns.splice(ruleNameIndex + 1, 0, 'space_ids');
setVisibleColumns(newVisibleColumns);
} else if (!nextShowFromAllSpaces && visibleColumns.includes('space_ids')) {
setVisibleColumns(visibleColumns.filter((c) => c !== 'space_ids'));
}
}, [setShowFromAllSpaces, showFromAllSpaces, visibleColumns]);
const columns: EuiDataGridColumn[] = useMemo(
() => [
...(hasRuleNames
? [
{
id: 'rule_id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleId',
{
defaultMessage: 'Rule Id',
}
),
isSortable: getIsColumnSortable('rule_id'),
actions: {
showSortAsc: false,
showSortDesc: false,
},
},
{
id: 'rule_name',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.ruleName',
{
defaultMessage: 'Rule',
}
),
isSortable: getIsColumnSortable('rule_name'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
...(showFromAllSpaces
? [
{
id: 'space_ids',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.spaceIds',
{
defaultMessage: 'Space',
}
),
isSortable: getIsColumnSortable('space_ids'),
actions: {
showSortAsc: false,
showSortDesc: false,
showHide: false,
},
},
]
: []),
{
id: 'id',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.id',
{
defaultMessage: 'Id',
}
),
isSortable: getIsColumnSortable('id'),
},
{
id: 'timestamp',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timestamp',
{
defaultMessage: 'Timestamp',
}
),
isSortable: getIsColumnSortable('timestamp'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 250,
},
{
id: 'execution_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.duration',
{
defaultMessage: 'Duration',
}
),
isSortable: getIsColumnSortable('execution_duration'),
isResizable: false,
actions: {
showHide: false,
},
initialWidth: 100,
},
{
id: 'status',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.response',
{
defaultMessage: 'Response',
}
),
actions: {
showHide: false,
showSortAsc: false,
showSortDesc: false,
additional: [
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showOnlyFailures',
{
defaultMessage: 'Show only failures',
}
),
onClick: () => onFilterChange(['failure']),
size: 'xs',
},
{
iconType: 'annotation',
label: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.showAll',
{
defaultMessage: 'Show all',
}
),
onClick: () => onFilterChange([]),
size: 'xs',
},
],
},
isSortable: getIsColumnSortable('status'),
isResizable: false,
initialWidth: 150,
},
{
id: 'message',
actions: {
showSortAsc: false,
showSortDesc: false,
},
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.message',
{
defaultMessage: 'Message',
}
),
isSortable: getIsColumnSortable('message'),
cellActions: [
({ rowIndex, Component }) => {
const pagedRowIndex = getPaginatedRowIndex(rowIndex);
const eventLog = logs || [];
const runLog = eventLog[pagedRowIndex];
const actionErrors = runLog?.num_errored_actions as number;
if (actionErrors) {
return (
<Component onClick={() => onFlyoutOpen(runLog)} iconType="alert">
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.viewActionErrors"
defaultMessage="View action errors"
/>
</Component>
);
}
return null;
},
],
},
{
id: 'num_active_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.activeAlerts',
{
defaultMessage: 'Active alerts',
}
),
initialWidth: 140,
isSortable: getIsColumnSortable('num_active_alerts'),
},
{
id: 'num_new_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.newAlerts',
{
defaultMessage: 'New alerts',
}
),
initialWidth: 140,
isSortable: getIsColumnSortable('num_new_alerts'),
},
{
id: 'num_recovered_alerts',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.recoveredAlerts',
{
defaultMessage: 'Recovered alerts',
}
),
isSortable: getIsColumnSortable('num_recovered_alerts'),
},
{
id: 'num_triggered_actions',
displayAsText: numTriggeredActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_triggered_actions" />,
isSortable: getIsColumnSortable('num_triggered_actions'),
},
{
id: 'num_generated_actions',
displayAsText: numGeneratedActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_generated_actions" />,
isSortable: getIsColumnSortable('num_generated_actions'),
},
{
id: 'num_succeeded_actions',
displayAsText: numSucceededActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_succeeded_actions" />,
isSortable: getIsColumnSortable('num_succeeded_actions'),
},
{
id: 'num_errored_actions',
actions: {
showSortAsc: false,
showSortDesc: false,
},
displayAsText: numErroredActionsDisplay,
display: <ColumnHeaderWithToolTip id="num_errored_actions" />,
isSortable: getIsColumnSortable('num_errored_actions'),
},
{
id: 'total_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.totalSearchDuration',
{
defaultMessage: 'Total search duration',
}
),
isSortable: getIsColumnSortable('total_search_duration'),
},
{
id: 'es_search_duration',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.esSearchDuration',
{
defaultMessage: 'ES search duration',
}
),
isSortable: getIsColumnSortable('es_search_duration'),
},
{
id: 'schedule_delay',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.scheduleDelay',
{
defaultMessage: 'Schedule delay',
}
),
isSortable: getIsColumnSortable('schedule_delay'),
},
{
id: 'timed_out',
displayAsText: i18n.translate(
'xpack.triggersActionsUI.sections.ruleDetails.eventLogColumn.timedOut',
{
defaultMessage: 'Timed out',
}
),
isSortable: getIsColumnSortable('timed_out'),
},
],
[getPaginatedRowIndex, onFlyoutOpen, onFilterChange, hasRuleNames, showFromAllSpaces, logs]
);
const renderList = () => {
if (!logs) {
@ -355,19 +622,17 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
{isLoading && (
<EuiProgress size="xs" color="accent" data-test-subj="ruleEventLogListProgressBar" />
)}
<RuleEventLogDataGrid
<EventLogDataGrid
columns={columns}
logs={logs}
pagination={pagination}
sortingColumns={sortingColumns}
visibleColumns={visibleColumns}
dateFormat={dateFormat}
selectedRunLog={selectedRunLog}
showRuleNameAndIdColumns={hasRuleNames}
showSpaceColumns={showFromAllSpaces}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={onChangePage}
onFlyoutOpen={onFlyoutOpen}
onFilterChange={setFilter}
setVisibleColumns={setVisibleColumns}
setSortingColumns={setSortingColumns}
/>
@ -420,7 +685,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RuleEventLogListStatusFilter selectedOptions={filter} onChange={onFilterChange} />
<EventLogListStatusFilter selectedOptions={filter} onChange={onFilterChange} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
@ -436,7 +701,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
updateButtonProps={updateButtonProps}
/>
</EuiFlexItem>
{hasAllSpaceSwitch && areMultipleSpacesAccessible && (
{hasAllSpaceSwitch && canAccessMultipleSpaces && (
<EuiFlexItem data-test-subj="showAllSpacesSwitch">
<EuiSwitch
label={ALL_SPACES_LABEL}

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import { v4 as uuidv4 } from 'uuid';
import moment from 'moment';
import expect from '@kbn/expect';
import { asyncForEach } from '@kbn/std';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ObjectRemover } from '../../../lib/object_remover';
import { generateUniqueKey } from '../../../lib/get_test_data';
@ -13,6 +16,8 @@ import {
getConnectorByName,
createSlackConnectorAndObjectRemover,
createSlackConnector,
createRuleWithActionsAndParams,
getAlertSummary,
} from './utils';
export default ({ getPageObjects, getPageObject, getService }: FtrProviderContext) => {
@ -291,6 +296,107 @@ export default ({ getPageObjects, getPageObject, getService }: FtrProviderContex
expect(await testSubjects.exists('preconfiguredBadge')).to.be(true);
expect(await testSubjects.exists('edit-connector-flyout-save-btn')).to.be(false);
});
describe('Execution log', () => {
const testRunUuid = uuidv4();
let rule: any;
before(async () => {
await pageObjects.common.navigateToApp('triggersActions');
const connectorName = generateUniqueKey();
const createdAction = await createSlackConnector({ name: connectorName, getService });
objectRemover.add(createdAction.id, 'action', 'actions');
const alerts = [{ id: 'us-central' }];
rule = await createRuleWithActionsAndParams(
createdAction.id,
testRunUuid,
{
instances: alerts,
},
{
schedule: { interval: '1s' },
},
supertest
);
objectRemover.add(rule.id, 'alert', 'alerts');
// refresh to see rule
await browser.refresh();
await pageObjects.header.waitUntilLoadingHasFinished();
// click on first rule
await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(rule.name);
// await first run to complete so we have an initial state
await retry.try(async () => {
const { alerts: alertInstances } = await getAlertSummary(rule.id, supertest);
expect(Object.keys(alertInstances).length).to.eql(alerts.length);
});
});
after(async () => {
await objectRemover.removeAll();
});
it('renders the event log list and can filter/sort', async () => {
await browser.refresh();
await (await testSubjects.find('logsTab')).click();
const tabbedContentExists = await testSubjects.exists('ruleDetailsTabbedContent');
if (!tabbedContentExists) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
const refreshButton = await testSubjects.find('superDatePickerApplyTimeButton');
await refreshButton.click();
// List, date picker, and status picker all exists
await testSubjects.existOrFail('eventLogList');
await testSubjects.existOrFail('eventLogStatusFilterButton');
let statusFilter = await testSubjects.find('eventLogStatusFilterButton');
let statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(0);
await statusFilter.click();
await testSubjects.click('eventLogStatusFilter-success');
await statusFilter.click();
statusFilter = await testSubjects.find('eventLogStatusFilterButton');
statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(1);
const eventLogList = await find.byCssSelector('.euiDataGridRow');
const rows = await eventLogList.parseDomContent();
expect(rows.length).to.be.greaterThan(0);
await pageObjects.triggersActionsUI.ensureEventLogColumnExists('timestamp');
const timestampCells = await find.allByCssSelector(
'[data-gridcell-column-id="timestamp"][data-test-subj="dataGridRowCell"]'
);
let validTimestamps = 0;
await asyncForEach(timestampCells, async (cell) => {
const text = await cell.getVisibleText();
if (text.toLowerCase() !== 'invalid date') {
if (moment(text).isValid()) {
validTimestamps += 1;
}
}
});
expect(validTimestamps).to.be.greaterThan(0);
await pageObjects.triggersActionsUI.sortEventLogColumn('timestamp', 'asc');
await testSubjects.existOrFail('dataGridHeaderCellSortingIcon-timestamp');
});
});
});
async function createIndexConnector(connectorName: string, indexName: string) {

View file

@ -7,10 +7,10 @@
import type SuperTest from 'supertest';
import { findIndex } from 'lodash';
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import { ObjectRemover } from '../../../lib/object_remover';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { getTestActionData } from '../../../lib/get_test_data';
import { getTestActionData, getTestAlertData } from '../../../lib/get_test_data';
export const createSlackConnectorAndObjectRemover = async ({
getService,
@ -59,3 +59,62 @@ export const getConnectorByName = async (
const i = findIndex(body, (c: any) => c.name === name);
return body[i];
};
export async function createRuleWithActionsAndParams(
connectorId: string,
testRunUuid: string,
params: Record<string, any> = {},
overwrites: Record<string, any> = {},
supertest: SuperTest.SuperTest<SuperTest.Test>
) {
return await createAlwaysFiringRule(
{
name: `test-rule-${testRunUuid}`,
actions: [
{
id: connectorId,
group: 'default',
params: {
message: 'from alert 1s',
level: 'warn',
},
frequency: {
summary: false,
notify_when: RuleNotifyWhen.THROTTLE,
throttle: '1m',
},
},
],
params,
...overwrites,
},
supertest
);
}
async function createAlwaysFiringRule(
overwrites: Record<string, any> = {},
supertest: SuperTest.SuperTest<SuperTest.Test>
) {
const { body: createdRule } = await supertest
.post(`/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
rule_type_id: 'test.always-firing',
...overwrites,
})
)
.expect(200);
return createdRule;
}
export async function getAlertSummary(
ruleId: string,
supertest: SuperTest.SuperTest<SuperTest.Test>
) {
const { body: summary } = await supertest
.get(`/internal/alerting/rule/${encodeURIComponent(ruleId)}/_alert_summary`)
.expect(200);
return summary;
}

View file

@ -1048,20 +1048,20 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await refreshButton.click();
// List, date picker, and status picker all exists
await testSubjects.existOrFail('ruleEventLogList');
await testSubjects.existOrFail('eventLogList');
await testSubjects.existOrFail('ruleEventLogListDatePicker');
await testSubjects.existOrFail('ruleEventLogStatusFilterButton');
await testSubjects.existOrFail('eventLogStatusFilterButton');
let statusFilter = await testSubjects.find('ruleEventLogStatusFilterButton');
let statusFilter = await testSubjects.find('eventLogStatusFilterButton');
let statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(0);
await statusFilter.click();
await testSubjects.click('ruleEventLogStatusFilter-success');
await testSubjects.click('eventLogStatusFilter-success');
await statusFilter.click();
statusFilter = await testSubjects.find('ruleEventLogStatusFilterButton');
statusFilter = await testSubjects.find('eventLogStatusFilterButton');
statusNumber = await statusFilter.findByCssSelector('.euiNotificationBadge');
expect(statusNumber.getVisibleText()).to.eql(1);