[RAM] Allow users to see event logs from all spaces they have access to (#140449)

* Add all_namespaces prop to global logs api

* Display space column and disable link on inactive spaces

* Add ability to link across spaces

* Fix allNamespace query on default space

* Fix KPI and link space switch to permissions

* Open alternate space rules in new tab

* Fix Jest 11

* Fix Jest 1

* Fix Jest 4 and 10

* Fix i18n

* Move space column visibility out of data grid
This commit is contained in:
Zacqary Adam Xeper 2022-10-31 19:26:53 -05:00 committed by GitHub
parent e502ecfd18
commit 5c50cd4ffd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 704 additions and 74 deletions

View file

@ -61,6 +61,7 @@ export interface IExecutionLog {
schedule_delay_ms: number;
timed_out: boolean;
rule_id: string;
space_ids: string[];
rule_name: string;
}

View file

@ -280,6 +280,7 @@ describe('getExecutionLogAggregation', () => {
'error.message',
'kibana.version',
'rule.id',
'kibana.space_ids',
'rule.name',
'kibana.alerting.outcome',
],
@ -486,6 +487,7 @@ describe('getExecutionLogAggregation', () => {
'error.message',
'kibana.version',
'rule.id',
'kibana.space_ids',
'rule.name',
'kibana.alerting.outcome',
],
@ -692,6 +694,7 @@ describe('getExecutionLogAggregation', () => {
'error.message',
'kibana.version',
'rule.id',
'kibana.space_ids',
'rule.name',
'kibana.alerting.outcome',
],
@ -954,6 +957,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -976,6 +980,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
],
});
@ -1203,6 +1208,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -1225,6 +1231,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
],
});
@ -1444,6 +1451,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3074,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -1466,6 +1474,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
],
});
@ -1690,6 +1699,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
{
id: '61bb867b-661a-471f-bf92-23471afa10b3',
@ -1712,6 +1722,7 @@ describe('formatExecutionLogResult', () => {
schedule_delay_ms: 3133,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: [],
},
],
});

View file

@ -18,6 +18,7 @@ const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number
const DEFAULT_MAX_KPI_BUCKETS_LIMIT = 10000;
const RULE_ID_FIELD = 'rule.id';
const SPACE_ID_FIELD = 'kibana.space_ids';
const RULE_NAME_FIELD = 'rule.name';
const PROVIDER_FIELD = 'event.provider';
const START_FIELD = 'event.start';
@ -410,6 +411,7 @@ export function getExecutionLogAggregation({
ERROR_MESSAGE_FIELD,
VERSION_FIELD,
RULE_ID_FIELD,
SPACE_ID_FIELD,
RULE_NAME_FIELD,
ALERTING_OUTCOME_FIELD,
],
@ -494,8 +496,9 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio
status === 'failure' ? `${outcomeMessage} - ${outcomeErrorMessage}` : outcomeMessage;
const version = outcomeAndMessage.kibana?.version ?? '';
const ruleId = outcomeAndMessage.rule?.id ?? '';
const ruleName = outcomeAndMessage.rule?.name ?? '';
const ruleId = outcomeAndMessage ? outcomeAndMessage?.rule?.id ?? '' : '';
const spaceIds = outcomeAndMessage ? outcomeAndMessage?.kibana?.space_ids ?? [] : [];
const ruleName = outcomeAndMessage ? outcomeAndMessage.rule?.name ?? '' : '';
return {
id: bucket?.key ?? '',
timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '',
@ -515,6 +518,7 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio
schedule_delay_ms: scheduleDelayUs / Millis2Nanos,
timed_out: timedOut,
rule_id: ruleId,
space_ids: spaceIds,
rule_name: ruleName,
};
}

View file

@ -34,15 +34,19 @@ const querySchema = schema.object({
per_page: schema.number({ defaultValue: 10, min: 1 }),
page: schema.number({ defaultValue: 1, min: 1 }),
sort: sortFieldsSchema,
namespace: schema.maybe(schema.string()),
with_auth: schema.maybe(schema.boolean()),
});
const rewriteReq: RewriteRequestCase<GetActionErrorLogByIdParams> = ({
date_start: dateStart,
date_end: dateEnd,
per_page: perPage,
namespace,
...rest
}) => ({
...rest,
namespace,
dateStart,
dateEnd,
perPage,
@ -64,8 +68,13 @@ export const getActionErrorLogRoute = (
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = (await context.alerting).getRulesClient();
const { id } = req.params;
const withAuth = req.query.with_auth;
const rewrittenReq = rewriteReq({ id, ...req.query });
const getter = (
withAuth ? rulesClient.getActionErrorLogWithAuth : rulesClient.getActionErrorLog
).bind(rulesClient);
return res.ok({
body: await rulesClient.getActionErrorLog(rewriteReq({ id, ...req.query })),
body: await getter(rewrittenReq),
});
})
)

View file

@ -7,7 +7,7 @@
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { RewriteRequestCase, verifyAccessAndContext } from './lib';
import { RewriteRequestCase, verifyAccessAndContext, rewriteNamespaces } from './lib';
import { GetGlobalExecutionKPIParams } from '../rules_client';
import { ILicenseState } from '../lib';
@ -15,14 +15,17 @@ const querySchema = 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,
});

View file

@ -47,6 +47,7 @@ describe('getRuleExecutionLogRoute', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: ['namespace'],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -69,6 +70,7 @@ describe('getRuleExecutionLogRoute', () => {
schedule_delay_ms: 3008,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: ['namespace'],
},
],
};

View file

@ -9,7 +9,7 @@ import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../lib';
import { GetGlobalExecutionLogParams } from '../rules_client';
import { RewriteRequestCase, verifyAccessAndContext } from './lib';
import { RewriteRequestCase, verifyAccessAndContext, rewriteNamespaces } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]);
@ -38,15 +38,18 @@ const querySchema = schema.object({
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 rewriteReq: RewriteRequestCase<GetGlobalExecutionLogParams> = ({
date_start: dateStart,
date_end: dateEnd,
per_page: perPage,
namespaces,
...rest
}) => ({
...rest,
namespaces: rewriteNamespaces(namespaces),
dateStart,
dateEnd,
perPage,

View file

@ -48,6 +48,7 @@ describe('getRuleExecutionLogRoute', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: ['namespace'],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -70,6 +71,7 @@ describe('getRuleExecutionLogRoute', () => {
schedule_delay_ms: 3008,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule_name',
space_ids: ['namespace'],
},
],
};

View file

@ -19,3 +19,4 @@ export type {
export { verifyAccessAndContext } from './verify_access_and_context';
export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids';
export { rewriteRule } from './rewrite_rule';
export { rewriteNamespaces } from './rewrite_namespaces';

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

@ -34,6 +34,7 @@ const createRulesClientMock = () => {
getGlobalExecutionKpiWithAuth: jest.fn(),
getGlobalExecutionLogWithAuth: jest.fn(),
getActionErrorLog: jest.fn(),
getActionErrorLogWithAuth: jest.fn(),
getSpaceId: jest.fn(),
bulkEdit: jest.fn(),
bulkDeleteRules: jest.fn(),

View file

@ -419,6 +419,7 @@ export interface GetGlobalExecutionKPIParams {
dateStart: string;
dateEnd?: string;
filter?: string;
namespaces?: Array<string | undefined>;
}
export interface GetGlobalExecutionLogParams {
@ -428,6 +429,7 @@ export interface GetGlobalExecutionLogParams {
page: number;
perPage: number;
sort: estypes.Sort;
namespaces?: Array<string | undefined>;
}
export interface GetActionErrorLogByIdParams {
@ -438,6 +440,7 @@ export interface GetActionErrorLogByIdParams {
page: number;
perPage: number;
sort: estypes.Sort;
namespace?: string;
}
interface ScheduleTaskOptions {
@ -458,6 +461,9 @@ const MAX_RULES_NUMBER_FOR_BULK_OPERATION = 10000;
const API_KEY_GENERATE_CONCURRENCY = 50;
const RULE_TYPE_CHECKS_CONCURRENCY = 50;
const actionErrorLogDefaultFilter =
'event.provider:actions AND ((event.action:execute AND (event.outcome:failure OR kibana.alerting.status:warning)) OR (event.action:execute-timeout))';
const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = {
type: AlertingAuthorizationFilterType.KQL,
fieldNames: { ruleTypeId: 'alert.attributes.alertTypeId', consumer: 'alert.attributes.consumer' },
@ -951,6 +957,7 @@ export class RulesClient {
page,
perPage,
sort,
namespaces,
}: GetGlobalExecutionLogParams): Promise<IExecutionLogResult> {
this.logger.debug(`getGlobalExecutionLogWithAuth(): getting global execution log`);
@ -1001,7 +1008,8 @@ export class RulesClient {
perPage,
sort,
}),
}
},
namespaces
);
return formatExecutionLogResult(aggResult);
@ -1050,9 +1058,6 @@ export class RulesClient {
})
);
const defaultFilter =
'event.provider:actions AND ((event.action:execute AND (event.outcome:failure OR kibana.alerting.status:warning)) OR (event.action:execute-timeout))';
// default duration of instance summary is 60 * rule interval
const dateNow = new Date();
const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow);
@ -1069,7 +1074,9 @@ export class RulesClient {
end: parsedDateEnd.toISOString(),
page,
per_page: perPage,
filter: filter ? `(${defaultFilter}) AND (${filter})` : defaultFilter,
filter: filter
? `(${actionErrorLogDefaultFilter}) AND (${filter})`
: actionErrorLogDefaultFilter,
sort: convertEsSortToEventLogSort(sort),
},
rule.legacyId !== null ? [rule.legacyId] : undefined
@ -1083,10 +1090,85 @@ export class RulesClient {
}
}
public async getActionErrorLogWithAuth({
id,
dateStart,
dateEnd,
filter,
page,
perPage,
sort,
namespace,
}: GetActionErrorLogByIdParams): Promise<IExecutionErrorsResult> {
this.logger.debug(`getActionErrorLogWithAuth(): getting action error logs for rule ${id}`);
let authorizationTuple;
try {
authorizationTuple = await this.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Alert,
{
type: AlertingAuthorizationFilterType.KQL,
fieldNames: {
ruleTypeId: 'kibana.alert.rule.rule_type_id',
consumer: 'kibana.alert.rule.consumer',
},
}
);
} catch (error) {
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.GET_ACTION_ERROR_LOG,
error,
})
);
throw error;
}
this.auditLogger?.log(
ruleAuditEvent({
action: RuleAuditAction.GET_ACTION_ERROR_LOG,
savedObject: { type: 'alert', id },
})
);
// default duration of instance summary is 60 * rule interval
const dateNow = new Date();
const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow);
const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow);
const eventLogClient = await this.getEventLogClient();
try {
const errorResult = await eventLogClient.findEventsWithAuthFilter(
'alert',
[id],
authorizationTuple.filter as KueryNode,
namespace,
{
start: parsedDateStart.toISOString(),
end: parsedDateEnd.toISOString(),
page,
per_page: perPage,
filter: filter
? `(${actionErrorLogDefaultFilter}) AND (${filter})`
: actionErrorLogDefaultFilter,
sort: convertEsSortToEventLogSort(sort),
}
);
return formatExecutionErrorsResult(errorResult);
} catch (err) {
this.logger.debug(
`rulesClient.getActionErrorLog(): error searching event log for rule ${id}: ${err.message}`
);
throw err;
}
}
public async getGlobalExecutionKpiWithAuth({
dateStart,
dateEnd,
filter,
namespaces,
}: GetGlobalExecutionKPIParams) {
this.logger.debug(`getGlobalExecutionLogWithAuth(): getting global execution log`);
@ -1132,7 +1214,8 @@ export class RulesClient {
start: parsedDateStart.toISOString(),
end: parsedDateEnd.toISOString(),
aggs: getExecutionKPIAggregation(filter),
}
},
namespaces
);
return formatExecutionKPIResult(aggResult);

View file

@ -8,6 +8,7 @@
import { RulesClient, ConstructorOptions, GetActionErrorLogByIdParams } from '../rules_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { fromKueryExpression } from '@kbn/es-query';
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
@ -574,3 +575,63 @@ describe('getActionErrorLog()', () => {
});
});
});
describe('getActionErrorLogWithAuth()', () => {
let rulesClient: RulesClient;
beforeEach(() => {
rulesClient = new RulesClient(rulesClientParams);
});
test('returns the expected return values when called', async () => {
const ruleSO = getRuleSavedObject({});
authorization.getFindAuthorizationFilter.mockResolvedValue({
filter: fromKueryExpression('*'),
ensureRuleTypeIsAuthorized() {},
});
unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO);
eventLogClient.findEventsWithAuthFilter.mockResolvedValueOnce(findResults);
const result = await rulesClient.getActionErrorLogWithAuth(getActionErrorLogParams());
expect(result).toEqual({
totalErrors: 5,
errors: [
{
id: '08d9b0f5-0b41-47c9-951f-a666b5788ddc',
timestamp: '2022-03-23T17:37:07.106Z',
type: 'actions',
message:
'action execution failure: .server-log:9e67b8b0-9e2c-11ec-bd64-774ed95c43ef: s - an error occurred while running the action executor: something funky with the server log',
},
{
id: '08d9b0f5-0b41-47c9-951f-a666b5788ddc',
timestamp: '2022-03-23T17:37:07.102Z',
type: 'actions',
message:
'action execution failure: .server-log:9e67b8b0-9e2c-11ec-bd64-774ed95c43ef: s - an error occurred while running the action executor: something funky with the server log',
},
{
id: '08d9b0f5-0b41-47c9-951f-a666b5788ddc',
timestamp: '2022-03-23T17:37:07.098Z',
type: 'actions',
message:
'action execution failure: .server-log:9e67b8b0-9e2c-11ec-bd64-774ed95c43ef: s - an error occurred while running the action executor: something funky with the server log',
},
{
id: '08d9b0f5-0b41-47c9-951f-a666b5788ddc',
timestamp: '2022-03-23T17:37:07.096Z',
type: 'actions',
message:
'action execution failure: .server-log:9e67b8b0-9e2c-11ec-bd64-774ed95c43ef: s - an error occurred while running the action executor: something funky with the server log',
},
{
id: '08d9b0f5-0b41-47c9-951f-a666b5788ddc',
timestamp: '2022-03-23T17:37:07.086Z',
type: 'actions',
message:
'action execution failure: .server-log:9e67b8b0-9e2c-11ec-bd64-774ed95c43ef: s - an error occurred while running the action executor: something funky with the server log',
},
],
});
});
});

View file

@ -385,6 +385,7 @@ describe('getExecutionLogForRule()', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: [],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -407,6 +408,7 @@ describe('getExecutionLogForRule()', () => {
schedule_delay_ms: 3345,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: [],
},
],
});
@ -719,6 +721,7 @@ describe('getGlobalExecutionLogWithAuth()', () => {
schedule_delay_ms: 3126,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: [],
},
{
id: '41b2755e-765a-4044-9745-b03875d5e79a',
@ -741,6 +744,7 @@ describe('getGlobalExecutionLogWithAuth()', () => {
schedule_delay_ms: 3345,
rule_id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef',
rule_name: 'rule-name',
space_ids: [],
},
],
});

View file

@ -24,6 +24,7 @@ const createClusterClientMock = () => {
getExistingIndexAliases: jest.fn(),
setIndexAliasToHidden: jest.fn(),
queryEventsBySavedObjects: jest.fn(),
queryEventsWithAuthFilter: jest.fn(),
aggregateEventsBySavedObjects: jest.fn(),
aggregateEventsWithAuthFilter: jest.fn(),
shutdown: jest.fn(),

View file

@ -779,7 +779,7 @@ describe('aggregateEventsWithAuthFilter', () => {
});
const options: AggregateEventsWithAuthFilter = {
index: 'index-name',
namespace: 'namespace',
namespaces: ['namespace'],
type: 'saved-object-type',
aggregateOptions: DEFAULT_OPTIONS as AggregateOptionsType,
authFilter: fromKueryExpression('test:test'),
@ -1515,7 +1515,7 @@ describe('getQueryBody', () => {
describe('getQueryBodyWithAuthFilter', () => {
const options = {
index: 'index-name',
namespace: undefined,
namespaces: undefined,
type: 'saved-object-type',
authFilter: fromKueryExpression('test:test'),
};
@ -1559,11 +1559,17 @@ describe('getQueryBodyWithAuthFilter', () => {
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
should: [
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
},
],
},
},
],
@ -1580,7 +1586,7 @@ describe('getQueryBodyWithAuthFilter', () => {
expect(
getQueryBodyWithAuthFilter(
logger,
{ ...options, namespace: 'namespace' } as AggregateEventsWithAuthFilter,
{ ...options, namespaces: ['namespace'] } as AggregateEventsWithAuthFilter,
{}
)
).toEqual({
@ -1619,10 +1625,16 @@ describe('getQueryBodyWithAuthFilter', () => {
},
},
{
term: {
'kibana.saved_objects.namespace': {
value: 'namespace',
},
bool: {
should: [
{
term: {
'kibana.saved_objects.namespace': {
value: 'namespace',
},
},
},
],
},
},
],
@ -1713,11 +1725,17 @@ describe('getQueryBodyWithAuthFilter', () => {
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
should: [
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
},
],
},
},
],
@ -1772,11 +1790,17 @@ describe('getQueryBodyWithAuthFilter', () => {
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
should: [
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
},
],
},
},
],
@ -1838,11 +1862,17 @@ describe('getQueryBodyWithAuthFilter', () => {
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
should: [
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
},
],
},
},
],
@ -1905,11 +1935,17 @@ describe('getQueryBodyWithAuthFilter', () => {
},
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
should: [
{
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
},
},
],
},
},
],

View file

@ -50,14 +50,26 @@ interface QueryOptionsEventsBySavedObjectFilter {
legacyIds?: string[];
}
export interface AggregateEventsWithAuthFilter {
interface QueryOptionsEventsWithAuthFilter {
index: string;
namespace: string | undefined;
type: string;
ids: string[];
authFilter: KueryNode;
}
export interface AggregateEventsWithAuthFilter {
index: string;
namespaces?: Array<string | undefined>;
type: string;
authFilter: KueryNode;
aggregateOptions: AggregateOptionsType;
}
export type FindEventsOptionsWithAuthFilter = QueryOptionsEventsWithAuthFilter & {
findOptions: FindOptionsType;
};
export type FindEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySavedObjectFilter & {
findOptions: FindOptionsType;
};
@ -70,6 +82,12 @@ export interface AggregateEventsBySavedObjectResult {
aggregations: Record<string, estypes.AggregationsAggregate> | undefined;
}
type GetQueryBodyWithAuthFilterOpts =
| (FindEventsOptionsWithAuthFilter & {
namespaces: AggregateEventsWithAuthFilter['namespaces'];
})
| AggregateEventsWithAuthFilter;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AliasAny = any;
@ -389,6 +407,50 @@ export class ClusterClientAdapter<TDoc extends { body: AliasAny; index: string }
}
}
public async queryEventsWithAuthFilter(
queryOptions: FindEventsOptionsWithAuthFilter
): Promise<QueryEventsBySavedObjectResult> {
const { index, type, ids, findOptions } = queryOptions;
const { page, per_page: perPage, sort } = findOptions;
const esClient = await this.elasticsearchClientPromise;
const query = getQueryBodyWithAuthFilter(
this.logger,
{ ...queryOptions, namespaces: [queryOptions.namespace] },
pick(queryOptions.findOptions, ['start', 'end', 'filter'])
);
const body: estypes.SearchRequest['body'] = {
size: perPage,
from: (page - 1) * perPage,
query,
...(sort
? { sort: sort.map((s) => ({ [s.sort_field]: { order: s.sort_order } })) as estypes.Sort }
: {}),
};
try {
const {
hits: { hits, total },
} = await esClient.search<IValidatedEvent>({
index,
track_total_hits: true,
body,
});
return {
page,
per_page: perPage,
total: isNumber(total) ? total : total!.value,
data: hits.map((hit) => hit._source),
};
} catch (err) {
throw new Error(
`querying for Event Log by for type "${type}" and ids "${ids}" failed with: ${err.message}`
);
}
}
public async aggregateEventsBySavedObjects(
queryOptions: AggregateEventsOptionsBySavedObjectFilter
): Promise<AggregateEventsBySavedObjectResult> {
@ -462,13 +524,15 @@ export class ClusterClientAdapter<TDoc extends { body: AliasAny; index: string }
export function getQueryBodyWithAuthFilter(
logger: Logger,
opts: AggregateEventsWithAuthFilter,
opts: GetQueryBodyWithAuthFilterOpts,
queryOptions: QueryOptionsType
) {
const { namespace, type, authFilter } = opts;
const { namespaces, type, authFilter } = opts;
const { start, end, filter } = queryOptions ?? {};
const namespaceQuery = getNamespaceQuery(namespace);
const namespaceQuery = (namespaces ?? [undefined]).map((namespace) =>
getNamespaceQuery(namespace)
);
let dslFilterQuery: estypes.QueryDslBoolQuery['filter'];
try {
const filterKueryNode = filter ? fromKueryExpression(filter) : null;
@ -501,8 +565,12 @@ export function getQueryBodyWithAuthFilter(
},
},
},
// @ts-expect-error undefined is not assignable as QueryDslTermQuery value
namespaceQuery,
{
bool: {
// @ts-expect-error undefined is not assignable as QueryDslTermQuery value
should: namespaceQuery,
},
},
];
const musts: estypes.QueryDslQueryContainer[] = [

View file

@ -10,6 +10,7 @@ import { IEventLogClient } from './types';
const createEventLogClientMock = () => {
const mock: jest.Mocked<IEventLogClient> = {
findEventsBySavedObjectIds: jest.fn(),
findEventsWithAuthFilter: jest.fn(),
aggregateEventsBySavedObjectIds: jest.fn(),
aggregateEventsWithAuthFilter: jest.fn(),
};

View file

@ -256,7 +256,7 @@ describe('EventLogStart', () => {
});
expect(esContext.esAdapter.aggregateEventsWithAuthFilter).toHaveBeenCalledWith({
index: esContext.esNames.indexPattern,
namespace: undefined,
namespaces: [undefined],
type: 'saved-object-type',
authFilter: testAuthFilter,
aggregateOptions: {

View file

@ -112,6 +112,31 @@ export class EventLogClient implements IEventLogClient {
});
}
public async findEventsWithAuthFilter(
type: string,
ids: string[],
authFilter: KueryNode,
namespace: string | undefined,
options?: Partial<FindOptionsType>
): Promise<QueryEventsBySavedObjectResult> {
if (!authFilter) {
throw new Error('No authorization filter defined!');
}
const findOptions = queryOptionsSchema.validate(options ?? {});
return await this.esContext.esAdapter.queryEventsWithAuthFilter({
index: this.esContext.esNames.indexPattern,
namespace: namespace
? this.spacesService?.spaceIdToNamespace(namespace)
: await this.getNamespace(),
type,
ids,
findOptions,
authFilter,
});
}
public async aggregateEventsBySavedObjectIds(
type: string,
ids: string[],
@ -142,7 +167,8 @@ export class EventLogClient implements IEventLogClient {
public async aggregateEventsWithAuthFilter(
type: string,
authFilter: KueryNode,
options?: AggregateOptionsType
options?: AggregateOptionsType,
namespaces?: Array<string | undefined>
) {
if (!authFilter) {
throw new Error('No authorization filter defined!');
@ -158,7 +184,7 @@ export class EventLogClient implements IEventLogClient {
return await this.esContext.esAdapter.aggregateEventsWithAuthFilter({
index: this.esContext.esNames.indexPattern,
namespace: await this.getNamespace(),
namespaces: namespaces ?? [await this.getNamespace()],
type,
authFilter,
aggregateOptions: { ...aggregateOptions, aggs } as AggregateOptionsType,

View file

@ -57,6 +57,13 @@ export interface IEventLogClient {
options?: Partial<FindOptionsType>,
legacyIds?: string[]
): Promise<QueryEventsBySavedObjectResult>;
findEventsWithAuthFilter(
type: string,
ids: string[],
authFilter: KueryNode,
namespace: string | undefined,
options?: Partial<FindOptionsType>
): Promise<QueryEventsBySavedObjectResult>;
aggregateEventsBySavedObjectIds(
type: string,
ids: string[],
@ -66,7 +73,8 @@ export interface IEventLogClient {
aggregateEventsWithAuthFilter(
type: string,
authFilter: KueryNode,
options?: Partial<AggregateOptionsType>
options?: Partial<AggregateOptionsType>,
namespaces?: Array<string | undefined>
): Promise<AggregateEventsBySavedObjectResult>;
}

View file

@ -44,6 +44,7 @@ export const DEFAULT_RULE_INTERVAL = '1m';
export const RULE_EXECUTION_LOG_COLUMN_IDS = [
'rule_id',
'rule_name',
'space_ids',
'id',
'timestamp',
'execution_duration',

View file

@ -118,9 +118,11 @@ describe('loadActionErrorLog', () => {
"date_end": "2022-03-23T16:17:53.482Z",
"date_start": "2022-03-23T16:17:53.482Z",
"filter": "(message: \\"test\\" OR error.message: \\"test\\") and kibana.alert.rule.execution.uuid: 123",
"namespace": undefined,
"page": 1,
"per_page": 10,
"sort": "[{\\"@timestamp\\":{\\"order\\":\\"asc\\"}}]",
"with_auth": false,
},
},
]

View file

@ -28,6 +28,8 @@ export interface LoadActionErrorLogProps {
perPage?: number;
page?: number;
sort?: SortField[];
namespace?: string;
withAuth?: boolean;
}
const SORT_MAP: Record<string, string> = {
@ -60,6 +62,8 @@ export const loadActionErrorLog = ({
perPage = 10,
page = 0,
sort,
namespace,
withAuth = false,
}: LoadActionErrorLogProps & { http: HttpSetup }) => {
const renamedSort = getRenamedSort(sort);
const filter = getFilter({ runId, message });
@ -76,6 +80,8 @@ export const loadActionErrorLog = ({
// whereas data grid sorts are 0 indexed.
page: page + 1,
sort: renamedSort.length ? JSON.stringify(renamedSort) : undefined,
namespace,
with_auth: withAuth,
},
}
);

View file

@ -59,7 +59,10 @@ export interface LoadExecutionLogAggregationsProps {
sort?: SortField[];
}
export type LoadGlobalExecutionLogAggregationsProps = Omit<LoadExecutionLogAggregationsProps, 'id'>;
export type LoadGlobalExecutionLogAggregationsProps = Omit<
LoadExecutionLogAggregationsProps,
'id'
> & { namespaces?: Array<string | undefined> };
export const loadExecutionLogAggregations = async ({
id,
@ -103,6 +106,7 @@ export const loadGlobalExecutionLogAggregations = async ({
perPage = 10,
page = 0,
sort = [],
namespaces,
}: LoadGlobalExecutionLogAggregationsProps & { http: HttpSetup }) => {
const sortField: any[] = sort;
const filter = getFilter({ outcomeFilter, message });
@ -119,6 +123,7 @@ export const loadGlobalExecutionLogAggregations = async ({
// whereas data grid sorts are 0 indexed.
page: page + 1,
sort: sortField.length ? JSON.stringify(sortField) : undefined,
namespaces: namespaces ? JSON.stringify(namespaces) : undefined,
},
}
);

View file

@ -16,6 +16,7 @@ export interface LoadGlobalExecutionKPIAggregationsProps {
message?: string;
dateStart: string;
dateEnd?: string;
namespaces?: Array<string | undefined>;
}
export const loadGlobalExecutionKPIAggregations = ({
@ -25,6 +26,7 @@ export const loadGlobalExecutionKPIAggregations = ({
message,
dateStart,
dateEnd,
namespaces,
}: LoadGlobalExecutionKPIAggregationsProps & { http: HttpSetup }) => {
const filter = getFilter({ outcomeFilter, message });
@ -33,6 +35,7 @@ export const loadGlobalExecutionKPIAggregations = ({
filter: filter.length ? filter.join(' and ') : undefined,
date_start: dateStart,
date_end: dateEnd,
namespaces: namespaces ? JSON.stringify(namespaces) : namespaces,
},
});
};

View file

@ -20,6 +20,7 @@ export const LogsList = () => {
refreshToken: 0,
initialPageSize: 50,
hasRuleNames: true,
hasAllSpaceSwitch: true,
localStorageKey: GLOBAL_EVENT_LOG_LIST_STORAGE_KEY,
});
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiTitle,
@ -28,17 +28,29 @@ export interface RuleActionErrorLogFlyoutProps {
runLog: IExecutionLog;
refreshToken?: number;
onClose: () => void;
activeSpaceId?: string;
}
export const RuleActionErrorLogFlyout = (props: RuleActionErrorLogFlyoutProps) => {
const { runLog, refreshToken, onClose } = props;
const { runLog, refreshToken, onClose, activeSpaceId } = props;
const { euiTheme } = useEuiTheme();
const { id, rule_id: ruleId, message, num_errored_actions: totalErrors } = runLog;
const {
id,
rule_id: ruleId,
message,
num_errored_actions: totalErrors,
space_ids: spaceIds = [],
} = runLog;
const isFlyoutPush = useIsWithinBreakpoints(['xl']);
const logFromDifferentSpace = useMemo(
() => Boolean(activeSpaceId && !spaceIds?.includes(activeSpaceId)),
[activeSpaceId, spaceIds]
);
return (
<EuiFlyout
type={isFlyoutPush ? 'push' : 'overlay'}
@ -82,7 +94,13 @@ export const RuleActionErrorLogFlyout = (props: RuleActionErrorLogFlyoutProps) =
}}
/>
</div>
<RuleErrorLogWithApi ruleId={ruleId} runId={id} refreshToken={refreshToken} />
<RuleErrorLogWithApi
ruleId={ruleId}
runId={id}
spaceId={spaceIds[0]}
logFromDifferentSpace={logFromDifferentSpace}
refreshToken={refreshToken}
/>
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -63,11 +63,13 @@ export type RuleErrorLogProps = {
ruleId: string;
runId?: string;
refreshToken?: number;
spaceId?: string;
logFromDifferentSpace?: boolean;
requestRefresh?: () => Promise<void>;
} & Pick<RuleApis, 'loadActionErrorLog'>;
export const RuleErrorLog = (props: RuleErrorLogProps) => {
const { ruleId, runId, loadActionErrorLog, refreshToken } = props;
const { ruleId, runId, loadActionErrorLog, refreshToken, spaceId, logFromDifferentSpace } = props;
const { uiSettings, notifications } = useKibana().services;
@ -138,6 +140,8 @@ export const RuleErrorLog = (props: RuleErrorLogProps) => {
page: pagination.pageIndex,
perPage: pagination.pageSize,
sort: formattedSort,
namespace: spaceId,
withAuth: logFromDifferentSpace,
});
setLogs(result.errors);
setPagination({

View file

@ -60,6 +60,7 @@ export interface RuleEventLogDataGrid {
pageSizeOptions?: number[];
selectedRunLog?: IExecutionLog;
showRuleNameAndIdColumns?: boolean;
showSpaceColumns?: boolean;
onChangeItemsPerPage: (pageSize: number) => void;
onChangePage: (pageIndex: number) => void;
onFilterChange: (filter: string[]) => void;
@ -162,6 +163,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
visibleColumns,
selectedRunLog,
showRuleNameAndIdColumns = false,
showSpaceColumns = false,
setVisibleColumns,
setSortingColumns,
onChangeItemsPerPage,
@ -215,6 +217,25 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
},
]
: []),
...(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(
@ -429,16 +450,22 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
isSortable: getIsColumnSortable('timed_out'),
},
],
[getPaginatedRowIndex, onFlyoutOpen, onFilterChange, showRuleNameAndIdColumns, logs]
[
getPaginatedRowIndex,
onFlyoutOpen,
onFilterChange,
showRuleNameAndIdColumns,
showSpaceColumns,
logs,
]
);
const columnVisibilityProps = useMemo(
() => ({
const columnVisibilityProps = useMemo(() => {
return {
visibleColumns,
setVisibleColumns,
}),
[visibleColumns, setVisibleColumns]
);
};
}, [visibleColumns, setVisibleColumns]);
const sortingProps = useMemo(
() => ({
@ -560,6 +587,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
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 (
@ -592,6 +620,7 @@ export const RuleEventLogDataGrid = (props: RuleEventLogDataGrid) => {
version={version}
dateFormat={dateFormat}
ruleId={ruleId}
spaceIds={spaceIds}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -7,7 +7,7 @@
import React from 'react';
import moment from 'moment';
import { EuiIcon } from '@elastic/eui';
import { EuiIcon, EuiLink } from '@elastic/eui';
import { shallow, mount } from 'enzyme';
import {
RuleEventLogListCellRenderer,
@ -16,7 +16,53 @@ import {
import { RuleEventLogListStatus } from './rule_event_log_list_status';
import { RuleDurationFormat } from '../../rules_list/components/rule_duration_format';
jest.mock('react-router-dom', () => ({
useHistory: () => ({
location: {
pathname: '/logs',
},
}),
}));
jest.mock('../../../../common/lib/kibana', () => ({
useSpacesData: () => ({
spacesMap: new Map([
['space1', { id: 'space1' }],
['space2', { id: 'space2' }],
]),
activeSpaceId: 'space1',
}),
useKibana: () => ({
services: {
http: {
basePath: {
get: () => '/basePath',
},
},
},
}),
}));
describe('rule_event_log_list_cell_renderer', () => {
const savedLocation = window.location;
beforeAll(() => {
// @ts-ignore Mocking window.location
delete window.location;
// @ts-ignore
window.location = Object.assign(
new URL('https://localhost/app/management/insightsAndAlerting/triggersActions/logs'),
{
ancestorOrigins: '',
assign: jest.fn(),
reload: jest.fn(),
replace: jest.fn(),
}
);
});
afterAll(() => {
window.location = savedLocation;
});
it('renders primitive values correctly', () => {
const wrapper = mount(<RuleEventLogListCellRenderer columnId="message" value="test" />);
@ -67,4 +113,31 @@ describe('rule_event_log_list_cell_renderer', () => {
expect(wrapper.find(RuleEventLogListStatus).text()).toEqual('newOutcome');
expect(wrapper.find(EuiIcon).props().color).toEqual('gray');
});
it('links to rules on the correct space', () => {
const wrapper1 = shallow(
<RuleEventLogListCellRenderer
columnId="rule_name"
value="Rule"
ruleId="1"
spaceIds={['space1']}
/>
);
// @ts-ignore data-href is not a native EuiLink prop
expect(wrapper1.find(EuiLink).props()['data-href']).toEqual('/rule/1');
const wrapper2 = shallow(
<RuleEventLogListCellRenderer
columnId="rule_name"
value="Rule"
ruleId="1"
spaceIds={['space2']}
/>
);
// @ts-ignore data-href is not a native EuiLink prop
expect(wrapper2.find(EuiLink).props()['data-href']).toEqual(
'/basePath/s/space2/app/management/insightsAndAlerting/triggersActions/rule/1'
);
window.location = savedLocation;
});
});

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import moment from 'moment';
import { EuiLink } from '@elastic/eui';
import { RuleAlertingOutcome } from '@kbn/alerting-plugin/common';
import { useHistory } from 'react-router-dom';
import { routeToRuleDetails } from '../../../constants';
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 {
@ -27,20 +28,58 @@ export type ColumnId = typeof RULE_EXECUTION_LOG_COLUMN_IDS[number];
interface RuleEventLogListCellRendererProps {
columnId: ColumnId;
version?: string;
value?: string;
value?: string | string[];
dateFormat?: string;
ruleId?: string;
spaceIds?: string[];
}
export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRendererProps) => {
const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT, ruleId } = props;
const { columnId, value, version, dateFormat = DEFAULT_DATE_FORMAT, ruleId, spaceIds } = props;
const spacesData = useSpacesData();
const { http } = useKibana().services;
const history = useHistory();
const onClickRuleName = useCallback(
() => ruleId && history.push(routeToRuleDetails.replace(':ruleId', ruleId)),
[ruleId, history]
const activeSpace = useMemo(
() => spacesData?.spacesMap.get(spacesData?.activeSpaceId),
[spacesData]
);
const ruleOnDifferentSpace = useMemo(
() => activeSpace && !spaceIds?.includes(activeSpace.id),
[activeSpace, spaceIds]
);
const ruleNamePathname = useMemo(() => {
if (!ruleId) return '';
const ruleRoute = routeToRuleDetails.replace(':ruleId', ruleId);
if (ruleOnDifferentSpace) {
const [linkedSpaceId] = spaceIds ?? [];
const basePath = http.basePath.get();
const spacePath = linkedSpaceId !== 'default' ? `/s/${linkedSpaceId}` : '';
const historyPathname = history.location.pathname;
const newPathname = `${basePath.replace(
`/s/${activeSpace!.id}`,
''
)}${spacePath}${window.location.pathname
.replace(basePath, '')
.replace(historyPathname, ruleRoute)}`;
return newPathname;
}
return ruleRoute;
}, [ruleId, ruleOnDifferentSpace, history, activeSpace, http, spaceIds]);
const onClickRuleName = useCallback(() => {
if (!ruleId) return;
if (ruleOnDifferentSpace) {
const newUrl = window.location.href.replace(window.location.pathname, ruleNamePathname);
window.open(newUrl, '_blank');
return;
}
history.push(ruleNamePathname);
}, [ruleNamePathname, history, ruleOnDifferentSpace, ruleId]);
if (typeof value === 'undefined') {
return null;
}
@ -54,15 +93,24 @@ export const RuleEventLogListCellRenderer = (props: RuleEventLogListCellRenderer
}
if (columnId === 'rule_name' && ruleId) {
return <EuiLink onClick={onClickRuleName}>{value}</EuiLink>;
return (
<EuiLink onClick={onClickRuleName} data-href={ruleNamePathname}>
{value}
</EuiLink>
);
}
if (columnId === 'space_ids') {
if (activeSpace && value.includes(activeSpace.id)) return <>{activeSpace.name}</>;
if (spacesData) return <>{spacesData.spacesMap.get(value[0])?.name ?? value[0]}</>;
}
if (RULE_EXECUTION_LOG_ALERT_COUNT_COLUMNS.includes(columnId)) {
return <>{formatRuleAlertCount(value, version)}</>;
return <>{formatRuleAlertCount(value as string, version)}</>;
}
if (RULE_EXECUTION_LOG_DURATION_COLUMNS.includes(columnId)) {
return <RuleDurationFormat duration={parseInt(value, 10)} />;
return <RuleDurationFormat duration={parseInt(value as string, 10)} />;
}
return <>{value}</>;

View file

@ -84,6 +84,7 @@ export type RuleEventLogListKPIProps = {
outcomeFilter?: string[];
message?: string;
refreshToken?: number;
namespaces?: Array<string | undefined>;
} & Pick<RuleApis, 'loadExecutionKPIAggregations' | 'loadGlobalExecutionKPIAggregations'>;
export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
@ -94,6 +95,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
outcomeFilter,
message,
refreshToken,
namespaces,
loadExecutionKPIAggregations,
loadGlobalExecutionKPIAggregations,
} = props;
@ -122,6 +124,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
dateEnd: getParsedDate(dateEnd),
outcomeFilter,
message,
...(namespaces ? { namespaces } : {}),
});
setKpi(newKpi);
} catch (e) {
@ -136,7 +139,7 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => {
useEffect(() => {
loadKPIs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ruleId, dateStart, dateEnd, outcomeFilter, message]);
}, [ruleId, dateStart, dateEnd, outcomeFilter, message, namespaces]);
useEffect(() => {
if (isInitialized.current) {

View file

@ -18,9 +18,11 @@ import {
Pagination,
EuiSuperDatePicker,
OnTimeChangeProps,
EuiSwitch,
} from '@elastic/eui';
import { IExecutionLog } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { SpacesContextProps } from '@kbn/spaces-plugin/public';
import { useKibana, useSpacesData } from '../../../../common/lib/kibana';
import {
RULE_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
GLOBAL_EXECUTION_DEFAULT_INITIAL_VISIBLE_COLUMNS,
@ -38,6 +40,8 @@ import {
withBulkRuleOperations,
} from '../../common/components/with_bulk_rule_api_operations';
const getEmptyFunctionComponent: React.FC<SpacesContextProps> = ({ children }) => <>{children}</>;
const getParsedDate = (date: string) => {
if (date.includes('now')) {
return datemath.parse(date)?.format() || date;
@ -66,6 +70,13 @@ const getDefaultColumns = (columns: string[]) => {
return [...LOCKED_COLUMNS, ...columnsWithoutLockedColumn];
};
const ALL_SPACES_LABEL = i18n.translate(
'xpack.triggersActionsUI.ruleEventLogList.showAllSpacesToggle',
{
defaultMessage: 'Show rules from all spaces',
}
);
const updateButtonProps = {
iconOnly: true,
fill: false,
@ -84,6 +95,7 @@ export type RuleEventLogListCommonProps = {
overrideLoadExecutionLogAggregations?: RuleApis['loadExecutionLogAggregations'];
overrideLoadGlobalExecutionLogAggregations?: RuleApis['loadGlobalExecutionLogAggregations'];
hasRuleNames?: boolean;
hasAllSpaceSwitch?: boolean;
} & Pick<RuleApis, 'loadExecutionLogAggregations' | 'loadGlobalExecutionLogAggregations'>;
export type RuleEventLogListTableProps<T extends RuleEventLogListOptions = 'default'> =
@ -106,6 +118,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
overrideLoadExecutionLogAggregations,
initialPageSize = 10,
hasRuleNames = false,
hasAllSpaceSwitch = false,
} = props;
const { uiSettings, notifications } = useKibana().services;
@ -117,6 +130,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
const [internalRefreshToken, setInternalRefreshToken] = useState<number | undefined>(
refreshToken
);
const [showFromAllSpaces, setShowFromAllSpaces] = useState(false);
// Data grid states
const [logs, setLogs] = useState<IExecutionLog[]>();
@ -153,6 +167,24 @@ 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 isInitialized = useRef(false);
const isOnLastPage = useMemo(() => {
@ -197,6 +229,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
dateEnd: getParsedDate(dateEnd),
page: pagination.pageIndex,
perPage: pagination.pageSize,
namespaces,
});
setLogs(result.data);
setPagination({
@ -290,6 +323,20 @@ 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 renderList = () => {
if (!logs) {
return <CenterJustifiedSpinner />;
@ -307,6 +354,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
dateFormat={dateFormat}
selectedRunLog={selectedRunLog}
showRuleNameAndIdColumns={hasRuleNames}
showSpaceColumns={showFromAllSpaces}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={onChangePage}
onFlyoutOpen={onFlyoutOpen}
@ -329,6 +377,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
pagination.pageIndex,
pagination.pageSize,
searchText,
showFromAllSpaces,
]);
useEffect(() => {
@ -350,7 +399,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
return (
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiFieldSearch
fullWidth
@ -378,6 +427,15 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
updateButtonProps={updateButtonProps}
/>
</EuiFlexItem>
{hasAllSpaceSwitch && areMultipleSpacesAccessible && (
<EuiFlexItem>
<EuiSwitch
label={ALL_SPACES_LABEL}
checked={showFromAllSpaces}
onChange={onShowAllSpacesChange}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />
</EuiFlexItem>
@ -389,6 +447,7 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
outcomeFilter={filter}
message={searchText}
refreshToken={internalRefreshToken}
namespaces={namespaces}
/>
<EuiSpacer />
</EuiFlexItem>
@ -407,13 +466,29 @@ export const RuleEventLogListTable = <T extends RuleEventLogListOptions>(
runLog={selectedRunLog}
refreshToken={refreshToken}
onClose={onFlyoutClose}
activeSpaceId={activeSpace?.id}
/>
)}
</EuiFlexGroup>
);
};
export const RuleEventLogListTableWithApi = withBulkRuleOperations(RuleEventLogListTable);
const RuleEventLogListTableWithSpaces: React.FC<RuleEventLogListTableProps> = (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">
<RuleEventLogListTable {...props} />
</SpacesContextWrapper>
);
};
export const RuleEventLogListTableWithApi = withBulkRuleOperations(RuleEventLogListTableWithSpaces);
// eslint-disable-next-line import/no-default-export
export { RuleEventLogListTableWithApi as default };

View file

@ -31,3 +31,4 @@ export const useCurrentUser = jest.fn();
export const withKibana = jest.fn(createWithKibanaMock());
export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock());
export const useGetUserSavedObjectPermissions = jest.fn();
export const useSpacesData = jest.fn();

View file

@ -6,3 +6,4 @@
*/
export * from './kibana_react';
export * from './use_spaces_data';

View file

@ -0,0 +1,24 @@
/*
* 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 { useState, useEffect } from 'react';
import { SpacesData } from '@kbn/spaces-plugin/public';
import { useKibana } from './kibana_react';
export const useSpacesData = () => {
const { spaces } = useKibana().services;
const [spacesData, setSpacesData] = useState<SpacesData | undefined>(undefined);
const spacesService = spaces?.ui.useSpaces();
useEffect(() => {
(async () => {
const result = await spacesService?.spacesDataPromise;
setSpacesData(result);
})();
}, [spaces, spacesService, setSpacesData]);
return spacesData;
};