= {}
+): { message: string; body?: { cause?: string[] }; statusCode: number } {
const { statusCode, response } = err;
const {
error: {
- root_cause = [], // eslint-disable-line camelcase
- caused_by, // eslint-disable-line camelcase
+ root_cause = [], // eslint-disable-line @typescript-eslint/camelcase
+ caused_by = undefined, // eslint-disable-line @typescript-eslint/camelcase
} = {},
} = JSON.parse(response);
// If no custom message if specified for the error's status code, just
// wrap the error as a Boom error response and return it
if (!statusCodeToMessageMap[statusCode]) {
- const boomError = Boom.boomify(err, { statusCode });
-
// The caused_by chain has the most information so use that if it's available. If not then
// settle for the root_cause.
const causedByChain = extractCausedByChain(caused_by);
const defaultCause = root_cause.length ? extractCausedByChain(root_cause[0]) : undefined;
- boomError.output.payload.cause = causedByChain.length ? causedByChain : defaultCause;
- return boomError;
+ return {
+ message: err.message,
+ statusCode,
+ body: {
+ cause: causedByChain.length ? causedByChain : defaultCause,
+ },
+ };
}
// Otherwise, use the custom message to create a Boom error response and
// return it
const message = statusCodeToMessageMap[statusCode];
- return new Boom(message, { statusCode });
+ return { message, statusCode };
}
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts
similarity index 51%
rename from x-pack/plugins/infra/public/components/logging/log_minimap/types.ts
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts
index d8197935dafa7..4137293cf39c0 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts
@@ -4,14 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { TimeKey } from '../../../../common/time';
+import * as legacyElasticsearch from 'elasticsearch';
-export interface SummaryBucket {
- start: number;
- end: number;
- entriesCount: number;
-}
+const esErrorsParent = legacyElasticsearch.errors._Abstract;
-export interface SummaryHighlightBucket extends SummaryBucket {
- representativeKey: TimeKey;
+export function isEsError(err: Error) {
+ return err instanceof esErrorsParent;
}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js
similarity index 100%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts
similarity index 100%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts
similarity index 76%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts
index 6c17554385ef8..fc6405b8e7513 100644
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts
@@ -6,13 +6,13 @@
import { memoize } from 'lodash';
-const esErrorsFactory = memoize(server => {
+const esErrorsFactory = memoize((server: any) => {
return server.plugins.elasticsearch.getCluster('admin').errors;
});
-export function isEsErrorFactory(server) {
+export function isEsErrorFactory(server: any) {
const esErrors = esErrorsFactory(server);
- return function isEsError(err) {
+ return function isEsError(err: any) {
return err instanceof esErrors._Abstract;
};
}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts
new file mode 100644
index 0000000000000..d22505f0e315a
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { kibanaResponseFactory } from '../../../../../../../../../src/core/server';
+import { licensePreRoutingFactory } from '../license_pre_routing_factory';
+
+describe('license_pre_routing_factory', () => {
+ describe('#reportingFeaturePreRoutingFactory', () => {
+ let mockDeps: any;
+ let mockLicenseCheckResults: any;
+
+ const anyContext: any = {};
+ const anyRequest: any = {};
+
+ beforeEach(() => {
+ mockDeps = {
+ __LEGACY: {
+ server: {
+ plugins: {
+ xpack_main: {
+ info: {
+ feature: () => ({
+ getLicenseCheckResults: () => mockLicenseCheckResults,
+ }),
+ },
+ },
+ },
+ },
+ },
+ requestHandler: jest.fn(),
+ };
+ });
+
+ describe('isAvailable is false', () => {
+ beforeEach(() => {
+ mockLicenseCheckResults = {
+ isAvailable: false,
+ };
+ });
+
+ it('replies with 403', async () => {
+ const licensePreRouting = licensePreRoutingFactory(mockDeps);
+ const response = await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory);
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('isAvailable is true', () => {
+ beforeEach(() => {
+ mockLicenseCheckResults = {
+ isAvailable: true,
+ };
+ });
+
+ it('it calls the wrapped handler', async () => {
+ const licensePreRouting = licensePreRoutingFactory(mockDeps);
+ await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory);
+ expect(mockDeps.requestHandler).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts
similarity index 100%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts
new file mode 100644
index 0000000000000..c47faa940a650
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RequestHandler } from 'src/core/server';
+import { PLUGIN } from '../../../../common/constants';
+
+export const licensePreRoutingFactory = ({
+ __LEGACY,
+ requestHandler,
+}: {
+ __LEGACY: { server: any };
+ requestHandler: RequestHandler
;
+}) => {
+ const xpackMainPlugin = __LEGACY.server.plugins.xpack_main;
+
+ // License checking and enable/disable logic
+ const licensePreRouting: RequestHandler
= (ctx, request, response) => {
+ const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults();
+ if (!licenseCheckResults.isAvailable) {
+ return response.forbidden({
+ body: licenseCheckResults.message,
+ });
+ } else {
+ return requestHandler(ctx, request, response);
+ }
+ };
+
+ return licensePreRouting;
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js
similarity index 100%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js
similarity index 66%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js
index dbd99efd95573..b9bb34a80ce79 100644
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js
@@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status';
-import { PLUGIN } from '../../../common/constants';
+import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status';
+import { PLUGIN } from '../../../../common/constants';
import { checkLicense } from '../check_license';
-export function registerLicenseChecker(server) {
- const xpackMainPlugin = server.plugins.xpack_main;
- const ccrPluggin = server.plugins[PLUGIN.ID];
+export function registerLicenseChecker(__LEGACY) {
+ const xpackMainPlugin = __LEGACY.server.plugins.xpack_main;
+ const ccrPluggin = __LEGACY.server.plugins[PLUGIN.ID];
mirrorPluginStatus(xpackMainPlugin, ccrPluggin);
xpackMainPlugin.status.once('green', () => {
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts
new file mode 100644
index 0000000000000..1012c07af3d2a
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Plugin, PluginInitializerContext, CoreSetup } from 'src/core/server';
+
+import { IndexMgmtSetup } from '../../../../../plugins/index_management/server';
+
+// @ts-ignore
+import { registerLicenseChecker } from './lib/register_license_checker';
+// @ts-ignore
+import { registerRoutes } from './routes/register_routes';
+import { ccrDataEnricher } from './cross_cluster_replication_data';
+
+interface PluginDependencies {
+ indexManagement: IndexMgmtSetup;
+ __LEGACY: {
+ server: any;
+ ccrUIEnabled: boolean;
+ };
+}
+
+export class CrossClusterReplicationServerPlugin implements Plugin {
+ // @ts-ignore
+ constructor(private readonly ctx: PluginInitializerContext) {}
+ setup({ http }: CoreSetup, { indexManagement, __LEGACY }: PluginDependencies) {
+ registerLicenseChecker(__LEGACY);
+
+ const router = http.createRouter();
+ registerRoutes({ router, __LEGACY });
+ if (__LEGACY.ccrUIEnabled && indexManagement && indexManagement.indexDataEnricher) {
+ indexManagement.indexDataEnricher.add(ccrDataEnricher);
+ }
+ }
+ start() {}
+}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js
similarity index 68%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js
index c610039cfd2ac..f3024515c7213 100644
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js
@@ -3,23 +3,23 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+import { deserializeAutoFollowPattern } from '../../../../../common/services/auto_follow_pattern_serialization';
+import { callWithRequestFactory } from '../../../lib/call_with_request_factory';
+import { isEsErrorFactory } from '../../../lib/is_es_error_factory';
+import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../../../fixtures';
+import { registerAutoFollowPatternRoutes } from '../auto_follow_pattern';
-import { deserializeAutoFollowPattern } from '../../../common/services/auto_follow_pattern_serialization';
-import { callWithRequestFactory } from '../../lib/call_with_request_factory';
-import { isEsErrorFactory } from '../../lib/is_es_error_factory';
-import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../fixtures';
-import { registerAutoFollowPatternRoutes } from './auto_follow_pattern';
+import { createRouter, callRoute } from './helpers';
-jest.mock('../../lib/call_with_request_factory');
-jest.mock('../../lib/is_es_error_factory');
-jest.mock('../../lib/license_pre_routing_factory');
+jest.mock('../../../lib/call_with_request_factory');
+jest.mock('../../../lib/is_es_error_factory');
+jest.mock('../../../lib/license_pre_routing_factory', () => ({
+ licensePreRoutingFactory: ({ requestHandler }) => requestHandler,
+}));
const DESERIALIZED_KEYS = Object.keys(deserializeAutoFollowPattern(getAutoFollowPatternMock()));
-/**
- * Hashtable to save the route handlers
- */
-const routeHandlers = {};
+let routeRegistry;
/**
* Helper to extract all the different server route handler so we can easily call them in our tests.
@@ -28,8 +28,6 @@ const routeHandlers = {};
* if a "server.route()" call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here.
*/
const registerHandlers = () => {
- let index = 0;
-
const HANDLER_INDEX_TO_ACTION = {
0: 'list',
1: 'create',
@@ -40,15 +38,12 @@ const registerHandlers = () => {
6: 'resume',
};
- const server = {
- route({ handler }) {
- // Save handler and increment index
- routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler;
- index++;
- },
- };
+ routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION);
- registerAutoFollowPatternRoutes(server);
+ registerAutoFollowPatternRoutes({
+ __LEGACY: {},
+ router: routeRegistry.router,
+ });
};
/**
@@ -94,14 +89,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('list()', () => {
beforeEach(() => {
- routeHandler = routeHandlers.list;
+ routeHandler = routeRegistry.getRoutes().list;
});
it('should deserialize the response from Elasticsearch', async () => {
const totalResult = 2;
setHttpRequestResponse(null, getAutoFollowPatternListMock(totalResult));
- const response = await routeHandler();
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler);
const autoFollowPattern = response.patterns[0];
expect(response.patterns.length).toEqual(totalResult);
@@ -112,21 +109,25 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('create()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.create;
+ routeHandler = routeRegistry.getRoutes().create;
});
it('should throw a 409 conflict error if id already exists', async () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({
- payload: {
- id: 'some-id',
- foo: 'bar',
- },
- }).catch(err => err); // return the error
-
- expect(response.output.statusCode).toEqual(409);
+ const response = await callRoute(
+ routeHandler,
+ {},
+ {
+ body: {
+ id: 'some-id',
+ foo: 'bar',
+ },
+ }
+ );
+
+ expect(response.status).toEqual(409);
});
it('should return 200 status when the id does not exist', async () => {
@@ -135,12 +136,18 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(error);
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({
- payload: {
- id: 'some-id',
- foo: 'bar',
- },
- });
+ const {
+ options: { body: response },
+ } = await callRoute(
+ routeHandler,
+ {},
+ {
+ body: {
+ id: 'some-id',
+ foo: 'bar',
+ },
+ }
+ );
expect(response).toEqual({ acknowledge: true });
});
@@ -148,7 +155,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('update()', () => {
beforeEach(() => {
- routeHandler = routeHandlers.update;
+ routeHandler = routeRegistry.getRoutes().update;
});
it('should serialize the payload before sending it to Elasticsearch', async () => {
@@ -156,16 +163,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
const request = {
params: { id: 'foo' },
- payload: {
+ body: {
remoteCluster: 'bar1',
leaderIndexPatterns: ['bar2'],
followIndexPattern: 'bar3',
},
};
- const response = await routeHandler(request);
+ const response = await callRoute(routeHandler, {}, request);
- expect(response).toEqual({
+ expect(response.options.body).toEqual({
id: 'foo',
body: {
remote_cluster: 'bar1',
@@ -178,7 +185,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('get()', () => {
beforeEach(() => {
- routeHandler = routeHandlers.get;
+ routeHandler = routeRegistry.getRoutes().get;
});
it('should return a single resource even though ES return an array with 1 item', async () => {
@@ -187,21 +194,23 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, esResponse);
- const response = await routeHandler({ params: { id: 1 } });
- expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS);
+ const response = await callRoute(routeHandler, {}, { params: { id: 1 } });
+ expect(Object.keys(response.options.body)).toEqual(DESERIALIZED_KEYS);
});
});
describe('delete()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.delete;
+ routeHandler = routeRegistry.getRoutes().delete;
});
it('should delete a single item', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a' } });
expect(response.itemsDeleted).toEqual(['a']);
expect(response.errors).toEqual([]);
@@ -212,9 +221,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a,b,c' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } });
- expect(response.itemsDeleted).toEqual(['a', 'b', 'c']);
+ expect(response.options.body.itemsDeleted).toEqual(['a', 'b', 'c']);
});
it('should catch error and return them in array', async () => {
@@ -224,7 +233,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: 'a,b' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } });
expect(response.itemsDeleted).toEqual(['a']);
expect(response.errors[0].id).toEqual('b');
@@ -234,13 +245,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('pause()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.pause;
+ routeHandler = routeRegistry.getRoutes().pause;
});
it('accept a single item', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a' } });
expect(response.itemsPaused).toEqual(['a']);
expect(response.errors).toEqual([]);
@@ -251,9 +264,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a,b,c' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } });
- expect(response.itemsPaused).toEqual(['a', 'b', 'c']);
+ expect(response.options.body.itemsPaused).toEqual(['a', 'b', 'c']);
});
it('should catch error and return them in array', async () => {
@@ -263,7 +276,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: 'a,b' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } });
expect(response.itemsPaused).toEqual(['a']);
expect(response.errors[0].id).toEqual('b');
@@ -273,13 +288,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
describe('resume()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.resume;
+ routeHandler = routeRegistry.getRoutes().resume;
});
it('accept a single item', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a' } });
expect(response.itemsResumed).toEqual(['a']);
expect(response.errors).toEqual([]);
@@ -290,9 +307,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: 'a,b,c' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } });
- expect(response.itemsResumed).toEqual(['a', 'b', 'c']);
+ expect(response.options.body.itemsResumed).toEqual(['a', 'b', 'c']);
});
it('should catch error and return them in array', async () => {
@@ -302,7 +319,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: 'a,b' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } });
expect(response.itemsResumed).toEqual(['a']);
expect(response.errors[0].id).toEqual('b');
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js
similarity index 72%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js
index 7e363c2758a4c..f0139e5bd7011 100644
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js
@@ -3,21 +3,23 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
-import { deserializeFollowerIndex } from '../../../common/services/follower_index_serialization';
+import { deserializeFollowerIndex } from '../../../../../common/services/follower_index_serialization';
import {
getFollowerIndexStatsMock,
getFollowerIndexListStatsMock,
getFollowerIndexInfoMock,
getFollowerIndexListInfoMock,
-} from '../../../fixtures';
-import { callWithRequestFactory } from '../../lib/call_with_request_factory';
-import { isEsErrorFactory } from '../../lib/is_es_error_factory';
-import { registerFollowerIndexRoutes } from './follower_index';
-
-jest.mock('../../lib/call_with_request_factory');
-jest.mock('../../lib/is_es_error_factory');
-jest.mock('../../lib/license_pre_routing_factory');
+} from '../../../../../fixtures';
+import { callWithRequestFactory } from '../../../lib/call_with_request_factory';
+import { isEsErrorFactory } from '../../../lib/is_es_error_factory';
+import { registerFollowerIndexRoutes } from '../follower_index';
+import { createRouter, callRoute } from './helpers';
+
+jest.mock('../../../lib/call_with_request_factory');
+jest.mock('../../../lib/is_es_error_factory');
+jest.mock('../../../lib/license_pre_routing_factory', () => ({
+ licensePreRoutingFactory: ({ requestHandler }) => requestHandler,
+}));
const DESERIALIZED_KEYS = Object.keys(
deserializeFollowerIndex({
@@ -26,10 +28,7 @@ const DESERIALIZED_KEYS = Object.keys(
})
);
-/**
- * Hashtable to save the route handlers
- */
-const routeHandlers = {};
+let routeRegistry;
/**
* Helper to extract all the different server route handler so we can easily call them in our tests.
@@ -38,8 +37,6 @@ const routeHandlers = {};
* if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here.
*/
const registerHandlers = () => {
- let index = 0;
-
const HANDLER_INDEX_TO_ACTION = {
0: 'list',
1: 'get',
@@ -50,15 +47,11 @@ const registerHandlers = () => {
6: 'unfollow',
};
- const server = {
- route({ handler }) {
- // Save handler and increment index
- routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler;
- index++;
- },
- };
-
- registerFollowerIndexRoutes(server);
+ routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION);
+ registerFollowerIndexRoutes({
+ __LEGACY: {},
+ router: routeRegistry.router,
+ });
};
/**
@@ -104,7 +97,7 @@ describe('[CCR API Routes] Follower Index', () => {
describe('list()', () => {
beforeEach(() => {
- routeHandler = routeHandlers.list;
+ routeHandler = routeRegistry.getRoutes().list;
});
it('deserializes the response from Elasticsearch', async () => {
@@ -117,7 +110,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, infoResult);
setHttpRequestResponse(null, statsResult);
- const response = await routeHandler();
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler);
const followerIndex = response.indices[0];
expect(response.indices.length).toEqual(totalResult);
@@ -127,7 +122,7 @@ describe('[CCR API Routes] Follower Index', () => {
describe('get()', () => {
beforeEach(() => {
- routeHandler = routeHandlers.get;
+ routeHandler = routeRegistry.getRoutes().get;
});
it('should return a single resource even though ES return an array with 1 item', async () => {
@@ -138,7 +133,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] });
setHttpRequestResponse(null, { indices: [followerIndexStats] });
- const response = await routeHandler({ params: { id: mockId } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: mockId } });
expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS);
});
});
@@ -146,34 +143,40 @@ describe('[CCR API Routes] Follower Index', () => {
describe('create()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.create;
+ routeHandler = routeRegistry.getRoutes().create;
});
it('should return 200 status when follower index is created', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({
- payload: {
- name: 'follower_index',
- remoteCluster: 'remote_cluster',
- leaderIndex: 'leader_index',
- },
- });
+ const response = await callRoute(
+ routeHandler,
+ {},
+ {
+ body: {
+ name: 'follower_index',
+ remoteCluster: 'remote_cluster',
+ leaderIndex: 'leader_index',
+ },
+ }
+ );
- expect(response).toEqual({ acknowledge: true });
+ expect(response.options.body).toEqual({ acknowledge: true });
});
});
describe('pause()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.pause;
+ routeHandler = routeRegistry.getRoutes().pause;
});
it('should pause a single item', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1' } });
expect(response.itemsPaused).toEqual(['1']);
expect(response.errors).toEqual([]);
@@ -184,9 +187,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1,2,3' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } });
- expect(response.itemsPaused).toEqual(['1', '2', '3']);
+ expect(response.options.body.itemsPaused).toEqual(['1', '2', '3']);
});
it('should catch error and return them in array', async () => {
@@ -196,7 +199,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: '1,2' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1,2' } });
expect(response.itemsPaused).toEqual(['1']);
expect(response.errors[0].id).toEqual('2');
@@ -206,13 +211,15 @@ describe('[CCR API Routes] Follower Index', () => {
describe('resume()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.resume;
+ routeHandler = routeRegistry.getRoutes().resume;
});
it('should resume a single item', async () => {
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1' } });
expect(response.itemsResumed).toEqual(['1']);
expect(response.errors).toEqual([]);
@@ -223,9 +230,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1,2,3' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } });
- expect(response.itemsResumed).toEqual(['1', '2', '3']);
+ expect(response.options.body.itemsResumed).toEqual(['1', '2', '3']);
});
it('should catch error and return them in array', async () => {
@@ -235,7 +242,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: '1,2' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1,2' } });
expect(response.itemsResumed).toEqual(['1']);
expect(response.errors[0].id).toEqual('2');
@@ -245,7 +254,7 @@ describe('[CCR API Routes] Follower Index', () => {
describe('unfollow()', () => {
beforeEach(() => {
resetHttpRequestResponses();
- routeHandler = routeHandlers.unfollow;
+ routeHandler = routeRegistry.getRoutes().unfollow;
});
it('should unfollow await single item', async () => {
@@ -254,7 +263,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1' } });
expect(response.itemsUnfollowed).toEqual(['1']);
expect(response.errors).toEqual([]);
@@ -274,9 +285,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(null, { acknowledge: true });
- const response = await routeHandler({ params: { id: '1,2,3' } });
+ const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } });
- expect(response.itemsUnfollowed).toEqual(['1', '2', '3']);
+ expect(response.options.body.itemsUnfollowed).toEqual(['1', '2', '3']);
});
it('should catch error and return them in array', async () => {
@@ -290,7 +301,9 @@ describe('[CCR API Routes] Follower Index', () => {
setHttpRequestResponse(null, { acknowledge: true });
setHttpRequestResponse(error);
- const response = await routeHandler({ params: { id: '1,2' } });
+ const {
+ options: { body: response },
+ } = await callRoute(routeHandler, {}, { params: { id: '1,2' } });
expect(response.itemsUnfollowed).toEqual(['1']);
expect(response.errors[0].id).toEqual('2');
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts
new file mode 100644
index 0000000000000..555fc0937c0ad
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { RequestHandler } from 'src/core/server';
+import { kibanaResponseFactory } from '../../../../../../../../../src/core/server';
+
+export const callRoute = (
+ route: RequestHandler,
+ ctx = {},
+ request = {},
+ response = kibanaResponseFactory
+) => {
+ return route(ctx as any, request as any, response);
+};
+
+export const createRouter = (indexToActionMap: Record) => {
+ let index = 0;
+ const routeHandlers: Record> = {};
+ const addHandler = (ignoreCtxForNow: any, handler: RequestHandler) => {
+ // Save handler and increment index
+ routeHandlers[indexToActionMap[index]] = handler;
+ index++;
+ };
+
+ return {
+ getRoutes: () => routeHandlers,
+ router: {
+ get: addHandler,
+ post: addHandler,
+ put: addHandler,
+ delete: addHandler,
+ },
+ };
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts
new file mode 100644
index 0000000000000..d458f1ccb354b
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts
@@ -0,0 +1,301 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { schema } from '@kbn/config-schema';
+// @ts-ignore
+import { callWithRequestFactory } from '../../lib/call_with_request_factory';
+import { isEsError } from '../../lib/is_es_error';
+// @ts-ignore
+import {
+ deserializeAutoFollowPattern,
+ deserializeListAutoFollowPatterns,
+ serializeAutoFollowPattern,
+ // @ts-ignore
+} from '../../../../common/services/auto_follow_pattern_serialization';
+
+import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
+import { API_BASE_PATH } from '../../../../common/constants';
+
+import { RouteDependencies } from '../types';
+import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error';
+
+export const registerAutoFollowPatternRoutes = ({ router, __LEGACY }: RouteDependencies) => {
+ /**
+ * Returns a list of all auto-follow patterns
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns`,
+ validate: false,
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+
+ try {
+ const result = await callWithRequest('ccr.autoFollowPatterns');
+ return response.ok({
+ body: {
+ patterns: deserializeListAutoFollowPatterns(result.patterns),
+ },
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Create an auto-follow pattern
+ */
+ router.post(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns`,
+ validate: {
+ body: schema.object(
+ {
+ id: schema.string(),
+ },
+ { unknowns: 'allow' }
+ ),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id, ...rest } = request.body;
+ const body = serializeAutoFollowPattern(rest);
+
+ /**
+ * First let's make sur that an auto-follow pattern with
+ * the same id does not exist.
+ */
+ try {
+ await callWithRequest('ccr.autoFollowPattern', { id });
+ // If we get here it means that an auto-follow pattern with the same id exists
+ return response.conflict({
+ body: `An auto-follow pattern with the name "${id}" already exists.`,
+ });
+ } catch (err) {
+ if (err.statusCode !== 404) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ }
+
+ try {
+ return response.ok({
+ body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Update an auto-follow pattern
+ */
+ router.put(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ body: schema.object({}, { unknowns: 'allow' }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const body = serializeAutoFollowPattern(request.body);
+
+ try {
+ return response.ok({
+ body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Returns a single auto-follow pattern
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+
+ try {
+ const result = await callWithRequest('ccr.autoFollowPattern', { id });
+ const autoFollowPattern = result.patterns[0];
+
+ return response.ok({
+ body: deserializeAutoFollowPattern(autoFollowPattern),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Delete an auto-follow pattern
+ */
+ router.delete(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsDeleted: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(_id =>
+ callWithRequest('ccr.deleteAutoFollowPattern', { id: _id })
+ .then(() => itemsDeleted.push(_id))
+ .catch((err: Error) => {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ } else {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ }
+ })
+ )
+ );
+
+ return response.ok({
+ body: {
+ itemsDeleted,
+ errors,
+ },
+ });
+ },
+ })
+ );
+
+ /**
+ * Pause auto-follow pattern(s)
+ */
+ router.post(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsPaused: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(_id =>
+ callWithRequest('ccr.pauseAutoFollowPattern', { id: _id })
+ .then(() => itemsPaused.push(_id))
+ .catch((err: Error) => {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ } else {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ }
+ })
+ )
+ );
+
+ return response.ok({
+ body: {
+ itemsPaused,
+ errors,
+ },
+ });
+ },
+ })
+ );
+
+ /**
+ * Resume auto-follow pattern(s)
+ */
+ router.post(
+ {
+ path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsResumed: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(_id =>
+ callWithRequest('ccr.resumeAutoFollowPattern', { id: _id })
+ .then(() => itemsResumed.push(_id))
+ .catch((err: Error) => {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ } else {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ }
+ })
+ )
+ );
+
+ return response.ok({
+ body: {
+ itemsResumed,
+ errors,
+ },
+ });
+ },
+ })
+ );
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts
new file mode 100644
index 0000000000000..b08b056ad2c8a
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts
@@ -0,0 +1,112 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { API_BASE_PATH } from '../../../../common/constants';
+// @ts-ignore
+import { callWithRequestFactory } from '../../lib/call_with_request_factory';
+// @ts-ignore
+import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization';
+import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
+
+import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error';
+import { RouteDependencies } from '../types';
+
+export const registerCcrRoutes = ({ router, __LEGACY }: RouteDependencies) => {
+ /**
+ * Returns Auto-follow stats
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/stats/auto_follow`,
+ validate: false,
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+
+ try {
+ const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats');
+
+ return response.ok({
+ body: deserializeAutoFollowStats(autoFollowStats),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Returns whether the user has CCR permissions
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/permissions`,
+ validate: false,
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const xpackMainPlugin = __LEGACY.server.plugins.xpack_main;
+ const xpackInfo = xpackMainPlugin && xpackMainPlugin.info;
+
+ if (!xpackInfo) {
+ // xpackInfo is updated via poll, so it may not be available until polling has begun.
+ // In this rare situation, tell the client the service is temporarily unavailable.
+ return response.customError({
+ statusCode: 503,
+ body: 'Security info unavailable',
+ });
+ }
+
+ const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security');
+ if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) {
+ // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR.
+ return response.ok({
+ body: {
+ hasPermission: true,
+ missingClusterPrivileges: [],
+ },
+ });
+ }
+
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+
+ try {
+ const { has_all_requested: hasPermission, cluster } = await callWithRequest(
+ 'ccr.permissions',
+ {
+ body: {
+ cluster: ['manage', 'manage_ccr'],
+ },
+ }
+ );
+
+ const missingClusterPrivileges = Object.keys(cluster).reduce(
+ (permissions: any, permissionName: any) => {
+ if (!cluster[permissionName]) {
+ permissions.push(permissionName);
+ return permissions;
+ }
+ },
+ [] as any[]
+ );
+
+ return response.ok({
+ body: {
+ hasPermission,
+ missingClusterPrivileges,
+ },
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts
new file mode 100644
index 0000000000000..3896e1c02c915
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts
@@ -0,0 +1,345 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { schema } from '@kbn/config-schema';
+import {
+ deserializeFollowerIndex,
+ deserializeListFollowerIndices,
+ serializeFollowerIndex,
+ serializeAdvancedSettings,
+ // @ts-ignore
+} from '../../../../common/services/follower_index_serialization';
+import { API_BASE_PATH } from '../../../../common/constants';
+// @ts-ignore
+import { removeEmptyFields } from '../../../../common/services/utils';
+// @ts-ignore
+import { callWithRequestFactory } from '../../lib/call_with_request_factory';
+import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
+
+import { RouteDependencies } from '../types';
+import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error';
+
+export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependencies) => {
+ /**
+ * Returns a list of all follower indices
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/follower_indices`,
+ validate: false,
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+
+ try {
+ const { follower_indices: followerIndices } = await callWithRequest('ccr.info', {
+ id: '_all',
+ });
+
+ const {
+ follow_stats: { indices: followerIndicesStats },
+ } = await callWithRequest('ccr.stats');
+
+ const followerIndicesStatsMap = followerIndicesStats.reduce((map: any, stats: any) => {
+ map[stats.index] = stats;
+ return map;
+ }, {});
+
+ const collatedFollowerIndices = followerIndices.map((followerIndex: any) => {
+ return {
+ ...followerIndex,
+ ...followerIndicesStatsMap[followerIndex.follower_index],
+ };
+ });
+
+ return response.ok({
+ body: {
+ indices: deserializeListFollowerIndices(collatedFollowerIndices),
+ },
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Returns a single follower index pattern
+ */
+ router.get(
+ {
+ path: `${API_BASE_PATH}/follower_indices/{id}`,
+ validate: {
+ params: schema.object({
+ id: schema.string(),
+ }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+
+ try {
+ const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id });
+
+ const followerIndexInfo = followerIndices && followerIndices[0];
+
+ if (!followerIndexInfo) {
+ return response.notFound({
+ body: `The follower index "${id}" does not exist.`,
+ });
+ }
+
+ // If this follower is paused, skip call to ES stats api since it will return 404
+ if (followerIndexInfo.status === 'paused') {
+ return response.ok({
+ body: deserializeFollowerIndex({
+ ...followerIndexInfo,
+ }),
+ });
+ } else {
+ const {
+ indices: followerIndicesStats,
+ } = await callWithRequest('ccr.followerIndexStats', { id });
+
+ return response.ok({
+ body: deserializeFollowerIndex({
+ ...followerIndexInfo,
+ ...(followerIndicesStats ? followerIndicesStats[0] : {}),
+ }),
+ });
+ }
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Create a follower index
+ */
+ router.post(
+ {
+ path: `${API_BASE_PATH}/follower_indices`,
+ validate: {
+ body: schema.object(
+ {
+ name: schema.string(),
+ },
+ { unknowns: 'allow' }
+ ),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { name, ...rest } = request.body;
+ const body = removeEmptyFields(serializeFollowerIndex(rest));
+
+ try {
+ return response.ok({
+ body: await callWithRequest('ccr.saveFollowerIndex', { name, body }),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Edit a follower index
+ */
+ router.put(
+ {
+ path: `${API_BASE_PATH}/follower_indices/{id}`,
+ validate: {
+ params: schema.object({ id: schema.string() }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+
+ // We need to first pause the follower and then resume it passing the advanced settings
+ try {
+ const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id });
+ const followerIndexInfo = followerIndices && followerIndices[0];
+ if (!followerIndexInfo) {
+ return response.notFound({ body: `The follower index "${id}" does not exist.` });
+ }
+
+ // Retrieve paused state instead of pulling it from the payload to ensure it's not stale.
+ const isPaused = followerIndexInfo.status === 'paused';
+ // Pause follower if not already paused
+ if (!isPaused) {
+ await callWithRequest('ccr.pauseFollowerIndex', { id });
+ }
+
+ // Resume follower
+ const body = removeEmptyFields(serializeAdvancedSettings(request.body));
+ return response.ok({
+ body: await callWithRequest('ccr.resumeFollowerIndex', { id, body }),
+ });
+ } catch (err) {
+ return mapErrorToKibanaHttpResponse(err);
+ }
+ },
+ })
+ );
+
+ /**
+ * Pauses a follower index
+ */
+ router.put(
+ {
+ path: `${API_BASE_PATH}/follower_indices/{id}/pause`,
+ validate: {
+ params: schema.object({ id: schema.string() }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsPaused: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(_id =>
+ callWithRequest('ccr.pauseFollowerIndex', { id: _id })
+ .then(() => itemsPaused.push(_id))
+ .catch((err: Error) => {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ })
+ )
+ );
+
+ return response.ok({
+ body: {
+ itemsPaused,
+ errors,
+ },
+ });
+ },
+ })
+ );
+
+ /**
+ * Resumes a follower index
+ */
+ router.put(
+ {
+ path: `${API_BASE_PATH}/follower_indices/{id}/resume`,
+ validate: {
+ params: schema.object({ id: schema.string() }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsResumed: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(_id =>
+ callWithRequest('ccr.resumeFollowerIndex', { id: _id })
+ .then(() => itemsResumed.push(_id))
+ .catch((err: Error) => {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ })
+ )
+ );
+
+ return response.ok({
+ body: {
+ itemsResumed,
+ errors,
+ },
+ });
+ },
+ })
+ );
+
+ /**
+ * Unfollow follower index's leader index
+ */
+ router.put(
+ {
+ path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`,
+ validate: {
+ params: schema.object({ id: schema.string() }),
+ },
+ },
+ licensePreRoutingFactory({
+ __LEGACY,
+ requestHandler: async (ctx, request, response) => {
+ const callWithRequest = callWithRequestFactory(__LEGACY.server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsUnfollowed: string[] = [];
+ const itemsNotOpen: string[] = [];
+ const errors: Array<{ id: string; error: any }> = [];
+
+ await Promise.all(
+ ids.map(async _id => {
+ try {
+ // Try to pause follower, let it fail silently since it may already be paused
+ try {
+ await callWithRequest('ccr.pauseFollowerIndex', { id: _id });
+ } catch (e) {
+ // Swallow errors
+ }
+
+ // Close index
+ await callWithRequest('indices.close', { index: _id });
+
+ // Unfollow leader
+ await callWithRequest('ccr.unfollowLeaderIndex', { id: _id });
+
+ // Try to re-open the index, store failures in a separate array to surface warnings in the UI
+ // This will allow users to query their index normally after unfollowing
+ try {
+ await callWithRequest('indices.open', { index: _id });
+ } catch (e) {
+ itemsNotOpen.push(_id);
+ }
+
+ // Push success
+ itemsUnfollowed.push(_id);
+ } catch (err) {
+ errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) });
+ }
+ })
+ );
+
+ return response.ok({
+ body: {
+ itemsUnfollowed,
+ itemsNotOpen,
+ errors,
+ },
+ });
+ },
+ })
+ );
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts
new file mode 100644
index 0000000000000..6a81bd26dc47d
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { kibanaResponseFactory } from '../../../../../../../src/core/server';
+// @ts-ignore
+import { wrapEsError } from '../lib/error_wrappers';
+import { isEsError } from '../lib/is_es_error';
+
+export const mapErrorToKibanaHttpResponse = (err: any) => {
+ if (isEsError(err)) {
+ const { statusCode, message, body } = wrapEsError(err);
+ return kibanaResponseFactory.customError({
+ statusCode,
+ body: {
+ message,
+ attributes: {
+ cause: body?.cause,
+ },
+ },
+ });
+ }
+ return kibanaResponseFactory.internalError(err);
+};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts
similarity index 67%
rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js
rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts
index 6e4088ec8600f..7e59417550691 100644
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts
@@ -7,9 +7,10 @@
import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern';
import { registerFollowerIndexRoutes } from './api/follower_index';
import { registerCcrRoutes } from './api/ccr';
+import { RouteDependencies } from './types';
-export function registerRoutes(server) {
- registerAutoFollowPatternRoutes(server);
- registerFollowerIndexRoutes(server);
- registerCcrRoutes(server);
+export function registerRoutes(deps: RouteDependencies) {
+ registerAutoFollowPatternRoutes(deps);
+ registerFollowerIndexRoutes(deps);
+ registerCcrRoutes(deps);
}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts
new file mode 100644
index 0000000000000..7f57c20c536e0
--- /dev/null
+++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import { IRouter } from 'src/core/server';
+
+export interface RouteDependencies {
+ router: IRouter;
+ __LEGACY: {
+ server: any;
+ };
+}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js
deleted file mode 100644
index 4667f0a110c1f..0000000000000
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js
+++ /dev/null
@@ -1,256 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-import Boom from 'boom';
-import { callWithRequestFactory } from '../../lib/call_with_request_factory';
-import { isEsErrorFactory } from '../../lib/is_es_error_factory';
-import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers';
-import {
- deserializeAutoFollowPattern,
- deserializeListAutoFollowPatterns,
- serializeAutoFollowPattern,
-} from '../../../common/services/auto_follow_pattern_serialization';
-import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
-import { API_BASE_PATH } from '../../../common/constants';
-
-export const registerAutoFollowPatternRoutes = server => {
- const isEsError = isEsErrorFactory(server);
- const licensePreRouting = licensePreRoutingFactory(server);
-
- /**
- * Returns a list of all auto-follow patterns
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
-
- try {
- const response = await callWithRequest('ccr.autoFollowPatterns');
- return {
- patterns: deserializeListAutoFollowPatterns(response.patterns),
- };
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Create an auto-follow pattern
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns`,
- method: 'POST',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id, ...rest } = request.payload;
- const body = serializeAutoFollowPattern(rest);
-
- /**
- * First let's make sur that an auto-follow pattern with
- * the same id does not exist.
- */
- try {
- await callWithRequest('ccr.autoFollowPattern', { id });
- // If we get here it means that an auto-follow pattern with the same id exists
- const error = Boom.conflict(`An auto-follow pattern with the name "${id}" already exists.`);
- throw error;
- } catch (err) {
- if (err.statusCode !== 404) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- }
-
- try {
- return await callWithRequest('ccr.saveAutoFollowPattern', { id, body });
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Update an auto-follow pattern
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
- method: 'PUT',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const body = serializeAutoFollowPattern(request.payload);
-
- try {
- return await callWithRequest('ccr.saveAutoFollowPattern', { id, body });
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Returns a single auto-follow pattern
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
-
- try {
- const response = await callWithRequest('ccr.autoFollowPattern', { id });
- const autoFollowPattern = response.patterns[0];
-
- return deserializeAutoFollowPattern(autoFollowPattern);
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Delete an auto-follow pattern
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
- method: 'DELETE',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsDeleted = [];
- const errors = [];
-
- await Promise.all(
- ids.map(_id =>
- callWithRequest('ccr.deleteAutoFollowPattern', { id: _id })
- .then(() => itemsDeleted.push(_id))
- .catch(err => {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- })
- )
- );
-
- return {
- itemsDeleted,
- errors,
- };
- },
- });
-
- /**
- * Pause auto-follow pattern(s)
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`,
- method: 'POST',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsPaused = [];
- const errors = [];
-
- await Promise.all(
- ids.map(_id =>
- callWithRequest('ccr.pauseAutoFollowPattern', { id: _id })
- .then(() => itemsPaused.push(_id))
- .catch(err => {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- })
- )
- );
-
- return {
- itemsPaused,
- errors,
- };
- },
- });
-
- /**
- * Resume auto-follow pattern(s)
- */
- server.route({
- path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`,
- method: 'POST',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsResumed = [];
- const errors = [];
-
- await Promise.all(
- ids.map(_id =>
- callWithRequest('ccr.resumeAutoFollowPattern', { id: _id })
- .then(() => itemsResumed.push(_id))
- .catch(err => {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- })
- )
- );
-
- return {
- itemsResumed,
- errors,
- };
- },
- });
-};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js
deleted file mode 100644
index 8255eb6e86b07..0000000000000
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js
+++ /dev/null
@@ -1,107 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import Boom from 'boom';
-
-import { API_BASE_PATH } from '../../../common/constants';
-import { callWithRequestFactory } from '../../lib/call_with_request_factory';
-import { isEsErrorFactory } from '../../lib/is_es_error_factory';
-import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers';
-import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization';
-import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
-
-export const registerCcrRoutes = server => {
- const isEsError = isEsErrorFactory(server);
- const licensePreRouting = licensePreRoutingFactory(server);
-
- /**
- * Returns Auto-follow stats
- */
- server.route({
- path: `${API_BASE_PATH}/stats/auto_follow`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
-
- try {
- const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats');
-
- return deserializeAutoFollowStats(autoFollowStats);
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Returns whether the user has CCR permissions
- */
- server.route({
- path: `${API_BASE_PATH}/permissions`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const xpackMainPlugin = server.plugins.xpack_main;
- const xpackInfo = xpackMainPlugin && xpackMainPlugin.info;
-
- if (!xpackInfo) {
- // xpackInfo is updated via poll, so it may not be available until polling has begun.
- // In this rare situation, tell the client the service is temporarily unavailable.
- throw new Boom('Security info unavailable', { statusCode: 503 });
- }
-
- const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security');
- if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) {
- // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR.
- return {
- hasPermission: true,
- missingClusterPrivileges: [],
- };
- }
-
- const callWithRequest = callWithRequestFactory(server, request);
-
- try {
- const { has_all_requested: hasPermission, cluster } = await callWithRequest(
- 'ccr.permissions',
- {
- body: {
- cluster: ['manage', 'manage_ccr'],
- },
- }
- );
-
- const missingClusterPrivileges = Object.keys(cluster).reduce(
- (permissions, permissionName) => {
- if (!cluster[permissionName]) {
- permissions.push(permissionName);
- return permissions;
- }
- },
- []
- );
-
- return {
- hasPermission,
- missingClusterPrivileges,
- };
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-};
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js
deleted file mode 100644
index e532edaa39636..0000000000000
--- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js
+++ /dev/null
@@ -1,328 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import Boom from 'boom';
-
-import {
- deserializeFollowerIndex,
- deserializeListFollowerIndices,
- serializeFollowerIndex,
- serializeAdvancedSettings,
-} from '../../../common/services/follower_index_serialization';
-import { API_BASE_PATH } from '../../../common/constants';
-import { removeEmptyFields } from '../../../common/services/utils';
-import { callWithRequestFactory } from '../../lib/call_with_request_factory';
-import { isEsErrorFactory } from '../../lib/is_es_error_factory';
-import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers';
-import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory';
-
-export const registerFollowerIndexRoutes = server => {
- const isEsError = isEsErrorFactory(server);
- const licensePreRouting = licensePreRoutingFactory(server);
-
- /**
- * Returns a list of all follower indices
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
-
- try {
- const { follower_indices: followerIndices } = await callWithRequest('ccr.info', {
- id: '_all',
- });
-
- const {
- follow_stats: { indices: followerIndicesStats },
- } = await callWithRequest('ccr.stats');
-
- const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => {
- map[stats.index] = stats;
- return map;
- }, {});
-
- const collatedFollowerIndices = followerIndices.map(followerIndex => {
- return {
- ...followerIndex,
- ...followerIndicesStatsMap[followerIndex.follower_index],
- };
- });
-
- return {
- indices: deserializeListFollowerIndices(collatedFollowerIndices),
- };
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Returns a single follower index pattern
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices/{id}`,
- method: 'GET',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
-
- try {
- const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id });
-
- const followerIndexInfo = followerIndices && followerIndices[0];
-
- if (!followerIndexInfo) {
- const error = Boom.notFound(`The follower index "${id}" does not exist.`);
- throw error;
- }
-
- // If this follower is paused, skip call to ES stats api since it will return 404
- if (followerIndexInfo.status === 'paused') {
- return deserializeFollowerIndex({
- ...followerIndexInfo,
- });
- } else {
- const { indices: followerIndicesStats } = await callWithRequest(
- 'ccr.followerIndexStats',
- { id }
- );
-
- return deserializeFollowerIndex({
- ...followerIndexInfo,
- ...(followerIndicesStats ? followerIndicesStats[0] : {}),
- });
- }
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Create a follower index
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices`,
- method: 'POST',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { name, ...rest } = request.payload;
- const body = removeEmptyFields(serializeFollowerIndex(rest));
-
- try {
- return await callWithRequest('ccr.saveFollowerIndex', { name, body });
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Edit a follower index
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices/{id}`,
- method: 'PUT',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
-
- async function isFollowerIndexPaused() {
- const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id });
-
- const followerIndexInfo = followerIndices && followerIndices[0];
-
- if (!followerIndexInfo) {
- const error = Boom.notFound(`The follower index "${id}" does not exist.`);
- throw error;
- }
-
- return followerIndexInfo.status === 'paused';
- }
-
- // We need to first pause the follower and then resume it passing the advanced settings
- try {
- // Retrieve paused state instead of pulling it from the payload to ensure it's not stale.
- const isPaused = await isFollowerIndexPaused();
- // Pause follower if not already paused
- if (!isPaused) {
- await callWithRequest('ccr.pauseFollowerIndex', { id });
- }
-
- // Resume follower
- const body = removeEmptyFields(serializeAdvancedSettings(request.payload));
- return await callWithRequest('ccr.resumeFollowerIndex', { id, body });
- } catch (err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- },
- });
-
- /**
- * Pauses a follower index
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices/{id}/pause`,
- method: 'PUT',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsPaused = [];
- const errors = [];
-
- await Promise.all(
- ids.map(_id =>
- callWithRequest('ccr.pauseFollowerIndex', { id: _id })
- .then(() => itemsPaused.push(_id))
- .catch(err => {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- })
- )
- );
-
- return {
- itemsPaused,
- errors,
- };
- },
- });
-
- /**
- * Resumes a follower index
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices/{id}/resume`,
- method: 'PUT',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsResumed = [];
- const errors = [];
-
- await Promise.all(
- ids.map(_id =>
- callWithRequest('ccr.resumeFollowerIndex', { id: _id })
- .then(() => itemsResumed.push(_id))
- .catch(err => {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- })
- )
- );
-
- return {
- itemsResumed,
- errors,
- };
- },
- });
-
- /**
- * Unfollow follower index's leader index
- */
- server.route({
- path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`,
- method: 'PUT',
- config: {
- pre: [licensePreRouting],
- },
- handler: async request => {
- const callWithRequest = callWithRequestFactory(server, request);
- const { id } = request.params;
- const ids = id.split(',');
-
- const itemsUnfollowed = [];
- const itemsNotOpen = [];
- const errors = [];
-
- await Promise.all(
- ids.map(async _id => {
- try {
- // Try to pause follower, let it fail silently since it may already be paused
- try {
- await callWithRequest('ccr.pauseFollowerIndex', { id: _id });
- } catch (e) {
- // Swallow errors
- }
-
- // Close index
- await callWithRequest('indices.close', { index: _id });
-
- // Unfollow leader
- await callWithRequest('ccr.unfollowLeaderIndex', { id: _id });
-
- // Try to re-open the index, store failures in a separate array to surface warnings in the UI
- // This will allow users to query their index normally after unfollowing
- try {
- await callWithRequest('indices.open', { index: _id });
- } catch (e) {
- itemsNotOpen.push(_id);
- }
-
- // Push success
- itemsUnfollowed.push(_id);
- } catch (err) {
- if (isEsError(err)) {
- errors.push({ id: _id, error: wrapEsError(err) });
- } else {
- errors.push({ id: _id, error: wrapUnknownError(err) });
- }
- }
- })
- );
-
- return {
- itemsUnfollowed,
- itemsNotOpen,
- errors,
- };
- },
- });
-};
diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts
index 3562834230ea1..c01e6377b039e 100644
--- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts
+++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts
@@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
+import Boom from 'boom';
import { errors as elasticsearchErrors } from 'elasticsearch';
import { ElasticsearchServiceSetup } from 'kibana/server';
import { get } from 'lodash';
@@ -152,5 +154,21 @@ export function jobsQueryFactory(server: ServerFacade, elasticsearch: Elasticsea
return hits[0];
});
},
+
+ async delete(deleteIndex: string, id: string) {
+ try {
+ const query = { id, index: deleteIndex };
+ return callAsInternalUser('delete', query);
+ } catch (error) {
+ const wrappedError = new Error(
+ i18n.translate('xpack.reporting.jobsQuery.deleteError', {
+ defaultMessage: 'Could not delete the report: {error}',
+ values: { error: error.message },
+ })
+ );
+
+ throw Boom.boomify(wrappedError, { statusCode: error.status });
+ }
+ },
};
}
diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts
index 2de420e6577c3..b9aa75e0ddd00 100644
--- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts
+++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts
@@ -18,9 +18,13 @@ import {
} from '../../types';
import { jobsQueryFactory } from '../lib/jobs_query';
import { ReportingSetupDeps, ReportingCore } from '../types';
-import { jobResponseHandlerFactory } from './lib/job_response_handler';
+import {
+ deleteJobResponseHandlerFactory,
+ downloadJobResponseHandlerFactory,
+} from './lib/job_response_handler';
import { makeRequestFacade } from './lib/make_request_facade';
import {
+ getRouteConfigFactoryDeletePre,
getRouteConfigFactoryDownloadPre,
getRouteConfigFactoryManagementPre,
} from './lib/route_config_factories';
@@ -40,7 +44,6 @@ export function registerJobInfoRoutes(
const { elasticsearch } = plugins;
const jobsQuery = jobsQueryFactory(server, elasticsearch);
const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
- const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger);
// list jobs in the queue, paginated
server.route({
@@ -138,7 +141,8 @@ export function registerJobInfoRoutes(
// trigger a download of the output from a job
const exportTypesRegistry = reporting.getExportTypesRegistry();
- const jobResponseHandler = jobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry);
+ const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger);
+ const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore
server.route({
path: `${MAIN_ENTRY}/download/{docId}`,
method: 'GET',
@@ -147,7 +151,47 @@ export function registerJobInfoRoutes(
const request = makeRequestFacade(legacyRequest);
const { docId } = request.params;
- let response = await jobResponseHandler(
+ let response = await downloadResponseHandler(
+ request.pre.management.jobTypes,
+ request.pre.user,
+ h,
+ { docId }
+ );
+
+ if (isResponse(response)) {
+ const { statusCode } = response;
+
+ if (statusCode !== 200) {
+ if (statusCode === 500) {
+ logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`);
+ } else {
+ logger.debug(
+ `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify(
+ response.source
+ )}]`
+ );
+ }
+ }
+
+ response = response.header('accept-ranges', 'none');
+ }
+
+ return response;
+ },
+ });
+
+ // allow a report to be deleted
+ const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger);
+ const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch);
+ server.route({
+ path: `${MAIN_ENTRY}/delete/{docId}`,
+ method: 'DELETE',
+ options: getRouteConfigDelete(),
+ handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => {
+ const request = makeRequestFacade(legacyRequest);
+ const { docId } = request.params;
+
+ let response = await deleteResponseHandler(
request.pre.management.jobTypes,
request.pre.user,
h,
diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts
index 62f0d0a72b389..30627d5b23230 100644
--- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts
+++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts
@@ -20,7 +20,7 @@ interface JobResponseHandlerOpts {
excludeContent?: boolean;
}
-export function jobResponseHandlerFactory(
+export function downloadJobResponseHandlerFactory(
server: ServerFacade,
elasticsearch: ElasticsearchServiceSetup,
exportTypesRegistry: ExportTypesRegistry
@@ -36,6 +36,7 @@ export function jobResponseHandlerFactory(
opts: JobResponseHandlerOpts = {}
) {
const { docId } = params;
+ // TODO: async/await
return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then(doc => {
if (!doc) return Boom.notFound();
@@ -67,3 +68,34 @@ export function jobResponseHandlerFactory(
});
};
}
+
+export function deleteJobResponseHandlerFactory(
+ server: ServerFacade,
+ elasticsearch: ElasticsearchServiceSetup
+) {
+ const jobsQuery = jobsQueryFactory(server, elasticsearch);
+
+ return async function deleteJobResponseHander(
+ validJobTypes: string[],
+ user: any,
+ h: ResponseToolkit,
+ params: JobResponseHandlerParams
+ ) {
+ const { docId } = params;
+ const doc = await jobsQuery.get(user, docId, { includeContent: false });
+ if (!doc) return Boom.notFound();
+
+ const { jobtype: jobType } = doc._source;
+ if (!validJobTypes.includes(jobType)) {
+ return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`);
+ }
+
+ try {
+ const docIndex = doc._index;
+ await jobsQuery.delete(docIndex, docId);
+ return h.response({ deleted: true });
+ } catch (error) {
+ return Boom.boomify(error, { statusCode: error.statusCode });
+ }
+ };
+}
diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts
index 82ba9ba22c706..3d275d34e2f7d 100644
--- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts
+++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts
@@ -106,7 +106,22 @@ export function getRouteConfigFactoryDownloadPre(
const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
return (): RouteConfigFactory => ({
...getManagementRouteConfig(),
- tags: [API_TAG],
+ tags: [API_TAG, 'download'],
+ response: {
+ ranges: false,
+ },
+ });
+}
+
+export function getRouteConfigFactoryDeletePre(
+ server: ServerFacade,
+ plugins: ReportingSetupDeps,
+ logger: Logger
+): GetRouteConfigFactoryFn {
+ const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
+ return (): RouteConfigFactory => ({
+ ...getManagementRouteConfig(),
+ tags: [API_TAG, 'delete'],
response: {
ranges: false,
},
diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts
index 917e9d7daae40..238079ba92a29 100644
--- a/x-pack/legacy/plugins/reporting/types.d.ts
+++ b/x-pack/legacy/plugins/reporting/types.d.ts
@@ -197,6 +197,7 @@ export interface JobDocPayload {
export interface JobSource {
_id: string;
+ _index: string;
_source: {
jobtype: string;
output: JobDocOutput;
diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
index a99471d92828e..03a1fe4496030 100644
--- a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
+++ b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts
@@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => {
clientY: subjectLocation.top,
force: true,
})
- .wait(5)
+ .wait(100)
.trigger('mousemove', {
button: primaryButton,
clientX: subjectLocation.left + dndSloppyClickDetectionThreshold,
clientY: subjectLocation.top,
force: true,
})
- .wait(5);
+ .wait(100);
};
/** Drags the subject being dragged on the specified drop target, but does not drop it */
@@ -44,7 +44,7 @@ export const dragWithoutDrop = (dropTarget: JQuery) => {
export const drop = (dropTarget: JQuery) => {
cy.wrap(dropTarget)
.trigger('mousemove', { button: primaryButton, force: true })
- .wait(5)
+ .wait(100)
.trigger('mouseup', { force: true })
- .wait(5);
+ .wait(100);
};
diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
index c3ce9a97bbea1..e15ce0ae5f543 100644
--- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
+++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap
@@ -38,18 +38,18 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
data-test-subj="stat-item"
>
@@ -258,18 +258,18 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
data-test-subj="stat-item"
>
@@ -548,18 +548,18 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
data-test-subj="stat-item"
>
1,714
@@ -734,10 +734,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
key="stat-items-field-uniqueDestinationIps"
>
2,359
@@ -815,10 +815,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
>
=> {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
+ const response = await KibanaServices.get().http.fetch(CASES_URL, {
method: 'POST',
body: JSON.stringify(newCase),
});
@@ -104,13 +112,21 @@ export const patchCase = async (
updatedCase: Partial,
version: string
): Promise => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
+ const response = await KibanaServices.get().http.fetch(CASES_URL, {
method: 'PATCH',
body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }),
});
return convertToCamelCase(decodeCasesResponse(response));
};
+export const patchCasesStatus = async (cases: BulkUpdateStatus[]): Promise => {
+ const response = await KibanaServices.get().http.fetch(CASES_URL, {
+ method: 'PATCH',
+ body: JSON.stringify({ cases }),
+ });
+ return convertToCamelCase(decodeCasesResponse(response));
+};
+
export const postComment = async (newComment: CommentRequest, caseId: string): Promise => {
const response = await KibanaServices.get().http.fetch(
`${CASES_URL}/${caseId}/comments`,
@@ -139,7 +155,7 @@ export const patchComment = async (
};
export const deleteCases = async (caseIds: string[]): Promise => {
- const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, {
+ const response = await KibanaServices.get().http.fetch(CASES_URL, {
method: 'DELETE',
query: { ids: JSON.stringify(caseIds) },
});
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
index 65d94865bf00c..44519031e91cb 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
@@ -18,6 +18,8 @@ export interface Comment {
export interface Case {
id: string;
+ closedAt: string | null;
+ closedBy: ElasticUser | null;
comments: Comment[];
commentIds: string[];
createdAt: string;
@@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus {
export enum SortFieldCase {
createdAt = 'createdAt',
- updatedAt = 'updatedAt',
+ closedAt = 'closedAt',
}
export interface ElasticUser {
- readonly username: string;
+ readonly email?: string | null;
readonly fullName?: string | null;
+ readonly username: string;
}
export interface FetchCasesProps {
@@ -75,3 +78,9 @@ export interface FetchCasesProps {
export interface ApiProps {
signal: AbortSignal;
}
+
+export interface BulkUpdateStatus {
+ status: string;
+ id: string;
+ version: string;
+}
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx
new file mode 100644
index 0000000000000..77d779ab906cf
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useCallback, useReducer } from 'react';
+import { errorToToaster, useStateToaster } from '../../components/toasters';
+import * as i18n from './translations';
+import { patchCasesStatus } from './api';
+import { BulkUpdateStatus, Case } from './types';
+
+interface UpdateState {
+ isUpdated: boolean;
+ isLoading: boolean;
+ isError: boolean;
+}
+type Action =
+ | { type: 'FETCH_INIT' }
+ | { type: 'FETCH_SUCCESS'; payload: boolean }
+ | { type: 'FETCH_FAILURE' }
+ | { type: 'RESET_IS_UPDATED' };
+
+const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => {
+ switch (action.type) {
+ case 'FETCH_INIT':
+ return {
+ ...state,
+ isLoading: true,
+ isError: false,
+ };
+ case 'FETCH_SUCCESS':
+ return {
+ ...state,
+ isLoading: false,
+ isError: false,
+ isUpdated: action.payload,
+ };
+ case 'FETCH_FAILURE':
+ return {
+ ...state,
+ isLoading: false,
+ isError: true,
+ };
+ case 'RESET_IS_UPDATED':
+ return {
+ ...state,
+ isUpdated: false,
+ };
+ default:
+ return state;
+ }
+};
+interface UseUpdateCase extends UpdateState {
+ updateBulkStatus: (cases: Case[], status: string) => void;
+ dispatchResetIsUpdated: () => void;
+}
+
+export const useUpdateCases = (): UseUpdateCase => {
+ const [state, dispatch] = useReducer(dataFetchReducer, {
+ isLoading: false,
+ isError: false,
+ isUpdated: false,
+ });
+ const [, dispatchToaster] = useStateToaster();
+
+ const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[]) => {
+ let cancel = false;
+ const patchData = async () => {
+ try {
+ dispatch({ type: 'FETCH_INIT' });
+ await patchCasesStatus(cases);
+ if (!cancel) {
+ dispatch({ type: 'FETCH_SUCCESS', payload: true });
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ dispatch({ type: 'FETCH_FAILURE' });
+ }
+ }
+ };
+ patchData();
+ return () => {
+ cancel = true;
+ };
+ }, []);
+
+ const dispatchResetIsUpdated = useCallback(() => {
+ dispatch({ type: 'RESET_IS_UPDATED' });
+ }, []);
+
+ const updateBulkStatus = useCallback((cases: Case[], status: string) => {
+ const updateCasesStatus: BulkUpdateStatus[] = cases.map(theCase => ({
+ status,
+ id: theCase.id,
+ version: theCase.version,
+ }));
+ dispatchUpdateCases(updateCasesStatus);
+ }, []);
+ return { ...state, updateBulkStatus, dispatchResetIsUpdated };
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
index a179b6f546b9b..b70195e2c126f 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
@@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
};
const initialData: Case = {
id: '',
+ closedAt: null,
+ closedBy: null,
createdAt: '',
comments: [],
commentIds: [],
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
index afcbe20fa791a..987620469901b 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
@@ -5,7 +5,7 @@
*/
import { useReducer, useCallback } from 'react';
-
+import { cloneDeep } from 'lodash/fp';
import { CaseRequest } from '../../../../../../plugins/case/common/api';
import { errorToToaster, useStateToaster } from '../../components/toasters';
@@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
...state,
isLoading: false,
isError: false,
- caseData: action.payload,
+ caseData: cloneDeep(action.payload),
updateKey: null,
};
case 'FETCH_FAILURE':
diff --git a/x-pack/legacy/plugins/siem/public/legacy.ts b/x-pack/legacy/plugins/siem/public/legacy.ts
index 157ec54353a3e..b3a06a170bb80 100644
--- a/x-pack/legacy/plugins/siem/public/legacy.ts
+++ b/x-pack/legacy/plugins/siem/public/legacy.ts
@@ -5,19 +5,12 @@
*/
import { npSetup, npStart } from 'ui/new_platform';
-import { PluginsSetup, PluginsStart } from 'ui/new_platform/new_platform';
import { PluginInitializerContext } from '../../../../../src/core/public';
import { plugin } from './';
-import {
- TriggersAndActionsUIPublicPluginSetup,
- TriggersAndActionsUIPublicPluginStart,
-} from '../../../../plugins/triggers_actions_ui/public';
+import { SetupPlugins, StartPlugins } from './plugin';
const pluginInstance = plugin({} as PluginInitializerContext);
-type myPluginsSetup = PluginsSetup & { triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup };
-type myPluginsStart = PluginsStart & { triggers_actions_ui: TriggersAndActionsUIPublicPluginStart };
-
-pluginInstance.setup(npSetup.core, npSetup.plugins as myPluginsSetup);
-pluginInstance.start(npStart.core, npStart.plugins as myPluginsStart);
+pluginInstance.setup(npSetup.core, (npSetup.plugins as unknown) as SetupPlugins);
+pluginInstance.start(npStart.core, (npStart.plugins as unknown) as StartPlugins);
diff --git a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts
index a4a70c77833c0..95ecee7b12bb1 100644
--- a/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts
+++ b/x-pack/legacy/plugins/siem/public/lib/kibana/hooks.ts
@@ -6,8 +6,13 @@
import moment from 'moment-timezone';
+import { useCallback, useEffect, useState } from 'react';
+import { i18n } from '@kbn/i18n';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants';
import { useUiSetting, useKibana } from './kibana_react';
+import { errorToToaster, useStateToaster } from '../../components/toasters';
+import { AuthenticatedUser } from '../../../../../../plugins/security/common/model';
+import { convertToCamelCase } from '../../containers/case/utils';
export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT);
@@ -17,3 +22,62 @@ export const useTimeZone = (): string => {
};
export const useBasePath = (): string => useKibana().services.http.basePath.get();
+
+interface UserRealm {
+ name: string;
+ type: string;
+}
+
+export interface AuthenticatedElasticUser {
+ username: string;
+ email: string;
+ fullName: string;
+ roles: string[];
+ enabled: boolean;
+ metadata?: {
+ _reserved: boolean;
+ };
+ authenticationRealm: UserRealm;
+ lookupRealm: UserRealm;
+ authenticationProvider: string;
+}
+
+export const useCurrentUser = (): AuthenticatedElasticUser | null => {
+ const [user, setUser] = useState(null);
+
+ const [, dispatchToaster] = useStateToaster();
+
+ const { security } = useKibana().services;
+
+ const fetchUser = useCallback(() => {
+ let didCancel = false;
+ const fetchData = async () => {
+ try {
+ const response = await security.authc.getCurrentUser();
+ if (!didCancel) {
+ setUser(convertToCamelCase(response));
+ }
+ } catch (error) {
+ if (!didCancel) {
+ errorToToaster({
+ title: i18n.translate('xpack.siem.getCurrentUser.Error', {
+ defaultMessage: 'Error getting user',
+ }),
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ setUser(null);
+ }
+ }
+ };
+ fetchData();
+ return () => {
+ didCancel = true;
+ };
+ }, [security]);
+
+ useEffect(() => {
+ fetchUser();
+ }, []);
+ return user;
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
index 0fe8daafcb30a..48fbb4e74c407 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
@@ -10,9 +10,11 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases';
export const useGetCasesMockState: UseGetCasesState = {
data: {
countClosedCases: 0,
- countOpenCases: 0,
+ countOpenCases: 5,
cases: [
{
+ closedAt: null,
+ closedBy: null,
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { username: 'elastic' },
@@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
+ closedAt: null,
+ closedBy: null,
id: '362a5c10-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:13.328Z',
createdBy: { username: 'elastic' },
@@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
+ closedAt: null,
+ closedBy: null,
id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:11.328Z',
createdBy: { username: 'elastic' },
@@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = {
version: 'WzQ3LDFd',
},
{
+ closedAt: '2020-02-13T19:44:13.328Z',
+ closedBy: { username: 'elastic' },
id: '31890e90-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:05.563Z',
createdBy: { username: 'elastic' },
@@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = {
status: 'closed',
tags: ['phishing'],
title: 'Uh oh',
- updatedAt: null,
- updatedBy: null,
+ updatedAt: '2020-02-13T19:44:13.328Z',
+ updatedBy: { username: 'elastic' },
version: 'WzQ3LDFd',
},
{
+ closedAt: null,
+ closedBy: null,
id: '2f5b3210-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:01.901Z',
createdBy: { username: 'elastic' },
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
index 5859e6bbce263..b9e1113c486ad 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
@@ -36,7 +36,8 @@ const Spacer = styled.span`
const renderStringField = (field: string, dataTestSubj: string) =>
field != null ? {field} : getEmptyTagValue();
export const getCasesColumns = (
- actions: Array>
+ actions: Array>,
+ filterStatus: string
): CasesColumns[] => [
{
name: i18n.NAME,
@@ -113,22 +114,39 @@ export const getCasesColumns = (
render: (comments: Case['commentIds']) =>
renderStringField(`${comments.length}`, `case-table-column-commentCount`),
},
- {
- field: 'createdAt',
- name: i18n.OPENED_ON,
- sortable: true,
- render: (createdAt: Case['createdAt']) => {
- if (createdAt != null) {
- return (
-
- );
+ filterStatus === 'open'
+ ? {
+ field: 'createdAt',
+ name: i18n.OPENED_ON,
+ sortable: true,
+ render: (createdAt: Case['createdAt']) => {
+ if (createdAt != null) {
+ return (
+
+ );
+ }
+ return getEmptyTagValue();
+ },
}
- return getEmptyTagValue();
- },
- },
+ : {
+ field: 'closedAt',
+ name: i18n.CLOSED_ON,
+ sortable: true,
+ render: (closedAt: Case['closedAt']) => {
+ if (closedAt != null) {
+ return (
+
+ );
+ }
+ return getEmptyTagValue();
+ },
+ },
{
name: 'Actions',
actions,
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
index 001acc1d4d36e..13869c79c45fd 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
@@ -10,35 +10,86 @@ import moment from 'moment-timezone';
import { AllCases } from './';
import { TestProviders } from '../../../../mock';
import { useGetCasesMockState } from './__mock__';
-import * as apiHook from '../../../../containers/case/use_get_cases';
-import { act } from '@testing-library/react';
-import { wait } from '../../../../lib/helpers';
+import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
+import { useGetCases } from '../../../../containers/case/use_get_cases';
+import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status';
+import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
+jest.mock('../../../../containers/case/use_bulk_update_case');
+jest.mock('../../../../containers/case/use_delete_cases');
+jest.mock('../../../../containers/case/use_get_cases');
+jest.mock('../../../../containers/case/use_get_cases_status');
+const useDeleteCasesMock = useDeleteCases as jest.Mock;
+const useGetCasesMock = useGetCases as jest.Mock;
+const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
+const useUpdateCasesMock = useUpdateCases as jest.Mock;
describe('AllCases', () => {
+ const dispatchResetIsDeleted = jest.fn();
+ const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
+ const handleOnDeleteConfirm = jest.fn();
+ const handleToggleModal = jest.fn();
const refetchCases = jest.fn();
const setFilters = jest.fn();
const setQueryParams = jest.fn();
const setSelectedCases = jest.fn();
+ const updateBulkStatus = jest.fn();
+ const fetchCasesStatus = jest.fn();
+
+ const defaultGetCases = {
+ ...useGetCasesMockState,
+ dispatchUpdateCaseProperty,
+ refetchCases,
+ setFilters,
+ setQueryParams,
+ setSelectedCases,
+ };
+ const defaultDeleteCases = {
+ dispatchResetIsDeleted,
+ handleOnDeleteConfirm,
+ handleToggleModal,
+ isDeleted: false,
+ isDisplayConfirmDeleteModal: false,
+ isLoading: false,
+ };
+ const defaultCasesStatus = {
+ countClosedCases: 0,
+ countOpenCases: 5,
+ fetchCasesStatus,
+ isError: false,
+ isLoading: true,
+ };
+ const defaultUpdateCases = {
+ isUpdated: false,
+ isLoading: false,
+ isError: false,
+ dispatchResetIsUpdated,
+ updateBulkStatus,
+ };
+ /* eslint-disable no-console */
+ // Silence until enzyme fixed to use ReactTestUtils.act()
+ const originalError = console.error;
+ beforeAll(() => {
+ console.error = jest.fn();
+ });
+ afterAll(() => {
+ console.error = originalError;
+ });
+ /* eslint-enable no-console */
beforeEach(() => {
jest.resetAllMocks();
- jest.spyOn(apiHook, 'useGetCases').mockReturnValue({
- ...useGetCasesMockState,
- dispatchUpdateCaseProperty,
- refetchCases,
- setFilters,
- setQueryParams,
- setSelectedCases,
- });
+ useUpdateCasesMock.mockImplementation(() => defaultUpdateCases);
+ useGetCasesMock.mockImplementation(() => defaultGetCases);
+ useDeleteCasesMock.mockImplementation(() => defaultDeleteCases);
+ useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus);
moment.tz.setDefault('UTC');
});
- it('should render AllCases', async () => {
+ it('should render AllCases', () => {
const wrapper = mount(
);
- await act(() => wait());
expect(
wrapper
.find(`a[data-test-subj="case-details-link"]`)
@@ -76,13 +127,12 @@ describe('AllCases', () => {
.text()
).toEqual('Showing 10 cases');
});
- it('should tableHeaderSortButton AllCases', async () => {
+ it('should tableHeaderSortButton AllCases', () => {
const wrapper = mount(
);
- await act(() => wait());
wrapper
.find('[data-test-subj="tableHeaderSortButton"]')
.first()
@@ -94,4 +144,139 @@ describe('AllCases', () => {
sortOrder: 'asc',
});
});
+ it('closes case when row action icon clicked', () => {
+ const wrapper = mount(
+
+
+
+ );
+ wrapper
+ .find('[data-test-subj="action-close"]')
+ .first()
+ .simulate('click');
+ const firstCase = useGetCasesMockState.data.cases[0];
+ expect(dispatchUpdateCaseProperty).toBeCalledWith({
+ caseId: firstCase.id,
+ updateKey: 'status',
+ updateValue: 'closed',
+ refetchCasesStatus: fetchCasesStatus,
+ version: firstCase.version,
+ });
+ });
+ it('Bulk delete', () => {
+ useGetCasesMock.mockImplementation(() => ({
+ ...defaultGetCases,
+ selectedCases: useGetCasesMockState.data.cases,
+ }));
+ useDeleteCasesMock
+ .mockReturnValueOnce({
+ ...defaultDeleteCases,
+ isDisplayConfirmDeleteModal: false,
+ })
+ .mockReturnValue({
+ ...defaultDeleteCases,
+ isDisplayConfirmDeleteModal: true,
+ });
+
+ const wrapper = mount(
+
+
+
+ );
+ wrapper
+ .find('[data-test-subj="case-table-bulk-actions"] button')
+ .first()
+ .simulate('click');
+ wrapper
+ .find('[data-test-subj="cases-bulk-delete-button"]')
+ .first()
+ .simulate('click');
+ expect(handleToggleModal).toBeCalled();
+
+ wrapper
+ .find(
+ '[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]'
+ )
+ .last()
+ .simulate('click');
+ expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual(
+ useGetCasesMockState.data.cases.map(theCase => theCase.id)
+ );
+ });
+ it('Bulk close status update', () => {
+ useGetCasesMock.mockImplementation(() => ({
+ ...defaultGetCases,
+ selectedCases: useGetCasesMockState.data.cases,
+ }));
+
+ const wrapper = mount(
+
+
+
+ );
+ wrapper
+ .find('[data-test-subj="case-table-bulk-actions"] button')
+ .first()
+ .simulate('click');
+ wrapper
+ .find('[data-test-subj="cases-bulk-close-button"]')
+ .first()
+ .simulate('click');
+ expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed');
+ });
+ it('Bulk open status update', () => {
+ useGetCasesMock.mockImplementation(() => ({
+ ...defaultGetCases,
+ selectedCases: useGetCasesMockState.data.cases,
+ filterOptions: {
+ ...defaultGetCases.filterOptions,
+ status: 'closed',
+ },
+ }));
+
+ const wrapper = mount(
+
+
+
+ );
+ wrapper
+ .find('[data-test-subj="case-table-bulk-actions"] button')
+ .first()
+ .simulate('click');
+ wrapper
+ .find('[data-test-subj="cases-bulk-open-button"]')
+ .first()
+ .simulate('click');
+ expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open');
+ });
+ it('isDeleted is true, refetch', () => {
+ useDeleteCasesMock.mockImplementation(() => ({
+ ...defaultDeleteCases,
+ isDeleted: true,
+ }));
+
+ mount(
+
+
+
+ );
+ expect(refetchCases).toBeCalled();
+ expect(fetchCasesStatus).toBeCalled();
+ expect(dispatchResetIsDeleted).toBeCalled();
+ });
+ it('isUpdated is true, refetch', () => {
+ useUpdateCasesMock.mockImplementation(() => ({
+ ...defaultUpdateCases,
+ isUpdated: true,
+ }));
+
+ mount(
+
+
+
+ );
+ expect(refetchCases).toBeCalled();
+ expect(fetchCasesStatus).toBeCalled();
+ expect(dispatchResetIsUpdated).toBeCalled();
+ });
});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
index 9f836bd043c9d..e7e1e624ccba2 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
@@ -43,6 +43,7 @@ import { OpenClosedStats } from '../open_closed_stats';
import { getActions } from './actions';
import { CasesTableFilters } from './table_filters';
+import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
const CONFIGURE_CASES_URL = getConfigureCasesUrl();
const CREATE_CASE_URL = getCreateCaseUrl();
@@ -71,8 +72,8 @@ const ProgressLoader = styled(EuiProgress)`
const getSortField = (field: string): SortFieldCase => {
if (field === SortFieldCase.createdAt) {
return SortFieldCase.createdAt;
- } else if (field === SortFieldCase.updatedAt) {
- return SortFieldCase.updatedAt;
+ } else if (field === SortFieldCase.closedAt) {
+ return SortFieldCase.closedAt;
}
return SortFieldCase.createdAt;
};
@@ -106,13 +107,20 @@ export const AllCases = React.memo(() => {
isDisplayConfirmDeleteModal,
} = useDeleteCases();
+ const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases();
+
useEffect(() => {
if (isDeleted) {
refetchCases(filterOptions, queryParams);
fetchCasesStatus();
dispatchResetIsDeleted();
}
- }, [isDeleted, filterOptions, queryParams]);
+ if (isUpdated) {
+ refetchCases(filterOptions, queryParams);
+ fetchCasesStatus();
+ dispatchResetIsUpdated();
+ }
+ }, [isDeleted, isUpdated, filterOptions, queryParams]);
const [deleteThisCase, setDeleteThisCase] = useState({
title: '',
@@ -135,36 +143,38 @@ export const AllCases = React.memo(() => {
[deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal]
);
- const toggleDeleteModal = useCallback(
- (deleteCase: Case) => {
- handleToggleModal();
- setDeleteThisCase(deleteCase);
- },
- [isDisplayConfirmDeleteModal]
- );
+ const toggleDeleteModal = useCallback((deleteCase: Case) => {
+ handleToggleModal();
+ setDeleteThisCase(deleteCase);
+ }, []);
- const toggleBulkDeleteModal = useCallback(
- (deleteCases: string[]) => {
- handleToggleModal();
- setDeleteBulk(deleteCases);
+ const toggleBulkDeleteModal = useCallback((deleteCases: string[]) => {
+ handleToggleModal();
+ setDeleteBulk(deleteCases);
+ }, []);
+
+ const handleUpdateCaseStatus = useCallback(
+ (status: string) => {
+ updateBulkStatus(selectedCases, status);
},
- [isDisplayConfirmDeleteModal]
+ [selectedCases]
);
const selectedCaseIds = useMemo(
- (): string[] =>
- selectedCases.reduce((arr: string[], caseObj: Case) => [...arr, caseObj.id], []),
+ (): string[] => selectedCases.map((caseObj: Case) => caseObj.id),
[selectedCases]
);
const getBulkItemsPopoverContent = useCallback(
(closePopover: () => void) => (
),
@@ -206,17 +216,25 @@ export const AllCases = React.memo(() => {
}
setQueryParams(newQueryParams);
},
- [setQueryParams, queryParams]
+ [queryParams]
);
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial) => {
+ if (newFilterOptions.status && newFilterOptions.status === 'closed') {
+ setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt });
+ } else if (newFilterOptions.status && newFilterOptions.status === 'open') {
+ setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt });
+ }
setFilters({ ...filterOptions, ...newFilterOptions });
},
- [filterOptions, setFilters]
+ [filterOptions, queryParams]
);
- const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]);
+ const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [
+ actions,
+ filterOptions.status,
+ ]);
const memoizedPagination = useMemo(
() => ({
pageIndex: queryParams.page - 1,
@@ -314,7 +332,7 @@ export const AllCases = React.memo(() => {
void;
deleteCasesAction: (cases: string[]) => void;
selectedCaseIds: string[];
- caseStatus: string;
+ updateCaseStatus: (status: string) => void;
}
export const getBulkItems = ({
- deleteCasesAction,
- closePopover,
caseStatus,
+ closePopover,
+ deleteCasesAction,
selectedCaseIds,
+ updateCaseStatus,
}: GetBulkItems) => {
return [
caseStatus === 'open' ? (
{
+ onClick={() => {
closePopover();
+ updateCaseStatus('closed');
}}
>
{i18n.BULK_ACTION_CLOSE_SELECTED}
) : (
{
closePopover();
+ updateCaseStatus('open');
}}
>
{i18n.BULK_ACTION_OPEN_SELECTED}
),
{
+ onClick={() => {
closePopover();
deleteCasesAction(selectedCaseIds);
}}
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts
index 0bf213868bd76..97045c8ebaf8b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts
@@ -16,7 +16,7 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
'xpack.siem.case.caseTable.bulkActions.openSelectedTitle',
{
- defaultMessage: 'Open selected',
+ defaultMessage: 'Reopen selected',
}
);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx
new file mode 100644
index 0000000000000..9dbd71ea3e34c
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useCallback } from 'react';
+import styled, { css } from 'styled-components';
+import {
+ EuiBadge,
+ EuiButtonToggle,
+ EuiDescriptionList,
+ EuiDescriptionListDescription,
+ EuiDescriptionListTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+} from '@elastic/eui';
+import * as i18n from '../case_view/translations';
+import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
+import { CaseViewActions } from '../case_view/actions';
+
+const MyDescriptionList = styled(EuiDescriptionList)`
+ ${({ theme }) => css`
+ & {
+ padding-right: ${theme.eui.euiSizeL};
+ border-right: ${theme.eui.euiBorderThin};
+ }
+ `}
+`;
+
+interface CaseStatusProps {
+ 'data-test-subj': string;
+ badgeColor: string;
+ buttonLabel: string;
+ caseId: string;
+ caseTitle: string;
+ icon: string;
+ isLoading: boolean;
+ isSelected: boolean;
+ status: string;
+ title: string;
+ toggleStatusCase: (status: string) => void;
+ value: string | null;
+}
+const CaseStatusComp: React.FC = ({
+ 'data-test-subj': dataTestSubj,
+ badgeColor,
+ buttonLabel,
+ caseId,
+ caseTitle,
+ icon,
+ isLoading,
+ isSelected,
+ status,
+ title,
+ toggleStatusCase,
+ value,
+}) => {
+ const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [
+ toggleStatusCase,
+ ]);
+ return (
+
+
+
+
+
+ {i18n.STATUS}
+
+
+ {status}
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const CaseStatus = React.memo(CaseStatusComp);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
index 53cc1f80b5c10..e11441eac3a9d 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
@@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types';
export const caseProps: CaseProps = {
caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
initialData: {
+ closedAt: null,
+ closedBy: null,
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'],
comments: [
@@ -20,6 +22,7 @@ export const caseProps: CaseProps = {
createdBy: {
fullName: 'Steph Milovic',
username: 'smilovic',
+ email: 'notmyrealemailfool@elastic.co',
},
updatedAt: '2020-02-20T23:06:33.798Z',
updatedBy: {
@@ -29,7 +32,7 @@ export const caseProps: CaseProps = {
},
],
createdAt: '2020-02-13T19:44:23.627Z',
- createdBy: { fullName: null, username: 'elastic' },
+ createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' },
description: 'Security banana Issue',
status: 'open',
tags: ['defacement'],
@@ -41,35 +44,22 @@ export const caseProps: CaseProps = {
version: 'WzQ3LDFd',
},
};
-
-export const data: Case = {
- id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
- commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'],
- comments: [
- {
- comment: 'Solve this fast!',
- id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
- createdAt: '2020-02-20T23:06:33.798Z',
- createdBy: {
- fullName: 'Steph Milovic',
- username: 'smilovic',
- },
- updatedAt: '2020-02-20T23:06:33.798Z',
- updatedBy: {
- username: 'elastic',
- },
- version: 'WzQ3LDFd',
+export const caseClosedProps: CaseProps = {
+ ...caseProps,
+ initialData: {
+ ...caseProps.initialData,
+ closedAt: '2020-02-20T23:06:33.798Z',
+ closedBy: {
+ username: 'elastic',
},
- ],
- createdAt: '2020-02-13T19:44:23.627Z',
- createdBy: { username: 'elastic', fullName: null },
- description: 'Security banana Issue',
- status: 'open',
- tags: ['defacement'],
- title: 'Another horrible breach!!',
- updatedAt: '2020-02-19T15:02:57.995Z',
- updatedBy: {
- username: 'elastic',
+ status: 'closed',
},
- version: 'WzQ3LDFd',
+};
+
+export const data: Case = {
+ ...caseProps.initialData,
+};
+
+export const dataClosed: Case = {
+ ...caseClosedProps.initialData,
};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx
new file mode 100644
index 0000000000000..4e1e5ba753c36
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount } from 'enzyme';
+import { CaseViewActions } from './actions';
+import { TestProviders } from '../../../../mock';
+import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
+jest.mock('../../../../containers/case/use_delete_cases');
+const useDeleteCasesMock = useDeleteCases as jest.Mock;
+
+describe('CaseView actions', () => {
+ const caseTitle = 'Cool title';
+ const caseId = 'cool-id';
+ const handleOnDeleteConfirm = jest.fn();
+ const handleToggleModal = jest.fn();
+ const dispatchResetIsDeleted = jest.fn();
+ const defaultDeleteState = {
+ dispatchResetIsDeleted,
+ handleToggleModal,
+ handleOnDeleteConfirm,
+ isLoading: false,
+ isError: false,
+ isDeleted: false,
+ isDisplayConfirmDeleteModal: false,
+ };
+ beforeEach(() => {
+ jest.resetAllMocks();
+ useDeleteCasesMock.mockImplementation(() => defaultDeleteState);
+ });
+ it('clicking trash toggles modal', () => {
+ const wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
+
+ wrapper
+ .find('button[data-test-subj="property-actions-ellipses"]')
+ .first()
+ .simulate('click');
+ wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
+ expect(handleToggleModal).toHaveBeenCalled();
+ });
+ it('toggle delete modal and confirm', () => {
+ useDeleteCasesMock.mockImplementation(() => ({
+ ...defaultDeleteState,
+ isDisplayConfirmDeleteModal: true,
+ }));
+ const wrapper = mount(
+
+
+
+ );
+
+ expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
+ wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
+ expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]);
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx
new file mode 100644
index 0000000000000..88a717ac5fa6a
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useMemo } from 'react';
+
+import { Redirect } from 'react-router-dom';
+import * as i18n from './translations';
+import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
+import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
+import { SiemPageName } from '../../../home/types';
+import { PropertyActions } from '../property_actions';
+
+interface CaseViewActions {
+ caseId: string;
+ caseTitle: string;
+}
+
+const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => {
+ // Delete case
+ const {
+ handleToggleModal,
+ handleOnDeleteConfirm,
+ isDeleted,
+ isDisplayConfirmDeleteModal,
+ } = useDeleteCases();
+
+ const confirmDeleteModal = useMemo(
+ () => (
+
+ ),
+ [isDisplayConfirmDeleteModal]
+ );
+ // TO DO refactor each of these const's into their own components
+ const propertyActions = useMemo(
+ () => [
+ {
+ iconType: 'trash',
+ label: i18n.DELETE_CASE,
+ onClick: handleToggleModal,
+ },
+ {
+ iconType: 'popout',
+ label: 'View ServiceNow incident',
+ onClick: () => null,
+ },
+ {
+ iconType: 'importAction',
+ label: 'Update ServiceNow incident',
+ onClick: () => null,
+ },
+ ],
+ [handleToggleModal]
+ );
+
+ if (isDeleted) {
+ return ;
+ }
+ return (
+ <>
+
+ {confirmDeleteModal}
+ >
+ );
+};
+
+export const CaseViewActions = React.memo(CaseViewActionsComponent);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
index 15d6cf7cf7317..41100ec6d50f1 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
@@ -5,17 +5,37 @@
*/
import React from 'react';
+import { Router } from 'react-router-dom';
import { mount } from 'enzyme';
import { CaseComponent } from './';
-import * as updateHook from '../../../../containers/case/use_update_case';
-import * as deleteHook from '../../../../containers/case/use_delete_cases';
-import { caseProps, data } from './__mock__';
+import { caseProps, caseClosedProps, data, dataClosed } from './__mock__';
import { TestProviders } from '../../../../mock';
+import { useUpdateCase } from '../../../../containers/case/use_update_case';
+jest.mock('../../../../containers/case/use_update_case');
+const useUpdateCaseMock = useUpdateCase as jest.Mock;
+type Action = 'PUSH' | 'POP' | 'REPLACE';
+const pop: Action = 'POP';
+const location = {
+ pathname: '/network',
+ search: '',
+ state: '',
+ hash: '',
+};
+const mockHistory = {
+ length: 2,
+ location,
+ action: pop,
+ push: jest.fn(),
+ replace: jest.fn(),
+ go: jest.fn(),
+ goBack: jest.fn(),
+ goForward: jest.fn(),
+ block: jest.fn(),
+ createHref: jest.fn(),
+ listen: jest.fn(),
+};
describe('CaseView ', () => {
- const handleOnDeleteConfirm = jest.fn();
- const handleToggleModal = jest.fn();
- const dispatchResetIsDeleted = jest.fn();
const updateCaseProperty = jest.fn();
/* eslint-disable no-console */
// Silence until enzyme fixed to use ReactTestUtils.act()
@@ -28,21 +48,25 @@ describe('CaseView ', () => {
});
/* eslint-enable no-console */
+ const defaultUpdateCaseState = {
+ caseData: data,
+ isLoading: false,
+ isError: false,
+ updateKey: null,
+ updateCaseProperty,
+ };
+
beforeEach(() => {
jest.resetAllMocks();
- jest.spyOn(updateHook, 'useUpdateCase').mockReturnValue({
- caseData: data,
- isLoading: false,
- isError: false,
- updateKey: null,
- updateCaseProperty,
- });
+ useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
});
it('should render CaseComponent', () => {
const wrapper = mount(
-
+
+
+
);
expect(
@@ -69,6 +93,7 @@ describe('CaseView ', () => {
.first()
.text()
).toEqual(data.createdBy.username);
+ expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false);
expect(
wrapper
.find(`[data-test-subj="case-view-createdAt"]`)
@@ -83,16 +108,45 @@ describe('CaseView ', () => {
).toEqual(data.description);
});
+ it('should show closed indicators in header when case is closed', () => {
+ useUpdateCaseMock.mockImplementation(() => ({
+ ...defaultUpdateCaseState,
+ caseData: dataClosed,
+ }));
+ const wrapper = mount(
+
+
+
+
+
+ );
+ expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false);
+ expect(
+ wrapper
+ .find(`[data-test-subj="case-view-closedAt"]`)
+ .first()
+ .prop('value')
+ ).toEqual(dataClosed.closedAt);
+ expect(
+ wrapper
+ .find(`[data-test-subj="case-view-status"]`)
+ .first()
+ .text()
+ ).toEqual(dataClosed.status);
+ });
+
it('should dispatch update state when button is toggled', () => {
const wrapper = mount(
-
+
+
+
);
wrapper
.find('input[data-test-subj="toggle-case-status"]')
- .simulate('change', { target: { value: false } });
+ .simulate('change', { target: { checked: true } });
expect(updateCaseProperty).toBeCalledWith({
updateKey: 'status',
@@ -103,7 +157,9 @@ describe('CaseView ', () => {
it('should render comments', () => {
const wrapper = mount(
-
+
+
+
);
expect(
@@ -133,46 +189,4 @@ describe('CaseView ', () => {
.prop('source')
).toEqual(data.comments[0].comment);
});
-
- it('toggle delete modal and cancel', () => {
- const wrapper = mount(
-
-
-
- );
-
- expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
-
- wrapper
- .find(
- '[data-test-subj="case-view-actions"] button[data-test-subj="property-actions-ellipses"]'
- )
- .first()
- .simulate('click');
- wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click');
- expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
- wrapper.find('button[data-test-subj="confirmModalCancelButton"]').simulate('click');
- expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
- });
-
- it('toggle delete modal and confirm', () => {
- jest.spyOn(deleteHook, 'useDeleteCases').mockReturnValue({
- dispatchResetIsDeleted,
- handleToggleModal,
- handleOnDeleteConfirm,
- isLoading: false,
- isError: false,
- isDeleted: false,
- isDisplayConfirmDeleteModal: true,
- });
- const wrapper = mount(
-
-
-
- );
-
- expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
- wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
- expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseProps.caseId]);
- });
});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
index 82216e88a091e..08af603cb0dbf 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
@@ -5,26 +5,14 @@
*/
import React, { useCallback, useMemo } from 'react';
-import {
- EuiBadge,
- EuiButtonToggle,
- EuiDescriptionList,
- EuiDescriptionListDescription,
- EuiDescriptionListTitle,
- EuiFlexGroup,
- EuiFlexItem,
- EuiLoadingSpinner,
-} from '@elastic/eui';
+import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
-import styled, { css } from 'styled-components';
-import { Redirect } from 'react-router-dom';
+import styled from 'styled-components';
import * as i18n from './translations';
import { Case } from '../../../../containers/case/types';
-import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date';
import { getCaseUrl } from '../../../../components/link_to';
import { HeaderPage } from '../../../../components/header_page';
import { EditableTitle } from '../../../../components/header_page/editable_title';
-import { PropertyActions } from '../property_actions';
import { TagList } from '../tag_list';
import { useGetCase } from '../../../../containers/case/use_get_case';
import { UserActionTree } from '../user_action_tree';
@@ -33,23 +21,14 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case';
import { WrapperPage } from '../../../../components/wrapper_page';
import { getTypedPayload } from '../../../../containers/case/utils';
import { WhitePageWrapper } from '../wrappers';
-import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
-import { SiemPageName } from '../../../home/types';
-import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
+import { useBasePath } from '../../../../lib/kibana';
+import { CaseStatus } from '../case_status';
+import { SpyRoute } from '../../../../utils/route/spy_routes';
interface Props {
caseId: string;
}
-const MyDescriptionList = styled(EuiDescriptionList)`
- ${({ theme }) => css`
- & {
- padding-right: ${theme.eui.euiSizeL};
- border-right: ${theme.eui.euiBorderThin};
- }
- `}
-`;
-
const MyWrapper = styled(WrapperPage)`
padding-bottom: 0;
`;
@@ -64,6 +43,8 @@ export interface CaseProps {
}
export const CaseComponent = React.memo(({ caseId, initialData }) => {
+ const basePath = window.location.origin + useBasePath();
+ const caseLink = `${basePath}/app/siem#/case/${caseId}`;
const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData);
// Update Fields
@@ -107,58 +88,46 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
return null;
}
},
- [updateCaseProperty, caseData.status]
- );
- const toggleStatusCase = useCallback(
- e => onUpdateField('status', e.target.checked ? 'open' : 'closed'),
- [onUpdateField]
+ [caseData.status]
);
- const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]);
-
- // Delete case
- const {
- handleToggleModal,
- handleOnDeleteConfirm,
- isDeleted,
- isDisplayConfirmDeleteModal,
- } = useDeleteCases();
-
- const confirmDeleteModal = useMemo(
- () => (
-
- ),
- [isDisplayConfirmDeleteModal]
+ const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
+ const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]);
+
+ const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]);
+
+ const caseStatusData = useMemo(
+ () =>
+ caseData.status === 'open'
+ ? {
+ 'data-test-subj': 'case-view-createdAt',
+ value: caseData.createdAt,
+ title: i18n.CASE_OPENED,
+ buttonLabel: i18n.CLOSE_CASE,
+ status: caseData.status,
+ icon: 'checkInCircleFilled',
+ badgeColor: 'secondary',
+ isSelected: false,
+ }
+ : {
+ 'data-test-subj': 'case-view-closedAt',
+ value: caseData.closedAt,
+ title: i18n.CASE_CLOSED,
+ buttonLabel: i18n.REOPEN_CASE,
+ status: caseData.status,
+ icon: 'magnet',
+ badgeColor: 'danger',
+ isSelected: true,
+ },
+ [caseData.closedAt, caseData.createdAt, caseData.status]
+ );
+ const emailContent = useMemo(
+ () => ({
+ subject: i18n.EMAIL_SUBJECT(caseData.title),
+ body: i18n.EMAIL_BODY(caseLink),
+ }),
+ [caseData.title]
);
- // TO DO refactor each of these const's into their own components
- const propertyActions = [
- {
- iconType: 'trash',
- label: 'Delete case',
- onClick: handleToggleModal,
- },
- {
- iconType: 'popout',
- label: 'View ServiceNow incident',
- onClick: () => null,
- },
- {
- iconType: 'importAction',
- label: 'Update ServiceNow incident',
- onClick: () => null,
- },
- ];
-
- if (isDeleted) {
- return ;
- }
-
return (
<>
@@ -177,51 +146,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
}
title={caseData.title}
>
-
-
-
-
-
- {i18n.STATUS}
-
-
- {caseData.status}
-
-
-
-
- {i18n.CASE_OPENED}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -237,6 +168,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
@@ -250,7 +182,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
- {confirmDeleteModal}
+
>
);
});
@@ -273,4 +205,5 @@ export const CaseView = React.memo(({ caseId }: Props) => {
return ;
});
+CaseComponent.displayName = 'CaseComponent';
CaseView.displayName = 'CaseView';
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
index 82b5e771e2151..e5fa3bff51f85 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
@@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', {
export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', {
defaultMessage: 'Case opened',
});
+
+export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', {
+ defaultMessage: 'Case closed',
+});
+
+export const EMAIL_SUBJECT = (caseTitle: string) =>
+ i18n.translate('xpack.siem.case.caseView.emailSubject', {
+ values: { caseTitle },
+ defaultMessage: 'SIEM Case - {caseTitle}',
+ });
+
+export const EMAIL_BODY = (caseUrl: string) =>
+ i18n.translate('xpack.siem.case.caseView.emailBody', {
+ values: { caseUrl },
+ defaultMessage: 'Case reference: {caseUrl}',
+ });
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
index b3c424bef6a7a..cbc3be6d144a2 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
@@ -7,8 +7,15 @@
import React, { useReducer, useCallback, useEffect, useState } from 'react';
import styled, { css } from 'styled-components';
-import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer, EuiCallOut } from '@elastic/eui';
-import { noop, isEmpty } from 'lodash/fp';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiCallOut,
+ EuiBottomBar,
+ EuiButtonEmpty,
+} from '@elastic/eui';
+import { isEmpty } from 'lodash/fp';
import { useKibana } from '../../../../lib/kibana';
import { useConnectors } from '../../../../containers/case/configure/use_connectors';
import { useCaseConfigure } from '../../../../containers/case/configure/use_configure';
@@ -32,6 +39,9 @@ import { Mapping } from '../configure_cases/mapping';
import { SectionWrapper } from '../wrappers';
import { configureCasesReducer, State } from './reducer';
import * as i18n from './translations';
+import { getCaseUrl } from '../../../../components/link_to';
+
+const CASE_URL = getCaseUrl();
const FormWrapper = styled.div`
${({ theme }) => css`
@@ -68,6 +78,8 @@ const ConfigureCasesComponent: React.FC = () => {
null
);
+ const [actionBarVisible, setActionBarVisible] = useState(false);
+
const handleShowAddFlyout = useCallback(() => setAddFlyoutVisibility(true), []);
const [{ connectorId, closureType, mapping }, dispatch] = useReducer(
@@ -111,11 +123,22 @@ const ConfigureCasesComponent: React.FC = () => {
const handleSubmit = useCallback(
// TO DO give a warning/error to user when field are not mapped so they have chance to do it
() => {
+ setActionBarVisible(false);
persistCaseConfigure({ connectorId, closureType });
},
[connectorId, closureType, mapping]
);
+ const onChangeConnector = useCallback((newConnectorId: string) => {
+ setActionBarVisible(true);
+ setConnectorId(newConnectorId);
+ }, []);
+
+ const onChangeClosureType = useCallback((newClosureType: ClosureType) => {
+ setActionBarVisible(true);
+ setClosureType(newClosureType);
+ }, []);
+
useEffect(() => {
if (
!isEmpty(connectors) &&
@@ -171,7 +194,7 @@ const ConfigureCasesComponent: React.FC = () => {
connectors={connectors ?? []}
disabled={persistLoading || isLoadingConnectors}
isLoading={isLoadingConnectors}
- onChangeConnector={setConnectorId}
+ onChangeConnector={onChangeConnector}
handleShowAddFlyout={handleShowAddFlyout}
selectedConnector={connectorId}
/>
@@ -180,7 +203,7 @@ const ConfigureCasesComponent: React.FC = () => {
@@ -192,37 +215,41 @@ const ConfigureCasesComponent: React.FC = () => {
setEditFlyoutVisibility={setEditFlyoutVisibility}
/>
-
-
-
-
-
- {i18n.CANCEL}
-
-
-
-
- {i18n.SAVE_CHANGES}
-
-
-
-
+ {actionBarVisible && (
+
+
+
+
+
+
+ {i18n.CANCEL}
+
+
+
+
+ {i18n.SAVE_CHANGES}
+
+
+
+
+
+
+ )}
{
const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment(
caseData.comments
);
-
+ const currentUser = useCurrentUser();
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]);
const handleManageMarkdownEditId = useCallback(
@@ -112,10 +113,10 @@ export const UserActionTree = React.memo(
id={NewId}
isEditable={true}
isLoading={isLoadingIds.includes(NewId)}
- fullName="to be determined"
+ fullName={currentUser != null ? currentUser.fullName : ''}
markdown={MarkdownNewComment}
onEdit={handleManageMarkdownEditId.bind(null, NewId)}
- userName="to be determined"
+ userName={currentUser != null ? currentUser.username : ''}
/>
>
);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx
index 0a33301010535..7b99f2ef76ab3 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
+import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import React from 'react';
import styled, { css } from 'styled-components';
@@ -48,6 +48,12 @@ const UserActionItemContainer = styled(EuiFlexGroup)`
margin-right: ${theme.eui.euiSize};
vertical-align: top;
}
+ .userAction_loadingAvatar {
+ position: relative;
+ margin-right: ${theme.eui.euiSizeXL};
+ top: ${theme.eui.euiSizeM};
+ left: ${theme.eui.euiSizeS};
+ }
.userAction__title {
padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL};
background: ${theme.eui.euiColorLightestShade};
@@ -74,7 +80,11 @@ export const UserActionItem = ({
}: UserActionItemProps) => (
-
+ {fullName.length > 0 || userName.length > 0 ? (
+
+ ) : (
+
+ )}
{isEditable && markdown}
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx
new file mode 100644
index 0000000000000..51acb3b810d92
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { UserList } from './';
+import * as i18n from '../case_view/translations';
+
+describe('UserList ', () => {
+ const title = 'Case Title';
+ const caseLink = 'http://reddit.com';
+ const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' };
+ const open = jest.fn();
+ beforeAll(() => {
+ window.open = open;
+ });
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+ it('triggers mailto when email icon clicked', () => {
+ const wrapper = shallow(
+
+ );
+ wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click');
+ expect(open).toBeCalledWith(
+ `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`,
+ '_blank'
+ );
+ });
+});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx
index abb49122dc142..74a1b98c29eef 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React from 'react';
+import React, { useCallback } from 'react';
import {
EuiButtonIcon,
EuiText,
@@ -17,6 +17,10 @@ import styled, { css } from 'styled-components';
import { ElasticUser } from '../../../../containers/case/types';
interface UserListProps {
+ email: {
+ subject: string;
+ body: string;
+ };
headline: string;
users: ElasticUser[];
}
@@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)`
`}
`;
-const renderUsers = (users: ElasticUser[]) => {
- return users.map(({ fullName, username }, key) => (
+const renderUsers = (
+ users: ElasticUser[],
+ handleSendEmail: (emailAddress: string | undefined | null) => void
+) => {
+ return users.map(({ fullName, username, email }, key) => (
@@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => {
{}} // TO DO
+ data-test-subj="user-list-email-button"
+ onClick={handleSendEmail.bind(null, email)} // TO DO
iconType="email"
aria-label="email"
/>
@@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => {
));
};
-export const UserList = React.memo(({ headline, users }: UserListProps) => {
+export const UserList = React.memo(({ email, headline, users }: UserListProps) => {
+ const handleSendEmail = useCallback(
+ (emailAddress: string | undefined | null) => {
+ if (emailAddress && emailAddress != null) {
+ window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank');
+ }
+ },
+ [email.subject]
+ );
return (
{headline}
- {renderUsers(users)}
+ {renderUsers(users, handleSendEmail)}
);
});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts
index 6ef412d408ae5..341a34240fe49 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts
@@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', {
defaultMessage: 'Opened on',
});
+export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', {
+ defaultMessage: 'Closed on',
+});
+export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', {
+ defaultMessage: 'Reopen case',
+});
+export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', {
+ defaultMessage: 'Close case',
+});
+
export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', {
defaultMessage: 'Reporter',
});
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts
index bd6cb5da5eb01..ccb3b71a476ec 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts
+++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts
@@ -28,7 +28,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => {
breadcrumb = [
...breadcrumb,
{
- text: params.detailName,
+ text: params.state?.caseTitle ?? '',
href: getCaseDetailsUrl(params.detailName),
},
];
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx
index b3b35699914f6..229ccde54ecab 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx
@@ -14,9 +14,10 @@ import { isMlRule } from '../../helpers';
interface SelectRuleTypeProps {
field: FieldHook;
+ isReadOnly: boolean;
}
-export const SelectRuleType: React.FC = ({ field }) => {
+export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => {
const ruleType = field.value as RuleType;
const setType = useCallback(
(type: RuleType) => {
@@ -37,6 +38,7 @@ export const SelectRuleType: React.FC = ({ field }) => {
description={i18n.QUERY_TYPE_DESCRIPTION}
icon={}
selectable={{
+ isDisabled: isReadOnly,
onClick: setQuery,
isSelected: !isMlRule(ruleType),
}}
@@ -49,6 +51,7 @@ export const SelectRuleType: React.FC = ({ field }) => {
isDisabled={!license}
icon={}
selectable={{
+ isDisabled: isReadOnly,
onClick: setMl,
isSelected: isMlRule(ruleType),
}}
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx
index 6b1a9a828d950..d3ef185f3786b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx
@@ -178,7 +178,13 @@ const StepDefineRuleComponent: FC = ({
<>
,
+
+
+ ,
+ ]}
+ />
+
+ );
+};
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx
new file mode 100644
index 0000000000000..a174a7d9c0ea4
--- /dev/null
+++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public';
+import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
+
+export const UptimeAlertsContextProvider: React.FC = ({ children }) => {
+ const {
+ services: {
+ data: { fieldFormats },
+ http,
+ charts,
+ notifications,
+ triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry },
+ uiSettings,
+ },
+ } = useKibana();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx
new file mode 100644
index 0000000000000..13705e7d19293
--- /dev/null
+++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public';
+
+interface Props {
+ alertFlyoutVisible: boolean;
+ alertTypeId?: string;
+ canChangeTrigger?: boolean;
+ setAlertFlyoutVisibility: React.Dispatch>;
+}
+
+export const UptimeAlertsFlyoutWrapperComponent = ({
+ alertFlyoutVisible,
+ alertTypeId,
+ canChangeTrigger,
+ setAlertFlyoutVisibility,
+}: Props) => (
+
+);
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts
index daba13d8df641..8d0352e01d40e 100644
--- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts
+++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts
@@ -4,6 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
+export {
+ ToggleAlertFlyoutButtonComponent,
+ UptimeAlertsContextProvider,
+ UptimeAlertsFlyoutWrapperComponent,
+} from './alerts';
+export * from './alerts';
export { DonutChart } from './charts/donut_chart';
export { KueryBarComponent } from './kuery_bar/kuery_bar';
export { MonitorCharts } from './monitor_charts';
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx
index 2f5ccc2adf313..63aceed2be636 100644
--- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx
+++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx
@@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) {
}
interface Props {
+ 'aria-label': string;
autocomplete: DataPublicPluginSetup['autocomplete'];
+ 'data-test-subj': string;
loadIndexPattern: () => void;
indexPattern: IIndexPattern | null;
loading: boolean;
}
export function KueryBarComponent({
+ 'aria-label': ariaLabel,
autocomplete: autocompleteService,
+ 'data-test-subj': dataTestSubj,
loadIndexPattern,
indexPattern,
loading,
@@ -119,6 +123,8 @@ export function KueryBarComponent({
return (
-
+
{
@@ -205,7 +204,7 @@ describe('PingList component', () => {
loading={false}
data={{ allPings }}
onPageCountChange={jest.fn()}
- onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}}
+ onSelectedLocationChange={(_loc: any[]) => {}}
onSelectedStatusChange={jest.fn()}
pageSize={30}
selectedOption="down"
diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx
index a377b9ed1507b..a2f3328b98612 100644
--- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx
+++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx
@@ -10,6 +10,7 @@ import ReactDOM from 'react-dom';
import { get } from 'lodash';
import { i18n as i18nFormatter } from '@kbn/i18n';
import { PluginsSetup } from 'ui/new_platform/new_platform';
+import { alertTypeInitializers } from '../../alert_types';
import { UptimeApp, UptimeAppProps } from '../../../uptime_app';
import { getIntegratedAppAvailability } from './capabilities_adapter';
import {
@@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = (
http: { basePath },
i18n,
} = core;
+
+ const {
+ data: { autocomplete },
+ // TODO: after NP migration we can likely fix this typing problem
+ // @ts-ignore we don't control this type
+ triggers_actions_ui,
+ } = plugins;
+
+ alertTypeInitializers.forEach(init =>
+ triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete }))
+ );
+
let breadcrumbs: ChromeBreadcrumb[] = [];
core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => {
breadcrumbs = nextBreadcrumbs || [];
});
+
const { apm, infrastructure, logs } = getIntegratedAppAvailability(
capabilities,
INTEGRATED_SOLUTIONS
);
+
const canSave = get(capabilities, 'uptime.save', false);
+
const props: UptimeAppProps = {
basePath: basePath.get(),
canSave,
diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts
new file mode 100644
index 0000000000000..6323ee3951e21
--- /dev/null
+++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts
@@ -0,0 +1,181 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { validate, initMonitorStatusAlertType } from '../monitor_status';
+
+describe('monitor status alert type', () => {
+ describe('validate', () => {
+ let params: any;
+
+ beforeEach(() => {
+ params = {
+ locations: [],
+ numTimes: 5,
+ timerange: {
+ from: 'now-15m',
+ to: 'now',
+ },
+ };
+ });
+
+ it(`doesn't throw on empty set`, () => {
+ expect(validate({})).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "typeCheckFailure": "Provided parameters do not conform to the expected type.",
+ "typeCheckParsingMessage": Array [
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array",
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }",
+ ],
+ },
+ }
+ `);
+ });
+
+ describe('timerange', () => {
+ it('is undefined', () => {
+ delete params.timerange;
+ expect(validate(params)).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "typeCheckFailure": "Provided parameters do not conform to the expected type.",
+ "typeCheckParsingMessage": Array [
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }",
+ ],
+ },
+ }
+ `);
+ });
+
+ it('is missing `from` or `to` value', () => {
+ expect(
+ validate({
+ ...params,
+ timerange: {},
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "typeCheckFailure": "Provided parameters do not conform to the expected type.",
+ "typeCheckParsingMessage": Array [
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string",
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string",
+ ],
+ },
+ }
+ `);
+ });
+
+ it('is invalid timespan', () => {
+ expect(
+ validate({
+ ...params,
+ timerange: {
+ from: 'now',
+ to: 'now-15m',
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "invalidTimeRange": "Time range start cannot exceed time range end",
+ },
+ }
+ `);
+ });
+
+ it('has unparse-able `from` value', () => {
+ expect(
+ validate({
+ ...params,
+ timerange: {
+ from: 'cannot parse this to a date',
+ to: 'now',
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value",
+ },
+ }
+ `);
+ });
+
+ it('has unparse-able `to` value', () => {
+ expect(
+ validate({
+ ...params,
+ timerange: {
+ from: 'now-15m',
+ to: 'cannot parse this to a date',
+ },
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value",
+ },
+ }
+ `);
+ });
+ });
+
+ describe('numTimes', () => {
+ it('is missing', () => {
+ delete params.numTimes;
+ expect(validate(params)).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "typeCheckFailure": "Provided parameters do not conform to the expected type.",
+ "typeCheckParsingMessage": Array [
+ "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
+ ],
+ },
+ }
+ `);
+ });
+
+ it('is NaN', () => {
+ expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "typeCheckFailure": "Provided parameters do not conform to the expected type.",
+ "typeCheckParsingMessage": Array [
+ "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
+ ],
+ },
+ }
+ `);
+ });
+
+ it('is < 1', () => {
+ expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(`
+ Object {
+ "errors": Object {
+ "invalidNumTimes": "Number of alert check down times must be an integer greater than 0",
+ },
+ }
+ `);
+ });
+ });
+ });
+
+ describe('initMonitorStatusAlertType', () => {
+ expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(`
+ Object {
+ "alertParamsExpression": [Function],
+ "defaultActionMessage": "{{context.message}}
+ {{context.completeIdList}}",
+ "iconClass": "uptimeApp",
+ "id": "xpack.uptime.alerts.monitorStatus",
+ "name": "Uptime Monitor Status",
+ "validate": [Function],
+ }
+ `);
+ });
+});
diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts
new file mode 100644
index 0000000000000..f764505a6d683
--- /dev/null
+++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// TODO: after NP migration is complete we should be able to remove this lint ignore comment
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types';
+import { initMonitorStatusAlertType } from './monitor_status';
+
+export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel;
+
+export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType];
diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx
new file mode 100644
index 0000000000000..effbb59539d16
--- /dev/null
+++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { PathReporter } from 'io-ts/lib/PathReporter';
+import React from 'react';
+import DateMath from '@elastic/datemath';
+import { isRight } from 'fp-ts/lib/Either';
+import {
+ AlertTypeModel,
+ ValidationResult,
+ // TODO: this typing issue should be resolved after NP migration
+ // eslint-disable-next-line @kbn/eslint/no-restricted-paths
+} from '../../../../../../plugins/triggers_actions_ui/public/types';
+import { AlertTypeInitializer } from '.';
+import { StatusCheckExecutorParamsType } from '../../../common/runtime_types';
+import { AlertMonitorStatus } from '../../components/connected/alerts';
+
+export const validate = (alertParams: any): ValidationResult => {
+ const errors: Record = {};
+ const decoded = StatusCheckExecutorParamsType.decode(alertParams);
+
+ /*
+ * When the UI initially loads, this validate function is called with an
+ * empty set of params, we don't want to type check against that.
+ */
+ if (!isRight(decoded)) {
+ errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.';
+ errors.typeCheckParsingMessage = PathReporter.report(decoded);
+ }
+
+ if (isRight(decoded)) {
+ const { numTimes, timerange } = decoded.right;
+ const { from, to } = timerange;
+ const fromAbs = DateMath.parse(from)?.valueOf();
+ const toAbs = DateMath.parse(to)?.valueOf();
+ if (!fromAbs || isNaN(fromAbs)) {
+ errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value';
+ }
+ if (!toAbs || isNaN(toAbs)) {
+ errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value';
+ }
+
+ // the default values for this test will pass, we only want to specify an error
+ // in the case that `from` is more recent than `to`
+ if ((fromAbs ?? 0) > (toAbs ?? 1)) {
+ errors.invalidTimeRange = 'Time range start cannot exceed time range end';
+ }
+
+ if (numTimes < 1) {
+ errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0';
+ }
+ }
+
+ return { errors };
+};
+
+export const initMonitorStatusAlertType: AlertTypeInitializer = ({
+ autocomplete,
+}): AlertTypeModel => ({
+ id: 'xpack.uptime.alerts.monitorStatus',
+ name: 'Uptime Monitor Status',
+ iconClass: 'uptimeApp',
+ alertParamsExpression: params => {
+ return ;
+ },
+ validate,
+ defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}',
+});
diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
index 5906a77f55441..30e15ba132996 100644
--- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
+++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap
@@ -14,6 +14,39 @@ Array [
TestingHeading
+
@@ -130,6 +163,39 @@ Array [
TestingHeading
+
,
{
const simpleBreadcrumbs: ChromeBreadcrumb[] = [
@@ -21,22 +22,26 @@ describe('PageHeader', () => {
it('shallow renders with breadcrumbs and the date picker', () => {
const component = renderWithRouter(
-
+
+
+
);
expect(component).toMatchSnapshot('page_header_with_date_picker');
});
it('shallow renders with breadcrumbs without the date picker', () => {
const component = renderWithRouter(
-
+
+
+
);
expect(component).toMatchSnapshot('page_header_no_date_picker');
});
@@ -45,13 +50,15 @@ describe('PageHeader', () => {
const [getBreadcrumbs, core] = mockCore();
mountWithRouter(
-
-
-
+
+
+
+
+
);
@@ -62,6 +69,19 @@ describe('PageHeader', () => {
});
});
+const MockReduxProvider = ({ children }: { children: React.ReactElement }) => (
+
+ {children}
+
+);
+
const mockCore: () => [() => ChromeBreadcrumb[], any] = () => {
let breadcrumbObj: ChromeBreadcrumb[] = [];
const get = () => {
diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx
index af9b8bf046416..f9184e2a0587f 100644
--- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx
+++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx
@@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi
-
+
diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx
index b0fb2d0ed7869..56d9ae2d5caa6 100644
--- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx
+++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx
@@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { stringifyUrlParams } from '../lib/helper/stringify_url_params';
import { useUrlParams } from '../hooks';
import { UptimeUrlParams } from '../lib/helper';
+import { ToggleAlertFlyoutButton } from '../components/connected';
interface PageHeaderProps {
headingText: string;
@@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page
{headingText}
+
+
+
{datePickerComponent}
diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts
index d15d601737b2d..4885f974dbbd4 100644
--- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts
+++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts
@@ -12,6 +12,8 @@ export interface PopoverState {
export type UiPayload = PopoverState & string & number & Map;
+export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT');
+
export const setBasePath = createAction('SET BASE PATH');
export const triggerAppRefresh = createAction('REFRESH APP');
diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap
index 5d03c0058c3c1..1dc4e45606c60 100644
--- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap
+++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap
@@ -2,6 +2,7 @@
exports[`ui reducer adds integration popover status to state 1`] = `
Object {
+ "alertFlyoutVisible": false,
"basePath": "",
"esKuery": "",
"integrationsPopoverOpen": Object {
@@ -14,6 +15,7 @@ Object {
exports[`ui reducer sets the application's base path 1`] = `
Object {
+ "alertFlyoutVisible": false,
"basePath": "yyz",
"esKuery": "",
"integrationsPopoverOpen": null,
@@ -23,6 +25,7 @@ Object {
exports[`ui reducer updates the refresh value 1`] = `
Object {
+ "alertFlyoutVisible": false,
"basePath": "abc",
"esKuery": "",
"integrationsPopoverOpen": null,
diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts
index 417095b64ba2d..3c134366347aa 100644
--- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts
+++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts
@@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions';
+import {
+ setBasePath,
+ toggleIntegrationsPopover,
+ triggerAppRefresh,
+ setAlertFlyoutVisible,
+} from '../../actions';
import { uiReducer } from '../ui';
import { Action } from 'redux-actions';
@@ -14,6 +19,7 @@ describe('ui reducer', () => {
expect(
uiReducer(
{
+ alertFlyoutVisible: false,
basePath: 'abc',
esKuery: '',
integrationsPopoverOpen: null,
@@ -32,6 +38,7 @@ describe('ui reducer', () => {
expect(
uiReducer(
{
+ alertFlyoutVisible: false,
basePath: '',
esKuery: '',
integrationsPopoverOpen: null,
@@ -47,6 +54,7 @@ describe('ui reducer', () => {
expect(
uiReducer(
{
+ alertFlyoutVisible: false,
basePath: 'abc',
esKuery: '',
integrationsPopoverOpen: null,
@@ -56,4 +64,28 @@ describe('ui reducer', () => {
)
).toMatchSnapshot();
});
+
+ it('updates the alert flyout value', () => {
+ const action = setAlertFlyoutVisible(true) as Action;
+ expect(
+ uiReducer(
+ {
+ alertFlyoutVisible: false,
+ basePath: '',
+ esKuery: '',
+ integrationsPopoverOpen: null,
+ lastRefresh: 125,
+ },
+ action
+ )
+ ).toMatchInlineSnapshot(`
+ Object {
+ "alertFlyoutVisible": true,
+ "basePath": "",
+ "esKuery": "",
+ "integrationsPopoverOpen": null,
+ "lastRefresh": 125,
+ }
+ `);
+ });
});
diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts
index bb5bd22085ac6..702d314250521 100644
--- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts
+++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts
@@ -12,19 +12,22 @@ import {
setEsKueryString,
triggerAppRefresh,
UiPayload,
+ setAlertFlyoutVisible,
} from '../actions/ui';
export interface UiState {
- integrationsPopoverOpen: PopoverState | null;
+ alertFlyoutVisible: boolean;
basePath: string;
esKuery: string;
+ integrationsPopoverOpen: PopoverState | null;
lastRefresh: number;
}
const initialState: UiState = {
- integrationsPopoverOpen: null,
+ alertFlyoutVisible: false,
basePath: '',
esKuery: '',
+ integrationsPopoverOpen: null,
lastRefresh: Date.now(),
};
@@ -35,6 +38,11 @@ export const uiReducer = handleActions(
integrationsPopoverOpen: action.payload as PopoverState,
}),
+ [String(setAlertFlyoutVisible)]: (state, action: Action) => ({
+ ...state,
+ alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible,
+ }),
+
[String(setBasePath)]: (state, action: Action) => ({
...state,
basePath: action.payload as string,
diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts
index de446418632b8..b1da995709f93 100644
--- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts
+++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts
@@ -35,6 +35,7 @@ describe('state selectors', () => {
loading: false,
},
ui: {
+ alertFlyoutVisible: false,
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,
diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts
index 4767c25e8f52f..7b5a5ddf8d3ca 100644
--- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts
+++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts
@@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => {
return monitorDuration;
};
+export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) =>
+ alertFlyoutVisible;
+
+export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({
+ filters: ui.esKuery,
+ indexPattern: indexPattern.index_pattern,
+ locations: overviewFilters.filters.locations,
+});
+
export const indexStatusSelector = ({ indexStatus }: AppState) => {
return indexStatus;
};
diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
index 09156db9ca7d2..fa2998532d145 100644
--- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
+++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx
@@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker';
import { store } from './state';
import { setBasePath } from './state/actions';
import { PageRouter } from './routes';
+import { UptimeAlertsFlyoutWrapper } from './components/connected';
+import { UptimeAlertsContextProvider } from './components/functional/alerts';
import { kibanaService } from './state/kibana_service';
export interface UptimeAppColors {
@@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => {
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/x-pack/package.json b/x-pack/package.json
index bc00dc21d9908..5d75e0c9edc4c 100644
--- a/x-pack/package.json
+++ b/x-pack/package.json
@@ -142,6 +142,7 @@
"jest-cli": "^24.9.0",
"jest-styled-components": "^7.0.0",
"jsdom": "^15.2.1",
+ "loader-utils": "^1.2.3",
"madge": "3.4.4",
"marge": "^1.0.1",
"mocha": "^6.2.2",
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts
index be687e33e2201..2712b8f6ea9b5 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts
@@ -78,11 +78,13 @@ beforeAll(() => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
}),
updateIncident: jest.fn().mockResolvedValue({
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
}),
batchCreateComments: jest
.fn()
@@ -107,6 +109,7 @@ describe('handleIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
comments: [
{
commentId: '456',
@@ -129,6 +132,7 @@ describe('handleIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
comments: [
{
commentId: '456',
@@ -161,6 +165,7 @@ describe('handleCreateIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
@@ -203,6 +208,7 @@ describe('handleCreateIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
comments: [
{
commentId: '456',
@@ -236,6 +242,7 @@ describe('handleUpdateIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
@@ -326,6 +333,7 @@ describe('handleUpdateIncident', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
comments: [
{
commentId: '456',
@@ -383,8 +391,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('nothing & append', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -426,8 +436,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('append & append', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -471,8 +483,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('nothing & nothing', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -511,8 +525,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('overwrite & nothing', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -553,8 +569,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('overwrite & overwrite', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -596,8 +614,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('nothing & overwrite', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -638,8 +658,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('append & overwrite', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -682,8 +704,10 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
+
test('append & nothing', async () => {
const { serviceNow } = new ServiceNowMock();
finalMapping.set('title', {
@@ -725,6 +749,7 @@ describe('handleUpdateIncident: different action types', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts
index 6439a68813fd5..fb296089e9ec5 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts
@@ -47,11 +47,11 @@ export const handleCreateIncident = async ({
fields,
});
- const { incidentId, number, pushedDate } = await serviceNow.createIncident({
+ const createdIncident = await serviceNow.createIncident({
...incident,
});
- const res: HandlerResponse = { incidentId, number, pushedDate };
+ const res: HandlerResponse = { ...createdIncident };
if (
comments &&
@@ -61,7 +61,12 @@ export const handleCreateIncident = async ({
) {
comments = transformComments(comments, params, ['informationAdded']);
res.comments = [
- ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)),
+ ...(await createComments(
+ serviceNow,
+ res.incidentId,
+ mapping.get('comments').target,
+ comments
+ )),
];
}
@@ -88,11 +93,11 @@ export const handleUpdateIncident = async ({
currentIncident,
});
- const { number, pushedDate } = await serviceNow.updateIncident(incidentId, {
+ const updatedIncident = await serviceNow.updateIncident(incidentId, {
...incident,
});
- const res: HandlerResponse = { incidentId, number, pushedDate };
+ const res: HandlerResponse = { ...updatedIncident };
if (
comments &&
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts
index 8ee81c5e76451..67d595cc3ec56 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts
@@ -231,8 +231,10 @@ describe('execute()', () => {
services,
};
+ handleIncidentMock.mockImplementation(() => incidentResponse);
+
const actionResponse = await actionType.executor(executorOptions);
- expect(actionResponse).toEqual({ actionId, status: 'ok' });
+ expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse });
});
test('should throw an error when failed to update an incident', async () => {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts
index c84e1928e2e5a..3f102ae19f437 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts
@@ -8,3 +8,6 @@ export const API_VERSION = 'v2';
export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`;
export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`;
export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`;
+
+// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
+export const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts
index 17c8bce651403..40eeb0f920f82 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts
@@ -92,6 +92,7 @@ describe('ServiceNow lib', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
@@ -116,6 +117,7 @@ describe('ServiceNow lib', () => {
incidentId: '123',
number: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts
index 2d1d8975c9efc..1acb6c563801c 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts
@@ -6,7 +6,7 @@
import axios, { AxiosInstance, Method, AxiosResponse } from 'axios';
-import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants';
+import { INCIDENT_URL, USER_URL, COMMENT_URL, VIEW_INCIDENT_URL } from './constants';
import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types';
import { Comment } from '../types';
@@ -72,6 +72,10 @@ class ServiceNow {
return `[Action][ServiceNow]: ${msg}`;
}
+ private _getIncidentViewURL(id: string) {
+ return `${this.instance.url}/${VIEW_INCIDENT_URL}${id}`;
+ }
+
async getUserID(): Promise {
try {
const res = await this._request({ url: `${this.userUrl}${this.instance.username}` });
@@ -109,6 +113,7 @@ class ServiceNow {
number: res.data.result.number,
incidentId: res.data.result.sys_id,
pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(),
+ url: this._getIncidentViewURL(res.data.result.sys_id),
};
} catch (error) {
throw new Error(this._getErrorMessage(`Unable to create incident. Error: ${error.message}`));
@@ -126,6 +131,7 @@ class ServiceNow {
number: res.data.result.number,
incidentId: res.data.result.sys_id,
pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(),
+ url: this._getIncidentViewURL(res.data.result.sys_id),
};
} catch (error) {
throw new Error(
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts
index 3c245bf3f688f..a65e417dbc486 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts
@@ -21,6 +21,7 @@ export interface IncidentResponse {
number: string;
incidentId: string;
pushedDate: string;
+ url: string;
}
export interface CommentResponse {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts
index b9608511159b6..06c006fb37825 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts
@@ -69,6 +69,8 @@ const params: ExecutorParams = {
const incidentResponse = {
incidentId: 'c816f79cc0a8016401c5a33be04be441',
number: 'INC0010001',
+ pushedDate: '2020-03-13T08:34:53.450Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
};
const userId = '2e9a0a5e2f79001016ab51172799b670';
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
index 418b78add2429..71b05be8f3e4d 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
@@ -16,7 +16,7 @@ import {
} from './schema';
import { ServiceNow } from './lib';
-import { Incident } from './lib/types';
+import { Incident, IncidentResponse } from './lib/types';
// config definition
export type ConfigType = TypeOf;
@@ -50,11 +50,8 @@ export type IncidentHandlerArguments = CreateHandlerArguments & {
incidentId: string | null;
};
-export interface HandlerResponse {
- incidentId: string;
- number: string;
+export interface HandlerResponse extends IncidentResponse {
comments?: SimpleComment[];
- pushedDate: string;
}
export interface SimpleComment {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts
index 042853796695d..3a351853c1e46 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts
@@ -35,7 +35,7 @@ const SecretsSchema = schema.object(secretsSchemaProps);
export type ActionParamsType = TypeOf;
const ParamsSchema = schema.object({
- message: schema.string(),
+ message: schema.string({ minLength: 1 }),
});
// action type definition
diff --git a/x-pack/plugins/actions/server/routes/find.test.ts b/x-pack/plugins/actions/server/routes/find.test.ts
index 862e26132fdc3..b51130b2640aa 100644
--- a/x-pack/plugins/actions/server/routes/find.test.ts
+++ b/x-pack/plugins/actions/server/routes/find.test.ts
@@ -81,6 +81,7 @@ describe('findActionRoute', () => {
"perPage": 1,
"search": undefined,
"sortField": undefined,
+ "sortOrder": undefined,
},
},
]
diff --git a/x-pack/plugins/actions/server/routes/find.ts b/x-pack/plugins/actions/server/routes/find.ts
index 71d4274980fcc..820dd32d710ae 100644
--- a/x-pack/plugins/actions/server/routes/find.ts
+++ b/x-pack/plugins/actions/server/routes/find.ts
@@ -26,6 +26,7 @@ const querySchema = schema.object({
}),
search_fields: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])),
sort_field: schema.maybe(schema.string()),
+ sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])),
has_reference: schema.maybe(
// use nullable as maybe is currently broken
// in config-schema
@@ -70,6 +71,7 @@ export const findActionRoute = (router: IRouter, licenseState: LicenseState) =>
sortField: query.sort_field,
fields: query.fields,
filter: query.filter,
+ sortOrder: query.sort_order,
};
if (query.search_fields) {
diff --git a/x-pack/plugins/alerting/server/routes/find.test.ts b/x-pack/plugins/alerting/server/routes/find.test.ts
index ba0114c99a9bd..391d6df3f9931 100644
--- a/x-pack/plugins/alerting/server/routes/find.test.ts
+++ b/x-pack/plugins/alerting/server/routes/find.test.ts
@@ -82,6 +82,7 @@ describe('findAlertRoute', () => {
"perPage": 1,
"search": undefined,
"sortField": undefined,
+ "sortOrder": undefined,
},
},
]
diff --git a/x-pack/plugins/alerting/server/routes/find.ts b/x-pack/plugins/alerting/server/routes/find.ts
index efc5c3ea97183..1f8f161cf3028 100644
--- a/x-pack/plugins/alerting/server/routes/find.ts
+++ b/x-pack/plugins/alerting/server/routes/find.ts
@@ -26,6 +26,7 @@ const querySchema = schema.object({
}),
search_fields: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])),
sort_field: schema.maybe(schema.string()),
+ sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])),
has_reference: schema.maybe(
// use nullable as maybe is currently broken
// in config-schema
@@ -70,6 +71,7 @@ export const findAlertRoute = (router: IRouter, licenseState: LicenseState) => {
sortField: query.sort_field,
fields: query.fields,
filter: query.filter,
+ sortOrder: query.sort_order,
};
if (query.search_fields) {
diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
index b79321a8803fa..6d27f8a99dd4b 100644
--- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
+++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts
@@ -113,6 +113,7 @@ export function getAlertType(service: Service): AlertType {
timeWindowUnit: params.timeWindowUnit,
interval: undefined,
};
+ // console.log(`index_threshold: query: ${JSON.stringify(queryParams, null, 4)}`);
const result = await service.indexThreshold.timeSeriesQuery({
logger,
callCluster,
@@ -121,6 +122,7 @@ export function getAlertType(service: Service): AlertType {
logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`);
const groupResults = result.results || [];
+ // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`);
for (const groupResult of groupResults) {
const instanceId = groupResult.group;
const value = groupResult.metrics[0][1];
diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts
index 68a222cb656ed..6f58e2702ec5b 100644
--- a/x-pack/plugins/case/common/api/cases/case.ts
+++ b/x-pack/plugins/case/common/api/cases/case.ts
@@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([
CaseBasicRt,
rt.type({
comment_ids: rt.array(rt.string),
+ closed_at: rt.union([rt.string, rt.null]),
+ closed_by: rt.union([UserRT, rt.null]),
created_at: rt.string,
created_by: UserRT,
updated_at: rt.union([rt.string, rt.null]),
diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts
index ed44791c4e04d..651cd08f08a02 100644
--- a/x-pack/plugins/case/common/api/user.ts
+++ b/x-pack/plugins/case/common/api/user.ts
@@ -7,6 +7,7 @@
import * as rt from 'io-ts';
export const UserRT = rt.type({
+ email: rt.union([rt.undefined, rt.string]),
full_name: rt.union([rt.undefined, rt.string]),
username: rt.string,
});
diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts
index 17a2518482637..c08dae1dc18b4 100644
--- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts
+++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts
@@ -13,7 +13,11 @@ function createAuthenticationMock({
authc.getCurrentUser.mockReturnValue(
currentUser !== undefined
? currentUser
- : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser)
+ : ({
+ email: 'd00d@awesome.com',
+ username: 'awesome',
+ full_name: 'Awesome D00d',
+ } as AuthenticatedUser)
);
return authc;
}
diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
index 1e1965f83ff68..5aa8b93f17b08 100644
--- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
+++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts
@@ -12,10 +12,13 @@ export const mockCases: Array> = [
type: 'cases',
id: 'mock-id-1',
attributes: {
+ closed_at: null,
+ closed_by: null,
comment_ids: ['mock-comment-1'],
created_at: '2019-11-25T21:54:48.952Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'This is a brand new case of a bad meanie defacing data',
@@ -25,6 +28,7 @@ export const mockCases: Array> = [
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
@@ -36,10 +40,13 @@ export const mockCases: Array> = [
type: 'cases',
id: 'mock-id-2',
attributes: {
+ closed_at: null,
+ closed_by: null,
comment_ids: [],
created_at: '2019-11-25T22:32:00.900Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'Oh no, a bad meanie destroying data!',
@@ -49,6 +56,7 @@ export const mockCases: Array> = [
updated_at: '2019-11-25T22:32:00.900Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
@@ -60,10 +68,13 @@ export const mockCases: Array> = [
type: 'cases',
id: 'mock-id-3',
attributes: {
+ closed_at: null,
+ closed_by: null,
comment_ids: [],
created_at: '2019-11-25T22:32:17.947Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
description: 'Oh no, a bad meanie going LOLBins all over the place!',
@@ -73,6 +84,39 @@ export const mockCases: Array> = [
updated_at: '2019-11-25T22:32:17.947Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
+ },
+ references: [],
+ updated_at: '2019-11-25T22:32:17.947Z',
+ version: 'WzUsMV0=',
+ },
+ {
+ type: 'cases',
+ id: 'mock-id-4',
+ attributes: {
+ closed_at: '2019-11-25T22:32:17.947Z',
+ closed_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
+ comment_ids: [],
+ created_at: '2019-11-25T22:32:17.947Z',
+ created_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
+ description: 'Oh no, a bad meanie going LOLBins all over the place!',
+ title: 'Another bad one',
+ status: 'closed',
+ tags: ['LOLBins'],
+ updated_at: '2019-11-25T22:32:17.947Z',
+ updated_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
@@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [
created_at: '2019-11-25T21:55:00.177Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T21:55:00.177Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
@@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [
created_at: '2019-11-25T21:55:14.633Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T21:55:14.633Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
@@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [
created_at: '2019-11-25T22:32:30.608Z',
created_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
updated_at: '2019-11-25T22:32:30.608Z',
updated_by: {
full_name: 'elastic',
+ email: 'testemail@elastic.co',
username: 'elastic',
},
},
diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts
index 0166ba89eb76c..c14a94e84e51c 100644
--- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts
@@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) {
}
const updatedBy = await caseService.getUser({ request, response });
- const { full_name, username } = updatedBy;
+ const { email, full_name, username } = updatedBy;
const updatedComment = await caseService.patchComment({
client: context.core.savedObjects.client,
commentId: query.id,
updatedAttributes: {
comment: query.comment,
updated_at: new Date().toISOString(),
- updated_by: { full_name, username },
+ updated_by: { email, full_name, username },
},
version: query.version,
});
diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts
index 1da1161ab01d1..1542394fc438d 100644
--- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts
@@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
}
const updatedBy = await caseService.getUser({ request, response });
- const { full_name, username } = updatedBy;
+ const { email, full_name, username } = updatedBy;
const updateDate = new Date().toISOString();
const patch = await caseConfigureService.patch({
@@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout
updatedAttributes: {
...queryWithoutVersion,
updated_at: updateDate,
- updated_by: { full_name, username },
+ updated_by: { email, full_name, username },
},
});
diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts
index a22dd8437e508..c839d36dcf4df 100644
--- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts
@@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
);
}
const updatedBy = await caseService.getUser({ request, response });
- const { full_name, username } = updatedBy;
+ const { email, full_name, username } = updatedBy;
const creationDate = new Date().toISOString();
const post = await caseConfigureService.post({
@@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route
attributes: {
...query,
created_at: creationDate,
- created_by: { full_name, username },
+ created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
},
diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
index 7ce37d2569e57..8fafb1af0eb82 100644
--- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts
@@ -34,6 +34,6 @@ describe('GET all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
- expect(response.payload.cases).toHaveLength(3);
+ expect(response.payload.cases).toHaveLength(4);
});
});
diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
index 7ab7212d2f436..19ff7f0734a77 100644
--- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts
@@ -25,7 +25,7 @@ describe('PATCH cases', () => {
toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'),
}));
});
- it(`Patch a case`, async () => {
+ it(`Close a case`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
@@ -50,17 +50,61 @@ describe('PATCH cases', () => {
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
+ closed_at: '2019-11-25T21:54:48.952Z',
+ closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
comment_ids: ['mock-comment-1'],
comments: [],
created_at: '2019-11-25T21:54:48.952Z',
- created_by: { full_name: 'elastic', username: 'elastic' },
+ created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
status: 'closed',
tags: ['defacement'],
title: 'Super Bad Security Issue',
updated_at: '2019-11-25T21:54:48.952Z',
- updated_by: { full_name: 'Awesome D00d', username: 'awesome' },
+ updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
+ version: 'WzE3LDFd',
+ },
+ ]);
+ });
+ it(`Open a case`, async () => {
+ const request = httpServerMock.createKibanaRequest({
+ path: '/api/cases',
+ method: 'patch',
+ body: {
+ cases: [
+ {
+ id: 'mock-id-4',
+ status: 'open',
+ version: 'WzUsMV0=',
+ },
+ ],
+ },
+ });
+
+ const theContext = createRouteContext(
+ createMockSavedObjectsRepository({
+ caseSavedObject: mockCases,
+ })
+ );
+
+ const response = await routeHandler(theContext, request, kibanaResponseFactory);
+ expect(response.status).toEqual(200);
+ expect(response.payload).toEqual([
+ {
+ closed_at: null,
+ closed_by: null,
+ comment_ids: [],
+ comments: [],
+ created_at: '2019-11-25T22:32:17.947Z',
+ created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
+ description: 'Oh no, a bad meanie going LOLBins all over the place!',
+ id: 'mock-id-4',
+ status: 'open',
+ tags: ['LOLBins'],
+ title: 'Another bad one',
+ updated_at: '2019-11-25T21:54:48.952Z',
+ updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
version: 'WzE3LDFd',
},
]);
diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts
index 3fd8c2a1627ab..4aa0d8daf5b34 100644
--- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts
+++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts
@@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) {
client: context.core.savedObjects.client,
caseIds: query.cases.map(q => q.id),
});
+ let nonExistingCases: CasePatchRequest[] = [];
const conflictedCases = query.cases.filter(q => {
const myCase = myCases.saved_objects.find(c => c.id === q.id);
+
+ if (myCase && myCase.error) {
+ nonExistingCases = [...nonExistingCases, q];
+ return false;
+ }
return myCase == null || myCase?.version !== q.version;
});
+ if (nonExistingCases.length > 0) {
+ throw Boom.notFound(
+ `These cases ${nonExistingCases
+ .map(c => c.id)
+ .join(', ')} do not exist. Please check you have the correct ids.`
+ );
+ }
if (conflictedCases.length > 0) {
throw Boom.conflict(
`These cases ${conflictedCases
@@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) {
});
if (updateFilterCases.length > 0) {
const updatedBy = await caseService.getUser({ request, response });
- const { full_name, username } = updatedBy;
+ const { email, full_name, username } = updatedBy;
const updatedDt = new Date().toISOString();
const updatedCases = await caseService.patchCases({
client: context.core.savedObjects.client,
cases: updateFilterCases.map(thisCase => {
const { id: caseId, version, ...updateCaseAttributes } = thisCase;
+ let closedInfo = {};
+ if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') {
+ closedInfo = {
+ closed_at: updatedDt,
+ closed_by: { email, full_name, username },
+ };
+ } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') {
+ closedInfo = {
+ closed_at: null,
+ closed_by: null,
+ };
+ }
return {
caseId,
updatedAttributes: {
...updateCaseAttributes,
+ ...closedInfo,
updated_at: updatedDt,
- updated_by: { full_name, username },
+ updated_by: { email, full_name, username },
},
version,
};
diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts
index eac259cc69c5a..7af3e7b70d96f 100644
--- a/x-pack/plugins/case/server/routes/api/types.ts
+++ b/x-pack/plugins/case/server/routes/api/types.ts
@@ -14,7 +14,7 @@ export interface RouteDeps {
}
export enum SortFieldCase {
+ closedAt = 'closed_at',
createdAt = 'created_at',
status = 'status',
- updatedAt = 'updated_at',
}
diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts
index 27ee6fc58e20a..19dbb024d1e0b 100644
--- a/x-pack/plugins/case/server/routes/api/utils.ts
+++ b/x-pack/plugins/case/server/routes/api/utils.ts
@@ -26,18 +26,22 @@ import { SortFieldCase } from './types';
export const transformNewCase = ({
createdDate,
- newCase,
+ email,
full_name,
+ newCase,
username,
}: {
createdDate: string;
- newCase: CaseRequest;
+ email?: string;
full_name?: string;
+ newCase: CaseRequest;
username: string;
}): CaseAttributes => ({
+ closed_at: newCase.status === 'closed' ? createdDate : null,
+ closed_by: newCase.status === 'closed' ? { email, full_name, username } : null,
comment_ids: [],
created_at: createdDate,
- created_by: { full_name, username },
+ created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
...newCase,
@@ -46,18 +50,20 @@ export const transformNewCase = ({
interface NewCommentArgs {
comment: string;
createdDate: string;
+ email?: string;
full_name?: string;
username: string;
}
export const transformNewComment = ({
comment,
createdDate,
+ email,
full_name,
username,
}: NewCommentArgs): CommentAttributes => ({
comment,
created_at: createdDate,
- created_by: { full_name, username },
+ created_by: { email, full_name, username },
updated_at: null,
updated_by: null,
});
@@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => {
case 'createdAt':
case 'created_at':
return SortFieldCase.createdAt;
- case 'updatedAt':
- case 'updated_at':
- return SortFieldCase.updatedAt;
+ case 'closedAt':
+ case 'closed_at':
+ return SortFieldCase.closedAt;
default:
return SortFieldCase.createdAt;
}
diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts
index 2aa64528739b1..8eab040b9ca9c 100644
--- a/x-pack/plugins/case/server/saved_object_types/cases.ts
+++ b/x-pack/plugins/case/server/saved_object_types/cases.ts
@@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = {
namespaceAgnostic: false,
mappings: {
properties: {
+ closed_at: {
+ type: 'date',
+ },
+ closed_by: {
+ properties: {
+ username: {
+ type: 'keyword',
+ },
+ full_name: {
+ type: 'keyword',
+ },
+ email: {
+ type: 'keyword',
+ },
+ },
+ },
comment_ids: {
type: 'keyword',
},
@@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
+ email: {
+ type: 'keyword',
+ },
},
},
description: {
@@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
+ email: {
+ type: 'keyword',
+ },
},
},
},
diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts
index 51c31421fec2f..f52da886e7611 100644
--- a/x-pack/plugins/case/server/saved_object_types/comments.ts
+++ b/x-pack/plugins/case/server/saved_object_types/comments.ts
@@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
username: {
type: 'keyword',
},
+ email: {
+ type: 'keyword',
+ },
},
},
updated_at: {
@@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
full_name: {
type: 'keyword',
},
+ email: {
+ type: 'keyword',
+ },
},
},
},
diff --git a/x-pack/plugins/console_extensions/server/plugin.ts b/x-pack/plugins/console_extensions/server/plugin.ts
index f4c41aa0a0ad5..8c2cb4d0db42b 100644
--- a/x-pack/plugins/console_extensions/server/plugin.ts
+++ b/x-pack/plugins/console_extensions/server/plugin.ts
@@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { join } from 'path';
-import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server';
+import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server';
-import { ConsoleSetup } from '../../../../src/plugins/console/server';
+import { ConsoleSetup, ConsoleStart } from '../../../../src/plugins/console/server';
import { processors } from './spec/ingest/index';
@@ -14,19 +14,25 @@ interface SetupDependencies {
console: ConsoleSetup;
}
+interface StartDependencies {
+ console: ConsoleStart;
+}
+
+const CONSOLE_XPACK_JSON_SPEC_PATH = join(__dirname, 'spec/');
+
export class ConsoleExtensionsServerPlugin implements Plugin {
log: Logger;
constructor(private readonly ctx: PluginInitializerContext) {
this.log = this.ctx.logger.get();
}
- setup(
- core: CoreSetup,
- { console: { addProcessorDefinition, addExtensionSpecFilePath } }: SetupDependencies
- ) {
- addExtensionSpecFilePath(join(__dirname, 'spec/'));
+ setup(core: CoreSetup, { console: { addExtensionSpecFilePath } }: SetupDependencies) {
+ addExtensionSpecFilePath(CONSOLE_XPACK_JSON_SPEC_PATH);
+ this.log.debug(`Added extension path to ${CONSOLE_XPACK_JSON_SPEC_PATH}...`);
+ }
+
+ start(core: CoreStart, { console: { addProcessorDefinition } }: StartDependencies) {
processors.forEach(processor => addProcessorDefinition(processor));
- this.log.debug('Installed console autocomplete extensions.');
+ this.log.debug('Added processor definition extensions.');
}
- start() {}
}
diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts
index f5ed6da197273..75351bb3bf07d 100644
--- a/x-pack/plugins/endpoint/common/generate_data.ts
+++ b/x-pack/plugins/endpoint/common/generate_data.ts
@@ -325,7 +325,7 @@ export class EndpointDocGenerator {
for (let i = 0; i < generations; i++) {
const newParents: EndpointEvent[] = [];
parents.forEach(element => {
- const numChildren = this.randomN(maxChildrenPerNode);
+ const numChildren = this.randomN(maxChildrenPerNode + 1);
for (let j = 0; j < numChildren; j++) {
timestamp = timestamp + 1000;
const child = this.generateEvent({
diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts
index 503999daec587..3d11ccaad005d 100644
--- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts
+++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts
@@ -131,8 +131,13 @@ async function main() {
process.exit(1);
}
}
-
- const generator = new EndpointDocGenerator(argv.seed);
+ let seed = argv.seed;
+ if (!seed) {
+ seed = Math.random().toString();
+ // eslint-disable-next-line no-console
+ console.log('No seed supplied, using random seed: ' + seed);
+ }
+ const generator = new EndpointDocGenerator(seed);
for (let i = 0; i < argv.numHosts; i++) {
await client.index({
index: argv.metadataIndex,
diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts
index 67a532d949e81..6d5ac8efdc1da 100644
--- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts
+++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts
@@ -4,28 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { ResolverEvent, LegacyEndpointEvent } from '../../../../common/types';
-
-function isLegacyData(data: ResolverEvent): data is LegacyEndpointEvent {
- return data.agent?.type === 'endgame';
-}
+import { ResolverEvent } from '../../../../common/types';
+import { isLegacyEvent } from '../../../../common/models/event';
export function extractEventID(event: ResolverEvent) {
- if (isLegacyData(event)) {
+ if (isLegacyEvent(event)) {
return String(event.endgame.serial_event_id);
}
return event.event.id;
}
export function extractEntityID(event: ResolverEvent) {
- if (isLegacyData(event)) {
+ if (isLegacyEvent(event)) {
return String(event.endgame.unique_pid);
}
return event.process.entity_id;
}
export function extractParentEntityID(event: ResolverEvent) {
- if (isLegacyData(event)) {
+ if (isLegacyEvent(event)) {
const ppid = event.endgame.unique_ppid;
return ppid && String(ppid); // if unique_ppid is undefined return undefined
}
diff --git a/x-pack/plugins/index_management/__mocks__/ui/notify.js b/x-pack/plugins/index_management/__mocks__/ui/notify.js
index d508c3383d5f9..3d64a99232bc3 100644
--- a/x-pack/plugins/index_management/__mocks__/ui/notify.js
+++ b/x-pack/plugins/index_management/__mocks__/ui/notify.js
@@ -5,6 +5,7 @@
*/
export const toastNotifications = {
+ addInfo: () => {},
addSuccess: () => {},
addDanger: () => {},
addWarning: () => {},
diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts
index 97bdad23beb24..419ee021a9189 100644
--- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts
+++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts
@@ -12,11 +12,11 @@ export const LOG_ENTRIES_PATH = '/api/log_entries/entries';
export const logEntriesBaseRequestRT = rt.intersection([
rt.type({
sourceId: rt.string,
- startDate: rt.number,
- endDate: rt.number,
+ startTimestamp: rt.number,
+ endTimestamp: rt.number,
}),
rt.partial({
- query: rt.string,
+ query: rt.union([rt.string, rt.null]),
size: rt.number,
}),
]);
@@ -31,7 +31,7 @@ export const logEntriesAfterRequestRT = rt.intersection([
rt.type({ after: rt.union([logEntriesCursorRT, rt.literal('first')]) }),
]);
-export const logEntriesCenteredRT = rt.intersection([
+export const logEntriesCenteredRequestRT = rt.intersection([
logEntriesBaseRequestRT,
rt.type({ center: logEntriesCursorRT }),
]);
@@ -40,38 +40,39 @@ export const logEntriesRequestRT = rt.union([
logEntriesBaseRequestRT,
logEntriesBeforeRequestRT,
logEntriesAfterRequestRT,
- logEntriesCenteredRT,
+ logEntriesCenteredRequestRT,
]);
+export type LogEntriesBaseRequest = rt.TypeOf;
+export type LogEntriesBeforeRequest = rt.TypeOf;
+export type LogEntriesAfterRequest = rt.TypeOf;
+export type LogEntriesCenteredRequest = rt.TypeOf;
export type LogEntriesRequest = rt.TypeOf;
-// JSON value
-const valueRT = rt.union([rt.string, rt.number, rt.boolean, rt.object, rt.null, rt.undefined]);
+export const logMessageConstantPartRT = rt.type({
+ constant: rt.string,
+});
+export const logMessageFieldPartRT = rt.type({
+ field: rt.string,
+ value: rt.unknown,
+ highlights: rt.array(rt.string),
+});
-export const logMessagePartRT = rt.union([
- rt.type({
- constant: rt.string,
- }),
- rt.type({
- field: rt.string,
- value: valueRT,
- highlights: rt.array(rt.string),
- }),
-]);
+export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]);
-export const logColumnRT = rt.union([
- rt.type({ columnId: rt.string, timestamp: rt.number }),
- rt.type({
- columnId: rt.string,
- field: rt.string,
- value: rt.union([rt.string, rt.undefined]),
- highlights: rt.array(rt.string),
- }),
- rt.type({
- columnId: rt.string,
- message: rt.array(logMessagePartRT),
- }),
-]);
+export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number });
+export const logFieldColumnRT = rt.type({
+ columnId: rt.string,
+ field: rt.string,
+ value: rt.unknown,
+ highlights: rt.array(rt.string),
+});
+export const logMessageColumnRT = rt.type({
+ columnId: rt.string,
+ message: rt.array(logMessagePartRT),
+});
+
+export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]);
export const logEntryRT = rt.type({
id: rt.string,
@@ -79,15 +80,20 @@ export const logEntryRT = rt.type({
columns: rt.array(logColumnRT),
});
-export type LogMessagepart = rt.TypeOf;
+export type LogMessageConstantPart = rt.TypeOf;
+export type LogMessageFieldPart = rt.TypeOf;
+export type LogMessagePart = rt.TypeOf;
+export type LogTimestampColumn = rt.TypeOf;
+export type LogFieldColumn = rt.TypeOf;
+export type LogMessageColumn = rt.TypeOf;
export type LogColumn = rt.TypeOf;
export type LogEntry = rt.TypeOf;
export const logEntriesResponseRT = rt.type({
data: rt.type({
entries: rt.array(logEntryRT),
- topCursor: logEntriesCursorRT,
- bottomCursor: logEntriesCursorRT,
+ topCursor: rt.union([logEntriesCursorRT, rt.null]),
+ bottomCursor: rt.union([logEntriesCursorRT, rt.null]),
}),
});
diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts
index 516cd67f2764d..f6d61a7177b49 100644
--- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts
+++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts
@@ -9,7 +9,7 @@ import {
logEntriesBaseRequestRT,
logEntriesBeforeRequestRT,
logEntriesAfterRequestRT,
- logEntriesCenteredRT,
+ logEntriesCenteredRequestRT,
logEntryRT,
} from './entries';
import { logEntriesCursorRT } from './common';
@@ -36,7 +36,7 @@ export const logEntriesHighlightsAfterRequestRT = rt.intersection([
]);
export const logEntriesHighlightsCenteredRequestRT = rt.intersection([
- logEntriesCenteredRT,
+ logEntriesCenteredRequestRT,
highlightsRT,
]);
diff --git a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts
index 4a2c0db0e995e..6af4b7c592ab6 100644
--- a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts
+++ b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts
@@ -10,8 +10,8 @@ export const LOG_ENTRIES_SUMMARY_PATH = '/api/log_entries/summary';
export const logEntriesSummaryRequestRT = rt.type({
sourceId: rt.string,
- startDate: rt.number,
- endDate: rt.number,
+ startTimestamp: rt.number,
+ endTimestamp: rt.number,
bucketSize: rt.number,
query: rt.union([rt.string, rt.undefined, rt.null]),
});
diff --git a/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx
new file mode 100644
index 0000000000000..e80f738eac6ba
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx
@@ -0,0 +1,73 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useCallback } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiButtonEmpty } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+interface LogDatepickerProps {
+ startDateExpression: string;
+ endDateExpression: string;
+ isStreaming: boolean;
+ onUpdateDateRange?: (range: { startDateExpression: string; endDateExpression: string }) => void;
+ onStartStreaming?: () => void;
+ onStopStreaming?: () => void;
+}
+
+export const LogDatepicker: React.FC = ({
+ startDateExpression,
+ endDateExpression,
+ isStreaming,
+ onUpdateDateRange,
+ onStartStreaming,
+ onStopStreaming,
+}) => {
+ const handleTimeChange = useCallback(
+ ({ start, end, isInvalid }) => {
+ if (onUpdateDateRange && !isInvalid) {
+ onUpdateDateRange({ startDateExpression: start, endDateExpression: end });
+ }
+ },
+ [onUpdateDateRange]
+ );
+
+ return (
+
+
+
+
+
+ {isStreaming ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx
index 729689e65739e..2bdb1f91a6dde 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx
@@ -10,10 +10,10 @@ import { max } from 'lodash';
import * as React from 'react';
import { euiStyled } from '../../../../../observability/public';
-import { SummaryBucket } from './types';
+import { LogEntriesSummaryBucket } from '../../../../common/http_api';
interface DensityChartProps {
- buckets: SummaryBucket[];
+ buckets: LogEntriesSummaryBucket[];
end: number;
start: number;
width: number;
@@ -38,36 +38,36 @@ export const DensityChart: React.FC = ({
const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0;
const xScale = scaleLinear()
.domain([0, xMax])
- .range([0, width * (2 / 3)]);
+ .range([0, width]);
- const path = area()
+ const path = area()
.x0(xScale(0))
.x1(bucket => xScale(bucket.entriesCount))
- .y(bucket => yScale((bucket.start + bucket.end) / 2))
+ .y0(bucket => yScale(bucket.start))
+ .y1(bucket => yScale(bucket.end))
.curve(curveMonotoneY);
- const pathData = path(buckets);
- const highestPathCoord = String(pathData)
- .replace(/[^.0-9,]/g, ' ')
- .split(/[ ,]/)
- .reduce((result, num) => (Number(num) > result ? Number(num) : result), 0);
+ const firstBucket = buckets[0];
+ const lastBucket = buckets[buckets.length - 1];
+ const pathBuckets = [
+ // Make sure the graph starts at the count of the first point
+ { start, end: start, entriesCount: firstBucket.entriesCount },
+ ...buckets,
+ // Make sure the line ends at the height of the last point
+ { start: lastBucket.end, end: lastBucket.end, entriesCount: lastBucket.entriesCount },
+ // If the last point is not at the end of the minimap, make sure it doesn't extend indefinitely and goes to 0
+ { start: end, end, entriesCount: 0 },
+ ];
+ const pathData = path(pathBuckets);
+
return (
-
-
-
+
+
);
};
-const DensityChartNegativeBackground = euiStyled.rect`
- fill: ${props => props.theme.eui.euiColorEmptyShade};
-`;
-
const DensityChartPositiveBackground = euiStyled.rect`
fill: ${props =>
props.theme.darkMode
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx
index 2e45bcea42109..975e83e0075ff 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx
@@ -13,6 +13,7 @@ interface HighlightedIntervalProps {
getPositionOfTime: (time: number) => number;
start: number;
end: number;
+ targetWidth: number;
width: number;
target: number | null;
}
@@ -22,6 +23,7 @@ export const HighlightedInterval: React.FC = ({
end,
getPositionOfTime,
start,
+ targetWidth,
width,
target,
}) => {
@@ -35,14 +37,14 @@ export const HighlightedInterval: React.FC = ({
)}
>
);
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx
index e3a7e5aa30633..c67674d198a3f 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx
@@ -13,42 +13,40 @@ import { DensityChart } from './density_chart';
import { HighlightedInterval } from './highlighted_interval';
import { SearchMarkers } from './search_markers';
import { TimeRuler } from './time_ruler';
-import { SummaryBucket, SummaryHighlightBucket } from './types';
+import {
+ LogEntriesSummaryBucket,
+ LogEntriesSummaryHighlightsBucket,
+} from '../../../../common/http_api';
interface Interval {
end: number;
start: number;
}
-interface DragRecord {
- startY: number;
- currentY: number | null;
-}
-
interface LogMinimapProps {
className?: string;
height: number;
highlightedInterval: Interval | null;
jumpToTarget: (params: LogEntryTime) => any;
- intervalSize: number;
- summaryBuckets: SummaryBucket[];
- summaryHighlightBuckets?: SummaryHighlightBucket[];
+ summaryBuckets: LogEntriesSummaryBucket[];
+ summaryHighlightBuckets?: LogEntriesSummaryHighlightsBucket[];
target: number | null;
+ start: number | null;
+ end: number | null;
width: number;
}
interface LogMinimapState {
target: number | null;
- drag: DragRecord | null;
- svgPosition: ClientRect;
timeCursorY: number;
}
-function calculateYScale(target: number | null, height: number, intervalSize: number) {
- const domainStart = target ? target - intervalSize / 2 : 0;
- const domainEnd = target ? target + intervalSize / 2 : 0;
+// Wide enough to fit "September"
+const TIMERULER_WIDTH = 50;
+
+function calculateYScale(start: number | null, end: number | null, height: number) {
return scaleLinear()
- .domain([domainStart, domainEnd])
+ .domain([start || 0, end || 0])
.range([0, height]);
}
@@ -58,103 +56,28 @@ export class LogMinimap extends React.Component = event => {
+ const minimapTop = event.currentTarget.getBoundingClientRect().top;
+ const clickedYPosition = event.clientY - minimapTop;
- public handleClick = (event: MouseEvent) => {
- if (!this.dragTargetArea) return;
- const svgPosition = this.dragTargetArea.getBoundingClientRect();
- const clickedYPosition = event.clientY - svgPosition.top;
const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition));
- this.setState({
- drag: null,
- });
- this.props.jumpToTarget({
- tiebreaker: 0,
- time: clickedTime,
- });
- };
-
- private handleMouseDown: React.MouseEventHandler = event => {
- const { clientY, target } = event;
- if (target === this.dragTargetArea) {
- const svgPosition = event.currentTarget.getBoundingClientRect();
- this.setState({
- drag: {
- startY: clientY,
- currentY: null,
- },
- svgPosition,
- });
- window.addEventListener('mousemove', this.handleDragMove);
- }
- window.addEventListener('mouseup', this.handleMouseUp);
- };
-
- private handleMouseUp = (event: MouseEvent) => {
- window.removeEventListener('mousemove', this.handleDragMove);
- window.removeEventListener('mouseup', this.handleMouseUp);
- const { drag, svgPosition } = this.state;
- if (!drag || !drag.currentY) {
- this.handleClick(event);
- return;
- }
- const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos));
- const startYPosition = drag.startY - svgPosition.top;
- const endYPosition = event.clientY - svgPosition.top;
- const startTime = getTime(startYPosition);
- const endTime = getTime(endYPosition);
- const timeDifference = endTime - startTime;
- const newTime = (this.props.target || 0) - timeDifference;
- this.setState({ drag: null, target: newTime });
this.props.jumpToTarget({
tiebreaker: 0,
- time: newTime,
- });
- };
-
- private handleDragMove = (event: MouseEvent) => {
- const { drag } = this.state;
- if (!drag) return;
- this.setState({
- drag: {
- ...drag,
- currentY: event.clientY,
- },
+ time: clickedTime,
});
};
public getYScale = () => {
- const { target } = this.state;
- const { height, intervalSize } = this.props;
- return calculateYScale(target, height, intervalSize);
+ const { start, end, height } = this.props;
+ return calculateYScale(start, end, height);
};
public getPositionOfTime = (time: number) => {
- const { height, intervalSize } = this.props;
-
- const [minTime] = this.getYScale().domain();
-
- return ((time - minTime) * height) / intervalSize; //
+ return this.getYScale()(time);
};
private updateTimeCursor: React.MouseEventHandler = event => {
@@ -166,6 +89,8 @@ export class LogMinimap extends React.Component
-
+
+
+
-
-
-
+
{highlightedInterval ? (
) : null}
-
- {
- this.dragTargetArea = node;
- }}
- x={0}
- y={0}
- width={width / 3}
- height={height}
- />
+
);
}
}
-const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>`
- fill: transparent;
- cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')};
-`;
-
const MinimapBorder = euiStyled.line`
stroke: ${props => props.theme.eui.euiColorMediumShade};
stroke-width: 1px;
@@ -269,9 +170,9 @@ const TimeCursor = euiStyled.line`
: props.theme.eui.euiColorDarkShade};
`;
-const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>`
- background: ${props =>
- props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'};
+const MinimapWrapper = euiStyled.svg`
+ cursor: pointer;
+ fill: ${props => props.theme.eui.euiColorEmptyShade};
& ${TimeCursor} {
visibility: hidden;
}
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx
index 8b87aa15f16f0..18d4a3bbfc8b3 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx
@@ -10,10 +10,9 @@ import * as React from 'react';
import { euiStyled, keyframes } from '../../../../../observability/public';
import { LogEntryTime } from '../../../../common/log_entry';
import { SearchMarkerTooltip } from './search_marker_tooltip';
-import { SummaryHighlightBucket } from './types';
-
+import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api';
interface SearchMarkerProps {
- bucket: SummaryHighlightBucket;
+ bucket: LogEntriesSummaryHighlightsBucket;
height: number;
width: number;
jumpToTarget: (target: LogEntryTime) => void;
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx
index ebdc390aef11b..1e254d999036e 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx
@@ -10,10 +10,10 @@ import * as React from 'react';
import { LogEntryTime } from '../../../../common/log_entry';
import { SearchMarker } from './search_marker';
-import { SummaryHighlightBucket } from './types';
+import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api';
interface SearchMarkersProps {
- buckets: SummaryHighlightBucket[];
+ buckets: LogEntriesSummaryHighlightsBucket[];
className?: string;
end: number;
start: number;
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx
new file mode 100644
index 0000000000000..af981105d1718
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// The default d3-time-format is a bit strange for small ranges, so we will specify our own
+export function getTimeLabelFormat(start: number, end: number): string | undefined {
+ const diff = Math.abs(end - start);
+
+ // 15 seconds
+ if (diff < 15 * 1000) {
+ return ':%S.%L';
+ }
+
+ // 16 minutes
+ if (diff < 16 * 60 * 1000) {
+ return '%I:%M:%S';
+ }
+
+ // Use D3's default
+ return;
+}
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx
index b610737663e8d..454935c32fe1e 100644
--- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx
@@ -8,6 +8,7 @@ import { scaleTime } from 'd3-scale';
import * as React from 'react';
import { euiStyled } from '../../../../../observability/public';
+import { getTimeLabelFormat } from './time_label_formatter';
interface TimeRulerProps {
end: number;
@@ -23,37 +24,19 @@ export const TimeRuler: React.FC = ({ end, height, start, tickCo
.range([0, height]);
const ticks = yScale.ticks(tickCount);
- const formatTick = yScale.tickFormat();
-
- const dateModLabel = (() => {
- for (let i = 0; i < ticks.length; i++) {
- const tickLabel = formatTick(ticks[i]);
- if (!tickLabel[0].match(/[0-9]/)) {
- return i % 12;
- }
- }
- })();
+ const formatTick = yScale.tickFormat(tickCount, getTimeLabelFormat(start, end));
return (
{ticks.map((tick, tickIndex) => {
const y = yScale(tick);
- const isLabeledTick = tickIndex % 12 === dateModLabel;
- const tickStartX = isLabeledTick ? 0 : width / 3 - 4;
+
return (
- {isLabeledTick && (
-
- {formatTick(tick)}
-
- )}
-
+
+ {formatTick(tick)}
+
+
);
})}
@@ -71,15 +54,11 @@ const TimeRulerTickLabel = euiStyled.text`
pointer-events: none;
`;
-const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>`
+const TimeRulerGridLine = euiStyled.line`
stroke: ${props =>
- props.isDark
- ? props.theme.darkMode
- ? props.theme.eui.euiColorDarkestShade
- : props.theme.eui.euiColorDarkShade
- : props.theme.darkMode
- ? props.theme.eui.euiColorDarkShade
- : props.theme.eui.euiColorMediumShade};
+ props.theme.darkMode
+ ? props.theme.eui.euiColorDarkestShade
+ : props.theme.eui.euiColorDarkShade};
stroke-opacity: 0.5;
stroke-width: 1px;
`;
diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx
deleted file mode 100644
index 41c6e554e603a..0000000000000
--- a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx
+++ /dev/null
@@ -1,67 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { EuiFormRow, EuiRadioGroup } from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n/react';
-import * as React from 'react';
-
-interface IntervalSizeDescriptor {
- label: string;
- intervalSize: number;
-}
-
-interface LogMinimapScaleControlsProps {
- availableIntervalSizes: IntervalSizeDescriptor[];
- intervalSize: number;
- setIntervalSize: (intervalSize: number) => any;
-}
-
-export class LogMinimapScaleControls extends React.PureComponent {
- public handleScaleChange = (intervalSizeDescriptorKey: string) => {
- const { availableIntervalSizes, setIntervalSize } = this.props;
- const [sizeDescriptor] = availableIntervalSizes.filter(
- intervalKeyEquals(intervalSizeDescriptorKey)
- );
-
- if (sizeDescriptor) {
- setIntervalSize(sizeDescriptor.intervalSize);
- }
- };
-
- public render() {
- const { availableIntervalSizes, intervalSize } = this.props;
- const [currentSizeDescriptor] = availableIntervalSizes.filter(intervalSizeEquals(intervalSize));
-
- return (
-
- }
- >
- ({
- id: getIntervalSizeDescriptorKey(sizeDescriptor),
- label: sizeDescriptor.label,
- }))}
- onChange={this.handleScaleChange}
- idSelected={getIntervalSizeDescriptorKey(currentSizeDescriptor)}
- />
-
- );
- }
-}
-
-const getIntervalSizeDescriptorKey = (sizeDescriptor: IntervalSizeDescriptor) =>
- `${sizeDescriptor.intervalSize}`;
-
-const intervalKeyEquals = (key: string) => (sizeDescriptor: IntervalSizeDescriptor) =>
- getIntervalSizeDescriptorKey(sizeDescriptor) === key;
-
-const intervalSizeEquals = (size: number) => (sizeDescriptor: IntervalSizeDescriptor) =>
- sizeDescriptor.intervalSize === size;
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts
index ca5ca9736b7b3..19e8108ee50e8 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts
@@ -7,27 +7,27 @@
import { bisector } from 'd3-array';
import { compareToTimeKey, TimeKey } from '../../../../common/time';
-import { LogEntry, LogEntryHighlight } from '../../../utils/log_entry';
+import { LogEntry } from '../../../../common/http_api';
export type StreamItem = LogEntryStreamItem;
export interface LogEntryStreamItem {
kind: 'logEntry';
logEntry: LogEntry;
- highlights: LogEntryHighlight[];
+ highlights: LogEntry[];
}
export function getStreamItemTimeKey(item: StreamItem) {
switch (item.kind) {
case 'logEntry':
- return item.logEntry.key;
+ return item.logEntry.cursor;
}
}
export function getStreamItemId(item: StreamItem) {
switch (item.kind) {
case 'logEntry':
- return `${item.logEntry.key.time}:${item.logEntry.key.tiebreaker}:${item.logEntry.gid}`;
+ return `${item.logEntry.cursor.time}:${item.logEntry.cursor.tiebreaker}:${item.logEntry.id}`;
}
}
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx
index 8c48d9e176d3b..5598528c0e0f5 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx
@@ -6,144 +6,279 @@
/* eslint-disable max-classes-per-file */
-import { EuiButtonEmpty, EuiIcon, EuiProgress, EuiText } from '@elastic/eui';
-import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react';
+import {
+ EuiText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTitle,
+ EuiLoadingSpinner,
+ EuiButton,
+} from '@elastic/eui';
+import { FormattedMessage, FormattedTime, FormattedRelative } from '@kbn/i18n/react';
import * as React from 'react';
+import { Unit } from '@elastic/datemath';
import { euiStyled } from '../../../../../observability/public';
+import { LogTextSeparator } from './log_text_separator';
+import { extendDatemath } from '../../../utils/datemath';
+
+type Position = 'start' | 'end';
interface LogTextStreamLoadingItemViewProps {
- alignment: 'top' | 'bottom';
+ position: Position;
+ timestamp: number; // Either the top of the bottom's cursor timestamp
+ startDateExpression: string;
+ endDateExpression: string;
className?: string;
hasMore: boolean;
isLoading: boolean;
isStreaming: boolean;
- lastStreamingUpdate: Date | null;
- onLoadMore?: () => void;
+ onExtendRange?: (newDate: string) => void;
+ onStreamStart?: () => void;
}
+const TIMESTAMP_FORMAT = {
+ hour12: false,
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+};
+
export class LogTextStreamLoadingItemView extends React.PureComponent<
LogTextStreamLoadingItemViewProps,
{}
> {
public render() {
const {
- alignment,
+ position,
+ timestamp,
+ startDateExpression,
+ endDateExpression,
className,
hasMore,
isLoading,
isStreaming,
- lastStreamingUpdate,
- onLoadMore,
+ onExtendRange,
+ onStreamStart,
} = this.props;
- if (isStreaming) {
- return (
-
-
-
-
-
-
- {lastStreamingUpdate ? (
-
-
-
-
- ),
- }}
- />
-
-
- ) : null}
-
- );
- } else if (isLoading) {
- return (
-
-
-
-
-
- );
- } else if (!hasMore) {
- return (
-
-
-
-
- {onLoadMore ? (
-
-
-
- ) : null}
-
- );
- } else {
- return null;
- }
+ const shouldShowCta = !hasMore && !isStreaming;
+
+ const extra = (
+
+ {isLoading || isStreaming ? (
+
+ ) : shouldShowCta ? (
+
+ ) : null}
+
+ );
+
+ return (
+
+ {position === 'start' ? extra : null}
+
+ {position === 'end' ? extra : null}
+
+ );
}
}
-interface ProgressEntryProps {
- alignment: 'top' | 'bottom';
- className?: string;
- color: 'subdued' | 'primary';
- isLoading: boolean;
-}
+const LoadingItemViewExtra = euiStyled(EuiFlexGroup)`
+ height: 40px;
+`;
-const ProgressEntry: React.FC = props => {
- const { alignment, children, className, color, isLoading } = props;
+const ProgressEntryWrapper = euiStyled.div<{ position: Position }>`
+ padding-left: ${props => props.theme.eui.euiSizeS};
+ padding-top: ${props =>
+ props.position === 'start' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM};
+ padding-bottom: ${props =>
+ props.position === 'end' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM};
+`;
- // NOTE: styled-components seems to make all props in EuiProgress required, so this
- // style attribute hacking replaces styled-components here for now until that can be fixed
- // see: https://github.com/elastic/eui/issues/1655
- const alignmentStyle =
- alignment === 'top' ? { top: 0, bottom: 'initial' } : { top: 'initial', bottom: 0 };
+type ProgressMessageProps = Pick<
+ LogTextStreamLoadingItemViewProps,
+ 'timestamp' | 'position' | 'isStreaming'
+>;
+const ProgressMessage: React.FC = ({ timestamp, position, isStreaming }) => {
+ const formattedTimestamp =
+ isStreaming && position === 'end' ? (
+
+ ) : (
+
+ );
- return (
-
-
+ ) : isStreaming ? (
+
+ ) : (
+
- {children}
-
+ );
+
+ return (
+
+ {message}
+
);
};
-const ProgressEntryWrapper = euiStyled.div`
- align-items: center;
- display: flex;
- min-height: ${props => props.theme.eui.euiSizeXXL};
- position: relative;
-`;
+const ProgressSpinner: React.FC<{ kind: 'streaming' | 'loading' }> = ({ kind }) => (
+ <>
+
+
+
+
+
+ {kind === 'streaming' ? (
+
+ ) : (
+
+ )}
+
+
+ >
+);
-const ProgressMessage = euiStyled.div`
- padding: 8px 16px;
-`;
+type ProgressCtaProps = Pick<
+ LogTextStreamLoadingItemViewProps,
+ 'position' | 'startDateExpression' | 'endDateExpression' | 'onExtendRange' | 'onStreamStart'
+>;
+const ProgressCta: React.FC = ({
+ position,
+ startDateExpression,
+ endDateExpression,
+ onExtendRange,
+ onStreamStart,
+}) => {
+ const rangeEdge = position === 'start' ? startDateExpression : endDateExpression;
+
+ if (rangeEdge === 'now' && position === 'end') {
+ return (
+
+
+
+ );
+ }
+
+ const iconType = position === 'start' ? 'arrowUp' : 'arrowDown';
+ const extendedRange =
+ position === 'start'
+ ? extendDatemath(startDateExpression, 'before', endDateExpression)
+ : extendDatemath(endDateExpression, 'after', startDateExpression);
+ if (!extendedRange || !('diffUnit' in extendedRange)) {
+ return null;
+ }
+
+ return (
+ {
+ if (typeof onExtendRange === 'function') {
+ onExtendRange(extendedRange.value);
+ }
+ }}
+ iconType={iconType}
+ size="s"
+ >
+
+
+ );
+};
+
+const ProgressExtendMessage: React.FC<{ amount: number; unit: Unit }> = ({ amount, unit }) => {
+ switch (unit) {
+ case 'ms':
+ return (
+
+ );
+ case 's':
+ return (
+
+ );
+ case 'm':
+ return (
+
+ );
+ case 'h':
+ return (
+
+ );
+ case 'd':
+ return (
+
+ );
+ case 'w':
+ return (
+
+ );
+ case 'M':
+ return (
+
+ );
+ case 'y':
+ return (
+
+ );
+ default:
+ throw new TypeError('Unhandled unit: ' + unit);
+ }
+};
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx
index 5d295ca7e4817..5fc4606a774d5 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx
@@ -8,15 +8,16 @@ import { mount } from 'enzyme';
import React from 'react';
import { EuiThemeProvider } from '../../../../../observability/public';
-import { LogEntryColumn } from '../../../utils/log_entry';
import { LogEntryFieldColumn } from './log_entry_field_column';
+import { LogColumn } from '../../../../common/http_api';
describe('LogEntryFieldColumn', () => {
it('should output a when displaying an Array of values', () => {
- const column: LogEntryColumn = {
+ const column: LogColumn = {
columnId: 'TEST_COLUMN',
field: 'TEST_FIELD',
- value: JSON.stringify(['a', 'b', 'c']),
+ value: ['a', 'b', 'c'],
+ highlights: [],
};
const component = mount(
@@ -42,13 +43,14 @@ describe('LogEntryFieldColumn', () => {
});
it('should output a text representation of a passed complex value', () => {
- const column: LogEntryColumn = {
+ const column: LogColumn = {
columnId: 'TEST_COLUMN',
field: 'TEST_FIELD',
- value: JSON.stringify({
+ value: {
lat: 1,
lon: 2,
- }),
+ },
+ highlights: [],
};
const component = mount(
@@ -67,10 +69,11 @@ describe('LogEntryFieldColumn', () => {
});
it('should output just text when passed a non-Array', () => {
- const column: LogEntryColumn = {
+ const column: LogColumn = {
columnId: 'TEST_COLUMN',
field: 'TEST_FIELD',
- value: JSON.stringify('foo'),
+ value: 'foo',
+ highlights: [],
};
const component = mount(
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx
index c6584f2fdbb6d..202108cda5ac0 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx
@@ -8,14 +8,10 @@ import stringify from 'json-stable-stringify';
import React, { useMemo } from 'react';
import { euiStyled } from '../../../../../observability/public';
-import {
- isFieldColumn,
- isHighlightFieldColumn,
- LogEntryColumn,
- LogEntryHighlightColumn,
-} from '../../../utils/log_entry';
+import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry';
import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting';
import { LogEntryColumnContent } from './log_entry_column';
+import { LogColumn } from '../../../../common/http_api';
import {
hoveredContentStyle,
longWrappedContentStyle,
@@ -25,8 +21,8 @@ import {
} from './text_styles';
interface LogEntryFieldColumnProps {
- columnValue: LogEntryColumn;
- highlights: LogEntryHighlightColumn[];
+ columnValue: LogColumn;
+ highlights: LogColumn[];
isActiveHighlight: boolean;
isHighlighted: boolean;
isHovered: boolean;
@@ -41,9 +37,12 @@ export const LogEntryFieldColumn: React.FunctionComponent {
- const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [
- columnValue,
- ]);
+ const value = useMemo(() => {
+ if (isFieldColumn(columnValue)) {
+ return columnValue.value;
+ }
+ return null;
+ }, [columnValue]);
const formattedValue = Array.isArray(value) ? (
{value.map((entry, i) => (
@@ -58,7 +57,7 @@ export const LogEntryFieldColumn: React.FunctionComponent
) : (
highlightFieldValue(
- typeof value === 'object' && value != null ? stringify(value) : value,
+ typeof value === 'string' ? value : stringify(value),
isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : [],
isActiveHighlight ? ActiveHighlightMarker : HighlightMarker
)
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx
index 122f0fe472c6e..5ad7cba6427d1 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx
@@ -5,6 +5,7 @@
*/
import React, { memo, useMemo } from 'react';
+import stringify from 'json-stable-stringify';
import { euiStyled } from '../../../../../observability/public';
import {
@@ -12,9 +13,7 @@ import {
isFieldSegment,
isHighlightMessageColumn,
isMessageColumn,
- LogEntryColumn,
- LogEntryHighlightColumn,
- LogEntryMessageSegment,
+ isHighlightFieldSegment,
} from '../../../utils/log_entry';
import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting';
import { LogEntryColumnContent } from './log_entry_column';
@@ -25,10 +24,11 @@ import {
unwrappedContentStyle,
WrapMode,
} from './text_styles';
+import { LogColumn, LogMessagePart } from '../../../../common/http_api';
interface LogEntryMessageColumnProps {
- columnValue: LogEntryColumn;
- highlights: LogEntryHighlightColumn[];
+ columnValue: LogColumn;
+ highlights: LogColumn[];
isActiveHighlight: boolean;
isHighlighted: boolean;
isHovered: boolean;
@@ -72,28 +72,39 @@ const MessageColumnContent = euiStyled(LogEntryColumnContent)
messageSegments.map((messageSegment, index) =>
formatMessageSegment(
messageSegment,
- highlights.map(highlight =>
- isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : []
- ),
+ highlights.map(highlight => {
+ if (isHighlightMessageColumn(highlight)) {
+ const segment = highlight.message[index];
+ if (isHighlightFieldSegment(segment)) {
+ return segment.highlights;
+ }
+ }
+ return [];
+ }),
isActiveHighlight
)
);
const formatMessageSegment = (
- messageSegment: LogEntryMessageSegment,
+ messageSegment: LogMessagePart,
[firstHighlight = []]: string[][], // we only support one highlight for now
isActiveHighlight: boolean
): React.ReactNode => {
if (isFieldSegment(messageSegment)) {
+ const value =
+ typeof messageSegment.value === 'string'
+ ? messageSegment.value
+ : stringify(messageSegment.value);
+
return highlightFieldValue(
- messageSegment.value,
+ value,
firstHighlight,
isActiveHighlight ? ActiveHighlightMarker : HighlightMarker
);
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx
index e5e3740f420e8..ce264245d385b 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx
@@ -7,12 +7,7 @@
import React, { memo, useState, useCallback, useMemo } from 'react';
import { euiStyled } from '../../../../../observability/public';
-import {
- LogEntry,
- LogEntryHighlight,
- LogEntryHighlightColumn,
- isTimestampColumn,
-} from '../../../utils/log_entry';
+import { isTimestampColumn } from '../../../utils/log_entry';
import {
LogColumnConfiguration,
isTimestampLogColumnConfiguration,
@@ -26,12 +21,13 @@ import { LogEntryDetailsIconColumn } from './log_entry_icon_column';
import { LogEntryMessageColumn } from './log_entry_message_column';
import { LogEntryTimestampColumn } from './log_entry_timestamp_column';
import { monospaceTextStyle } from './text_styles';
+import { LogEntry, LogColumn } from '../../../../common/http_api';
interface LogEntryRowProps {
boundingBoxRef?: React.Ref;
columnConfigurations: LogColumnConfiguration[];
columnWidths: LogEntryColumnWidths;
- highlights: LogEntryHighlight[];
+ highlights: LogEntry[];
isActiveHighlight: boolean;
isHighlighted: boolean;
logEntry: LogEntry;
@@ -63,9 +59,9 @@ export const LogEntryRow = memo(
setIsHovered(false);
}, []);
- const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [
+ const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.id), [
openFlyoutWithItem,
- logEntry.gid,
+ logEntry.id,
]);
const logEntryColumnsById = useMemo(
@@ -85,7 +81,7 @@ export const LogEntryRow = memo(
const highlightsByColumnId = useMemo(
() =>
highlights.reduce<{
- [columnId: string]: LogEntryHighlightColumn[];
+ [columnId: string]: LogColumn[];
}>(
(columnsById, highlight) =>
highlight.columns.reduce(
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx
new file mode 100644
index 0000000000000..9cc91fa11e4ed
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
+
+/**
+ * Create a separator with a text on the right side
+ */
+export const LogTextSeparator: React.FC = ({ children }) => {
+ return (
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx
index 6544a32ba414c..2c389b47fa6cf 100644
--- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx
@@ -54,6 +54,10 @@ interface ScrollableLogTextStreamViewProps {
setFlyoutVisibility: (visible: boolean) => void;
highlightedItem: string | null;
currentHighlightKey: UniqueTimeKey | null;
+ startDateExpression: string;
+ endDateExpression: string;
+ updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void;
+ startLiveStreaming: () => void;
}
interface ScrollableLogTextStreamViewState {
@@ -90,7 +94,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)),
items: nextItems,
};
- } else if (!nextProps.target || !hasItems) {
+ } else if (!hasItems) {
return {
target: null,
targetId: null,
@@ -129,9 +133,13 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
isLoadingMore,
isReloading,
isStreaming,
- lastLoadedTime,
scale,
wrap,
+ startDateExpression,
+ endDateExpression,
+ lastLoadedTime,
+ updateDateRange,
+ startLiveStreaming,
} = this.props;
const { targetId, items, isScrollLocked } = this.state;
const hasItems = items.length > 0;
@@ -184,72 +192,88 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
isLocked={isScrollLocked}
entriesCount={items.length}
>
- {registerChild => (
- <>
-
- {items.map((item, idx) => {
- const currentTimestamp = item.logEntry.key.time;
- let showDate = false;
+ {registerChild =>
+ items.length > 0 ? (
+ <>
+
+ updateDateRange({ startDateExpression: newDateExpression })
+ }
+ />
+ {items.map((item, idx) => {
+ const currentTimestamp = item.logEntry.cursor.time;
+ let showDate = false;
- if (idx > 0) {
- const prevTimestamp = items[idx - 1].logEntry.key.time;
- showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day');
- }
+ if (idx > 0) {
+ const prevTimestamp = items[idx - 1].logEntry.cursor.time;
+ showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day');
+ }
- return (
-
- {showDate && }
-
- {itemMeasureRef => (
-
- )}
-
-
- );
- })}
-
- {isScrollLocked && (
-
+ {showDate && }
+
+ {itemMeasureRef => (
+
+ )}
+
+
+ );
+ })}
+
+ updateDateRange({ endDateExpression: newDateExpression })
+ }
+ onStreamStart={() => startLiveStreaming()}
/>
- )}
- >
- )}
+ {isScrollLocked && (
+
+ )}
+ >
+ ) : null
+ }
)}
@@ -275,14 +299,6 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
}
};
- private handleLoadNewerItems = () => {
- const { loadNewerItems } = this.props;
-
- if (loadNewerItems) {
- loadNewerItems();
- }
- };
-
// this is actually a method but not recognized as such
// eslint-disable-next-line @typescript-eslint/member-ordering
private handleVisibleChildrenChange = callWithoutRepeats(
diff --git a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx
deleted file mode 100644
index 3653a6d6bbeae..0000000000000
--- a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx
+++ /dev/null
@@ -1,97 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n/react';
-import moment, { Moment } from 'moment';
-import React from 'react';
-import { FixedDatePicker } from '../fixed_datepicker';
-
-const noop = () => undefined;
-
-interface LogTimeControlsProps {
- currentTime: number | null;
- startLiveStreaming: () => any;
- stopLiveStreaming: () => void;
- isLiveStreaming: boolean;
- jumpToTime: (time: number) => any;
-}
-
-export class LogTimeControls extends React.PureComponent {
- public render() {
- const { currentTime, isLiveStreaming } = this.props;
-
- const currentMoment = currentTime ? moment(currentTime) : null;
- if (isLiveStreaming) {
- return (
-
-
-
-
-
-
-
-
-
-
- );
- } else {
- return (
-
-
-
-
-
-
-
-
-
-
- );
- }
- }
-
- private handleChangeDate = (date: Moment | null) => {
- if (date !== null) {
- this.props.jumpToTime(date.valueOf());
- }
- };
-
- private startLiveStreaming = () => {
- this.props.startLiveStreaming();
- };
-
- private stopLiveStreaming = () => {
- this.props.stopLiveStreaming();
- };
-}
diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts
new file mode 100644
index 0000000000000..2a19a82892427
--- /dev/null
+++ b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fold } from 'fp-ts/lib/Either';
+import { pipe } from 'fp-ts/lib/pipeable';
+import { identity } from 'fp-ts/lib/function';
+import { npStart } from '../../../../legacy_singletons';
+
+import { throwErrors, createPlainError } from '../../../../../common/runtime_types';
+
+import {
+ LOG_ENTRIES_PATH,
+ LogEntriesRequest,
+ logEntriesRequestRT,
+ logEntriesResponseRT,
+} from '../../../../../common/http_api';
+
+export const fetchLogEntries = async (requestArgs: LogEntriesRequest) => {
+ const response = await npStart.http.fetch(LOG_ENTRIES_PATH, {
+ method: 'POST',
+ body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)),
+ });
+
+ return pipe(logEntriesResponseRT.decode(response), fold(throwErrors(createPlainError), identity));
+};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts
deleted file mode 100644
index 83bae37c348d4..0000000000000
--- a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts
+++ /dev/null
@@ -1,64 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-import { ApolloClient } from 'apollo-client';
-import { TimeKey } from '../../../../common/time';
-import { logEntriesQuery } from '../../../graphql/log_entries.gql_query';
-import { useApolloClient } from '../../../utils/apollo_context';
-import { LogEntriesResponse } from '.';
-
-const LOAD_CHUNK_SIZE = 200;
-
-type LogEntriesGetter = (
- client: ApolloClient<{}>,
- countBefore: number,
- countAfter: number
-) => (params: {
- sourceId: string;
- timeKey: TimeKey | null;
- filterQuery: string | null;
-}) => Promise;
-
-const getLogEntries: LogEntriesGetter = (client, countBefore, countAfter) => async ({
- sourceId,
- timeKey,
- filterQuery,
-}) => {
- if (!timeKey) throw new Error('TimeKey is null');
- const result = await client.query({
- query: logEntriesQuery,
- variables: {
- sourceId,
- timeKey: { time: timeKey.time, tiebreaker: timeKey.tiebreaker },
- countBefore,
- countAfter,
- filterQuery,
- },
- fetchPolicy: 'no-cache',
- });
- // Workaround for Typescript. Since we're removing the GraphQL API in another PR or two
- // 7.6 goes out I don't think it's worth the effort to actually make this
- // typecheck pass
- const { source } = result.data as any;
- const { logEntriesAround } = source;
- return {
- entries: logEntriesAround.entries,
- entriesStart: logEntriesAround.start,
- entriesEnd: logEntriesAround.end,
- hasMoreAfterEnd: logEntriesAround.hasMoreAfter,
- hasMoreBeforeStart: logEntriesAround.hasMoreBefore,
- lastLoadedTime: new Date(),
- };
-};
-
-export const useGraphQLQueries = () => {
- const client = useApolloClient();
- if (!client) throw new Error('Unable to get Apollo Client from context');
- return {
- getLogEntriesAround: getLogEntries(client, LOAD_CHUNK_SIZE, LOAD_CHUNK_SIZE),
- getLogEntriesBefore: getLogEntries(client, LOAD_CHUNK_SIZE, 0),
- getLogEntriesAfter: getLogEntries(client, 0, LOAD_CHUNK_SIZE),
- };
-};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts
index 04412f5fdd871..b9a5c4068e166 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts
@@ -5,12 +5,18 @@
*/
import { useEffect, useState, useReducer, useCallback } from 'react';
import createContainer from 'constate';
-import { pick, throttle, omit } from 'lodash';
-import { useGraphQLQueries } from './gql_queries';
+import { pick, throttle } from 'lodash';
import { TimeKey, timeKeyIsBetween } from '../../../../common/time';
-import { InfraLogEntry } from './types';
+import {
+ LogEntriesResponse,
+ LogEntry,
+ LogEntriesRequest,
+ LogEntriesBaseRequest,
+} from '../../../../common/http_api';
+import { fetchLogEntries } from './api/fetch_log_entries';
const DESIRED_BUFFER_PAGES = 2;
+const LIVE_STREAM_INTERVAL = 5000;
enum Action {
FetchingNewEntries,
@@ -20,6 +26,7 @@ enum Action {
ReceiveEntriesAfter,
ErrorOnNewEntries,
ErrorOnMoreEntries,
+ ExpandRange,
}
type ReceiveActions =
@@ -29,41 +36,46 @@ type ReceiveActions =
interface ReceiveEntriesAction {
type: ReceiveActions;
- payload: LogEntriesResponse;
+ payload: LogEntriesResponse['data'];
+}
+interface ExpandRangeAction {
+ type: Action.ExpandRange;
+ payload: { before: boolean; after: boolean };
}
interface FetchOrErrorAction {
- type: Exclude;
+ type: Exclude;
}
-type ActionObj = ReceiveEntriesAction | FetchOrErrorAction;
+type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction;
type Dispatch = (action: ActionObj) => void;
interface LogEntriesProps {
+ startTimestamp: number;
+ endTimestamp: number;
+ timestampsLastUpdate: number;
filterQuery: string | null;
timeKey: TimeKey | null;
pagesBeforeStart: number | null;
pagesAfterEnd: number | null;
sourceId: string;
- isAutoReloading: boolean;
+ isStreaming: boolean;
jumpToTargetPosition: (position: TimeKey) => void;
}
-type FetchEntriesParams = Omit;
+type FetchEntriesParams = Omit;
type FetchMoreEntriesParams = Pick;
-export interface LogEntriesResponse {
- entries: InfraLogEntry[];
- entriesStart: TimeKey | null;
- entriesEnd: TimeKey | null;
- hasMoreAfterEnd: boolean;
- hasMoreBeforeStart: boolean;
- lastLoadedTime: Date | null;
-}
-
-export type LogEntriesStateParams = {
+export interface LogEntriesStateParams {
+ entries: LogEntriesResponse['data']['entries'];
+ topCursor: LogEntriesResponse['data']['topCursor'] | null;
+ bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null;
+ centerCursor: TimeKey | null;
isReloading: boolean;
isLoadingMore: boolean;
-} & LogEntriesResponse;
+ lastLoadedTime: Date | null;
+ hasMoreBeforeStart: boolean;
+ hasMoreAfterEnd: boolean;
+}
export interface LogEntriesCallbacks {
fetchNewerEntries: () => Promise;
@@ -75,32 +87,40 @@ export const logEntriesInitialCallbacks = {
export const logEntriesInitialState: LogEntriesStateParams = {
entries: [],
- entriesStart: null,
- entriesEnd: null,
- hasMoreAfterEnd: false,
- hasMoreBeforeStart: false,
+ topCursor: null,
+ bottomCursor: null,
+ centerCursor: null,
isReloading: true,
isLoadingMore: false,
lastLoadedTime: null,
+ hasMoreBeforeStart: false,
+ hasMoreAfterEnd: false,
};
-const cleanDuplicateItems = (entriesA: InfraLogEntry[], entriesB: InfraLogEntry[]) => {
- const gids = new Set(entriesB.map(item => item.gid));
- return entriesA.filter(item => !gids.has(item.gid));
+const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => {
+ const ids = new Set(entriesB.map(item => item.id));
+ return entriesA.filter(item => !ids.has(item.id));
};
const shouldFetchNewEntries = ({
prevParams,
timeKey,
filterQuery,
- entriesStart,
- entriesEnd,
-}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams }) => {
- if (!timeKey) return false;
- const shouldLoadWithNewFilter = filterQuery !== prevParams.filterQuery;
+ topCursor,
+ bottomCursor,
+ startTimestamp,
+ endTimestamp,
+}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => {
+ const shouldLoadWithNewDates = prevParams
+ ? (startTimestamp !== prevParams.startTimestamp &&
+ startTimestamp > prevParams.startTimestamp) ||
+ (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp)
+ : true;
+ const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true;
const shouldLoadAroundNewPosition =
- !entriesStart || !entriesEnd || !timeKeyIsBetween(entriesStart, entriesEnd, timeKey);
- return shouldLoadWithNewFilter || shouldLoadAroundNewPosition;
+ timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey));
+
+ return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition;
};
enum ShouldFetchMoreEntries {
@@ -124,48 +144,105 @@ const useFetchEntriesEffect = (
dispatch: Dispatch,
props: LogEntriesProps
) => {
- const { getLogEntriesAround, getLogEntriesBefore, getLogEntriesAfter } = useGraphQLQueries();
-
- const [prevParams, cachePrevParams] = useState(props);
+ const [prevParams, cachePrevParams] = useState();
const [startedStreaming, setStartedStreaming] = useState(false);
- const runFetchNewEntriesRequest = async (override = {}) => {
+ const runFetchNewEntriesRequest = async (overrides: Partial = {}) => {
+ if (!props.startTimestamp || !props.endTimestamp) {
+ return;
+ }
+
dispatch({ type: Action.FetchingNewEntries });
+
try {
- const payload = await getLogEntriesAround({
- ...omit(props, 'jumpToTargetPosition'),
- ...override,
- });
+ const commonFetchArgs: LogEntriesBaseRequest = {
+ sourceId: overrides.sourceId || props.sourceId,
+ startTimestamp: overrides.startTimestamp || props.startTimestamp,
+ endTimestamp: overrides.endTimestamp || props.endTimestamp,
+ query: overrides.filterQuery || props.filterQuery,
+ };
+
+ const fetchArgs: LogEntriesRequest = props.timeKey
+ ? {
+ ...commonFetchArgs,
+ center: props.timeKey,
+ }
+ : {
+ ...commonFetchArgs,
+ before: 'last',
+ };
+
+ const { data: payload } = await fetchLogEntries(fetchArgs);
dispatch({ type: Action.ReceiveNewEntries, payload });
+
+ // Move position to the bottom if it's the first load.
+ // Do it in the next tick to allow the `dispatch` to fire
+ if (!props.timeKey && payload.bottomCursor) {
+ setTimeout(() => {
+ props.jumpToTargetPosition(payload.bottomCursor!);
+ });
+ } else if (
+ props.timeKey &&
+ payload.topCursor &&
+ payload.bottomCursor &&
+ !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey)
+ ) {
+ props.jumpToTargetPosition(payload.topCursor);
+ }
} catch (e) {
dispatch({ type: Action.ErrorOnNewEntries });
}
};
const runFetchMoreEntriesRequest = async (direction: ShouldFetchMoreEntries) => {
- dispatch({ type: Action.FetchingMoreEntries });
+ if (!props.startTimestamp || !props.endTimestamp) {
+ return;
+ }
const getEntriesBefore = direction === ShouldFetchMoreEntries.Before;
- const timeKey = getEntriesBefore
- ? state.entries[0].key
- : state.entries[state.entries.length - 1].key;
- const getMoreLogEntries = getEntriesBefore ? getLogEntriesBefore : getLogEntriesAfter;
+
+ // Control that cursors are correct
+ if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) {
+ return;
+ }
+
+ dispatch({ type: Action.FetchingMoreEntries });
+
try {
- const payload = await getMoreLogEntries({ ...props, timeKey });
+ const commonFetchArgs: LogEntriesBaseRequest = {
+ sourceId: props.sourceId,
+ startTimestamp: props.startTimestamp,
+ endTimestamp: props.endTimestamp,
+ query: props.filterQuery,
+ };
+
+ const fetchArgs: LogEntriesRequest = getEntriesBefore
+ ? {
+ ...commonFetchArgs,
+ before: state.topCursor!, // We already check for nullity above
+ }
+ : {
+ ...commonFetchArgs,
+ after: state.bottomCursor,
+ };
+
+ const { data: payload } = await fetchLogEntries(fetchArgs);
+
dispatch({
type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter,
payload,
});
- return payload.entriesEnd;
+
+ return payload.bottomCursor;
} catch (e) {
dispatch({ type: Action.ErrorOnMoreEntries });
}
};
const fetchNewEntriesEffectDependencies = Object.values(
- pick(props, ['sourceId', 'filterQuery', 'timeKey'])
+ pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp'])
);
const fetchNewEntriesEffect = () => {
- if (props.isAutoReloading) return;
+ if (props.isStreaming && prevParams) return;
if (shouldFetchNewEntries({ ...props, ...state, prevParams })) {
runFetchNewEntriesRequest();
}
@@ -177,7 +254,7 @@ const useFetchEntriesEffect = (
Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])),
];
const fetchMoreEntriesEffect = () => {
- if (state.isLoadingMore || props.isAutoReloading) return;
+ if (state.isLoadingMore || props.isStreaming) return;
const direction = shouldFetchMoreEntries(props, state);
switch (direction) {
case ShouldFetchMoreEntries.Before:
@@ -191,30 +268,25 @@ const useFetchEntriesEffect = (
const fetchNewerEntries = useCallback(
throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500),
- [props, state.entriesEnd]
+ [props, state.bottomCursor]
);
const streamEntriesEffectDependencies = [
- props.isAutoReloading,
+ props.isStreaming,
state.isLoadingMore,
state.isReloading,
];
const streamEntriesEffect = () => {
(async () => {
- if (props.isAutoReloading && !state.isLoadingMore && !state.isReloading) {
+ if (props.isStreaming && !state.isLoadingMore && !state.isReloading) {
if (startedStreaming) {
- await new Promise(res => setTimeout(res, 5000));
+ await new Promise(res => setTimeout(res, LIVE_STREAM_INTERVAL));
} else {
- const nowKey = {
- tiebreaker: 0,
- time: Date.now(),
- };
- props.jumpToTargetPosition(nowKey);
+ const endTimestamp = Date.now();
+ props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp });
setStartedStreaming(true);
if (state.hasMoreAfterEnd) {
- runFetchNewEntriesRequest({
- timeKey: nowKey,
- });
+ runFetchNewEntriesRequest({ endTimestamp });
return;
}
}
@@ -222,15 +294,41 @@ const useFetchEntriesEffect = (
if (newEntriesEnd) {
props.jumpToTargetPosition(newEntriesEnd);
}
- } else if (!props.isAutoReloading) {
+ } else if (!props.isStreaming) {
setStartedStreaming(false);
}
})();
};
+ const expandRangeEffect = () => {
+ if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) {
+ return;
+ }
+
+ if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) {
+ return;
+ }
+
+ const shouldExpand = {
+ before: props.startTimestamp < prevParams.startTimestamp,
+ after: props.endTimestamp > prevParams.endTimestamp,
+ };
+
+ dispatch({ type: Action.ExpandRange, payload: shouldExpand });
+ };
+
+ const expandRangeEffectDependencies = [
+ prevParams?.startTimestamp,
+ prevParams?.endTimestamp,
+ props.startTimestamp,
+ props.endTimestamp,
+ props.timestampsLastUpdate,
+ ];
+
useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies);
useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies);
useEffect(streamEntriesEffect, streamEntriesEffectDependencies);
+ useEffect(expandRangeEffect, expandRangeEffectDependencies);
return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest };
};
@@ -249,44 +347,87 @@ export const useLogEntriesState: (
const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => {
switch (action.type) {
case Action.ReceiveNewEntries:
- return { ...prevState, ...action.payload, isReloading: false };
+ return {
+ ...prevState,
+ ...action.payload,
+ centerCursor: getCenterCursor(action.payload.entries),
+ lastLoadedTime: new Date(),
+ isReloading: false,
+
+ // Be optimistic. If any of the before/after requests comes empty, set
+ // the corresponding flag to `false`
+ hasMoreBeforeStart: true,
+ hasMoreAfterEnd: true,
+ };
case Action.ReceiveEntriesBefore: {
- const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries);
- const newEntries = [...action.payload.entries, ...prevEntries];
- const { hasMoreBeforeStart, entriesStart, lastLoadedTime } = action.payload;
+ const newEntries = action.payload.entries;
+ const prevEntries = cleanDuplicateItems(prevState.entries, newEntries);
+ const entries = [...newEntries, ...prevEntries];
+
const update = {
- entries: newEntries,
+ entries,
isLoadingMore: false,
- hasMoreBeforeStart,
- entriesStart,
- lastLoadedTime,
+ hasMoreBeforeStart: newEntries.length > 0,
+ // Keep the previous cursor if request comes empty, to easily extend the range.
+ topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor,
+ centerCursor: getCenterCursor(entries),
+ lastLoadedTime: new Date(),
};
+
return { ...prevState, ...update };
}
case Action.ReceiveEntriesAfter: {
- const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries);
- const newEntries = [...prevEntries, ...action.payload.entries];
- const { hasMoreAfterEnd, entriesEnd, lastLoadedTime } = action.payload;
+ const newEntries = action.payload.entries;
+ const prevEntries = cleanDuplicateItems(prevState.entries, newEntries);
+ const entries = [...prevEntries, ...newEntries];
+
const update = {
- entries: newEntries,
+ entries,
isLoadingMore: false,
- hasMoreAfterEnd,
- entriesEnd,
- lastLoadedTime,
+ hasMoreAfterEnd: newEntries.length > 0,
+ // Keep the previous cursor if request comes empty, to easily extend the range.
+ bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor,
+ centerCursor: getCenterCursor(entries),
+ lastLoadedTime: new Date(),
};
+
return { ...prevState, ...update };
}
case Action.FetchingNewEntries:
- return { ...prevState, isReloading: true };
+ return {
+ ...prevState,
+ isReloading: true,
+ entries: [],
+ topCursor: null,
+ bottomCursor: null,
+ centerCursor: null,
+ hasMoreBeforeStart: true,
+ hasMoreAfterEnd: true,
+ };
case Action.FetchingMoreEntries:
return { ...prevState, isLoadingMore: true };
case Action.ErrorOnNewEntries:
return { ...prevState, isReloading: false };
case Action.ErrorOnMoreEntries:
return { ...prevState, isLoadingMore: false };
+
+ case Action.ExpandRange: {
+ const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart;
+ const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd;
+
+ return {
+ ...prevState,
+ hasMoreBeforeStart,
+ hasMoreAfterEnd,
+ };
+ }
default:
throw new Error();
}
};
+function getCenterCursor(entries: LogEntry[]): TimeKey | null {
+ return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null;
+}
+
export const LogEntriesState = createContainer(useLogEntriesState);
diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx
index 5c1667a4b7680..267abe631c142 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx
@@ -19,7 +19,7 @@ export enum FlyoutVisibility {
visible = 'visible',
}
-interface FlyoutOptionsUrlState {
+export interface FlyoutOptionsUrlState {
flyoutId?: string | null;
flyoutVisibility?: string | null;
surroundingLogsId?: string | null;
diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts
new file mode 100644
index 0000000000000..030a9d180c7b5
--- /dev/null
+++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { fold } from 'fp-ts/lib/Either';
+import { pipe } from 'fp-ts/lib/pipeable';
+import { identity } from 'fp-ts/lib/function';
+import { npStart } from '../../../../legacy_singletons';
+
+import { throwErrors, createPlainError } from '../../../../../common/runtime_types';
+
+import {
+ LOG_ENTRIES_HIGHLIGHTS_PATH,
+ LogEntriesHighlightsRequest,
+ logEntriesHighlightsRequestRT,
+ logEntriesHighlightsResponseRT,
+} from '../../../../../common/http_api';
+
+export const fetchLogEntriesHighlights = async (requestArgs: LogEntriesHighlightsRequest) => {
+ const response = await npStart.http.fetch(LOG_ENTRIES_HIGHLIGHTS_PATH, {
+ method: 'POST',
+ body: JSON.stringify(logEntriesHighlightsRequestRT.encode(requestArgs)),
+ });
+
+ return pipe(
+ logEntriesHighlightsResponseRT.decode(response),
+ fold(throwErrors(createPlainError), identity)
+ );
+};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx
index 2b19958a9b1a1..7701850443768 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx
@@ -6,62 +6,47 @@
import { useEffect, useMemo, useState } from 'react';
-import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time';
-import { LogEntryHighlightsQuery } from '../../../graphql/types';
-import { DependencyError, useApolloClient } from '../../../utils/apollo_context';
-import { LogEntryHighlightsMap } from '../../../utils/log_entry';
+import { TimeKey } from '../../../../common/time';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
-import { logEntryHighlightsQuery } from './log_entry_highlights.gql_query';
-
-export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights'];
+import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights';
+import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api';
export const useLogEntryHighlights = (
sourceId: string,
sourceVersion: string | undefined,
- startKey: TimeKey | null,
- endKey: TimeKey | null,
+ startTimestamp: number | null,
+ endTimestamp: number | null,
+ centerPoint: TimeKey | null,
+ size: number,
filterQuery: string | null,
highlightTerms: string[]
) => {
- const apolloClient = useApolloClient();
- const [logEntryHighlights, setLogEntryHighlights] = useState([]);
+ const [logEntryHighlights, setLogEntryHighlights] = useState<
+ LogEntriesHighlightsResponse['data']
+ >([]);
const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async () => {
- if (!apolloClient) {
- throw new DependencyError('Failed to load source: No apollo client available.');
- }
- if (!startKey || !endKey || !highlightTerms.length) {
+ if (!startTimestamp || !endTimestamp || !centerPoint || !highlightTerms.length) {
throw new Error('Skipping request: Insufficient parameters');
}
- return await apolloClient.query<
- LogEntryHighlightsQuery.Query,
- LogEntryHighlightsQuery.Variables
- >({
- fetchPolicy: 'no-cache',
- query: logEntryHighlightsQuery,
- variables: {
- sourceId,
- startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive
- endKey: getNextTimeKey(endKey), // interval boundaries are exclusive
- filterQuery,
- highlights: [
- {
- query: highlightTerms[0],
- countBefore: 1,
- countAfter: 1,
- },
- ],
- },
+ return await fetchLogEntriesHighlights({
+ sourceId,
+ startTimestamp,
+ endTimestamp,
+ center: centerPoint,
+ size,
+ query: filterQuery || undefined,
+ highlightTerms,
});
},
onResolve: response => {
- setLogEntryHighlights(response.data.source.logEntryHighlights);
+ setLogEntryHighlights(response.data);
},
},
- [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms]
+ [sourceId, startTimestamp, endTimestamp, centerPoint, size, filterQuery, highlightTerms]
);
useEffect(() => {
@@ -71,24 +56,31 @@ export const useLogEntryHighlights = (
useEffect(() => {
if (
highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length &&
- startKey &&
- endKey
+ startTimestamp &&
+ endTimestamp
) {
loadLogEntryHighlights();
} else {
setLogEntryHighlights([]);
}
- }, [endKey, filterQuery, highlightTerms, loadLogEntryHighlights, sourceVersion, startKey]);
+ }, [
+ endTimestamp,
+ filterQuery,
+ highlightTerms,
+ loadLogEntryHighlights,
+ sourceVersion,
+ startTimestamp,
+ ]);
const logEntryHighlightsById = useMemo(
() =>
- logEntryHighlights.reduce(
- (accumulatedLogEntryHighlightsById, { entries }) => {
- return entries.reduce((singleHighlightLogEntriesById, entry) => {
- const highlightsForId = singleHighlightLogEntriesById[entry.gid] || [];
+ logEntryHighlights.reduce>(
+ (accumulatedLogEntryHighlightsById, highlightData) => {
+ return highlightData.entries.reduce((singleHighlightLogEntriesById, entry) => {
+ const highlightsForId = singleHighlightLogEntriesById[entry.id] || [];
return {
...singleHighlightLogEntriesById,
- [entry.gid]: [...highlightsForId, entry],
+ [entry.id]: [...highlightsForId, entry],
};
}, accumulatedLogEntryHighlightsById);
},
diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx
index a4a94851ad383..941e89848131b 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx
@@ -6,39 +6,38 @@
import createContainer from 'constate';
import { useState, useContext } from 'react';
+import { useThrottle } from 'react-use';
import { useLogEntryHighlights } from './log_entry_highlights';
import { useLogSummaryHighlights } from './log_summary_highlights';
import { useNextAndPrevious } from './next_and_previous';
-import { useLogSummaryBufferInterval } from '../log_summary';
-import { LogViewConfiguration } from '../log_view_configuration';
import { LogPositionState } from '../log_position';
import { TimeKey } from '../../../../common/time';
+const FETCH_THROTTLE_INTERVAL = 3000;
+
+interface UseLogHighlightsStateProps {
+ sourceId: string;
+ sourceVersion: string | undefined;
+ centerCursor: TimeKey | null;
+ size: number;
+ filterQuery: string | null;
+}
+
export const useLogHighlightsState = ({
sourceId,
sourceVersion,
- entriesStart,
- entriesEnd,
+ centerCursor,
+ size,
filterQuery,
-}: {
- sourceId: string;
- sourceVersion: string | undefined;
- entriesStart: TimeKey | null;
- entriesEnd: TimeKey | null;
- filterQuery: string | null;
-}) => {
+}: UseLogHighlightsStateProps) => {
const [highlightTerms, setHighlightTerms] = useState([]);
- const { visibleMidpoint, jumpToTargetPosition } = useContext(LogPositionState.Context);
- const { intervalSize: summaryIntervalSize } = useContext(LogViewConfiguration.Context);
- const {
- start: summaryStart,
- end: summaryEnd,
- bucketSize: summaryBucketSize,
- } = useLogSummaryBufferInterval(
- visibleMidpoint ? visibleMidpoint.time : null,
- summaryIntervalSize
+ const { visibleMidpoint, jumpToTargetPosition, startTimestamp, endTimestamp } = useContext(
+ LogPositionState.Context
);
+ const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL);
+ const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL);
+
const {
logEntryHighlights,
logEntryHighlightsById,
@@ -46,8 +45,10 @@ export const useLogHighlightsState = ({
} = useLogEntryHighlights(
sourceId,
sourceVersion,
- entriesStart,
- entriesEnd,
+ throttledStartTimestamp,
+ throttledEndTimestamp,
+ centerCursor,
+ size,
filterQuery,
highlightTerms
);
@@ -55,9 +56,8 @@ export const useLogHighlightsState = ({
const { logSummaryHighlights, loadLogSummaryHighlightsRequest } = useLogSummaryHighlights(
sourceId,
sourceVersion,
- summaryStart,
- summaryEnd,
- summaryBucketSize,
+ throttledStartTimestamp,
+ throttledEndTimestamp,
filterQuery,
highlightTerms
);
diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts
index 81639aba411ef..41ee63bf0e23d 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts
@@ -10,13 +10,13 @@ import { debounce } from 'lodash';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
import { fetchLogSummaryHighlights } from './api/fetch_log_summary_highlights';
import { LogEntriesSummaryHighlightsResponse } from '../../../../common/http_api';
+import { useBucketSize } from '../log_summary/bucket_size';
export const useLogSummaryHighlights = (
sourceId: string,
sourceVersion: string | undefined,
- start: number | null,
- end: number | null,
- bucketSize: number,
+ startTimestamp: number | null,
+ endTimestamp: number | null,
filterQuery: string | null,
highlightTerms: string[]
) => {
@@ -24,18 +24,20 @@ export const useLogSummaryHighlights = (
LogEntriesSummaryHighlightsResponse['data']
>([]);
+ const bucketSize = useBucketSize(startTimestamp, endTimestamp);
+
const [loadLogSummaryHighlightsRequest, loadLogSummaryHighlights] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async () => {
- if (!start || !end || !highlightTerms.length) {
+ if (!startTimestamp || !endTimestamp || !bucketSize || !highlightTerms.length) {
throw new Error('Skipping request: Insufficient parameters');
}
return await fetchLogSummaryHighlights({
sourceId,
- startDate: start,
- endDate: end,
+ startTimestamp,
+ endTimestamp,
bucketSize,
query: filterQuery,
highlightTerms,
@@ -45,7 +47,7 @@ export const useLogSummaryHighlights = (
setLogSummaryHighlights(response.data);
},
},
- [sourceId, start, end, bucketSize, filterQuery, highlightTerms]
+ [sourceId, startTimestamp, endTimestamp, bucketSize, filterQuery, highlightTerms]
);
const debouncedLoadSummaryHighlights = useMemo(() => debounce(loadLogSummaryHighlights, 275), [
@@ -57,7 +59,11 @@ export const useLogSummaryHighlights = (
}, [highlightTerms]);
useEffect(() => {
- if (highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && start && end) {
+ if (
+ highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length &&
+ startTimestamp &&
+ endTimestamp
+ ) {
debouncedLoadSummaryHighlights();
} else {
setLogSummaryHighlights([]);
@@ -65,11 +71,11 @@ export const useLogSummaryHighlights = (
}, [
bucketSize,
debouncedLoadSummaryHighlights,
- end,
filterQuery,
highlightTerms,
sourceVersion,
- start,
+ startTimestamp,
+ endTimestamp,
]);
return {
diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx
index 7557550883f11..689c30a52b597 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx
@@ -13,7 +13,7 @@ import {
getLogEntryIndexBeforeTime,
getUniqueLogEntryKey,
} from '../../../utils/log_entry';
-import { LogEntryHighlights } from './log_entry_highlights';
+import { LogEntriesHighlightsResponse } from '../../../../common/http_api';
export const useNextAndPrevious = ({
highlightTerms,
@@ -23,7 +23,7 @@ export const useNextAndPrevious = ({
}: {
highlightTerms: string[];
jumpToTargetPosition: (target: TimeKey) => void;
- logEntryHighlights: LogEntryHighlights | undefined;
+ logEntryHighlights: LogEntriesHighlightsResponse['data'] | undefined;
visibleMidpoint: TimeKey | null;
}) => {
const [currentTimeKey, setCurrentTimeKey] = useState(null);
diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts
index 1a8274024bd26..5ac34e5df70ec 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts
@@ -6,10 +6,20 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import createContainer from 'constate';
+import { useSetState } from 'react-use';
import { TimeKey } from '../../../../common/time';
+import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath';
type TimeKeyOrNull = TimeKey | null;
+interface DateRange {
+ startDateExpression: string;
+ endDateExpression: string;
+ startTimestamp: number;
+ endTimestamp: number;
+ timestampsLastUpdate: number;
+}
+
interface VisiblePositions {
startKey: TimeKeyOrNull;
middleKey: TimeKeyOrNull;
@@ -19,24 +29,35 @@ interface VisiblePositions {
}
export interface LogPositionStateParams {
+ isInitialized: boolean;
targetPosition: TimeKeyOrNull;
- isAutoReloading: boolean;
+ isStreaming: boolean;
firstVisiblePosition: TimeKeyOrNull;
pagesBeforeStart: number;
pagesAfterEnd: number;
visibleMidpoint: TimeKeyOrNull;
visibleMidpointTime: number | null;
visibleTimeInterval: { start: number; end: number } | null;
+ startDateExpression: string;
+ endDateExpression: string;
+ startTimestamp: number | null;
+ endTimestamp: number | null;
+ timestampsLastUpdate: number;
}
export interface LogPositionCallbacks {
+ initialize: () => void;
jumpToTargetPosition: (pos: TimeKeyOrNull) => void;
jumpToTargetPositionTime: (time: number) => void;
reportVisiblePositions: (visPos: VisiblePositions) => void;
startLiveStreaming: () => void;
stopLiveStreaming: () => void;
+ updateDateRange: (newDateRage: Partial) => void;
}
+const DEFAULT_DATE_RANGE = { startDateExpression: 'now-1d', endDateExpression: 'now' };
+const DESIRED_BUFFER_PAGES = 2;
+
const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => {
// Of the two dependencies `middleKey` and `targetPosition`, return
// whichever one was the most recently updated. This allows the UI controls
@@ -60,8 +81,18 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN
};
export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => {
+ // Flag to determine if `LogPositionState` has been fully initialized.
+ //
+ // When the page loads, there might be initial state in the URL. We want to
+ // prevent the entries from showing until we have processed that initial
+ // state. That prevents double fetching.
+ const [isInitialized, setInitialized] = useState(false);
+ const initialize = useCallback(() => {
+ setInitialized(true);
+ }, [setInitialized]);
+
const [targetPosition, jumpToTargetPosition] = useState(null);
- const [isAutoReloading, setIsAutoReloading] = useState(false);
+ const [isStreaming, setIsStreaming] = useState(false);
const [visiblePositions, reportVisiblePositions] = useState({
endKey: null,
middleKey: null,
@@ -70,6 +101,15 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall
pagesAfterEnd: Infinity,
});
+ // We group the `startDate` and `endDate` values in the same object to be able
+ // to set both at the same time, saving a re-render
+ const [dateRange, setDateRange] = useSetState({
+ ...DEFAULT_DATE_RANGE,
+ startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!,
+ endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!,
+ timestampsLastUpdate: Date.now(),
+ });
+
const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions;
const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition);
@@ -79,26 +119,87 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall
[startKey, endKey]
);
+ // Allow setting `startDate` and `endDate` separately, or together
+ const updateDateRange = useCallback(
+ (newDateRange: Partial) => {
+ // Prevent unnecessary re-renders
+ if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) {
+ return;
+ }
+
+ const nextStartDateExpression =
+ newDateRange.startDateExpression || dateRange.startDateExpression;
+ const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression;
+
+ if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) {
+ return;
+ }
+
+ // Dates are valid, so the function cannot return `null`
+ const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!;
+ const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!;
+
+ // Reset the target position if it doesn't fall within the new range.
+ if (
+ targetPosition &&
+ (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time)
+ ) {
+ jumpToTargetPosition(null);
+ }
+
+ setDateRange({
+ ...newDateRange,
+ startTimestamp: nextStartTimestamp,
+ endTimestamp: nextEndTimestamp,
+ timestampsLastUpdate: Date.now(),
+ });
+ },
+ [setDateRange, dateRange, targetPosition]
+ );
+
+ // `endTimestamp` update conditions
+ useEffect(() => {
+ if (dateRange.endDateExpression !== 'now') {
+ return;
+ }
+
+ // User is close to the bottom edge of the scroll.
+ if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) {
+ setDateRange({
+ endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!,
+ timestampsLastUpdate: Date.now(),
+ });
+ }
+ }, [dateRange.endDateExpression, visiblePositions, setDateRange]);
+
const state = {
+ isInitialized,
targetPosition,
- isAutoReloading,
+ isStreaming,
firstVisiblePosition: startKey,
pagesBeforeStart,
pagesAfterEnd,
visibleMidpoint,
visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null,
visibleTimeInterval,
+ ...dateRange,
};
const callbacks = {
+ initialize,
jumpToTargetPosition,
jumpToTargetPositionTime: useCallback(
(time: number) => jumpToTargetPosition({ tiebreaker: 0, time }),
[jumpToTargetPosition]
),
reportVisiblePositions,
- startLiveStreaming: useCallback(() => setIsAutoReloading(true), [setIsAutoReloading]),
- stopLiveStreaming: useCallback(() => setIsAutoReloading(false), [setIsAutoReloading]),
+ startLiveStreaming: useCallback(() => {
+ setIsStreaming(true);
+ jumpToTargetPosition(null);
+ updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' });
+ }, [setIsStreaming, updateDateRange]),
+ stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]),
+ updateDateRange,
};
return { ...state, ...callbacks };
diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx
index 221dac95ef5f0..0d3586f9376f3 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx
@@ -9,31 +9,40 @@ import React, { useContext, useMemo } from 'react';
import { pickTimeKey } from '../../../../common/time';
import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state';
import { LogPositionState, LogPositionStateParams } from './log_position_state';
+import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath';
/**
* Url State
*/
-
-interface LogPositionUrlState {
- position: LogPositionStateParams['visibleMidpoint'] | undefined;
+export interface LogPositionUrlState {
+ position?: LogPositionStateParams['visibleMidpoint'];
streamLive: boolean;
+ start?: string;
+ end?: string;
}
+const ONE_HOUR = 3600000;
+
export const WithLogPositionUrlState = () => {
const {
visibleMidpoint,
- isAutoReloading,
+ isStreaming,
jumpToTargetPosition,
- jumpToTargetPositionTime,
startLiveStreaming,
stopLiveStreaming,
+ startDateExpression,
+ endDateExpression,
+ updateDateRange,
+ initialize,
} = useContext(LogPositionState.Context);
const urlState = useMemo(
() => ({
position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null,
- streamLive: isAutoReloading,
+ streamLive: isStreaming,
+ start: startDateExpression,
+ end: endDateExpression,
}),
- [visibleMidpoint, isAutoReloading]
+ [visibleMidpoint, isStreaming, startDateExpression, endDateExpression]
);
return (
{
urlStateKey="logPosition"
mapToUrlState={mapToUrlState}
onChange={(newUrlState: LogPositionUrlState | undefined) => {
- if (newUrlState && newUrlState.position) {
+ if (!newUrlState) {
+ return;
+ }
+
+ if (newUrlState.start || newUrlState.end) {
+ updateDateRange({
+ startDateExpression: newUrlState.start,
+ endDateExpression: newUrlState.end,
+ });
+ }
+
+ if (newUrlState.position) {
jumpToTargetPosition(newUrlState.position);
}
- if (newUrlState && newUrlState.streamLive) {
+
+ if (newUrlState.streamLive) {
startLiveStreaming();
- } else if (
- newUrlState &&
- typeof newUrlState.streamLive !== 'undefined' &&
- !newUrlState.streamLive
- ) {
+ } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) {
stopLiveStreaming();
}
}}
onInitialize={(initialUrlState: LogPositionUrlState | undefined) => {
- if (initialUrlState && initialUrlState.position) {
- jumpToTargetPosition(initialUrlState.position);
- } else {
- jumpToTargetPositionTime(Date.now());
- }
- if (initialUrlState && initialUrlState.streamLive) {
- startLiveStreaming();
+ if (initialUrlState) {
+ const initialPosition = initialUrlState.position;
+ let initialStartDateExpression = initialUrlState.start;
+ let initialEndDateExpression = initialUrlState.end;
+
+ if (!initialPosition) {
+ initialStartDateExpression = initialStartDateExpression || 'now-1d';
+ initialEndDateExpression = initialEndDateExpression || 'now';
+ } else {
+ const initialStartTimestamp = initialStartDateExpression
+ ? datemathToEpochMillis(initialStartDateExpression)
+ : undefined;
+ const initialEndTimestamp = initialEndDateExpression
+ ? datemathToEpochMillis(initialEndDateExpression, 'up')
+ : undefined;
+
+ // Adjust the start-end range if the target position falls outside or if it's not set.
+ if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) {
+ initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString();
+ }
+
+ if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) {
+ initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString();
+ }
+
+ jumpToTargetPosition(initialPosition);
+ }
+
+ if (initialStartDateExpression || initialEndDateExpression) {
+ updateDateRange({
+ startDateExpression: initialStartDateExpression,
+ endDateExpression: initialEndDateExpression,
+ });
+ }
+
+ if (initialUrlState.streamLive) {
+ startLiveStreaming();
+ }
}
+
+ initialize();
}}
/>
);
@@ -73,6 +123,8 @@ const mapToUrlState = (value: any): LogPositionUrlState | undefined =>
? {
position: mapToPositionUrlState(value.position),
streamLive: mapToStreamLiveUrlState(value.streamLive),
+ start: mapToDate(value.start),
+ end: mapToDate(value.end),
}
: undefined;
@@ -83,6 +135,7 @@ const mapToPositionUrlState = (value: any) =>
const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false);
+const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined);
export const replaceLogPositionInQueryString = (time: number) =>
Number.isNaN(time)
? (value: string) => value
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts
new file mode 100644
index 0000000000000..e46b304156f83
--- /dev/null
+++ b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useMemo } from 'react';
+
+const SUMMARY_BUCKET_COUNT = 100;
+
+export function useBucketSize(
+ startTimestamp: number | null,
+ endTimestamp: number | null
+): number | null {
+ const bucketSize = useMemo(() => {
+ if (!startTimestamp || !endTimestamp) {
+ return null;
+ }
+ return (endTimestamp - startTimestamp) / SUMMARY_BUCKET_COUNT;
+ }, [startTimestamp, endTimestamp]);
+
+ return bucketSize;
+}
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts
index 20c4267000a25..dc0437fa75a31 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts
@@ -5,5 +5,4 @@
*/
export * from './log_summary';
-export * from './use_log_summary_buffer_interval';
export * from './with_summary';
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx
index 2bbcc22b150e4..73d0e5efdf06b 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx
@@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks';
import { useLogSummary } from './log_summary';
import { fetchLogSummary } from './api/fetch_log_summary';
+import { datemathToEpochMillis } from '../../../utils/datemath';
// Typescript doesn't know that `fetchLogSummary` is a jest mock.
// We use a second variable with a type cast to help the compiler further down the line.
@@ -21,20 +22,26 @@ describe('useLogSummary hook', () => {
});
it('provides an empty list of buckets by default', () => {
- const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, 1000, null));
+ const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, null, null));
expect(result.current.buckets).toEqual([]);
});
it('queries for new summary buckets when the source id changes', async () => {
- const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]);
- const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]);
+ const { startTimestamp, endTimestamp } = createMockDateRange();
+
+ const firstMockResponse = createMockResponse([
+ { start: startTimestamp, end: endTimestamp, entriesCount: 1 },
+ ]);
+ const secondMockResponse = createMockResponse([
+ { start: startTimestamp, end: endTimestamp, entriesCount: 2 },
+ ]);
fetchLogSummaryMock
.mockResolvedValueOnce(firstMockResponse)
.mockResolvedValueOnce(secondMockResponse);
const { result, waitForNextUpdate, rerender } = renderHook(
- ({ sourceId }) => useLogSummary(sourceId, 100000, 1000, null),
+ ({ sourceId }) => useLogSummary(sourceId, startTimestamp, endTimestamp, null),
{
initialProps: { sourceId: 'INITIAL_SOURCE_ID' },
}
@@ -63,15 +70,21 @@ describe('useLogSummary hook', () => {
});
it('queries for new summary buckets when the filter query changes', async () => {
- const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]);
- const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]);
+ const { startTimestamp, endTimestamp } = createMockDateRange();
+
+ const firstMockResponse = createMockResponse([
+ { start: startTimestamp, end: endTimestamp, entriesCount: 1 },
+ ]);
+ const secondMockResponse = createMockResponse([
+ { start: startTimestamp, end: endTimestamp, entriesCount: 2 },
+ ]);
fetchLogSummaryMock
.mockResolvedValueOnce(firstMockResponse)
.mockResolvedValueOnce(secondMockResponse);
const { result, waitForNextUpdate, rerender } = renderHook(
- ({ filterQuery }) => useLogSummary('SOURCE_ID', 100000, 1000, filterQuery),
+ ({ filterQuery }) => useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, filterQuery),
{
initialProps: { filterQuery: 'INITIAL_FILTER_QUERY' },
}
@@ -99,15 +112,17 @@ describe('useLogSummary hook', () => {
expect(result.current.buckets).toEqual(secondMockResponse.data.buckets);
});
- it('queries for new summary buckets when the midpoint time changes', async () => {
+ it('queries for new summary buckets when the start and end date changes', async () => {
fetchLogSummaryMock
.mockResolvedValueOnce(createMockResponse([]))
.mockResolvedValueOnce(createMockResponse([]));
+ const firstRange = createMockDateRange();
const { waitForNextUpdate, rerender } = renderHook(
- ({ midpointTime }) => useLogSummary('SOURCE_ID', midpointTime, 1000, null),
+ ({ startTimestamp, endTimestamp }) =>
+ useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, null),
{
- initialProps: { midpointTime: 100000 },
+ initialProps: firstRange,
}
);
@@ -115,54 +130,21 @@ describe('useLogSummary hook', () => {
expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1);
expect(fetchLogSummaryMock).toHaveBeenLastCalledWith(
expect.objectContaining({
- startDate: 98500,
- endDate: 101500,
- })
- );
-
- rerender({ midpointTime: 200000 });
- await waitForNextUpdate();
-
- expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2);
- expect(fetchLogSummaryMock).toHaveBeenLastCalledWith(
- expect.objectContaining({
- startDate: 198500,
- endDate: 201500,
+ startTimestamp: firstRange.startTimestamp,
+ endTimestamp: firstRange.endTimestamp,
})
);
- });
- it('queries for new summary buckets when the interval size changes', async () => {
- fetchLogSummaryMock
- .mockResolvedValueOnce(createMockResponse([]))
- .mockResolvedValueOnce(createMockResponse([]));
-
- const { waitForNextUpdate, rerender } = renderHook(
- ({ intervalSize }) => useLogSummary('SOURCE_ID', 100000, intervalSize, null),
- {
- initialProps: { intervalSize: 1000 },
- }
- );
+ const secondRange = createMockDateRange('now-20s', 'now');
- await waitForNextUpdate();
- expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1);
- expect(fetchLogSummaryMock).toHaveBeenLastCalledWith(
- expect.objectContaining({
- bucketSize: 10,
- startDate: 98500,
- endDate: 101500,
- })
- );
-
- rerender({ intervalSize: 2000 });
+ rerender(secondRange);
await waitForNextUpdate();
expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2);
expect(fetchLogSummaryMock).toHaveBeenLastCalledWith(
expect.objectContaining({
- bucketSize: 20,
- startDate: 97000,
- endDate: 103000,
+ startTimestamp: secondRange.startTimestamp,
+ endTimestamp: secondRange.endTimestamp,
})
);
});
@@ -171,3 +153,12 @@ describe('useLogSummary hook', () => {
const createMockResponse = (
buckets: Array<{ start: number; end: number; entriesCount: number }>
) => ({ data: { buckets, start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY } });
+
+const createMockDateRange = (startDate = 'now-10s', endDate = 'now') => {
+ return {
+ startDate,
+ endDate,
+ startTimestamp: datemathToEpochMillis(startDate)!,
+ endTimestamp: datemathToEpochMillis(endDate, 'up')!,
+ };
+};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx
index c39b7075af325..94723125cc0ec 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx
@@ -7,34 +7,31 @@
import { useState } from 'react';
import { useCancellableEffect } from '../../../utils/cancellable_effect';
-import { useLogSummaryBufferInterval } from './use_log_summary_buffer_interval';
import { fetchLogSummary } from './api/fetch_log_summary';
import { LogEntriesSummaryResponse } from '../../../../common/http_api';
+import { useBucketSize } from './bucket_size';
export type LogSummaryBuckets = LogEntriesSummaryResponse['data']['buckets'];
export const useLogSummary = (
sourceId: string,
- midpointTime: number | null,
- intervalSize: number,
+ startTimestamp: number | null,
+ endTimestamp: number | null,
filterQuery: string | null
) => {
const [logSummaryBuckets, setLogSummaryBuckets] = useState([]);
- const { start: bufferStart, end: bufferEnd, bucketSize } = useLogSummaryBufferInterval(
- midpointTime,
- intervalSize
- );
+ const bucketSize = useBucketSize(startTimestamp, endTimestamp);
useCancellableEffect(
getIsCancelled => {
- if (bufferStart === null || bufferEnd === null) {
+ if (startTimestamp === null || endTimestamp === null || bucketSize === null) {
return;
}
fetchLogSummary({
sourceId,
- startDate: bufferStart,
- endDate: bufferEnd,
+ startTimestamp,
+ endTimestamp,
bucketSize,
query: filterQuery,
}).then(response => {
@@ -43,12 +40,12 @@ export const useLogSummary = (
}
});
},
- [sourceId, filterQuery, bufferStart, bufferEnd, bucketSize]
+ [sourceId, filterQuery, startTimestamp, endTimestamp, bucketSize]
);
return {
buckets: logSummaryBuckets,
- start: bufferStart,
- end: bufferEnd,
+ start: startTimestamp,
+ end: endTimestamp,
};
};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts
deleted file mode 100644
index 27af76b70f47a..0000000000000
--- a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts
+++ /dev/null
@@ -1,30 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { useMemo } from 'react';
-
-const LOAD_BUCKETS_PER_PAGE = 100;
-const UNKNOWN_BUFFER_INTERVAL = {
- start: null,
- end: null,
- bucketSize: 0,
-};
-
-export const useLogSummaryBufferInterval = (midpointTime: number | null, intervalSize: number) => {
- return useMemo(() => {
- if (midpointTime === null || intervalSize <= 0) {
- return UNKNOWN_BUFFER_INTERVAL;
- }
-
- const halfIntervalSize = intervalSize / 2;
-
- return {
- start: (Math.floor((midpointTime - halfIntervalSize) / intervalSize) - 0.5) * intervalSize,
- end: (Math.ceil((midpointTime + halfIntervalSize) / intervalSize) + 0.5) * intervalSize,
- bucketSize: intervalSize / LOAD_BUCKETS_PER_PAGE,
- };
- }, [midpointTime, intervalSize]);
-};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts
index 4db0d2e645448..14da2b47bcfa2 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts
@@ -5,14 +5,16 @@
*/
import { useContext } from 'react';
+import { useThrottle } from 'react-use';
import { RendererFunction } from '../../../utils/typed_react';
import { Source } from '../../source';
-import { LogViewConfiguration } from '../log_view_configuration';
import { LogSummaryBuckets, useLogSummary } from './log_summary';
import { LogFilterState } from '../log_filter';
import { LogPositionState } from '../log_position';
+const FETCH_THROTTLE_INTERVAL = 3000;
+
export const WithSummary = ({
children,
}: {
@@ -22,15 +24,18 @@ export const WithSummary = ({
end: number | null;
}>;
}) => {
- const { intervalSize } = useContext(LogViewConfiguration.Context);
const { sourceId } = useContext(Source.Context);
const { filterQuery } = useContext(LogFilterState.Context);
- const { visibleMidpointTime } = useContext(LogPositionState.Context);
+ const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context);
+
+ // Keep it reasonably updated for the `now` case, but don't reload all the time when the user scrolls
+ const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL);
+ const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL);
const { buckets, start, end } = useLogSummary(
sourceId,
- visibleMidpointTime,
- intervalSize,
+ throttledStartTimestamp,
+ throttledEndTimestamp,
filterQuery
);
diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx
index b6de1230d9a59..5954cb834a11d 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx
@@ -45,35 +45,10 @@ describe('useLogViewConfiguration hook', () => {
});
});
- describe('intervalSize state', () => {
- it('has a default value', () => {
- const { getLastHookValue } = mountHook(() => useLogViewConfiguration().intervalSize);
-
- expect(getLastHookValue()).toEqual(86400000);
- });
-
- it('can be updated', () => {
- const { act, getLastHookValue } = mountHook(() => useLogViewConfiguration());
-
- act(({ setIntervalSize }) => {
- setIntervalSize(90000000);
- });
-
- expect(getLastHookValue().intervalSize).toEqual(90000000);
- });
- });
-
it('provides the available text scales', () => {
const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableTextScales);
expect(getLastHookValue()).toEqual(expect.any(Array));
expect(getLastHookValue().length).toBeGreaterThan(0);
});
-
- it('provides the available interval sizes', () => {
- const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableIntervalSizes);
-
- expect(getLastHookValue()).toEqual(expect.any(Array));
- expect(getLastHookValue().length).toBeGreaterThan(0);
- });
});
diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx
index 8837078aa4a0d..e1351ad0b17ad 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { i18n } from '@kbn/i18n';
import createContainer from 'constate';
import { useState } from 'react';
@@ -17,18 +16,12 @@ export const useLogViewConfiguration = () => {
// text wrap
const [textWrap, setTextWrap] = useState(true);
- // minimap interval
- const [intervalSize, setIntervalSize] = useState(1000 * 60 * 60 * 24);
-
return {
- availableIntervalSizes,
availableTextScales,
setTextScale,
setTextWrap,
textScale,
textWrap,
- intervalSize,
- setIntervalSize,
};
};
@@ -39,42 +32,3 @@ export const LogViewConfiguration = createContainer(useLogViewConfiguration);
*/
export const availableTextScales: TextScale[] = ['large', 'medium', 'small'];
-
-export const availableIntervalSizes = [
- {
- label: i18n.translate('xpack.infra.mapLogs.oneYearLabel', {
- defaultMessage: '1 Year',
- }),
- intervalSize: 1000 * 60 * 60 * 24 * 365,
- },
- {
- label: i18n.translate('xpack.infra.mapLogs.oneMonthLabel', {
- defaultMessage: '1 Month',
- }),
- intervalSize: 1000 * 60 * 60 * 24 * 30,
- },
- {
- label: i18n.translate('xpack.infra.mapLogs.oneWeekLabel', {
- defaultMessage: '1 Week',
- }),
- intervalSize: 1000 * 60 * 60 * 24 * 7,
- },
- {
- label: i18n.translate('xpack.infra.mapLogs.oneDayLabel', {
- defaultMessage: '1 Day',
- }),
- intervalSize: 1000 * 60 * 60 * 24,
- },
- {
- label: i18n.translate('xpack.infra.mapLogs.oneHourLabel', {
- defaultMessage: '1 Hour',
- }),
- intervalSize: 1000 * 60 * 60,
- },
- {
- label: i18n.translate('xpack.infra.mapLogs.oneMinuteLabel', {
- defaultMessage: '1 Minute',
- }),
- intervalSize: 1000 * 60,
- },
-];
diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx
deleted file mode 100644
index 3f2b4d7cc16f9..0000000000000
--- a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx
+++ /dev/null
@@ -1,52 +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;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import React, { useContext, useMemo } from 'react';
-
-import { UrlStateContainer } from '../../utils/url_state';
-import { LogViewConfiguration } from './log_view_configuration';
-
-/**
- * Url State
- */
-
-interface LogMinimapUrlState {
- intervalSize?: number;
-}
-
-export const WithLogMinimapUrlState = () => {
- const { intervalSize, setIntervalSize } = useContext(LogViewConfiguration.Context);
-
- const urlState = useMemo(() => ({ intervalSize }), [intervalSize]);
-
- return (
- {
- if (newUrlState && newUrlState.intervalSize) {
- setIntervalSize(newUrlState.intervalSize);
- }
- }}
- onInitialize={newUrlState => {
- if (newUrlState && newUrlState.intervalSize) {
- setIntervalSize(newUrlState.intervalSize);
- }
- }}
- />
- );
-};
-
-const mapToUrlState = (value: any): LogMinimapUrlState | undefined =>
- value
- ? {
- intervalSize: mapToIntervalSizeUrlState(value.intervalSize),
- }
- : undefined;
-
-const mapToIntervalSizeUrlState = (value: any) =>
- value && typeof value === 'number' ? value : undefined;
diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts
index 6da9cd7513cba..5c0e245448ce5 100644
--- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts
+++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts
@@ -6,12 +6,12 @@
import { useContext, useMemo } from 'react';
import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item';
-import { LogEntry, LogEntryHighlight } from '../../utils/log_entry';
import { RendererFunction } from '../../utils/typed_react';
// deep inporting to avoid a circular import problem
import { LogHighlightsState } from './log_highlights/log_highlights';
import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries';
import { UniqueTimeKey } from '../../../common/time';
+import { LogEntry } from '../../../common/http_api';
export const WithStreamItems: React.FunctionComponent<{
children: RendererFunction<
@@ -30,7 +30,7 @@ export const WithStreamItems: React.FunctionComponent<{
logEntries.isReloading
? []
: logEntries.entries.map(logEntry =>
- createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || [])
+ createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || [])
),
[logEntries.entries, logEntries.isReloading, logEntryHighlightsById]
@@ -46,7 +46,7 @@ export const WithStreamItems: React.FunctionComponent<{
const createLogEntryStreamItem = (
logEntry: LogEntry,
- highlights: LogEntryHighlight[]
+ highlights: LogEntry[]
): LogEntryStreamItem => ({
kind: 'logEntry' as 'logEntry',
logEntry,
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx
index 54609bcf8e2c2..023082154565c 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx
@@ -44,11 +44,8 @@ export const CategoryExampleMessage: React.FunctionComponent<{