- {error.stack && (
+ {text && (
- {error.stack}
+ {text}
)}
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 303d005197588..c811209dfa80f 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -1282,7 +1282,7 @@ export interface SavedObjectsCreateOptions {
}
// @public (undocumented)
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
// (undocumented)
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
@@ -1294,6 +1294,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
id: string;
};
// (undocumented)
+ namespaces?: string[];
+ // (undocumented)
page?: number;
// (undocumented)
perPage?: number;
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts
index c4daaf5d7f307..209f489e29139 100644
--- a/src/core/public/saved_objects/saved_objects_client.ts
+++ b/src/core/public/saved_objects/saved_objects_client.ts
@@ -294,6 +294,7 @@ export class SavedObjectsClient {
sortField: 'sort_field',
type: 'type',
filter: 'filter',
+ namespaces: 'namespaces',
preference: 'preference',
};
diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts
index 2f0f80728c707..fd57d06e61eee 100644
--- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts
+++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts
@@ -130,7 +130,7 @@ describe('#callAsInternalUser', () => {
expect(mockEsClientInstance.security.authenticate).toHaveBeenLastCalledWith(mockParams);
});
- test('does not wrap errors if `wrap401Errors` is not set', async () => {
+ test('does not wrap errors if `wrap401Errors` is set to `false`', async () => {
const mockError = { message: 'some error' };
mockEsClientInstance.ping.mockRejectedValue(mockError);
@@ -146,7 +146,7 @@ describe('#callAsInternalUser', () => {
).rejects.toBe(mockAuthenticationError);
});
- test('wraps only 401 errors by default or when `wrap401Errors` is set', async () => {
+ test('wraps 401 errors when `wrap401Errors` is set to `true` or unspecified', async () => {
const mockError = { message: 'some error' };
mockEsClientInstance.ping.mockRejectedValue(mockError);
diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts
index bbef0a105c089..7d37af833d4c1 100644
--- a/src/core/server/http/http_server.mocks.ts
+++ b/src/core/server/http/http_server.mocks.ts
@@ -33,7 +33,7 @@ import {
} from './router';
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
-import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';
+import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing';
interface RequestFixtureOptions {
auth?: { isAuthenticated: boolean };
@@ -161,7 +161,7 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked;
+type ToolkitMock = jest.Mocked;
const createToolkitMock = (): ToolkitMock => {
return {
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index 72cb0b2821c5c..601eba835a54e 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -1089,6 +1089,16 @@ describe('setup contract', () => {
});
});
+ describe('#registerOnPreRouting', () => {
+ test('does not throw if called after stop', async () => {
+ const { registerOnPreRouting } = await server.setup(config);
+ await server.stop();
+ expect(() => {
+ registerOnPreRouting((req, res) => res.unauthorized());
+ }).not.toThrow();
+ });
+ });
+
describe('#registerOnPreAuth', () => {
test('does not throw if called after stop', async () => {
const { registerOnPreAuth } = await server.setup(config);
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index 1abf5c0c133bb..9c16162d69334 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -24,8 +24,9 @@ import { Logger, LoggerFactory } from '../logging';
import { HttpConfig } from './http_config';
import { createServer, getListenerOptions, getServerOptions } from './http_tools';
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
+import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
-import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
+import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing';
import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response';
import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router';
import {
@@ -49,8 +50,9 @@ export interface HttpServerSetup {
basePath: HttpServiceSetup['basePath'];
csp: HttpServiceSetup['csp'];
createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory'];
- registerAuth: HttpServiceSetup['registerAuth'];
+ registerOnPreRouting: HttpServiceSetup['registerOnPreRouting'];
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
+ registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
registerOnPreResponse: HttpServiceSetup['registerOnPreResponse'];
getAuthHeaders: GetAuthHeaders;
@@ -64,7 +66,11 @@ export interface HttpServerSetup {
/** @internal */
export type LifecycleRegistrar = Pick<
HttpServerSetup,
- 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse'
+ | 'registerOnPreRouting'
+ | 'registerOnPreAuth'
+ | 'registerAuth'
+ | 'registerOnPostAuth'
+ | 'registerOnPreResponse'
>;
export class HttpServer {
@@ -113,12 +119,13 @@ export class HttpServer {
return {
registerRouter: this.registerRouter.bind(this),
registerStaticDir: this.registerStaticDir.bind(this),
+ registerOnPreRouting: this.registerOnPreRouting.bind(this),
registerOnPreAuth: this.registerOnPreAuth.bind(this),
+ registerAuth: this.registerAuth.bind(this),
registerOnPostAuth: this.registerOnPostAuth.bind(this),
registerOnPreResponse: this.registerOnPreResponse.bind(this),
createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) =>
this.createCookieSessionStorageFactory(cookieOptions, config.basePath),
- registerAuth: this.registerAuth.bind(this),
basePath: basePathService,
csp: config.csp,
auth: {
@@ -222,7 +229,7 @@ export class HttpServer {
return;
}
- this.registerOnPreAuth((request, response, toolkit) => {
+ this.registerOnPreRouting((request, response, toolkit) => {
const oldUrl = request.url.href!;
const newURL = basePathService.remove(oldUrl);
const shouldRedirect = newURL !== oldUrl;
@@ -263,6 +270,17 @@ export class HttpServer {
}
}
+ private registerOnPreAuth(fn: OnPreAuthHandler) {
+ if (this.server === undefined) {
+ throw new Error('Server is not created yet');
+ }
+ if (this.stopped) {
+ this.log.warn(`registerOnPreAuth called after stop`);
+ }
+
+ this.server.ext('onPreAuth', adoptToHapiOnPreAuth(fn, this.log));
+ }
+
private registerOnPostAuth(fn: OnPostAuthHandler) {
if (this.server === undefined) {
throw new Error('Server is not created yet');
@@ -274,15 +292,15 @@ export class HttpServer {
this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log));
}
- private registerOnPreAuth(fn: OnPreAuthHandler) {
+ private registerOnPreRouting(fn: OnPreRoutingHandler) {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
if (this.stopped) {
- this.log.warn(`registerOnPreAuth called after stop`);
+ this.log.warn(`registerOnPreRouting called after stop`);
}
- this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log));
+ this.server.ext('onRequest', adoptToHapiOnRequest(fn, this.log));
}
private registerOnPreResponse(fn: OnPreResponseHandler) {
diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts
index 5e7ee7b658eca..51f11b15f2e09 100644
--- a/src/core/server/http/http_service.mock.ts
+++ b/src/core/server/http/http_service.mock.ts
@@ -29,7 +29,7 @@ import {
} from './types';
import { HttpService } from './http_service';
import { AuthStatus } from './auth_state_storage';
-import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';
+import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing';
import { AuthToolkit } from './lifecycle/auth';
import { sessionStorageMock } from './cookie_session_storage.mocks';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
@@ -87,6 +87,7 @@ const createInternalSetupContractMock = () => {
config: jest.fn().mockReturnValue(configMock.create()),
} as unknown) as jest.MockedClass,
createCookieSessionStorageFactory: jest.fn(),
+ registerOnPreRouting: jest.fn(),
registerOnPreAuth: jest.fn(),
registerAuth: jest.fn(),
registerOnPostAuth: jest.fn(),
@@ -117,7 +118,8 @@ const createSetupContractMock = () => {
const mock: HttpServiceSetupMock = {
createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory,
- registerOnPreAuth: internalMock.registerOnPreAuth,
+ registerOnPreRouting: internalMock.registerOnPreRouting,
+ registerOnPreAuth: jest.fn(),
registerAuth: internalMock.registerAuth,
registerOnPostAuth: internalMock.registerOnPostAuth,
registerOnPreResponse: internalMock.registerOnPreResponse,
@@ -173,7 +175,7 @@ const createHttpServiceMock = () => {
return mocked;
};
-const createOnPreAuthToolkitMock = (): jest.Mocked => ({
+const createOnPreAuthToolkitMock = (): jest.Mocked => ({
next: jest.fn(),
rewriteUrl: jest.fn(),
});
diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts
index 65d633260a791..e91f7d9375842 100644
--- a/src/core/server/http/index.ts
+++ b/src/core/server/http/index.ts
@@ -64,7 +64,7 @@ export {
SafeRouteMethod,
} from './router';
export { BasePathProxyServer } from './base_path_proxy_server';
-export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
+export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing';
export {
AuthenticationHandler,
AuthHeaders,
@@ -78,6 +78,7 @@ export {
AuthResultType,
} from './lifecycle/auth';
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
+export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
export {
OnPreResponseHandler,
OnPreResponseToolkit,
diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts
index f7ebd18b9c488..c23724b7d332f 100644
--- a/src/core/server/http/integration_tests/core_service.test.mocks.ts
+++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts
@@ -19,10 +19,9 @@
import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock';
export const clusterClientMock = jest.fn();
+export const clusterClientInstanceMock = elasticsearchServiceMock.createLegacyScopedClusterClient();
jest.doMock('../../elasticsearch/legacy/scoped_cluster_client', () => ({
- LegacyScopedClusterClient: clusterClientMock.mockImplementation(function () {
- return elasticsearchServiceMock.createLegacyScopedClusterClient();
- }),
+ LegacyScopedClusterClient: clusterClientMock.mockImplementation(() => clusterClientInstanceMock),
}));
jest.doMock('elasticsearch', () => {
diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts
index ba39effa77016..3c5f22500e5e0 100644
--- a/src/core/server/http/integration_tests/core_services.test.ts
+++ b/src/core/server/http/integration_tests/core_services.test.ts
@@ -16,9 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
+
+import { clusterClientMock, clusterClientInstanceMock } from './core_service.test.mocks';
+
import Boom from 'boom';
import { Request } from 'hapi';
-import { clusterClientMock } from './core_service.test.mocks';
+import { errors as esErrors } from 'elasticsearch';
+import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
@@ -333,7 +337,7 @@ describe('http service', () => {
it('basePath information for an incoming request is available in legacy server', async () => {
const reqBasePath = '/requests-specific-base-path';
const { http } = await root.setup();
- http.registerOnPreAuth((req, res, toolkit) => {
+ http.registerOnPreRouting((req, res, toolkit) => {
http.basePath.set(req, reqBasePath);
return toolkit.next();
});
@@ -352,7 +356,7 @@ describe('http service', () => {
});
});
});
- describe('elasticsearch', () => {
+ describe('legacy elasticsearch client', () => {
let root: ReturnType;
beforeEach(async () => {
root = kbnTestServer.createRoot({ plugins: { initialize: false } });
@@ -410,5 +414,31 @@ describe('http service', () => {
const [, , clientHeaders] = client;
expect(clientHeaders).toEqual({ authorization: authorizationHeader });
});
+
+ it('forwards 401 errors returned from elasticsearch', async () => {
+ const { http } = await root.setup();
+ const { createRouter } = http;
+
+ const authenticationError = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(
+ new (esErrors.AuthenticationException as any)('Authentication Exception', {
+ body: { error: { header: { 'WWW-Authenticate': 'authenticate header' } } },
+ statusCode: 401,
+ })
+ );
+
+ clusterClientInstanceMock.callAsCurrentUser.mockRejectedValue(authenticationError);
+
+ const router = createRouter('/new-platform');
+ router.get({ path: '/', validate: false }, async (context, req, res) => {
+ await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping');
+ return res.ok();
+ });
+
+ await root.start();
+
+ const response = await kbnTestServer.request.get(root, '/new-platform/').expect(401);
+
+ expect(response.header['www-authenticate']).toEqual('authenticate header');
+ });
});
});
diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts
index cbab14115ba6b..b9548bf7a8d70 100644
--- a/src/core/server/http/integration_tests/lifecycle.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle.test.ts
@@ -57,20 +57,22 @@ interface StorageData {
expires: number;
}
-describe('OnPreAuth', () => {
+describe('OnPreRouting', () => {
it('supports registering a request interceptor', async () => {
- const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
const router = createRouter('/');
router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' }));
const callingOrder: string[] = [];
- registerOnPreAuth((req, res, t) => {
+ registerOnPreRouting((req, res, t) => {
callingOrder.push('first');
return t.next();
});
- registerOnPreAuth((req, res, t) => {
+ registerOnPreRouting((req, res, t) => {
callingOrder.push('second');
return t.next();
});
@@ -82,7 +84,9 @@ describe('OnPreAuth', () => {
});
it('supports request forwarding to specified url', async () => {
- const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
const router = createRouter('/');
router.get({ path: '/initial', validate: false }, (context, req, res) =>
@@ -93,13 +97,13 @@ describe('OnPreAuth', () => {
);
let urlBeforeForwarding;
- registerOnPreAuth((req, res, t) => {
+ registerOnPreRouting((req, res, t) => {
urlBeforeForwarding = ensureRawRequest(req).raw.req.url;
return t.rewriteUrl('/redirectUrl');
});
let urlAfterForwarding;
- registerOnPreAuth((req, res, t) => {
+ registerOnPreRouting((req, res, t) => {
// used by legacy platform
urlAfterForwarding = ensureRawRequest(req).raw.req.url;
return t.next();
@@ -113,6 +117,152 @@ describe('OnPreAuth', () => {
expect(urlAfterForwarding).toBe('/redirectUrl');
});
+ it('supports redirection from the interceptor', async () => {
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ const redirectUrl = '/redirectUrl';
+ router.get({ path: '/initial', validate: false }, (context, req, res) => res.ok());
+
+ registerOnPreRouting((req, res, t) =>
+ res.redirected({
+ headers: {
+ location: redirectUrl,
+ },
+ })
+ );
+ await server.start();
+
+ const result = await supertest(innerServer.listener).get('/initial').expect(302);
+
+ expect(result.header.location).toBe(redirectUrl);
+ });
+
+ it('supports rejecting request and adjusting response headers', async () => {
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
+
+ registerOnPreRouting((req, res, t) =>
+ res.unauthorized({
+ headers: {
+ 'www-authenticate': 'challenge',
+ },
+ })
+ );
+ await server.start();
+
+ const result = await supertest(innerServer.listener).get('/').expect(401);
+
+ expect(result.header['www-authenticate']).toBe('challenge');
+ });
+
+ it('does not expose error details if interceptor throws', async () => {
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
+
+ registerOnPreRouting((req, res, t) => {
+ throw new Error('reason');
+ });
+ await server.start();
+
+ const result = await supertest(innerServer.listener).get('/').expect(500);
+
+ expect(result.body.message).toBe('An internal server error occurred.');
+ expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ [Error: reason],
+ ],
+ ]
+ `);
+ });
+
+ it('returns internal error if interceptor returns unexpected result', async () => {
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok());
+
+ registerOnPreRouting((req, res, t) => ({} as any));
+ await server.start();
+
+ const result = await supertest(innerServer.listener).get('/').expect(500);
+
+ expect(result.body.message).toBe('An internal server error occurred.');
+ expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ [Error: Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: [object Object].],
+ ],
+ ]
+ `);
+ });
+
+ it(`doesn't share request object between interceptors`, async () => {
+ const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup(
+ setupDeps
+ );
+ const router = createRouter('/');
+
+ registerOnPreRouting((req, res, t) => {
+ // don't complain customField is not defined on Request type
+ (req as any).customField = { value: 42 };
+ return t.next();
+ });
+ registerOnPreRouting((req, res, t) => {
+ // don't complain customField is not defined on Request type
+ if (typeof (req as any).customField !== 'undefined') {
+ throw new Error('Request object was mutated');
+ }
+ return t.next();
+ });
+ router.get({ path: '/', validate: false }, (context, req, res) =>
+ // don't complain customField is not defined on Request type
+ res.ok({ body: { customField: String((req as any).customField) } })
+ );
+
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' });
+ });
+});
+
+describe('OnPreAuth', () => {
+ it('supports registering a request interceptor', async () => {
+ const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
+ const router = createRouter('/');
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' }));
+
+ const callingOrder: string[] = [];
+ registerOnPreAuth((req, res, t) => {
+ callingOrder.push('first');
+ return t.next();
+ });
+
+ registerOnPreAuth((req, res, t) => {
+ callingOrder.push('second');
+ return t.next();
+ });
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(200, 'ok');
+
+ expect(callingOrder).toEqual(['first', 'second']);
+ });
+
it('supports redirection from the interceptor', async () => {
const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
@@ -203,20 +353,20 @@ describe('OnPreAuth', () => {
const router = createRouter('/');
registerOnPreAuth((req, res, t) => {
- // don't complain customField is not defined on Request type
- (req as any).customField = { value: 42 };
+ // @ts-expect-error customField property is not defined on request object
+ req.customField = { value: 42 };
return t.next();
});
registerOnPreAuth((req, res, t) => {
- // don't complain customField is not defined on Request type
- if (typeof (req as any).customField !== 'undefined') {
+ // @ts-expect-error customField property is not defined on request object
+ if (typeof req.customField !== 'undefined') {
throw new Error('Request object was mutated');
}
return t.next();
});
router.get({ path: '/', validate: false }, (context, req, res) =>
- // don't complain customField is not defined on Request type
- res.ok({ body: { customField: String((req as any).customField) } })
+ // @ts-expect-error customField property is not defined on request object
+ res.ok({ body: { customField: String(req.customField) } })
);
await server.start();
@@ -664,7 +814,7 @@ describe('Auth', () => {
it.skip('is the only place with access to the authorization header', async () => {
const {
- registerOnPreAuth,
+ registerOnPreRouting,
registerAuth,
registerOnPostAuth,
server: innerServer,
@@ -672,9 +822,9 @@ describe('Auth', () => {
} = await server.setup(setupDeps);
const router = createRouter('/');
- let fromRegisterOnPreAuth;
- await registerOnPreAuth((req, res, toolkit) => {
- fromRegisterOnPreAuth = req.headers.authorization;
+ let fromregisterOnPreRouting;
+ await registerOnPreRouting((req, res, toolkit) => {
+ fromregisterOnPreRouting = req.headers.authorization;
return toolkit.next();
});
@@ -701,7 +851,7 @@ describe('Auth', () => {
const token = 'Basic: user:password';
await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200);
- expect(fromRegisterOnPreAuth).toEqual({});
+ expect(fromregisterOnPreRouting).toEqual({});
expect(fromRegisterAuth).toEqual({ authorization: token });
expect(fromRegisterOnPostAuth).toEqual({});
expect(fromRouteHandler).toEqual({});
@@ -1137,3 +1287,135 @@ describe('OnPreResponse', () => {
expect(requestBody).toStrictEqual({});
});
});
+
+describe('run interceptors in the right order', () => {
+ it('with Auth registered', async () => {
+ const {
+ registerOnPreRouting,
+ registerOnPreAuth,
+ registerAuth,
+ registerOnPostAuth,
+ registerOnPreResponse,
+ server: innerServer,
+ createRouter,
+ } = await server.setup(setupDeps);
+
+ const router = createRouter('/');
+
+ const executionOrder: string[] = [];
+ registerOnPreRouting((req, res, t) => {
+ executionOrder.push('onPreRouting');
+ return t.next();
+ });
+ registerOnPreAuth((req, res, t) => {
+ executionOrder.push('onPreAuth');
+ return t.next();
+ });
+ registerAuth((req, res, t) => {
+ executionOrder.push('auth');
+ return t.authenticated({});
+ });
+ registerOnPostAuth((req, res, t) => {
+ executionOrder.push('onPostAuth');
+ return t.next();
+ });
+ registerOnPreResponse((req, res, t) => {
+ executionOrder.push('onPreResponse');
+ return t.next();
+ });
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' }));
+
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(200);
+ expect(executionOrder).toEqual([
+ 'onPreRouting',
+ 'onPreAuth',
+ 'auth',
+ 'onPostAuth',
+ 'onPreResponse',
+ ]);
+ });
+
+ it('with no Auth registered', async () => {
+ const {
+ registerOnPreRouting,
+ registerOnPreAuth,
+ registerOnPostAuth,
+ registerOnPreResponse,
+ server: innerServer,
+ createRouter,
+ } = await server.setup(setupDeps);
+
+ const router = createRouter('/');
+
+ const executionOrder: string[] = [];
+ registerOnPreRouting((req, res, t) => {
+ executionOrder.push('onPreRouting');
+ return t.next();
+ });
+ registerOnPreAuth((req, res, t) => {
+ executionOrder.push('onPreAuth');
+ return t.next();
+ });
+ registerOnPostAuth((req, res, t) => {
+ executionOrder.push('onPostAuth');
+ return t.next();
+ });
+ registerOnPreResponse((req, res, t) => {
+ executionOrder.push('onPreResponse');
+ return t.next();
+ });
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' }));
+
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(200);
+ expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']);
+ });
+
+ it('when a user failed auth', async () => {
+ const {
+ registerOnPreRouting,
+ registerOnPreAuth,
+ registerOnPostAuth,
+ registerAuth,
+ registerOnPreResponse,
+ server: innerServer,
+ createRouter,
+ } = await server.setup(setupDeps);
+
+ const router = createRouter('/');
+
+ const executionOrder: string[] = [];
+ registerOnPreRouting((req, res, t) => {
+ executionOrder.push('onPreRouting');
+ return t.next();
+ });
+ registerOnPreAuth((req, res, t) => {
+ executionOrder.push('onPreAuth');
+ return t.next();
+ });
+ registerAuth((req, res, t) => {
+ executionOrder.push('auth');
+ return res.forbidden();
+ });
+ registerOnPostAuth((req, res, t) => {
+ executionOrder.push('onPostAuth');
+ return t.next();
+ });
+ registerOnPreResponse((req, res, t) => {
+ executionOrder.push('onPreResponse');
+ return t.next();
+ });
+
+ router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' }));
+
+ await server.start();
+
+ await supertest(innerServer.listener).get('/').expect(403);
+ expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']);
+ });
+});
diff --git a/src/core/server/http/lifecycle/on_pre_auth.ts b/src/core/server/http/lifecycle/on_pre_auth.ts
index dc2ae6922fb94..f76fe87fd14a3 100644
--- a/src/core/server/http/lifecycle/on_pre_auth.ts
+++ b/src/core/server/http/lifecycle/on_pre_auth.ts
@@ -29,33 +29,21 @@ import {
enum ResultType {
next = 'next',
- rewriteUrl = 'rewriteUrl',
}
interface Next {
type: ResultType.next;
}
-interface RewriteUrl {
- type: ResultType.rewriteUrl;
- url: string;
-}
-
-type OnPreAuthResult = Next | RewriteUrl;
+type OnPreAuthResult = Next;
const preAuthResult = {
next(): OnPreAuthResult {
return { type: ResultType.next };
},
- rewriteUrl(url: string): OnPreAuthResult {
- return { type: ResultType.rewriteUrl, url };
- },
isNext(result: OnPreAuthResult): result is Next {
return result && result.type === ResultType.next;
},
- isRewriteUrl(result: OnPreAuthResult): result is RewriteUrl {
- return result && result.type === ResultType.rewriteUrl;
- },
};
/**
@@ -65,13 +53,10 @@ const preAuthResult = {
export interface OnPreAuthToolkit {
/** To pass request to the next handler */
next: () => OnPreAuthResult;
- /** Rewrite requested resources url before is was authenticated and routed to a handler */
- rewriteUrl: (url: string) => OnPreAuthResult;
}
const toolkit: OnPreAuthToolkit = {
next: preAuthResult.next,
- rewriteUrl: preAuthResult.rewriteUrl,
};
/**
@@ -88,9 +73,9 @@ export type OnPreAuthHandler = (
* @public
* Adopt custom request interceptor to Hapi lifecycle system.
* @param fn - an extension point allowing to perform custom logic for
- * incoming HTTP requests.
+ * incoming HTTP requests before a user has been authenticated.
*/
-export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) {
+export function adoptToHapiOnPreAuth(fn: OnPreAuthHandler, log: Logger) {
return async function interceptPreAuthRequest(
request: Request,
responseToolkit: HapiResponseToolkit
@@ -107,13 +92,6 @@ export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) {
return responseToolkit.continue;
}
- if (preAuthResult.isRewriteUrl(result)) {
- const { url } = result;
- request.setUrl(url);
- // We should update raw request as well since it can be proxied to the old platform
- request.raw.req.url = url;
- return responseToolkit.continue;
- }
throw new Error(
`Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: ${result}.`
);
diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts
index 9c8c6fba690d1..4d1b53313a51f 100644
--- a/src/core/server/http/lifecycle/on_pre_response.ts
+++ b/src/core/server/http/lifecycle/on_pre_response.ts
@@ -64,7 +64,7 @@ const preResponseResult = {
};
/**
- * A tool set defining an outcome of OnPreAuth interceptor for incoming request.
+ * A tool set defining an outcome of OnPreResponse interceptor for incoming request.
* @public
*/
export interface OnPreResponseToolkit {
@@ -77,7 +77,7 @@ const toolkit: OnPreResponseToolkit = {
};
/**
- * See {@link OnPreAuthToolkit}.
+ * See {@link OnPreRoutingToolkit}.
* @public
*/
export type OnPreResponseHandler = (
diff --git a/src/core/server/http/lifecycle/on_pre_routing.ts b/src/core/server/http/lifecycle/on_pre_routing.ts
new file mode 100644
index 0000000000000..e62eb54f2398f
--- /dev/null
+++ b/src/core/server/http/lifecycle/on_pre_routing.ts
@@ -0,0 +1,125 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi';
+import { Logger } from '../../logging';
+import {
+ HapiResponseAdapter,
+ KibanaRequest,
+ KibanaResponse,
+ lifecycleResponseFactory,
+ LifecycleResponseFactory,
+} from '../router';
+
+enum ResultType {
+ next = 'next',
+ rewriteUrl = 'rewriteUrl',
+}
+
+interface Next {
+ type: ResultType.next;
+}
+
+interface RewriteUrl {
+ type: ResultType.rewriteUrl;
+ url: string;
+}
+
+type OnPreRoutingResult = Next | RewriteUrl;
+
+const preRoutingResult = {
+ next(): OnPreRoutingResult {
+ return { type: ResultType.next };
+ },
+ rewriteUrl(url: string): OnPreRoutingResult {
+ return { type: ResultType.rewriteUrl, url };
+ },
+ isNext(result: OnPreRoutingResult): result is Next {
+ return result && result.type === ResultType.next;
+ },
+ isRewriteUrl(result: OnPreRoutingResult): result is RewriteUrl {
+ return result && result.type === ResultType.rewriteUrl;
+ },
+};
+
+/**
+ * @public
+ * A tool set defining an outcome of OnPreRouting interceptor for incoming request.
+ */
+export interface OnPreRoutingToolkit {
+ /** To pass request to the next handler */
+ next: () => OnPreRoutingResult;
+ /** Rewrite requested resources url before is was authenticated and routed to a handler */
+ rewriteUrl: (url: string) => OnPreRoutingResult;
+}
+
+const toolkit: OnPreRoutingToolkit = {
+ next: preRoutingResult.next,
+ rewriteUrl: preRoutingResult.rewriteUrl,
+};
+
+/**
+ * See {@link OnPreRoutingToolkit}.
+ * @public
+ */
+export type OnPreRoutingHandler = (
+ request: KibanaRequest,
+ response: LifecycleResponseFactory,
+ toolkit: OnPreRoutingToolkit
+) => OnPreRoutingResult | KibanaResponse | Promise;
+
+/**
+ * @public
+ * Adopt custom request interceptor to Hapi lifecycle system.
+ * @param fn - an extension point allowing to perform custom logic for
+ * incoming HTTP requests.
+ */
+export function adoptToHapiOnRequest(fn: OnPreRoutingHandler, log: Logger) {
+ return async function interceptPreRoutingRequest(
+ request: Request,
+ responseToolkit: HapiResponseToolkit
+ ): Promise {
+ const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
+
+ try {
+ const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit);
+ if (result instanceof KibanaResponse) {
+ return hapiResponseAdapter.handle(result);
+ }
+
+ if (preRoutingResult.isNext(result)) {
+ return responseToolkit.continue;
+ }
+
+ if (preRoutingResult.isRewriteUrl(result)) {
+ const { url } = result;
+ request.setUrl(url);
+ // We should update raw request as well since it can be proxied to the old platform
+ request.raw.req.url = url;
+ return responseToolkit.continue;
+ }
+ throw new Error(
+ `Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: ${result}.`
+ );
+ } catch (error) {
+ log.error(error);
+ return hapiResponseAdapter.toInternalError();
+ }
+ };
+}
diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts
index 69402a74eda5f..35eec746163ce 100644
--- a/src/core/server/http/router/router.ts
+++ b/src/core/server/http/router/router.ts
@@ -22,6 +22,7 @@ import Boom from 'boom';
import { isConfigSchema } from '@kbn/config-schema';
import { Logger } from '../../logging';
+import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy/errors';
import { KibanaRequest } from './request';
import { KibanaResponseFactory, kibanaResponseFactory, IKibanaResponse } from './response';
import { RouteConfig, RouteConfigOptions, RouteMethod, validBodyOutput } from './route';
@@ -263,6 +264,10 @@ export class Router implements IRouter {
return hapiResponseAdapter.handle(kibanaResponse);
} catch (e) {
this.log.error(e);
+ // forward 401 (boom) error from ES
+ if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(e)) {
+ return e;
+ }
return hapiResponseAdapter.toInternalError();
}
}
diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts
index 241af1a3020cb..3df098a1df00d 100644
--- a/src/core/server/http/types.ts
+++ b/src/core/server/http/types.ts
@@ -25,6 +25,7 @@ import { HttpServerSetup } from './http_server';
import { SessionStorageCookieOptions } from './cookie_session_storage';
import { SessionStorageFactory } from './session_storage';
import { AuthenticationHandler } from './lifecycle/auth';
+import { OnPreRoutingHandler } from './lifecycle/on_pre_routing';
import { OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { OnPostAuthHandler } from './lifecycle/on_post_auth';
import { OnPreResponseHandler } from './lifecycle/on_pre_response';
@@ -145,15 +146,26 @@ export interface HttpServiceSetup {
) => Promise>;
/**
- * To define custom logic to perform for incoming requests.
+ * To define custom logic to perform for incoming requests before server performs a route lookup.
*
* @remarks
- * Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the
- * only place when you can forward a request to another URL right on the server.
- * Can register any number of registerOnPostAuth, which are called in sequence
+ * It's the only place when you can forward a request to another URL right on the server.
+ * Can register any number of registerOnPreRouting, which are called in sequence
+ * (from the first registered to the last). See {@link OnPreRoutingHandler}.
+ *
+ * @param handler {@link OnPreRoutingHandler} - function to call.
+ */
+ registerOnPreRouting: (handler: OnPreRoutingHandler) => void;
+
+ /**
+ * To define custom logic to perform for incoming requests before
+ * the Auth interceptor performs a check that user has access to requested resources.
+ *
+ * @remarks
+ * Can register any number of registerOnPreAuth, which are called in sequence
* (from the first registered to the last). See {@link OnPreAuthHandler}.
*
- * @param handler {@link OnPreAuthHandler} - function to call.
+ * @param handler {@link OnPreRoutingHandler} - function to call.
*/
registerOnPreAuth: (handler: OnPreAuthHandler) => void;
@@ -170,13 +182,11 @@ export interface HttpServiceSetup {
registerAuth: (handler: AuthenticationHandler) => void;
/**
- * To define custom logic to perform for incoming requests.
+ * To define custom logic after Auth interceptor did make sure a user has access to the requested resource.
*
* @remarks
- * Runs the handler after Auth interceptor
- * did make sure a user has access to the requested resource.
* The auth state is available at stage via http.auth.get(..)
- * Can register any number of registerOnPreAuth, which are called in sequence
+ * Can register any number of registerOnPostAuth, which are called in sequence
* (from the first registered to the last). See {@link OnPostAuthHandler}.
*
* @param handler {@link OnPostAuthHandler} - function to call.
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index dcaa5f2367214..706ec88c6ebfd 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -148,6 +148,8 @@ export {
LegacyRequest,
OnPreAuthHandler,
OnPreAuthToolkit,
+ OnPreRoutingHandler,
+ OnPreRoutingToolkit,
OnPostAuthHandler,
OnPostAuthToolkit,
OnPreResponseHandler,
diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts
index 6b34a4eb58319..fada40e773f12 100644
--- a/src/core/server/legacy/legacy_service.ts
+++ b/src/core/server/legacy/legacy_service.ts
@@ -301,6 +301,7 @@ export class LegacyService implements CoreService {
),
createRouter: () => router,
resources: setupDeps.core.httpResources.createRegistrar(router),
+ registerOnPreRouting: setupDeps.core.http.registerOnPreRouting,
registerOnPreAuth: setupDeps.core.http.registerOnPreAuth,
registerAuth: setupDeps.core.http.registerAuth,
registerOnPostAuth: setupDeps.core.http.registerOnPostAuth,
diff --git a/src/core/server/path/index.test.ts b/src/core/server/path/index.test.ts
index 048622e1f7eab..522e100d85e5d 100644
--- a/src/core/server/path/index.test.ts
+++ b/src/core/server/path/index.test.ts
@@ -18,7 +18,7 @@
*/
import { accessSync, constants } from 'fs';
-import { getConfigPath, getDataPath } from './';
+import { getConfigPath, getDataPath, getConfigDirectory } from './';
describe('Default path finder', () => {
it('should find a kibana.yml', () => {
@@ -30,4 +30,9 @@ describe('Default path finder', () => {
const dataPath = getDataPath();
expect(() => accessSync(dataPath, constants.R_OK)).not.toThrow();
});
+
+ it('should find a config directory', () => {
+ const configDirectory = getConfigDirectory();
+ expect(() => accessSync(configDirectory, constants.R_OK)).not.toThrow();
+ });
});
diff --git a/src/core/server/path/index.ts b/src/core/server/path/index.ts
index 2e05e3856bd4c..1bb650518c47a 100644
--- a/src/core/server/path/index.ts
+++ b/src/core/server/path/index.ts
@@ -30,6 +30,10 @@ const CONFIG_PATHS = [
fromRoot('config/kibana.yml'),
].filter(isString);
+const CONFIG_DIRECTORIES = [process.env.KIBANA_PATH_CONF, fromRoot('config'), '/etc/kibana'].filter(
+ isString
+);
+
const DATA_PATHS = [
process.env.DATA_PATH, // deprecated
fromRoot('data'),
@@ -49,12 +53,19 @@ function findFile(paths: string[]) {
}
/**
- * Get the path where the config files are stored
+ * Get the path of kibana.yml
* @internal
*/
export const getConfigPath = () => findFile(CONFIG_PATHS);
+
+/**
+ * Get the directory containing configuration files
+ * @internal
+ */
+export const getConfigDirectory = () => findFile(CONFIG_DIRECTORIES);
+
/**
- * Get the path where the data can be stored
+ * Get the directory containing runtime data
* @internal
*/
export const getDataPath = () => findFile(DATA_PATHS);
diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts
index a6dd13a12b527..c17b8df8bb52c 100644
--- a/src/core/server/plugins/plugin_context.ts
+++ b/src/core/server/plugins/plugin_context.ts
@@ -157,6 +157,7 @@ export function createPluginSetupContext(
),
createRouter: () => router,
resources: deps.httpResources.createRegistrar(router),
+ registerOnPreRouting: deps.http.registerOnPreRouting,
registerOnPreAuth: deps.http.registerOnPreAuth,
registerAuth: deps.http.registerAuth,
registerOnPostAuth: deps.http.registerOnPostAuth,
diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
index 5da2235828b5c..27c0a5205ae38 100644
--- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
+++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
@@ -107,7 +107,97 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": undefined,
+ "namespaces": undefined,
+ "perPage": 500,
+ "search": undefined,
+ "type": Array [
+ "index-pattern",
+ "search",
+ ],
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Promise {},
+ },
+ ],
+ }
+ `);
+ });
+
+ test('omits the `namespaces` property from the export', async () => {
+ savedObjectsClient.find.mockResolvedValueOnce({
+ total: 2,
+ saved_objects: [
+ {
+ id: '2',
+ type: 'search',
+ attributes: {},
+ namespaces: ['foo', 'bar'],
+ score: 0,
+ references: [
+ {
+ name: 'name',
+ type: 'index-pattern',
+ id: '1',
+ },
+ ],
+ },
+ {
+ id: '1',
+ type: 'index-pattern',
+ attributes: {},
+ namespaces: ['foo', 'bar'],
+ score: 0,
+ references: [],
+ },
+ ],
+ per_page: 1,
+ page: 0,
+ });
+ const exportStream = await exportSavedObjectsToStream({
+ savedObjectsClient,
+ exportSizeLimit: 500,
+ types: ['index-pattern', 'search'],
+ });
+
+ const response = await readStreamToCompletion(exportStream);
+
+ expect(response).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
+ expect(savedObjectsClient.find).toMatchInlineSnapshot(`
+ [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "namespaces": undefined,
"perPage": 500,
"search": undefined,
"type": Array [
@@ -257,7 +347,7 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": undefined,
+ "namespaces": undefined,
"perPage": 500,
"search": "foo",
"type": Array [
@@ -346,7 +436,9 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": "foo",
+ "namespaces": Array [
+ "foo",
+ ],
"perPage": 500,
"search": undefined,
"type": Array [
diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
index 6e985c25aeaef..6cfe6f1be5669 100644
--- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
+++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
@@ -109,7 +109,7 @@ async function fetchObjectsToExport({
type: types,
search,
perPage: exportSizeLimit,
- namespace,
+ namespaces: namespace ? [namespace] : undefined,
});
if (findResponse.total > exportSizeLimit) {
throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`);
@@ -162,10 +162,15 @@ export async function exportSavedObjectsToStream({
exportedObjects = sortObjects(rootObjects);
}
+ // redact attributes that should not be exported
+ const redactedObjects = exportedObjects.map>(
+ ({ namespaces, ...object }) => object
+ );
+
const exportDetails: SavedObjectsExportResultDetails = {
exportedCount: exportedObjects.length,
missingRefCount: missingReferences.length,
missingReferences,
};
- return createListStream([...exportedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);
+ return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);
}
diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts
index 5c1c2c9a9ab87..6313a95b1fefa 100644
--- a/src/core/server/saved_objects/routes/find.ts
+++ b/src/core/server/saved_objects/routes/find.ts
@@ -45,11 +45,18 @@ export const registerFindRoute = (router: IRouter) => {
),
fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
filter: schema.maybe(schema.string()),
+ namespaces: schema.maybe(
+ schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
+ ),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const query = req.query;
+
+ const namespaces =
+ typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces;
+
const result = await context.core.savedObjects.client.find({
perPage: query.per_page,
page: query.page,
@@ -62,6 +69,7 @@ export const registerFindRoute = (router: IRouter) => {
hasReference: query.has_reference,
fields: typeof query.fields === 'string' ? [query.fields] : query.fields,
filter: query.filter,
+ namespaces,
});
return res.ok({ body: result });
diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts
index 33e12dd4e517d..d5a7710f04b39 100644
--- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts
+++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts
@@ -81,6 +81,7 @@ describe('GET /api/saved_objects/_find', () => {
attributes: {},
score: 1,
references: [],
+ namespaces: ['default'],
},
{
type: 'index-pattern',
@@ -91,6 +92,7 @@ describe('GET /api/saved_objects/_find', () => {
attributes: {},
score: 1,
references: [],
+ namespaces: ['default'],
},
],
};
@@ -241,4 +243,38 @@ describe('GET /api/saved_objects/_find', () => {
defaultSearchOperator: 'OR',
});
});
+
+ it('accepts the query parameter namespaces as a string', async () => {
+ await supertest(httpSetup.server.listener)
+ .get('/api/saved_objects/_find?type=index-pattern&namespaces=foo')
+ .expect(200);
+
+ expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
+
+ const options = savedObjectsClient.find.mock.calls[0][0];
+ expect(options).toEqual({
+ perPage: 20,
+ page: 1,
+ type: ['index-pattern'],
+ namespaces: ['foo'],
+ defaultSearchOperator: 'OR',
+ });
+ });
+
+ it('accepts the query parameter namespaces as an array', async () => {
+ await supertest(httpSetup.server.listener)
+ .get('/api/saved_objects/_find?type=index-pattern&namespaces=default&namespaces=foo')
+ .expect(200);
+
+ expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
+
+ const options = savedObjectsClient.find.mock.calls[0][0];
+ expect(options).toEqual({
+ perPage: 20,
+ page: 1,
+ type: ['index-pattern'],
+ namespaces: ['default', 'foo'],
+ defaultSearchOperator: 'OR',
+ });
+ });
});
diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js
index ea749235cbb41..d563edbe66c9b 100644
--- a/src/core/server/saved_objects/service/lib/repository.test.js
+++ b/src/core/server/saved_objects/service/lib/repository.test.js
@@ -494,6 +494,7 @@ describe('SavedObjectsRepository', () => {
...obj,
migrationVersion: { [obj.type]: '1.1.1' },
version: mockVersion,
+ namespaces: obj.namespaces ?? [obj.namespace ?? 'default'],
...mockTimestampFields,
});
@@ -826,9 +827,19 @@ describe('SavedObjectsRepository', () => {
// Assert that both raw docs from the ES response are deserialized
expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(1, {
...response.items[0].create,
+ _source: {
+ ...response.items[0].create._source,
+ namespaces: response.items[0].create._source.namespaces,
+ },
_id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/),
});
- expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, response.items[1].create);
+ expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, {
+ ...response.items[1].create,
+ _source: {
+ ...response.items[1].create._source,
+ namespaces: response.items[1].create._source.namespaces,
+ },
+ });
// Assert that ID's are deserialized to remove the type and namespace
expect(result.saved_objects[0].id).toEqual(
@@ -985,7 +996,7 @@ describe('SavedObjectsRepository', () => {
const expectSuccessResult = ({ type, id }, doc) => ({
type,
id,
- ...(doc._source.namespaces && { namespaces: doc._source.namespaces }),
+ namespaces: doc._source.namespaces ?? ['default'],
...(doc._source.updated_at && { updated_at: doc._source.updated_at }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
@@ -1027,12 +1038,12 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`includes namespaces property for multi-namespace documents`, async () => {
+ it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => {
const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' };
const result = await bulkGetSuccess([obj1, obj]);
expect(result).toEqual({
saved_objects: [
- expect.not.objectContaining({ namespaces: expect.anything() }),
+ expect.objectContaining({ namespaces: ['default'] }),
expect.objectContaining({ namespaces: expect.any(Array) }),
],
});
@@ -1350,12 +1361,13 @@ describe('SavedObjectsRepository', () => {
});
describe('returns', () => {
- const expectSuccessResult = ({ type, id, attributes, references }) => ({
+ const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({
type,
id,
attributes,
references,
version: mockVersion,
+ namespaces: namespaces ?? ['default'],
...mockTimestampFields,
});
@@ -1389,12 +1401,12 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`includes namespaces property for multi-namespace documents`, async () => {
+ it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => {
const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' };
const result = await bulkUpdateSuccess([obj1, obj]);
expect(result).toEqual({
saved_objects: [
- expect.not.objectContaining({ namespaces: expect.anything() }),
+ expect.objectContaining({ namespaces: expect.any(Array) }),
expect.objectContaining({ namespaces: expect.any(Array) }),
],
});
@@ -1651,6 +1663,7 @@ describe('SavedObjectsRepository', () => {
version: mockVersion,
attributes,
references,
+ namespaces: [namespace ?? 'default'],
migrationVersion: { [type]: '1.1.1' },
});
});
@@ -1907,7 +1920,7 @@ describe('SavedObjectsRepository', () => {
await deleteByNamespaceSuccess(namespace);
const allTypes = registry.getAllTypes().map((type) => type.name);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, {
- namespace,
+ namespaces: [namespace],
type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)),
});
});
@@ -2134,6 +2147,7 @@ describe('SavedObjectsRepository', () => {
score: doc._score,
attributes: doc._source[doc._source.type],
references: [],
+ namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : ['default'],
});
});
});
@@ -2143,7 +2157,7 @@ describe('SavedObjectsRepository', () => {
callAdminCluster.mockReturnValue(namespacedSearchResults);
const count = namespacedSearchResults.hits.hits.length;
- const response = await savedObjectsRepository.find({ type, namespace });
+ const response = await savedObjectsRepository.find({ type, namespaces: [namespace] });
expect(response.total).toBe(count);
expect(response.saved_objects).toHaveLength(count);
@@ -2157,6 +2171,7 @@ describe('SavedObjectsRepository', () => {
score: doc._score,
attributes: doc._source[doc._source.type],
references: [],
+ namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace],
});
});
});
@@ -2176,7 +2191,7 @@ describe('SavedObjectsRepository', () => {
describe('search dsl', () => {
it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => {
const relevantOpts = {
- namespace,
+ namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: [type],
@@ -2374,6 +2389,7 @@ describe('SavedObjectsRepository', () => {
title: 'Testing',
},
references: [],
+ namespaces: ['default'],
});
});
@@ -2384,10 +2400,10 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`doesn't include namespaces if type is not multi-namespace`, async () => {
+ it(`include namespaces if type is not multi-namespace`, async () => {
const result = await getSuccess(type, id);
- expect(result).not.toMatchObject({
- namespaces: expect.anything(),
+ expect(result).toMatchObject({
+ namespaces: ['default'],
});
});
});
@@ -2908,10 +2924,10 @@ describe('SavedObjectsRepository', () => {
_id: `${type}:${id}`,
...mockVersionProps,
result: 'updated',
- ...(registry.isMultiNamespace(type) && {
- // don't need the rest of the source for test purposes, just the namespaces attribute
- get: { _source: { namespaces: [options?.namespace ?? 'default'] } },
- }),
+ // don't need the rest of the source for test purposes, just the namespace and namespaces attributes
+ get: {
+ _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace },
+ },
}); // this._writeToCluster('update', ...)
const result = await savedObjectsRepository.update(type, id, attributes, options);
expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1);
@@ -3011,15 +3027,15 @@ describe('SavedObjectsRepository', () => {
it(`includes _sourceIncludes when type is multi-namespace`, async () => {
await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes);
- expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2);
+ expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2);
});
- it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => {
+ it(`includes _sourceIncludes when type is not multi-namespace`, async () => {
await updateSuccess(type, id, attributes);
expect(callAdminCluster).toHaveBeenLastCalledWith(
expect.any(String),
- expect.not.objectContaining({
- _sourceIncludes: expect.anything(),
+ expect.objectContaining({
+ _sourceIncludes: ['namespace', 'namespaces'],
})
);
});
@@ -3093,6 +3109,7 @@ describe('SavedObjectsRepository', () => {
version: mockVersion,
attributes,
references,
+ namespaces: [namespace],
});
});
@@ -3103,10 +3120,10 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`doesn't include namespaces if type is not multi-namespace`, async () => {
+ it(`includes namespaces if type is not multi-namespace`, async () => {
const result = await updateSuccess(type, id, attributes);
- expect(result).not.toMatchObject({
- namespaces: expect.anything(),
+ expect(result).toMatchObject({
+ namespaces: ['default'],
});
});
});
diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts
index 880b71e164b5b..7a5ac9204627c 100644
--- a/src/core/server/saved_objects/service/lib/repository.ts
+++ b/src/core/server/saved_objects/service/lib/repository.ts
@@ -423,7 +423,7 @@ export class SavedObjectsRepository {
// When method == 'index' the bulkResponse doesn't include the indexed
// _source so we return rawMigratedDoc but have to spread the latest
// _seq_no and _primary_term values from the rawResponse.
- return this._serializer.rawToSavedObject({
+ return this._rawToSavedObject({
...rawMigratedDoc,
...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term },
});
@@ -554,7 +554,7 @@ export class SavedObjectsRepository {
},
conflicts: 'proceed',
...getSearchDsl(this._mappings, this._registry, {
- namespace,
+ namespaces: namespace ? [namespace] : undefined,
type: typesToUpdate,
}),
},
@@ -590,7 +590,7 @@ export class SavedObjectsRepository {
sortField,
sortOrder,
fields,
- namespace,
+ namespaces,
type,
filter,
preference,
@@ -651,7 +651,7 @@ export class SavedObjectsRepository {
type: allowedTypes,
sortField,
sortOrder,
- namespace,
+ namespaces,
hasReference,
kueryNode,
}),
@@ -768,10 +768,16 @@ export class SavedObjectsRepository {
}
const time = doc._source.updated_at;
+
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)];
+ }
+
return {
id,
type,
- ...(doc._source.namespaces && { namespaces: doc._source.namespaces }),
+ namespaces,
...(time && { updated_at: time }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
@@ -817,10 +823,15 @@ export class SavedObjectsRepository {
const { updated_at: updatedAt } = response._source;
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)];
+ }
+
return {
id,
type,
- ...(response._source.namespaces && { namespaces: response._source.namespaces }),
+ namespaces,
...(updatedAt && { updated_at: updatedAt }),
version: encodeHitVersion(response),
attributes: response._source[type],
@@ -874,7 +885,7 @@ export class SavedObjectsRepository {
body: {
doc,
},
- ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }),
+ _sourceIncludes: ['namespace', 'namespaces'],
});
if (updateResponse.status === 404) {
@@ -882,14 +893,19 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = updateResponse.get._source.namespaces ?? [
+ getNamespaceString(updateResponse.get._source.namespace),
+ ];
+ }
+
return {
id,
type,
updated_at: time,
version: encodeHitVersion(updateResponse),
- ...(this._registry.isMultiNamespace(type) && {
- namespaces: updateResponse.get._source.namespaces,
- }),
+ namespaces,
references,
attributes,
};
@@ -1142,9 +1158,14 @@ export class SavedObjectsRepository {
},
};
}
- namespaces = actualResult._source.namespaces;
+ namespaces = actualResult._source.namespaces ?? [
+ getNamespaceString(actualResult._source.namespace),
+ ];
versionProperties = getExpectedVersionProperties(version, actualResult);
} else {
+ if (this._registry.isSingleNamespace(type)) {
+ namespaces = [getNamespaceString(namespace)];
+ }
versionProperties = getExpectedVersionProperties(version);
}
@@ -1340,12 +1361,12 @@ export class SavedObjectsRepository {
return new Date().toISOString();
}
- // The internal representation of the saved object that the serializer returns
- // includes the namespace, and we use this for migrating documents. However, we don't
- // want the namespace to be returned from the repository, as the repository scopes each
- // method transparently to the specified namespace.
private _rawToSavedObject(raw: SavedObjectsRawDoc): SavedObject {
const savedObject = this._serializer.rawToSavedObject(raw);
+ const { namespace, type } = savedObject;
+ if (this._registry.isSingleNamespace(type)) {
+ savedObject.namespaces = [getNamespaceString(namespace)];
+ }
return omit(savedObject, 'namespace') as SavedObject;
}
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts
index a0ffa91f53671..f916638c5251b 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts
@@ -196,19 +196,29 @@ describe('#getQueryParams', () => {
});
});
- describe('`namespace` parameter', () => {
- const createTypeClause = (type: string, namespace?: string) => {
+ describe('`namespaces` parameter', () => {
+ const createTypeClause = (type: string, namespaces?: string[]) => {
if (registry.isMultiNamespace(type)) {
return {
bool: {
- must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]),
+ must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
must_not: [{ exists: { field: 'namespace' } }],
},
};
- } else if (namespace && registry.isSingleNamespace(type)) {
+ } else if (registry.isSingleNamespace(type)) {
+ const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? [];
+ const should: any = [];
+ if (nonDefaultNamespaces.length > 0) {
+ should.push({ terms: { namespace: nonDefaultNamespaces } });
+ }
+ if (namespaces?.includes('default')) {
+ should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
+ }
return {
bool: {
- must: expect.arrayContaining([{ term: { namespace } }]),
+ must: [{ term: { type } }],
+ should: expect.arrayContaining(should),
+ minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
@@ -229,23 +239,45 @@ describe('#getQueryParams', () => {
);
};
- const test = (namespace?: string) => {
+ const test = (namespaces?: string[]) => {
for (const typeOrTypes of ALL_TYPE_SUBSETS) {
- const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace });
+ const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces });
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
- expectResult(result, ...types.map((x) => createTypeClause(x, namespace)));
+ expectResult(result, ...types.map((x) => createTypeClause(x, namespaces)));
}
// also test with no specified type/s
- const result = getQueryParams({ mappings, registry, type: undefined, namespace });
- expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespace)));
+ const result = getQueryParams({ mappings, registry, type: undefined, namespaces });
+ expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces)));
};
- it('filters results with "namespace" field when `namespace` is not specified', () => {
+ it('normalizes and deduplicates provided namespaces', () => {
+ const result = getQueryParams({
+ mappings,
+ registry,
+ search: '*',
+ namespaces: ['foo', '*', 'foo', 'bar', 'default'],
+ });
+
+ expectResult(
+ result,
+ ...ALL_TYPES.map((x) => createTypeClause(x, ['foo', 'default', 'bar']))
+ );
+ });
+
+ it('filters results with "namespace" field when `namespaces` is not specified', () => {
test(undefined);
});
it('filters results for specified namespace for appropriate type/s', () => {
- test('foo-namespace');
+ test(['foo-namespace']);
+ });
+
+ it('filters results for specified namespaces for appropriate type/s', () => {
+ test(['foo-namespace', 'default']);
+ });
+
+ it('filters results for specified `default` namespace for appropriate type/s', () => {
+ test(['default']);
});
});
});
@@ -353,4 +385,18 @@ describe('#getQueryParams', () => {
});
});
});
+
+ describe('namespaces property', () => {
+ ALL_TYPES.forEach((type) => {
+ it(`throws for ${type} when namespaces is an empty array`, () => {
+ expect(() =>
+ getQueryParams({
+ mappings,
+ registry,
+ namespaces: [],
+ })
+ ).toThrowError('cannot specify empty namespaces array');
+ });
+ });
+ });
});
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts
index 40485564176a6..164756f9796a5 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts
@@ -63,25 +63,42 @@ function getFieldsForTypes(types: string[], searchFields?: string[]) {
*/
function getClauseForType(
registry: ISavedObjectTypeRegistry,
- namespace: string | undefined,
+ namespaces: string[] = ['default'],
type: string
) {
+ if (namespaces.length === 0) {
+ throw new Error('cannot specify empty namespaces array');
+ }
if (registry.isMultiNamespace(type)) {
return {
bool: {
- must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }],
+ must: [{ term: { type } }, { terms: { namespaces } }],
must_not: [{ exists: { field: 'namespace' } }],
},
};
- } else if (namespace && registry.isSingleNamespace(type)) {
+ } else if (registry.isSingleNamespace(type)) {
+ const should: Array> = [];
+ const eligibleNamespaces = namespaces.filter((namespace) => namespace !== 'default');
+ if (eligibleNamespaces.length > 0) {
+ should.push({ terms: { namespace: eligibleNamespaces } });
+ }
+ if (namespaces.includes('default')) {
+ should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
+ }
+ if (should.length === 0) {
+ // This is indicitive of a bug, and not user error.
+ throw new Error('unhandled search condition: expected at least 1 `should` clause.');
+ }
return {
bool: {
- must: [{ term: { type } }, { term: { namespace } }],
+ must: [{ term: { type } }],
+ should,
+ minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
}
- // isSingleNamespace in the default namespace, or isNamespaceAgnostic
+ // isNamespaceAgnostic
return {
bool: {
must: [{ term: { type } }],
@@ -98,7 +115,7 @@ interface HasReferenceQueryParams {
interface QueryParams {
mappings: IndexMapping;
registry: ISavedObjectTypeRegistry;
- namespace?: string;
+ namespaces?: string[];
type?: string | string[];
search?: string;
searchFields?: string[];
@@ -113,7 +130,7 @@ interface QueryParams {
export function getQueryParams({
mappings,
registry,
- namespace,
+ namespaces,
type,
search,
searchFields,
@@ -122,6 +139,22 @@ export function getQueryParams({
kueryNode,
}: QueryParams) {
const types = getTypes(mappings, type);
+
+ // A de-duplicated set of namespaces makes for a more effecient query.
+ //
+ // Additonally, we treat the `*` namespace as the `default` namespace.
+ // In the Default Distribution, the `*` is automatically expanded to include all available namespaces.
+ // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*`
+ // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`,
+ // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place
+ // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard.
+ // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716
+ const normalizedNamespaces = namespaces
+ ? Array.from(
+ new Set(namespaces.map((namespace) => (namespace === '*' ? 'default' : namespace)))
+ )
+ : undefined;
+
const bool: any = {
filter: [
...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []),
@@ -152,7 +185,9 @@ export function getQueryParams({
},
]
: undefined,
- should: types.map((shouldType) => getClauseForType(registry, namespace, shouldType)),
+ should: types.map((shouldType) =>
+ getClauseForType(registry, normalizedNamespaces, shouldType)
+ ),
minimum_should_match: 1,
},
},
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
index 95b7ffd117ee9..08ad72397e4a2 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
@@ -57,9 +57,9 @@ describe('getSearchDsl', () => {
});
describe('passes control', () => {
- it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => {
+ it('passes (mappings, schema, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => {
const opts = {
- namespace: 'foo-namespace',
+ namespaces: ['foo-namespace'],
type: 'foo',
search: 'bar',
searchFields: ['baz'],
@@ -75,7 +75,7 @@ describe('getSearchDsl', () => {
expect(getQueryParams).toHaveBeenCalledWith({
mappings,
registry,
- namespace: opts.namespace,
+ namespaces: opts.namespaces,
type: opts.type,
search: opts.search,
searchFields: opts.searchFields,
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
index 74c25491aff8b..6de868c320240 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
@@ -33,7 +33,7 @@ interface GetSearchDslOptions {
searchFields?: string[];
sortField?: string;
sortOrder?: string;
- namespace?: string;
+ namespaces?: string[];
hasReference?: {
type: string;
id: string;
@@ -53,7 +53,7 @@ export function getSearchDsl(
searchFields,
sortField,
sortOrder,
- namespace,
+ namespaces,
hasReference,
kueryNode,
} = options;
@@ -70,7 +70,7 @@ export function getSearchDsl(
...getQueryParams({
mappings,
registry,
- namespace,
+ namespaces,
type,
search,
searchFields,
diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts
index 2183b47b732f9..f9301d6598b1d 100644
--- a/src/core/server/saved_objects/types.ts
+++ b/src/core/server/saved_objects/types.ts
@@ -63,7 +63,7 @@ export interface SavedObjectStatusMeta {
*
* @public
*/
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
type: string | string[];
page?: number;
perPage?: number;
@@ -82,6 +82,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
hasReference?: { type: string; id: string };
defaultSearchOperator?: 'AND' | 'OR';
filter?: string;
+ namespaces?: string[];
/** An optional ES preference value to be used for the query **/
preference?: string;
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 3d3e1905577d9..a0e16602ba4bf 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -811,6 +811,7 @@ export interface HttpServiceSetup {
registerOnPostAuth: (handler: OnPostAuthHandler) => void;
registerOnPreAuth: (handler: OnPreAuthHandler) => void;
registerOnPreResponse: (handler: OnPreResponseHandler) => void;
+ registerOnPreRouting: (handler: OnPreRoutingHandler) => void;
registerRouteHandlerContext: (contextName: T, provider: RequestHandlerContextProvider) => RequestHandlerContextContainer;
}
@@ -1536,7 +1537,6 @@ export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleRespo
// @public
export interface OnPreAuthToolkit {
next: () => OnPreAuthResult;
- rewriteUrl: (url: string) => OnPreAuthResult;
}
// @public
@@ -1560,6 +1560,17 @@ export interface OnPreResponseToolkit {
next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult;
}
+// Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts
+//
+// @public
+export type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise;
+
+// @public
+export interface OnPreRoutingToolkit {
+ next: () => OnPreRoutingResult;
+ rewriteUrl: (url: string) => OnPreRoutingResult;
+}
+
// @public
export interface OpsMetrics {
concurrent_connections: OpsServerMetrics['concurrent_connections'];
@@ -2164,7 +2175,7 @@ export interface SavedObjectsExportResultDetails {
export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjectsComplexFieldMapping;
// @public (undocumented)
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
// (undocumented)
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
@@ -2176,6 +2187,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
id: string;
};
// (undocumented)
+ namespaces?: string[];
+ // (undocumented)
page?: number;
// (undocumented)
perPage?: number;
@@ -2387,7 +2400,7 @@ export class SavedObjectsRepository {
deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise;
deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>;
// (undocumented)
- find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
+ find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{
id: string;
diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx
index 72a1056b1a866..b33d349cede28 100644
--- a/src/plugins/console/public/application/components/editor_example.tsx
+++ b/src/plugins/console/public/application/components/editor_example.tsx
@@ -27,13 +27,13 @@ interface EditorExampleProps {
const exampleText = `
# index a doc
-PUT index/1
+PUT index/_doc/1
{
"body": "here"
}
# and get it ...
-GET index/1
+GET index/_doc/1
`;
export function EditorExample(props: EditorExampleProps) {
diff --git a/src/plugins/data/common/field_formats/constants/base_formatters.ts b/src/plugins/data/common/field_formats/constants/base_formatters.ts
index 921c50571f727..99c24496cf220 100644
--- a/src/plugins/data/common/field_formats/constants/base_formatters.ts
+++ b/src/plugins/data/common/field_formats/constants/base_formatters.ts
@@ -23,7 +23,6 @@ import {
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
@@ -40,7 +39,6 @@ export const baseFormatters: FieldFormatInstanceType[] = [
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts
similarity index 99%
rename from src/plugins/data/common/field_formats/converters/date_nanos.test.ts
rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts
index 267f023e9b69d..6843427d273ff 100644
--- a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts
+++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts
@@ -18,7 +18,7 @@
*/
import moment from 'moment-timezone';
-import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos';
+import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos_shared';
describe('Date Nanos Format', () => {
let convert: Function;
diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts
similarity index 93%
rename from src/plugins/data/common/field_formats/converters/date_nanos.ts
rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.ts
index 3fa2b1c276cd7..89a63243c76f0 100644
--- a/src/plugins/data/common/field_formats/converters/date_nanos.ts
+++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts
@@ -18,11 +18,9 @@
*/
import { i18n } from '@kbn/i18n';
-import moment, { Moment } from 'moment';
import { memoize, noop } from 'lodash';
-import { KBN_FIELD_TYPES } from '../../kbn_field_types/types';
-import { FieldFormat } from '../field_format';
-import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types';
+import moment, { Moment } from 'moment';
+import { FieldFormat, FIELD_FORMAT_IDS, KBN_FIELD_TYPES, TextContextTypeConvert } from '../../';
/**
* Analyse the given moment.js format pattern for the fractional sec part (S,SS,SSS...)
@@ -76,9 +74,9 @@ export class DateNanosFormat extends FieldFormat {
});
static fieldType = KBN_FIELD_TYPES.DATE;
- private memoizedConverter: Function = noop;
- private memoizedPattern: string = '';
- private timeZone: string = '';
+ protected memoizedConverter: Function = noop;
+ protected memoizedPattern: string = '';
+ protected timeZone: string = '';
getParamDefaults() {
return {
diff --git a/src/plugins/data/common/field_formats/converters/index.ts b/src/plugins/data/common/field_formats/converters/index.ts
index cc9fae7fc9965..f71ddf5f781f7 100644
--- a/src/plugins/data/common/field_formats/converters/index.ts
+++ b/src/plugins/data/common/field_formats/converters/index.ts
@@ -19,7 +19,6 @@
export { UrlFormat } from './url';
export { BytesFormat } from './bytes';
-export { DateNanosFormat } from './date_nanos';
export { RelativeDateFormat } from './relative_date';
export { DurationFormat } from './duration';
export { IpFormat } from './ip';
diff --git a/src/plugins/data/common/field_formats/field_formats_registry.ts b/src/plugins/data/common/field_formats/field_formats_registry.ts
index 74a942b51583d..84bedd2f9dee0 100644
--- a/src/plugins/data/common/field_formats/field_formats_registry.ts
+++ b/src/plugins/data/common/field_formats/field_formats_registry.ts
@@ -180,10 +180,18 @@ export class FieldFormatsRegistry {
* @param {ES_FIELD_TYPES[]} esTypes
* @return {FieldFormat}
*/
- getDefaultInstancePlain(fieldType: KBN_FIELD_TYPES, esTypes?: ES_FIELD_TYPES[]): FieldFormat {
+ getDefaultInstancePlain(
+ fieldType: KBN_FIELD_TYPES,
+ esTypes?: ES_FIELD_TYPES[],
+ params: Record = {}
+ ): FieldFormat {
const conf = this.getDefaultConfig(fieldType, esTypes);
+ const instanceParams = {
+ ...conf.params,
+ ...params,
+ };
- return this.getInstance(conf.id, conf.params);
+ return this.getInstance(conf.id, instanceParams);
}
/**
* Returns a cache key built by the given variables for caching in memoized
diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts
index 104ff030873aa..d622af2f663a1 100644
--- a/src/plugins/data/common/field_formats/index.ts
+++ b/src/plugins/data/common/field_formats/index.ts
@@ -27,7 +27,6 @@ export {
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
diff --git a/src/plugins/data/public/field_formats/constants.ts b/src/plugins/data/public/field_formats/constants.ts
index a5c2b4e379908..d5e292c0e78e5 100644
--- a/src/plugins/data/public/field_formats/constants.ts
+++ b/src/plugins/data/public/field_formats/constants.ts
@@ -18,6 +18,6 @@
*/
import { baseFormatters } from '../../common';
-import { DateFormat } from './converters/date';
+import { DateFormat, DateNanosFormat } from './converters';
-export const baseFormattersPublic = [DateFormat, ...baseFormatters];
+export const baseFormattersPublic = [DateFormat, DateNanosFormat, ...baseFormatters];
diff --git a/src/plugins/data/public/field_formats/converters/date_nanos.ts b/src/plugins/data/public/field_formats/converters/date_nanos.ts
new file mode 100644
index 0000000000000..d83926826011a
--- /dev/null
+++ b/src/plugins/data/public/field_formats/converters/date_nanos.ts
@@ -0,0 +1,20 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { DateNanosFormat } from '../../../common/field_formats/converters/date_nanos_shared';
diff --git a/src/plugins/data/public/field_formats/converters/index.ts b/src/plugins/data/public/field_formats/converters/index.ts
index c51111092beca..f5f154084242f 100644
--- a/src/plugins/data/public/field_formats/converters/index.ts
+++ b/src/plugins/data/public/field_formats/converters/index.ts
@@ -18,3 +18,4 @@
*/
export { DateFormat } from './date';
+export { DateNanosFormat } from './date_nanos';
diff --git a/src/plugins/data/public/field_formats/index.ts b/src/plugins/data/public/field_formats/index.ts
index 015d5b39561bb..4525959fb864d 100644
--- a/src/plugins/data/public/field_formats/index.ts
+++ b/src/plugins/data/public/field_formats/index.ts
@@ -18,5 +18,5 @@
*/
export { FieldFormatsService, FieldFormatsSetup, FieldFormatsStart } from './field_formats_service';
-export { DateFormat } from './converters';
+export { DateFormat, DateNanosFormat } from './converters';
export { baseFormattersPublic } from './constants';
diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts
index abec908b41c0f..2efd1c82aae79 100644
--- a/src/plugins/data/public/index.ts
+++ b/src/plugins/data/public/index.ts
@@ -157,7 +157,6 @@ import {
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
@@ -170,7 +169,7 @@ import {
TruncateFormat,
} from '../common/field_formats';
-import { DateFormat } from './field_formats';
+import { DateNanosFormat, DateFormat } from './field_formats';
export { baseFormattersPublic } from './field_formats';
// Field formats helpers namespace:
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index b532bacf5df25..0c23ba340304f 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -246,11 +246,12 @@ export class AggParamType extends Ba
makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig;
}
+// Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
-export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateFormat)[];
+export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateNanosFormat | typeof DateFormat)[];
// Warning: (ae-missing-release-tag) "BUCKET_TYPES" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -1955,42 +1956,41 @@ export const UI_SETTINGS: {
// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:373:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
-// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:371:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
+// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:41:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:53:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts
diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts
index 12cdf13caeb55..e2caca7895c42 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts
+++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts
@@ -177,11 +177,27 @@ describe('Filter editor utils', () => {
it('should return true for range filter with from/to', () => {
const isValid = isFilterValid(stubIndexPattern, stubFields[0], isBetweenOperator, {
from: 'foo',
- too: 'goo',
+ to: 'goo',
});
expect(isValid).toBe(true);
});
+ it('should return false for date range filter with bad from', () => {
+ const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, {
+ from: 'foo',
+ to: 'now',
+ });
+ expect(isValid).toBe(false);
+ });
+
+ it('should return false for date range filter with bad to', () => {
+ const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, {
+ from: '2020-01-01',
+ to: 'mau',
+ });
+ expect(isValid).toBe(false);
+ });
+
it('should return true for exists filter without params', () => {
const isValid = isFilterValid(stubIndexPattern, stubFields[0], existsOperator);
expect(isValid).toBe(true);
diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts
index 114be67e490cf..97a59fa69f458 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts
+++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts
@@ -85,7 +85,10 @@ export function isFilterValid(
if (typeof params !== 'object') {
return false;
}
- return validateParams(params.from, field.type) || validateParams(params.to, field.type);
+ return (
+ (!params.from || validateParams(params.from, field.type)) &&
+ (!params.to || validateParams(params.to, field.type))
+ );
case 'exists':
return true;
default:
diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx
index 65b842f0bd4aa..bdfd1014625d8 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx
+++ b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx
@@ -17,8 +17,9 @@
* under the License.
*/
-import { EuiIcon, EuiLink, EuiFormHelpText, EuiFormControlLayoutDelimited } from '@elastic/eui';
-import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
+import moment from 'moment';
+import { EuiFormControlLayoutDelimited } from '@elastic/eui';
+import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { get } from 'lodash';
import React from 'react';
import { useKibana } from '../../../../../kibana_react/public';
@@ -41,8 +42,17 @@ interface Props {
function RangeValueInputUI(props: Props) {
const kibana = useKibana();
- const dataMathDocLink = kibana.services.docLinks!.links.date.dateMath;
const type = props.field ? props.field.type : 'string';
+ const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz');
+
+ const formatDateChange = (value: string | number | boolean) => {
+ if (typeof value !== 'string' && typeof value !== 'number') return value;
+
+ const momentParsedValue = moment(value).tz(tzConfig);
+ if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
+
+ return value;
+ };
const onFromChange = (value: string | number | boolean) => {
if (typeof value !== 'string' && typeof value !== 'number') {
@@ -71,6 +81,9 @@ function RangeValueInputUI(props: Props) {
type={type}
value={props.value ? props.value.from : undefined}
onChange={onFromChange}
+ onBlur={(value) => {
+ onFromChange(formatDateChange(value));
+ }}
placeholder={props.intl.formatMessage({
id: 'data.filter.filterEditor.rangeStartInputPlaceholder',
defaultMessage: 'Start of the range',
@@ -83,6 +96,9 @@ function RangeValueInputUI(props: Props) {
type={type}
value={props.value ? props.value.to : undefined}
onChange={onToChange}
+ onBlur={(value) => {
+ onToChange(formatDateChange(value));
+ }}
placeholder={props.intl.formatMessage({
id: 'data.filter.filterEditor.rangeEndInputPlaceholder',
defaultMessage: 'End of the range',
@@ -90,19 +106,6 @@ function RangeValueInputUI(props: Props) {
/>
}
/>
- {type === 'date' ? (
-
-
- {' '}
-
-
-
- ) : (
- ''
- )}
);
}
diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx
index 3737dae1bf9ef..1a165c78d4d79 100644
--- a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx
+++ b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx
@@ -27,6 +27,7 @@ interface Props {
value?: string | number;
type: string;
onChange: (value: string | number | boolean) => void;
+ onBlur?: (value: string | number | boolean) => void;
placeholder: string;
intl: InjectedIntl;
controlOnly?: boolean;
@@ -66,6 +67,7 @@ class ValueInputTypeUI extends Component {
placeholder={this.props.placeholder}
value={value}
onChange={this.onChange}
+ onBlur={this.onBlur}
isInvalid={!isEmpty(value) && !validateParams(value, this.props.type)}
controlOnly={this.props.controlOnly}
className={this.props.className}
@@ -126,6 +128,13 @@ class ValueInputTypeUI extends Component {
const params = event.target.value;
this.props.onChange(params);
};
+
+ private onBlur = (event: React.ChangeEvent) => {
+ if (this.props.onBlur) {
+ const params = event.target.value;
+ this.props.onBlur(params);
+ }
+ };
}
export const ValueInputType = injectI18n(ValueInputTypeUI);
diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts
new file mode 100644
index 0000000000000..ba8e128f32728
--- /dev/null
+++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts
@@ -0,0 +1,74 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { DateNanosFormat } from './date_nanos_server';
+import { FieldFormatsGetConfigFn } from 'src/plugins/data/common';
+
+describe('Date Nanos Format: Server side edition', () => {
+ let convert: Function;
+ let mockConfig: Record;
+ let getConfig: FieldFormatsGetConfigFn;
+
+ const dateTime = '2019-05-05T14:04:56.201900001Z';
+
+ beforeEach(() => {
+ mockConfig = {};
+ mockConfig.dateNanosFormat = 'MMMM Do YYYY, HH:mm:ss.SSSSSSSSS';
+ mockConfig['dateFormat:tz'] = 'Browser';
+
+ getConfig = (key: string) => mockConfig[key];
+ });
+
+ test('should format according to the given timezone parameter', () => {
+ const dateNy = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig);
+ convert = dateNy.convert.bind(dateNy);
+ expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`);
+
+ const datePhx = new DateNanosFormat({ timezone: 'America/Phoenix' }, getConfig);
+ convert = datePhx.convert.bind(datePhx);
+ expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`);
+ });
+
+ test('should format according to UTC if no timezone parameter is given or exists in settings', () => {
+ const utcFormat = 'May 5th 2019, 14:04:56.201900001';
+ const dateUtc = new DateNanosFormat({ timezone: 'UTC' }, getConfig);
+ convert = dateUtc.convert.bind(dateUtc);
+ expect(convert(dateTime)).toBe(utcFormat);
+
+ const dateDefault = new DateNanosFormat({}, getConfig);
+ convert = dateDefault.convert.bind(dateDefault);
+ expect(convert(dateTime)).toBe(utcFormat);
+ });
+
+ test('should format according to dateFormat:tz if the setting is not "Browser"', () => {
+ mockConfig['dateFormat:tz'] = 'America/Phoenix';
+
+ const date = new DateNanosFormat({}, getConfig);
+ convert = date.convert.bind(date);
+ expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`);
+ });
+
+ test('should defer to meta params for timezone, not the UI config', () => {
+ mockConfig['dateFormat:tz'] = 'America/Phoenix';
+
+ const date = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig);
+ convert = date.convert.bind(date);
+ expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`);
+ });
+});
diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts
new file mode 100644
index 0000000000000..299b2aac93d49
--- /dev/null
+++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts
@@ -0,0 +1,79 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { memoize } from 'lodash';
+import moment from 'moment-timezone';
+import {
+ analysePatternForFract,
+ DateNanosFormat,
+ formatWithNanos,
+} from '../../../common/field_formats/converters/date_nanos_shared';
+import { TextContextTypeConvert } from '../../../common';
+
+class DateNanosFormatServer extends DateNanosFormat {
+ textConvert: TextContextTypeConvert = (val) => {
+ // don't give away our ref to converter so
+ // we can hot-swap when config changes
+ const pattern = this.param('pattern');
+ const timezone = this.param('timezone');
+ const fractPattern = analysePatternForFract(pattern);
+ const fallbackPattern = this.param('patternFallback');
+
+ const timezoneChanged = this.timeZone !== timezone;
+ const datePatternChanged = this.memoizedPattern !== pattern;
+ if (timezoneChanged || datePatternChanged) {
+ this.timeZone = timezone;
+ this.memoizedPattern = pattern;
+
+ this.memoizedConverter = memoize((value: any) => {
+ if (value === null || value === undefined) {
+ return '-';
+ }
+
+ /* On the server, importing moment returns a new instance. Unlike on
+ * the client side, it doesn't have the dateFormat:tz configuration
+ * baked in.
+ * We need to set the timezone manually here. The date is taken in as
+ * UTC and converted into the desired timezone. */
+ let date;
+ if (this.timeZone === 'Browser') {
+ // Assume a warning has been logged that this can be unpredictable. It
+ // would be too verbose to log anything here.
+ date = moment.utc(val);
+ } else {
+ date = moment.utc(val).tz(this.timeZone);
+ }
+
+ if (typeof value !== 'string' && date.isValid()) {
+ // fallback for max/min aggregation, where unixtime in ms is returned as a number
+ // aggregations in Elasticsearch generally just return ms
+ return date.format(fallbackPattern);
+ } else if (date.isValid()) {
+ return formatWithNanos(date, value, fractPattern);
+ } else {
+ return value;
+ }
+ });
+ }
+
+ return this.memoizedConverter(val);
+ };
+}
+
+export { DateNanosFormatServer as DateNanosFormat };
diff --git a/src/plugins/data/server/field_formats/converters/index.ts b/src/plugins/data/server/field_formats/converters/index.ts
index f5c69df972869..1c6b827e2fbb5 100644
--- a/src/plugins/data/server/field_formats/converters/index.ts
+++ b/src/plugins/data/server/field_formats/converters/index.ts
@@ -18,3 +18,4 @@
*/
export { DateFormat } from './date_server';
+export { DateNanosFormat } from './date_nanos_server';
diff --git a/src/plugins/data/server/field_formats/field_formats_service.ts b/src/plugins/data/server/field_formats/field_formats_service.ts
index 70584efbee0a0..cafb88de4b893 100644
--- a/src/plugins/data/server/field_formats/field_formats_service.ts
+++ b/src/plugins/data/server/field_formats/field_formats_service.ts
@@ -23,10 +23,14 @@ import {
baseFormatters,
} from '../../common/field_formats';
import { IUiSettingsClient } from '../../../../core/server';
-import { DateFormat } from './converters';
+import { DateFormat, DateNanosFormat } from './converters';
export class FieldFormatsService {
- private readonly fieldFormatClasses: FieldFormatInstanceType[] = [DateFormat, ...baseFormatters];
+ private readonly fieldFormatClasses: FieldFormatInstanceType[] = [
+ DateFormat,
+ DateNanosFormat,
+ ...baseFormatters,
+ ];
public setup() {
return {
diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts
index 31fad75ddb658..321bd913ce760 100644
--- a/src/plugins/data/server/index.ts
+++ b/src/plugins/data/server/index.ts
@@ -86,7 +86,6 @@ import {
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
@@ -105,7 +104,6 @@ export const fieldFormats = {
BoolFormat,
BytesFormat,
ColorFormat,
- DateNanosFormat,
DurationFormat,
IpFormat,
NumberFormat,
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index 0c32358f36caa..88f2cc3264c6e 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -295,7 +295,6 @@ export const fieldFormats: {
BoolFormat: typeof BoolFormat;
BytesFormat: typeof BytesFormat;
ColorFormat: typeof ColorFormat;
- DateNanosFormat: typeof DateNanosFormat;
DurationFormat: typeof DurationFormat;
IpFormat: typeof IpFormat;
NumberFormat: typeof NumberFormat;
@@ -768,31 +767,30 @@ export const UI_SETTINGS: {
// src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:180:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:181:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:182:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:183:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:184:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:185:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
-// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:178:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:179:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:180:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:181:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:182:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:183:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
+// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts
index b7dd95ccba32c..42adb9d770e8a 100644
--- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts
+++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts
@@ -19,7 +19,7 @@
import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks';
import { EmbeddableStateTransfer } from '.';
-import { ApplicationStart, ScopedHistory } from '../../../../../core/public';
+import { ApplicationStart } from '../../../../../core/public';
function mockHistoryState(state: unknown) {
return scopedHistoryMock.create({ state });
@@ -46,10 +46,7 @@ describe('embeddable state transfer', () => {
it('can send an outgoing originating app state in append mode', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
await stateTransfer.navigateToEditor(destinationApp, {
state: { originatingApp },
appendToExistingState: true,
@@ -74,10 +71,7 @@ describe('embeddable state transfer', () => {
it('can send an outgoing embeddable package state in append mode', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, {
state: { type: 'coolestType', id: '150' },
appendToExistingState: true,
@@ -90,40 +84,28 @@ describe('embeddable state transfer', () => {
it('can fetch an incoming originating app state', async () => {
const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
const fetchedState = stateTransfer.getIncomingEditorState();
expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' });
});
it('returns undefined with originating app state is not in the right shape', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
const fetchedState = stateTransfer.getIncomingEditorState();
expect(fetchedState).toBeUndefined();
});
it('can fetch an incoming embeddable package state', async () => {
const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
const fetchedState = stateTransfer.getIncomingEmbeddablePackage();
expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' });
});
it('returns undefined when embeddable package is not in the right shape', async () => {
const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' });
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
const fetchedState = stateTransfer.getIncomingEmbeddablePackage();
expect(fetchedState).toBeUndefined();
});
@@ -135,10 +117,7 @@ describe('embeddable state transfer', () => {
test1: 'test1',
test2: 'test2',
});
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] });
expect(historyMock.replace).toHaveBeenCalledWith(
expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } })
@@ -152,10 +131,7 @@ describe('embeddable state transfer', () => {
test1: 'test1',
test2: 'test2',
});
- stateTransfer = new EmbeddableStateTransfer(
- application.navigateToApp,
- (historyMock as unknown) as ScopedHistory
- );
+ stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock);
stateTransfer.getIncomingEmbeddablePackage();
expect(historyMock.location.state).toEqual({
type: 'skisEmbeddable',
diff --git a/src/plugins/maps_legacy/server/index.ts b/src/plugins/maps_legacy/server/index.ts
index 18f58189fc607..5da3ce1a84408 100644
--- a/src/plugins/maps_legacy/server/index.ts
+++ b/src/plugins/maps_legacy/server/index.ts
@@ -17,8 +17,9 @@
* under the License.
*/
-import { PluginConfigDescriptor } from 'kibana/server';
-import { PluginInitializerContext } from 'kibana/public';
+import { Plugin, PluginConfigDescriptor } from 'kibana/server';
+import { PluginInitializerContext } from 'src/core/server';
+import { Observable } from 'rxjs';
import { configSchema, ConfigSchema } from '../config';
export const config: PluginConfigDescriptor = {
@@ -37,13 +38,27 @@ export const config: PluginConfigDescriptor = {
schema: configSchema,
};
-export const plugin = (initializerContext: PluginInitializerContext) => ({
- setup() {
+export interface MapsLegacyPluginSetup {
+ config$: Observable;
+}
+
+export class MapsLegacyPlugin implements Plugin {
+ readonly _initializerContext: PluginInitializerContext;
+
+ constructor(initializerContext: PluginInitializerContext) {
+ this._initializerContext = initializerContext;
+ }
+
+ public setup() {
// @ts-ignore
- const config$ = initializerContext.config.create();
+ const config$ = this._initializerContext.config.create();
return {
- config: config$,
+ config$,
};
- },
- start() {},
-});
+ }
+
+ public start() {}
+}
+
+export const plugin = (initializerContext: PluginInitializerContext) =>
+ new MapsLegacyPlugin(initializerContext);
diff --git a/src/plugins/vis_type_timeseries/common/metric_types.js b/src/plugins/vis_type_timeseries/common/metric_types.js
index 9dc6085b080e9..05836a6df410a 100644
--- a/src/plugins/vis_type_timeseries/common/metric_types.js
+++ b/src/plugins/vis_type_timeseries/common/metric_types.js
@@ -27,6 +27,9 @@ export const METRIC_TYPES = {
VARIANCE: 'variance',
SUM_OF_SQUARES: 'sum_of_squares',
CARDINALITY: 'cardinality',
+ VALUE_COUNT: 'value_count',
+ AVERAGE: 'avg',
+ SUM: 'sum',
};
export const EXTENDED_STATS_TYPES = [
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx
new file mode 100644
index 0000000000000..968fa5384e1d8
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx
@@ -0,0 +1,184 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { AggSelect } from './agg_select';
+import { METRIC, SERIES } from '../../../test_utils';
+import { EuiComboBox } from '@elastic/eui';
+
+describe('TSVB AggSelect', () => {
+ const setup = (panelType: string, value: string) => {
+ const metric = {
+ ...METRIC,
+ type: 'filter_ratio',
+ field: 'histogram_value',
+ };
+ const series = { ...SERIES, metrics: [metric] };
+
+ const wrapper = mountWithIntl(
+
+ );
+ return wrapper;
+ };
+
+ it('should only display filter ratio compattible aggs', () => {
+ const wrapper = setup('filter_ratio', 'avg');
+ expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "Average",
+ "value": "avg",
+ },
+ Object {
+ "label": "Cardinality",
+ "value": "cardinality",
+ },
+ Object {
+ "label": "Count",
+ "value": "count",
+ },
+ Object {
+ "label": "Positive Rate",
+ "value": "positive_rate",
+ },
+ Object {
+ "label": "Max",
+ "value": "max",
+ },
+ Object {
+ "label": "Min",
+ "value": "min",
+ },
+ Object {
+ "label": "Sum",
+ "value": "sum",
+ },
+ Object {
+ "label": "Value Count",
+ "value": "value_count",
+ },
+ ]
+ `);
+ });
+
+ it('should only display histogram compattible aggs', () => {
+ const wrapper = setup('histogram', 'avg');
+ expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "Average",
+ "value": "avg",
+ },
+ Object {
+ "label": "Count",
+ "value": "count",
+ },
+ Object {
+ "label": "Sum",
+ "value": "sum",
+ },
+ Object {
+ "label": "Value Count",
+ "value": "value_count",
+ },
+ ]
+ `);
+ });
+
+ it('should only display metrics compattible aggs', () => {
+ const wrapper = setup('metrics', 'avg');
+ expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "Average",
+ "value": "avg",
+ },
+ Object {
+ "label": "Cardinality",
+ "value": "cardinality",
+ },
+ Object {
+ "label": "Count",
+ "value": "count",
+ },
+ Object {
+ "label": "Filter Ratio",
+ "value": "filter_ratio",
+ },
+ Object {
+ "label": "Positive Rate",
+ "value": "positive_rate",
+ },
+ Object {
+ "label": "Max",
+ "value": "max",
+ },
+ Object {
+ "label": "Min",
+ "value": "min",
+ },
+ Object {
+ "label": "Percentile",
+ "value": "percentile",
+ },
+ Object {
+ "label": "Percentile Rank",
+ "value": "percentile_rank",
+ },
+ Object {
+ "label": "Static Value",
+ "value": "static",
+ },
+ Object {
+ "label": "Std. Deviation",
+ "value": "std_deviation",
+ },
+ Object {
+ "label": "Sum",
+ "value": "sum",
+ },
+ Object {
+ "label": "Sum of Squares",
+ "value": "sum_of_squares",
+ },
+ Object {
+ "label": "Top Hit",
+ "value": "top_hit",
+ },
+ Object {
+ "label": "Value Count",
+ "value": "value_count",
+ },
+ Object {
+ "label": "Variance",
+ "value": "variance",
+ },
+ ]
+ `);
+ });
+});
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx
index 6fa1a2adaa08e..7701d351e5478 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx
@@ -225,6 +225,19 @@ const specialAggs: AggSelectOption[] = [
},
];
+const FILTER_RATIO_AGGS = [
+ 'avg',
+ 'cardinality',
+ 'count',
+ 'positive_rate',
+ 'max',
+ 'min',
+ 'sum',
+ 'value_count',
+];
+
+const HISTOGRAM_AGGS = ['avg', 'count', 'sum', 'value_count'];
+
const allAggOptions = [...metricAggs, ...pipelineAggs, ...siblingAggs, ...specialAggs];
function filterByPanelType(panelType: string) {
@@ -257,6 +270,10 @@ export function AggSelect(props: AggSelectUiProps) {
let options: EuiComboBoxOptionOption[];
if (panelType === 'metrics') {
options = metricAggs;
+ } else if (panelType === 'filter_ratio') {
+ options = metricAggs.filter((m) => FILTER_RATIO_AGGS.includes(`${m.value}`));
+ } else if (panelType === 'histogram') {
+ options = metricAggs.filter((m) => HISTOGRAM_AGGS.includes(`${m.value}`));
} else {
const disableSiblingAggs = (agg: AggSelectOption) => ({
...agg,
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js
index b5311e3832da4..2aa994c09a2ad 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js
@@ -36,7 +36,15 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public';
-import { METRIC_TYPES } from '../../../../../../plugins/vis_type_timeseries/common/metric_types';
+import { getSupportedFieldsByMetricType } from '../lib/get_supported_fields_by_metric_type';
+
+const isFieldHistogram = (fields, indexPattern, field) => {
+ const indexFields = fields[indexPattern];
+ if (!indexFields) return false;
+ const fieldObject = indexFields.find((f) => f.name === field);
+ if (!fieldObject) return false;
+ return fieldObject.type === KBN_FIELD_TYPES.HISTOGRAM;
+};
export const FilterRatioAgg = (props) => {
const { series, fields, panel } = props;
@@ -56,9 +64,6 @@ export const FilterRatioAgg = (props) => {
const model = { ...defaults, ...props.model };
const htmlId = htmlIdGenerator();
- const restrictFields =
- model.metric_agg === METRIC_TYPES.CARDINALITY ? [] : [KBN_FIELD_TYPES.NUMBER];
-
return (
{
@@ -149,7 +156,7 @@ export const FilterRatioAgg = (props) => {
{
+ const setup = (metric) => {
+ const series = { ...SERIES, metrics: [metric] };
+ const panel = { ...PANEL, series };
+
+ const wrapper = mountWithIntl(
+
+
+
+ );
+ return wrapper;
+ };
+
+ describe('histogram support', () => {
+ it('should only display histogram compattible aggs', () => {
+ const metric = {
+ ...METRIC,
+ metric_agg: 'avg',
+ field: 'histogram_value',
+ };
+ const wrapper = setup(metric);
+ expect(wrapper.find(EuiComboBox).at(1).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "Average",
+ "value": "avg",
+ },
+ Object {
+ "label": "Count",
+ "value": "count",
+ },
+ Object {
+ "label": "Sum",
+ "value": "sum",
+ },
+ Object {
+ "label": "Value Count",
+ "value": "value_count",
+ },
+ ]
+ `);
+ });
+ const shouldNotHaveHistogramField = (agg) => {
+ it(`should not have histogram fields for ${agg}`, () => {
+ const metric = {
+ ...METRIC,
+ metric_agg: agg,
+ field: '',
+ };
+ const wrapper = setup(metric);
+ expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "number",
+ "options": Array [
+ Object {
+ "label": "system.cpu.user.pct",
+ "value": "system.cpu.user.pct",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+ };
+ shouldNotHaveHistogramField('max');
+ shouldNotHaveHistogramField('min');
+ shouldNotHaveHistogramField('positive_rate');
+
+ it(`should not have histogram fields for cardinality`, () => {
+ const metric = {
+ ...METRIC,
+ metric_agg: 'cardinality',
+ field: '',
+ };
+ const wrapper = setup(metric);
+ expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "label": "date",
+ "options": Array [
+ Object {
+ "label": "@timestamp",
+ "value": "@timestamp",
+ },
+ ],
+ },
+ Object {
+ "label": "number",
+ "options": Array [
+ Object {
+ "label": "system.cpu.user.pct",
+ "value": "system.cpu.user.pct",
+ },
+ ],
+ },
+ ]
+ `);
+ });
+ });
+});
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js
new file mode 100644
index 0000000000000..7af33ba11f247
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js
@@ -0,0 +1,94 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { Agg } from './agg';
+import { FieldSelect } from './field_select';
+import { FIELDS, METRIC, SERIES, PANEL } from '../../../test_utils';
+const runTest = (aggType, name, test, additionalProps = {}) => {
+ describe(aggType, () => {
+ const metric = {
+ ...METRIC,
+ type: aggType,
+ field: 'histogram_value',
+ ...additionalProps,
+ };
+ const series = { ...SERIES, metrics: [metric] };
+ const panel = { ...PANEL, series };
+
+ it(name, () => {
+ const wrapper = mountWithIntl(
+
+ );
+ test(wrapper);
+ });
+ });
+};
+
+describe('Histogram Types', () => {
+ describe('supported', () => {
+ const shouldHaveHistogramSupport = (aggType, additionalProps = {}) => {
+ runTest(
+ aggType,
+ 'supports',
+ (wrapper) =>
+ expect(wrapper.find(FieldSelect).at(0).props().restrict).toContain('histogram'),
+ additionalProps
+ );
+ };
+ shouldHaveHistogramSupport('avg');
+ shouldHaveHistogramSupport('sum');
+ shouldHaveHistogramSupport('value_count');
+ shouldHaveHistogramSupport('percentile');
+ shouldHaveHistogramSupport('percentile_rank');
+ shouldHaveHistogramSupport('filter_ratio', { metric_agg: 'avg' });
+ });
+ describe('not supported', () => {
+ const shouldNotHaveHistogramSupport = (aggType, additionalProps = {}) => {
+ runTest(
+ aggType,
+ 'does not support',
+ (wrapper) =>
+ expect(wrapper.find(FieldSelect).at(0).props().restrict).not.toContain('histogram'),
+ additionalProps
+ );
+ };
+ shouldNotHaveHistogramSupport('cardinality');
+ shouldNotHaveHistogramSupport('max');
+ shouldNotHaveHistogramSupport('min');
+ shouldNotHaveHistogramSupport('variance');
+ shouldNotHaveHistogramSupport('sum_of_squares');
+ shouldNotHaveHistogramSupport('std_deviation');
+ shouldNotHaveHistogramSupport('positive_rate');
+ shouldNotHaveHistogramSupport('top_hit');
+ });
+});
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js
index 6a7bf1bffe83c..f12c0c8f6f465 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js
@@ -36,7 +36,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public';
import { Percentiles, newPercentile } from './percentile_ui';
-const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER];
+const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM];
const checkModel = (model) => Array.isArray(model.percentiles);
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx
index a16f5aeefc49c..d02a16ade2bba 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx
@@ -41,7 +41,7 @@ import { IFieldType, KBN_FIELD_TYPES } from '../../../../../../../plugins/data/p
import { MetricsItemsSchema, PanelSchema, SeriesItemsSchema } from '../../../../../common/types';
import { DragHandleProps } from '../../../../types';
-const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER];
+const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM];
interface PercentileRankAggProps {
disableDelete: boolean;
diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js
index 3ca89f7289d65..c20bcc1babc1d 100644
--- a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js
+++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js
@@ -123,7 +123,7 @@ export const PositiveRateAgg = (props) => {
t !== KBN_FIELD_TYPES.HISTOGRAM);
+ case METRIC_TYPES.VALUE_COUNT:
+ case METRIC_TYPES.AVERAGE:
+ case METRIC_TYPES.SUM:
+ return [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM];
+ default:
+ return [KBN_FIELD_TYPES.NUMBER];
+ }
+}
diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js
new file mode 100644
index 0000000000000..3cd3fac191bf1
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js
@@ -0,0 +1,44 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { getSupportedFieldsByMetricType } from './get_supported_fields_by_metric_type';
+
+describe('getSupportedFieldsByMetricType', () => {
+ const shouldHaveHistogramAndNumbers = (type) =>
+ it(`should return numbers and histogram for ${type}`, () => {
+ expect(getSupportedFieldsByMetricType(type)).toEqual(['number', 'histogram']);
+ });
+ const shouldHaveOnlyNumbers = (type) =>
+ it(`should return only numbers for ${type}`, () => {
+ expect(getSupportedFieldsByMetricType(type)).toEqual(['number']);
+ });
+
+ shouldHaveHistogramAndNumbers('value_count');
+ shouldHaveHistogramAndNumbers('avg');
+ shouldHaveHistogramAndNumbers('sum');
+
+ shouldHaveOnlyNumbers('positive_rate');
+ shouldHaveOnlyNumbers('std_deviation');
+ shouldHaveOnlyNumbers('max');
+ shouldHaveOnlyNumbers('min');
+
+ it(`should return everything but histogram for cardinality`, () => {
+ expect(getSupportedFieldsByMetricType('cardinality')).not.toContain('histogram');
+ });
+});
diff --git a/src/plugins/vis_type_timeseries/public/test_utils/index.ts b/src/plugins/vis_type_timeseries/public/test_utils/index.ts
new file mode 100644
index 0000000000000..96ecc89b70c2d
--- /dev/null
+++ b/src/plugins/vis_type_timeseries/public/test_utils/index.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const UI_RESTRICTIONS = { '*': true };
+export const INDEX_PATTERN = 'some-pattern';
+export const FIELDS = {
+ [INDEX_PATTERN]: [
+ {
+ type: 'date',
+ name: '@timestamp',
+ },
+ {
+ type: 'number',
+ name: 'system.cpu.user.pct',
+ },
+ {
+ type: 'histogram',
+ name: 'histogram_value',
+ },
+ ],
+};
+export const METRIC = {
+ id: 'sample_metric',
+ type: 'avg',
+ field: 'system.cpu.user.pct',
+};
+export const SERIES = {
+ metrics: [METRIC],
+};
+export const PANEL = {
+ type: 'timeseries',
+ index_pattern: INDEX_PATTERN,
+ series: SERIES,
+};
diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss
index 6d96fa39e7c34..96c72bd5956d2 100644
--- a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss
+++ b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss
@@ -304,11 +304,14 @@
.series > path,
.series > rect {
- fill-opacity: .8;
stroke-opacity: 1;
stroke-width: 0;
}
+ .series > path {
+ fill-opacity: .8;
+ }
+
.blur_shape {
// sass-lint:disable-block no-important
opacity: .3 !important;
diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js
index 6cb9d5dccdc9a..7db968df8357a 100644
--- a/test/api_integration/apis/saved_objects/bulk_create.js
+++ b/test/api_integration/apis/saved_objects/bulk_create.js
@@ -76,6 +76,7 @@ export default function ({ getService }) {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
references: [],
+ namespaces: ['default'],
},
],
});
@@ -121,6 +122,7 @@ export default function ({ getService }) {
title: 'An existing visualization',
},
references: [],
+ namespaces: ['default'],
migrationVersion: {
visualization: resp.body.saved_objects[0].migrationVersion.visualization,
},
@@ -134,6 +136,7 @@ export default function ({ getService }) {
title: 'A great new dashboard',
},
references: [],
+ namespaces: ['default'],
migrationVersion: {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js
index c802d52913065..56ee5a69be23e 100644
--- a/test/api_integration/apis/saved_objects/bulk_get.js
+++ b/test/api_integration/apis/saved_objects/bulk_get.js
@@ -68,6 +68,7 @@ export default function ({ getService }) {
resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta,
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
@@ -94,6 +95,7 @@ export default function ({ getService }) {
buildNum: 8467,
defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
+ namespaces: ['default'],
migrationVersion: resp.body.saved_objects[2].migrationVersion,
references: [],
},
diff --git a/test/api_integration/apis/saved_objects/bulk_update.js b/test/api_integration/apis/saved_objects/bulk_update.js
index e3f994ff224e8..973ce382ea813 100644
--- a/test/api_integration/apis/saved_objects/bulk_update.js
+++ b/test/api_integration/apis/saved_objects/bulk_update.js
@@ -65,6 +65,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing visualization',
},
+ namespaces: ['default'],
});
expect(secondObject)
@@ -77,6 +78,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing dashboard',
},
+ namespaces: ['default'],
});
});
@@ -233,6 +235,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing dashboard',
},
+ namespaces: ['default'],
});
});
});
diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js
index eddda3aded141..c1300125441bc 100644
--- a/test/api_integration/apis/saved_objects/create.js
+++ b/test/api_integration/apis/saved_objects/create.js
@@ -58,6 +58,7 @@ export default function ({ getService }) {
title: 'My favorite vis',
},
references: [],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
});
@@ -104,6 +105,7 @@ export default function ({ getService }) {
title: 'My favorite vis',
},
references: [],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
});
diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js
index 7cb5955e4a43d..f129bf22840da 100644
--- a/test/api_integration/apis/saved_objects/find.js
+++ b/test/api_integration/apis/saved_objects/find.js
@@ -48,6 +48,7 @@ export default function ({ getService }) {
},
score: 0,
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
@@ -107,6 +108,93 @@ export default function ({ getService }) {
}));
});
+ describe('unknown namespace', () => {
+ it('should return 200 with empty response', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&namespaces=foo')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 0,
+ saved_objects: [],
+ });
+ }));
+ });
+
+ describe('known namespace', () => {
+ it('should return 200 with individual responses', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 1,
+ saved_objects: [
+ {
+ type: 'visualization',
+ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ version: 'WzIsMV0=',
+ attributes: {
+ title: 'Count of requests',
+ },
+ migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
+ score: 0,
+ references: [
+ {
+ id: '91200a00-9efd-11e7-acb3-3dab96693fab',
+ name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
+ type: 'index-pattern',
+ },
+ ],
+ updated_at: '2017-09-21T18:51:23.794Z',
+ },
+ ],
+ });
+ expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
+ }));
+ });
+
+ describe('wildcard namespace', () => {
+ it('should return 200 with individual responses from the default namespace', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 1,
+ saved_objects: [
+ {
+ type: 'visualization',
+ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ version: 'WzIsMV0=',
+ attributes: {
+ title: 'Count of requests',
+ },
+ migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
+ score: 0,
+ references: [
+ {
+ id: '91200a00-9efd-11e7-acb3-3dab96693fab',
+ name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
+ type: 'index-pattern',
+ },
+ ],
+ updated_at: '2017-09-21T18:51:23.794Z',
+ },
+ ],
+ });
+ expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
+ }));
+ });
+
describe('with a filter', () => {
it('should return 200 with a valid response', async () =>
await supertest
@@ -135,6 +223,7 @@ export default function ({ getService }) {
.searchSourceJSON,
},
},
+ namespaces: ['default'],
score: 0,
references: [
{
diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js
index 55dfda251a75a..6bb5cf0c8a7ff 100644
--- a/test/api_integration/apis/saved_objects/get.js
+++ b/test/api_integration/apis/saved_objects/get.js
@@ -56,6 +56,7 @@ export default function ({ getService }) {
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
}));
diff --git a/test/api_integration/apis/saved_objects/update.js b/test/api_integration/apis/saved_objects/update.js
index d613f46878bb5..7803c39897f28 100644
--- a/test/api_integration/apis/saved_objects/update.js
+++ b/test/api_integration/apis/saved_objects/update.js
@@ -56,6 +56,7 @@ export default function ({ getService }) {
attributes: {
title: 'My second favorite vis',
},
+ namespaces: ['default'],
});
});
});
diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts
index b5154d619685a..08c4327d7c0c4 100644
--- a/test/api_integration/apis/saved_objects_management/find.ts
+++ b/test/api_integration/apis/saved_objects_management/find.ts
@@ -49,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) {
title: 'Count of requests',
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js
index cb8b5a6ddc65f..97f2641b51d13 100644
--- a/test/functional/apps/management/_create_index_pattern_wizard.js
+++ b/test/functional/apps/management/_create_index_pattern_wizard.js
@@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }) {
const es = getService('legacyEs');
const PageObjects = getPageObjects(['settings', 'common']);
- describe('"Create Index Pattern" wizard', function () {
+ // Flaky: https://github.com/elastic/kibana/issues/71501
+ describe.skip('"Create Index Pattern" wizard', function () {
before(async function () {
// delete .kibana index and then wait for Kibana to re-create it
await kibanaServer.uiSettings.replace({});
diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts
index 2c9200c2f8d93..0e2ff44ff62ef 100644
--- a/test/functional/apps/saved_objects_management/edit_saved_object.ts
+++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts
@@ -66,6 +66,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await button.click();
};
+ // Flaky: https://github.com/elastic/kibana/issues/68400
describe('saved objects edition page', () => {
beforeEach(async () => {
await esArchiver.load('saved_objects_management/edit_saved_object');
diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy
index f3fc5f84583c9..f43fe9f96c3ef 100644
--- a/vars/kibanaPipeline.groovy
+++ b/vars/kibanaPipeline.groovy
@@ -209,7 +209,7 @@ def runErrorReporter() {
bash(
"""
source src/dev/ci_setup/setup_env.sh
- node scripts/report_failed_tests ${dryRun}
+ node scripts/report_failed_tests ${dryRun} target/junit/**/*.xml
""",
"Report failed tests, if necessary"
)
diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md
index 9e07727204f88..e6b22da7a1fe3 100644
--- a/x-pack/plugins/actions/README.md
+++ b/x-pack/plugins/actions/README.md
@@ -437,9 +437,12 @@ The config and params properties are modelled after the [Watcher Index Action](h
### `config`
-| Property | Description | Type |
-| -------- | -------------------------------------- | ------------------- |
-| index | The Elasticsearch index to index into. | string _(optional)_ |
+| Property | Description | Type |
+| -------------------- | ---------------------------------------------------------- | -------------------- |
+| index | The Elasticsearch index to index into. | string _(optional)_ |
+| doc_id | The optional \_id of the document. | string _(optional)_ |
+| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ |
+| refresh | Setting of the refresh policy for the write request. | boolean _(optional)_ |
### `secrets`
@@ -447,13 +450,9 @@ This action type has no `secrets` properties.
### `params`
-| Property | Description | Type |
-| -------------------- | ---------------------------------------------------------- | -------------------- |
-| index | The Elasticsearch index to index into. | string _(optional)_ |
-| doc_id | The optional \_id of the document. | string _(optional)_ |
-| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ |
-| refresh | Setting of the refresh policy for the write request | boolean _(optional)_ |
-| body | The documument body/bodies to index. | object or object[] |
+| Property | Description | Type |
+| --------- | ---------------------------------------- | ------------------- |
+| documents | JSON object that describes the [document](https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index.html#getting-started-batch-processing). | object[] |
---
diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx
new file mode 100644
index 0000000000000..268d8bd7ea823
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx
@@ -0,0 +1,43 @@
+/*
+ * 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 { showAlert } from './AnomalyDetectionSetupLink';
+
+describe('#showAlert', () => {
+ describe('when an environment is selected', () => {
+ it('should return true when there are no jobs', () => {
+ const result = showAlert([], 'testing');
+ expect(result).toBe(true);
+ });
+ it('should return true when environment is not included in the jobs', () => {
+ const result = showAlert(
+ [{ environment: 'staging' }, { environment: 'production' }],
+ 'testing'
+ );
+ expect(result).toBe(true);
+ });
+ it('should return false when environment is included in the jobs', () => {
+ const result = showAlert(
+ [{ environment: 'staging' }, { environment: 'production' }],
+ 'staging'
+ );
+ expect(result).toBe(false);
+ });
+ });
+ describe('there is no environment selected (All)', () => {
+ it('should return true when there are no jobs', () => {
+ const result = showAlert([], undefined);
+ expect(result).toBe(true);
+ });
+ it('should return false when there are any number of jobs', () => {
+ const result = showAlert(
+ [{ environment: 'staging' }, { environment: 'production' }],
+ undefined
+ );
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx
index 88d15239b8fba..6f3a5df480d7e 100644
--- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx
+++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx
@@ -23,16 +23,12 @@ export function AnomalyDetectionSetupLink() {
);
const isFetchSuccess = status === FETCH_STATUS.SUCCESS;
- // Show alert if there are no jobs OR if no job matches the current environment
- const showAlert =
- isFetchSuccess && !data.jobs.some((job) => environment === job.environment);
-
return (
{ANOMALY_DETECTION_LINK_LABEL}
- {showAlert && (
+ {isFetchSuccess && showAlert(data.jobs, environment) && (
@@ -61,3 +57,16 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate(
'xpack.apm.anomalyDetectionSetup.linkLabel',
{ defaultMessage: `Anomaly detection` }
);
+
+export function showAlert(
+ jobs: Array<{ environment: string }> = [],
+ environment: string | undefined
+) {
+ return (
+ // No job exists, or
+ jobs.length === 0 ||
+ // no job exists for the selected environment
+ (environment !== undefined &&
+ jobs.every((job) => environment !== job.environment))
+ );
+}
diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts
index 6e3a29d9f3dbc..f264ae6cd9852 100644
--- a/x-pack/plugins/apm/public/plugin.ts
+++ b/x-pack/plugins/apm/public/plugin.ts
@@ -39,9 +39,9 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav';
import { setReadonlyBadge } from './updateBadge';
import { createStaticIndexPattern } from './services/rest/index_pattern';
import {
- fetchLandingPageData,
+ fetchOverviewPageData,
hasData,
-} from './services/rest/observability_dashboard';
+} from './services/rest/apm_overview_fetchers';
export type ApmPluginSetup = void;
export type ApmPluginStart = void;
@@ -81,9 +81,7 @@ export class ApmPlugin implements Plugin {
if (plugins.observability) {
plugins.observability.dashboard.register({
appName: 'apm',
- fetchData: async (params) => {
- return fetchLandingPageData(params);
- },
+ fetchData: fetchOverviewPageData,
hasData,
});
}
diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts
similarity index 78%
rename from x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts
rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts
index fd407a8bf72ad..8b3ed38e25319 100644
--- a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts
+++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts
@@ -4,11 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { fetchLandingPageData, hasData } from './observability_dashboard';
+import moment from 'moment';
+import { fetchOverviewPageData, hasData } from './apm_overview_fetchers';
import * as createCallApmApi from './createCallApmApi';
describe('Observability dashboard data', () => {
const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi');
+ const params = {
+ absoluteTime: {
+ start: moment('2020-07-02T13:25:11.629Z').valueOf(),
+ end: moment('2020-07-09T14:25:11.629Z').valueOf(),
+ },
+ relativeTime: {
+ start: 'now-15m',
+ end: 'now',
+ },
+ bucketSize: '600s',
+ };
afterEach(() => {
callApmApiMock.mockClear();
});
@@ -25,7 +37,7 @@ describe('Observability dashboard data', () => {
});
});
- describe('fetchLandingPageData', () => {
+ describe('fetchOverviewPageData', () => {
it('returns APM data with series and stats', async () => {
callApmApiMock.mockImplementation(() =>
Promise.resolve({
@@ -37,14 +49,9 @@ describe('Observability dashboard data', () => {
],
})
);
- const response = await fetchLandingPageData({
- startTime: '1',
- endTime: '2',
- bucketSize: '3',
- });
+ const response = await fetchOverviewPageData(params);
expect(response).toEqual({
- title: 'APM',
- appLink: '/app/apm',
+ appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now',
stats: {
services: {
type: 'number',
@@ -73,14 +80,9 @@ describe('Observability dashboard data', () => {
transactionCoordinates: [],
})
);
- const response = await fetchLandingPageData({
- startTime: '1',
- endTime: '2',
- bucketSize: '3',
- });
+ const response = await fetchOverviewPageData(params);
expect(response).toEqual({
- title: 'APM',
- appLink: '/app/apm',
+ appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now',
stats: {
services: {
type: 'number',
@@ -105,14 +107,9 @@ describe('Observability dashboard data', () => {
transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }],
})
);
- const response = await fetchLandingPageData({
- startTime: '1',
- endTime: '2',
- bucketSize: '3',
- });
+ const response = await fetchOverviewPageData(params);
expect(response).toEqual({
- title: 'APM',
- appLink: '/app/apm',
+ appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now',
stats: {
services: {
type: 'number',
diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts
similarity index 70%
rename from x-pack/plugins/apm/public/services/rest/observability_dashboard.ts
rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts
index 409cec8b9ce10..78f3a0a0aaa80 100644
--- a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts
+++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { i18n } from '@kbn/i18n';
import { mean } from 'lodash';
import {
ApmFetchDataResponse,
@@ -12,23 +11,26 @@ import {
} from '../../../../observability/public';
import { callApmApi } from './createCallApmApi';
-export const fetchLandingPageData = async ({
- startTime,
- endTime,
+export const fetchOverviewPageData = async ({
+ absoluteTime,
+ relativeTime,
bucketSize,
}: FetchDataParams): Promise => {
const data = await callApmApi({
- pathname: '/api/apm/observability_dashboard',
- params: { query: { start: startTime, end: endTime, bucketSize } },
+ pathname: '/api/apm/observability_overview',
+ params: {
+ query: {
+ start: new Date(absoluteTime.start).toISOString(),
+ end: new Date(absoluteTime.end).toISOString(),
+ bucketSize,
+ },
+ },
});
const { serviceCount, transactionCoordinates } = data;
return {
- title: i18n.translate('xpack.apm.observabilityDashboard.title', {
- defaultMessage: 'APM',
- }),
- appLink: '/app/apm',
+ appLink: `/app/apm#/services?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`,
stats: {
services: {
type: 'number',
@@ -54,6 +56,6 @@ export const fetchLandingPageData = async ({
export async function hasData() {
return await callApmApi({
- pathname: '/api/apm/observability_dashboard/has_data',
+ pathname: '/api/apm/observability_overview/has_data',
});
}
diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts
similarity index 100%
rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts
rename to x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts
diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts
similarity index 100%
rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts
rename to x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts
diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts
similarity index 100%
rename from x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts
rename to x-pack/plugins/apm/server/lib/observability_overview/has_data.ts
diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts
index 2f5e703251c03..154821b261fd1 100644
--- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts
+++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts
@@ -31,7 +31,7 @@ export async function getMlBucketSize({
body: {
_source: 'bucket_span',
size: 1,
- terminateAfter: 1,
+ terminate_after: 1,
query: {
bool: {
filter: [
diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts
index 513c44904683e..0a4295fea3997 100644
--- a/x-pack/plugins/apm/server/routes/create_apm_api.ts
+++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts
@@ -79,9 +79,9 @@ import {
rumServicesRoute,
} from './rum_client';
import {
- observabilityDashboardHasDataRoute,
- observabilityDashboardDataRoute,
-} from './observability_dashboard';
+ observabilityOverviewHasDataRoute,
+ observabilityOverviewRoute,
+} from './observability_overview';
import {
anomalyDetectionJobsRoute,
createAnomalyDetectionJobsRoute,
@@ -176,8 +176,8 @@ const createApmApi = () => {
.add(rumServicesRoute)
// Observability dashboard
- .add(observabilityDashboardHasDataRoute)
- .add(observabilityDashboardDataRoute)
+ .add(observabilityOverviewHasDataRoute)
+ .add(observabilityOverviewRoute)
// Anomaly detection
.add(anomalyDetectionJobsRoute)
diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts
similarity index 74%
rename from x-pack/plugins/apm/server/routes/observability_dashboard.ts
rename to x-pack/plugins/apm/server/routes/observability_overview.ts
index 10c74295fe3e4..d5bb3b49c2f4c 100644
--- a/x-pack/plugins/apm/server/routes/observability_dashboard.ts
+++ b/x-pack/plugins/apm/server/routes/observability_overview.ts
@@ -5,22 +5,22 @@
*/
import * as t from 'io-ts';
import { setupRequest } from '../lib/helpers/setup_request';
-import { hasData } from '../lib/observability_dashboard/has_data';
+import { getServiceCount } from '../lib/observability_overview/get_service_count';
+import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates';
+import { hasData } from '../lib/observability_overview/has_data';
import { createRoute } from './create_route';
import { rangeRt } from './default_api_types';
-import { getServiceCount } from '../lib/observability_dashboard/get_service_count';
-import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates';
-export const observabilityDashboardHasDataRoute = createRoute(() => ({
- path: '/api/apm/observability_dashboard/has_data',
+export const observabilityOverviewHasDataRoute = createRoute(() => ({
+ path: '/api/apm/observability_overview/has_data',
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return await hasData({ setup });
},
}));
-export const observabilityDashboardDataRoute = createRoute(() => ({
- path: '/api/apm/observability_dashboard',
+export const observabilityOverviewRoute = createRoute(() => ({
+ path: '/api/apm/observability_overview',
params: {
query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]),
},
diff --git a/x-pack/plugins/canvas/.storybook/storyshots.test.js b/x-pack/plugins/canvas/.storybook/storyshots.test.js
index a3412c3a14e79..7195b97712464 100644
--- a/x-pack/plugins/canvas/.storybook/storyshots.test.js
+++ b/x-pack/plugins/canvas/.storybook/storyshots.test.js
@@ -84,6 +84,10 @@ import { RenderedElement } from '../shareable_runtime/components/rendered_elemen
jest.mock('../shareable_runtime/components/rendered_element');
RenderedElement.mockImplementation(() => 'RenderedElement');
+import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer';
+jest.mock('@elastic/eui/test-env/components/observer/observer');
+EuiObserver.mockImplementation(() => 'EuiObserver');
+
addSerializer(styleSheetSerializer);
// Initialize Storyshots and build the Jest Snapshots
diff --git a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts
index 271fc7a979057..4b1f31cb14687 100644
--- a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts
+++ b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts
@@ -25,6 +25,7 @@ const BaseWorkpad: CanvasWorkpad = {
pages: [],
colors: [],
isWriteable: true,
+ variables: [],
};
const BasePage: CanvasPage = {
diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js
index 7384986fa5c2b..618fe756ba0a4 100644
--- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js
+++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js
@@ -107,7 +107,7 @@ const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => {
diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts
index 8acda5da4f0d2..78083f26a38b1 100644
--- a/x-pack/plugins/canvas/i18n/components.ts
+++ b/x-pack/plugins/canvas/i18n/components.ts
@@ -545,7 +545,7 @@ export const ComponentStrings = {
}),
getTitle: () =>
i18n.translate('xpack.canvas.pageConfig.title', {
- defaultMessage: 'Page styles',
+ defaultMessage: 'Page settings',
}),
getTransitionLabel: () =>
i18n.translate('xpack.canvas.pageConfig.transitionLabel', {
@@ -899,6 +899,144 @@ export const ComponentStrings = {
defaultMessage: 'Close tray',
}),
},
+ VarConfig: {
+ getAddButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.addButtonLabel', {
+ defaultMessage: 'Add a variable',
+ }),
+ getAddTooltipLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.addTooltipLabel', {
+ defaultMessage: 'Add a variable',
+ }),
+ getCopyActionButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.copyActionButtonLabel', {
+ defaultMessage: 'Copy snippet',
+ }),
+ getCopyActionTooltipLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.copyActionTooltipLabel', {
+ defaultMessage: 'Copy variable syntax to clipboard',
+ }),
+ getCopyNotificationDescription: () =>
+ i18n.translate('xpack.canvas.varConfig.copyNotificationDescription', {
+ defaultMessage: 'Variable syntax copied to clipboard',
+ }),
+ getDeleteActionButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.deleteActionButtonLabel', {
+ defaultMessage: 'Delete variable',
+ }),
+ getDeleteNotificationDescription: () =>
+ i18n.translate('xpack.canvas.varConfig.deleteNotificationDescription', {
+ defaultMessage: 'Variable successfully deleted',
+ }),
+ getEditActionButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.editActionButtonLabel', {
+ defaultMessage: 'Edit variable',
+ }),
+ getEmptyDescription: () =>
+ i18n.translate('xpack.canvas.varConfig.emptyDescription', {
+ defaultMessage:
+ 'This workpad has no variables currently. You may add variables to store and edit common values. These variables can then be used in elements or within the expression editor.',
+ }),
+ getTableNameLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.tableNameLabel', {
+ defaultMessage: 'Name',
+ }),
+ getTableTypeLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.tableTypeLabel', {
+ defaultMessage: 'Type',
+ }),
+ getTableValueLabel: () =>
+ i18n.translate('xpack.canvas.varConfig.tableValueLabel', {
+ defaultMessage: 'Value',
+ }),
+ getTitle: () =>
+ i18n.translate('xpack.canvas.varConfig.titleLabel', {
+ defaultMessage: 'Variables',
+ }),
+ getTitleTooltip: () =>
+ i18n.translate('xpack.canvas.varConfig.titleTooltip', {
+ defaultMessage: 'Add variables to store and edit common values',
+ }),
+ },
+ VarConfigDeleteVar: {
+ getCancelButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfigDeleteVar.cancelButtonLabel', {
+ defaultMessage: 'Cancel',
+ }),
+ getDeleteButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfigDeleteVar.deleteButtonLabel', {
+ defaultMessage: 'Delete variable',
+ }),
+ getTitle: () =>
+ i18n.translate('xpack.canvas.varConfigDeleteVar.titleLabel', {
+ defaultMessage: 'Delete variable?',
+ }),
+ getWarningDescription: () =>
+ i18n.translate('xpack.canvas.varConfigDeleteVar.warningDescription', {
+ defaultMessage:
+ 'Deleting this variable may adversely affect the workpad. Are you sure you wish to continue?',
+ }),
+ },
+ VarConfigEditVar: {
+ getAddTitle: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.addTitleLabel', {
+ defaultMessage: 'Add variable',
+ }),
+ getCancelButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.cancelButtonLabel', {
+ defaultMessage: 'Cancel',
+ }),
+ getDuplicateNameError: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.duplicateNameError', {
+ defaultMessage: 'Variable name already in use',
+ }),
+ getEditTitle: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.editTitleLabel', {
+ defaultMessage: 'Edit variable',
+ }),
+ getEditWarning: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.editWarning', {
+ defaultMessage: 'Editing a variable in use may adversely affect your workpad',
+ }),
+ getNameFieldLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.nameFieldLabel', {
+ defaultMessage: 'Name',
+ }),
+ getSaveButtonLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.saveButtonLabel', {
+ defaultMessage: 'Save changes',
+ }),
+ getTypeBooleanLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.typeBooleanLabel', {
+ defaultMessage: 'Boolean',
+ }),
+ getTypeFieldLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.typeFieldLabel', {
+ defaultMessage: 'Type',
+ }),
+ getTypeNumberLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.typeNumberLabel', {
+ defaultMessage: 'Number',
+ }),
+ getTypeStringLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.typeStringLabel', {
+ defaultMessage: 'String',
+ }),
+ getValueFieldLabel: () =>
+ i18n.translate('xpack.canvas.varConfigEditVar.valueFieldLabel', {
+ defaultMessage: 'Value',
+ }),
+ },
+ VarConfigVarValueField: {
+ getFalseOption: () =>
+ i18n.translate('xpack.canvas.varConfigVarValueField.falseOption', {
+ defaultMessage: 'False',
+ }),
+ getTrueOption: () =>
+ i18n.translate('xpack.canvas.varConfigVarValueField.trueOption', {
+ defaultMessage: 'True',
+ }),
+ },
WorkpadConfig: {
getApplyStylesheetButtonLabel: () =>
i18n.translate('xpack.canvas.workpadConfig.applyStylesheetButtonLabel', {
diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js
index dfd99b18646a6..f356eedff19cf 100644
--- a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js
+++ b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js
@@ -120,7 +120,7 @@ class ArgFormComponent extends PureComponent {
);
return (
-
+
{
@@ -17,18 +17,16 @@ export const ArgLabel = (props) => {
{expandable ? (
-
- {label}
-
+ {label}
}
extraAction={simpleArg}
initialIsOpen={initialIsOpen}
>
- {children}
+ {children}
) : (
simpleArg && (
diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js
index 045e98bab870e..dcd933c2320cf 100644
--- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js
+++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js
@@ -15,10 +15,13 @@ export const DatasourcePreview = compose(
withState('datatable', 'setDatatable'),
lifecycle({
componentDidMount() {
- interpretAst({
- type: 'expression',
- chain: [this.props.function],
- }).then(this.props.setDatatable);
+ interpretAst(
+ {
+ type: 'expression',
+ chain: [this.props.function],
+ },
+ {}
+ ).then(this.props.setDatatable);
},
}),
branch(({ datatable }) => !datatable, renderComponent(Loading))
diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.js b/x-pack/plugins/canvas/public/components/element_config/element_config.js
deleted file mode 100644
index 5d710ef883548..0000000000000
--- a/x-pack/plugins/canvas/public/components/element_config/element_config.js
+++ /dev/null
@@ -1,73 +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 { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion, EuiText, EuiSpacer } from '@elastic/eui';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ComponentStrings } from '../../../i18n';
-
-const { ElementConfig: strings } = ComponentStrings;
-
-export const ElementConfig = ({ elementStats }) => {
- if (!elementStats) {
- return null;
- }
-
- const { total, ready, error } = elementStats;
- const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100;
-
- return (
-
- {strings.getTitle()}
-
- }
- initialIsOpen={false}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-ElementConfig.propTypes = {
- elementStats: PropTypes.object,
-};
diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.tsx b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx
new file mode 100644
index 0000000000000..c2fd827d62099
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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 { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion } from '@elastic/eui';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ComponentStrings } from '../../../i18n';
+import { State } from '../../../types';
+
+const { ElementConfig: strings } = ComponentStrings;
+
+interface Props {
+ elementStats: State['transient']['elementStats'];
+}
+
+export const ElementConfig = ({ elementStats }: Props) => {
+ if (!elementStats) {
+ return null;
+ }
+
+ const { total, ready, error } = elementStats;
+ const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ElementConfig.propTypes = {
+ elementStats: PropTypes.object,
+};
diff --git a/x-pack/plugins/canvas/public/components/page_config/page_config.js b/x-pack/plugins/canvas/public/components/page_config/page_config.js
index 51a4762fca501..c45536ac7b175 100644
--- a/x-pack/plugins/canvas/public/components/page_config/page_config.js
+++ b/x-pack/plugins/canvas/public/components/page_config/page_config.js
@@ -30,7 +30,7 @@ export const PageConfig = ({
}) => {
return (
-
+
{strings.getTitle()}
diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx
index f89ab79a086cf..62673a5b38cc8 100644
--- a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx
+++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx
@@ -17,8 +17,6 @@ export const GlobalConfig: FunctionComponent = () => (
-
-
diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss
index 338d515165e43..76d758197aa19 100644
--- a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss
+++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss
@@ -31,12 +31,68 @@
&--isEmpty {
border-bottom: none;
}
+
+ .canvasSidebar__expandable:last-child {
+ .canvasSidebar__accordion {
+ margin-bottom: (-$euiSizeS);
+ }
+
+ .canvasSidebar__accordion:after {
+ content: none;
+ }
+
+ .canvasSidebar__accordion.euiAccordion-isOpen:after {
+ display: none;
+ }
+ }
}
.canvasSidebar__panel-noMinWidth .euiButton {
min-width: 0;
}
+.canvasSidebar__expandable + .canvasSidebar__expandable {
+ margin-top: 0;
+
+ .canvasSidebar__accordion:before {
+ display: none;
+ }
+}
+
+.canvasSidebar__accordion {
+ padding: $euiSizeM;
+ margin: 0 (-$euiSizeM);
+ background: $euiColorLightestShade;
+ position: relative;
+
+ &.euiAccordion-isOpen {
+ background: transparent;
+ }
+
+ &:before,
+ &:after {
+ content: '';
+ height: 1px;
+ position: absolute;
+ left: 0;
+ width: 100%;
+ background: $euiColorLightShade;
+ }
+
+ &:before {
+ top: 0;
+ }
+
+ &:after {
+ bottom: 0;
+ }
+}
+
+.canvasSidebar__accordionContent {
+ padding-top: $euiSize;
+ padding-left: $euiSizeXS + $euiSizeS + $euiSize;
+}
+
@keyframes sidebarPop {
0% {
opacity: 0;
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot
new file mode 100644
index 0000000000000..64f8cba665c15
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot
@@ -0,0 +1,109 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storyshots components/Variables/DeleteVar default 1`] = `
+Array [
+
+
+
+
+
+
+
+ Delete variable?
+
+
+
+
,
+
+
+
+
+
+
+ Deleting this variable may adversely affect the workpad. Are you sure you wish to continue?
+
+
+
+
+
+
+
+
+
+
+
+ Delete variable
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
,
+]
+`;
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot
new file mode 100644
index 0000000000000..65043e13e5143
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot
@@ -0,0 +1,1236 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storyshots components/Variables/EditVar edit variable (boolean) 1`] = `
+Array [
+
+
+
+
+
+
+
+ Edit variable
+
+
+
+
,
+
+
+
+
+
+
+ Editing a variable in use may adversely affect your workpad
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Storyshots components/Variables/EditVar edit variable (number) 1`] = `
+Array [
+
+
+
+
+
+
+
+ Edit variable
+
+
+
+
,
+
+
+
+
+
+
+ Editing a variable in use may adversely affect your workpad
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Storyshots components/Variables/EditVar edit variable (string) 1`] = `
+Array [
+
+
+
+
+
+
+
+ Edit variable
+
+
+
+
,
+
+
+
+
+
+
+ Editing a variable in use may adversely affect your workpad
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Storyshots components/Variables/EditVar new variable 1`] = `
+Array [
+
+
+
+
+
+
+
+ Add variable
+
+
+
+
,
+ ,
+]
+`;
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot
new file mode 100644
index 0000000000000..146f07a9d0118
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot
@@ -0,0 +1,87 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storyshots components/Variables/VarConfig default 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ Variables
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx
new file mode 100644
index 0000000000000..8f5b73d1f6ae9
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.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.
+ */
+
+import { action } from '@storybook/addon-actions';
+import { storiesOf } from '@storybook/react';
+import React from 'react';
+
+import { CanvasVariable } from '../../../../types';
+
+import { DeleteVar } from '../delete_var';
+
+const variable: CanvasVariable = {
+ name: 'homeUrl',
+ value: 'https://elastic.co',
+ type: 'string',
+};
+
+storiesOf('components/Variables/DeleteVar', module).add('default', () => (
+
+));
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx
new file mode 100644
index 0000000000000..0369c2c09a39c
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.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 { action } from '@storybook/addon-actions';
+import { storiesOf } from '@storybook/react';
+import React from 'react';
+
+import { CanvasVariable } from '../../../../types';
+
+import { EditVar } from '../edit_var';
+
+const variables: CanvasVariable[] = [
+ {
+ name: 'homeUrl',
+ value: 'https://elastic.co',
+ type: 'string',
+ },
+ {
+ name: 'bigNumber',
+ value: 1000,
+ type: 'number',
+ },
+ {
+ name: 'zenMode',
+ value: true,
+ type: 'boolean',
+ },
+];
+
+storiesOf('components/Variables/EditVar', module)
+ .add('new variable', () => (
+
+ ))
+ .add('edit variable (string)', () => (
+
+ ))
+ .add('edit variable (number)', () => (
+
+ ))
+ .add('edit variable (boolean)', () => (
+
+ ));
diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx
new file mode 100644
index 0000000000000..ac5c97d122138
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx
@@ -0,0 +1,41 @@
+/*
+ * 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 { action } from '@storybook/addon-actions';
+import { storiesOf } from '@storybook/react';
+import React from 'react';
+
+import { CanvasVariable } from '../../../../types';
+
+import { VarConfig } from '../var_config';
+
+const variables: CanvasVariable[] = [
+ {
+ name: 'homeUrl',
+ value: 'https://elastic.co',
+ type: 'string',
+ },
+ {
+ name: 'bigNumber',
+ value: 1000,
+ type: 'number',
+ },
+ {
+ name: 'zenMode',
+ value: true,
+ type: 'boolean',
+ },
+];
+
+storiesOf('components/Variables/VarConfig', module).add('default', () => (
+
+));
diff --git a/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx
new file mode 100644
index 0000000000000..fa1771a752848
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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, { FC } from 'react';
+import {
+ EuiIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { CanvasVariable } from '../../../types';
+
+import { ComponentStrings } from '../../../i18n';
+const { VarConfigDeleteVar: strings } = ComponentStrings;
+
+import './var_panel.scss';
+
+interface Props {
+ selectedVar: CanvasVariable;
+ onDelete: (v: CanvasVariable) => void;
+ onCancel: () => void;
+}
+
+export const DeleteVar: FC = ({ selectedVar, onCancel, onDelete }) => {
+ return (
+
+
+ onCancel()}>
+
+
+
+
+ {strings.getTitle()}
+
+
+
+
+
+
+
+
+ {strings.getWarningDescription()}
+
+
+
+
+
+
+
+
+ onDelete(selectedVar)}
+ iconType="trash"
+ >
+ {strings.getDeleteButtonLabel()}
+
+
+
+ onCancel()}>
+ {strings.getCancelButtonLabel()}
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.scss b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss
new file mode 100644
index 0000000000000..7d4a7a4c81ba1
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss
@@ -0,0 +1,8 @@
+.canvasEditVar__typeOption {
+ display: flex;
+ align-items: center;
+
+ .canvasEditVar__tokenIcon {
+ margin-right: 15px;
+ }
+}
diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx b/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx
new file mode 100644
index 0000000000000..a1a5541431d26
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx
@@ -0,0 +1,189 @@
+/*
+ * 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, { useState, FC } from 'react';
+import {
+ EuiIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiToken,
+ EuiSuperSelect,
+ EuiForm,
+ EuiFormRow,
+ EuiFieldText,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiSpacer,
+ EuiCallOut,
+} from '@elastic/eui';
+import { CanvasVariable } from '../../../types';
+
+import { VarValueField } from './var_value_field';
+
+import { ComponentStrings } from '../../../i18n';
+const { VarConfigEditVar: strings } = ComponentStrings;
+
+import './edit_var.scss';
+import './var_panel.scss';
+
+interface Props {
+ selectedVar: CanvasVariable | null;
+ variables: CanvasVariable[];
+ onSave: (v: CanvasVariable) => void;
+ onCancel: () => void;
+}
+
+const checkDupeName = (newName: string, oldName: string | null, variables: CanvasVariable[]) => {
+ const match = variables.find((v) => {
+ // If the new name matches an existing variable and that
+ // matched variable name isn't the old name, then there
+ // is a duplicate
+ return newName === v.name && (!oldName || v.name !== oldName);
+ });
+
+ return !!match;
+};
+
+export const EditVar: FC = ({ variables, selectedVar, onCancel, onSave }) => {
+ // If there isn't a selected variable, we're creating a new var
+ const isNew = selectedVar === null;
+
+ const [type, setType] = useState(isNew ? 'string' : selectedVar!.type);
+ const [name, setName] = useState(isNew ? '' : selectedVar!.name);
+ const [value, setValue] = useState(isNew ? '' : selectedVar!.value);
+
+ const hasDupeName = checkDupeName(name, selectedVar && selectedVar.name, variables);
+
+ const typeOptions = [
+ {
+ value: 'string',
+ inputDisplay: (
+
+ {' '}
+ {strings.getTypeStringLabel()}
+
+ ),
+ },
+ {
+ value: 'number',
+ inputDisplay: (
+
+ {' '}
+ {strings.getTypeNumberLabel()}
+
+ ),
+ },
+ {
+ value: 'boolean',
+ inputDisplay: (
+
+ {' '}
+ {strings.getTypeBooleanLabel()}
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+ onCancel()}>
+
+
+
+
+
+ {isNew ? strings.getAddTitle() : strings.getEditTitle()}
+
+
+
+
+
+ {!isNew && (
+
+
+
+
+ )}
+
+
+
+ {
+ // Only have these types possible in the dropdown
+ setType(v as CanvasVariable['type']);
+
+ // Reset default value
+ if (v === 'boolean') {
+ // Just setting a default value
+ setValue(true);
+ } else if (v === 'number') {
+ // Setting default number
+ setValue(0);
+ } else {
+ setValue('');
+ }
+ }}
+ compressed={true}
+ />
+
+
+ setName(e.target.value)}
+ isInvalid={hasDupeName}
+ />
+
+
+ setValue(v)} />
+
+
+
+
+
+
+
+ onSave({
+ name,
+ value,
+ type,
+ })
+ }
+ disabled={hasDupeName || !name}
+ iconType="save"
+ >
+ {strings.getSaveButtonLabel()}
+
+
+
+ onCancel()}>
+ {strings.getCancelButtonLabel()}
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/canvas/public/components/var_config/index.tsx b/x-pack/plugins/canvas/public/components/var_config/index.tsx
new file mode 100644
index 0000000000000..526037b79e0e0
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/index.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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, { FC } from 'react';
+import copy from 'copy-to-clipboard';
+import { VarConfig as ChildComponent } from './var_config';
+import {
+ withKibana,
+ KibanaReactContextValue,
+ KibanaServices,
+} from '../../../../../../src/plugins/kibana_react/public';
+import { CanvasServices } from '../../services';
+
+import { ComponentStrings } from '../../../i18n';
+
+import { CanvasVariable } from '../../../types';
+
+const { VarConfig: strings } = ComponentStrings;
+
+interface Props {
+ kibana: KibanaReactContextValue<{ canvas: CanvasServices } & KibanaServices>;
+
+ variables: CanvasVariable[];
+ setVariables: (variables: CanvasVariable[]) => void;
+}
+
+const WrappedComponent: FC = ({ kibana, variables, setVariables }) => {
+ const onDeleteVar = (v: CanvasVariable) => {
+ const index = variables.findIndex((targetVar: CanvasVariable) => {
+ return targetVar.name === v.name;
+ });
+ if (index !== -1) {
+ const newVars = [...variables];
+ newVars.splice(index, 1);
+ setVariables(newVars);
+
+ kibana.services.canvas.notify.success(strings.getDeleteNotificationDescription());
+ }
+ };
+
+ const onCopyVar = (v: CanvasVariable) => {
+ const snippetStr = `{var "${v.name}"}`;
+ copy(snippetStr, { debug: true });
+ kibana.services.canvas.notify.success(strings.getCopyNotificationDescription());
+ };
+
+ const onAddVar = (v: CanvasVariable) => {
+ setVariables([...variables, v]);
+ };
+
+ const onEditVar = (oldVar: CanvasVariable, newVar: CanvasVariable) => {
+ const existingVarIndex = variables.findIndex((v) => oldVar.name === v.name);
+
+ const newVars = [...variables];
+ newVars[existingVarIndex] = newVar;
+
+ setVariables(newVars);
+ };
+
+ return ;
+};
+
+export const VarConfig = withKibana(WrappedComponent);
diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.scss b/x-pack/plugins/canvas/public/components/var_config/var_config.scss
new file mode 100644
index 0000000000000..19fe64e7422fd
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/var_config.scss
@@ -0,0 +1,66 @@
+.canvasVarConfig__container {
+ width: 100%;
+ position: relative;
+
+ &.canvasVarConfig-isEditMode {
+ .canvasVarConfig__innerContainer {
+ transform: translateX(-50%);
+ }
+ }
+}
+
+.canvasVarConfig__list {
+ table {
+ background-color: transparent;
+ }
+
+ thead tr th,
+ thead tr td {
+ border-bottom: none;
+ border-top: none;
+ }
+
+ tbody tr td {
+ border-top: none;
+ border-bottom: none;
+ }
+
+ tbody tr:hover {
+ background-color: transparent;
+ }
+
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+}
+
+.canvasVarConfig__innerContainer {
+ width: calc(200% + 48px); // Account for the extra padding
+
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+ align-content: stretch;
+
+ .canvasVarConfig__editView {
+ margin-left: 0;
+ }
+
+ .canvasVarConfig__listView {
+ margin-right: 0;
+ }
+}
+
+.canvasVarConfig__editView {
+ width: 50%;
+ height: 100%;
+
+ flex-shrink: 0;
+}
+
+.canvasVarConfig__listView {
+ width: 50%;
+
+ flex-shrink: 0;
+}
diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.tsx b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx
new file mode 100644
index 0000000000000..6120130c77e24
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx
@@ -0,0 +1,230 @@
+/*
+ * 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, { useState, FC } from 'react';
+import {
+ EuiAccordion,
+ EuiButtonIcon,
+ EuiToken,
+ EuiToolTip,
+ EuiText,
+ EuiInMemoryTable,
+ EuiBasicTableColumn,
+ EuiTableActionsColumnType,
+ EuiSpacer,
+ EuiButton,
+} from '@elastic/eui';
+
+import { CanvasVariable } from '../../../types';
+import { ComponentStrings } from '../../../i18n';
+
+import { EditVar } from './edit_var';
+import { DeleteVar } from './delete_var';
+
+import './var_config.scss';
+
+const { VarConfig: strings } = ComponentStrings;
+
+enum PanelMode {
+ List,
+ Edit,
+ Delete,
+}
+
+const typeToToken = {
+ number: 'tokenNumber',
+ boolean: 'tokenBoolean',
+ string: 'tokenString',
+};
+
+interface Props {
+ variables: CanvasVariable[];
+ onCopyVar: (v: CanvasVariable) => void;
+ onDeleteVar: (v: CanvasVariable) => void;
+ onAddVar: (v: CanvasVariable) => void;
+ onEditVar: (oldVar: CanvasVariable, newVar: CanvasVariable) => void;
+}
+
+export const VarConfig: FC = ({
+ variables,
+ onCopyVar,
+ onDeleteVar,
+ onAddVar,
+ onEditVar,
+}) => {
+ const [panelMode, setPanelMode] = useState(PanelMode.List);
+ const [selectedVar, setSelectedVar] = useState(null);
+
+ const selectAndEditVar = (v: CanvasVariable) => {
+ setSelectedVar(v);
+ setPanelMode(PanelMode.Edit);
+ };
+
+ const selectAndDeleteVar = (v: CanvasVariable) => {
+ setSelectedVar(v);
+ setPanelMode(PanelMode.Delete);
+ };
+
+ const actions: EuiTableActionsColumnType['actions'] = [
+ {
+ type: 'icon',
+ name: strings.getCopyActionButtonLabel(),
+ description: strings.getCopyActionTooltipLabel(),
+ icon: 'copyClipboard',
+ onClick: onCopyVar,
+ isPrimary: true,
+ },
+ {
+ type: 'icon',
+ name: strings.getEditActionButtonLabel(),
+ description: '',
+ icon: 'pencil',
+ onClick: selectAndEditVar,
+ },
+ {
+ type: 'icon',
+ name: strings.getDeleteActionButtonLabel(),
+ description: '',
+ icon: 'trash',
+ color: 'danger',
+ onClick: selectAndDeleteVar,
+ },
+ ];
+
+ const varColumns: Array> = [
+ {
+ field: 'type',
+ name: strings.getTableTypeLabel(),
+ sortable: true,
+ render: (varType: CanvasVariable['type'], _v: CanvasVariable) => {
+ return ;
+ },
+ width: '50px',
+ },
+ {
+ field: 'name',
+ name: strings.getTableNameLabel(),
+ sortable: true,
+ },
+ {
+ field: 'value',
+ name: strings.getTableValueLabel(),
+ sortable: true,
+ truncateText: true,
+ render: (value: CanvasVariable['value'], _v: CanvasVariable) => {
+ return '' + value;
+ },
+ },
+ {
+ actions,
+ width: '60px',
+ },
+ ];
+
+ return (
+
+
+
+ {strings.getTitle()}
+
+ }
+ extraAction={
+
+ {
+ setSelectedVar(null);
+ setPanelMode(PanelMode.Edit);
+ }}
+ />
+
+ }
+ >
+ {variables.length !== 0 && (
+
+
+
+ )}
+ {variables.length === 0 && (
+
+
+ {strings.getEmptyDescription()}
+
+
+ setPanelMode(PanelMode.Edit)}
+ >
+ {strings.getAddButtonLabel()}
+
+
+ )}
+
+
+ {panelMode === PanelMode.Edit && (
+ {
+ if (!selectedVar) {
+ onAddVar(newVar);
+ } else {
+ onEditVar(selectedVar, newVar);
+ }
+
+ setSelectedVar(null);
+ setPanelMode(PanelMode.List);
+ }}
+ onCancel={() => {
+ setSelectedVar(null);
+ setPanelMode(PanelMode.List);
+ }}
+ />
+ )}
+
+ {panelMode === PanelMode.Delete && selectedVar && (
+ {
+ onDeleteVar(v);
+
+ setSelectedVar(null);
+ setPanelMode(PanelMode.List);
+ }}
+ onCancel={() => {
+ setSelectedVar(null);
+ setPanelMode(PanelMode.List);
+ }}
+ />
+ )}
+
+
+
+ );
+};
diff --git a/x-pack/plugins/canvas/public/components/var_config/var_panel.scss b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss
new file mode 100644
index 0000000000000..84f92aab28146
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss
@@ -0,0 +1,31 @@
+.canvasVarHeader__triggerWrapper {
+ display: flex;
+ align-items: center;
+}
+
+.canvasVarHeader__button {
+ @include euiFontSize;
+ text-align: left;
+
+ width: 100%;
+ flex-grow: 1;
+
+ display: flex;
+ align-items: center;
+}
+
+.canvasVarHeader__iconWrapper {
+ width: $euiSize;
+ height: $euiSize;
+
+ border-radius: $euiBorderRadius;
+
+ margin-right: $euiSizeS;
+ margin-left: $euiSizeXS;
+
+ flex-shrink: 0;
+}
+
+.canvasVarHeader__anchor {
+ display: inline-block;
+}
\ No newline at end of file
diff --git a/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx b/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx
new file mode 100644
index 0000000000000..c86be4efec043
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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, { FC } from 'react';
+import { EuiFieldText, EuiFieldNumber, EuiButtonGroup } from '@elastic/eui';
+import { htmlIdGenerator } from '@elastic/eui';
+
+import { CanvasVariable } from '../../../types';
+
+import { ComponentStrings } from '../../../i18n';
+const { VarConfigVarValueField: strings } = ComponentStrings;
+
+interface Props {
+ type: CanvasVariable['type'];
+ value: CanvasVariable['value'];
+ onChange: (v: CanvasVariable['value']) => void;
+}
+
+export const VarValueField: FC = ({ type, value, onChange }) => {
+ const idPrefix = htmlIdGenerator()();
+
+ const options = [
+ {
+ id: `${idPrefix}-true`,
+ label: strings.getTrueOption(),
+ },
+ {
+ id: `${idPrefix}-false`,
+ label: strings.getFalseOption(),
+ },
+ ];
+
+ if (type === 'number') {
+ return (
+ onChange(e.target.value)}
+ />
+ );
+ } else if (type === 'boolean') {
+ return (
+ {
+ const val = id.replace(`${idPrefix}-`, '') === 'true';
+ onChange(val);
+ }}
+ buttonSize="compressed"
+ isFullWidth
+ />
+ );
+ }
+
+ return (
+ onChange(e.target.value)}
+ />
+ );
+};
diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.ts b/x-pack/plugins/canvas/public/components/workpad_config/index.ts
index c69a1fd9b8137..bba08d7647e9e 100644
--- a/x-pack/plugins/canvas/public/components/workpad_config/index.ts
+++ b/x-pack/plugins/canvas/public/components/workpad_config/index.ts
@@ -7,11 +7,17 @@
import { connect } from 'react-redux';
import { get } from 'lodash';
-import { sizeWorkpad as setSize, setName, setWorkpadCSS } from '../../state/actions/workpad';
+import {
+ sizeWorkpad as setSize,
+ setName,
+ setWorkpadCSS,
+ updateWorkpadVariables,
+} from '../../state/actions/workpad';
+
import { getWorkpad } from '../../state/selectors/workpad';
import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants';
import { WorkpadConfig as Component } from './workpad_config';
-import { State } from '../../../types';
+import { State, CanvasVariable } from '../../../types';
const mapStateToProps = (state: State) => {
const workpad = getWorkpad(state);
@@ -23,6 +29,7 @@ const mapStateToProps = (state: State) => {
height: get(workpad, 'height'),
},
css: get(workpad, 'css', DEFAULT_WORKPAD_CSS),
+ variables: get(workpad, 'variables', []),
};
};
@@ -30,6 +37,7 @@ const mapDispatchToProps = {
setSize,
setName,
setWorkpadCSS,
+ setWorkpadVariables: (vars: CanvasVariable[]) => updateWorkpadVariables(vars),
};
export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component);
diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx
index 7b7a1e08b2c5d..a7424882f1072 100644
--- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx
+++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx
@@ -19,10 +19,13 @@ import {
EuiToolTip,
EuiTextArea,
EuiAccordion,
- EuiText,
EuiButton,
} from '@elastic/eui';
+
+import { VarConfig } from '../var_config';
+
import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants';
+import { CanvasVariable } from '../../../types';
import { ComponentStrings } from '../../../i18n';
const { WorkpadConfig: strings } = ComponentStrings;
@@ -34,14 +37,16 @@ interface Props {
};
name: string;
css?: string;
+ variables: CanvasVariable[];
setSize: ({ height, width }: { height: number; width: number }) => void;
setName: (name: string) => void;
setWorkpadCSS: (css: string) => void;
+ setWorkpadVariables: (vars: CanvasVariable[]) => void;
}
export const WorkpadConfig: FunctionComponent = (props) => {
const [css, setCSS] = useState(props.css);
- const { size, name, setSize, setName, setWorkpadCSS } = props;
+ const { size, name, setSize, setName, setWorkpadCSS, variables, setWorkpadVariables } = props;
const rotate = () => setSize({ width: size.height, height: size.width });
const badges = [
@@ -129,23 +134,25 @@ export const WorkpadConfig: FunctionComponent = (props) => {
-
+
+
+
+
-
- {strings.getGlobalCSSLabel()}
-
+ {strings.getGlobalCSSLabel()}
}
>
-
+
F
if (filterList && filterList.length) {
const filterExpression = filterList.join(' | ');
const filterAST = fromExpression(filterExpression);
- return interpretAst(filterAST);
+ return interpretAst(filterAST, getWorkpadVariablesAsObject(getState()));
} else {
const filterType = initialize.typesRegistry.get('filter');
return filterType?.from(null, {});
diff --git a/x-pack/plugins/canvas/public/lib/run_interpreter.ts b/x-pack/plugins/canvas/public/lib/run_interpreter.ts
index 07c0ca4b1ce15..12e07ed3535f6 100644
--- a/x-pack/plugins/canvas/public/lib/run_interpreter.ts
+++ b/x-pack/plugins/canvas/public/lib/run_interpreter.ts
@@ -15,8 +15,12 @@ interface Options {
/**
* Meant to be a replacement for plugins/interpreter/interpretAST
*/
-export async function interpretAst(ast: ExpressionAstExpression): Promise {
- return await expressionsService.getService().execute(ast).getData();
+export async function interpretAst(
+ ast: ExpressionAstExpression,
+ variables: Record
+): Promise {
+ const context = { variables };
+ return await expressionsService.getService().execute(ast, null, context).getData();
}
/**
@@ -24,6 +28,7 @@ export async function interpretAst(ast: ExpressionAstExpression): Promise,
options: Options = {}
): Promise {
+ const context = { variables };
+
try {
- const renderable = await expressionsService.getService().execute(ast, input).getData();
+ const renderable = await expressionsService.getService().execute(ast, input, context).getData();
if (getType(renderable) === 'render') {
return renderable;
}
if (options.castToRender) {
- return runInterpreter(fromExpression('render'), renderable, {
+ return runInterpreter(fromExpression('render'), renderable, variables, {
castToRender: false,
});
}
diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js
index 1617759e83dd8..2047e20424acc 100644
--- a/x-pack/plugins/canvas/public/lib/workpad_service.js
+++ b/x-pack/plugins/canvas/public/lib/workpad_service.js
@@ -21,6 +21,7 @@ const validKeys = [
'assets',
'colors',
'css',
+ 'variables',
'height',
'id',
'isWriteable',
@@ -61,6 +62,7 @@ export function create(workpad) {
return fetch.post(getApiPath(), {
...sanitizeWorkpad({ ...workpad }),
assets: workpad.assets || {},
+ variables: workpad.variables || [],
});
}
@@ -73,7 +75,7 @@ export async function createFromTemplate(templateId) {
export function get(workpadId) {
return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => {
// shim old workpads with new properties
- return { css: DEFAULT_WORKPAD_CSS, ...workpad };
+ return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad };
});
}
diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js
index e89e62917da39..2ba011373c670 100644
--- a/x-pack/plugins/canvas/public/state/actions/elements.js
+++ b/x-pack/plugins/canvas/public/state/actions/elements.js
@@ -9,7 +9,13 @@ import immutable from 'object-path-immutable';
import { get, pick, cloneDeep, without } from 'lodash';
import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common';
import { createThunk } from '../../lib/create_thunk';
-import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad';
+import {
+ getPages,
+ getWorkpadVariablesAsObject,
+ getNodeById,
+ getNodes,
+ getSelectedPageIndex,
+} from '../selectors/workpad';
import { getValue as getResolvedArgsValue } from '../selectors/resolved_args';
import { getDefaultElement } from '../defaults';
import { ErrorStrings } from '../../../i18n';
@@ -96,13 +102,15 @@ export const fetchContext = createThunk(
return i < index;
});
+ const variables = getWorkpadVariablesAsObject(getState());
+
// get context data from a partial AST
return interpretAst(
{
...element.ast,
chain: astChain,
},
- prevContextValue
+ variables
).then((value) => {
dispatch(
args.setValue({
@@ -114,7 +122,7 @@ export const fetchContext = createThunk(
}
);
-const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => {
+const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, context) => {
const argumentPath = [element.id, 'expressionRenderable'];
dispatch(
args.setLoading({
@@ -128,7 +136,9 @@ const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => {
value: renderable,
});
- return runInterpreter(ast, context, { castToRender: true })
+ const variables = getWorkpadVariablesAsObject(getState());
+
+ return runInterpreter(ast, context, variables, { castToRender: true })
.then((renderable) => {
dispatch(getAction(renderable));
})
@@ -172,7 +182,9 @@ export const fetchAllRenderables = createThunk(
const ast = element.ast || safeElementFromExpression(element.expression);
const argumentPath = [element.id, 'expressionRenderable'];
- return runInterpreter(ast, null, { castToRender: true })
+ const variables = getWorkpadVariablesAsObject(getState());
+
+ return runInterpreter(ast, null, variables, { castToRender: true })
.then((renderable) => ({ path: argumentPath, value: renderable }))
.catch((err) => {
services.notify.getService().error(err);
diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.ts b/x-pack/plugins/canvas/public/state/actions/workpad.ts
index 419832e404594..7af55730f5787 100644
--- a/x-pack/plugins/canvas/public/state/actions/workpad.ts
+++ b/x-pack/plugins/canvas/public/state/actions/workpad.ts
@@ -10,7 +10,7 @@ import { createThunk } from '../../lib/create_thunk';
import { getWorkpadColors } from '../selectors/workpad';
// @ts-expect-error
import { fetchAllRenderables } from './elements';
-import { CanvasWorkpad } from '../../../types';
+import { CanvasWorkpad, CanvasVariable } from '../../../types';
export const sizeWorkpad = createAction<{ height: number; width: number }>('sizeWorkpad');
export const setName = createAction('setName');
@@ -18,6 +18,7 @@ export const setWriteable = createAction('setWriteable');
export const setColors = createAction('setColors');
export const setRefreshInterval = createAction('setRefreshInterval');
export const setWorkpadCSS = createAction('setWorkpadCSS');
+export const setWorkpadVariables = createAction('setWorkpadVariables');
export const enableAutoplay = createAction('enableAutoplay');
export const setAutoplayInterval = createAction('setAutoplayInterval');
export const resetWorkpad = createAction('resetWorkpad');
@@ -38,6 +39,14 @@ export const removeColor = createThunk('removeColor', ({ dispatch, getState }, c
dispatch(setColors(without(getWorkpadColors(getState()), color)));
});
+export const updateWorkpadVariables = createThunk(
+ 'updateWorkpadVariables',
+ ({ dispatch }, vars) => {
+ dispatch(setWorkpadVariables(vars));
+ dispatch(fetchAllRenderables());
+ }
+);
+
export const setWorkpad = createThunk(
'setWorkpad',
(
diff --git a/x-pack/plugins/canvas/public/state/defaults.js b/x-pack/plugins/canvas/public/state/defaults.js
index 13ff7102bcafe..5cffb5e865d64 100644
--- a/x-pack/plugins/canvas/public/state/defaults.js
+++ b/x-pack/plugins/canvas/public/state/defaults.js
@@ -81,6 +81,7 @@ export const getDefaultWorkpad = () => {
'#FFFFFF',
'rgba(255,255,255,0)', // 'transparent'
],
+ variables: [],
isWriteable: true,
};
};
diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js
index 30f9c638a054f..9a0c30bdf1337 100644
--- a/x-pack/plugins/canvas/public/state/reducers/workpad.js
+++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js
@@ -14,6 +14,7 @@ import {
setName,
setWriteable,
setWorkpadCSS,
+ setWorkpadVariables,
resetWorkpad,
} from '../actions/workpad';
@@ -59,6 +60,10 @@ export const workpadReducer = handleActions(
return { ...workpadState, css: payload };
},
+ [setWorkpadVariables]: (workpadState, { payload }) => {
+ return { ...workpadState, variables: payload };
+ },
+
[resetWorkpad]: () => ({ ...getDefaultWorkpad() }),
},
{}
diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts
index 83f4984b4a300..1d7ea05daaa61 100644
--- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts
+++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts
@@ -10,7 +10,14 @@ import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/comm
// @ts-expect-error untyped local
import { append } from '../../lib/modify_path';
import { getAssets } from './assets';
-import { State, CanvasWorkpad, CanvasPage, CanvasElement, ResolvedArgType } from '../../../types';
+import {
+ State,
+ CanvasWorkpad,
+ CanvasPage,
+ CanvasElement,
+ CanvasVariable,
+ ResolvedArgType,
+} from '../../../types';
import {
ExpressionContext,
CanvasGroup,
@@ -49,6 +56,23 @@ export function getWorkpadPersisted(state: State) {
return getWorkpad(state);
}
+export function getWorkpadVariables(state: State) {
+ const workpad = getWorkpad(state);
+ return get(workpad, 'variables', []);
+}
+
+export function getWorkpadVariablesAsObject(state: State) {
+ const variables = getWorkpadVariables(state);
+ if (variables.length === 0) {
+ return {};
+ }
+
+ return (variables as CanvasVariable[]).reduce(
+ (vars: Record, v: CanvasVariable) => ({ ...vars, [v.name]: v.value }),
+ {}
+ );
+}
+
export function getWorkpadInfo(state: State): WorkpadInfo {
return {
...getWorkpad(state),
@@ -326,7 +350,9 @@ export function getElements(
return elements.map((el) => omit(el, ['ast']));
}
- return elements.map(appendAst);
+ const elementAppendAst = (elem: CanvasElement) => appendAst(elem);
+
+ return elements.map(elementAppendAst);
}
const augment = (type: string) => (n: T): T => ({
diff --git a/x-pack/plugins/canvas/server/lib/sanitize_name.js b/x-pack/plugins/canvas/server/lib/sanitize_name.js
index 295315c3ceb2e..4c787c816a331 100644
--- a/x-pack/plugins/canvas/server/lib/sanitize_name.js
+++ b/x-pack/plugins/canvas/server/lib/sanitize_name.js
@@ -5,9 +5,9 @@
*/
export function sanitizeName(name) {
- // blacklisted characters
- const blacklist = ['(', ')'];
- const pattern = blacklist.map((v) => escapeRegExp(v)).join('|');
+ // invalid characters
+ const invalid = ['(', ')'];
+ const pattern = invalid.map((v) => escapeRegExp(v)).join('|');
const regex = new RegExp(pattern, 'g');
return name.replace(regex, '_');
}
diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts
index 0c31f517a74b3..5bbd2caa0cb99 100644
--- a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts
+++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts
@@ -51,12 +51,19 @@ export const WorkpadAssetSchema = schema.object({
value: schema.string(),
});
+export const WorkpadVariable = schema.object({
+ name: schema.string(),
+ value: schema.oneOf([schema.string(), schema.number(), schema.boolean()]),
+ type: schema.string(),
+});
+
export const WorkpadSchema = schema.object({
'@created': schema.maybe(schema.string()),
'@timestamp': schema.maybe(schema.string()),
assets: schema.maybe(schema.recordOf(schema.string(), WorkpadAssetSchema)),
colors: schema.arrayOf(schema.string()),
css: schema.string(),
+ variables: schema.arrayOf(WorkpadVariable),
height: schema.number(),
id: schema.string(),
isWriteable: schema.maybe(schema.boolean()),
diff --git a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts
index 95f0dc4c3da39..416d3aee2dd03 100644
--- a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts
+++ b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts
@@ -1644,5 +1644,6 @@ export const pitch: CanvasTemplate = {
},
css:
".canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5 {\nfont-family: 'Futura';\ncolor: #444444;\n}\n\n.canvasPage h1 {\nfont-size: 112px;\nfont-weight: bold;\ncolor: #FFFFFF;\n}\n\n.canvasPage h2 {\nfont-size: 48px;\nfont-weight: bold;\n}\n\n.canvasPage h3 {\nfont-size: 30px;\nfont-weight: 300;\ntext-transform: uppercase;\ncolor: #FFFFFF;\n}\n\n.canvasPage h5 {\nfont-size: 24px;\nfont-style: italic;\n}",
+ variables: [],
},
};
diff --git a/x-pack/plugins/canvas/server/templates/status_report.ts b/x-pack/plugins/canvas/server/templates/status_report.ts
index b396ed784cbed..447e1f99afaee 100644
--- a/x-pack/plugins/canvas/server/templates/status_report.ts
+++ b/x-pack/plugins/canvas/server/templates/status_report.ts
@@ -17,6 +17,7 @@ export const status: CanvasTemplate = {
height: 792,
css:
'.canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5, .canvasPage h6, .canvasPage li, .canvasPage p, .canvasPage th, .canvasPage td {\nfont-family: "Gill Sans" !important;\ncolor: #333333;\n}\n\n.canvasPage h1, .canvasPage h2 {\nfont-weight: 400;\n}\n\n.canvasPage h2 {\ntext-transform: uppercase;\ncolor: #1785B0;\n}\n\n.canvasMarkdown p,\n.canvasMarkdown li {\nfont-size: 18px;\n}\n\n.canvasMarkdown li {\nmargin-bottom: .75em;\n}\n\n.canvasMarkdown h3:not(:first-child) {\nmargin-top: 2em;\n}\n\n.canvasMarkdown a {\ncolor: #1785B0;\n}\n\n.canvasMarkdown th,\n.canvasMarkdown td {\npadding: .5em 1em;\n}\n\n.canvasMarkdown th {\nbackground-color: #FAFBFD;\n}\n\n.canvasMarkdown table,\n.canvasMarkdown th,\n.canvasMarkdown td {\nborder: 1px solid #e4e9f2;\n}',
+ variables: [],
page: 0,
pages: [
{
diff --git a/x-pack/plugins/canvas/server/templates/summary_report.ts b/x-pack/plugins/canvas/server/templates/summary_report.ts
index 1b32a80fa82c7..64f04eef4194e 100644
--- a/x-pack/plugins/canvas/server/templates/summary_report.ts
+++ b/x-pack/plugins/canvas/server/templates/summary_report.ts
@@ -493,5 +493,6 @@ export const summary: CanvasTemplate = {
'@created': '2019-05-31T16:01:45.751Z',
assets: {},
css: 'h3 {\ncolor: #343741;\nfont-weight: 400;\n}\n\nh5 {\ncolor: #69707D;\n}',
+ variables: [],
},
};
diff --git a/x-pack/plugins/canvas/server/templates/theme_dark.ts b/x-pack/plugins/canvas/server/templates/theme_dark.ts
index 8dce2c5eb9b6e..5822a17976cd3 100644
--- a/x-pack/plugins/canvas/server/templates/theme_dark.ts
+++ b/x-pack/plugins/canvas/server/templates/theme_dark.ts
@@ -17,6 +17,7 @@ export const dark: CanvasTemplate = {
height: 720,
page: 0,
css: '',
+ variables: [],
pages: [
{
id: 'page-fda26a1f-c096-44e4-a149-cb99e1038a34',
diff --git a/x-pack/plugins/canvas/server/templates/theme_light.ts b/x-pack/plugins/canvas/server/templates/theme_light.ts
index fb654a2fd2954..d278e057bb441 100644
--- a/x-pack/plugins/canvas/server/templates/theme_light.ts
+++ b/x-pack/plugins/canvas/server/templates/theme_light.ts
@@ -14,6 +14,7 @@ export const light: CanvasTemplate = {
template: {
name: 'Light',
css: '',
+ variables: [],
width: 1080,
height: 720,
page: 0,
diff --git a/x-pack/plugins/canvas/types/canvas.ts b/x-pack/plugins/canvas/types/canvas.ts
index 2f20dc88fdec4..cc07f498f1eec 100644
--- a/x-pack/plugins/canvas/types/canvas.ts
+++ b/x-pack/plugins/canvas/types/canvas.ts
@@ -37,12 +37,19 @@ export interface CanvasPage {
groups: CanvasGroup[];
}
+export interface CanvasVariable {
+ name: string;
+ value: boolean | number | string;
+ type: 'boolean' | 'number' | 'string';
+}
+
export interface CanvasWorkpad {
'@created': string;
'@timestamp': string;
assets: { [id: string]: CanvasAsset };
colors: string[];
css: string;
+ variables: CanvasVariable[];
height: number;
id: string;
isWriteable: boolean;
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
index eea19bb1aa7dd..5d4ea5a6370e4 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
@@ -939,6 +939,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
{
@@ -950,6 +951,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
],
@@ -1015,6 +1017,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
{
@@ -1026,6 +1029,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
],
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
index bdc2b6cb2e667..3246457179f68 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
@@ -25,6 +25,7 @@ import {
} from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsService } from '../crypto';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
interface EncryptedSavedObjectsClientOptions {
baseClient: SavedObjectsClientContract;
@@ -47,10 +48,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
public readonly errors = options.baseClient.errors
) {}
- // only include namespace in AAD descriptor if the specified type is single-namespace
- private getDescriptorNamespace = (type: string, namespace?: string) =>
- this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined;
-
public async create(
type: string,
attributes: T = {} as T,
@@ -70,7 +67,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
}
const id = generateID();
- const namespace = this.getDescriptorNamespace(type, options.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options.namespace
+ );
return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.create(
type,
@@ -109,7 +110,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
}
const id = generateID();
- const namespace = this.getDescriptorNamespace(object.type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ object.type,
+ options?.namespace
+ );
return {
...object,
id,
@@ -124,8 +129,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkCreate(encryptedObjects, options),
- objects,
- options?.namespace
+ objects
);
}
@@ -142,7 +146,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
if (!this.options.service.isRegistered(type)) {
return object;
}
- const namespace = this.getDescriptorNamespace(type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options?.namespace
+ );
return {
...object,
attributes: await this.options.service.encryptAttributes(
@@ -156,8 +164,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkUpdate(encryptedObjects, options),
- objects,
- options?.namespace
+ objects
);
}
@@ -168,8 +175,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
public async find(options: SavedObjectsFindOptions) {
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.find(options),
- undefined,
- options.namespace
+ undefined
);
}
@@ -179,8 +185,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
) {
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkGet(objects, options),
- undefined,
- options?.namespace
+ undefined
);
}
@@ -188,7 +193,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.get(type, id, options),
undefined as unknown,
- this.getDescriptorNamespace(type, options?.namespace)
+ getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace)
);
}
@@ -201,7 +206,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
if (!this.options.service.isRegistered(type)) {
return await this.options.baseClient.update(type, id, attributes, options);
}
- const namespace = this.getDescriptorNamespace(type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options?.namespace
+ );
return this.handleEncryptedAttributesInResponse(
await this.options.baseClient.update(
type,
@@ -270,7 +279,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
* response portion isn't registered, it is returned as is.
* @param response Raw response returned by the underlying base client.
* @param [objects] Optional list of saved objects with original attributes.
- * @param [namespace] Optional namespace that was used for the saved objects operation.
*/
private async handleEncryptedAttributesInBulkResponse<
T,
@@ -279,12 +287,16 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
| SavedObjectsFindResponse
| SavedObjectsBulkUpdateResponse,
O extends Array> | Array>
- >(response: R, objects?: O, namespace?: string) {
+ >(response: R, objects?: O) {
for (const [index, savedObject] of response.saved_objects.entries()) {
await this.handleEncryptedAttributesInResponse(
savedObject,
objects?.[index].attributes ?? undefined,
- this.getDescriptorNamespace(savedObject.type, namespace)
+ getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ savedObject.type,
+ savedObject.namespaces ? savedObject.namespaces[0] : undefined
+ )
);
}
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts
new file mode 100644
index 0000000000000..7ba90a5a76ab3
--- /dev/null
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { savedObjectsTypeRegistryMock } from 'src/core/server/mocks';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
+
+describe('getDescriptorNamespace', () => {
+ describe('namespace agnostic', () => {
+ it('returns undefined', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(true);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', undefined)).toEqual(
+ undefined
+ );
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', 'foo-namespace')).toEqual(
+ undefined
+ );
+ });
+ });
+
+ describe('multi-namespace', () => {
+ it('returns undefined', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', undefined)).toEqual(
+ undefined
+ );
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', 'foo-namespace')).toEqual(
+ undefined
+ );
+ });
+ });
+
+ describe('single namespace', () => {
+ it('returns `undefined` if provided namespace is undefined or `default`', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', undefined)).toEqual(
+ undefined
+ );
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'default')).toEqual(
+ undefined
+ );
+ });
+
+ it('returns the provided namespace', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'foo-namespace')).toEqual(
+ 'foo-namespace'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts
new file mode 100644
index 0000000000000..b2842df909a1d
--- /dev/null
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 { ISavedObjectTypeRegistry } from 'kibana/server';
+
+export const getDescriptorNamespace = (
+ typeRegistry: ISavedObjectTypeRegistry,
+ type: string,
+ namespace?: string
+) => {
+ const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined;
+ return descriptorNamespace === 'default' ? undefined : descriptorNamespace;
+};
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
index af00050183b77..0e5be4e4eee5a 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
@@ -15,6 +15,7 @@ import {
import { SecurityPluginSetup } from '../../../security/server';
import { EncryptedSavedObjectsService } from '../crypto';
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
interface SetupSavedObjectsParams {
service: PublicMethodsOf;
@@ -84,7 +85,7 @@ export function setupSavedObjects({
{
type,
id,
- namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined,
+ namespace: getDescriptorNamespace(typeRegistry, type, options?.namespace),
},
savedObject.attributes as Record
)) as T,
diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md
index 8c316c848184b..31ee304fe2247 100644
--- a/x-pack/plugins/enterprise_search/README.md
+++ b/x-pack/plugins/enterprise_search/README.md
@@ -2,7 +2,10 @@
## Overview
-This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness.
+This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness:
+
+- **App Search:** A basic engines overview with links into the product.
+- **Workplace Search:** A simple app overview with basic statistics, links to the sources, users (if standard auth), and product settings.
## Development
diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts
index c134131caba75..fc9a47717871b 100644
--- a/x-pack/plugins/enterprise_search/common/constants.ts
+++ b/x-pack/plugins/enterprise_search/common/constants.ts
@@ -4,4 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
+export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error
+
export const ENGINES_PAGE_SIZE = 10;
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts
index 14fde357a980a..6f82946c0ea14 100644
--- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts
@@ -7,7 +7,11 @@
export { mockHistory } from './react_router_history.mock';
export { mockKibanaContext } from './kibana_context.mock';
export { mockLicenseContext } from './license_context.mock';
-export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock';
+export {
+ mountWithContext,
+ mountWithKibanaContext,
+ mountWithAsyncContext,
+} from './mount_with_context.mock';
export { shallowWithIntl } from './shallow_with_i18n.mock';
// Note: shallow_usecontext must be imported directly as a file
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx
index dfcda544459d4..1e0df1326c177 100644
--- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx
@@ -5,7 +5,8 @@
*/
import React from 'react';
-import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../';
@@ -47,3 +48,33 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje
);
};
+
+/**
+ * This helper is intended for components that have async effects
+ * (e.g. http fetches) on mount. It mostly adds act/update boilerplate
+ * that's needed for the wrapper to play nice with Enzyme/Jest
+ *
+ * Example usage:
+ *
+ * const wrapper = mountWithAsyncContext( , { http: { get: () => someData } });
+ */
+export const mountWithAsyncContext = async (
+ children: React.ReactNode,
+ context: object
+): Promise => {
+ let wrapper: ReactWrapper | undefined;
+
+ // We get a lot of act() warning/errors in the terminal without this.
+ // TBH, I don't fully understand why since Enzyme's mount is supposed to
+ // have act() baked in - could be because of the wrapping context provider?
+ await act(async () => {
+ wrapper = mountWithContext(children, context);
+ });
+ if (wrapper) {
+ wrapper.update(); // This seems to be required for the DOM to actually update
+
+ return wrapper;
+ } else {
+ throw new Error('Could not mount wrapper');
+ }
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts
index 767a52a75d1fb..2bcdd42c38055 100644
--- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts
@@ -19,7 +19,7 @@ jest.mock('react', () => ({
/**
* Example usage within a component test using shallow():
*
- * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed
+ * import '../../../__mocks__/shallow_usecontext'; // Must come before React's import, adjust relative path as needed
*
* import React from 'react';
* import { shallow } from 'enzyme';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx
index 12bf003564103..25a9fa7430c40 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx
@@ -9,6 +9,7 @@ import '../../../__mocks__/shallow_usecontext.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui';
+import { ErrorStatePrompt } from '../../../shared/error_state';
jest.mock('../../../shared/telemetry', () => ({
sendTelemetry: jest.fn(),
@@ -22,7 +23,7 @@ describe('ErrorState', () => {
it('renders', () => {
const wrapper = shallow( );
- expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+ expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1);
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx
index d8eeff2aba1c6..7ac02082ee75c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx
@@ -4,21 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useContext } from 'react';
-import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
-import { EuiButton } from '../../../shared/react_router_helpers';
+import { ErrorStatePrompt } from '../../../shared/error_state';
import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
-import { KibanaContext, IKibanaContext } from '../../../index';
import { EngineOverviewHeader } from '../engine_overview_header';
import './empty_states.scss';
export const ErrorState: React.FC = () => {
- const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
-
return (
@@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => {
-
-
-
-
- }
- titleSize="l"
- body={
- <>
-
- {enterpriseSearchUrl},
- }}
- />
-
-
-
- config/kibana.yml,
- }}
- />
-
-
-
-
-
- [enterpriseSearch][plugins],
- }}
- />
-
-
- >
- }
- actions={
-
-
-
- }
- />
+
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx
index 4d2a2ea1df9aa..45ab5dc5b9ab1 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx
@@ -8,51 +8,45 @@ import '../../../__mocks__/react_router_history.mock';
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { render, ReactWrapper } from 'enzyme';
+import { shallow, ReactWrapper } from 'enzyme';
-import { I18nProvider } from '@kbn/i18n/react';
-import { KibanaContext } from '../../../';
-import { LicenseContext } from '../../../shared/licensing';
-import { mountWithContext, mockKibanaContext } from '../../../__mocks__';
+import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__';
-import { EmptyState, ErrorState } from '../empty_states';
-import { EngineTable, IEngineTablePagination } from './engine_table';
+import { LoadingState, EmptyState, ErrorState } from '../empty_states';
+import { EngineTable } from './engine_table';
import { EngineOverview } from './';
describe('EngineOverview', () => {
+ const mockHttp = mockKibanaContext.http;
+
describe('non-happy-path states', () => {
it('isLoading', () => {
- // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect)
- // TODO: Consider pulling this out to a renderWithContext mock/helper
- const wrapper: Cheerio = render(
-
-
-
-
-
-
-
- );
-
- // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly
- expect(wrapper.find('.euiLoadingContent')).toHaveLength(2);
+ const wrapper = shallow( );
+
+ expect(wrapper.find(LoadingState)).toHaveLength(1);
});
it('isEmpty', async () => {
- const wrapper = await mountWithApiMock({
- get: () => ({
- results: [],
- meta: { page: { total_results: 0 } },
- }),
+ const wrapper = await mountWithAsyncContext( , {
+ http: {
+ ...mockHttp,
+ get: () => ({
+ results: [],
+ meta: { page: { total_results: 0 } },
+ }),
+ },
});
expect(wrapper.find(EmptyState)).toHaveLength(1);
});
it('hasErrorConnecting', async () => {
- const wrapper = await mountWithApiMock({
- get: () => ({ invalidPayload: true }),
+ const wrapper = await mountWithAsyncContext( , {
+ http: {
+ ...mockHttp,
+ get: () => ({ invalidPayload: true }),
+ },
});
expect(wrapper.find(ErrorState)).toHaveLength(1);
});
@@ -78,17 +72,17 @@ describe('EngineOverview', () => {
},
};
const mockApi = jest.fn(() => mockedApiResponse);
- let wrapper: ReactWrapper;
- beforeAll(async () => {
- wrapper = await mountWithApiMock({ get: mockApi });
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('renders', () => {
- expect(wrapper.find(EngineTable)).toHaveLength(1);
- });
+ it('renders and calls the engines API', async () => {
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ });
- it('calls the engines API', () => {
+ expect(wrapper.find(EngineTable)).toHaveLength(1);
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', {
query: {
type: 'indexed',
@@ -97,19 +91,42 @@ describe('EngineOverview', () => {
});
});
+ describe('when on a platinum license', () => {
+ it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => {
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ license: { type: 'platinum', isActive: true },
+ });
+
+ expect(wrapper.find(EngineTable)).toHaveLength(2);
+ expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', {
+ query: {
+ type: 'meta',
+ pageIndex: 1,
+ },
+ });
+ });
+ });
+
describe('pagination', () => {
- const getTablePagination: () => IEngineTablePagination = () =>
- wrapper.find(EngineTable).first().prop('pagination');
+ const getTablePagination = (wrapper: ReactWrapper) =>
+ wrapper.find(EngineTable).prop('pagination');
- it('passes down page data from the API', () => {
- const pagination = getTablePagination();
+ it('passes down page data from the API', async () => {
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ });
+ const pagination = getTablePagination(wrapper);
expect(pagination.totalEngines).toEqual(100);
expect(pagination.pageIndex).toEqual(0);
});
it('re-polls the API on page change', async () => {
- await act(async () => getTablePagination().onPaginate(5));
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ });
+ await act(async () => getTablePagination(wrapper).onPaginate(5));
wrapper.update();
expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', {
@@ -118,54 +135,8 @@ describe('EngineOverview', () => {
pageIndex: 5,
},
});
- expect(getTablePagination().pageIndex).toEqual(4);
- });
- });
-
- describe('when on a platinum license', () => {
- beforeAll(async () => {
- mockApi.mockClear();
- wrapper = await mountWithApiMock({
- license: { type: 'platinum', isActive: true },
- get: mockApi,
- });
- });
-
- it('renders a 2nd meta engines table', () => {
- expect(wrapper.find(EngineTable)).toHaveLength(2);
- });
-
- it('makes a 2nd call to the engines API with type meta', () => {
- expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', {
- query: {
- type: 'meta',
- pageIndex: 1,
- },
- });
+ expect(getTablePagination(wrapper).pageIndex).toEqual(4);
});
});
});
-
- /**
- * Test helpers
- */
-
- const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => {
- let wrapper: ReactWrapper | undefined;
- const httpMock = { ...mockKibanaContext.http, get };
-
- // We get a lot of act() warning/errors in the terminal without this.
- // TBH, I don't fully understand why since Enzyme's mount is supposed to
- // have act() baked in - could be because of the wrapping context provider?
- await act(async () => {
- wrapper = mountWithContext( , { http: httpMock, license });
- });
- if (wrapper) {
- wrapper.update(); // This seems to be required for the DOM to actually update
-
- return wrapper;
- } else {
- throw new Error('Could not mount wrapper');
- }
- };
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx
index 1aead8468ca3b..70e16e61846b4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx
@@ -6,14 +6,16 @@
import React from 'react';
+import { AppMountParameters } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
import { renderApp } from './';
import { AppSearch } from './app_search';
+import { WorkplaceSearch } from './workplace_search';
describe('renderApp', () => {
- const params = coreMock.createAppMountParamters();
+ let params: AppMountParameters;
const core = coreMock.createStart();
const config = {};
const plugins = {
@@ -22,6 +24,7 @@ describe('renderApp', () => {
beforeEach(() => {
jest.clearAllMocks();
+ params = coreMock.createAppMountParamters();
});
it('mounts and unmounts UI', () => {
@@ -37,4 +40,9 @@ describe('renderApp', () => {
renderApp(AppSearch, core, params, config, plugins);
expect(params.element.querySelector('.setupGuide')).not.toBeNull();
});
+
+ it('renders WorkplaceSearch', () => {
+ renderApp(WorkplaceSearch, core, params, config, plugins);
+ expect(params.element.querySelector('.setupGuide')).not.toBeNull();
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx
new file mode 100644
index 0000000000000..29b773b80158a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.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 '../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiEmptyPrompt } from '@elastic/eui';
+
+import { ErrorStatePrompt } from './';
+
+describe('ErrorState', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx
new file mode 100644
index 0000000000000..81455cea0b497
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 } from 'react';
+import { EuiEmptyPrompt, EuiCode } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { EuiButton } from '../react_router_helpers';
+import { KibanaContext, IKibanaContext } from '../../index';
+
+export const ErrorStatePrompt: React.FC = () => {
+ const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
+
+ return (
+
+
+
+ }
+ titleSize="l"
+ body={
+ <>
+
+ {enterpriseSearchUrl},
+ }}
+ />
+
+
+
+ config/kibana.yml,
+ }}
+ />
+
+
+
+
+
+ [enterpriseSearch][plugins],
+ }}
+ />
+
+
+ >
+ }
+ actions={
+
+
+
+ }
+ />
+ );
+};
diff --git a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts
similarity index 71%
rename from x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts
rename to x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts
index 0499fdd1ac8db..1012fdf4126a2 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { EntriesArray, exceptionListType, namespaceType } from '../../../lists/common/schemas';
+export { ErrorStatePrompt } from './error_state_prompt';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts
index 7ea73577c4de6..70aa723d62601 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts
@@ -5,7 +5,7 @@
*/
import { generateBreadcrumb } from './generate_breadcrumbs';
-import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './';
+import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs, workplaceSearchBreadcrumbs } from './';
import { mockHistory as mockHistoryUntyped } from '../../__mocks__';
const mockHistory = mockHistoryUntyped as any;
@@ -204,3 +204,86 @@ describe('appSearchBreadcrumbs', () => {
});
});
});
+
+describe('workplaceSearchBreadcrumbs', () => {
+ const breadCrumbs = [
+ {
+ text: 'Page 1',
+ path: '/page1',
+ },
+ {
+ text: 'Page 2',
+ path: '/page2',
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockHistory.createHref.mockImplementation(
+ ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}`
+ );
+ });
+
+ const subject = () => workplaceSearchBreadcrumbs(mockHistory)(breadCrumbs);
+
+ it('Builds a chain of breadcrumbs with Enterprise Search and Workplace Search at the root', () => {
+ expect(subject()).toEqual([
+ {
+ text: 'Enterprise Search',
+ },
+ {
+ href: '/enterprise_search/workplace_search/',
+ onClick: expect.any(Function),
+ text: 'Workplace Search',
+ },
+ {
+ href: '/enterprise_search/workplace_search/page1',
+ onClick: expect.any(Function),
+ text: 'Page 1',
+ },
+ {
+ href: '/enterprise_search/workplace_search/page2',
+ onClick: expect.any(Function),
+ text: 'Page 2',
+ },
+ ]);
+ });
+
+ it('shows just the root if breadcrumbs is empty', () => {
+ expect(workplaceSearchBreadcrumbs(mockHistory)()).toEqual([
+ {
+ text: 'Enterprise Search',
+ },
+ {
+ href: '/enterprise_search/workplace_search/',
+ onClick: expect.any(Function),
+ text: 'Workplace Search',
+ },
+ ]);
+ });
+
+ describe('links', () => {
+ const eventMock = {
+ preventDefault: jest.fn(),
+ } as any;
+
+ it('has Enterprise Search text first', () => {
+ expect(subject()[0].onClick).toBeUndefined();
+ });
+
+ it('has a link to Workplace Search second', () => {
+ (subject()[1] as any).onClick(eventMock);
+ expect(mockHistory.push).toHaveBeenCalledWith('/');
+ });
+
+ it('has a link to page 1 third', () => {
+ (subject()[2] as any).onClick(eventMock);
+ expect(mockHistory.push).toHaveBeenCalledWith('/page1');
+ });
+
+ it('has a link to page 2 last', () => {
+ (subject()[3] as any).onClick(eventMock);
+ expect(mockHistory.push).toHaveBeenCalledWith('/page2');
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts
index 8f72875a32bd4..b57fdfdbb75ca 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts
@@ -52,3 +52,6 @@ export const enterpriseSearchBreadcrumbs = (history: History) => (
export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]);
+
+export const workplaceSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) =>
+ enterpriseSearchBreadcrumbs(history)([{ text: 'Workplace Search', path: '/' }, ...breadcrumbs]);
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts
index cf8bbbc593f2f..c4ef68704b7e0 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts
@@ -4,6 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs';
-export { appSearchBreadcrumbs } from './generate_breadcrumbs';
-export { SetAppSearchBreadcrumbs } from './set_breadcrumbs';
+export {
+ enterpriseSearchBreadcrumbs,
+ appSearchBreadcrumbs,
+ workplaceSearchBreadcrumbs,
+} from './generate_breadcrumbs';
+export { SetAppSearchBreadcrumbs, SetWorkplaceSearchBreadcrumbs } from './set_breadcrumbs';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx
index 530117e197616..e54f1a12b73cb 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx
@@ -8,7 +8,11 @@ import React, { useContext, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { EuiBreadcrumb } from '@elastic/eui';
import { KibanaContext, IKibanaContext } from '../../index';
-import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs';
+import {
+ appSearchBreadcrumbs,
+ workplaceSearchBreadcrumbs,
+ TBreadcrumbs,
+} from './generate_breadcrumbs';
/**
* Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view
@@ -17,19 +21,17 @@ import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs';
export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void;
-interface IBreadcrumbProps {
+interface IBreadcrumbsProps {
text: string;
isRoot?: never;
}
-interface IRootBreadcrumbProps {
+interface IRootBreadcrumbsProps {
isRoot: true;
text?: never;
}
+type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps;
-export const SetAppSearchBreadcrumbs: React.FC = ({
- text,
- isRoot,
-}) => {
+export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => {
const history = useHistory();
const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
@@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => {
+ const history = useHistory();
+ const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext;
+
+ const crumb = isRoot ? [] : [{ text, path: history.location.pathname }];
+
+ useEffect(() => {
+ setBreadcrumbs(workplaceSearchBreadcrumbs(history)(crumb as TBreadcrumbs | []));
+ }, []);
+
+ return null;
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
index f871f48b17154..eadf7fa805590 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts
@@ -6,3 +6,4 @@
export { sendTelemetry } from './send_telemetry';
export { SendAppSearchTelemetry } from './send_telemetry';
+export { SendWorkplaceSearchTelemetry } from './send_telemetry';
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
index 9825c0d8ab889..3c873dbc25e37 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx
@@ -7,8 +7,10 @@
import React from 'react';
import { httpServiceMock } from 'src/core/public/mocks';
+import { JSON_HEADER as headers } from '../../../../common/constants';
import { mountWithKibanaContext } from '../../__mocks__';
-import { sendTelemetry, SendAppSearchTelemetry } from './';
+
+import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './';
describe('Shared Telemetry Helpers', () => {
const httpMock = httpServiceMock.createSetupContract();
@@ -27,8 +29,8 @@ describe('Shared Telemetry Helpers', () => {
});
expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
- headers: { 'Content-Type': 'application/json' },
- body: '{"action":"viewed","metric":"setup_guide"}',
+ headers,
+ body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}',
});
});
@@ -47,9 +49,20 @@ describe('Shared Telemetry Helpers', () => {
http: httpMock,
});
- expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', {
- headers: { 'Content-Type': 'application/json' },
- body: '{"action":"clicked","metric":"button"}',
+ expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
+ headers,
+ body: '{"product":"app_search","action":"clicked","metric":"button"}',
+ });
+ });
+
+ it('SendWorkplaceSearchTelemetry component', () => {
+ mountWithKibanaContext( , {
+ http: httpMock,
+ });
+
+ expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', {
+ headers,
+ body: '{"product":"workplace_search","action":"viewed","metric":"page"}',
});
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
index 300cb18272717..715d61b31512c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx
@@ -7,6 +7,7 @@
import React, { useContext, useEffect } from 'react';
import { HttpSetup } from 'src/core/public';
+import { JSON_HEADER as headers } from '../../../../common/constants';
import { KibanaContext, IKibanaContext } from '../../index';
interface ISendTelemetryProps {
@@ -25,10 +26,8 @@ interface ISendTelemetry extends ISendTelemetryProps {
export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => {
try {
- await http.put(`/api/${product}/telemetry`, {
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ action, metric }),
- });
+ const body = JSON.stringify({ product, action, metric });
+ await http.put('/api/enterprise_search/telemetry', { headers, body });
} catch (error) {
throw new Error('Unable to send telemetry');
}
@@ -36,7 +35,7 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele
/**
* React component helpers - useful for on-page-load/views
- * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry
+ * TODO: SendEnterpriseSearchTelemetry
*/
export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => {
@@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC = ({ action,
return null;
};
+
+export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+
+ useEffect(() => {
+ sendTelemetry({ http, action, metric, product: 'workplace_search' });
+ }, [action, metric, http]);
+
+ return null;
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts
new file mode 100644
index 0000000000000..3f28710d92295
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.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.
+ */
+
+export interface IFlashMessagesProps {
+ info?: string[];
+ warning?: string[];
+ error?: string[];
+ success?: string[];
+ isWrapped?: boolean;
+ children?: React.ReactNode;
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png
new file mode 100644
index 0000000000000..b6267b6e2c48e
Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png differ
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg
new file mode 100644
index 0000000000000..e6b987c398268
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx
new file mode 100644
index 0000000000000..ab5cd7f0de90f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.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 '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { ErrorStatePrompt } from '../../../shared/error_state';
+import { ErrorState } from './';
+
+describe('ErrorState', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx
new file mode 100644
index 0000000000000..9fa508d599425
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { ErrorStatePrompt } from '../../../shared/error_state';
+import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import { ViewContentHeader } from '../shared/view_content_header';
+
+export const ErrorState: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts
similarity index 82%
rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts
rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts
index 90f90ba168a2f..b4d58bab58ff1 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { createGenerateCsv } from './generate_csv';
+export { ErrorState } from './error_state';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts
new file mode 100644
index 0000000000000..9ee1b444ee817
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { Overview } from './overview';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx
new file mode 100644
index 0000000000000..1d7c565935e97
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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 '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui';
+
+import { OnboardingCard } from './onboarding_card';
+
+jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+const cardProps = {
+ title: 'My card',
+ icon: 'icon',
+ description: 'this is a card',
+ actionTitle: 'action',
+ testSubj: 'actionButton',
+};
+
+describe('OnboardingCard', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+ expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+ });
+
+ it('renders an action button', () => {
+ const wrapper = shallow( );
+ const prompt = wrapper.find(EuiEmptyPrompt).dive();
+
+ expect(prompt.find(EuiButton)).toHaveLength(1);
+ expect(prompt.find(EuiButtonEmpty)).toHaveLength(0);
+
+ const button = prompt.find('[data-test-subj="actionButton"]');
+ expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path');
+
+ button.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalled();
+ });
+
+ it('renders an empty button when onboarding is completed', () => {
+ const wrapper = shallow( );
+ const prompt = wrapper.find(EuiEmptyPrompt).dive();
+
+ expect(prompt.find(EuiButton)).toHaveLength(0);
+ expect(prompt.find(EuiButtonEmpty)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx
new file mode 100644
index 0000000000000..288c0be84fa9a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 } from 'react';
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexItem,
+ EuiPanel,
+ EuiEmptyPrompt,
+ IconType,
+ EuiButtonProps,
+ EuiButtonEmptyProps,
+ EuiLinkProps,
+} from '@elastic/eui';
+import { useRoutes } from '../shared/use_routes';
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+interface IOnboardingCardProps {
+ title: React.ReactNode;
+ icon: React.ReactNode;
+ description: React.ReactNode;
+ actionTitle: React.ReactNode;
+ testSubj: string;
+ actionPath?: string;
+ complete?: boolean;
+}
+
+export const OnboardingCard: React.FC = ({
+ title,
+ icon,
+ description,
+ actionTitle,
+ testSubj,
+ actionPath,
+ complete,
+}) => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+ const { getWSRoute } = useRoutes();
+
+ const onClick = () =>
+ sendTelemetry({
+ http,
+ product: 'workplace_search',
+ action: 'clicked',
+ metric: 'onboarding_card_button',
+ });
+ const buttonActionProps = actionPath
+ ? {
+ onClick,
+ href: getWSRoute(actionPath),
+ target: '_blank',
+ 'data-test-subj': testSubj,
+ }
+ : {
+ 'data-test-subj': testSubj,
+ };
+
+ const emptyButtonProps = {
+ ...buttonActionProps,
+ } as EuiButtonEmptyProps & EuiLinkProps;
+ const fillButtonProps = {
+ ...buttonActionProps,
+ color: 'secondary',
+ fill: true,
+ } as EuiButtonProps & EuiLinkProps;
+
+ return (
+
+
+ {title}}
+ body={description}
+ actions={
+ complete ? (
+ {actionTitle}
+ ) : (
+ {actionTitle}
+ )
+ }
+ />
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx
new file mode 100644
index 0000000000000..6174dc1c795eb
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx
@@ -0,0 +1,136 @@
+/*
+ * 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 '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes';
+
+jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps';
+import { OnboardingCard } from './onboarding_card';
+import { defaultServerData } from './overview';
+
+const account = {
+ id: '1',
+ isAdmin: true,
+ canCreatePersonalSources: true,
+ groups: [],
+ supportEligible: true,
+ isCurated: false,
+};
+
+describe('OnboardingSteps', () => {
+ describe('Shared Sources', () => {
+ it('renders 0 sources state', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(OnboardingCard)).toHaveLength(1);
+ expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH);
+ expect(wrapper.find(OnboardingCard).prop('description')).toBe(
+ 'Add shared sources for your organization to start searching.'
+ );
+ });
+
+ it('renders completed sources state', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OnboardingCard).prop('description')).toEqual(
+ 'You have added 2 shared sources. Happy searching.'
+ );
+ });
+
+ it('disables link when the user cannot create sources', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined);
+ });
+ });
+
+ describe('Users & Invitations', () => {
+ it('renders 0 users when not on federated auth', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OnboardingCard)).toHaveLength(2);
+ expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH);
+ expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual(
+ 'Invite your colleagues into this organization to search with you.'
+ );
+ });
+
+ it('renders completed users state', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual(
+ 'Nice, you’ve invited colleagues to search with you.'
+ );
+ });
+
+ it('disables link when the user cannot create invitations', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined);
+ });
+ });
+
+ describe('Org Name', () => {
+ it('renders button to change name', () => {
+ const wrapper = shallow( );
+
+ const button = wrapper
+ .find(OrgNameOnboarding)
+ .dive()
+ .find('[data-test-subj="orgNameChangeButton"]');
+
+ button.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalled();
+ });
+
+ it('hides card when name has been changed', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0);
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx
new file mode 100644
index 0000000000000..1b00347437338
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx
@@ -0,0 +1,179 @@
+/*
+ * 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 } from 'react';
+import { i18n } from '@kbn/i18n';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import {
+ EuiSpacer,
+ EuiButtonEmpty,
+ EuiTitle,
+ EuiPanel,
+ EuiIcon,
+ EuiFlexGrid,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiButtonEmptyProps,
+ EuiLinkProps,
+} from '@elastic/eui';
+import sharedSourcesIcon from '../shared/assets/share_circle.svg';
+import { useRoutes } from '../shared/use_routes';
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes';
+
+import { ContentSection } from '../shared/content_section';
+
+import { IAppServerData } from './overview';
+
+import { OnboardingCard } from './onboarding_card';
+
+const SOURCES_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title',
+ { defaultMessage: 'Shared sources' }
+);
+
+const USERS_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title',
+ { defaultMessage: 'Users & invitations' }
+);
+
+const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description',
+ { defaultMessage: 'Add shared sources for your organization to start searching.' }
+);
+
+const USERS_CARD_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title',
+ { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' }
+);
+
+const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description',
+ { defaultMessage: 'Invite your colleagues into this organization to search with you.' }
+);
+
+export const OnboardingSteps: React.FC = ({
+ hasUsers,
+ hasOrgSources,
+ canCreateContentSources,
+ canCreateInvitations,
+ accountsCount,
+ sourcesCount,
+ fpAccount: { isCurated },
+ organization: { name, defaultOrgName },
+ isFederatedAuth,
+}) => {
+ const accountsPath =
+ !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined;
+ const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined;
+
+ const SOURCES_CARD_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description',
+ {
+ defaultMessage:
+ 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.',
+ values: { sourcesCount },
+ }
+ );
+
+ return (
+
+
+ 0 ? 'more' : '' },
+ }
+ )}
+ actionPath={sourcesPath}
+ complete={hasOrgSources}
+ />
+ {!isFederatedAuth && (
+ 0 ? 'more' : '' },
+ }
+ )}
+ actionPath={accountsPath}
+ complete={hasUsers}
+ />
+ )}
+
+ {name === defaultOrgName && (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+export const OrgNameOnboarding: React.FC = () => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+ const { getWSRoute } = useRoutes();
+
+ const onClick = () =>
+ sendTelemetry({
+ http,
+ product: 'workplace_search',
+ action: 'clicked',
+ metric: 'org_name_change_button',
+ });
+
+ const buttonProps = {
+ onClick,
+ target: '_blank',
+ color: 'primary',
+ href: getWSRoute(ORG_SETTINGS_PATH),
+ 'data-test-subj': 'orgNameChangeButton',
+ } as EuiButtonEmptyProps & EuiLinkProps;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx
new file mode 100644
index 0000000000000..112e9a910667a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx
@@ -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 '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiFlexGrid } from '@elastic/eui';
+
+import { OrganizationStats } from './organization_stats';
+import { StatisticCard } from './statistic_card';
+import { defaultServerData } from './overview';
+
+describe('OrganizationStats', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(StatisticCard)).toHaveLength(2);
+ expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2);
+ });
+
+ it('renders additional cards for federated auth', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(StatisticCard)).toHaveLength(4);
+ expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx
new file mode 100644
index 0000000000000..aa9be81f32bae
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 { EuiFlexGrid } from '@elastic/eui';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import { ContentSection } from '../shared/content_section';
+import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes';
+
+import { IAppServerData } from './overview';
+
+import { StatisticCard } from './statistic_card';
+
+export const OrganizationStats: React.FC = ({
+ sourcesCount,
+ pendingInvitationsCount,
+ accountsCount,
+ personalSourcesCount,
+ isFederatedAuth,
+}) => (
+
+ }
+ headerSpacer="m"
+ >
+
+
+ {!isFederatedAuth && (
+ <>
+
+
+ >
+ )}
+
+
+
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx
new file mode 100644
index 0000000000000..e5e5235c52368
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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 '../../../__mocks__/react_router_history.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__';
+
+import { ErrorState } from '../error_state';
+import { Loading } from '../shared/loading';
+import { ViewContentHeader } from '../shared/view_content_header';
+
+import { OnboardingSteps } from './onboarding_steps';
+import { OrganizationStats } from './organization_stats';
+import { RecentActivity } from './recent_activity';
+import { Overview, defaultServerData } from './overview';
+
+describe('Overview', () => {
+ const mockHttp = mockKibanaContext.http;
+
+ describe('non-happy-path states', () => {
+ it('isLoading', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(Loading)).toHaveLength(1);
+ });
+
+ it('hasErrorConnecting', async () => {
+ const wrapper = await mountWithAsyncContext( , {
+ http: {
+ ...mockHttp,
+ get: () => Promise.reject({ invalidPayload: true }),
+ },
+ });
+
+ expect(wrapper.find(ErrorState)).toHaveLength(1);
+ });
+ });
+
+ describe('happy-path states', () => {
+ it('renders onboarding state', async () => {
+ const mockApi = jest.fn(() => defaultServerData);
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ });
+
+ expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
+ expect(wrapper.find(OnboardingSteps)).toHaveLength(1);
+ expect(wrapper.find(OrganizationStats)).toHaveLength(1);
+ expect(wrapper.find(RecentActivity)).toHaveLength(1);
+ });
+
+ it('renders when onboarding complete', async () => {
+ const obCompleteData = {
+ ...defaultServerData,
+ hasUsers: true,
+ hasOrgSources: true,
+ isOldAccount: true,
+ organization: {
+ name: 'foo',
+ defaultOrgName: 'bar',
+ },
+ };
+ const mockApi = jest.fn(() => obCompleteData);
+ const wrapper = await mountWithAsyncContext( , {
+ http: { ...mockHttp, get: mockApi },
+ });
+
+ expect(wrapper.find(OnboardingSteps)).toHaveLength(0);
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx
new file mode 100644
index 0000000000000..bacd65a2be75f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx
@@ -0,0 +1,151 @@
+/*
+ * 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, useEffect, useState } from 'react';
+import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+
+import { IAccount } from '../../types';
+
+import { ErrorState } from '../error_state';
+
+import { Loading } from '../shared/loading';
+import { ProductButton } from '../shared/product_button';
+import { ViewContentHeader } from '../shared/view_content_header';
+
+import { OnboardingSteps } from './onboarding_steps';
+import { OrganizationStats } from './organization_stats';
+import { RecentActivity, IFeedActivity } from './recent_activity';
+
+export interface IAppServerData {
+ hasUsers: boolean;
+ hasOrgSources: boolean;
+ canCreateContentSources: boolean;
+ canCreateInvitations: boolean;
+ isOldAccount: boolean;
+ sourcesCount: number;
+ pendingInvitationsCount: number;
+ accountsCount: number;
+ personalSourcesCount: number;
+ activityFeed: IFeedActivity[];
+ organization: {
+ name: string;
+ defaultOrgName: string;
+ };
+ isFederatedAuth: boolean;
+ currentUser: {
+ firstName: string;
+ email: string;
+ name: string;
+ color: string;
+ };
+ fpAccount: IAccount;
+}
+
+export const defaultServerData = {
+ accountsCount: 1,
+ activityFeed: [],
+ canCreateContentSources: true,
+ canCreateInvitations: true,
+ currentUser: {
+ firstName: '',
+ email: '',
+ name: '',
+ color: '',
+ },
+ fpAccount: {} as IAccount,
+ hasOrgSources: false,
+ hasUsers: false,
+ isFederatedAuth: true,
+ isOldAccount: false,
+ organization: {
+ name: '',
+ defaultOrgName: '',
+ },
+ pendingInvitationsCount: 0,
+ personalSourcesCount: 0,
+ sourcesCount: 0,
+} as IAppServerData;
+
+const ONBOARDING_HEADER_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title',
+ { defaultMessage: 'Get started with Workplace Search' }
+);
+
+const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', {
+ defaultMessage: 'Organization overview',
+});
+
+const ONBOARDING_HEADER_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description',
+ { defaultMessage: 'Complete the following to set up your organization.' }
+);
+
+const HEADER_DESCRIPTION = i18n.translate(
+ 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description',
+ { defaultMessage: "Your organizations's statistics and activity" }
+);
+
+export const Overview: React.FC = () => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasErrorConnecting, setHasErrorConnecting] = useState(false);
+ const [appData, setAppData] = useState(defaultServerData);
+
+ const getAppData = async () => {
+ try {
+ const response = await http.get('/api/workplace_search/overview');
+ setAppData(response);
+ } catch (error) {
+ setHasErrorConnecting(true);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ getAppData();
+ }, []);
+
+ if (hasErrorConnecting) return ;
+ if (isLoading) return ;
+
+ const {
+ hasUsers,
+ hasOrgSources,
+ isOldAccount,
+ organization: { name: orgName, defaultOrgName },
+ } = appData as IAppServerData;
+ const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName;
+
+ const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE;
+ const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION;
+
+ return (
+
+
+
+
+
+ }
+ />
+ {!hideOnboarding && }
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss
new file mode 100644
index 0000000000000..2d1e474c03faa
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss
@@ -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.
+ */
+
+.activity {
+ display: flex;
+ justify-content: space-between;
+ padding: $euiSizeM;
+ font-size: $euiFontSizeS;
+
+ &--error {
+ font-weight: $euiFontWeightSemiBold;
+ color: $euiColorDanger;
+ background: rgba($euiColorDanger, 0.1);
+
+ &__label {
+ margin-left: $euiSizeS * 1.75;
+ font-weight: $euiFontWeightRegular;
+ text-decoration: underline;
+ opacity: 0.7;
+ }
+ }
+
+ &__message {
+ flex-grow: 1;
+ }
+
+ &__date {
+ flex-grow: 0;
+ }
+
+ & + & {
+ border-top: $euiBorderThin;
+ }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx
new file mode 100644
index 0000000000000..e9bdedb199dad
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { EuiEmptyPrompt, EuiLink } from '@elastic/eui';
+
+import { RecentActivity, RecentActivityItem } from './recent_activity';
+import { defaultServerData } from './overview';
+
+jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() }));
+import { sendTelemetry } from '../../../shared/telemetry';
+
+const org = { name: 'foo', defaultOrgName: 'bar' };
+
+const feed = [
+ {
+ id: 'demo',
+ sourceId: 'd2d2d23d',
+ message: 'was successfully connected',
+ target: 'http://localhost:3002/ws/org/sources',
+ timestamp: '2020-06-24 16:34:16',
+ },
+];
+
+describe('RecentActivity', () => {
+ it('renders with no feed data', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
+
+ // Branch coverage - renders without error for custom org name
+ shallow( );
+ });
+
+ it('renders an activity feed with links', () => {
+ const wrapper = shallow( );
+ const activity = wrapper.find(RecentActivityItem).dive();
+
+ expect(activity).toHaveLength(1);
+
+ const link = activity.find('[data-test-subj="viewSourceDetailsLink"]');
+ link.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalled();
+ });
+
+ it('renders activity item error state', () => {
+ const props = { ...feed[0], status: 'error' };
+ const wrapper = shallow( );
+
+ expect(wrapper.find('.activity--error')).toHaveLength(1);
+ expect(wrapper.find('.activity--error__label')).toHaveLength(1);
+ expect(wrapper.find(EuiLink).prop('color')).toEqual('danger');
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx
new file mode 100644
index 0000000000000..8d69582c93684
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx
@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React, { useContext } from 'react';
+
+import moment from 'moment';
+
+import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { ContentSection } from '../shared/content_section';
+import { useRoutes } from '../shared/use_routes';
+import { sendTelemetry } from '../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../index';
+import { getSourcePath } from '../../routes';
+
+import { IAppServerData } from './overview';
+
+import './recent_activity.scss';
+
+export interface IFeedActivity {
+ status?: string;
+ id: string;
+ message: string;
+ timestamp: string;
+ sourceId: string;
+}
+
+export const RecentActivity: React.FC = ({
+ organization: { name, defaultOrgName },
+ activityFeed,
+}) => {
+ return (
+
+ }
+ headerSpacer="m"
+ >
+
+ {activityFeed.length > 0 ? (
+ <>
+ {activityFeed.map((props: IFeedActivity, index) => (
+
+ ))}
+ >
+ ) : (
+ <>
+
+
+ {name === defaultOrgName ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ >
+ )}
+
+
+ );
+};
+
+export const RecentActivityItem: React.FC = ({
+ id,
+ status,
+ message,
+ timestamp,
+ sourceId,
+}) => {
+ const { http } = useContext(KibanaContext) as IKibanaContext;
+ const { getWSRoute } = useRoutes();
+
+ const onClick = () =>
+ sendTelemetry({
+ http,
+ product: 'workplace_search',
+ action: 'clicked',
+ metric: 'recent_activity_source_details_link',
+ });
+
+ const linkProps = {
+ onClick,
+ target: '_blank',
+ href: getWSRoute(getSourcePath(sourceId)),
+ external: true,
+ color: status === 'error' ? 'danger' : 'primary',
+ 'data-test-subj': 'viewSourceDetailsLink',
+ } as EuiLinkProps;
+
+ return (
+
+
+
+ {id} {message}
+ {status === 'error' && (
+
+ {' '}
+
+
+ )}
+
+
+
{moment.utc(timestamp).fromNow()}
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx
new file mode 100644
index 0000000000000..edf266231b39e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx
@@ -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 '../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { EuiCard } from '@elastic/eui';
+
+import { StatisticCard } from './statistic_card';
+
+const props = {
+ title: 'foo',
+};
+
+describe('StatisticCard', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiCard)).toHaveLength(1);
+ });
+
+ it('renders clickable card', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo');
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx
new file mode 100644
index 0000000000000..9bc8f4f768073
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui';
+
+import { useRoutes } from '../shared/use_routes';
+
+interface IStatisticCardProps {
+ title: string;
+ count?: number;
+ actionPath?: string;
+}
+
+export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => {
+ const { getWSRoute } = useRoutes();
+
+ const linkProps = actionPath
+ ? {
+ href: getWSRoute(actionPath),
+ target: '_blank',
+ rel: 'noopener',
+ }
+ : {};
+ // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx)
+
+ return (
+
+
+ {count}
+
+ }
+ />
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts
new file mode 100644
index 0000000000000..c367424d375f9
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { SetupGuide } from './setup_guide';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx
new file mode 100644
index 0000000000000..b87c35d5a5942
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.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 { shallow } from 'enzyme';
+
+import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
+import { SetupGuide } from './';
+
+describe('SetupGuide', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(SetupGuideLayout)).toHaveLength(1);
+ expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx
new file mode 100644
index 0000000000000..5b5d067d23eb8
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { EuiSpacer, EuiTitle, EuiText, EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+
+import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide';
+
+import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs';
+import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import GettingStarted from '../../assets/getting_started.png';
+
+const GETTING_STARTED_LINK_URL =
+ 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html';
+
+export const SetupGuide: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get started with Workplace Search
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg
new file mode 100644
index 0000000000000..f8d2ea1e634f6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx
new file mode 100644
index 0000000000000..f406fb136f13f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 '../../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiTitle, EuiSpacer } from '@elastic/eui';
+
+import { ContentSection } from './';
+
+const props = {
+ children:
,
+ testSubj: 'contentSection',
+ className: 'test',
+};
+
+describe('ContentSection', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.prop('data-test-subj')).toEqual('contentSection');
+ expect(wrapper.prop('className')).toEqual('test');
+ expect(wrapper.find('.children')).toHaveLength(1);
+ });
+
+ it('displays title and description', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiTitle)).toHaveLength(1);
+ expect(wrapper.find('p').text()).toEqual('bar');
+ });
+
+ it('displays header content', () => {
+ const wrapper = shallow(
+ }
+ />
+ );
+
+ expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s');
+ expect(wrapper.find('.header')).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx
new file mode 100644
index 0000000000000..b2a9eebc72e85
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import { EuiSpacer, EuiTitle } from '@elastic/eui';
+
+import { TSpacerSize } from '../../../types';
+
+interface IContentSectionProps {
+ children: React.ReactNode;
+ className?: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ headerChildren?: React.ReactNode;
+ headerSpacer?: TSpacerSize;
+ testSubj?: string;
+}
+
+export const ContentSection: React.FC = ({
+ children,
+ className = '',
+ title,
+ description,
+ headerChildren,
+ headerSpacer,
+ testSubj,
+}) => (
+
+ {title && (
+ <>
+
+ {title}
+
+ {description &&
{description}
}
+ {headerChildren}
+ {headerSpacer &&
}
+ >
+ )}
+ {children}
+
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts
new file mode 100644
index 0000000000000..7dcb1b13ad1dc
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { ContentSection } from './content_section';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts
new file mode 100644
index 0000000000000..745639955dcba
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { Loading } from './loading';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss
new file mode 100644
index 0000000000000..008a8066f807b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss
@@ -0,0 +1,14 @@
+.loadingSpinnerWrapper {
+ width: 100%;
+ height: 90vh;
+ margin: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.loadingSpinner {
+ width: $euiSizeXXL * 1.25;
+ height: $euiSizeXXL * 1.25;
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx
new file mode 100644
index 0000000000000..8d168b436cc3b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.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 '../../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiLoadingSpinner } from '@elastic/eui';
+
+import { Loading } from './';
+
+describe('Loading', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx
new file mode 100644
index 0000000000000..399abedf55e87
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx
@@ -0,0 +1,17 @@
+/*
+ * 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 { EuiLoadingSpinner } from '@elastic/eui';
+
+import './loading.scss';
+
+export const Loading: React.FC = () => (
+
+
+
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts
new file mode 100644
index 0000000000000..c41e27bacb892
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { ProductButton } from './product_button';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx
new file mode 100644
index 0000000000000..429a2c509813d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.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 '../../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { ProductButton } from './';
+
+jest.mock('../../../../shared/telemetry', () => ({
+ sendTelemetry: jest.fn(),
+ SendAppSearchTelemetry: jest.fn(),
+}));
+import { sendTelemetry } from '../../../../shared/telemetry';
+
+describe('ProductButton', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(EuiButton)).toHaveLength(1);
+ expect(wrapper.find(FormattedMessage)).toHaveLength(1);
+ });
+
+ it('sends telemetry on create first engine click', () => {
+ const wrapper = shallow( );
+ const button = wrapper.find(EuiButton);
+
+ button.simulate('click');
+ expect(sendTelemetry).toHaveBeenCalled();
+ (sendTelemetry as jest.Mock).mockClear();
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx
new file mode 100644
index 0000000000000..5b86e14132e0f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx
@@ -0,0 +1,41 @@
+/*
+ * 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 } from 'react';
+
+import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { sendTelemetry } from '../../../../shared/telemetry';
+import { KibanaContext, IKibanaContext } from '../../../../index';
+
+export const ProductButton: React.FC = () => {
+ const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext;
+
+ const buttonProps = {
+ fill: true,
+ iconType: 'popout',
+ 'data-test-subj': 'launchButton',
+ } as EuiButtonProps & EuiLinkProps;
+ buttonProps.href = `${enterpriseSearchUrl}/ws`;
+ buttonProps.target = '_blank';
+ buttonProps.onClick = () =>
+ sendTelemetry({
+ http,
+ product: 'workplace_search',
+ action: 'clicked',
+ metric: 'header_launch_button',
+ });
+
+ return (
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts
new file mode 100644
index 0000000000000..cb9684408c459
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { useRoutes } from './use_routes';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx
new file mode 100644
index 0000000000000..48b8695f82b43
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx
@@ -0,0 +1,15 @@
+/*
+ * 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 { useContext } from 'react';
+
+import { KibanaContext, IKibanaContext } from '../../../../index';
+
+export const useRoutes = () => {
+ const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
+ const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`;
+ return { getWSRoute };
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts
new file mode 100644
index 0000000000000..774b3d85c8c85
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { ViewContentHeader } from './view_content_header';
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx
new file mode 100644
index 0000000000000..4680f15771caa
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import '../../../../__mocks__/shallow_usecontext.mock';
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import { EuiFlexGroup } from '@elastic/eui';
+
+import { ViewContentHeader } from './';
+
+const props = {
+ title: 'Header',
+ alignItems: 'flexStart' as any,
+};
+
+describe('ViewContentHeader', () => {
+ it('renders with title and alignItems', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find('h2').text()).toEqual('Header');
+ expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart');
+ });
+
+ it('shows description, when present', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find('p').text()).toEqual('Hello World');
+ });
+
+ it('shows action, when present', () => {
+ const wrapper = shallow( } />);
+
+ expect(wrapper.find('.action')).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx
new file mode 100644
index 0000000000000..0408517fd4ec5
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui';
+
+import { FlexGroupAlignItems } from '@elastic/eui/src/components/flex/flex_group';
+
+interface IViewContentHeaderProps {
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+ alignItems?: FlexGroupAlignItems;
+}
+
+export const ViewContentHeader: React.FC = ({
+ title,
+ description,
+ action,
+ alignItems = 'center',
+}) => (
+ <>
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action && {action} }
+
+
+ >
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx
new file mode 100644
index 0000000000000..743080d965c36
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 '../__mocks__/shallow_usecontext.mock';
+
+import React, { useContext } from 'react';
+import { Redirect } from 'react-router-dom';
+import { shallow } from 'enzyme';
+
+import { SetupGuide } from './components/setup_guide';
+import { Overview } from './components/overview';
+
+import { WorkplaceSearch } from './';
+
+describe('Workplace Search Routes', () => {
+ describe('/', () => {
+ it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => {
+ (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' }));
+ const wrapper = shallow( );
+
+ expect(wrapper.find(Redirect)).toHaveLength(1);
+ expect(wrapper.find(Overview)).toHaveLength(0);
+ });
+
+ it('renders Engine Overview when enterpriseSearchUrl is set', () => {
+ (useContext as jest.Mock).mockImplementationOnce(() => ({
+ enterpriseSearchUrl: 'https://foo.bar',
+ }));
+ const wrapper = shallow( );
+
+ expect(wrapper.find(Overview)).toHaveLength(1);
+ expect(wrapper.find(Redirect)).toHaveLength(0);
+ });
+ });
+
+ describe('/setup_guide', () => {
+ it('renders', () => {
+ const wrapper = shallow( );
+
+ expect(wrapper.find(SetupGuide)).toHaveLength(1);
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
new file mode 100644
index 0000000000000..36b1a56ecba26
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx
@@ -0,0 +1,29 @@
+/*
+ * 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 } from 'react';
+import { Route, Redirect } from 'react-router-dom';
+
+import { KibanaContext, IKibanaContext } from '../index';
+
+import { SETUP_GUIDE_PATH } from './routes';
+
+import { SetupGuide } from './components/setup_guide';
+import { Overview } from './components/overview';
+
+export const WorkplaceSearch: React.FC = () => {
+ const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext;
+ return (
+ <>
+
+ {!enterpriseSearchUrl ? : }
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts
new file mode 100644
index 0000000000000..d9798d1f30cfc
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts
@@ -0,0 +1,12 @@
+/*
+ * 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.
+ */
+
+export const ORG_SOURCES_PATH = '/org/sources';
+export const USERS_PATH = '/org/users';
+export const ORG_SETTINGS_PATH = '/org/settings';
+export const SETUP_GUIDE_PATH = '/setup_guide';
+
+export const getSourcePath = (sourceId: string): string => `${ORG_SOURCES_PATH}/${sourceId}`;
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts
new file mode 100644
index 0000000000000..b448c59c52f3e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+export interface IAccount {
+ id: string;
+ isCurated?: boolean;
+ isAdmin: boolean;
+ canCreatePersonalSources: boolean;
+ groups: string[];
+ supportEligible: boolean;
+}
+
+export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl';
diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts
index fbfcc303de47a..fc95828a3f4a4 100644
--- a/x-pack/plugins/enterprise_search/public/plugin.ts
+++ b/x-pack/plugins/enterprise_search/public/plugin.ts
@@ -22,6 +22,7 @@ import { LicensingPluginSetup } from '../../licensing/public';
import { getPublicUrl } from './applications/shared/enterprise_search_url';
import AppSearchLogo from './applications/app_search/assets/logo.svg';
+import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg';
export interface ClientConfigType {
host?: string;
@@ -58,7 +59,21 @@ export class EnterpriseSearchPlugin implements Plugin {
return renderApp(AppSearch, coreStart, params, config, plugins);
},
});
- // TODO: Workplace Search will need to register its own plugin.
+
+ core.application.register({
+ id: 'workplaceSearch',
+ title: 'Workplace Search',
+ appRoute: '/app/enterprise_search/workplace_search',
+ category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
+ mount: async (params: AppMountParameters) => {
+ const [coreStart] = await core.getStartServices();
+
+ const { renderApp } = await import('./applications');
+ const { WorkplaceSearch } = await import('./applications/workplace_search');
+
+ return renderApp(WorkplaceSearch, coreStart, params, config, plugins);
+ },
+ });
plugins.home.featureCatalogue.register({
id: 'appSearch',
@@ -70,7 +85,17 @@ export class EnterpriseSearchPlugin implements Plugin {
category: FeatureCatalogueCategory.DATA,
showOnHomePage: true,
});
- // TODO: Workplace Search will need to register its own feature catalogue section/card.
+
+ plugins.home.featureCatalogue.register({
+ id: 'workplaceSearch',
+ title: 'Workplace Search',
+ icon: WorkplaceSearchLogo,
+ description:
+ 'Search all documents, files, and sources available across your virtual workplace.',
+ path: '/app/enterprise_search/workplace_search',
+ category: FeatureCatalogueCategory.DATA,
+ showOnHomePage: true,
+ });
}
public start(core: CoreStart) {}
diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts
index e95056b871324..53c6dee61cd1d 100644
--- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts
+++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts
@@ -4,20 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { loggingSystemMock } from 'src/core/server/mocks';
+import { mockLogger } from '../../routes/__mocks__';
-jest.mock('../../../../../../src/core/server', () => ({
- SavedObjectsErrorHelpers: {
- isNotFoundError: jest.fn(),
- },
-}));
-import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
-
-import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry';
+import { registerTelemetryUsageCollector } from './telemetry';
describe('App Search Telemetry Usage Collector', () => {
- const mockLogger = loggingSystemMock.create().get();
-
const makeUsageCollectorStub = jest.fn();
const registerStub = jest.fn();
const usageCollectionMock = {
@@ -103,41 +94,5 @@ describe('App Search Telemetry Usage Collector', () => {
},
});
});
-
- it('should not throw but log a warning if saved objects errors', async () => {
- const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any;
- registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger);
-
- // Without log warning (not found)
- (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
- await makeUsageCollectorStub.mock.calls[0][0].fetch();
-
- expect(mockLogger.warn).not.toHaveBeenCalled();
-
- // With log warning
- (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
- await makeUsageCollectorStub.mock.calls[0][0].fetch();
-
- expect(mockLogger.warn).toHaveBeenCalledWith(
- 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function'
- );
- });
- });
-
- describe('incrementUICounter', () => {
- it('should increment the saved objects internal repository', async () => {
- const response = await incrementUICounter({
- savedObjects: savedObjectsMock,
- uiAction: 'ui_clicked',
- metric: 'button',
- });
-
- expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith(
- 'app_search_telemetry',
- 'app_search_telemetry',
- 'ui_clicked.button'
- );
- expect(response).toEqual({ success: true });
- });
});
});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts
index a10f96907ad28..f700088cb67a0 100644
--- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts
+++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts
@@ -5,16 +5,10 @@
*/
import { get } from 'lodash';
-import {
- ISavedObjectsRepository,
- SavedObjectsServiceStart,
- SavedObjectAttributes,
- Logger,
-} from 'src/core/server';
+import { SavedObjectsServiceStart, Logger } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
-// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯
-import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
+import { getSavedObjectAttributesFromRepo } from '../lib/telemetry';
interface ITelemetry {
ui_viewed: {
@@ -70,10 +64,11 @@ export const registerTelemetryUsageCollector = (
const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
const savedObjectsRepository = savedObjects.createInternalRepository();
- const savedObjectAttributes = (await getSavedObjectAttributesFromRepo(
+ const savedObjectAttributes = await getSavedObjectAttributesFromRepo(
+ AS_TELEMETRY_NAME,
savedObjectsRepository,
log
- )) as SavedObjectAttributes;
+ );
const defaultTelemetrySavedObject: ITelemetry = {
ui_viewed: {
@@ -114,43 +109,3 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log
},
} as ITelemetry;
};
-
-/**
- * Helper function - fetches saved objects attributes
- */
-
-const getSavedObjectAttributesFromRepo = async (
- savedObjectsRepository: ISavedObjectsRepository,
- log: Logger
-) => {
- try {
- return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes;
- } catch (e) {
- if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
- log.warn(`Failed to retrieve App Search telemetry data: ${e}`);
- }
- return null;
- }
-};
-
-/**
- * Set saved objection attributes - used by telemetry route
- */
-
-interface IIncrementUICounter {
- savedObjects: SavedObjectsServiceStart;
- uiAction: string;
- metric: string;
-}
-
-export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) {
- const internalRepository = savedObjects.createInternalRepository();
-
- await internalRepository.incrementCounter(
- AS_TELEMETRY_NAME,
- AS_TELEMETRY_NAME,
- `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
- );
-
- return { success: true };
-}
diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts
new file mode 100644
index 0000000000000..3ab3b03dd7725
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts
@@ -0,0 +1,69 @@
+/*
+ * 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 { mockLogger } from '../../routes/__mocks__';
+
+jest.mock('../../../../../../src/core/server', () => ({
+ SavedObjectsErrorHelpers: {
+ isNotFoundError: jest.fn(),
+ },
+}));
+import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
+
+import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry';
+
+describe('App Search Telemetry Usage Collector', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getSavedObjectAttributesFromRepo', () => {
+ // Note: savedObjectsRepository.get() is best tested as a whole from
+ // individual fetchTelemetryMetrics tests. This mostly just tests error handling
+ it('should not throw but log a warning if saved objects errors', async () => {
+ const errorSavedObjectsMock = {} as any;
+
+ // Without log warning (not found)
+ (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true);
+ await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger);
+
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+
+ // With log warning
+ (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false);
+ await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger);
+
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ 'Failed to retrieve some_id telemetry data: TypeError: savedObjectsRepository.get is not a function'
+ );
+ });
+ });
+
+ describe('incrementUICounter', () => {
+ const incrementCounterMock = jest.fn();
+ const savedObjectsMock = {
+ createInternalRepository: jest.fn(() => ({
+ incrementCounter: incrementCounterMock,
+ })),
+ } as any;
+
+ it('should increment the saved objects internal repository', async () => {
+ const response = await incrementUICounter({
+ id: 'app_search_telemetry',
+ savedObjects: savedObjectsMock,
+ uiAction: 'ui_clicked',
+ metric: 'button',
+ });
+
+ expect(incrementCounterMock).toHaveBeenCalledWith(
+ 'app_search_telemetry',
+ 'app_search_telemetry',
+ 'ui_clicked.button'
+ );
+ expect(response).toEqual({ success: true });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts
new file mode 100644
index 0000000000000..f5f4fa368555f
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 {
+ ISavedObjectsRepository,
+ SavedObjectsServiceStart,
+ SavedObjectAttributes,
+ Logger,
+} from 'src/core/server';
+
+// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯
+import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server';
+
+/**
+ * Fetches saved objects attributes - used by collectors
+ */
+
+export const getSavedObjectAttributesFromRepo = async (
+ id: string, // Telemetry name
+ savedObjectsRepository: ISavedObjectsRepository,
+ log: Logger
+): Promise => {
+ try {
+ return (await savedObjectsRepository.get(id, id)).attributes as SavedObjectAttributes;
+ } catch (e) {
+ if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
+ log.warn(`Failed to retrieve ${id} telemetry data: ${e}`);
+ }
+ return null;
+ }
+};
+
+/**
+ * Set saved objection attributes - used by telemetry route
+ */
+
+interface IIncrementUICounter {
+ id: string; // Telemetry name
+ savedObjects: SavedObjectsServiceStart;
+ uiAction: string;
+ metric: string;
+}
+
+export async function incrementUICounter({
+ id,
+ savedObjects,
+ uiAction,
+ metric,
+}: IIncrementUICounter) {
+ const internalRepository = savedObjects.createInternalRepository();
+
+ await internalRepository.incrementCounter(
+ id,
+ id,
+ `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide
+ );
+
+ return { success: true };
+}
diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts
new file mode 100644
index 0000000000000..496b2f254f9a6
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts
@@ -0,0 +1,101 @@
+/*
+ * 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 { mockLogger } from '../../routes/__mocks__';
+
+import { registerTelemetryUsageCollector } from './telemetry';
+
+describe('Workplace Search Telemetry Usage Collector', () => {
+ const makeUsageCollectorStub = jest.fn();
+ const registerStub = jest.fn();
+ const usageCollectionMock = {
+ makeUsageCollector: makeUsageCollectorStub,
+ registerCollector: registerStub,
+ } as any;
+
+ const savedObjectsRepoStub = {
+ get: () => ({
+ attributes: {
+ 'ui_viewed.setup_guide': 10,
+ 'ui_viewed.overview': 20,
+ 'ui_error.cannot_connect': 3,
+ 'ui_clicked.header_launch_button': 30,
+ 'ui_clicked.org_name_change_button': 40,
+ 'ui_clicked.onboarding_card_button': 50,
+ 'ui_clicked.recent_activity_source_details_link': 60,
+ },
+ }),
+ incrementCounter: jest.fn(),
+ };
+ const savedObjectsMock = {
+ createInternalRepository: jest.fn(() => savedObjectsRepoStub),
+ } as any;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('registerTelemetryUsageCollector', () => {
+ it('should make and register the usage collector', () => {
+ registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+
+ expect(registerStub).toHaveBeenCalledTimes(1);
+ expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1);
+ expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('workplace_search');
+ expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true);
+ });
+ });
+
+ describe('fetchTelemetryMetrics', () => {
+ it('should return existing saved objects data', async () => {
+ registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger);
+ const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+ expect(savedObjectsCounts).toEqual({
+ ui_viewed: {
+ setup_guide: 10,
+ overview: 20,
+ },
+ ui_error: {
+ cannot_connect: 3,
+ },
+ ui_clicked: {
+ header_launch_button: 30,
+ org_name_change_button: 40,
+ onboarding_card_button: 50,
+ recent_activity_source_details_link: 60,
+ },
+ });
+ });
+
+ it('should return a default telemetry object if no saved data exists', async () => {
+ const emptySavedObjectsMock = {
+ createInternalRepository: () => ({
+ get: () => ({ attributes: null }),
+ }),
+ } as any;
+
+ registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger);
+ const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch();
+
+ expect(savedObjectsCounts).toEqual({
+ ui_viewed: {
+ setup_guide: 0,
+ overview: 0,
+ },
+ ui_error: {
+ cannot_connect: 0,
+ },
+ ui_clicked: {
+ header_launch_button: 0,
+ org_name_change_button: 0,
+ onboarding_card_button: 0,
+ recent_activity_source_details_link: 0,
+ },
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts
new file mode 100644
index 0000000000000..892de5cfee35e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts
@@ -0,0 +1,115 @@
+/*
+ * 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 { get } from 'lodash';
+import { SavedObjectsServiceStart, Logger } from 'src/core/server';
+import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
+
+import { getSavedObjectAttributesFromRepo } from '../lib/telemetry';
+
+interface ITelemetry {
+ ui_viewed: {
+ setup_guide: number;
+ overview: number;
+ };
+ ui_error: {
+ cannot_connect: number;
+ };
+ ui_clicked: {
+ header_launch_button: number;
+ org_name_change_button: number;
+ onboarding_card_button: number;
+ recent_activity_source_details_link: number;
+ };
+}
+
+export const WS_TELEMETRY_NAME = 'workplace_search_telemetry';
+
+/**
+ * Register the telemetry collector
+ */
+
+export const registerTelemetryUsageCollector = (
+ usageCollection: UsageCollectionSetup,
+ savedObjects: SavedObjectsServiceStart,
+ log: Logger
+) => {
+ const telemetryUsageCollector = usageCollection.makeUsageCollector({
+ type: 'workplace_search',
+ fetch: async () => fetchTelemetryMetrics(savedObjects, log),
+ isReady: () => true,
+ schema: {
+ ui_viewed: {
+ setup_guide: { type: 'long' },
+ overview: { type: 'long' },
+ },
+ ui_error: {
+ cannot_connect: { type: 'long' },
+ },
+ ui_clicked: {
+ header_launch_button: { type: 'long' },
+ org_name_change_button: { type: 'long' },
+ onboarding_card_button: { type: 'long' },
+ recent_activity_source_details_link: { type: 'long' },
+ },
+ },
+ });
+ usageCollection.registerCollector(telemetryUsageCollector);
+};
+
+/**
+ * Fetch the aggregated telemetry metrics from our saved objects
+ */
+
+const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => {
+ const savedObjectsRepository = savedObjects.createInternalRepository();
+ const savedObjectAttributes = await getSavedObjectAttributesFromRepo(
+ WS_TELEMETRY_NAME,
+ savedObjectsRepository,
+ log
+ );
+
+ const defaultTelemetrySavedObject: ITelemetry = {
+ ui_viewed: {
+ setup_guide: 0,
+ overview: 0,
+ },
+ ui_error: {
+ cannot_connect: 0,
+ },
+ ui_clicked: {
+ header_launch_button: 0,
+ org_name_change_button: 0,
+ onboarding_card_button: 0,
+ recent_activity_source_details_link: 0,
+ },
+ };
+
+ // If we don't have an existing/saved telemetry object, return the default
+ if (!savedObjectAttributes) {
+ return defaultTelemetrySavedObject;
+ }
+
+ return {
+ ui_viewed: {
+ setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0),
+ overview: get(savedObjectAttributes, 'ui_viewed.overview', 0),
+ },
+ ui_error: {
+ cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0),
+ },
+ ui_clicked: {
+ header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0),
+ org_name_change_button: get(savedObjectAttributes, 'ui_clicked.org_name_change_button', 0),
+ onboarding_card_button: get(savedObjectAttributes, 'ui_clicked.onboarding_card_button', 0),
+ recent_activity_source_details_link: get(
+ savedObjectAttributes,
+ 'ui_clicked.recent_activity_source_details_link',
+ 0
+ ),
+ },
+ } as ITelemetry;
+};
diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts
index 70be8600862e9..a7bd68f92f78b 100644
--- a/x-pack/plugins/enterprise_search/server/plugin.ts
+++ b/x-pack/plugins/enterprise_search/server/plugin.ts
@@ -22,10 +22,15 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve
import { ConfigType } from './';
import { checkAccess } from './lib/check_access';
import { registerPublicUrlRoute } from './routes/enterprise_search/public_url';
-import { registerEnginesRoute } from './routes/app_search/engines';
-import { registerTelemetryRoute } from './routes/app_search/telemetry';
-import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry';
+import { registerTelemetryRoute } from './routes/enterprise_search/telemetry';
+
import { appSearchTelemetryType } from './saved_objects/app_search/telemetry';
+import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry';
+import { registerEnginesRoute } from './routes/app_search/engines';
+
+import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry';
+import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry';
+import { registerWSOverviewRoute } from './routes/workplace_search/overview';
export interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
@@ -64,8 +69,8 @@ export class EnterpriseSearchPlugin implements Plugin {
order: 0,
icon: 'logoEnterpriseSearch',
navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId
- app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
- catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch'
+ app: ['kibana', 'appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch'
+ catalogue: ['appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch'
privileges: null,
});
@@ -75,15 +80,16 @@ export class EnterpriseSearchPlugin implements Plugin {
capabilities.registerSwitcher(async (request: KibanaRequest) => {
const dependencies = { config, security, request, log: this.logger };
- const { hasAppSearchAccess } = await checkAccess(dependencies);
- // TODO: hasWorkplaceSearchAccess
+ const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies);
return {
navLinks: {
appSearch: hasAppSearchAccess,
+ workplaceSearch: hasWorkplaceSearchAccess,
},
catalogue: {
appSearch: hasAppSearchAccess,
+ workplaceSearch: hasWorkplaceSearchAccess,
},
};
});
@@ -96,23 +102,24 @@ export class EnterpriseSearchPlugin implements Plugin {
registerPublicUrlRoute(dependencies);
registerEnginesRoute(dependencies);
+ registerWSOverviewRoute(dependencies);
/**
* Bootstrap the routes, saved objects, and collector for telemetry
*/
savedObjects.registerType(appSearchTelemetryType);
+ savedObjects.registerType(workplaceSearchTelemetryType);
let savedObjectsStarted: SavedObjectsServiceStart;
getStartServices().then(([coreStart]) => {
savedObjectsStarted = coreStart.savedObjects;
+
if (usageCollection) {
- registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
+ registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
+ registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger);
}
});
- registerTelemetryRoute({
- ...dependencies,
- getSavedObjectsService: () => savedObjectsStarted,
- });
+ registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted });
}
public start() {}
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts
similarity index 56%
rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts
rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts
index e2d5fbcec3705..ebd84d3e0e79a 100644
--- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts
@@ -7,20 +7,21 @@
import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks';
import { MockRouter, mockConfig, mockLogger } from '../__mocks__';
-import { registerTelemetryRoute } from './telemetry';
-
-jest.mock('../../collectors/app_search/telemetry', () => ({
+jest.mock('../../collectors/lib/telemetry', () => ({
incrementUICounter: jest.fn(),
}));
-import { incrementUICounter } from '../../collectors/app_search/telemetry';
+import { incrementUICounter } from '../../collectors/lib/telemetry';
+
+import { registerTelemetryRoute } from './telemetry';
/**
* Since these route callbacks are so thin, these serve simply as integration tests
* to ensure they're wired up to the collector functions correctly. Business logic
* is tested more thoroughly in the collectors/telemetry tests.
*/
-describe('App Search Telemetry API', () => {
+describe('Enterprise Search Telemetry API', () => {
let mockRouter: MockRouter;
+ const successResponse = { success: true };
beforeEach(() => {
jest.clearAllMocks();
@@ -34,14 +35,20 @@ describe('App Search Telemetry API', () => {
});
});
- describe('PUT /api/app_search/telemetry', () => {
- it('increments the saved objects counter', async () => {
- const successResponse = { success: true };
+ describe('PUT /api/enterprise_search/telemetry', () => {
+ it('increments the saved objects counter for App Search', async () => {
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
- await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } });
+ await mockRouter.callRoute({
+ body: {
+ product: 'app_search',
+ action: 'viewed',
+ metric: 'setup_guide',
+ },
+ });
expect(incrementUICounter).toHaveBeenCalledWith({
+ id: 'app_search_telemetry',
savedObjects: expect.any(Object),
uiAction: 'ui_viewed',
metric: 'setup_guide',
@@ -49,10 +56,36 @@ describe('App Search Telemetry API', () => {
expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
});
+ it('increments the saved objects counter for Workplace Search', async () => {
+ (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse));
+
+ await mockRouter.callRoute({
+ body: {
+ product: 'workplace_search',
+ action: 'clicked',
+ metric: 'onboarding_card_button',
+ },
+ });
+
+ expect(incrementUICounter).toHaveBeenCalledWith({
+ id: 'workplace_search_telemetry',
+ savedObjects: expect.any(Object),
+ uiAction: 'ui_clicked',
+ metric: 'onboarding_card_button',
+ });
+ expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse });
+ });
+
it('throws an error when incrementing fails', async () => {
(incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed')));
- await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } });
+ await mockRouter.callRoute({
+ body: {
+ product: 'enterprise_search',
+ action: 'error',
+ metric: 'error',
+ },
+ });
expect(incrementUICounter).toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalled();
@@ -73,34 +106,50 @@ describe('App Search Telemetry API', () => {
expect(mockRouter.response.internalError).toHaveBeenCalled();
expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual(
expect.stringContaining(
- 'App Search UI telemetry error: Error: Could not find Saved Objects service'
+ 'Enterprise Search UI telemetry error: Error: Could not find Saved Objects service'
)
);
});
describe('validates', () => {
it('correctly', () => {
- const request = { body: { action: 'viewed', metric: 'setup_guide' } };
+ const request = {
+ body: { product: 'workplace_search', action: 'viewed', metric: 'setup_guide' },
+ };
mockRouter.shouldValidate(request);
});
+ it('wrong product string', () => {
+ const request = {
+ body: { product: 'workspace_space_search', action: 'viewed', metric: 'setup_guide' },
+ };
+ mockRouter.shouldThrow(request);
+ });
+
it('wrong action string', () => {
- const request = { body: { action: 'invalid', metric: 'setup_guide' } };
+ const request = {
+ body: { product: 'app_search', action: 'invalid', metric: 'setup_guide' },
+ };
mockRouter.shouldThrow(request);
});
it('wrong metric type', () => {
- const request = { body: { action: 'clicked', metric: true } };
+ const request = { body: { product: 'enterprise_search', action: 'clicked', metric: true } };
+ mockRouter.shouldThrow(request);
+ });
+
+ it('product is missing string', () => {
+ const request = { body: { action: 'viewed', metric: 'setup_guide' } };
mockRouter.shouldThrow(request);
});
it('action is missing', () => {
- const request = { body: { metric: 'engines_overview' } };
+ const request = { body: { product: 'app_search', metric: 'engines_overview' } };
mockRouter.shouldThrow(request);
});
it('metric is missing', () => {
- const request = { body: { action: 'error' } };
+ const request = { body: { product: 'app_search', action: 'error' } };
mockRouter.shouldThrow(request);
});
});
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
similarity index 55%
rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts
rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
index 4cc9b64adc092..7ed1d7b17753c 100644
--- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts
@@ -7,7 +7,15 @@
import { schema } from '@kbn/config-schema';
import { IRouteDependencies } from '../../plugin';
-import { incrementUICounter } from '../../collectors/app_search/telemetry';
+import { incrementUICounter } from '../../collectors/lib/telemetry';
+
+import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry';
+import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
+const productToTelemetryMap = {
+ app_search: AS_TELEMETRY_NAME,
+ workplace_search: WS_TELEMETRY_NAME,
+ enterprise_search: 'TODO',
+};
export function registerTelemetryRoute({
router,
@@ -16,9 +24,14 @@ export function registerTelemetryRoute({
}: IRouteDependencies) {
router.put(
{
- path: '/api/app_search/telemetry',
+ path: '/api/enterprise_search/telemetry',
validate: {
body: schema.object({
+ product: schema.oneOf([
+ schema.literal('app_search'),
+ schema.literal('workplace_search'),
+ schema.literal('enterprise_search'),
+ ]),
action: schema.oneOf([
schema.literal('viewed'),
schema.literal('clicked'),
@@ -29,21 +42,24 @@ export function registerTelemetryRoute({
},
},
async (ctx, request, response) => {
- const { action, metric } = request.body;
+ const { product, action, metric } = request.body;
try {
if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service');
return response.ok({
body: await incrementUICounter({
+ id: productToTelemetryMap[product],
savedObjects: getSavedObjectsService(),
uiAction: `ui_${action}`,
metric,
}),
});
} catch (e) {
- log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`);
- return response.internalError({ body: 'App Search UI telemetry failed' });
+ log.error(
+ `Enterprise Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`
+ );
+ return response.internalError({ body: 'Enterprise Search UI telemetry failed' });
}
}
);
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts
new file mode 100644
index 0000000000000..b1b5539795357
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts
@@ -0,0 +1,127 @@
+/*
+ * 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 { MockRouter, mockConfig, mockLogger } from '../__mocks__';
+
+import { registerWSOverviewRoute } from './overview';
+
+jest.mock('node-fetch');
+const fetch = jest.requireActual('node-fetch');
+const { Response } = fetch;
+const fetchMock = require('node-fetch') as jest.Mocked;
+
+const ORG_ROUTE = 'http://localhost:3002/ws/org';
+
+describe('engine routes', () => {
+ describe('GET /api/workplace_search/overview', () => {
+ const AUTH_HEADER = 'Basic 123';
+ const mockRequest = {
+ headers: {
+ authorization: AUTH_HEADER,
+ },
+ query: {},
+ };
+
+ const mockRouter = new MockRouter({ method: 'get', payload: 'query' });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouter.createRouter();
+
+ registerWSOverviewRoute({
+ router: mockRouter.router,
+ log: mockLogger,
+ config: mockConfig,
+ });
+ });
+
+ describe('when the underlying Workplace Search API returns a 200', () => {
+ beforeEach(() => {
+ WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
+ headers: { Authorization: AUTH_HEADER },
+ }).andReturn({ accountsCount: 1 });
+ });
+
+ it('should return 200 with a list of overview from the Workplace Search API', async () => {
+ await mockRouter.callRoute(mockRequest);
+
+ expect(mockRouter.response.ok).toHaveBeenCalledWith({
+ body: { accountsCount: 1 },
+ headers: { 'content-type': 'application/json' },
+ });
+ });
+ });
+
+ describe('when the Workplace Search URL is invalid', () => {
+ beforeEach(() => {
+ WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
+ headers: { Authorization: AUTH_HEADER },
+ }).andReturnError();
+ });
+
+ it('should return 404 with a message', async () => {
+ await mockRouter.callRoute(mockRequest);
+
+ expect(mockRouter.response.notFound).toHaveBeenCalledWith({
+ body: 'cannot-connect',
+ });
+ expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed');
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the Workplace Search API returns invalid data', () => {
+ beforeEach(() => {
+ WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, {
+ headers: { Authorization: AUTH_HEADER },
+ }).andReturnInvalidData();
+ });
+
+ it('should return 404 with a message', async () => {
+ await mockRouter.callRoute(mockRequest);
+
+ expect(mockRouter.response.notFound).toHaveBeenCalledWith({
+ body: 'cannot-connect',
+ });
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ 'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}'
+ );
+ expect(mockLogger.debug).toHaveBeenCalled();
+ });
+ });
+
+ const WorkplaceSearchAPI = {
+ shouldBeCalledWith(expectedUrl: string, expectedParams: object) {
+ return {
+ andReturn(response: object) {
+ fetchMock.mockImplementation((url: string, params: object) => {
+ expect(url).toEqual(expectedUrl);
+ expect(params).toEqual(expectedParams);
+
+ return Promise.resolve(new Response(JSON.stringify(response)));
+ });
+ },
+ andReturnInvalidData() {
+ fetchMock.mockImplementation((url: string, params: object) => {
+ expect(url).toEqual(expectedUrl);
+ expect(params).toEqual(expectedParams);
+
+ return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' })));
+ });
+ },
+ andReturnError() {
+ fetchMock.mockImplementation((url: string, params: object) => {
+ expect(url).toEqual(expectedUrl);
+ expect(params).toEqual(expectedParams);
+
+ return Promise.reject('Failed');
+ });
+ },
+ };
+ },
+ };
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts
new file mode 100644
index 0000000000000..d1e2f4f5f180d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 fetch from 'node-fetch';
+
+import { IRouteDependencies } from '../../plugin';
+
+export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) {
+ router.get(
+ {
+ path: '/api/workplace_search/overview',
+ validate: false,
+ },
+ async (context, request, response) => {
+ try {
+ const entSearchUrl = config.host as string;
+ const url = `${encodeURI(entSearchUrl)}/ws/org`;
+
+ const overviewResponse = await fetch(url, {
+ headers: { Authorization: request.headers.authorization as string },
+ });
+
+ const body = await overviewResponse.json();
+ const hasValidData = typeof body?.accountsCount === 'number';
+
+ if (hasValidData) {
+ return response.ok({
+ body,
+ headers: { 'content-type': 'application/json' },
+ });
+ } else {
+ // Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data
+ throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`);
+ }
+ } catch (e) {
+ log.error(`Cannot connect to Workplace Search: ${e.toString()}`);
+ if (e instanceof Error) log.debug(e.stack as string);
+
+ return response.notFound({ body: 'cannot-connect' });
+ }
+ }
+ );
+}
diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts
new file mode 100644
index 0000000000000..86315a9d617e4
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+/* istanbul ignore file */
+
+import { SavedObjectsType } from 'src/core/server';
+import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry';
+
+export const workplaceSearchTelemetryType: SavedObjectsType = {
+ name: WS_TELEMETRY_NAME,
+ hidden: false,
+ namespaceType: 'agnostic',
+ mappings: {
+ dynamic: false,
+ properties: {},
+ },
+};
diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js
index cd690c768a326..d90ad9378efd4 100644
--- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js
+++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js
@@ -179,7 +179,7 @@ export const MinAgeInput = (props) => {
return (
-
+
{
/>
-
+
= ({ value, onChan
{
},
_kbnMeta: {
usedBy: ['my_index_template'],
+ isManaged: false,
},
});
});
@@ -105,6 +106,7 @@ describe('Component template serialization', () => {
version: 1,
_kbnMeta: {
usedBy: [],
+ isManaged: false,
},
_meta: {
serialization: {
diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
index 672b8140f79fb..3a1c2c1ca55b2 100644
--- a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
+++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts
@@ -60,24 +60,26 @@ export function deserializeComponentTemplate(
_meta,
_kbnMeta: {
usedBy: indexTemplatesToUsedBy[name] || [],
+ isManaged: Boolean(_meta?.managed === true),
},
};
return deserializedComponentTemplate;
}
-export function deserializeComponenTemplateList(
+export function deserializeComponentTemplateList(
componentTemplateEs: ComponentTemplateFromEs,
indexTemplatesEs: TemplateFromEs[]
) {
const { name, component_template: componentTemplate } = componentTemplateEs;
- const { template } = componentTemplate;
+ const { template, _meta } = componentTemplate;
const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs);
const componentTemplateListItem: ComponentTemplateListItem = {
name,
usedBy: indexTemplatesToUsedBy[name] || [],
+ isManaged: Boolean(_meta?.managed === true),
hasSettings: hasEntries(template.settings),
hasMappings: hasEntries(template.mappings),
hasAliases: hasEntries(template.aliases),
diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts
index f39cc063ba731..9e87e87b0eee0 100644
--- a/x-pack/plugins/index_management/common/lib/index.ts
+++ b/x-pack/plugins/index_management/common/lib/index.ts
@@ -19,6 +19,6 @@ export { getTemplateParameter } from './utils';
export {
deserializeComponentTemplate,
- deserializeComponenTemplateList,
+ deserializeComponentTemplateList,
serializeComponentTemplate,
} from './component_template_serialization';
diff --git a/x-pack/plugins/index_management/common/types/component_templates.ts b/x-pack/plugins/index_management/common/types/component_templates.ts
index bc7ebdc2753dd..c8dec40d061bd 100644
--- a/x-pack/plugins/index_management/common/types/component_templates.ts
+++ b/x-pack/plugins/index_management/common/types/component_templates.ts
@@ -22,6 +22,7 @@ export interface ComponentTemplateDeserialized extends ComponentTemplateSerializ
name: string;
_kbnMeta: {
usedBy: string[];
+ isManaged: boolean;
};
}
@@ -36,4 +37,5 @@ export interface ComponentTemplateListItem {
hasMappings: boolean;
hasAliases: boolean;
hasSettings: boolean;
+ isManaged: boolean;
}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
index 75eb419d56a5c..4462a42758878 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx
@@ -185,7 +185,7 @@ describe(' ', () => {
},
aliases: ALIASES,
},
- _kbnMeta: { usedBy: [] },
+ _kbnMeta: { usedBy: [], isManaged: false },
};
expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected);
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
index 7c17dde119c42..3d496d68cc66e 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts
@@ -26,13 +26,13 @@ const COMPONENT_TEMPLATE: ComponentTemplateDeserialized = {
},
version: 1,
_meta: { description: 'component template test' },
- _kbnMeta: { usedBy: ['template_1'] },
+ _kbnMeta: { usedBy: ['template_1'], isManaged: false },
};
const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = {
name: 'comp-base',
template: {},
- _kbnMeta: { usedBy: [] },
+ _kbnMeta: { usedBy: [], isManaged: false },
};
describe(' ', () => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
index 115fdf032da8f..114cafe9defde 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx
@@ -52,7 +52,7 @@ describe(' ', () => {
template: {
settings: { number_of_shards: 1 },
},
- _kbnMeta: { usedBy: [] },
+ _kbnMeta: { usedBy: [], isManaged: false },
};
beforeEach(async () => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
index 6f09e51255f3b..bd6ac27375836 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts
@@ -42,6 +42,7 @@ describe(' ', () => {
hasAliases: true,
hasSettings: true,
usedBy: [],
+ isManaged: false,
};
const componentTemplate2: ComponentTemplateListItem = {
@@ -50,6 +51,7 @@ describe(' ', () => {
hasAliases: true,
hasSettings: true,
usedBy: ['test_index_template_1'],
+ isManaged: false,
};
const componentTemplates = [componentTemplate1, componentTemplate2];
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
index 70634a226c67b..7e460d3855cb0 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx
@@ -12,6 +12,7 @@ import { HttpSetup } from 'kibana/public';
import {
notificationServiceMock,
docLinksServiceMock,
+ applicationServiceMock,
} from '../../../../../../../../../../src/core/public/mocks';
import { ComponentTemplatesProvider } from '../../../component_templates_context';
@@ -28,6 +29,7 @@ const appDependencies = {
docLinks: docLinksServiceMock.createStartContract(),
toasts: notificationServiceMock.createSetupContract().toasts,
setBreadcrumbs: () => {},
+ getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp,
};
export const setupEnvironment = () => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
index f94c5c38f23dd..60f1fff3cc9de 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx
@@ -6,6 +6,7 @@
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
+
import {
EuiFlyout,
EuiFlyoutHeader,
@@ -17,6 +18,7 @@ import {
EuiButtonEmpty,
EuiSpacer,
EuiCallOut,
+ EuiBadge,
} from '@elastic/eui';
import { SectionLoading, TabSettings, TabAliases, TabMappings } from '../shared_imports';
@@ -29,14 +31,15 @@ import { attemptToDecodeURI } from '../lib';
interface Props {
componentTemplateName: string;
onClose: () => void;
- showFooter?: boolean;
actions?: ManageAction[];
+ showSummaryCallToAction?: boolean;
}
export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
componentTemplateName,
onClose,
actions,
+ showSummaryCallToAction,
}) => {
const { api } = useComponentTemplatesContext();
@@ -81,7 +84,12 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
} = componentTemplateDetails;
const tabToComponentMap: Record = {
- summary: ,
+ summary: (
+
+ ),
settings: ,
mappings: ,
aliases: ,
@@ -109,11 +117,27 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({
maxWidth={500}
>
-
-
- {decodedComponentTemplateName}
-
-
+
+
+
+
+ {decodedComponentTemplateName}
+
+
+
+
+ {componentTemplateDetails?._kbnMeta.isManaged ? (
+
+ {' '}
+
+
+
+
+ ) : null}
+
{content}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
index 80f28f23c9f91..8d054b97cb4f6 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx
@@ -6,6 +6,7 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
+
import {
EuiDescriptionList,
EuiDescriptionListTitle,
@@ -14,15 +15,23 @@ import {
EuiTitle,
EuiCallOut,
EuiSpacer,
+ EuiLink,
} from '@elastic/eui';
import { ComponentTemplateDeserialized } from '../shared_imports';
+import { useComponentTemplatesContext } from '../component_templates_context';
interface Props {
componentTemplateDetails: ComponentTemplateDeserialized;
+ showCallToAction?: boolean;
}
-export const TabSummary: React.FunctionComponent = ({ componentTemplateDetails }) => {
+export const TabSummary: React.FunctionComponent = ({
+ componentTemplateDetails,
+ showCallToAction,
+}) => {
+ const { getUrlForApp } = useComponentTemplatesContext();
+
const { version, _meta, _kbnMeta } = componentTemplateDetails;
const { usedBy } = _kbnMeta;
@@ -43,7 +52,42 @@ export const TabSummary: React.FunctionComponent = ({ componentTemplateDe
iconType="pin"
data-test-subj="notInUseCallout"
size="s"
- />
+ >
+ {showCallToAction && (
+
+
+
+
+ ),
+ editLink: (
+
+
+
+ ),
+ }}
+ />
+
+ )}
+
>
)}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx
index d356eabc7997d..efc8b649ef872 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx
@@ -9,6 +9,7 @@ import { RouteComponentProps } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ScopedHistory } from 'kibana/public';
+import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui';
import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants';
@@ -29,7 +30,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
componentTemplateName,
history,
}) => {
- const { api, trackMetric } = useComponentTemplatesContext();
+ const { api, trackMetric, documentation } = useComponentTemplatesContext();
const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates();
@@ -65,20 +66,40 @@ export const ComponentTemplateList: React.FunctionComponent = ({
);
} else if (data?.length) {
content = (
-
+ <>
+
+
+ {i18n.translate('xpack.idxMgmt.componentTemplates.list.learnMoreLinkText', {
+ defaultMessage: 'Learn more.',
+ })}
+
+ ),
+ }}
+ />
+
+
+
+
+
+ >
);
} else if (data && data.length === 0) {
content = ;
@@ -111,6 +132,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({
= ({ history }) => {
{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptDocumentionLink', {
- defaultMessage: 'Learn more',
+ defaultMessage: 'Learn more.',
})}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
index 089c2f889e726..fc86609f1217d 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx
@@ -13,11 +13,11 @@ import {
EuiTextColor,
EuiIcon,
EuiLink,
+ EuiBadge,
} from '@elastic/eui';
import { ScopedHistory } from 'kibana/public';
-import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public';
-import { ComponentTemplateListItem } from '../shared_imports';
+import { ComponentTemplateListItem, reactRouterNavigate } from '../shared_imports';
import { UIM_COMPONENT_TEMPLATE_DETAILS } from '../constants';
import { useComponentTemplatesContext } from '../component_templates_context';
@@ -105,6 +105,13 @@ export const ComponentTable: FunctionComponent = ({
incremental: true,
},
filters: [
+ {
+ type: 'is',
+ field: 'isManaged',
+ name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isManagedFilterLabel', {
+ defaultMessage: 'Managed',
+ }),
+ },
{
type: 'field_value_toggle_group',
field: 'usedBy.length',
@@ -144,26 +151,38 @@ export const ComponentTable: FunctionComponent = ({
defaultMessage: 'Name',
}),
sortable: true,
- render: (name: string) => (
- /* eslint-disable-next-line @elastic/eui/href-or-on-click */
- trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS)
+ width: '20%',
+ render: (name: string, item: ComponentTemplateListItem) => (
+ <>
+ trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS)
+ )}
+ data-test-subj="templateDetailsLink"
+ >
+ {name}
+
+ {item.isManaged && (
+ <>
+
+
+ {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.managedBadgeLabel', {
+ defaultMessage: 'Managed',
+ })}
+
+ >
)}
- data-test-subj="templateDetailsLink"
- >
- {name}
-
+ >
),
},
{
field: 'usedBy',
name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isInUseColumnTitle', {
- defaultMessage: 'Index templates',
+ defaultMessage: 'Usage count',
}),
sortable: true,
render: (usedBy: string[]) => {
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
index 6e35fbad31d4e..134b8b5eda93d 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx
@@ -74,14 +74,11 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = {
export const ComponentTemplateForm = ({
defaultValue = {
name: '',
- template: {
- settings: {},
- mappings: {},
- aliases: {},
- },
+ template: {},
_meta: {},
_kbnMeta: {
usedBy: [],
+ isManaged: false,
},
},
isEditing,
@@ -137,23 +134,49 @@ export const ComponentTemplateForm = ({
>
) : null;
- const buildComponentTemplateObject = (initialTemplate: ComponentTemplateDeserialized) => (
- wizardData: WizardContent
- ): ComponentTemplateDeserialized => {
- const componentTemplate = {
- ...initialTemplate,
- name: wizardData.logistics.name,
- version: wizardData.logistics.version,
- _meta: wizardData.logistics._meta,
- template: {
- settings: wizardData.settings,
- mappings: wizardData.mappings,
- aliases: wizardData.aliases,
- },
- };
- return componentTemplate;
+ /**
+ * If no mappings, settings or aliases are defined, it is better to not send an empty
+ * object for those values.
+ * @param componentTemplate The component template object to clean up
+ */
+ const cleanupComponentTemplateObject = (componentTemplate: ComponentTemplateDeserialized) => {
+ const outputTemplate = { ...componentTemplate };
+
+ if (outputTemplate.template.settings === undefined) {
+ delete outputTemplate.template.settings;
+ }
+
+ if (outputTemplate.template.mappings === undefined) {
+ delete outputTemplate.template.mappings;
+ }
+
+ if (outputTemplate.template.aliases === undefined) {
+ delete outputTemplate.template.aliases;
+ }
+
+ return outputTemplate;
};
+ const buildComponentTemplateObject = useCallback(
+ (initialTemplate: ComponentTemplateDeserialized) => (
+ wizardData: WizardContent
+ ): ComponentTemplateDeserialized => {
+ const outputComponentTemplate = {
+ ...initialTemplate,
+ name: wizardData.logistics.name,
+ version: wizardData.logistics.version,
+ _meta: wizardData.logistics._meta,
+ template: {
+ settings: wizardData.settings,
+ mappings: wizardData.mappings,
+ aliases: wizardData.aliases,
+ },
+ };
+ return cleanupComponentTemplateObject(outputComponentTemplate);
+ },
+ []
+ );
+
const onSaveComponentTemplate = useCallback(
async (wizardData: WizardContent) => {
const componentTemplate = buildComponentTemplateObject(defaultValue)(wizardData);
@@ -161,13 +184,13 @@ export const ComponentTemplateForm = ({
// This will strip an empty string if "version" is not set, as well as an empty "_meta" object
onSave(
stripEmptyFields(componentTemplate, {
- types: ['string', 'object'],
+ types: ['string'],
}) as ComponentTemplateDeserialized
);
clearSaveError();
},
- [defaultValue, onSave, clearSaveError]
+ [buildComponentTemplateObject, defaultValue, onSave, clearSaveError]
);
return (
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
index 18988fa125a06..c48a23226a371 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx
@@ -117,7 +117,7 @@ export const StepLogistics: React.FunctionComponent = React.memo(
description={
}
>
@@ -141,7 +141,7 @@ export const StepLogistics: React.FunctionComponent = React.memo(
description={
}
>
@@ -165,7 +165,7 @@ export const StepLogistics: React.FunctionComponent = React.memo(
<>
= React.memo(
{i18n.translate(
'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaDocumentionLink',
{
- defaultMessage: 'Learn more',
+ defaultMessage: 'Learn more.',
}
)}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx
index ce85854dc79ab..67246f2e10c3b 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx
@@ -52,16 +52,12 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen
const serializedComponentTemplate = serializeComponentTemplate(
stripEmptyFields(componentTemplate, {
- types: ['string', 'object'],
+ types: ['string'],
}) as ComponentTemplateDeserialized
);
const {
- template: {
- mappings: serializedMappings,
- settings: serializedSettings,
- aliases: serializedAliases,
- },
+ template: serializedTemplate,
_meta: serializedMeta,
version: serializedVersion,
} = serializedComponentTemplate;
@@ -94,7 +90,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen
/>
- {getDescriptionText(serializedSettings)}
+ {getDescriptionText(serializedTemplate?.settings)}
{/* Mappings */}
@@ -105,7 +101,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen
/>
- {getDescriptionText(serializedMappings)}
+ {getDescriptionText(serializedTemplate?.mappings)}
{/* Aliases */}
@@ -116,7 +112,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen
/>
- {getDescriptionText(serializedAliases)}
+ {getDescriptionText(serializedTemplate?.aliases)}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx
index ce9e28d0feefe..7be0618481a69 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx
@@ -5,7 +5,7 @@
*/
import React, { createContext, useContext } from 'react';
-import { HttpSetup, DocLinksStart, NotificationsSetup } from 'src/core/public';
+import { HttpSetup, DocLinksStart, NotificationsSetup, CoreStart } from 'src/core/public';
import { ManagementAppMountParams } from 'src/plugins/management/public';
import { getApi, getUseRequest, getSendRequest, getDocumentation, getBreadcrumbs } from './lib';
@@ -19,6 +19,7 @@ interface Props {
docLinks: DocLinksStart;
toasts: NotificationsSetup['toasts'];
setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs'];
+ getUrlForApp: CoreStart['application']['getUrlForApp'];
}
interface Context {
@@ -29,6 +30,7 @@ interface Context {
breadcrumbs: ReturnType;
trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void;
toasts: NotificationsSetup['toasts'];
+ getUrlForApp: CoreStart['application']['getUrlForApp'];
}
export const ComponentTemplatesProvider = ({
@@ -38,7 +40,15 @@ export const ComponentTemplatesProvider = ({
value: Props;
children: React.ReactNode;
}) => {
- const { httpClient, apiBasePath, trackMetric, docLinks, toasts, setBreadcrumbs } = value;
+ const {
+ httpClient,
+ apiBasePath,
+ trackMetric,
+ docLinks,
+ toasts,
+ setBreadcrumbs,
+ getUrlForApp,
+ } = value;
const useRequest = getUseRequest(httpClient);
const sendRequest = getSendRequest(httpClient);
@@ -49,7 +59,16 @@ export const ComponentTemplatesProvider = ({
return (
{children}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts
index 80e222f4f7706..278fadcd90c8b 100644
--- a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts
+++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts
@@ -62,3 +62,5 @@ export {
} from '../../../../common';
export { serializeComponentTemplate } from '../../../../common/lib';
+
+export { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public';
diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx
index 7b053a15b26d0..ebc29ac86a17f 100644
--- a/x-pack/plugins/index_management/public/application/index.tsx
+++ b/x-pack/plugins/index_management/public/application/index.tsx
@@ -25,7 +25,7 @@ export const renderApp = (
return () => undefined;
}
- const { i18n, docLinks, notifications } = core;
+ const { i18n, docLinks, notifications, application } = core;
const { Context: I18nContext } = i18n;
const { services, history, setBreadcrumbs } = dependencies;
@@ -36,6 +36,7 @@ export const renderApp = (
docLinks,
toasts: notifications.toasts,
setBreadcrumbs,
+ getUrlForApp: application.getUrlForApp,
};
render(
diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts
index f6f8e7d63d370..16b028887f63c 100644
--- a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts
+++ b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts
@@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema';
import {
deserializeComponentTemplate,
- deserializeComponenTemplateList,
+ deserializeComponentTemplateList,
} from '../../../../common/lib';
import { ComponentTemplateFromEs } from '../../../../common';
import { RouteDependencies } from '../../../types';
@@ -36,7 +36,7 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou
);
const body = componentTemplates.map((componentTemplate) => {
- const deserializedComponentTemplateListItem = deserializeComponenTemplateList(
+ const deserializedComponentTemplateListItem = deserializeComponentTemplateList(
componentTemplate,
indexTemplates
);
diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts
index a1fc258127229..cfcb428f00501 100644
--- a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts
+++ b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts
@@ -16,5 +16,6 @@ export const componentTemplateSchema = schema.object({
_meta: schema.maybe(schema.object({}, { unknowns: 'allow' })),
_kbnMeta: schema.object({
usedBy: schema.arrayOf(schema.string()),
+ isManaged: schema.boolean(),
}),
});
diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts
index 30b6be435837b..cbd89db97236f 100644
--- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts
+++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts
@@ -8,4 +8,5 @@ export * from './log_entry_categories';
export * from './log_entry_category_datasets';
export * from './log_entry_category_examples';
export * from './log_entry_rate';
-export * from './log_entry_rate_examples';
+export * from './log_entry_examples';
+export * from './log_entry_anomalies';
diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts
new file mode 100644
index 0000000000000..639ac63f9b14d
--- /dev/null
+++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts
@@ -0,0 +1,137 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as rt from 'io-ts';
+
+import { timeRangeRT, routeTimingMetadataRT } from '../../shared';
+
+export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH =
+ '/api/infra/log_analysis/results/log_entry_anomalies';
+
+// [Sort field value, tiebreaker value]
+const paginationCursorRT = rt.tuple([
+ rt.union([rt.string, rt.number]),
+ rt.union([rt.string, rt.number]),
+]);
+
+export type PaginationCursor = rt.TypeOf;
+
+export const anomalyTypeRT = rt.keyof({
+ logRate: null,
+ logCategory: null,
+});
+
+export type AnomalyType = rt.TypeOf;
+
+const logEntryAnomalyCommonFieldsRT = rt.type({
+ id: rt.string,
+ anomalyScore: rt.number,
+ dataset: rt.string,
+ typical: rt.number,
+ actual: rt.number,
+ type: anomalyTypeRT,
+ duration: rt.number,
+ startTime: rt.number,
+ jobId: rt.string,
+});
+const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT;
+const logEntrylogCategoryAnomalyRT = rt.partial({
+ categoryId: rt.string,
+});
+const logEntryAnomalyRT = rt.intersection([
+ logEntryAnomalyCommonFieldsRT,
+ logEntrylogRateAnomalyRT,
+ logEntrylogCategoryAnomalyRT,
+]);
+
+export type LogEntryAnomaly = rt.TypeOf;
+
+export const getLogEntryAnomaliesSuccessReponsePayloadRT = rt.intersection([
+ rt.type({
+ data: rt.intersection([
+ rt.type({
+ anomalies: rt.array(logEntryAnomalyRT),
+ // Signifies there are more entries backwards or forwards. If this was a request
+ // for a previous page, there are more previous pages, if this was a request for a next page,
+ // there are more next pages.
+ hasMoreEntries: rt.boolean,
+ }),
+ rt.partial({
+ paginationCursors: rt.type({
+ // The cursor to use to fetch the previous page
+ previousPageCursor: paginationCursorRT,
+ // The cursor to use to fetch the next page
+ nextPageCursor: paginationCursorRT,
+ }),
+ }),
+ ]),
+ }),
+ rt.partial({
+ timing: routeTimingMetadataRT,
+ }),
+]);
+
+export type GetLogEntryAnomaliesSuccessResponsePayload = rt.TypeOf<
+ typeof getLogEntryAnomaliesSuccessReponsePayloadRT
+>;
+
+const sortOptionsRT = rt.keyof({
+ anomalyScore: null,
+ dataset: null,
+ startTime: null,
+});
+
+const sortDirectionsRT = rt.keyof({
+ asc: null,
+ desc: null,
+});
+
+const paginationPreviousPageCursorRT = rt.type({
+ searchBefore: paginationCursorRT,
+});
+
+const paginationNextPageCursorRT = rt.type({
+ searchAfter: paginationCursorRT,
+});
+
+const paginationRT = rt.intersection([
+ rt.type({
+ pageSize: rt.number,
+ }),
+ rt.partial({
+ cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]),
+ }),
+]);
+
+export type Pagination = rt.TypeOf;
+
+const sortRT = rt.type({
+ field: sortOptionsRT,
+ direction: sortDirectionsRT,
+});
+
+export type Sort = rt.TypeOf;
+
+export const getLogEntryAnomaliesRequestPayloadRT = rt.type({
+ data: rt.intersection([
+ rt.type({
+ // the ID of the source configuration
+ sourceId: rt.string,
+ // the time range to fetch the log entry anomalies from
+ timeRange: timeRangeRT,
+ }),
+ rt.partial({
+ // Pagination properties
+ pagination: paginationRT,
+ // Sort properties
+ sort: sortRT,
+ }),
+ ]),
+});
+
+export type GetLogEntryAnomaliesRequestPayload = rt.TypeOf<
+ typeof getLogEntryAnomaliesRequestPayloadRT
+>;
diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts
new file mode 100644
index 0000000000000..1eed29cd37560
--- /dev/null
+++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 * as rt from 'io-ts';
+
+import {
+ badRequestErrorRT,
+ forbiddenErrorRT,
+ timeRangeRT,
+ routeTimingMetadataRT,
+} from '../../shared';
+
+export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH =
+ '/api/infra/log_analysis/results/log_entry_examples';
+
+/**
+ * request
+ */
+
+export const getLogEntryExamplesRequestPayloadRT = rt.type({
+ data: rt.intersection([
+ rt.type({
+ // the dataset to fetch the log rate examples from
+ dataset: rt.string,
+ // the number of examples to fetch
+ exampleCount: rt.number,
+ // the id of the source configuration
+ sourceId: rt.string,
+ // the time range to fetch the log rate examples from
+ timeRange: timeRangeRT,
+ }),
+ rt.partial({
+ categoryId: rt.string,
+ }),
+ ]),
+});
+
+export type GetLogEntryExamplesRequestPayload = rt.TypeOf<
+ typeof getLogEntryExamplesRequestPayloadRT
+>;
+
+/**
+ * response
+ */
+
+const logEntryExampleRT = rt.type({
+ id: rt.string,
+ dataset: rt.string,
+ message: rt.string,
+ timestamp: rt.number,
+ tiebreaker: rt.number,
+});
+
+export type LogEntryExample = rt.TypeOf;
+
+export const getLogEntryExamplesSuccessReponsePayloadRT = rt.intersection([
+ rt.type({
+ data: rt.type({
+ examples: rt.array(logEntryExampleRT),
+ }),
+ }),
+ rt.partial({
+ timing: routeTimingMetadataRT,
+ }),
+]);
+
+export type GetLogEntryExamplesSuccessReponsePayload = rt.TypeOf<
+ typeof getLogEntryExamplesSuccessReponsePayloadRT
+>;
+
+export const getLogEntryExamplesResponsePayloadRT = rt.union([
+ getLogEntryExamplesSuccessReponsePayloadRT,
+ badRequestErrorRT,
+ forbiddenErrorRT,
+]);
+
+export type GetLogEntryExamplesResponsePayload = rt.TypeOf<
+ typeof getLogEntryExamplesResponsePayloadRT
+>;
diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts
deleted file mode 100644
index 700f87ec3beb1..0000000000000
--- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts
+++ /dev/null
@@ -1,77 +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 * as rt from 'io-ts';
-
-import {
- badRequestErrorRT,
- forbiddenErrorRT,
- timeRangeRT,
- routeTimingMetadataRT,
-} from '../../shared';
-
-export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH =
- '/api/infra/log_analysis/results/log_entry_rate_examples';
-
-/**
- * request
- */
-
-export const getLogEntryRateExamplesRequestPayloadRT = rt.type({
- data: rt.type({
- // the dataset to fetch the log rate examples from
- dataset: rt.string,
- // the number of examples to fetch
- exampleCount: rt.number,
- // the id of the source configuration
- sourceId: rt.string,
- // the time range to fetch the log rate examples from
- timeRange: timeRangeRT,
- }),
-});
-
-export type GetLogEntryRateExamplesRequestPayload = rt.TypeOf<
- typeof getLogEntryRateExamplesRequestPayloadRT
->;
-
-/**
- * response
- */
-
-const logEntryRateExampleRT = rt.type({
- id: rt.string,
- dataset: rt.string,
- message: rt.string,
- timestamp: rt.number,
- tiebreaker: rt.number,
-});
-
-export type LogEntryRateExample = rt.TypeOf;
-
-export const getLogEntryRateExamplesSuccessReponsePayloadRT = rt.intersection([
- rt.type({
- data: rt.type({
- examples: rt.array(logEntryRateExampleRT),
- }),
- }),
- rt.partial({
- timing: routeTimingMetadataRT,
- }),
-]);
-
-export type GetLogEntryRateExamplesSuccessReponsePayload = rt.TypeOf<
- typeof getLogEntryRateExamplesSuccessReponsePayloadRT
->;
-
-export const getLogEntryRateExamplesResponsePayloadRT = rt.union([
- getLogEntryRateExamplesSuccessReponsePayloadRT,
- badRequestErrorRT,
- forbiddenErrorRT,
-]);
-
-export type GetLogEntryRateExamplesResponsePayload = rt.TypeOf<
- typeof getLogEntryRateExamplesResponsePayloadRT
->;
diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts
index b8fba7a14e243..680a2a0fef114 100644
--- a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts
+++ b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts
@@ -14,18 +14,10 @@ export type JobStatus =
| 'finished'
| 'failed';
-export type SetupStatusRequiredReason =
- | 'missing' // jobs are missing
- | 'reconfiguration' // the configurations don't match the source configurations
- | 'update'; // the definitions don't match the module definitions
-
export type SetupStatus =
| { type: 'initializing' } // acquiring job statuses to determine setup status
| { type: 'unknown' } // job status could not be acquired (failed request etc)
- | {
- type: 'required';
- reason: SetupStatusRequiredReason;
- } // setup required
+ | { type: 'required' } // setup required
| { type: 'pending' } // In the process of setting up the module for the first time or retrying, waiting for response
| { type: 'succeeded' } // setup succeeded, notifying user
| {
diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts
index 19c92cb381104..f4497dbba5056 100644
--- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts
+++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts
@@ -41,6 +41,10 @@ export const formatAnomalyScore = (score: number) => {
return Math.round(score);
};
+export const formatOneDecimalPlace = (number: number) => {
+ return Math.round(number * 10) / 10;
+};
+
export const getFriendlyNameForPartitionId = (partitionId: string) => {
return partitionId !== '' ? partitionId : 'unknown';
};
diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap
index 4680414493a2c..d71e1feb575e4 100644
--- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap
+++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap
@@ -2,7 +2,7 @@
exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = `
Object {
- "appLink": "/app/metrics",
+ "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)",
"series": Object {
"inboundTraffic": Object {
"coordinates": Array [
@@ -203,6 +203,5 @@ Object {
"value": 3,
},
},
- "title": "Metrics",
}
`;
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts
index e954cf21229ee..afad55dd22d43 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts
@@ -5,4 +5,5 @@
*/
export * from './log_analysis_job_problem_indicator';
+export * from './notices_section';
export * from './recreate_job_button';
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx
index 13b7d1927f676..a8a7ec4f5f44f 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx
@@ -11,19 +11,24 @@ import React from 'react';
import { RecreateJobCallout } from './recreate_job_callout';
export const JobConfigurationOutdatedCallout: React.FC<{
+ moduleName: string;
onRecreateMlJob: () => void;
-}> = ({ onRecreateMlJob }) => (
-
+}> = ({ moduleName, onRecreateMlJob }) => (
+
);
-
-const jobConfigurationOutdatedTitle = i18n.translate(
- 'xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutTitle',
- {
- defaultMessage: 'ML job configuration outdated',
- }
-);
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx
index 5072fb09cdceb..7d876b91fc6b5 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx
@@ -11,19 +11,24 @@ import React from 'react';
import { RecreateJobCallout } from './recreate_job_callout';
export const JobDefinitionOutdatedCallout: React.FC<{
+ moduleName: string;
onRecreateMlJob: () => void;
-}> = ({ onRecreateMlJob }) => (
-
+}> = ({ moduleName, onRecreateMlJob }) => (
+
);
-
-const jobDefinitionOutdatedTitle = i18n.translate(
- 'xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle',
- {
- defaultMessage: 'ML job definition outdated',
- }
-);
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx
index e7e89bb365e4f..9cdf4a667d140 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx
@@ -16,6 +16,7 @@ export const LogAnalysisJobProblemIndicator: React.FC<{
hasOutdatedJobDefinitions: boolean;
hasStoppedJobs: boolean;
isFirstUse: boolean;
+ moduleName: string;
onRecreateMlJobForReconfiguration: () => void;
onRecreateMlJobForUpdate: () => void;
}> = ({
@@ -23,16 +24,23 @@ export const LogAnalysisJobProblemIndicator: React.FC<{
hasOutdatedJobDefinitions,
hasStoppedJobs,
isFirstUse,
+ moduleName,
onRecreateMlJobForReconfiguration,
onRecreateMlJobForUpdate,
}) => {
return (
<>
{hasOutdatedJobDefinitions ? (
-
+
) : null}
{hasOutdatedJobConfigurations ? (
-
+
) : null}
{hasStoppedJobs ? : null}
{isFirstUse ? : null}
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx
similarity index 83%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx
rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx
index 8f44b5b54c48f..aa72281b9fbdb 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx
@@ -5,8 +5,8 @@
*/
import React from 'react';
-import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status';
-import { QualityWarning } from './quality_warnings';
+import { QualityWarning } from '../../../containers/logs/log_analysis/log_analysis_module_types';
+import { LogAnalysisJobProblemIndicator } from './log_analysis_job_problem_indicator';
import { CategoryQualityWarnings } from './quality_warning_notices';
export const CategoryJobNoticesSection: React.FC<{
@@ -14,6 +14,7 @@ export const CategoryJobNoticesSection: React.FC<{
hasOutdatedJobDefinitions: boolean;
hasStoppedJobs: boolean;
isFirstUse: boolean;
+ moduleName: string;
onRecreateMlJobForReconfiguration: () => void;
onRecreateMlJobForUpdate: () => void;
qualityWarnings: QualityWarning[];
@@ -22,6 +23,7 @@ export const CategoryJobNoticesSection: React.FC<{
hasOutdatedJobDefinitions,
hasStoppedJobs,
isFirstUse,
+ moduleName,
onRecreateMlJobForReconfiguration,
onRecreateMlJobForUpdate,
qualityWarnings,
@@ -32,6 +34,7 @@ export const CategoryJobNoticesSection: React.FC<{
hasOutdatedJobDefinitions={hasOutdatedJobDefinitions}
hasStoppedJobs={hasStoppedJobs}
isFirstUse={isFirstUse}
+ moduleName={moduleName}
onRecreateMlJobForReconfiguration={onRecreateMlJobForReconfiguration}
onRecreateMlJobForUpdate={onRecreateMlJobForUpdate}
/>
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx
similarity index 96%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx
rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx
index 73b6b88db873a..0d93ead5a82c6 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx
@@ -8,7 +8,10 @@ import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
-import { CategoryQualityWarningReason, QualityWarning } from './quality_warnings';
+import type {
+ CategoryQualityWarningReason,
+ QualityWarning,
+} from '../../../containers/logs/log_analysis/log_analysis_module_types';
export const CategoryQualityWarnings: React.FC<{ qualityWarnings: QualityWarning[] }> = ({
qualityWarnings,
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx
index c9b14a1ffe47a..d4c3c727bd34e 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx
@@ -84,7 +84,7 @@ export const InitialConfigurationStep: React.FunctionComponent> = (props) => (
+
+
+
+);
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx
index 3fa72fe8a07e7..a9c94b5983803 100644
--- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx
@@ -101,11 +101,10 @@ export const ProcessStep: React.FunctionComponent = ({
/>
>
- ) : setupStatus.type === 'required' &&
- (setupStatus.reason === 'update' || setupStatus.reason === 'reconfiguration') ? (
-
- ) : (
+ ) : setupStatus.type === 'required' ? (
+ ) : (
+
)}
);
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx
new file mode 100644
index 0000000000000..881996073871e
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export * from './setup_flyout';
+export * from './setup_flyout_state';
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx
new file mode 100644
index 0000000000000..2bc5b08a1016a
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiSpacer, EuiSteps, EuiText, EuiTitle } from '@elastic/eui';
+import React, { useCallback, useMemo } from 'react';
+import { useLogEntryCategoriesSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_categories';
+import { createInitialConfigurationStep } from '../initial_configuration_step';
+import { createProcessStep } from '../process_step';
+
+export const LogEntryCategoriesSetupView: React.FC<{
+ onClose: () => void;
+}> = ({ onClose }) => {
+ const {
+ cleanUpAndSetUp,
+ endTime,
+ isValidating,
+ lastSetupErrorMessages,
+ moduleDescriptor,
+ setEndTime,
+ setStartTime,
+ setValidatedIndices,
+ setUp,
+ setupStatus,
+ startTime,
+ validatedIndices,
+ validationErrors,
+ viewResults,
+ } = useLogEntryCategoriesSetup();
+
+ const viewResultsAndClose = useCallback(() => {
+ viewResults();
+ onClose();
+ }, [viewResults, onClose]);
+
+ const steps = useMemo(
+ () => [
+ createInitialConfigurationStep({
+ setStartTime,
+ setEndTime,
+ startTime,
+ endTime,
+ isValidating,
+ validatedIndices,
+ setupStatus,
+ setValidatedIndices,
+ validationErrors,
+ }),
+ createProcessStep({
+ cleanUpAndSetUp,
+ errorMessages: lastSetupErrorMessages,
+ isConfigurationValid: validationErrors.length <= 0 && !isValidating,
+ setUp,
+ setupStatus,
+ viewResults: viewResultsAndClose,
+ }),
+ ],
+ [
+ cleanUpAndSetUp,
+ endTime,
+ isValidating,
+ lastSetupErrorMessages,
+ setEndTime,
+ setStartTime,
+ setUp,
+ setValidatedIndices,
+ setupStatus,
+ startTime,
+ validatedIndices,
+ validationErrors,
+ viewResultsAndClose,
+ ]
+ );
+
+ return (
+ <>
+
+ {moduleDescriptor.moduleName}
+
+ {moduleDescriptor.moduleDescription}
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx
similarity index 50%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx
rename to x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx
index 0e9e34432f28b..0b7037e60de0b 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx
@@ -5,37 +5,20 @@
*/
import React, { useMemo, useCallback } from 'react';
-import { FormattedMessage } from '@kbn/i18n/react';
-import {
- EuiFlyout,
- EuiFlyoutHeader,
- EuiFlyoutBody,
- EuiTitle,
- EuiText,
- EuiSpacer,
- EuiSteps,
-} from '@elastic/eui';
+import { EuiTitle, EuiText, EuiSpacer, EuiSteps } from '@elastic/eui';
+import { createInitialConfigurationStep } from '../initial_configuration_step';
+import { createProcessStep } from '../process_step';
+import { useLogEntryRateSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_rate';
-import {
- createInitialConfigurationStep,
- createProcessStep,
-} from '../../../components/logging/log_analysis_setup';
-import { useLogEntryRateSetup } from './use_log_entry_rate_setup';
-
-interface LogEntryRateSetupFlyoutProps {
- isOpen: boolean;
+export const LogEntryRateSetupView: React.FC<{
onClose: () => void;
-}
-
-export const LogEntryRateSetupFlyout: React.FC = ({
- isOpen,
- onClose,
-}) => {
+}> = ({ onClose }) => {
const {
cleanUpAndSetUp,
endTime,
isValidating,
lastSetupErrorMessages,
+ moduleDescriptor,
setEndTime,
setStartTime,
setValidatedIndices,
@@ -91,39 +74,14 @@ export const LogEntryRateSetupFlyout: React.FC = (
]
);
- if (!isOpen) {
- return null;
- }
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ <>
+
+ {moduleDescriptor.moduleName}
+
+ {moduleDescriptor.moduleDescription}
+
+
+ >
);
};
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx
new file mode 100644
index 0000000000000..8239ab4a730ff
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import React, { useCallback } from 'react';
+import {
+ logEntryCategoriesModule,
+ useLogEntryCategoriesModuleContext,
+} from '../../../../containers/logs/log_analysis/modules/log_entry_categories';
+import {
+ logEntryRateModule,
+ useLogEntryRateModuleContext,
+} from '../../../../containers/logs/log_analysis/modules/log_entry_rate';
+import { LogAnalysisModuleListCard } from './module_list_card';
+import type { ModuleId } from './setup_flyout_state';
+
+export const LogAnalysisModuleList: React.FC<{
+ onViewModuleSetup: (module: ModuleId) => void;
+}> = ({ onViewModuleSetup }) => {
+ const { setupStatus: logEntryRateSetupStatus } = useLogEntryRateModuleContext();
+ const { setupStatus: logEntryCategoriesSetupStatus } = useLogEntryCategoriesModuleContext();
+
+ const viewLogEntryRateSetupFlyout = useCallback(() => {
+ onViewModuleSetup('logs_ui_analysis');
+ }, [onViewModuleSetup]);
+ const viewLogEntryCategoriesSetupFlyout = useCallback(() => {
+ onViewModuleSetup('logs_ui_categories');
+ }, [onViewModuleSetup]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx
new file mode 100644
index 0000000000000..17806dbe93797
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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 { EuiCard, EuiIcon } from '@elastic/eui';
+import React from 'react';
+import { EuiButton } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { RecreateJobButton } from '../../log_analysis_job_status';
+import { SetupStatus } from '../../../../../common/log_analysis';
+
+export const LogAnalysisModuleListCard: React.FC<{
+ moduleDescription: string;
+ moduleName: string;
+ moduleStatus: SetupStatus;
+ onViewSetup: () => void;
+}> = ({ moduleDescription, moduleName, moduleStatus, onViewSetup }) => {
+ const icon =
+ moduleStatus.type === 'required' ? (
+
+ ) : (
+
+ );
+ const footerContent =
+ moduleStatus.type === 'required' ? (
+
+
+
+ ) : (
+
+ );
+
+ return (
+ {footerContent} }
+ icon={icon}
+ title={moduleName}
+ />
+ );
+};
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx
new file mode 100644
index 0000000000000..8e00254431438
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 {
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiTitle,
+} from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import React from 'react';
+import { LogEntryRateSetupView } from './log_entry_rate_setup_view';
+import { LogEntryCategoriesSetupView } from './log_entry_categories_setup_view';
+import { LogAnalysisModuleList } from './module_list';
+import { useLogAnalysisSetupFlyoutStateContext } from './setup_flyout_state';
+
+const FLYOUT_HEADING_ID = 'logAnalysisSetupFlyoutHeading';
+
+export const LogAnalysisSetupFlyout: React.FC = () => {
+ const {
+ closeFlyout,
+ flyoutView,
+ showModuleList,
+ showModuleSetup,
+ } = useLogAnalysisSetupFlyoutStateContext();
+
+ if (flyoutView.view === 'hidden') {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {flyoutView.view === 'moduleList' ? (
+
+ ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_analysis' ? (
+
+
+
+ ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_categories' ? (
+
+
+
+ ) : null}
+
+
+ );
+};
+
+const LogAnalysisSetupFlyoutSubPage: React.FC<{
+ onViewModuleList: () => void;
+}> = ({ children, onViewModuleList }) => (
+
+
+
+
+
+
+ {children}
+
+);
diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts
new file mode 100644
index 0000000000000..7a64584df4303
--- /dev/null
+++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import createContainer from 'constate';
+import { useState, useCallback } from 'react';
+
+export type ModuleId = 'logs_ui_analysis' | 'logs_ui_categories';
+
+type FlyoutView =
+ | { view: 'hidden' }
+ | { view: 'moduleList' }
+ | { view: 'moduleSetup'; module: ModuleId };
+
+export const useLogAnalysisSetupFlyoutState = ({
+ initialFlyoutView = { view: 'hidden' },
+}: {
+ initialFlyoutView?: FlyoutView;
+}) => {
+ const [flyoutView, setFlyoutView] = useState
(initialFlyoutView);
+
+ const closeFlyout = useCallback(() => setFlyoutView({ view: 'hidden' }), []);
+ const showModuleList = useCallback(() => setFlyoutView({ view: 'moduleList' }), []);
+ const showModuleSetup = useCallback(
+ (module: ModuleId) => {
+ setFlyoutView({ view: 'moduleSetup', module });
+ },
+ [setFlyoutView]
+ );
+
+ return {
+ closeFlyout,
+ flyoutView,
+ setFlyoutView,
+ showModuleList,
+ showModuleSetup,
+ };
+};
+
+export const [
+ LogAnalysisSetupFlyoutStateProvider,
+ useLogAnalysisSetupFlyoutStateContext,
+] = createContainer(useLogAnalysisSetupFlyoutState);
diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx
index a70758e3aefd7..79768302a7310 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx
@@ -111,14 +111,6 @@ export const useLogAnalysisModule = ({
[cleanUpModule, dispatchModuleStatus, setUpModule]
);
- const viewSetupForReconfiguration = useCallback(() => {
- dispatchModuleStatus({ type: 'requestedJobConfigurationUpdate' });
- }, [dispatchModuleStatus]);
-
- const viewSetupForUpdate = useCallback(() => {
- dispatchModuleStatus({ type: 'requestedJobDefinitionUpdate' });
- }, [dispatchModuleStatus]);
-
const viewResults = useCallback(() => {
dispatchModuleStatus({ type: 'viewedResults' });
}, [dispatchModuleStatus]);
@@ -143,7 +135,5 @@ export const useLogAnalysisModule = ({
setupStatus: moduleStatus.setupStatus,
sourceConfiguration,
viewResults,
- viewSetupForReconfiguration,
- viewSetupForUpdate,
};
};
diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx
index a0046b630bfe1..84b5404fe96aa 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx
@@ -43,8 +43,6 @@ type StatusReducerAction =
payload: FetchJobStatusResponsePayload;
}
| { type: 'failedFetchingJobStatuses' }
- | { type: 'requestedJobConfigurationUpdate' }
- | { type: 'requestedJobDefinitionUpdate' }
| { type: 'viewedResults' };
const createInitialState = ({
@@ -173,18 +171,6 @@ const createStatusReducer = (jobTypes: JobType[]) => (
),
};
}
- case 'requestedJobConfigurationUpdate': {
- return {
- ...state,
- setupStatus: { type: 'required', reason: 'reconfiguration' },
- };
- }
- case 'requestedJobDefinitionUpdate': {
- return {
- ...state,
- setupStatus: { type: 'required', reason: 'update' },
- };
- }
case 'viewedResults': {
return {
...state,
@@ -251,7 +237,7 @@ const getSetupStatus = (everyJobStatus: Record
Object.entries(everyJobStatus).reduce((setupStatus, [, jobStatus]) => {
if (jobStatus === 'missing') {
- return { type: 'required', reason: 'missing' };
+ return { type: 'required' };
} else if (setupStatus.type === 'required' || setupStatus.type === 'succeeded') {
return setupStatus;
} else if (setupStatus.type === 'skipped' || isJobStatusWithResults(jobStatus)) {
diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts
index cc9ef73019844..4930c8b478a9c 100644
--- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts
@@ -4,18 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { DeleteJobsResponsePayload } from './api/ml_cleanup';
-import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api';
-import { GetMlModuleResponsePayload } from './api/ml_get_module';
-import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api';
import {
- ValidationIndicesResponsePayload,
ValidateLogEntryDatasetsResponsePayload,
+ ValidationIndicesResponsePayload,
} from '../../../../common/http_api/log_analysis';
import { DatasetFilter } from '../../../../common/log_analysis';
+import { DeleteJobsResponsePayload } from './api/ml_cleanup';
+import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api';
+import { GetMlModuleResponsePayload } from './api/ml_get_module';
+import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api';
+
+export { JobModelSizeStats, JobSummary } from './api/ml_get_jobs_summary_api';
export interface ModuleDescriptor {
moduleId: string;
+ moduleName: string;
+ moduleDescription: string;
jobTypes: JobType[];
bucketSpan: number;
getJobIds: (spaceId: string, sourceId: string) => Record;
@@ -46,3 +50,43 @@ export interface ModuleSourceConfiguration {
spaceId: string;
timestampField: string;
}
+
+interface ManyCategoriesWarningReason {
+ type: 'manyCategories';
+ categoriesDocumentRatio: number;
+}
+
+interface ManyDeadCategoriesWarningReason {
+ type: 'manyDeadCategories';
+ deadCategoriesRatio: number;
+}
+
+interface ManyRareCategoriesWarningReason {
+ type: 'manyRareCategories';
+ rareCategoriesRatio: number;
+}
+
+interface NoFrequentCategoriesWarningReason {
+ type: 'noFrequentCategories';
+}
+
+interface SingleCategoryWarningReason {
+ type: 'singleCategory';
+}
+
+export type CategoryQualityWarningReason =
+ | ManyCategoriesWarningReason
+ | ManyDeadCategoriesWarningReason
+ | ManyRareCategoriesWarningReason
+ | NoFrequentCategoriesWarningReason
+ | SingleCategoryWarningReason;
+
+export type CategoryQualityWarningReasonType = CategoryQualityWarningReason['type'];
+
+export interface CategoryQualityWarning {
+ type: 'categoryQualityWarning';
+ jobId: string;
+ reasons: CategoryQualityWarningReason[];
+}
+
+export type QualityWarning = CategoryQualityWarning;
diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts
new file mode 100644
index 0000000000000..63f1025214331
--- /dev/null
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts
@@ -0,0 +1,10 @@
+/*
+ * 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.
+ */
+
+export * from './module_descriptor';
+export * from './use_log_entry_categories_module';
+export * from './use_log_entry_categories_quality';
+export * from './use_log_entry_categories_setup';
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts
similarity index 77%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts
index 8d9b9130f74a4..9682b3e74db3b 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import {
bucketSpan,
categoriesMessageField,
@@ -12,19 +13,25 @@ import {
LogEntryCategoriesJobType,
logEntryCategoriesJobTypes,
partitionField,
-} from '../../../../common/log_analysis';
-import {
- cleanUpJobsAndDatafeeds,
- ModuleDescriptor,
- ModuleSourceConfiguration,
-} from '../../../containers/logs/log_analysis';
-import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api';
-import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module';
-import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api';
-import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets';
-import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices';
+} from '../../../../../../common/log_analysis';
+import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api';
+import { callGetMlModuleAPI } from '../../api/ml_get_module';
+import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api';
+import { callValidateDatasetsAPI } from '../../api/validate_datasets';
+import { callValidateIndicesAPI } from '../../api/validate_indices';
+import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup';
+import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types';
const moduleId = 'logs_ui_categories';
+const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryCategoriesModuleName', {
+ defaultMessage: 'Categorization',
+});
+const moduleDescription = i18n.translate(
+ 'xpack.infra.logs.analysis.logEntryCategoriesModuleDescription',
+ {
+ defaultMessage: 'Use Machine Learning to automatically categorize log messages.',
+ }
+);
const getJobIds = (spaceId: string, sourceId: string) =>
logEntryCategoriesJobTypes.reduce(
@@ -138,6 +145,8 @@ const validateSetupDatasets = async (
export const logEntryCategoriesModule: ModuleDescriptor = {
moduleId,
+ moduleName,
+ moduleDescription,
jobTypes: logEntryCategoriesJobTypes,
bucketSpan,
getJobIds,
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx
similarity index 88%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx
index fe832d3fe3a54..0b12d6834d522 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx
@@ -6,12 +6,10 @@
import createContainer from 'constate';
import { useMemo } from 'react';
-import {
- ModuleSourceConfiguration,
- useLogAnalysisModule,
- useLogAnalysisModuleConfiguration,
- useLogAnalysisModuleDefinition,
-} from '../../../containers/logs/log_analysis';
+import { useLogAnalysisModule } from '../../log_analysis_module';
+import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration';
+import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition';
+import { ModuleSourceConfiguration } from '../../log_analysis_module_types';
import { logEntryCategoriesModule } from './module_descriptor';
import { useLogEntryCategoriesQuality } from './use_log_entry_categories_quality';
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts
similarity index 92%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts
index 51e049d576235..346281fa94e1b 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts
@@ -5,9 +5,12 @@
*/
import { useMemo } from 'react';
-
-import { JobModelSizeStats, JobSummary } from '../../../containers/logs/log_analysis';
-import { QualityWarning, CategoryQualityWarningReason } from './sections/notices/quality_warnings';
+import {
+ JobModelSizeStats,
+ JobSummary,
+ QualityWarning,
+ CategoryQualityWarningReason,
+} from '../../log_analysis_module_types';
export const useLogEntryCategoriesQuality = ({ jobSummaries }: { jobSummaries: JobSummary[] }) => {
const categoryQualityWarnings: QualityWarning[] = useMemo(
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx
similarity index 92%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx
index c011230942d7c..399c30cf47e71 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { useAnalysisSetupState } from '../../../containers/logs/log_analysis';
+import { useAnalysisSetupState } from '../../log_analysis_setup_state';
import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module';
export const useLogEntryCategoriesSetup = () => {
@@ -41,6 +41,7 @@ export const useLogEntryCategoriesSetup = () => {
endTime,
isValidating,
lastSetupErrorMessages,
+ moduleDescriptor,
setEndTime,
setStartTime,
setValidatedIndices,
diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts
new file mode 100644
index 0000000000000..7fc1e4558961a
--- /dev/null
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts
@@ -0,0 +1,9 @@
+/*
+ * 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.
+ */
+
+export * from './module_descriptor';
+export * from './use_log_entry_rate_module';
+export * from './use_log_entry_rate_setup';
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts
similarity index 76%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts
index 6ca306f39e947..001174a2b7558 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { i18n } from '@kbn/i18n';
import {
bucketSpan,
DatasetFilter,
@@ -11,19 +12,25 @@ import {
LogEntryRateJobType,
logEntryRateJobTypes,
partitionField,
-} from '../../../../common/log_analysis';
-import {
- cleanUpJobsAndDatafeeds,
- ModuleDescriptor,
- ModuleSourceConfiguration,
-} from '../../../containers/logs/log_analysis';
-import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api';
-import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module';
-import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api';
-import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets';
-import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices';
+} from '../../../../../../common/log_analysis';
+import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types';
+import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup';
+import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api';
+import { callGetMlModuleAPI } from '../../api/ml_get_module';
+import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api';
+import { callValidateDatasetsAPI } from '../../api/validate_datasets';
+import { callValidateIndicesAPI } from '../../api/validate_indices';
const moduleId = 'logs_ui_analysis';
+const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryRateModuleName', {
+ defaultMessage: 'Log rate',
+});
+const moduleDescription = i18n.translate(
+ 'xpack.infra.logs.analysis.logEntryRateModuleDescription',
+ {
+ defaultMessage: 'Use Machine Learning to automatically detect anomalous log entry rates.',
+ }
+);
const getJobIds = (spaceId: string, sourceId: string) =>
logEntryRateJobTypes.reduce(
@@ -126,6 +133,8 @@ const validateSetupDatasets = async (
export const logEntryRateModule: ModuleDescriptor = {
moduleId,
+ moduleName,
+ moduleDescription,
jobTypes: logEntryRateJobTypes,
bucketSpan,
getJobIds,
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx
similarity index 86%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx
index 07bdb0249cd3d..f9832e2cdd7ec 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx
@@ -6,12 +6,10 @@
import createContainer from 'constate';
import { useMemo } from 'react';
-import {
- ModuleSourceConfiguration,
- useLogAnalysisModule,
- useLogAnalysisModuleConfiguration,
- useLogAnalysisModuleDefinition,
-} from '../../../containers/logs/log_analysis';
+import { ModuleSourceConfiguration } from '../../log_analysis_module_types';
+import { useLogAnalysisModule } from '../../log_analysis_module';
+import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration';
+import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition';
import { logEntryRateModule } from './module_descriptor';
export const useLogEntryRateModule = ({
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx
similarity index 82%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx
rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx
index 3595b6bf830fc..f67ab1fef823e 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx
+++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx
@@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { useAnalysisSetupState } from '../../../containers/logs/log_analysis';
+import createContainer from 'constate';
+import { useAnalysisSetupState } from '../../log_analysis_setup_state';
import { useLogEntryRateModuleContext } from './use_log_entry_rate_module';
export const useLogEntryRateSetup = () => {
@@ -41,6 +42,7 @@ export const useLogEntryRateSetup = () => {
endTime,
isValidating,
lastSetupErrorMessages,
+ moduleDescriptor,
setEndTime,
setStartTime,
setValidatedIndices,
@@ -52,3 +54,7 @@ export const useLogEntryRateSetup = () => {
viewResults,
};
};
+
+export const [LogEntryRateSetupProvider, useLogEntryRateSetupContext] = createContainer(
+ useLogEntryRateSetup
+);
diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts
index 24c51598ad257..88bc426e9a0f7 100644
--- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts
+++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts
@@ -53,12 +53,18 @@ describe('Metrics UI Observability Homepage Functions', () => {
const { core, mockedGetStartServices } = setup();
core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE);
const fetchData = createMetricsFetchData(mockedGetStartServices);
- const endTime = moment();
+ const endTime = moment('2020-07-02T13:25:11.629Z');
const startTime = endTime.clone().subtract(1, 'h');
const bucketSize = '300s';
const response = await fetchData({
- startTime: startTime.toISOString(),
- endTime: endTime.toISOString(),
+ absoluteTime: {
+ start: startTime.valueOf(),
+ end: endTime.valueOf(),
+ },
+ relativeTime: {
+ start: 'now-15m',
+ end: 'now',
+ },
bucketSize,
});
expect(core.http.post).toHaveBeenCalledTimes(1);
diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts
index 25b334d03c4f7..4eaf903e17608 100644
--- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts
+++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts
@@ -4,15 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import moment from 'moment';
-import { sum, isFinite, isNumber } from 'lodash';
-import { i18n } from '@kbn/i18n';
-import { MetricsFetchDataResponse, FetchDataParams } from '../../observability/public';
+import { isFinite, isNumber, sum } from 'lodash';
+import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public';
import {
- SnapshotRequest,
SnapshotMetricInput,
SnapshotNode,
SnapshotNodeResponse,
+ SnapshotRequest,
} from '../common/http_api/snapshot_api';
import { SnapshotMetricType } from '../common/inventory_models/types';
import { InfraClientCoreSetup } from './types';
@@ -77,13 +75,12 @@ export const combineNodeTimeseriesBy = (
export const createMetricsFetchData = (
getStartServices: InfraClientCoreSetup['getStartServices']
-) => async ({
- startTime,
- endTime,
- bucketSize,
-}: FetchDataParams): Promise => {
+) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => {
const [coreServices] = await getStartServices();
const { http } = coreServices;
+
+ const { start, end } = absoluteTime;
+
const snapshotRequest: SnapshotRequest = {
sourceId: 'default',
metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[],
@@ -91,8 +88,8 @@ export const createMetricsFetchData = (
nodeType: 'host',
includeTimeseries: true,
timerange: {
- from: moment(startTime).valueOf(),
- to: moment(endTime).valueOf(),
+ from: start,
+ to: end,
interval: bucketSize,
forceInterval: true,
ignoreLookback: true,
@@ -102,12 +99,8 @@ export const createMetricsFetchData = (
const results = await http.post('/api/metrics/snapshot', {
body: JSON.stringify(snapshotRequest),
});
-
return {
- title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', {
- defaultMessage: 'Metrics',
- }),
- appLink: '/app/metrics',
+ appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`,
stats: {
hosts: {
type: 'number',
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx
index 26633cd190a07..2880b1b794443 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx
@@ -5,7 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { isJobStatusWithResults } from '../../../../common/log_analysis';
import { LoadingPage } from '../../../components/loading_page';
import {
@@ -17,10 +17,10 @@ import {
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis';
+import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { LogEntryCategoriesResultsContent } from './page_results_content';
import { LogEntryCategoriesSetupContent } from './page_setup_content';
-import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module';
import { LogEntryCategoriesSetupFlyout } from './setup_flyout';
export const LogEntryCategoriesPageContent = () => {
@@ -50,13 +50,6 @@ export const LogEntryCategoriesPageContent = () => {
}
}, [fetchJobStatus, hasLogAnalysisReadCapabilities]);
- // Open flyout if there are no ML jobs
- useEffect(() => {
- if (setupStatus.type === 'required' && setupStatus.reason === 'missing') {
- openFlyout();
- }
- }, [setupStatus, openFlyout]);
-
if (isLoading || isUninitialized) {
return ;
} else if (hasFailedLoadingSource) {
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx
index cecea733b49e4..48ad156714ccf 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx
@@ -5,10 +5,9 @@
*/
import React from 'react';
-
+import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id';
-import { LogEntryCategoriesModuleProvider } from './use_log_entry_categories_module';
export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ children }) => {
const { sourceId, sourceConfiguration } = useLogSourceContext();
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx
index 8ce582df7466e..5e602e1f63862 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx
@@ -12,17 +12,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { euiStyled, useTrackPageview } from '../../../../../observability/public';
import { TimeRange } from '../../../../common/http_api/shared/time_range';
+import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status';
+import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories';
+import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { useInterval } from '../../../hooks/use_interval';
-import { CategoryJobNoticesSection } from './sections/notices/notices_section';
+import { PageViewLogInContext } from '../stream/page_view_log_in_context';
import { TopCategoriesSection } from './sections/top_categories';
-import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module';
import { useLogEntryCategoriesResults } from './use_log_entry_categories_results';
import {
StringTimeRange,
useLogEntryCategoriesResultsUrlState,
} from './use_log_entry_categories_results_url_state';
-import { PageViewLogInContext } from '../stream/page_view_log_in_context';
-import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
const JOB_STATUS_POLLING_INTERVAL = 30000;
@@ -39,9 +39,8 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent {
- viewSetupForReconfiguration();
- onOpenSetup();
- }, [onOpenSetup, viewSetupForReconfiguration]);
-
- const viewSetupFlyoutForUpdate = useCallback(() => {
- viewSetupForUpdate();
- onOpenSetup();
- }, [onOpenSetup, viewSetupForUpdate]);
-
const hasResults = useMemo(() => topLogEntryCategories.length > 0, [
topLogEntryCategories.length,
]);
@@ -210,8 +199,9 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent
@@ -223,7 +213,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent {
+export const LogEntryRatePageContent = memo(() => {
const {
hasFailedLoadingSource,
isLoading,
@@ -38,24 +45,52 @@ export const LogEntryRatePageContent = () => {
hasLogAnalysisSetupCapabilities,
} = useLogAnalysisCapabilitiesContext();
- const { fetchJobStatus, setupStatus, jobStatus } = useLogEntryRateModuleContext();
+ const {
+ fetchJobStatus: fetchLogEntryCategoriesJobStatus,
+ fetchModuleDefinition: fetchLogEntryCategoriesModuleDefinition,
+ jobStatus: logEntryCategoriesJobStatus,
+ setupStatus: logEntryCategoriesSetupStatus,
+ } = useLogEntryCategoriesModuleContext();
+ const {
+ fetchJobStatus: fetchLogEntryRateJobStatus,
+ fetchModuleDefinition: fetchLogEntryRateModuleDefinition,
+ jobStatus: logEntryRateJobStatus,
+ setupStatus: logEntryRateSetupStatus,
+ } = useLogEntryRateModuleContext();
- const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
- const openFlyout = useCallback(() => setIsFlyoutOpen(true), []);
- const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []);
+ const { showModuleList } = useLogAnalysisSetupFlyoutStateContext();
+
+ const fetchAllJobStatuses = useCallback(
+ () => Promise.all([fetchLogEntryCategoriesJobStatus(), fetchLogEntryRateJobStatus()]),
+ [fetchLogEntryCategoriesJobStatus, fetchLogEntryRateJobStatus]
+ );
useEffect(() => {
if (hasLogAnalysisReadCapabilities) {
- fetchJobStatus();
+ fetchAllJobStatuses();
}
- }, [fetchJobStatus, hasLogAnalysisReadCapabilities]);
+ }, [fetchAllJobStatuses, hasLogAnalysisReadCapabilities]);
- // Open flyout if there are no ML jobs
useEffect(() => {
- if (setupStatus.type === 'required' && setupStatus.reason === 'missing') {
- openFlyout();
+ if (hasLogAnalysisReadCapabilities) {
+ fetchLogEntryCategoriesModuleDefinition();
+ }
+ }, [fetchLogEntryCategoriesModuleDefinition, hasLogAnalysisReadCapabilities]);
+
+ useEffect(() => {
+ if (hasLogAnalysisReadCapabilities) {
+ fetchLogEntryRateModuleDefinition();
+ }
+ }, [fetchLogEntryRateModuleDefinition, hasLogAnalysisReadCapabilities]);
+
+ useInterval(() => {
+ if (logEntryCategoriesSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) {
+ fetchLogEntryCategoriesJobStatus();
+ }
+ if (logEntryRateSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) {
+ fetchLogEntryRateJobStatus();
}
- }, [setupStatus, openFlyout]);
+ }, JOB_STATUS_POLLING_INTERVAL);
if (isLoading || isUninitialized) {
return ;
@@ -65,7 +100,10 @@ export const LogEntryRatePageContent = () => {
return ;
} else if (!hasLogAnalysisReadCapabilities) {
return ;
- } else if (setupStatus.type === 'initializing') {
+ } else if (
+ logEntryCategoriesSetupStatus.type === 'initializing' ||
+ logEntryRateSetupStatus.type === 'initializing'
+ ) {
return (
{
})}
/>
);
- } else if (setupStatus.type === 'unknown') {
- return ;
- } else if (isJobStatusWithResults(jobStatus['log-entry-rate'])) {
+ } else if (
+ logEntryCategoriesSetupStatus.type === 'unknown' ||
+ logEntryRateSetupStatus.type === 'unknown'
+ ) {
+ return ;
+ } else if (
+ isJobStatusWithResults(logEntryCategoriesJobStatus['log-entry-categories-count']) ||
+ isJobStatusWithResults(logEntryRateJobStatus['log-entry-rate'])
+ ) {
return (
<>
-
-
+
+
>
);
} else if (!hasLogAnalysisSetupCapabilities) {
@@ -87,9 +131,9 @@ export const LogEntryRatePageContent = () => {
} else {
return (
<>
-
-
+
+
>
);
}
-};
+});
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx
index e91ef87bdf34a..ac11260d2075d 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx
@@ -5,10 +5,11 @@
*/
import React from 'react';
-
+import { LogAnalysisSetupFlyoutStateProvider } from '../../../components/logging/log_analysis_setup/setup_flyout';
+import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories';
+import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id';
-import { LogEntryRateModuleProvider } from './use_log_entry_rate_module';
export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => {
const { sourceId, sourceConfiguration } = useLogSourceContext();
@@ -21,7 +22,14 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children })
spaceId={spaceId}
timestampField={sourceConfiguration?.configuration.fields.timestamp ?? ''}
>
- {children}
+
+ {children}
+
);
};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx
index bf4dbcd87cc41..f2a60541b3b3c 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx
@@ -5,62 +5,61 @@
*/
import datemath from '@elastic/datemath';
-import {
- EuiBadge,
- EuiFlexGroup,
- EuiFlexItem,
- EuiPage,
- EuiPanel,
- EuiSuperDatePicker,
- EuiText,
-} from '@elastic/eui';
-import numeral from '@elastic/numeral';
-import { FormattedMessage } from '@kbn/i18n/react';
+import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui';
import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { euiStyled, useTrackPageview } from '../../../../../observability/public';
import { TimeRange } from '../../../../common/http_api/shared/time_range';
import { bucketSpan } from '../../../../common/log_analysis';
-import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapper';
-import { LogAnalysisJobProblemIndicator } from '../../../components/logging/log_analysis_job_status';
+import {
+ CategoryJobNoticesSection,
+ LogAnalysisJobProblemIndicator,
+} from '../../../components/logging/log_analysis_job_status';
+import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout';
+import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories';
+import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate';
+import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useInterval } from '../../../hooks/use_interval';
-import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting';
import { AnomaliesResults } from './sections/anomalies';
-import { LogRateResults } from './sections/log_rate';
-import { useLogEntryRateModuleContext } from './use_log_entry_rate_module';
+import { useLogEntryAnomaliesResults } from './use_log_entry_anomalies_results';
import { useLogEntryRateResults } from './use_log_entry_rate_results';
import {
StringTimeRange,
useLogAnalysisResultsUrlState,
} from './use_log_entry_rate_results_url_state';
-const JOB_STATUS_POLLING_INTERVAL = 30000;
+export const SORT_DEFAULTS = {
+ direction: 'desc' as const,
+ field: 'anomalyScore' as const,
+};
-interface LogEntryRateResultsContentProps {
- onOpenSetup: () => void;
-}
+export const PAGINATION_DEFAULTS = {
+ pageSize: 25,
+};
-export const LogEntryRateResultsContent: React.FunctionComponent = ({
- onOpenSetup,
-}) => {
+export const LogEntryRateResultsContent: React.FunctionComponent = () => {
useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results' });
useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results', delay: 15000 });
- const [dateFormat] = useKibanaUiSetting('dateFormat', 'MMMM D, YYYY h:mm A');
+ const { sourceId } = useLogSourceContext();
const {
- fetchJobStatus,
- fetchModuleDefinition,
- setupStatus,
- viewSetupForReconfiguration,
- viewSetupForUpdate,
- hasOutdatedJobConfigurations,
- hasOutdatedJobDefinitions,
- hasStoppedJobs,
- jobIds,
- sourceConfiguration: { sourceId },
+ hasOutdatedJobConfigurations: hasOutdatedLogEntryRateJobConfigurations,
+ hasOutdatedJobDefinitions: hasOutdatedLogEntryRateJobDefinitions,
+ hasStoppedJobs: hasStoppedLogEntryRateJobs,
+ moduleDescriptor: logEntryRateModuleDescriptor,
+ setupStatus: logEntryRateSetupStatus,
} = useLogEntryRateModuleContext();
+ const {
+ categoryQualityWarnings,
+ hasOutdatedJobConfigurations: hasOutdatedLogEntryCategoriesJobConfigurations,
+ hasOutdatedJobDefinitions: hasOutdatedLogEntryCategoriesJobDefinitions,
+ hasStoppedJobs: hasStoppedLogEntryCategoriesJobs,
+ moduleDescriptor: logEntryCategoriesModuleDescriptor,
+ setupStatus: logEntryCategoriesSetupStatus,
+ } = useLogEntryCategoriesModuleContext();
+
const {
timeRange: selectedTimeRange,
setTimeRange: setSelectedTimeRange,
@@ -88,6 +87,24 @@ export const LogEntryRateResultsContent: React.FunctionComponent {
setQueryTimeRange({
@@ -133,41 +150,33 @@ export const LogEntryRateResultsContent: React.FunctionComponent {
- viewSetupForReconfiguration();
- onOpenSetup();
- }, [viewSetupForReconfiguration, onOpenSetup]);
-
- const viewSetupFlyoutForUpdate = useCallback(() => {
- viewSetupForUpdate();
- onOpenSetup();
- }, [viewSetupForUpdate, onOpenSetup]);
+ const { showModuleList, showModuleSetup } = useLogAnalysisSetupFlyoutStateContext();
- /* eslint-disable-next-line react-hooks/exhaustive-deps */
- const hasResults = useMemo(() => (logEntryRate?.histogramBuckets?.length ?? 0) > 0, [
- logEntryRate,
+ const showLogEntryRateSetup = useCallback(() => showModuleSetup('logs_ui_analysis'), [
+ showModuleSetup,
+ ]);
+ const showLogEntryCategoriesSetup = useCallback(() => showModuleSetup('logs_ui_categories'), [
+ showModuleSetup,
]);
+ const hasLogRateResults = (logEntryRate?.histogramBuckets?.length ?? 0) > 0;
+ const hasAnomalyResults = logEntryAnomalies.length > 0;
+
const isFirstUse = useMemo(
() =>
- ((setupStatus.type === 'skipped' && !!setupStatus.newlyCreated) ||
- setupStatus.type === 'succeeded') &&
- !hasResults,
- [hasResults, setupStatus]
+ ((logEntryCategoriesSetupStatus.type === 'skipped' &&
+ !!logEntryCategoriesSetupStatus.newlyCreated) ||
+ logEntryCategoriesSetupStatus.type === 'succeeded' ||
+ (logEntryRateSetupStatus.type === 'skipped' && !!logEntryRateSetupStatus.newlyCreated) ||
+ logEntryRateSetupStatus.type === 'succeeded') &&
+ !(hasLogRateResults || hasAnomalyResults),
+ [hasAnomalyResults, hasLogRateResults, logEntryCategoriesSetupStatus, logEntryRateSetupStatus]
);
useEffect(() => {
getLogEntryRate();
}, [getLogEntryRate, queryTimeRange.lastChangedTime]);
- useEffect(() => {
- fetchModuleDefinition();
- }, [fetchModuleDefinition]);
-
- useInterval(() => {
- fetchJobStatus();
- }, JOB_STATUS_POLLING_INTERVAL);
-
useInterval(
() => {
handleQueryTimeRangeChange({
@@ -182,75 +191,57 @@ export const LogEntryRateResultsContent: React.FunctionComponent
-
-
-
- {logEntryRate ? (
-
-
-
-
- {numeral(logEntryRate.totalNumberOfLogEntries).format('0.00a')}
-
-
- ),
- startTime: (
- {moment(queryTimeRange.value.startTime).format(dateFormat)}
- ),
- endTime: {moment(queryTimeRange.value.endTime).format(dateFormat)} ,
- }}
- />
-
-
- ) : null}
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx
index 79ab4475ee5a3..ae5c3b5b93b47 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx
@@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-
+import { EuiEmptyPrompt } from '@elastic/eui';
import { RectAnnotationDatum, AnnotationId } from '@elastic/charts';
import {
Axis,
@@ -21,6 +21,7 @@ import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
+import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper';
import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
import {
@@ -36,7 +37,16 @@ export const AnomaliesChart: React.FunctionComponent<{
series: Array<{ time: number; value: number }>;
annotations: Record;
renderAnnotationTooltip?: (details?: string) => JSX.Element;
-}> = ({ chartId, series, annotations, setTimeRange, timeRange, renderAnnotationTooltip }) => {
+ isLoading: boolean;
+}> = ({
+ chartId,
+ series,
+ annotations,
+ setTimeRange,
+ timeRange,
+ renderAnnotationTooltip,
+ isLoading,
+}) => {
const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss.SSS');
const [isDarkMode] = useKibanaUiSetting('theme:darkMode');
@@ -68,41 +78,56 @@ export const AnomaliesChart: React.FunctionComponent<{
[setTimeRange]
);
- return (
-
-
-
- numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194
- />
-
+ {i18n.translate('xpack.infra.logs.analysis.anomalySectionLogRateChartNoData', {
+ defaultMessage: 'There is no log rate data to display.',
})}
- xScaleType="time"
- yScaleType="linear"
- xAccessor={'time'}
- yAccessors={['value']}
- data={series}
- barSeriesStyle={barSeriesStyle}
- />
- {renderAnnotations(annotations, chartId, renderAnnotationTooltip)}
-
-
-
+
+ }
+ titleSize="m"
+ />
+ ) : (
+
+
+ {series.length ? (
+
+
+ numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194
+ />
+
+ {renderAnnotations(annotations, chartId, renderAnnotationTooltip)}
+
+
+ ) : null}
+
+
);
};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx
index c527b8c49d099..84ef13cc70706 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx
@@ -4,18 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiStat } from '@elastic/eui';
+import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiTitle } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useMount } from 'react-use';
+import { euiStyled } from '../../../../../../../observability/public';
+import { LogEntryAnomaly } from '../../../../../../common/http_api';
import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
-import { AnomalyRecord } from '../../use_log_entry_rate_results';
-import { useLogEntryRateModuleContext } from '../../use_log_entry_rate_module';
-import { useLogEntryRateExamples } from '../../use_log_entry_rate_examples';
import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples';
-import { LogEntryRateExampleMessage, LogEntryRateExampleMessageHeaders } from './log_entry_example';
-import { euiStyled } from '../../../../../../../observability/public';
+import { useLogSourceContext } from '../../../../../containers/logs/log_source';
+import { useLogEntryExamples } from '../../use_log_entry_examples';
+import { LogEntryExampleMessage, LogEntryExampleMessageHeaders } from './log_entry_example';
const EXAMPLE_COUNT = 5;
@@ -24,29 +24,27 @@ const examplesTitle = i18n.translate('xpack.infra.logs.analysis.anomaliesTableEx
});
export const AnomaliesTableExpandedRow: React.FunctionComponent<{
- anomaly: AnomalyRecord;
+ anomaly: LogEntryAnomaly;
timeRange: TimeRange;
- jobId: string;
-}> = ({ anomaly, timeRange, jobId }) => {
- const {
- sourceConfiguration: { sourceId },
- } = useLogEntryRateModuleContext();
+}> = ({ anomaly, timeRange }) => {
+ const { sourceId } = useLogSourceContext();
const {
- getLogEntryRateExamples,
- hasFailedLoadingLogEntryRateExamples,
- isLoadingLogEntryRateExamples,
- logEntryRateExamples,
- } = useLogEntryRateExamples({
- dataset: anomaly.partitionId,
+ getLogEntryExamples,
+ hasFailedLoadingLogEntryExamples,
+ isLoadingLogEntryExamples,
+ logEntryExamples,
+ } = useLogEntryExamples({
+ dataset: anomaly.dataset,
endTime: anomaly.startTime + anomaly.duration,
exampleCount: EXAMPLE_COUNT,
sourceId,
startTime: anomaly.startTime,
+ categoryId: anomaly.categoryId,
});
useMount(() => {
- getLogEntryRateExamples();
+ getLogEntryExamples();
});
return (
@@ -57,17 +55,17 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{
{examplesTitle}
0}
+ isLoading={isLoadingLogEntryExamples}
+ hasFailedLoading={hasFailedLoadingLogEntryExamples}
+ hasResults={logEntryExamples.length > 0}
exampleCount={EXAMPLE_COUNT}
- onReload={getLogEntryRateExamples}
+ onReload={getLogEntryExamples}
>
- {logEntryRateExamples.length > 0 ? (
+ {logEntryExamples.length > 0 ? (
<>
-
- {logEntryRateExamples.map((example, exampleIndex) => (
-
+ {logEntryExamples.map((example, exampleIndex) => (
+
))}
>
@@ -87,11 +85,11 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{
void;
timeRange: TimeRange;
- viewSetupForReconfiguration: () => void;
- jobId: string;
-}> = ({ isLoading, results, setTimeRange, timeRange, viewSetupForReconfiguration, jobId }) => {
- const hasAnomalies = useMemo(() => {
- return results && results.histogramBuckets
- ? results.histogramBuckets.some((bucket) => {
- return bucket.partitions.some((partition) => {
- return partition.anomalies.length > 0;
- });
- })
- : false;
- }, [results]);
-
+ onViewModuleList: () => void;
+ page: Page;
+ fetchNextPage?: FetchNextPage;
+ fetchPreviousPage?: FetchPreviousPage;
+ changeSortOptions: ChangeSortOptions;
+ changePaginationOptions: ChangePaginationOptions;
+ sortOptions: SortOptions;
+ paginationOptions: PaginationOptions;
+}> = ({
+ isLoadingLogRateResults,
+ isLoadingAnomaliesResults,
+ logEntryRateResults,
+ setTimeRange,
+ timeRange,
+ onViewModuleList,
+ anomalies,
+ changeSortOptions,
+ sortOptions,
+ changePaginationOptions,
+ paginationOptions,
+ fetchNextPage,
+ fetchPreviousPage,
+ page,
+}) => {
const logEntryRateSeries = useMemo(
- () => (results && results.histogramBuckets ? getLogEntryRateCombinedSeries(results) : []),
- [results]
+ () =>
+ logEntryRateResults && logEntryRateResults.histogramBuckets
+ ? getLogEntryRateCombinedSeries(logEntryRateResults)
+ : [],
+ [logEntryRateResults]
);
const anomalyAnnotations = useMemo(
() =>
- results && results.histogramBuckets
- ? getAnnotationsForAll(results)
+ logEntryRateResults && logEntryRateResults.histogramBuckets
+ ? getAnnotationsForAll(logEntryRateResults)
: {
warning: [],
minor: [],
major: [],
critical: [],
},
- [results]
+ [logEntryRateResults]
);
return (
<>
-
- {title}
+
+ {title}
-
-
-
-
+
- }>
- {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? (
+ {(!logEntryRateResults ||
+ (logEntryRateResults &&
+ logEntryRateResults.histogramBuckets &&
+ !logEntryRateResults.histogramBuckets.length)) &&
+ (!anomalies || anomalies.length === 0) ? (
+ }
+ >
@@ -94,41 +123,38 @@ export const AnomaliesResults: React.FunctionComponent<{
}
/>
- ) : !hasAnomalies ? (
-
- {i18n.translate('xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle', {
- defaultMessage: 'No anomalies were detected.',
- })}
-
- }
- titleSize="m"
+
+ ) : (
+ <>
+
+
+
+
+
+
+
- ) : (
- <>
-
-
-
-
-
-
-
- >
- )}
-
+ >
+ )}
>
);
};
@@ -137,13 +163,6 @@ const title = i18n.translate('xpack.infra.logs.analysis.anomaliesSectionTitle',
defaultMessage: 'Anomalies',
});
-const loadingAriaLabel = i18n.translate(
- 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel',
- { defaultMessage: 'Loading anomalies' }
-);
-
-const LoadingOverlayContent = () => ;
-
interface ParsedAnnotationDetails {
anomalyScoresByPartition: Array<{ partitionName: string; maximumAnomalyScore: number }>;
}
@@ -189,3 +208,10 @@ const renderAnnotationTooltip = (details?: string) => {
const TooltipWrapper = euiStyled('div')`
white-space: nowrap;
`;
+
+const loadingAriaLabel = i18n.translate(
+ 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel',
+ { defaultMessage: 'Loading anomalies' }
+);
+
+const LoadingOverlayContent = () => ;
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx
index 96f665b3693ca..2965e1fede822 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx
@@ -28,7 +28,7 @@ import { useLinkProps } from '../../../../../hooks/use_link_props';
import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
import { partitionField } from '../../../../../../common/log_analysis/job_parameters';
import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button';
-import { LogEntryRateExample } from '../../../../../../common/http_api/log_analysis/results';
+import { LogEntryExample } from '../../../../../../common/http_api/log_analysis/results';
import {
LogColumnConfiguration,
isTimestampLogColumnConfiguration,
@@ -36,6 +36,7 @@ import {
isMessageLogColumnConfiguration,
} from '../../../../../utils/source_configuration';
import { localizedDate } from '../../../../../../common/formatters/datetime';
+import { LogEntryAnomaly } from '../../../../../../common/http_api';
export const exampleMessageScale = 'medium' as const;
export const exampleTimestampFormat = 'time' as const;
@@ -58,19 +59,19 @@ const VIEW_ANOMALY_IN_ML_LABEL = i18n.translate(
}
);
-type Props = LogEntryRateExample & {
+type Props = LogEntryExample & {
timeRange: TimeRange;
- jobId: string;
+ anomaly: LogEntryAnomaly;
};
-export const LogEntryRateExampleMessage: React.FunctionComponent = ({
+export const LogEntryExampleMessage: React.FunctionComponent = ({
id,
dataset,
message,
timestamp,
tiebreaker,
timeRange,
- jobId,
+ anomaly,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -107,8 +108,9 @@ export const LogEntryRateExampleMessage: React.FunctionComponent = ({
});
const viewAnomalyInMachineLearningLinkProps = useLinkProps(
- getEntitySpecificSingleMetricViewerLink(jobId, timeRange, {
+ getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, {
[partitionField]: dataset,
+ ...(anomaly.categoryId ? { mlcategory: anomaly.categoryId } : {}),
})
);
@@ -233,11 +235,11 @@ export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [
},
];
-export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{
+export const LogEntryExampleMessageHeaders: React.FunctionComponent<{
dateTime: number;
}> = ({ dateTime }) => {
return (
-
+
<>
{exampleMessageColumnConfigurations.map((columnConfiguration) => {
if (isTimestampLogColumnConfiguration(columnConfiguration)) {
@@ -280,11 +282,11 @@ export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{
{null}
>
-
+
);
};
-const LogEntryRateExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)`
+const LogEntryExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)`
border-bottom: none;
box-shadow: none;
padding-right: 0;
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx
index c70a456bfe06a..e0a3b6fb91db0 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx
@@ -4,45 +4,52 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui';
+import {
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonIcon,
+ EuiSpacer,
+} from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { useSet } from 'react-use';
import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
import {
formatAnomalyScore,
getFriendlyNameForPartitionId,
+ formatOneDecimalPlace,
} from '../../../../../../common/log_analysis';
+import { AnomalyType } from '../../../../../../common/http_api/log_analysis';
import { RowExpansionButton } from '../../../../../components/basic_table';
-import { LogEntryRateResults } from '../../use_log_entry_rate_results';
import { AnomaliesTableExpandedRow } from './expanded_row';
import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator';
import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting';
+import {
+ Page,
+ FetchNextPage,
+ FetchPreviousPage,
+ ChangeSortOptions,
+ ChangePaginationOptions,
+ SortOptions,
+ PaginationOptions,
+ LogEntryAnomalies,
+} from '../../use_log_entry_anomalies_results';
+import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper';
interface TableItem {
id: string;
dataset: string;
datasetName: string;
anomalyScore: number;
- anomalyMessage: string;
startTime: number;
-}
-
-interface SortingOptions {
- sort: {
- field: keyof TableItem;
- direction: 'asc' | 'desc';
- };
-}
-
-interface PaginationOptions {
- pageIndex: number;
- pageSize: number;
- totalItemCount: number;
- pageSizeOptions: number[];
- hidePerPageOptions: boolean;
+ typical: number;
+ actual: number;
+ type: AnomalyType;
}
const anomalyScoreColumnName = i18n.translate(
@@ -73,125 +80,78 @@ const datasetColumnName = i18n.translate(
}
);
-const moreThanExpectedAnomalyMessage = i18n.translate(
- 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage',
- {
- defaultMessage: 'More log messages in this dataset than expected',
- }
-);
-
-const fewerThanExpectedAnomalyMessage = i18n.translate(
- 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage',
- {
- defaultMessage: 'Fewer log messages in this dataset than expected',
- }
-);
-
-const getAnomalyMessage = (actualRate: number, typicalRate: number): string => {
- return actualRate < typicalRate
- ? fewerThanExpectedAnomalyMessage
- : moreThanExpectedAnomalyMessage;
-};
-
export const AnomaliesTable: React.FunctionComponent<{
- results: LogEntryRateResults;
+ results: LogEntryAnomalies;
setTimeRange: (timeRange: TimeRange) => void;
timeRange: TimeRange;
- jobId: string;
-}> = ({ results, timeRange, setTimeRange, jobId }) => {
+ changeSortOptions: ChangeSortOptions;
+ changePaginationOptions: ChangePaginationOptions;
+ sortOptions: SortOptions;
+ paginationOptions: PaginationOptions;
+ page: Page;
+ fetchNextPage?: FetchNextPage;
+ fetchPreviousPage?: FetchPreviousPage;
+ isLoading: boolean;
+}> = ({
+ results,
+ timeRange,
+ setTimeRange,
+ changeSortOptions,
+ sortOptions,
+ changePaginationOptions,
+ paginationOptions,
+ fetchNextPage,
+ fetchPreviousPage,
+ page,
+ isLoading,
+}) => {
const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss');
+ const tableSortOptions = useMemo(() => {
+ return {
+ sort: sortOptions,
+ };
+ }, [sortOptions]);
+
const tableItems: TableItem[] = useMemo(() => {
- return results.anomalies.map((anomaly) => {
+ return results.map((anomaly) => {
return {
id: anomaly.id,
- dataset: anomaly.partitionId,
- datasetName: getFriendlyNameForPartitionId(anomaly.partitionId),
+ dataset: anomaly.dataset,
+ datasetName: getFriendlyNameForPartitionId(anomaly.dataset),
anomalyScore: formatAnomalyScore(anomaly.anomalyScore),
- anomalyMessage: getAnomalyMessage(anomaly.actualLogEntryRate, anomaly.typicalLogEntryRate),
startTime: anomaly.startTime,
+ type: anomaly.type,
+ typical: anomaly.typical,
+ actual: anomaly.actual,
};
});
}, [results]);
const [expandedIds, { add: expandId, remove: collapseId }] = useSet(new Set());
- const expandedDatasetRowContents = useMemo(
+ const expandedIdsRowContents = useMemo(
() =>
- [...expandedIds].reduce>((aggregatedDatasetRows, id) => {
- const anomaly = results.anomalies.find((_anomaly) => _anomaly.id === id);
+ [...expandedIds].reduce>((aggregatedRows, id) => {
+ const anomaly = results.find((_anomaly) => _anomaly.id === id);
return {
- ...aggregatedDatasetRows,
+ ...aggregatedRows,
[id]: anomaly ? (
-
+
) : null,
};
}, {}),
- [expandedIds, results, timeRange, jobId]
+ [expandedIds, results, timeRange]
);
- const [sorting, setSorting] = useState({
- sort: {
- field: 'anomalyScore',
- direction: 'desc',
- },
- });
-
- const [_pagination, setPagination] = useState({
- pageIndex: 0,
- pageSize: 20,
- totalItemCount: results.anomalies.length,
- pageSizeOptions: [10, 20, 50],
- hidePerPageOptions: false,
- });
-
- const paginationOptions = useMemo(() => {
- return {
- ..._pagination,
- totalItemCount: results.anomalies.length,
- };
- }, [_pagination, results]);
-
const handleTableChange = useCallback(
- ({ page = {}, sort = {} }) => {
- const { index, size } = page;
- setPagination((currentPagination) => {
- return {
- ...currentPagination,
- pageIndex: index,
- pageSize: size,
- };
- });
- const { field, direction } = sort;
- setSorting({
- sort: {
- field,
- direction,
- },
- });
+ ({ sort = {} }) => {
+ changeSortOptions(sort);
},
- [setSorting, setPagination]
+ [changeSortOptions]
);
- const sortedTableItems = useMemo(() => {
- let sortedItems: TableItem[] = [];
- if (sorting.sort.field === 'datasetName') {
- sortedItems = tableItems.sort((a, b) => (a.datasetName > b.datasetName ? 1 : -1));
- } else if (sorting.sort.field === 'anomalyScore') {
- sortedItems = tableItems.sort((a, b) => a.anomalyScore - b.anomalyScore);
- } else if (sorting.sort.field === 'startTime') {
- sortedItems = tableItems.sort((a, b) => a.startTime - b.startTime);
- }
-
- return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse();
- }, [tableItems, sorting]);
-
- const pageOfItems: TableItem[] = useMemo(() => {
- const { pageIndex, pageSize } = paginationOptions;
- return sortedTableItems.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize);
- }, [paginationOptions, sortedTableItems]);
-
const columns: Array> = useMemo(
() => [
{
@@ -204,10 +164,11 @@ export const AnomaliesTable: React.FunctionComponent<{
render: (anomalyScore: number) => ,
},
{
- field: 'anomalyMessage',
name: anomalyMessageColumnName,
- sortable: false,
truncateText: true,
+ render: (item: TableItem) => (
+
+ ),
},
{
field: 'startTime',
@@ -240,18 +201,116 @@ export const AnomaliesTable: React.FunctionComponent<{
],
[collapseId, expandId, expandedIds, dateFormat]
);
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+const AnomalyMessage = ({
+ actual,
+ typical,
+ type,
+}: {
+ actual: number;
+ typical: number;
+ type: AnomalyType;
+}) => {
+ const moreThanExpectedAnomalyMessage = i18n.translate(
+ 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage',
+ {
+ defaultMessage:
+ 'more log messages in this {type, select, logRate {dataset} logCategory {category}} than expected',
+ values: { type },
+ }
+ );
+
+ const fewerThanExpectedAnomalyMessage = i18n.translate(
+ 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage',
+ {
+ defaultMessage:
+ 'fewer log messages in this {type, select, logRate {dataset} logCategory {category}} than expected',
+ values: { type },
+ }
+ );
+
+ const isMore = actual > typical;
+ const message = isMore ? moreThanExpectedAnomalyMessage : fewerThanExpectedAnomalyMessage;
+ const ratio = isMore ? actual / typical : typical / actual;
+ const icon = isMore ? 'sortUp' : 'sortDown';
+ // Edge case scenarios where actual and typical might sit at 0.
+ const useRatio = ratio !== Infinity;
+ const ratioMessage = useRatio ? `${formatOneDecimalPlace(ratio)}x` : '';
return (
-
+
+ {`${ratioMessage} ${message}`}
+
+ );
+};
+
+const previousPageLabel = i18n.translate(
+ 'xpack.infra.logs.analysis.anomaliesTablePreviousPageLabel',
+ {
+ defaultMessage: 'Previous page',
+ }
+);
+
+const nextPageLabel = i18n.translate('xpack.infra.logs.analysis.anomaliesTableNextPageLabel', {
+ defaultMessage: 'Next page',
+});
+
+const PaginationControls = ({
+ fetchPreviousPage,
+ fetchNextPage,
+ page,
+ isLoading,
+}: {
+ fetchPreviousPage?: () => void;
+ fetchNextPage?: () => void;
+ page: number;
+ isLoading: boolean;
+}) => {
+ return (
+
+
+
+
+
+ {page}
+
+
+
+
+
);
};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx
deleted file mode 100644
index 498a9f88176f8..0000000000000
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx
+++ /dev/null
@@ -1,100 +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 {
- Axis,
- BarSeries,
- Chart,
- niceTimeFormatter,
- Settings,
- TooltipValue,
- BrushEndListener,
- LIGHT_THEME,
- DARK_THEME,
-} from '@elastic/charts';
-import { i18n } from '@kbn/i18n';
-import numeral from '@elastic/numeral';
-import moment from 'moment';
-import React, { useCallback, useMemo } from 'react';
-
-import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
-import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting';
-
-export const LogEntryRateBarChart: React.FunctionComponent<{
- setTimeRange: (timeRange: TimeRange) => void;
- timeRange: TimeRange;
- series: Array<{ group: string; time: number; value: number }>;
-}> = ({ series, setTimeRange, timeRange }) => {
- const [dateFormat] = useKibanaUiSetting('dateFormat');
- const [isDarkMode] = useKibanaUiSetting('theme:darkMode');
-
- const chartDateFormatter = useMemo(
- () => niceTimeFormatter([timeRange.startTime, timeRange.endTime]),
- [timeRange]
- );
-
- const tooltipProps = useMemo(
- () => ({
- headerFormatter: (tooltipData: TooltipValue) =>
- moment(tooltipData.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'),
- }),
- [dateFormat]
- );
-
- const handleBrushEnd = useCallback(
- ({ x }) => {
- if (!x) {
- return;
- }
- const [startTime, endTime] = x;
- setTimeRange({
- endTime,
- startTime,
- });
- },
- [setTimeRange]
- );
-
- return (
-
-
-
- numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194
- />
-
-
-
-
- );
-};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx
deleted file mode 100644
index 3da025d90119f..0000000000000
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx
+++ /dev/null
@@ -1,98 +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 { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import React, { useMemo } from 'react';
-
-import { TimeRange } from '../../../../../../common/http_api/shared/time_range';
-import { BetaBadge } from '../../../../../components/beta_badge';
-import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper';
-import { LogEntryRateResults as Results } from '../../use_log_entry_rate_results';
-import { getLogEntryRatePartitionedSeries } from '../helpers/data_formatters';
-import { LogEntryRateBarChart } from './bar_chart';
-
-export const LogRateResults = ({
- isLoading,
- results,
- setTimeRange,
- timeRange,
-}: {
- isLoading: boolean;
- results: Results | null;
- setTimeRange: (timeRange: TimeRange) => void;
- timeRange: TimeRange;
-}) => {
- const logEntryRateSeries = useMemo(
- () => (results && results.histogramBuckets ? getLogEntryRatePartitionedSeries(results) : []),
- [results]
- );
-
- return (
- <>
-
-
- {title}
-
-
- }>
- {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? (
- <>
-
-
- {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataTitle', {
- defaultMessage: 'There is no data to display.',
- })}
-
- }
- titleSize="m"
- body={
-
- {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataBody', {
- defaultMessage: 'You may want to adjust your time range.',
- })}
-
- }
- />
- >
- ) : (
- <>
-
-
-
- {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanLabel', {
- defaultMessage: 'Bucket span: ',
- })}
-
- {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanValue', {
- defaultMessage: '15 minutes',
- })}
-
-
-
- >
- )}
-
- >
- );
-};
-
-const title = i18n.translate('xpack.infra.logs.analysis.logRateSectionTitle', {
- defaultMessage: 'Log entries',
-});
-
-const loadingAriaLabel = i18n.translate(
- 'xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel',
- { defaultMessage: 'Loading log rate results' }
-);
-
-const LoadingOverlayContent = () => ;
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts
new file mode 100644
index 0000000000000..d4a0eaae43ac0
--- /dev/null
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { npStart } from '../../../../legacy_singletons';
+import {
+ getLogEntryAnomaliesRequestPayloadRT,
+ getLogEntryAnomaliesSuccessReponsePayloadRT,
+ LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH,
+} from '../../../../../common/http_api/log_analysis';
+import { decodeOrThrow } from '../../../../../common/runtime_types';
+import { Sort, Pagination } from '../../../../../common/http_api/log_analysis';
+
+export const callGetLogEntryAnomaliesAPI = async (
+ sourceId: string,
+ startTime: number,
+ endTime: number,
+ sort: Sort,
+ pagination: Pagination
+) => {
+ const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, {
+ method: 'POST',
+ body: JSON.stringify(
+ getLogEntryAnomaliesRequestPayloadRT.encode({
+ data: {
+ sourceId,
+ timeRange: {
+ startTime,
+ endTime,
+ },
+ sort,
+ pagination,
+ },
+ })
+ ),
+ });
+
+ return decodeOrThrow(getLogEntryAnomaliesSuccessReponsePayloadRT)(response);
+};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts
similarity index 77%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts
rename to x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts
index d3b30da72af96..a125b53f9e635 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts
@@ -10,23 +10,24 @@ import { identity } from 'fp-ts/lib/function';
import { npStart } from '../../../../legacy_singletons';
import {
- getLogEntryRateExamplesRequestPayloadRT,
- getLogEntryRateExamplesSuccessReponsePayloadRT,
+ getLogEntryExamplesRequestPayloadRT,
+ getLogEntryExamplesSuccessReponsePayloadRT,
LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH,
} from '../../../../../common/http_api/log_analysis';
import { createPlainError, throwErrors } from '../../../../../common/runtime_types';
-export const callGetLogEntryRateExamplesAPI = async (
+export const callGetLogEntryExamplesAPI = async (
sourceId: string,
startTime: number,
endTime: number,
dataset: string,
- exampleCount: number
+ exampleCount: number,
+ categoryId?: string
) => {
const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, {
method: 'POST',
body: JSON.stringify(
- getLogEntryRateExamplesRequestPayloadRT.encode({
+ getLogEntryExamplesRequestPayloadRT.encode({
data: {
dataset,
exampleCount,
@@ -35,13 +36,14 @@ export const callGetLogEntryRateExamplesAPI = async (
startTime,
endTime,
},
+ categoryId,
},
})
),
});
return pipe(
- getLogEntryRateExamplesSuccessReponsePayloadRT.decode(response),
+ getLogEntryExamplesSuccessReponsePayloadRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts
new file mode 100644
index 0000000000000..cadb4c420c133
--- /dev/null
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts
@@ -0,0 +1,262 @@
+/*
+ * 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, useState, useCallback, useEffect, useReducer } from 'react';
+
+import { LogEntryAnomaly } from '../../../../common/http_api';
+import { useTrackedPromise } from '../../../utils/use_tracked_promise';
+import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies';
+import { Sort, Pagination, PaginationCursor } from '../../../../common/http_api/log_analysis';
+
+export type SortOptions = Sort;
+export type PaginationOptions = Pick;
+export type Page = number;
+export type FetchNextPage = () => void;
+export type FetchPreviousPage = () => void;
+export type ChangeSortOptions = (sortOptions: Sort) => void;
+export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void;
+export type LogEntryAnomalies = LogEntryAnomaly[];
+interface PaginationCursors {
+ previousPageCursor: PaginationCursor;
+ nextPageCursor: PaginationCursor;
+}
+
+interface ReducerState {
+ page: number;
+ lastReceivedCursors: PaginationCursors | undefined;
+ paginationCursor: Pagination['cursor'] | undefined;
+ hasNextPage: boolean;
+ paginationOptions: PaginationOptions;
+ sortOptions: Sort;
+ timeRange: {
+ start: number;
+ end: number;
+ };
+}
+
+type ReducerStateDefaults = Pick<
+ ReducerState,
+ 'page' | 'lastReceivedCursors' | 'paginationCursor' | 'hasNextPage'
+>;
+
+type ReducerAction =
+ | { type: 'changePaginationOptions'; payload: { paginationOptions: PaginationOptions } }
+ | { type: 'changeSortOptions'; payload: { sortOptions: Sort } }
+ | { type: 'fetchNextPage' }
+ | { type: 'fetchPreviousPage' }
+ | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } }
+ | { type: 'changeLastReceivedCursors'; payload: { lastReceivedCursors: PaginationCursors } }
+ | { type: 'changeTimeRange'; payload: { timeRange: { start: number; end: number } } };
+
+const stateReducer = (state: ReducerState, action: ReducerAction): ReducerState => {
+ const resetPagination = {
+ page: 1,
+ paginationCursor: undefined,
+ };
+ switch (action.type) {
+ case 'changePaginationOptions':
+ return {
+ ...state,
+ ...resetPagination,
+ ...action.payload,
+ };
+ case 'changeSortOptions':
+ return {
+ ...state,
+ ...resetPagination,
+ ...action.payload,
+ };
+ case 'changeHasNextPage':
+ return {
+ ...state,
+ ...action.payload,
+ };
+ case 'changeLastReceivedCursors':
+ return {
+ ...state,
+ ...action.payload,
+ };
+ case 'fetchNextPage':
+ return state.lastReceivedCursors
+ ? {
+ ...state,
+ page: state.page + 1,
+ paginationCursor: { searchAfter: state.lastReceivedCursors.nextPageCursor },
+ }
+ : state;
+ case 'fetchPreviousPage':
+ return state.lastReceivedCursors
+ ? {
+ ...state,
+ page: state.page - 1,
+ paginationCursor: { searchBefore: state.lastReceivedCursors.previousPageCursor },
+ }
+ : state;
+ case 'changeTimeRange':
+ return {
+ ...state,
+ ...resetPagination,
+ ...action.payload,
+ };
+ default:
+ return state;
+ }
+};
+
+const STATE_DEFAULTS: ReducerStateDefaults = {
+ // NOTE: This piece of state is purely for the client side, it could be extracted out of the hook.
+ page: 1,
+ // Cursor from the last request
+ lastReceivedCursors: undefined,
+ // Cursor to use for the next request. For the first request, and therefore not paging, this will be undefined.
+ paginationCursor: undefined,
+ hasNextPage: false,
+};
+
+export const useLogEntryAnomaliesResults = ({
+ endTime,
+ startTime,
+ sourceId,
+ defaultSortOptions,
+ defaultPaginationOptions,
+}: {
+ endTime: number;
+ startTime: number;
+ sourceId: string;
+ defaultSortOptions: Sort;
+ defaultPaginationOptions: Pick;
+}) => {
+ const initStateReducer = (stateDefaults: ReducerStateDefaults): ReducerState => {
+ return {
+ ...stateDefaults,
+ paginationOptions: defaultPaginationOptions,
+ sortOptions: defaultSortOptions,
+ timeRange: {
+ start: startTime,
+ end: endTime,
+ },
+ };
+ };
+
+ const [reducerState, dispatch] = useReducer(stateReducer, STATE_DEFAULTS, initStateReducer);
+
+ const [logEntryAnomalies, setLogEntryAnomalies] = useState([]);
+
+ const [getLogEntryAnomaliesRequest, getLogEntryAnomalies] = useTrackedPromise(
+ {
+ cancelPreviousOn: 'creation',
+ createPromise: async () => {
+ const {
+ timeRange: { start: queryStartTime, end: queryEndTime },
+ sortOptions,
+ paginationOptions,
+ paginationCursor,
+ } = reducerState;
+ return await callGetLogEntryAnomaliesAPI(
+ sourceId,
+ queryStartTime,
+ queryEndTime,
+ sortOptions,
+ {
+ ...paginationOptions,
+ cursor: paginationCursor,
+ }
+ );
+ },
+ onResolve: ({ data: { anomalies, paginationCursors: requestCursors, hasMoreEntries } }) => {
+ const { paginationCursor } = reducerState;
+ if (requestCursors) {
+ dispatch({
+ type: 'changeLastReceivedCursors',
+ payload: { lastReceivedCursors: requestCursors },
+ });
+ }
+ // Check if we have more "next" entries. "Page" covers the "previous" scenario,
+ // since we need to know the page we're on anyway.
+ if (!paginationCursor || (paginationCursor && 'searchAfter' in paginationCursor)) {
+ dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: hasMoreEntries } });
+ } else if (paginationCursor && 'searchBefore' in paginationCursor) {
+ // We've requested a previous page, therefore there is a next page.
+ dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: true } });
+ }
+ setLogEntryAnomalies(anomalies);
+ },
+ },
+ [
+ sourceId,
+ dispatch,
+ reducerState.timeRange,
+ reducerState.sortOptions,
+ reducerState.paginationOptions,
+ reducerState.paginationCursor,
+ ]
+ );
+
+ const changeSortOptions = useCallback(
+ (nextSortOptions: Sort) => {
+ dispatch({ type: 'changeSortOptions', payload: { sortOptions: nextSortOptions } });
+ },
+ [dispatch]
+ );
+
+ const changePaginationOptions = useCallback(
+ (nextPaginationOptions: PaginationOptions) => {
+ dispatch({
+ type: 'changePaginationOptions',
+ payload: { paginationOptions: nextPaginationOptions },
+ });
+ },
+ [dispatch]
+ );
+
+ // Time range has changed
+ useEffect(() => {
+ dispatch({
+ type: 'changeTimeRange',
+ payload: { timeRange: { start: startTime, end: endTime } },
+ });
+ }, [startTime, endTime]);
+
+ useEffect(() => {
+ getLogEntryAnomalies();
+ }, [getLogEntryAnomalies]);
+
+ const handleFetchNextPage = useCallback(() => {
+ if (reducerState.lastReceivedCursors) {
+ dispatch({ type: 'fetchNextPage' });
+ }
+ }, [dispatch, reducerState]);
+
+ const handleFetchPreviousPage = useCallback(() => {
+ if (reducerState.lastReceivedCursors) {
+ dispatch({ type: 'fetchPreviousPage' });
+ }
+ }, [dispatch, reducerState]);
+
+ const isLoadingLogEntryAnomalies = useMemo(
+ () => getLogEntryAnomaliesRequest.state === 'pending',
+ [getLogEntryAnomaliesRequest.state]
+ );
+
+ const hasFailedLoadingLogEntryAnomalies = useMemo(
+ () => getLogEntryAnomaliesRequest.state === 'rejected',
+ [getLogEntryAnomaliesRequest.state]
+ );
+
+ return {
+ logEntryAnomalies,
+ getLogEntryAnomalies,
+ isLoadingLogEntryAnomalies,
+ hasFailedLoadingLogEntryAnomalies,
+ changeSortOptions,
+ sortOptions: reducerState.sortOptions,
+ changePaginationOptions,
+ paginationOptions: reducerState.paginationOptions,
+ fetchPreviousPage: reducerState.page > 1 ? handleFetchPreviousPage : undefined,
+ fetchNextPage: reducerState.hasNextPage ? handleFetchNextPage : undefined,
+ page: reducerState.page,
+ };
+};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts
new file mode 100644
index 0000000000000..fae5bd200a415
--- /dev/null
+++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts
@@ -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 { useMemo, useState } from 'react';
+
+import { LogEntryExample } from '../../../../common/http_api';
+import { useTrackedPromise } from '../../../utils/use_tracked_promise';
+import { callGetLogEntryExamplesAPI } from './service_calls/get_log_entry_examples';
+
+export const useLogEntryExamples = ({
+ dataset,
+ endTime,
+ exampleCount,
+ sourceId,
+ startTime,
+ categoryId,
+}: {
+ dataset: string;
+ endTime: number;
+ exampleCount: number;
+ sourceId: string;
+ startTime: number;
+ categoryId?: string;
+}) => {
+ const [logEntryExamples, setLogEntryExamples] = useState([]);
+
+ const [getLogEntryExamplesRequest, getLogEntryExamples] = useTrackedPromise(
+ {
+ cancelPreviousOn: 'creation',
+ createPromise: async () => {
+ return await callGetLogEntryExamplesAPI(
+ sourceId,
+ startTime,
+ endTime,
+ dataset,
+ exampleCount,
+ categoryId
+ );
+ },
+ onResolve: ({ data: { examples } }) => {
+ setLogEntryExamples(examples);
+ },
+ },
+ [dataset, endTime, exampleCount, sourceId, startTime]
+ );
+
+ const isLoadingLogEntryExamples = useMemo(() => getLogEntryExamplesRequest.state === 'pending', [
+ getLogEntryExamplesRequest.state,
+ ]);
+
+ const hasFailedLoadingLogEntryExamples = useMemo(
+ () => getLogEntryExamplesRequest.state === 'rejected',
+ [getLogEntryExamplesRequest.state]
+ );
+
+ return {
+ getLogEntryExamples,
+ hasFailedLoadingLogEntryExamples,
+ isLoadingLogEntryExamples,
+ logEntryExamples,
+ };
+};
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts
deleted file mode 100644
index 12bcdb2a4b4d6..0000000000000
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts
+++ /dev/null
@@ -1,63 +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, useState } from 'react';
-
-import { LogEntryRateExample } from '../../../../common/http_api';
-import { useTrackedPromise } from '../../../utils/use_tracked_promise';
-import { callGetLogEntryRateExamplesAPI } from './service_calls/get_log_entry_rate_examples';
-
-export const useLogEntryRateExamples = ({
- dataset,
- endTime,
- exampleCount,
- sourceId,
- startTime,
-}: {
- dataset: string;
- endTime: number;
- exampleCount: number;
- sourceId: string;
- startTime: number;
-}) => {
- const [logEntryRateExamples, setLogEntryRateExamples] = useState([]);
-
- const [getLogEntryRateExamplesRequest, getLogEntryRateExamples] = useTrackedPromise(
- {
- cancelPreviousOn: 'creation',
- createPromise: async () => {
- return await callGetLogEntryRateExamplesAPI(
- sourceId,
- startTime,
- endTime,
- dataset,
- exampleCount
- );
- },
- onResolve: ({ data: { examples } }) => {
- setLogEntryRateExamples(examples);
- },
- },
- [dataset, endTime, exampleCount, sourceId, startTime]
- );
-
- const isLoadingLogEntryRateExamples = useMemo(
- () => getLogEntryRateExamplesRequest.state === 'pending',
- [getLogEntryRateExamplesRequest.state]
- );
-
- const hasFailedLoadingLogEntryRateExamples = useMemo(
- () => getLogEntryRateExamplesRequest.state === 'rejected',
- [getLogEntryRateExamplesRequest.state]
- );
-
- return {
- getLogEntryRateExamples,
- hasFailedLoadingLogEntryRateExamples,
- isLoadingLogEntryRateExamples,
- logEntryRateExamples,
- };
-};
diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx
index c5047dbdf3bb5..426ae8e9d05a8 100644
--- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx
+++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx
@@ -42,10 +42,10 @@ export const LogsPageContent: React.FunctionComponent = () => {
pathname: '/stream',
};
- const logRateTab = {
+ const anomaliesTab = {
app: 'logs',
- title: logRateTabTitle,
- pathname: '/log-rate',
+ title: anomaliesTabTitle,
+ pathname: '/anomalies',
};
const logCategoriesTab = {
@@ -77,7 +77,7 @@ export const LogsPageContent: React.FunctionComponent = () => {
-
+
@@ -96,10 +96,11 @@ export const LogsPageContent: React.FunctionComponent = () => {
-
+
-
+
+
@@ -114,8 +115,8 @@ const streamTabTitle = i18n.translate('xpack.infra.logs.index.streamTabTitle', {
defaultMessage: 'Stream',
});
-const logRateTabTitle = i18n.translate('xpack.infra.logs.index.logRateBetaBadgeTitle', {
- defaultMessage: 'Log Rate',
+const anomaliesTabTitle = i18n.translate('xpack.infra.logs.index.anomaliesTabTitle', {
+ defaultMessage: 'Anomalies',
});
const logCategoriesTabTitle = i18n.translate('xpack.infra.logs.index.logCategoriesBetaBadgeTitle', {
diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
index 5a0a996287959..53f7e00a3354c 100644
--- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
+++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts
@@ -5,18 +5,17 @@
*/
import { encode } from 'rison-node';
-import { i18n } from '@kbn/i18n';
import { SearchResponse } from 'src/plugins/data/public';
-import { DEFAULT_SOURCE_ID } from '../../common/constants';
-import { InfraClientCoreSetup, InfraClientStartDeps } from '../types';
import {
FetchData,
- LogsFetchDataResponse,
- HasData,
FetchDataParams,
+ HasData,
+ LogsFetchDataResponse,
} from '../../../observability/public';
+import { DEFAULT_SOURCE_ID } from '../../common/constants';
import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration';
import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status';
+import { InfraClientCoreSetup, InfraClientStartDeps } from '../types';
interface StatsAggregation {
buckets: Array<{ key: string; doc_count: number }>;
@@ -69,15 +68,11 @@ export function getLogsOverviewDataFetcher(
data
);
- const timeSpanInMinutes =
- (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60);
+ const timeSpanInMinutes = (params.absoluteTime.end - params.absoluteTime.start) / (1000 * 60);
return {
- title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', {
- defaultMessage: 'Logs',
- }),
- appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode(
- params.startTime
+ appLink: `/app/logs/stream?logPosition=(end:${encode(params.relativeTime.end)},start:${encode(
+ params.relativeTime.start
)})`,
stats: normalizeStats(stats, timeSpanInMinutes),
series: normalizeSeries(series),
@@ -122,8 +117,8 @@ function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) {
return {
range: {
[logParams.timestampField]: {
- gt: params.startTime,
- lte: params.endTime,
+ gt: new Date(params.absoluteTime.start).toISOString(),
+ lte: new Date(params.absoluteTime.end).toISOString(),
format: 'strict_date_optional_time',
},
},
diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts
index 8af37a36ef745..6596e07ebaca5 100644
--- a/x-pack/plugins/infra/server/infra_server.ts
+++ b/x-pack/plugins/infra/server/infra_server.ts
@@ -15,9 +15,10 @@ import {
initGetLogEntryCategoryDatasetsRoute,
initGetLogEntryCategoryExamplesRoute,
initGetLogEntryRateRoute,
- initGetLogEntryRateExamplesRoute,
+ initGetLogEntryExamplesRoute,
initValidateLogAnalysisDatasetsRoute,
initValidateLogAnalysisIndicesRoute,
+ initGetLogEntryAnomaliesRoute,
} from './routes/log_analysis';
import { initMetricExplorerRoute } from './routes/metrics_explorer';
import { initMetadataRoute } from './routes/metadata';
@@ -51,13 +52,14 @@ export const initInfraServer = (libs: InfraBackendLibs) => {
initGetLogEntryCategoryDatasetsRoute(libs);
initGetLogEntryCategoryExamplesRoute(libs);
initGetLogEntryRateRoute(libs);
+ initGetLogEntryAnomaliesRoute(libs);
initSnapshotRoute(libs);
initNodeDetailsRoute(libs);
initSourceRoute(libs);
initValidateLogAnalysisDatasetsRoute(libs);
initValidateLogAnalysisIndicesRoute(libs);
initLogEntriesRoute(libs);
- initGetLogEntryRateExamplesRoute(libs);
+ initGetLogEntryExamplesRoute(libs);
initLogEntriesHighlightsRoute(libs);
initLogEntriesSummaryRoute(libs);
initLogEntriesSummaryHighlightsRoute(libs);
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts
new file mode 100644
index 0000000000000..0c0b0a0f19982
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/log_analysis/common.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 type { MlAnomalyDetectors } from '../../types';
+import { startTracingSpan } from '../../../common/performance_tracing';
+import { NoLogAnalysisMlJobError } from './errors';
+
+export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) {
+ const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES');
+ const {
+ jobs: [mlJob],
+ } = await mlAnomalyDetectors.jobs(jobId);
+
+ const mlGetJobSpan = finalizeMlGetJobSpan();
+
+ if (mlJob == null) {
+ throw new NoLogAnalysisMlJobError(`Failed to find ml job ${jobId}.`);
+ }
+
+ return {
+ mlJob,
+ timing: {
+ spans: [mlGetJobSpan],
+ },
+ };
+}
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts
index e07126416f4ce..09fee8844fbc5 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts
@@ -33,3 +33,10 @@ export class UnknownCategoryError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
}
+
+export class InsufficientAnomalyMlJobsConfigured extends Error {
+ constructor(message?: string) {
+ super(message);
+ Object.setPrototypeOf(this, new.target.prototype);
+ }
+}
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/index.ts
index 44c2bafce4194..c9a176be0a28f 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/index.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/index.ts
@@ -7,3 +7,4 @@
export * from './errors';
export * from './log_entry_categories_analysis';
export * from './log_entry_rate_analysis';
+export * from './log_entry_anomalies';
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts
new file mode 100644
index 0000000000000..12ae516564d66
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts
@@ -0,0 +1,398 @@
+/*
+ * 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 { RequestHandlerContext } from 'src/core/server';
+import { InfraRequestHandlerContext } from '../../types';
+import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing';
+import { fetchMlJob } from './common';
+import {
+ getJobId,
+ logEntryCategoriesJobTypes,
+ logEntryRateJobTypes,
+ jobCustomSettingsRT,
+} from '../../../common/log_analysis';
+import { Sort, Pagination } from '../../../common/http_api/log_analysis';
+import type { MlSystem } from '../../types';
+import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries';
+import {
+ InsufficientAnomalyMlJobsConfigured,
+ InsufficientLogAnalysisMlJobConfigurationError,
+ UnknownCategoryError,
+} from './errors';
+import { decodeOrThrow } from '../../../common/runtime_types';
+import {
+ createLogEntryExamplesQuery,
+ logEntryExamplesResponseRT,
+} from './queries/log_entry_examples';
+import { InfraSource } from '../sources';
+import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter';
+import { fetchLogEntryCategories } from './log_entry_categories_analysis';
+
+interface MappedAnomalyHit {
+ id: string;
+ anomalyScore: number;
+ dataset: string;
+ typical: number;
+ actual: number;
+ jobId: string;
+ startTime: number;
+ duration: number;
+ categoryId?: string;
+}
+
+export async function getLogEntryAnomalies(
+ context: RequestHandlerContext & { infra: Required },
+ sourceId: string,
+ startTime: number,
+ endTime: number,
+ sort: Sort,
+ pagination: Pagination
+) {
+ const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies');
+
+ const logRateJobId = getJobId(context.infra.spaceId, sourceId, logEntryRateJobTypes[0]);
+ const logCategoriesJobId = getJobId(
+ context.infra.spaceId,
+ sourceId,
+ logEntryCategoriesJobTypes[0]
+ );
+
+ const jobIds: string[] = [];
+ let jobSpans: TracingSpan[] = [];
+
+ try {
+ const {
+ timing: { spans },
+ } = await fetchMlJob(context.infra.mlAnomalyDetectors, logRateJobId);
+ jobIds.push(logRateJobId);
+ jobSpans = [...jobSpans, ...spans];
+ } catch (e) {
+ // Job wasn't found
+ }
+
+ try {
+ const {
+ timing: { spans },
+ } = await fetchMlJob(context.infra.mlAnomalyDetectors, logCategoriesJobId);
+ jobIds.push(logCategoriesJobId);
+ jobSpans = [...jobSpans, ...spans];
+ } catch (e) {
+ // Job wasn't found
+ }
+
+ if (jobIds.length === 0) {
+ throw new InsufficientAnomalyMlJobsConfigured(
+ 'Log rate or categorisation ML jobs need to be configured to search anomalies'
+ );
+ }
+
+ const {
+ anomalies,
+ paginationCursors,
+ hasMoreEntries,
+ timing: { spans: fetchLogEntryAnomaliesSpans },
+ } = await fetchLogEntryAnomalies(
+ context.infra.mlSystem,
+ jobIds,
+ startTime,
+ endTime,
+ sort,
+ pagination
+ );
+
+ const data = anomalies.map((anomaly) => {
+ const { jobId } = anomaly;
+
+ if (jobId === logRateJobId) {
+ return parseLogRateAnomalyResult(anomaly, logRateJobId);
+ } else {
+ return parseCategoryAnomalyResult(anomaly, logCategoriesJobId);
+ }
+ });
+
+ const logEntryAnomaliesSpan = finalizeLogEntryAnomaliesSpan();
+
+ return {
+ data,
+ paginationCursors,
+ hasMoreEntries,
+ timing: {
+ spans: [logEntryAnomaliesSpan, ...jobSpans, ...fetchLogEntryAnomaliesSpans],
+ },
+ };
+}
+
+const parseLogRateAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => {
+ const {
+ id,
+ anomalyScore,
+ dataset,
+ typical,
+ actual,
+ duration,
+ startTime: anomalyStartTime,
+ } = anomaly;
+
+ return {
+ id,
+ anomalyScore,
+ dataset,
+ typical,
+ actual,
+ duration,
+ startTime: anomalyStartTime,
+ type: 'logRate' as const,
+ jobId,
+ };
+};
+
+const parseCategoryAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => {
+ const {
+ id,
+ anomalyScore,
+ dataset,
+ typical,
+ actual,
+ duration,
+ startTime: anomalyStartTime,
+ categoryId,
+ } = anomaly;
+
+ return {
+ id,
+ anomalyScore,
+ dataset,
+ typical,
+ actual,
+ duration,
+ startTime: anomalyStartTime,
+ categoryId,
+ type: 'logCategory' as const,
+ jobId,
+ };
+};
+
+async function fetchLogEntryAnomalies(
+ mlSystem: MlSystem,
+ jobIds: string[],
+ startTime: number,
+ endTime: number,
+ sort: Sort,
+ pagination: Pagination
+) {
+ // We'll request 1 extra entry on top of our pageSize to determine if there are
+ // more entries to be fetched. This avoids scenarios where the client side can't
+ // determine if entries.length === pageSize actually means there are more entries / next page
+ // or not.
+ const expandedPagination = { ...pagination, pageSize: pagination.pageSize + 1 };
+
+ const finalizeFetchLogEntryAnomaliesSpan = startTracingSpan('fetch log entry anomalies');
+
+ const results = decodeOrThrow(logEntryAnomaliesResponseRT)(
+ await mlSystem.mlAnomalySearch(
+ createLogEntryAnomaliesQuery(jobIds, startTime, endTime, sort, expandedPagination)
+ )
+ );
+
+ const {
+ hits: { hits },
+ } = results;
+ const hasMoreEntries = hits.length > pagination.pageSize;
+
+ // An extra entry was found and hasMoreEntries has been determined, the extra entry can be removed.
+ if (hasMoreEntries) {
+ hits.pop();
+ }
+
+ // To "search_before" the sort order will have been reversed for ES.
+ // The results are now reversed back, to match the requested sort.
+ if (pagination.cursor && 'searchBefore' in pagination.cursor) {
+ hits.reverse();
+ }
+
+ const paginationCursors =
+ hits.length > 0
+ ? {
+ previousPageCursor: hits[0].sort,
+ nextPageCursor: hits[hits.length - 1].sort,
+ }
+ : undefined;
+
+ const anomalies = hits.map((result) => {
+ const {
+ job_id,
+ record_score: anomalyScore,
+ typical,
+ actual,
+ partition_field_value: dataset,
+ bucket_span: duration,
+ timestamp: anomalyStartTime,
+ by_field_value: categoryId,
+ } = result._source;
+
+ return {
+ id: result._id,
+ anomalyScore,
+ dataset,
+ typical: typical[0],
+ actual: actual[0],
+ jobId: job_id,
+ startTime: anomalyStartTime,
+ duration: duration * 1000,
+ categoryId,
+ };
+ });
+
+ const fetchLogEntryAnomaliesSpan = finalizeFetchLogEntryAnomaliesSpan();
+
+ return {
+ anomalies,
+ paginationCursors,
+ hasMoreEntries,
+ timing: {
+ spans: [fetchLogEntryAnomaliesSpan],
+ },
+ };
+}
+
+export async function getLogEntryExamples(
+ context: RequestHandlerContext & { infra: Required },
+ sourceId: string,
+ startTime: number,
+ endTime: number,
+ dataset: string,
+ exampleCount: number,
+ sourceConfiguration: InfraSource,
+ callWithRequest: KibanaFramework['callWithRequest'],
+ categoryId?: string
+) {
+ const finalizeLogEntryExamplesSpan = startTracingSpan('get log entry rate example log entries');
+
+ const jobId = getJobId(
+ context.infra.spaceId,
+ sourceId,
+ categoryId != null ? logEntryCategoriesJobTypes[0] : logEntryRateJobTypes[0]
+ );
+
+ const {
+ mlJob,
+ timing: { spans: fetchMlJobSpans },
+ } = await fetchMlJob(context.infra.mlAnomalyDetectors, jobId);
+
+ const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings);
+ const indices = customSettings?.logs_source_config?.indexPattern;
+ const timestampField = customSettings?.logs_source_config?.timestampField;
+ const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker;
+
+ if (indices == null || timestampField == null) {
+ throw new InsufficientLogAnalysisMlJobConfigurationError(
+ `Failed to find index configuration for ml job ${jobId}`
+ );
+ }
+
+ const {
+ examples,
+ timing: { spans: fetchLogEntryExamplesSpans },
+ } = await fetchLogEntryExamples(
+ context,
+ sourceId,
+ indices,
+ timestampField,
+ tiebreakerField,
+ startTime,
+ endTime,
+ dataset,
+ exampleCount,
+ callWithRequest,
+ categoryId
+ );
+
+ const logEntryExamplesSpan = finalizeLogEntryExamplesSpan();
+
+ return {
+ data: examples,
+ timing: {
+ spans: [logEntryExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryExamplesSpans],
+ },
+ };
+}
+
+export async function fetchLogEntryExamples(
+ context: RequestHandlerContext & { infra: Required },
+ sourceId: string,
+ indices: string,
+ timestampField: string,
+ tiebreakerField: string,
+ startTime: number,
+ endTime: number,
+ dataset: string,
+ exampleCount: number,
+ callWithRequest: KibanaFramework['callWithRequest'],
+ categoryId?: string
+) {
+ const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES');
+
+ let categoryQuery: string | undefined;
+
+ // Examples should be further scoped to a specific ML category
+ if (categoryId) {
+ const parsedCategoryId = parseInt(categoryId, 10);
+
+ const logEntryCategoriesCountJobId = getJobId(
+ context.infra.spaceId,
+ sourceId,
+ logEntryCategoriesJobTypes[0]
+ );
+
+ const { logEntryCategoriesById } = await fetchLogEntryCategories(
+ context,
+ logEntryCategoriesCountJobId,
+ [parsedCategoryId]
+ );
+
+ const category = logEntryCategoriesById[parsedCategoryId];
+
+ if (category == null) {
+ throw new UnknownCategoryError(parsedCategoryId);
+ }
+
+ categoryQuery = category._source.terms;
+ }
+
+ const {
+ hits: { hits },
+ } = decodeOrThrow(logEntryExamplesResponseRT)(
+ await callWithRequest(
+ context,
+ 'search',
+ createLogEntryExamplesQuery(
+ indices,
+ timestampField,
+ tiebreakerField,
+ startTime,
+ endTime,
+ dataset,
+ exampleCount,
+ categoryQuery
+ )
+ )
+ );
+
+ const esSearchSpan = finalizeEsSearchSpan();
+
+ return {
+ examples: hits.map((hit) => ({
+ id: hit._id,
+ dataset: hit._source.event?.dataset ?? '',
+ message: hit._source.message ?? '',
+ timestamp: hit.sort[0],
+ tiebreaker: hit.sort[1],
+ })),
+ timing: {
+ spans: [esSearchSpan],
+ },
+ };
+}
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts
index 4f244d724405e..6d00ba56e0e66 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts
@@ -17,7 +17,6 @@ import { decodeOrThrow } from '../../../common/runtime_types';
import type { MlAnomalyDetectors, MlSystem } from '../../types';
import {
InsufficientLogAnalysisMlJobConfigurationError,
- NoLogAnalysisMlJobError,
NoLogAnalysisResultsIndexError,
UnknownCategoryError,
} from './errors';
@@ -45,6 +44,7 @@ import {
topLogEntryCategoriesResponseRT,
} from './queries/top_log_entry_categories';
import { InfraSource } from '../sources';
+import { fetchMlJob } from './common';
const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000;
@@ -213,7 +213,7 @@ export async function getLogEntryCategoryExamples(
const {
mlJob,
timing: { spans: fetchMlJobSpans },
- } = await fetchMlJob(context, logEntryCategoriesCountJobId);
+ } = await fetchMlJob(context.infra.mlAnomalyDetectors, logEntryCategoriesCountJobId);
const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings);
const indices = customSettings?.logs_source_config?.indexPattern;
@@ -330,7 +330,7 @@ async function fetchTopLogEntryCategories(
};
}
-async function fetchLogEntryCategories(
+export async function fetchLogEntryCategories(
context: { infra: { mlSystem: MlSystem } },
logEntryCategoriesCountJobId: string,
categoryIds: number[]
@@ -452,30 +452,6 @@ async function fetchTopLogEntryCategoryHistograms(
};
}
-async function fetchMlJob(
- context: { infra: { mlAnomalyDetectors: MlAnomalyDetectors } },
- logEntryCategoriesCountJobId: string
-) {
- const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES');
-
- const {
- jobs: [mlJob],
- } = await context.infra.mlAnomalyDetectors.jobs(logEntryCategoriesCountJobId);
-
- const mlGetJobSpan = finalizeMlGetJobSpan();
-
- if (mlJob == null) {
- throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`);
- }
-
- return {
- mlJob,
- timing: {
- spans: [mlGetJobSpan],
- },
- };
-}
-
async function fetchLogEntryCategoryExamples(
requestContext: { core: { elasticsearch: { legacy: { client: ILegacyScopedClusterClient } } } },
indices: string,
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts
index 290cf03b67365..0323980dcd013 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts
@@ -7,7 +7,6 @@
import { pipe } from 'fp-ts/lib/pipeable';
import { map, fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
-import { RequestHandlerContext } from 'src/core/server';
import { throwErrors, createPlainError } from '../../../common/runtime_types';
import {
logRateModelPlotResponseRT,
@@ -15,22 +14,9 @@ import {
LogRateModelPlotBucket,
CompositeTimestampPartitionKey,
} from './queries';
-import { startTracingSpan } from '../../../common/performance_tracing';
-import { decodeOrThrow } from '../../../common/runtime_types';
-import { getJobId, jobCustomSettingsRT } from '../../../common/log_analysis';
-import {
- createLogEntryRateExamplesQuery,
- logEntryRateExamplesResponseRT,
-} from './queries/log_entry_rate_examples';
-import {
- InsufficientLogAnalysisMlJobConfigurationError,
- NoLogAnalysisMlJobError,
- NoLogAnalysisResultsIndexError,
-} from './errors';
-import { InfraSource } from '../sources';
+import { getJobId } from '../../../common/log_analysis';
+import { NoLogAnalysisResultsIndexError } from './errors';
import type { MlSystem } from '../../types';
-import { InfraRequestHandlerContext } from '../../types';
-import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter';
const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000;
@@ -143,130 +129,3 @@ export async function getLogEntryRateBuckets(
}
}, []);
}
-
-export async function getLogEntryRateExamples(
- context: RequestHandlerContext & { infra: Required },
- sourceId: string,
- startTime: number,
- endTime: number,
- dataset: string,
- exampleCount: number,
- sourceConfiguration: InfraSource,
- callWithRequest: KibanaFramework['callWithRequest']
-) {
- const finalizeLogEntryRateExamplesSpan = startTracingSpan(
- 'get log entry rate example log entries'
- );
-
- const jobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate');
-
- const {
- mlJob,
- timing: { spans: fetchMlJobSpans },
- } = await fetchMlJob(context, jobId);
-
- const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings);
- const indices = customSettings?.logs_source_config?.indexPattern;
- const timestampField = customSettings?.logs_source_config?.timestampField;
- const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker;
-
- if (indices == null || timestampField == null) {
- throw new InsufficientLogAnalysisMlJobConfigurationError(
- `Failed to find index configuration for ml job ${jobId}`
- );
- }
-
- const {
- examples,
- timing: { spans: fetchLogEntryRateExamplesSpans },
- } = await fetchLogEntryRateExamples(
- context,
- indices,
- timestampField,
- tiebreakerField,
- startTime,
- endTime,
- dataset,
- exampleCount,
- callWithRequest
- );
-
- const logEntryRateExamplesSpan = finalizeLogEntryRateExamplesSpan();
-
- return {
- data: examples,
- timing: {
- spans: [logEntryRateExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryRateExamplesSpans],
- },
- };
-}
-
-export async function fetchLogEntryRateExamples(
- context: RequestHandlerContext & { infra: Required },
- indices: string,
- timestampField: string,
- tiebreakerField: string,
- startTime: number,
- endTime: number,
- dataset: string,
- exampleCount: number,
- callWithRequest: KibanaFramework['callWithRequest']
-) {
- const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES');
-
- const {
- hits: { hits },
- } = decodeOrThrow(logEntryRateExamplesResponseRT)(
- await callWithRequest(
- context,
- 'search',
- createLogEntryRateExamplesQuery(
- indices,
- timestampField,
- tiebreakerField,
- startTime,
- endTime,
- dataset,
- exampleCount
- )
- )
- );
-
- const esSearchSpan = finalizeEsSearchSpan();
-
- return {
- examples: hits.map((hit) => ({
- id: hit._id,
- dataset,
- message: hit._source.message ?? '',
- timestamp: hit.sort[0],
- tiebreaker: hit.sort[1],
- })),
- timing: {
- spans: [esSearchSpan],
- },
- };
-}
-
-async function fetchMlJob(
- context: RequestHandlerContext & { infra: Required },
- logEntryRateJobId: string
-) {
- const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES');
- const {
- jobs: [mlJob],
- } = await context.infra.mlAnomalyDetectors.jobs(logEntryRateJobId);
-
- const mlGetJobSpan = finalizeMlGetJobSpan();
-
- if (mlJob == null) {
- throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryRateJobId}.`);
- }
-
- return {
- mlJob,
- timing: {
- spans: [mlGetJobSpan],
- },
- };
-}
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts
index eacf29b303db0..87394028095de 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts
@@ -21,6 +21,14 @@ export const createJobIdFilters = (jobId: string) => [
},
];
+export const createJobIdsFilters = (jobIds: string[]) => [
+ {
+ terms: {
+ job_id: jobIds,
+ },
+ },
+];
+
export const createTimeRangeFilters = (startTime: number, endTime: number) => [
{
range: {
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts
index 8c470acbf02fb..792c5bf98b538 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts
@@ -6,3 +6,4 @@
export * from './log_entry_rate';
export * from './top_log_entry_categories';
+export * from './log_entry_anomalies';
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts
new file mode 100644
index 0000000000000..fc72776ea5cac
--- /dev/null
+++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts
@@ -0,0 +1,128 @@
+/*
+ * 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 * as rt from 'io-ts';
+import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types';
+import {
+ createJobIdsFilters,
+ createTimeRangeFilters,
+ createResultTypeFilters,
+ defaultRequestParameters,
+} from './common';
+import { Sort, Pagination } from '../../../../common/http_api/log_analysis';
+
+// TODO: Reassess validity of this against ML docs
+const TIEBREAKER_FIELD = '_doc';
+
+const sortToMlFieldMap = {
+ dataset: 'partition_field_value',
+ anomalyScore: 'record_score',
+ startTime: 'timestamp',
+};
+
+export const createLogEntryAnomaliesQuery = (
+ jobIds: string[],
+ startTime: number,
+ endTime: number,
+ sort: Sort,
+ pagination: Pagination
+) => {
+ const { field } = sort;
+ const { pageSize } = pagination;
+
+ const filters = [
+ ...createJobIdsFilters(jobIds),
+ ...createTimeRangeFilters(startTime, endTime),
+ ...createResultTypeFilters(['record']),
+ ];
+
+ const sourceFields = [
+ 'job_id',
+ 'record_score',
+ 'typical',
+ 'actual',
+ 'partition_field_value',
+ 'timestamp',
+ 'bucket_span',
+ 'by_field_value',
+ ];
+
+ const { querySortDirection, queryCursor } = parsePaginationCursor(sort, pagination);
+
+ const sortOptions = [
+ { [sortToMlFieldMap[field]]: querySortDirection },
+ { [TIEBREAKER_FIELD]: querySortDirection }, // Tiebreaker
+ ];
+
+ const resultsQuery = {
+ ...defaultRequestParameters,
+ body: {
+ query: {
+ bool: {
+ filter: filters,
+ },
+ },
+ search_after: queryCursor,
+ sort: sortOptions,
+ size: pageSize,
+ _source: sourceFields,
+ },
+ };
+
+ return resultsQuery;
+};
+
+export const logEntryAnomalyHitRT = rt.type({
+ _id: rt.string,
+ _source: rt.intersection([
+ rt.type({
+ job_id: rt.string,
+ record_score: rt.number,
+ typical: rt.array(rt.number),
+ actual: rt.array(rt.number),
+ partition_field_value: rt.string,
+ bucket_span: rt.number,
+ timestamp: rt.number,
+ }),
+ rt.partial({
+ by_field_value: rt.string,
+ }),
+ ]),
+ sort: rt.tuple([rt.union([rt.string, rt.number]), rt.union([rt.string, rt.number])]),
+});
+
+export type LogEntryAnomalyHit = rt.TypeOf;
+
+export const logEntryAnomaliesResponseRT = rt.intersection([
+ commonSearchSuccessResponseFieldsRT,
+ rt.type({
+ hits: rt.type({
+ hits: rt.array(logEntryAnomalyHitRT),
+ }),
+ }),
+]);
+
+export type LogEntryAnomaliesResponseRT = rt.TypeOf;
+
+const parsePaginationCursor = (sort: Sort, pagination: Pagination) => {
+ const { cursor } = pagination;
+ const { direction } = sort;
+
+ if (!cursor) {
+ return { querySortDirection: direction, queryCursor: undefined };
+ }
+
+ // We will always use ES's search_after to paginate, to mimic "search_before" behaviour we
+ // need to reverse the user's chosen search direction for the ES query.
+ if ('searchBefore' in cursor) {
+ return {
+ querySortDirection: direction === 'desc' ? 'asc' : 'desc',
+ queryCursor: cursor.searchBefore,
+ };
+ } else {
+ return { querySortDirection: direction, queryCursor: cursor.searchAfter };
+ }
+};
diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts
similarity index 59%
rename from x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts
rename to x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts
index ef06641caf797..74a664e78dcd6 100644
--- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts
+++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts
@@ -10,14 +10,15 @@ import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearc
import { defaultRequestParameters } from './common';
import { partitionField } from '../../../../common/log_analysis';
-export const createLogEntryRateExamplesQuery = (
+export const createLogEntryExamplesQuery = (
indices: string,
timestampField: string,
tiebreakerField: string,
startTime: number,
endTime: number,
dataset: string,
- exampleCount: number
+ exampleCount: number,
+ categoryQuery?: string
) => ({
...defaultRequestParameters,
body: {
@@ -32,11 +33,27 @@ export const createLogEntryRateExamplesQuery = (
},
},
},
- {
- term: {
- [partitionField]: dataset,
- },
- },
+ ...(!!dataset
+ ? [
+ {
+ term: {
+ [partitionField]: dataset,
+ },
+ },
+ ]
+ : []),
+ ...(categoryQuery
+ ? [
+ {
+ match: {
+ message: {
+ query: categoryQuery,
+ operator: 'AND',
+ },
+ },
+ },
+ ]
+ : []),
],
},
},
@@ -47,7 +64,7 @@ export const createLogEntryRateExamplesQuery = (
size: exampleCount,
});
-export const logEntryRateExampleHitRT = rt.type({
+export const logEntryExampleHitRT = rt.type({
_id: rt.string,
_source: rt.partial({
event: rt.partial({
@@ -58,15 +75,15 @@ export const logEntryRateExampleHitRT = rt.type({
sort: rt.tuple([rt.number, rt.number]),
});
-export type LogEntryRateExampleHit = rt.TypeOf;
+export type LogEntryExampleHit = rt.TypeOf;
-export const logEntryRateExamplesResponseRT = rt.intersection([
+export const logEntryExamplesResponseRT = rt.intersection([
commonSearchSuccessResponseFieldsRT,
rt.type({
hits: rt.type({
- hits: rt.array(logEntryRateExampleHitRT),
+ hits: rt.array(logEntryExampleHitRT),
}),
}),
]);
-export type LogEntryRateExamplesResponse = rt.TypeOf;
+export type LogEntryExamplesResponse = rt.TypeOf;
diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts
index 30b6be435837b..cbd89db97236f 100644
--- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts
+++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts
@@ -8,4 +8,5 @@ export * from './log_entry_categories';
export * from './log_entry_category_datasets';
export * from './log_entry_category_examples';
export * from './log_entry_rate';
-export * from './log_entry_rate_examples';
+export * from './log_entry_examples';
+export * from './log_entry_anomalies';
diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts
new file mode 100644
index 0000000000000..f4911658ea496
--- /dev/null
+++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.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 Boom from 'boom';
+import { InfraBackendLibs } from '../../../lib/infra_types';
+import {
+ LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH,
+ getLogEntryAnomaliesSuccessReponsePayloadRT,
+ getLogEntryAnomaliesRequestPayloadRT,
+ GetLogEntryAnomaliesRequestPayload,
+ Sort,
+ Pagination,
+} from '../../../../common/http_api/log_analysis';
+import { createValidationFunction } from '../../../../common/runtime_types';
+import { assertHasInfraMlPlugins } from '../../../utils/request_context';
+import { getLogEntryAnomalies } from '../../../lib/log_analysis';
+
+export const initGetLogEntryAnomaliesRoute = ({ framework }: InfraBackendLibs) => {
+ framework.registerRoute(
+ {
+ method: 'post',
+ path: LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH,
+ validate: {
+ body: createValidationFunction(getLogEntryAnomaliesRequestPayloadRT),
+ },
+ },
+ framework.router.handleLegacyErrors(async (requestContext, request, response) => {
+ const {
+ data: {
+ sourceId,
+ timeRange: { startTime, endTime },
+ sort: sortParam,
+ pagination: paginationParam,
+ },
+ } = request.body;
+
+ const { sort, pagination } = getSortAndPagination(sortParam, paginationParam);
+
+ try {
+ assertHasInfraMlPlugins(requestContext);
+
+ const {
+ data: logEntryAnomalies,
+ paginationCursors,
+ hasMoreEntries,
+ timing,
+ } = await getLogEntryAnomalies(
+ requestContext,
+ sourceId,
+ startTime,
+ endTime,
+ sort,
+ pagination
+ );
+
+ return response.ok({
+ body: getLogEntryAnomaliesSuccessReponsePayloadRT.encode({
+ data: {
+ anomalies: logEntryAnomalies,
+ hasMoreEntries,
+ paginationCursors,
+ },
+ timing,
+ }),
+ });
+ } catch (error) {
+ if (Boom.isBoom(error)) {
+ throw error;
+ }
+
+ return response.customError({
+ statusCode: error.statusCode ?? 500,
+ body: {
+ message: error.message ?? 'An unexpected error occurred',
+ },
+ });
+ }
+ })
+ );
+};
+
+const getSortAndPagination = (
+ sort: Partial = {},
+ pagination: Partial = {}
+): {
+ sort: Sort;
+ pagination: Pagination;
+} => {
+ const sortDefaults = {
+ field: 'anomalyScore' as const,
+ direction: 'desc' as const,
+ };
+
+ const sortWithDefaults = {
+ ...sortDefaults,
+ ...sort,
+ };
+
+ const paginationDefaults = {
+ pageSize: 50,
+ };
+
+ const paginationWithDefaults = {
+ ...paginationDefaults,
+ ...pagination,
+ };
+
+ return { sort: sortWithDefaults, pagination: paginationWithDefaults };
+};
diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts
similarity index 75%
rename from x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts
rename to x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts
index b8ebcc66911dc..be4caee769506 100644
--- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts
+++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts
@@ -7,21 +7,21 @@
import Boom from 'boom';
import { createValidationFunction } from '../../../../common/runtime_types';
import { InfraBackendLibs } from '../../../lib/infra_types';
-import { NoLogAnalysisResultsIndexError, getLogEntryRateExamples } from '../../../lib/log_analysis';
+import { NoLogAnalysisResultsIndexError, getLogEntryExamples } from '../../../lib/log_analysis';
import { assertHasInfraMlPlugins } from '../../../utils/request_context';
import {
- getLogEntryRateExamplesRequestPayloadRT,
- getLogEntryRateExamplesSuccessReponsePayloadRT,
+ getLogEntryExamplesRequestPayloadRT,
+ getLogEntryExamplesSuccessReponsePayloadRT,
LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH,
} from '../../../../common/http_api/log_analysis';
-export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBackendLibs) => {
+export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'post',
path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH,
validate: {
- body: createValidationFunction(getLogEntryRateExamplesRequestPayloadRT),
+ body: createValidationFunction(getLogEntryExamplesRequestPayloadRT),
},
},
framework.router.handleLegacyErrors(async (requestContext, request, response) => {
@@ -31,6 +31,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa
exampleCount,
sourceId,
timeRange: { startTime, endTime },
+ categoryId,
},
} = request.body;
@@ -42,7 +43,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa
try {
assertHasInfraMlPlugins(requestContext);
- const { data: logEntryRateExamples, timing } = await getLogEntryRateExamples(
+ const { data: logEntryExamples, timing } = await getLogEntryExamples(
requestContext,
sourceId,
startTime,
@@ -50,13 +51,14 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa
dataset,
exampleCount,
sourceConfiguration,
- framework.callWithRequest
+ framework.callWithRequest,
+ categoryId
);
return response.ok({
- body: getLogEntryRateExamplesSuccessReponsePayloadRT.encode({
+ body: getLogEntryExamplesSuccessReponsePayloadRT.encode({
data: {
- examples: logEntryRateExamples,
+ examples: logEntryExamples,
},
timing,
}),
diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md
index eebafc76a5e00..1a19672331035 100644
--- a/x-pack/plugins/ingest_manager/README.md
+++ b/x-pack/plugins/ingest_manager/README.md
@@ -4,11 +4,11 @@
- The plugin is disabled by default. See the TypeScript type for the [the available plugin configuration options](https://github.com/elastic/kibana/blob/master/x-pack/plugins/ingest_manager/common/types/index.ts#L9-L27)
- Setting `xpack.ingestManager.enabled=true` enables the plugin including the EPM and Fleet features. It also adds the `PACKAGE_CONFIG_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts)
-- Adding `--xpack.ingestManager.epm.enabled=false` will disable the EPM API & UI
- Adding `--xpack.ingestManager.fleet.enabled=false` will disable the Fleet API & UI
- [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133)
- [Integration tests](server/integration_tests/router.test.ts)
- Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features.
+- For Gold+ license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080`
## Fleet Requirements
diff --git a/x-pack/plugins/ingest_manager/common/mocks.ts b/x-pack/plugins/ingest_manager/common/mocks.ts
index 131917af44595..e85364f2bb672 100644
--- a/x-pack/plugins/ingest_manager/common/mocks.ts
+++ b/x-pack/plugins/ingest_manager/common/mocks.ts
@@ -6,7 +6,7 @@
import { NewPackageConfig, PackageConfig } from './types/models/package_config';
-export const createNewPackageConfigMock = () => {
+export const createNewPackageConfigMock = (): NewPackageConfig => {
return {
name: 'endpoint-1',
description: '',
@@ -20,10 +20,10 @@ export const createNewPackageConfigMock = () => {
version: '0.9.0',
},
inputs: [],
- } as NewPackageConfig;
+ };
};
-export const createPackageConfigMock = () => {
+export const createPackageConfigMock = (): PackageConfig => {
const newPackageConfig = createNewPackageConfigMock();
return {
...newPackageConfig,
@@ -37,7 +37,10 @@ export const createPackageConfigMock = () => {
inputs: [
{
config: {},
+ enabled: true,
+ type: 'endpoint',
+ streams: [],
},
],
- } as PackageConfig;
+ };
};
diff --git a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts
index 7e03e4572f9ee..1fb6fead454ef 100644
--- a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts
+++ b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts
@@ -12,7 +12,7 @@ const CONFIG_KEYS_ORDER = [
'revision',
'type',
'outputs',
- 'settings',
+ 'agent',
'inputs',
'enabled',
'use_output',
diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts
index ff08b8a925204..0fce5cfa6226f 100644
--- a/x-pack/plugins/ingest_manager/common/types/index.ts
+++ b/x-pack/plugins/ingest_manager/common/types/index.ts
@@ -8,10 +8,7 @@ export * from './rest_spec';
export interface IngestManagerConfigType {
enabled: boolean;
- epm: {
- enabled: boolean;
- registryUrl?: string;
- };
+ registryUrl?: string;
fleet: {
enabled: boolean;
tlsCheckDisabled: boolean;
diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
index a6040742e45fc..00ba51fc1843a 100644
--- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts
@@ -62,7 +62,7 @@ export interface FullAgentConfig {
};
inputs: FullAgentConfigInput[];
revision?: number;
- settings?: {
+ agent?: {
monitoring: {
use_output?: string;
enabled: boolean;
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx
index 1e7a14e350229..03c70f71529c9 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx
@@ -38,50 +38,34 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => {
-
-
-
- ),
- forumLink: (
-
-
-
- ),
- }}
- />
-
-
+ docsLink: (
+
+
+
+ ),
+ forumLink: (
+
-
+
),
}}
/>
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
index f43419fc52ef0..ca4dfcb685e7b 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx
@@ -28,17 +28,20 @@ export const AlphaMessaging: React.FC<{}> = () => {
{' – '}
{' '}
setIsAlphaFlyoutOpen(true)}>
- View more details.
+
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx
index 94d3379f35e05..0eaf785405590 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx
@@ -59,7 +59,7 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => (
const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>(
({ history, ...rest }) => {
- const { epm, fleet } = useConfig();
+ const { fleet } = useConfig();
const { notifications } = useCore();
const [isPermissionsLoading, setIsPermissionsLoading] = useState(false);
@@ -186,11 +186,11 @@ const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basep
-
+
-
+
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
index 1f356301b714a..09da96fac4462 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx
@@ -41,7 +41,7 @@ export const DefaultLayout: React.FunctionComponent = ({
children,
}) => {
const { getHref } = useLink();
- const { epm, fleet } = useConfig();
+ const { fleet } = useConfig();
const { uiSettings } = useCore();
const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false);
@@ -71,11 +71,7 @@ export const DefaultLayout: React.FunctionComponent = ({
defaultMessage="Overview"
/>
-
+
) : null}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
index 795c46ec282c5..37fce340da6ea 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
+import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
@@ -17,16 +18,24 @@ import {
EuiButtonEmpty,
EuiButton,
EuiText,
+ EuiFlyoutProps,
} from '@elastic/eui';
import { NewAgentConfig, AgentConfig } from '../../../../types';
import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks';
import { AgentConfigForm, agentConfigFormValidation } from '../../components';
-interface Props {
+const FlyoutWithHigherZIndex = styled(EuiFlyout)`
+ z-index: ${(props) => props.theme.eui.euiZLevel5};
+`;
+
+interface Props extends EuiFlyoutProps {
onClose: (createdAgentConfig?: AgentConfig) => void;
}
-export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => {
+export const CreateAgentConfigFlyout: React.FunctionComponent = ({
+ onClose,
+ ...restOfProps
+}) => {
const { notifications } = useCore();
const hasWriteCapabilites = useCapabilities().write;
const [agentConfig, setAgentConfig] = useState({
@@ -147,10 +156,10 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos
);
return (
-
+
{header}
{body}
{footer}
-
+
);
};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx
index cb0664143bb34..f15b7d7f182a8 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx
@@ -7,16 +7,15 @@
import React from 'react';
import { HashRouter as Router, Switch, Route } from 'react-router-dom';
import { PAGE_ROUTING_PATHS } from '../../constants';
-import { useConfig, useBreadcrumbs } from '../../hooks';
+import { useBreadcrumbs } from '../../hooks';
import { CreatePackageConfigPage } from '../agent_config/create_package_config_page';
import { EPMHomePage } from './screens/home';
import { Detail } from './screens/detail';
export const EPMApp: React.FunctionComponent = () => {
useBreadcrumbs('integrations');
- const { epm } = useConfig();
- return epm.enabled ? (
+ return (
@@ -30,5 +29,5 @@ export const EPMApp: React.FunctionComponent = () => {
- ) : null;
+ );
};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx
index 15086879ce80b..ae9b1e1f6f433 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx
@@ -86,7 +86,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => {
>
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx
index 6f53a237187e5..09b00240dc127 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx
@@ -13,7 +13,7 @@ import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks';
import { AgentConfigPackageBadges } from '../agent_config_package_badges';
type Props = {
- agentConfigs: AgentConfig[];
+ agentConfigs?: AgentConfig[];
onConfigChange?: (key: string) => void;
} & (
| {
@@ -37,9 +37,16 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => {
const [selectedState, setSelectedState] = useState<{
agentConfigId?: string;
enrollmentAPIKeyId?: string;
- }>({
- agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined,
- });
+ }>({});
+
+ useEffect(() => {
+ if (agentConfigs && agentConfigs.length && !selectedState.agentConfigId) {
+ setSelectedState({
+ ...selectedState,
+ agentConfigId: agentConfigs[0].id,
+ });
+ }
+ }, [agentConfigs, selectedState]);
useEffect(() => {
if (onConfigChange && selectedState.agentConfigId) {
@@ -110,7 +117,8 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => {
/>
}
- options={agentConfigs.map((config) => ({
+ isLoading={!agentConfigs}
+ options={(agentConfigs || []).map((config) => ({
value: config.id,
text: config.name,
}))}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx
index 5a9d3b7efe1bb..2c66001cc8c08 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx
@@ -24,12 +24,12 @@ import { StandaloneInstructions } from './standalone_instructions';
interface Props {
onClose: () => void;
- agentConfigs: AgentConfig[];
+ agentConfigs?: AgentConfig[];
}
export const AgentEnrollmentFlyout: React.FunctionComponent = ({
onClose,
- agentConfigs = [],
+ agentConfigs,
}) => {
const [mode, setMode] = useState<'managed' | 'standalone'>('managed');
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx
index aabbd37e809a8..eefb7f1bb7b5f 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx
@@ -21,10 +21,10 @@ import { ManualInstructions } from '../../../../components/enrollment_instructio
import { DownloadStep, AgentConfigSelectionStep } from './steps';
interface Props {
- agentConfigs: AgentConfig[];
+ agentConfigs?: AgentConfig[];
}
-export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => {
+export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs }) => {
const { getHref } = useLink();
const core = useCore();
const fleetStatus = useFleetStatus();
@@ -85,7 +85,7 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentConfi
}}
/>
>
- )}{' '}
+ )}
>
);
};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx
index 27f64059deb84..d5f79563f33c4 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx
@@ -25,12 +25,12 @@ import { DownloadStep, AgentConfigSelectionStep } from './steps';
import { configToYaml, agentConfigRouteService } from '../../../../services';
interface Props {
- agentConfigs: AgentConfig[];
+ agentConfigs?: AgentConfig[];
}
const RUN_INSTRUCTIONS = './elastic-agent run';
-export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => {
+export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs }) => {
const core = useCore();
const { notifications } = core;
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx
index 267f9027a094a..d01e207169920 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx
@@ -46,7 +46,7 @@ export const AgentConfigSelectionStep = ({
setSelectedAPIKeyId,
setSelectedConfigId,
}: {
- agentConfigs: AgentConfig[];
+ agentConfigs?: AgentConfig[];
setSelectedAPIKeyId?: (key: string) => void;
setSelectedConfigId?: (configId: string) => void;
}) => {
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx
index 6e61a55466e87..7e33589bffea1 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx
@@ -5,13 +5,13 @@
*/
import React from 'react';
-import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
import {
- EuiTitle,
- EuiButtonEmpty,
+ EuiI18nNumber,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
+ EuiFlexItem,
} from '@elastic/eui';
import { OverviewPanel } from './overview_panel';
import { OverviewStats } from './overview_stats';
@@ -24,23 +24,19 @@ export const OverviewAgentSection = () => {
return (
-
-
+
{agentStatusRequest.isLoading ? (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx
index 5a5e901d629b5..56aaba1d43321 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx
@@ -5,11 +5,11 @@
*/
import React from 'react';
-import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
import {
- EuiTitle,
- EuiButtonEmpty,
+ EuiFlexItem,
+ EuiI18nNumber,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
@@ -30,23 +30,18 @@ export const OverviewConfigurationSection: React.FC<{ agentConfigs: AgentConfig[
return (
-
-
+
{packageConfigsRequest.isLoading ? (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx
index eab6cf087e127..41c011de2da5c 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx
@@ -5,11 +5,11 @@
*/
import React from 'react';
-import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
import {
- EuiTitle,
- EuiButtonEmpty,
+ EuiFlexItem,
+ EuiI18nNumber,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
@@ -45,23 +45,18 @@ export const OverviewDatastreamSection: React.FC = () => {
return (
-
-
+
{datastreamRequest.isLoading ? (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx
index b4669b0a0569b..ba16b47e73051 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx
@@ -5,11 +5,11 @@
*/
import React from 'react';
-import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
import {
- EuiTitle,
- EuiButtonEmpty,
+ EuiFlexItem,
+ EuiI18nNumber,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
@@ -31,23 +31,19 @@ export const OverviewIntegrationSection: React.FC = () => {
)?.length ?? 0;
return (
-
-
+
{packagesRequest.isLoading ? (
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx
index 2e75d1e4690d6..65811261a6d6b 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx
@@ -4,10 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import React from 'react';
import styled from 'styled-components';
-import { EuiPanel } from '@elastic/eui';
+import {
+ EuiPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiTitle,
+ EuiIconTip,
+ EuiButtonEmpty,
+} from '@elastic/eui';
-export const OverviewPanel = styled(EuiPanel).attrs((props) => ({
+const StyledPanel = styled(EuiPanel).attrs((props) => ({
paddingSize: 'm',
}))`
header {
@@ -26,3 +34,40 @@ export const OverviewPanel = styled(EuiPanel).attrs((props) => ({
padding: ${(props) => props.theme.eui.paddingSizes.xs} 0;
}
`;
+
+interface OverviewPanelProps {
+ title: string;
+ tooltip: string;
+ linkToText: string;
+ linkTo: string;
+ children: React.ReactNode;
+}
+
+export const OverviewPanel = ({
+ title,
+ tooltip,
+ linkToText,
+ linkTo,
+ children,
+}: OverviewPanelProps) => {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {linkToText}
+
+
+ {children}
+
+ );
+};
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
index ca4151fa5c46f..f4b68f0c5107e 100644
--- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
+++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx
@@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
-import styled from 'styled-components';
import {
EuiButton,
EuiBetaBadge,
EuiText,
+ EuiTitle,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
@@ -23,11 +23,6 @@ import { OverviewConfigurationSection } from './components/configuration_section
import { OverviewIntegrationSection } from './components/integration_section';
import { OverviewDatastreamSection } from './components/datastream_section';
-const AlphaBadge = styled(EuiBetaBadge)`
- vertical-align: top;
- margin-left: ${(props) => props.theme.eui.paddingSizes.s};
-`;
-
export const IngestManagerOverview: React.FunctionComponent = () => {
useBreadcrumbs('overview');
@@ -46,26 +41,30 @@ export const IngestManagerOverview: React.FunctionComponent = () => {
leftColumn={
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
@@ -102,9 +101,7 @@ export const IngestManagerOverview: React.FunctionComponent = () => {
-
-
diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts
index 811ec8a3d0222..1823cc3561693 100644
--- a/x-pack/plugins/ingest_manager/server/index.ts
+++ b/x-pack/plugins/ingest_manager/server/index.ts
@@ -21,10 +21,7 @@ export const config = {
},
schema: schema.object({
enabled: schema.boolean({ defaultValue: false }),
- epm: schema.object({
- enabled: schema.boolean({ defaultValue: true }),
- registryUrl: schema.maybe(schema.uri()),
- }),
+ registryUrl: schema.maybe(schema.uri()),
fleet: schema.object({
enabled: schema.boolean({ defaultValue: true }),
tlsCheckDisabled: schema.boolean({ defaultValue: false }),
diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts
index d1adbd8b2f65d..e32533dc907b9 100644
--- a/x-pack/plugins/ingest_manager/server/plugin.ts
+++ b/x-pack/plugins/ingest_manager/server/plugin.ts
@@ -215,12 +215,9 @@ export class IngestManagerPlugin
registerOutputRoutes(router);
registerSettingsRoutes(router);
registerDataStreamRoutes(router);
+ registerEPMRoutes(router);
// Conditional config routes
- if (config.epm.enabled) {
- registerEPMRoutes(router);
- }
-
if (config.fleet.enabled) {
const isESOUsingEphemeralEncryptionKey =
deps.encryptedSavedObjects.usingEphemeralEncryptionKey;
diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
index 2aaf889296bd6..718aca89ea4fd 100644
--- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts
@@ -283,7 +283,7 @@ export const downloadFullAgentConfig: RequestHandler<
const body = configToYaml(fullAgentConfig);
const headers: ResponseHeaders = {
'content-type': 'text/x-yaml',
- 'content-disposition': `attachment; filename="elastic-agent-config-${fullAgentConfig.id}.yml"`,
+ 'content-disposition': `attachment; filename="elastic-agent.yml"`,
};
return response.ok({
body,
diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts
index 6c360fdeda460..4c58ac57a54a2 100644
--- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts
+++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts
@@ -67,7 +67,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
last_checkin_status: { type: 'keyword' },
config_revision: { type: 'integer' },
default_api_key_id: { type: 'keyword' },
- default_api_key: { type: 'binary', index: false },
+ default_api_key: { type: 'binary' },
updated_at: { type: 'date' },
current_error_events: { type: 'text', index: false },
packages: { type: 'keyword' },
@@ -85,7 +85,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
properties: {
agent_id: { type: 'keyword' },
type: { type: 'keyword' },
- data: { type: 'binary', index: false },
+ data: { type: 'binary' },
sent_at: { type: 'date' },
created_at: { type: 'date' },
},
@@ -146,7 +146,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
properties: {
name: { type: 'keyword' },
type: { type: 'keyword' },
- api_key: { type: 'binary', index: false },
+ api_key: { type: 'binary' },
api_key_id: { type: 'keyword' },
config_id: { type: 'keyword' },
created_at: { type: 'date' },
@@ -170,8 +170,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = {
is_default: { type: 'boolean' },
hosts: { type: 'keyword' },
ca_sha256: { type: 'keyword', index: false },
- fleet_enroll_username: { type: 'binary', index: false },
- fleet_enroll_password: { type: 'binary', index: false },
+ fleet_enroll_username: { type: 'binary' },
+ fleet_enroll_password: { type: 'binary' },
config: { type: 'flattened' },
},
},
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts
index c46e648ad088a..225251b061e58 100644
--- a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts
@@ -61,7 +61,7 @@ describe('agent config', () => {
},
inputs: [],
revision: 1,
- settings: {
+ agent: {
monitoring: {
enabled: false,
logs: false,
@@ -90,7 +90,7 @@ describe('agent config', () => {
},
inputs: [],
revision: 1,
- settings: {
+ agent: {
monitoring: {
use_output: 'default',
enabled: true,
@@ -120,7 +120,7 @@ describe('agent config', () => {
},
inputs: [],
revision: 1,
- settings: {
+ agent: {
monitoring: {
use_output: 'default',
enabled: true,
diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
index 5f98c8881388d..c068b594318c1 100644
--- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts
+++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts
@@ -417,7 +417,7 @@ class AgentConfigService {
revision: config.revision,
...(config.monitoring_enabled && config.monitoring_enabled.length > 0
? {
- settings: {
+ agent: {
monitoring: {
use_output: defaultOutput.name,
enabled: true,
@@ -427,7 +427,7 @@ class AgentConfigService {
},
}
: {
- settings: {
+ agent: {
monitoring: { enabled: false, logs: false, metrics: false },
},
}),
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts
index d92d6faf8472e..47c9121808988 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts
@@ -8,7 +8,7 @@ import { appContextService, licenseService } from '../../';
export const getRegistryUrl = (): string => {
const license = licenseService.getLicenseInformation();
- const customUrl = appContextService.getConfig()?.epm.registryUrl;
+ const customUrl = appContextService.getConfig()?.registryUrl;
if (
customUrl &&
@@ -20,5 +20,9 @@ export const getRegistryUrl = (): string => {
return customUrl;
}
+ if (customUrl) {
+ appContextService.getLogger().warn('Gold license is required to use a custom registry url.');
+ }
+
return DEFAULT_REGISTRY_URL;
};
diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts
index f8dd1c65e3e72..e86e2608e252d 100644
--- a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts
+++ b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts
@@ -4,8 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { savedObjectsClientMock } from 'src/core/server/mocks';
+import { createPackageConfigMock } from '../../common/mocks';
import { packageConfigService } from './package_config';
-import { PackageInfo } from '../types';
+import { PackageInfo, PackageConfigSOAttributes } from '../types';
+import { SavedObjectsUpdateResponse } from 'src/core/server';
async function mockedGetAssetsData(_a: any, _b: any, dataset: string) {
if (dataset === 'dataset1') {
@@ -161,4 +164,32 @@ describe('Package config service', () => {
]);
});
});
+
+ describe('update', () => {
+ it('should fail to update on version conflict', async () => {
+ const savedObjectsClient = savedObjectsClientMock.create();
+ savedObjectsClient.get.mockResolvedValue({
+ id: 'test',
+ type: 'abcd',
+ references: [],
+ version: 'test',
+ attributes: createPackageConfigMock(),
+ });
+ savedObjectsClient.update.mockImplementation(
+ async (
+ type: string,
+ id: string
+ ): Promise> => {
+ throw savedObjectsClient.errors.createConflictError('abc', '123');
+ }
+ );
+ await expect(
+ packageConfigService.update(
+ savedObjectsClient,
+ 'the-package-config-id',
+ createPackageConfigMock()
+ )
+ ).rejects.toThrow('Saved object [abc/123] conflict');
+ });
+ });
});
diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx
index fa8c4f82c1b68..a5796c10f8d93 100644
--- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx
+++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx
@@ -6,7 +6,6 @@
/* eslint-disable @kbn/eslint/no-restricted-paths */
import React from 'react';
import { LocationDescriptorObject } from 'history';
-import { ScopedHistory } from 'kibana/public';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import {
notificationServiceMock,
@@ -35,10 +34,10 @@ const httpServiceSetupMock = new HttpService().setup({
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
});
-const history = (scopedHistoryMock.create() as unknown) as ScopedHistory;
-history.createHref = (location: LocationDescriptorObject) => {
+const history = scopedHistoryMock.create();
+history.createHref.mockImplementation((location: LocationDescriptorObject) => {
return `${location.pathname}?${location.search}`;
-};
+});
const appServices = {
breadcrumbs: breadcrumbService,
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss
index 261d6672df93a..a7c8e4dfc6baa 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss
@@ -1,6 +1,7 @@
.lnsDataPanelWrapper {
flex: 1 0 100%;
overflow: hidden;
+ background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk);
}
.lnsDataPanelWrapper__switchSource {
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss
index 35c28595a59c0..c2e8d4f6c0049 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss
@@ -22,7 +22,7 @@
// Leave out bottom padding so the suggestions scrollbar stays flush to window edge
// Leave out left padding so the left sidebar's focus states are visible outside of content bounds
// This also means needing to add same amount of margin to page content and suggestion items
- padding: $euiSize $euiSize 0 0;
+ padding: $euiSize $euiSize 0;
&:first-child {
padding-left: $euiSize;
@@ -40,9 +40,10 @@
.lnsFrameLayout__sidebar--right {
@include euiScrollBar;
- min-width: $lnsPanelMinWidth + $euiSize;
+ background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk);
+ min-width: $lnsPanelMinWidth + $euiSizeXL;
overflow-x: hidden;
overflow-y: scroll;
- padding-top: $euiSize;
+ padding: $euiSize 0 $euiSize $euiSize;
max-height: 100%;
}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
index 924f44a37c459..4e13fd95d1961 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
@@ -2,6 +2,10 @@
margin-bottom: $euiSizeS;
}
+.lnsLayerPanel__sourceFlexItem {
+ max-width: calc(100% - #{$euiSize * 3.625});
+}
+
.lnsLayerPanel__row {
background: $euiColorLightestShade;
padding: $euiSizeS;
@@ -32,5 +36,6 @@
}
.lnsLayerPanel__styleEditor {
- width: $euiSize * 28;
+ width: $euiSize * 30;
+ padding: $euiSizeS;
}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
index cc8d97a445016..8d31e1bcc2e6a 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
@@ -40,8 +40,7 @@ export function DimensionPopover({
}}
button={trigger}
anchorPosition="leftUp"
- withTitle
- panelPaddingSize="s"
+ panelPaddingSize="none"
>
{panel}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
index 36d5bfd965e26..e51a155a19935 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
@@ -103,7 +103,7 @@ export function LayerPanel(
{layerDatasource && (
-
+
-
-
- >
+
),
},
];
@@ -194,7 +191,6 @@ export function LayerPanel(
}),
content: (
-
- setIsOpen(!isOpen)}
data-test-subj="lns_layer_settings"
/>
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
index ae4a7861b1d90..8a44d59ff1c0d 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss
@@ -5,15 +5,9 @@
}
}
-.lnsChartSwitch__triggerButton {
- @include euiTitle('xs');
- background-color: $euiColorEmptyShade;
- border-color: $euiColorLightShade;
-}
-
.lnsChartSwitch__summaryIcon {
margin-right: $euiSizeS;
- transform: translateY(-2px);
+ transform: translateY(-1px);
}
// Targeting img as this won't target normal EuiIcon's only the custom svgs's
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
index 4c5a44ecc695e..fa87d80e5cf40 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import './chart_switch.scss';
import React, { useState, useMemo } from 'react';
import {
EuiIcon,
@@ -11,7 +12,6 @@ import {
EuiPopoverTitle,
EuiKeyPadMenu,
EuiKeyPadMenuItem,
- EuiButton,
} from '@elastic/eui';
import { flatten } from 'lodash';
import { i18n } from '@kbn/i18n';
@@ -19,6 +19,7 @@ import { Visualization, FramePublicAPI, Datasource } from '../../../types';
import { Action } from '../state_management';
import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers';
import { trackUiEvent } from '../../../lens_ui_telemetry';
+import { ToolbarButton } from '../../../toolbar_button';
interface VisualizationSelection {
visualizationId: string;
@@ -72,8 +73,6 @@ function VisualizationSummary(props: Props) {
);
}
-import './chart_switch.scss';
-
export function ChartSwitch(props: Props) {
const [flyoutOpen, setFlyoutOpen] = useState(false);
@@ -202,16 +201,13 @@ export function ChartSwitch(props: Props) {
panelClassName="lnsChartSwitch__popoverPanel"
panelPaddingSize="s"
button={
- setFlyoutOpen(!flyoutOpen)}
data-test-subj="lnsChartSwitchPopover"
- iconSide="right"
- iconType="arrowDown"
- color="text"
+ fontWeight="bold"
>
-
+
}
isOpen={flyoutOpen}
closePopover={() => setFlyoutOpen(false)}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
index beb6952556067..9f5b6665b31d3 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx
@@ -15,6 +15,7 @@ import {
EuiText,
EuiBetaBadge,
EuiButtonEmpty,
+ EuiLink,
} from '@elastic/eui';
import { CoreStart, CoreSetup } from 'kibana/public';
import {
@@ -208,18 +209,20 @@ export function InnerWorkspacePanel({
/>{' '}
-
-
-
+
+
+
+
+
+
+
);
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
index 94c0f4083dfee..5e2fe9d7bbc14 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
@@ -6,18 +6,13 @@
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
-import {
- EuiButtonEmpty,
- EuiPopover,
- EuiPopoverTitle,
- EuiSelectable,
- EuiButtonEmptyProps,
-} from '@elastic/eui';
+import { EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui';
import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable';
import { IndexPatternRef } from './types';
import { trackUiEvent } from '../lens_ui_telemetry';
+import { ToolbarButtonProps, ToolbarButton } from '../toolbar_button';
-export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & {
+export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & {
label: string;
title?: string;
};
@@ -40,29 +35,24 @@ export function ChangeIndexPattern({
const createTrigger = function () {
const { label, title, ...rest } = trigger;
return (
- setPopoverIsOpen(!isPopoverOpen)}
+ fullWidth
{...rest}
>
{label}
-
+
);
};
return (
<>
setPopoverIsOpen(false)}
- className="eui-textTruncate"
- anchorClassName="eui-textTruncate"
display="block"
panelPaddingSize="s"
ownFocus
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss
index 3e767502fae3b..70fb57ee79ee5 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss
@@ -7,13 +7,7 @@
.lnsInnerIndexPatternDataPanel__header {
display: flex;
align-items: center;
- height: $euiSize * 3;
- margin-top: -$euiSizeS;
-}
-
-.lnsInnerIndexPatternDataPanel__triggerButton {
- @include euiTitle('xs');
- line-height: $euiSizeXXL;
+ margin-bottom: $euiSizeS;
}
/**
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx
index 91c068c2b4fab..6854452fd02a4 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx
@@ -424,7 +424,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
label: currentIndexPattern.title,
title: currentIndexPattern.title,
'data-test-subj': 'indexPattern-switch-link',
- className: 'lnsInnerIndexPatternDataPanel__triggerButton',
+ fontWeight: 'bold',
}}
indexPatternId={currentIndexPatternId}
indexPatternRefs={indexPatternRefs}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss
index f619fa55f9ceb..b8986cea48d4e 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss
@@ -1,7 +1,6 @@
.lnsIndexPatternDimensionEditor {
- flex-grow: 1;
- line-height: 0;
- overflow: hidden;
+ width: $euiSize * 30;
+ padding: $euiSizeS;
}
.lnsIndexPatternDimensionEditor__left,
@@ -11,10 +10,7 @@
.lnsIndexPatternDimensionEditor__left {
background-color: $euiPageBackgroundColor;
-}
-
-.lnsIndexPatternDimensionEditor__right {
- width: $euiSize * 20;
+ width: $euiSize * 8;
}
.lnsIndexPatternDimensionEditor__operation > button {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
index 5b84108b99dd9..2fb7382f992e7 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
@@ -299,25 +299,31 @@ export function PopoverEditor(props: PopoverEditorProps) {
{incompatibleSelectedOperationType && selectedColumn && (
-
+ <>
+
+
+ >
)}
{incompatibleSelectedOperationType && !selectedColumn && (
-
+ <>
+
+
+ >
)}
{!incompatibleSelectedOperationType && ParamEditor && (
<>
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx
index 1ae10e07b0c24..dac451013826e 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx
@@ -27,7 +27,8 @@ export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatter
label: state.indexPatterns[layer.indexPatternId].title,
title: state.indexPatterns[layer.indexPatternId].title,
'data-test-subj': 'lns_layerIndexPatternLabel',
- size: 'xs',
+ size: 's',
+ fontWeight: 'normal',
}}
indexPatternId={layer.indexPatternId}
indexPatternRefs={state.indexPatternRefs}
diff --git a/x-pack/plugins/lens/public/toolbar_button/index.tsx b/x-pack/plugins/lens/public/toolbar_button/index.tsx
new file mode 100644
index 0000000000000..ee6489726a0a7
--- /dev/null
+++ b/x-pack/plugins/lens/public/toolbar_button/index.tsx
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { ToolbarButtonProps, ToolbarButton } from './toolbar_button';
diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss
new file mode 100644
index 0000000000000..f36fdfdf02aba
--- /dev/null
+++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss
@@ -0,0 +1,30 @@
+.lnsToolbarButton {
+ line-height: $euiButtonHeight; // Keeps alignment of text and chart icon
+ background-color: $euiColorEmptyShade;
+ border-color: $euiBorderColor;
+
+ // Some toolbar buttons are just icons, but EuiButton comes with margin and min-width that need to be removed
+ min-width: 0;
+
+ .lnsToolbarButton__text:empty {
+ margin: 0;
+ }
+
+ // Toolbar buttons don't look good with centered text when fullWidth
+ &[class*='fullWidth'] {
+ text-align: left;
+
+ .lnsToolbarButton__content {
+ justify-content: space-between;
+ }
+ }
+}
+
+.lnsToolbarButton--bold {
+ font-weight: $euiFontWeightBold;
+}
+
+.lnsToolbarButton--s {
+ box-shadow: none !important; // sass-lint:disable-line no-important
+ font-size: $euiFontSizeS;
+}
diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx
new file mode 100644
index 0000000000000..0a63781818171
--- /dev/null
+++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 './toolbar_button.scss';
+import React from 'react';
+import classNames from 'classnames';
+import { EuiButton, PropsOf, EuiButtonProps } from '@elastic/eui';
+
+export type ToolbarButtonProps = PropsOf & {
+ /**
+ * Determines prominence
+ */
+ fontWeight?: 'normal' | 'bold';
+ /**
+ * Smaller buttons also remove extra shadow for less prominence
+ */
+ size?: EuiButtonProps['size'];
+};
+
+export const ToolbarButton: React.FunctionComponent = ({
+ children,
+ className,
+ fontWeight = 'normal',
+ size = 'm',
+ ...rest
+}) => {
+ const classes = classNames(
+ 'lnsToolbarButton',
+ [`lnsToolbarButton--${fontWeight}`, `lnsToolbarButton--${size}`],
+ className
+ );
+ return (
+
+ {children}
+
+ );
+};
diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx
index 84ea53fb4dc3d..d22b3ec0a44a6 100644
--- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx
+++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx
@@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import './xy_config_panel.scss';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import {
- EuiButtonEmpty,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
@@ -32,8 +32,7 @@ import { State, SeriesType, visualizationTypes, YAxisMode } from './types';
import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers';
import { trackUiEvent } from '../lens_ui_telemetry';
import { fittingFunctionDefinitions } from './fitting_functions';
-
-import './xy_config_panel.scss';
+import { ToolbarButton } from '../toolbar_button';
type UnwrapArray = T extends Array ? P : T;
@@ -101,17 +100,16 @@ export function XyToolbar(props: VisualizationToolbarProps) {
{
setOpen(!open);
}}
>
{i18n.translate('xpack.lens.xyChart.settingsLabel', { defaultMessage: 'Settings' })}
-
+
}
isOpen={open}
closePopover={() => {
@@ -119,12 +117,9 @@ export function XyToolbar(props: VisualizationToolbarProps) {
}}
anchorPosition="downRight"
>
- ) {
})
}
>
- {
- return {
- value: id,
- dropdownDisplay: (
- <>
- {title}
-
- {description}
-
- >
- ),
- inputDisplay: title,
- };
+ props.setState({ ...props.state, fittingFunction: value })}
- itemLayoutAlign="top"
- hasDividers
- />
-
+ >
+ {
+ return {
+ value: id,
+ dropdownDisplay: (
+ <>
+ {title}
+
+ {description}
+
+ >
+ ),
+ inputDisplay: title,
+ };
+ })}
+ valueOfSelected={props.state?.fittingFunction || 'None'}
+ onChange={(value) => props.setState({ ...props.state, fittingFunction: value })}
+ itemLayoutAlign="top"
+ hasDividers
+ />
+
+
@@ -183,12 +185,12 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps)
})}
>
+ );
+
return (
-
+ {colorPicker}
) : (
-
+ colorPicker
)}
);
diff --git a/x-pack/plugins/lists/README.md b/x-pack/plugins/lists/README.md
index b6061368f6b13..dac6e8bb78fa5 100644
--- a/x-pack/plugins/lists/README.md
+++ b/x-pack/plugins/lists/README.md
@@ -57,7 +57,7 @@ which will:
- Delete any existing exception list items you have
- Delete any existing mapping, policies, and templates, you might have previously had.
- Add the latest list and list item index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.lists.listIndex` and `xpack.lists.listItemIndex`.
-- Posts the sample list from `./lists/new/list_ip.json`
+- Posts the sample list from `./lists/new/ip_list.json`
Now you can run
@@ -69,7 +69,7 @@ You should see the new list created like so:
```sh
{
- "id": "list_ip",
+ "id": "ip_list",
"created_at": "2020-05-28T19:15:22.344Z",
"created_by": "yo",
"description": "This list describes bad internet ip",
@@ -96,7 +96,7 @@ You should see the new list item created and attached to the above list like so:
"value": "127.0.0.1",
"created_at": "2020-05-28T19:15:49.790Z",
"created_by": "yo",
- "list_id": "list_ip",
+ "list_id": "ip_list",
"tie_breaker_id": "a881bf2e-1e17-4592-bba8-d567cb07d234",
"updated_at": "2020-05-28T19:15:49.790Z",
"updated_by": "yo"
@@ -195,7 +195,7 @@ You can then do find for each one like so:
"cursor": "WzIwLFsiYzU3ZWZiYzQtNDk3Ny00YTMyLTk5NWYtY2ZkMjk2YmVkNTIxIl1d",
"data": [
{
- "id": "list_ip",
+ "id": "ip_list",
"created_at": "2020-05-28T19:15:22.344Z",
"created_by": "yo",
"description": "This list describes bad internet ip",
diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts b/x-pack/plugins/lists/common/index.ts
similarity index 87%
rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts
rename to x-pack/plugins/lists/common/index.ts
index 41bc2aa258807..b55ca5db30a44 100644
--- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts
+++ b/x-pack/plugins/lists/common/index.ts
@@ -3,3 +3,5 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
+
+export * from './shared_exports';
diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts
index 6bb6ee05034cb..6199a5f16f109 100644
--- a/x-pack/plugins/lists/common/schemas/common/schemas.ts
+++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts
@@ -273,7 +273,6 @@ export const cursorOrUndefined = t.union([cursor, t.undefined]);
export type CursorOrUndefined = t.TypeOf;
export const namespace_type = DefaultNamespace;
-export type NamespaceType = t.TypeOf;
export const operator = t.keyof({ excluded: null, included: null });
export type Operator = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts
index fb452ac89576d..4b7db3eee35bc 100644
--- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts
@@ -10,7 +10,6 @@ import * as t from 'io-ts';
import {
ItemId,
- NamespaceType,
Tags,
_Tags,
_tags,
@@ -23,7 +22,12 @@ import {
tags,
} from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
-import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types';
+import {
+ CreateCommentsArray,
+ DefaultCreateCommentsArray,
+ DefaultEntryArray,
+ NamespaceType,
+} from '../types';
import { EntriesArray } from '../types/entries';
import { DefaultUuid } from '../../siem_common_deps';
diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts
index a0aaa91c81427..66cca4ab9ca53 100644
--- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts
@@ -10,7 +10,6 @@ import * as t from 'io-ts';
import {
ListId,
- NamespaceType,
Tags,
_Tags,
_tags,
@@ -23,6 +22,7 @@ import {
} from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
import { DefaultUuid } from '../../siem_common_deps';
+import { NamespaceType } from '../types';
export const createExceptionListSchema = t.intersection([
t.exact(
diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts
index 4c5b70d9a4073..909960c9fffc0 100644
--- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts
@@ -8,7 +8,8 @@
import * as t from 'io-ts';
-import { NamespaceType, id, item_id, namespace_type } from '../common/schemas';
+import { id, item_id, namespace_type } from '../common/schemas';
+import { NamespaceType } from '../types';
export const deleteExceptionListItemSchema = t.exact(
t.partial({
diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts
index 2577d867031f0..3bf5e7a4d0782 100644
--- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts
@@ -8,7 +8,8 @@
import * as t from 'io-ts';
-import { NamespaceType, id, list_id, namespace_type } from '../common/schemas';
+import { id, list_id, namespace_type } from '../common/schemas';
+import { NamespaceType } from '../types';
export const deleteExceptionListSchema = t.exact(
t.partial({
diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts
index 31eb4925eb6d6..826da972fe7a3 100644
--- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts
@@ -8,27 +8,26 @@
import * as t from 'io-ts';
-import {
- NamespaceType,
- filter,
- list_id,
- namespace_type,
- sort_field,
- sort_order,
-} from '../common/schemas';
+import { sort_field, sort_order } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
import { StringToPositiveNumber } from '../types/string_to_positive_number';
+import {
+ DefaultNamespaceArray,
+ DefaultNamespaceArrayTypeDecoded,
+} from '../types/default_namespace_array';
+import { NonEmptyStringArray } from '../types/non_empty_string_array';
+import { EmptyStringArray, EmptyStringArrayDecoded } from '../types/empty_string_array';
export const findExceptionListItemSchema = t.intersection([
t.exact(
t.type({
- list_id,
+ list_id: NonEmptyStringArray,
})
),
t.exact(
t.partial({
- filter, // defaults to undefined if not set during decode
- namespace_type, // defaults to 'single' if not set during decode
+ filter: EmptyStringArray, // defaults to undefined if not set during decode
+ namespace_type: DefaultNamespaceArray, // defaults to ['single'] if not set during decode
page: StringToPositiveNumber, // defaults to undefined if not set during decode
per_page: StringToPositiveNumber, // defaults to undefined if not set during decode
sort_field, // defaults to undefined if not set during decode
@@ -37,14 +36,15 @@ export const findExceptionListItemSchema = t.intersection([
),
]);
-export type FindExceptionListItemSchemaPartial = t.TypeOf;
+export type FindExceptionListItemSchemaPartial = t.OutputOf;
// This type is used after a decode since some things are defaults after a decode.
export type FindExceptionListItemSchemaPartialDecoded = Omit<
- FindExceptionListItemSchemaPartial,
- 'namespace_type'
+ t.TypeOf,
+ 'namespace_type' | 'filter'
> & {
- namespace_type: NamespaceType;
+ filter: EmptyStringArrayDecoded;
+ namespace_type: DefaultNamespaceArrayTypeDecoded;
};
// This type is used after a decode since some things are defaults after a decode.
diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts
index fa00c5b0dafb1..8b9b08ed387b1 100644
--- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts
@@ -8,9 +8,10 @@
import * as t from 'io-ts';
-import { NamespaceType, filter, namespace_type, sort_field, sort_order } from '../common/schemas';
+import { filter, namespace_type, sort_field, sort_order } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
import { StringToPositiveNumber } from '../types/string_to_positive_number';
+import { NamespaceType } from '../types';
export const findExceptionListSchema = t.exact(
t.partial({
diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts
index 93a372ba383b0..d8864a6fc66e5 100644
--- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts
@@ -8,8 +8,9 @@
import * as t from 'io-ts';
-import { NamespaceType, id, item_id, namespace_type } from '../common/schemas';
+import { id, item_id, namespace_type } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
+import { NamespaceType } from '../types';
export const readExceptionListItemSchema = t.exact(
t.partial({
diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts
index 3947c88bf4c9c..613fb22a99d61 100644
--- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts
@@ -8,8 +8,9 @@
import * as t from 'io-ts';
-import { NamespaceType, id, list_id, namespace_type } from '../common/schemas';
+import { id, list_id, namespace_type } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
+import { NamespaceType } from '../types';
export const readExceptionListSchema = t.exact(
t.partial({
diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts
index 582fabdc160f9..20a63e0fc7dac 100644
--- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts
@@ -9,7 +9,6 @@
import * as t from 'io-ts';
import {
- NamespaceType,
Tags,
_Tags,
_tags,
@@ -26,6 +25,7 @@ import {
DefaultEntryArray,
DefaultUpdateCommentsArray,
EntriesArray,
+ NamespaceType,
UpdateCommentsArray,
} from '../types';
diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts
index 76160c3419449..0b5f3a8a01794 100644
--- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts
+++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts
@@ -9,7 +9,6 @@
import * as t from 'io-ts';
import {
- NamespaceType,
Tags,
_Tags,
_tags,
@@ -21,6 +20,7 @@ import {
tags,
} from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
+import { NamespaceType } from '../types';
export const updateExceptionListSchema = t.intersection([
t.exact(
diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts
index 8f8f8d105b624..ecc45d3c84313 100644
--- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts
+++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts
@@ -8,23 +8,18 @@ import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
export const namespaceType = t.keyof({ agnostic: null, single: null });
-
-type NamespaceType = t.TypeOf;
-
-export type DefaultNamespaceC = t.Type;
+export type NamespaceType = t.TypeOf;
/**
* Types the DefaultNamespace as:
* - If null or undefined, then a default string/enumeration of "single" will be used.
*/
-export const DefaultNamespace: DefaultNamespaceC = new t.Type<
- NamespaceType,
- NamespaceType,
- unknown
->(
+export const DefaultNamespace = new t.Type(
'DefaultNamespace',
namespaceType.is,
(input, context): Either =>
input == null ? t.success('single') : namespaceType.validate(input, context),
t.identity
);
+
+export type DefaultNamespaceC = typeof DefaultNamespace;
diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts
new file mode 100644
index 0000000000000..055f93069950e
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { pipe } from 'fp-ts/lib/pipeable';
+import { left } from 'fp-ts/lib/Either';
+
+import { foldLeftRight, getPaths } from '../../siem_common_deps';
+
+import { DefaultNamespaceArray, DefaultNamespaceArrayTypeEncoded } from './default_namespace_array';
+
+describe('default_namespace_array', () => {
+ test('it should validate "null" single item as an array with a "single" value', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = null;
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['single']);
+ });
+
+ test('it should NOT validate a numeric value', () => {
+ const payload = 5;
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "5" supplied to "DefaultNamespaceArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should validate "undefined" item as an array with a "single" value', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = undefined;
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['single']);
+ });
+
+ test('it should validate "single" as an array of a "single" value', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = 'single';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual([payload]);
+ });
+
+ test('it should validate "agnostic" as an array of a "agnostic" value', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual([payload]);
+ });
+
+ test('it should validate "single,agnostic" as an array of 2 values of ["single", "agnostic"] values', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic,single';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['agnostic', 'single']);
+ });
+
+ test('it should validate 3 elements of "single,agnostic,single" as an array of 3 values of ["single", "agnostic", "single"] values', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,single';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['single', 'agnostic', 'single']);
+ });
+
+ test('it should validate 3 elements of "single,agnostic, single" as an array of 3 values of ["single", "agnostic", "single"] values when there are spaces', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = ' single, agnostic, single ';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['single', 'agnostic', 'single']);
+ });
+
+ test('it should not validate 3 elements of "single,agnostic,junk" since the 3rd value is junk', () => {
+ const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,junk';
+ const decoded = DefaultNamespaceArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "junk" supplied to "DefaultNamespaceArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+});
diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts
new file mode 100644
index 0000000000000..c4099a48ffbcc
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+import { Either } from 'fp-ts/lib/Either';
+
+import { namespaceType } from './default_namespace';
+
+export const namespaceTypeArray = t.array(namespaceType);
+export type NamespaceTypeArray = t.TypeOf;
+
+/**
+ * Types the DefaultNamespaceArray as:
+ * - If null or undefined, then a default string array of "single" will be used.
+ * - If it contains a string, then it is split along the commas and puts them into an array and validates it
+ */
+export const DefaultNamespaceArray = new t.Type<
+ NamespaceTypeArray,
+ string | undefined | null,
+ unknown
+>(
+ 'DefaultNamespaceArray',
+ namespaceTypeArray.is,
+ (input, context): Either => {
+ if (input == null) {
+ return t.success(['single']);
+ } else if (typeof input === 'string') {
+ const commaSeparatedValues = input
+ .trim()
+ .split(',')
+ .map((value) => value.trim());
+ return namespaceTypeArray.validate(commaSeparatedValues, context);
+ }
+ return t.failure(input, context);
+ },
+ String
+);
+
+export type DefaultNamespaceC = typeof DefaultNamespaceArray;
+
+export type DefaultNamespaceArrayTypeEncoded = t.OutputOf;
+export type DefaultNamespaceArrayTypeDecoded = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts
new file mode 100644
index 0000000000000..b14afab327fb0
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts
@@ -0,0 +1,79 @@
+/*
+ * 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 { pipe } from 'fp-ts/lib/pipeable';
+import { left } from 'fp-ts/lib/Either';
+
+import { foldLeftRight, getPaths } from '../../siem_common_deps';
+
+import { EmptyStringArray, EmptyStringArrayEncoded } from './empty_string_array';
+
+describe('empty_string_array', () => {
+ test('it should validate "null" and create an empty array', () => {
+ const payload: EmptyStringArrayEncoded = null;
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual([]);
+ });
+
+ test('it should validate "undefined" and create an empty array', () => {
+ const payload: EmptyStringArrayEncoded = undefined;
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual([]);
+ });
+
+ test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => {
+ const payload: EmptyStringArrayEncoded = 'a';
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a']);
+ });
+
+ test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => {
+ const payload: EmptyStringArrayEncoded = 'a,b';
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b']);
+ });
+
+ test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => {
+ const payload: EmptyStringArrayEncoded = 'a,b,c';
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b', 'c']);
+ });
+
+ test('it should NOT validate a number', () => {
+ const payload: number = 5;
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "5" supplied to "EmptyStringArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => {
+ const payload: EmptyStringArrayEncoded = ' a, b, c ';
+ const decoded = EmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts
new file mode 100644
index 0000000000000..389dc4a410cc9
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import * as t from 'io-ts';
+import { Either } from 'fp-ts/lib/Either';
+
+/**
+ * Types the EmptyStringArray as:
+ * - A value that can be undefined, or null (which will be turned into an empty array)
+ * - A comma separated string that can turn into an array by splitting on it
+ * - Example input converted to output: undefined -> []
+ * - Example input converted to output: null -> []
+ * - Example input converted to output: "a,b,c" -> ["a", "b", "c"]
+ */
+export const EmptyStringArray = new t.Type(
+ 'EmptyStringArray',
+ t.array(t.string).is,
+ (input, context): Either => {
+ if (input == null) {
+ return t.success([]);
+ } else if (typeof input === 'string' && input.trim() !== '') {
+ const arrayValues = input
+ .trim()
+ .split(',')
+ .map((value) => value.trim());
+ const emptyValueFound = arrayValues.some((value) => value === '');
+ if (emptyValueFound) {
+ return t.failure(input, context);
+ } else {
+ return t.success(arrayValues);
+ }
+ } else {
+ return t.failure(input, context);
+ }
+ },
+ String
+);
+
+export type EmptyStringArrayC = typeof EmptyStringArray;
+
+export type EmptyStringArrayEncoded = t.OutputOf;
+export type EmptyStringArrayDecoded = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts
new file mode 100644
index 0000000000000..6124487cdd7fb
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts
@@ -0,0 +1,94 @@
+/*
+ * 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 { pipe } from 'fp-ts/lib/pipeable';
+import { left } from 'fp-ts/lib/Either';
+
+import { foldLeftRight, getPaths } from '../../siem_common_deps';
+
+import { NonEmptyStringArray, NonEmptyStringArrayEncoded } from './non_empty_string_array';
+
+describe('non_empty_string_array', () => {
+ test('it should NOT validate "null"', () => {
+ const payload: NonEmptyStringArrayEncoded | null = null;
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "null" supplied to "NonEmptyStringArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should NOT validate "undefined"', () => {
+ const payload: NonEmptyStringArrayEncoded | undefined = undefined;
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "undefined" supplied to "NonEmptyStringArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should NOT validate a single value of an empty string ""', () => {
+ const payload: NonEmptyStringArrayEncoded = '';
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "" supplied to "NonEmptyStringArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => {
+ const payload: NonEmptyStringArrayEncoded = 'a';
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a']);
+ });
+
+ test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => {
+ const payload: NonEmptyStringArrayEncoded = 'a,b';
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b']);
+ });
+
+ test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => {
+ const payload: NonEmptyStringArrayEncoded = 'a,b,c';
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b', 'c']);
+ });
+
+ test('it should NOT validate a number', () => {
+ const payload: number = 5;
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "5" supplied to "NonEmptyStringArray"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => {
+ const payload: NonEmptyStringArrayEncoded = ' a, b, c ';
+ const decoded = NonEmptyStringArray.decode(payload);
+ const message = pipe(decoded, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts
new file mode 100644
index 0000000000000..c4a640e7cdbad
--- /dev/null
+++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 * as t from 'io-ts';
+import { Either } from 'fp-ts/lib/Either';
+
+/**
+ * Types the NonEmptyStringArray as:
+ * - A string that is not empty (which will be turned into an array of size 1)
+ * - A comma separated string that can turn into an array by splitting on it
+ * - Example input converted to output: "a,b,c" -> ["a", "b", "c"]
+ */
+export const NonEmptyStringArray = new t.Type(
+ 'NonEmptyStringArray',
+ t.array(t.string).is,
+ (input, context): Either => {
+ if (typeof input === 'string' && input.trim() !== '') {
+ const arrayValues = input
+ .trim()
+ .split(',')
+ .map((value) => value.trim());
+ const emptyValueFound = arrayValues.some((value) => value === '');
+ if (emptyValueFound) {
+ return t.failure(input, context);
+ } else {
+ return t.success(arrayValues);
+ }
+ } else {
+ return t.failure(input, context);
+ }
+ },
+ String
+);
+
+export type NonEmptyStringArrayC = typeof NonEmptyStringArray;
+
+export type NonEmptyStringArrayEncoded = t.OutputOf;
+export type NonEmptyStringArrayDecoded = t.TypeOf;
diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts
new file mode 100644
index 0000000000000..7bb565792969c
--- /dev/null
+++ b/x-pack/plugins/lists/common/shared_exports.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+export {
+ ListSchema,
+ CommentsArray,
+ CreateCommentsArray,
+ Comments,
+ CreateComments,
+ ExceptionListSchema,
+ ExceptionListItemSchema,
+ CreateExceptionListItemSchema,
+ UpdateExceptionListItemSchema,
+ Entry,
+ EntryExists,
+ EntryMatch,
+ EntryMatchAny,
+ EntryNested,
+ EntryList,
+ EntriesArray,
+ NamespaceType,
+ Operator,
+ OperatorEnum,
+ OperatorType,
+ OperatorTypeEnum,
+ ExceptionListTypeEnum,
+ exceptionListItemSchema,
+ exceptionListType,
+ createExceptionListItemSchema,
+ listSchema,
+ entry,
+ entriesNested,
+ entriesMatch,
+ entriesMatchAny,
+ entriesExists,
+ entriesList,
+ namespaceType,
+ ExceptionListType,
+ Type,
+} from './schemas';
diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts
new file mode 100644
index 0000000000000..ad7c24b3db610
--- /dev/null
+++ b/x-pack/plugins/lists/common/shared_imports.ts
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+
+export {
+ NonEmptyString,
+ DefaultUuid,
+ DefaultStringArray,
+ exactCheck,
+ getPaths,
+ foldLeftRight,
+ validate,
+ validateEither,
+ formatErrors,
+} from '../../security_solution/common';
diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts
index dccc548985e77..2b37e2b7bf106 100644
--- a/x-pack/plugins/lists/common/siem_common_deps.ts
+++ b/x-pack/plugins/lists/common/siem_common_deps.ts
@@ -4,10 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { NonEmptyString } from '../../security_solution/common/detection_engine/schemas/types/non_empty_string';
-export { DefaultUuid } from '../../security_solution/common/detection_engine/schemas/types/default_uuid';
-export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array';
-export { exactCheck } from '../../security_solution/common/exact_check';
-export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils';
-export { validate, validateEither } from '../../security_solution/common/validate';
-export { formatErrors } from '../../security_solution/common/format_errors';
+// DEPRECATED: Do not add exports to this file; please import from shared_imports instead
+
+export * from './shared_imports';
diff --git a/x-pack/plugins/lists/kibana.json b/x-pack/plugins/lists/kibana.json
index b7aaac6d3fc76..1e25fd987552d 100644
--- a/x-pack/plugins/lists/kibana.json
+++ b/x-pack/plugins/lists/kibana.json
@@ -1,10 +1,12 @@
{
"configPath": ["xpack", "lists"],
+ "extraPublicDirs": ["common"],
"id": "lists",
"kibanaVersion": "kibana",
"requiredPlugins": [],
"optionalPlugins": ["spaces", "security"],
+ "requiredBundles": ["securitySolution"],
"server": true,
- "ui": false,
+ "ui": true,
"version": "8.0.0"
}
diff --git a/x-pack/plugins/lists/public/common/fp_utils.ts b/x-pack/plugins/lists/public/common/fp_utils.ts
index 04e1033879476..196bfee0b501b 100644
--- a/x-pack/plugins/lists/public/common/fp_utils.ts
+++ b/x-pack/plugins/lists/public/common/fp_utils.ts
@@ -16,3 +16,5 @@ export const toPromise = async (taskEither: TaskEither): Promise
(a) => Promise.resolve(a)
)
);
+
+export const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts
new file mode 100644
index 0000000000000..b8967086ef956
--- /dev/null
+++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts
@@ -0,0 +1,118 @@
+/*
+ * 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 { act, renderHook } from '@testing-library/react-hooks';
+
+import { UseCursorProps, useCursor } from './use_cursor';
+
+describe('useCursor', () => {
+ it('returns undefined cursor if no values have been set', () => {
+ const { result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+
+ expect(result.current[0]).toBeUndefined();
+ });
+
+ it('retrieves a cursor for the next page of a given page size', () => {
+ const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+ rerender({ pageIndex: 1, pageSize: 1 });
+ act(() => {
+ result.current[1]('new_cursor');
+ });
+
+ expect(result.current[0]).toBeUndefined();
+
+ rerender({ pageIndex: 2, pageSize: 1 });
+ expect(result.current[0]).toEqual('new_cursor');
+ });
+
+ it('returns undefined cursor for an unknown search', () => {
+ const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+ act(() => {
+ result.current[1]('new_cursor');
+ });
+
+ rerender({ pageIndex: 1, pageSize: 2 });
+ expect(result.current[0]).toBeUndefined();
+ });
+
+ it('remembers cursor through rerenders', () => {
+ const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+
+ rerender({ pageIndex: 1, pageSize: 1 });
+ act(() => {
+ result.current[1]('new_cursor');
+ });
+
+ rerender({ pageIndex: 2, pageSize: 1 });
+ expect(result.current[0]).toEqual('new_cursor');
+
+ rerender({ pageIndex: 0, pageSize: 0 });
+ expect(result.current[0]).toBeUndefined();
+
+ rerender({ pageIndex: 2, pageSize: 1 });
+ expect(result.current[0]).toEqual('new_cursor');
+ });
+
+ it('remembers multiple cursors', () => {
+ const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+
+ rerender({ pageIndex: 1, pageSize: 1 });
+ act(() => {
+ result.current[1]('new_cursor');
+ });
+ rerender({ pageIndex: 2, pageSize: 2 });
+ act(() => {
+ result.current[1]('another_cursor');
+ });
+
+ rerender({ pageIndex: 2, pageSize: 1 });
+ expect(result.current[0]).toEqual('new_cursor');
+
+ rerender({ pageIndex: 3, pageSize: 2 });
+ expect(result.current[0]).toEqual('another_cursor');
+ });
+
+ it('returns the "nearest" cursor for the given page size', () => {
+ const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), {
+ initialProps: { pageIndex: 0, pageSize: 0 },
+ });
+
+ rerender({ pageIndex: 1, pageSize: 2 });
+ act(() => {
+ result.current[1]('cursor1');
+ });
+ rerender({ pageIndex: 2, pageSize: 2 });
+ act(() => {
+ result.current[1]('cursor2');
+ });
+ rerender({ pageIndex: 3, pageSize: 2 });
+ act(() => {
+ result.current[1]('cursor3');
+ });
+
+ rerender({ pageIndex: 2, pageSize: 2 });
+ expect(result.current[0]).toEqual('cursor1');
+
+ rerender({ pageIndex: 3, pageSize: 2 });
+ expect(result.current[0]).toEqual('cursor2');
+
+ rerender({ pageIndex: 4, pageSize: 2 });
+ expect(result.current[0]).toEqual('cursor3');
+
+ rerender({ pageIndex: 6, pageSize: 2 });
+ expect(result.current[0]).toEqual('cursor3');
+ });
+});
diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts
new file mode 100644
index 0000000000000..2409436ff3137
--- /dev/null
+++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts
@@ -0,0 +1,43 @@
+/*
+ * 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, useState } from 'react';
+
+export interface UseCursorProps {
+ pageIndex: number;
+ pageSize: number;
+}
+type Cursor = string | undefined;
+type SetCursor = (cursor: Cursor) => void;
+type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor];
+
+const hash = (props: UseCursorProps): string => JSON.stringify(props);
+
+export const useCursor: UseCursor = ({ pageIndex, pageSize }) => {
+ const [cache, setCache] = useState>({});
+
+ const setCursor = useCallback(
+ (cursor) => {
+ setCache({
+ ...cache,
+ [hash({ pageIndex: pageIndex + 1, pageSize })]: cursor,
+ });
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [pageIndex, pageSize]
+ );
+
+ let cursor: Cursor;
+ for (let i = pageIndex; i >= 0; i--) {
+ const currentProps = { pageIndex: i, pageSize };
+ cursor = cache[hash(currentProps)];
+ if (cursor) {
+ break;
+ }
+ }
+
+ return [cursor, setCursor];
+};
diff --git a/x-pack/plugins/lists/public/index.ts b/x-pack/plugins/lists/public/index.ts
new file mode 100644
index 0000000000000..2cff5af613d9a
--- /dev/null
+++ b/x-pack/plugins/lists/public/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
+export * from './shared_exports';
+
+import { PluginInitializerContext } from '../../../../src/core/public';
+
+import { Plugin } from './plugin';
+import { PluginSetup, PluginStart } from './types';
+
+export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context);
+
+export { Plugin, PluginSetup, PluginStart };
diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts
index 38556e2eabc18..d79dc86802399 100644
--- a/x-pack/plugins/lists/public/lists/api.test.ts
+++ b/x-pack/plugins/lists/public/lists/api.test.ts
@@ -6,10 +6,19 @@
import { HttpFetchOptions } from '../../../../../src/core/public';
import { httpServiceMock } from '../../../../../src/core/public/mocks';
+import { getAcknowledgeSchemaResponseMock } from '../../common/schemas/response/acknowledge_schema.mock';
import { getListResponseMock } from '../../common/schemas/response/list_schema.mock';
+import { getListItemIndexExistSchemaResponseMock } from '../../common/schemas/response/list_item_index_exist_schema.mock';
import { getFoundListSchemaMock } from '../../common/schemas/response/found_list_schema.mock';
-import { deleteList, exportList, findLists, importList } from './api';
+import {
+ createListIndex,
+ deleteList,
+ exportList,
+ findLists,
+ importList,
+ readListIndex,
+} from './api';
import {
ApiPayload,
DeleteListParams,
@@ -60,7 +69,7 @@ describe('Value Lists API', () => {
...((payload as unknown) as ApiPayload),
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "23" supplied to "id"');
+ ).rejects.toEqual(new Error('Invalid value "23" supplied to "id"'));
expect(httpMock.fetch).not.toHaveBeenCalled();
});
@@ -76,7 +85,7 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "undefined" supplied to "id"');
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"'));
});
});
@@ -105,6 +114,7 @@ describe('Value Lists API', () => {
it('sends pagination as query parameters', async () => {
const abortCtrl = new AbortController();
await findLists({
+ cursor: 'cursor',
http: httpMock,
pageIndex: 1,
pageSize: 10,
@@ -114,14 +124,21 @@ describe('Value Lists API', () => {
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/_find',
expect.objectContaining({
- query: { page: 1, per_page: 10 },
+ query: {
+ cursor: 'cursor',
+ page: 1,
+ per_page: 10,
+ },
})
);
});
it('rejects with an error if request payload is invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
- const payload: ApiPayload = { pageIndex: 10, pageSize: 0 };
+ const payload: ApiPayload = {
+ pageIndex: 10,
+ pageSize: 0,
+ };
await expect(
findLists({
@@ -129,13 +146,16 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "0" supplied to "per_page"');
+ ).rejects.toEqual(new Error('Invalid value "0" supplied to "per_page"'));
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
- const payload: ApiPayload = { pageIndex: 1, pageSize: 10 };
+ const payload: ApiPayload = {
+ pageIndex: 1,
+ pageSize: 10,
+ };
const badResponse = { ...getFoundListSchemaMock(), cursor: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
@@ -145,7 +165,7 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "undefined" supplied to "cursor"');
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "cursor"'));
});
});
@@ -214,7 +234,7 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "undefined" supplied to "file"');
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "file"'));
expect(httpMock.fetch).not.toHaveBeenCalled();
});
@@ -233,7 +253,7 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "other" supplied to "type"');
+ ).rejects.toEqual(new Error('Invalid value "other" supplied to "type"'));
expect(httpMock.fetch).not.toHaveBeenCalled();
});
@@ -254,13 +274,13 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "undefined" supplied to "id"');
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"'));
});
});
describe('exportList', () => {
beforeEach(() => {
- httpMock.fetch.mockResolvedValue(getListResponseMock());
+ httpMock.fetch.mockResolvedValue({});
});
it('POSTs to the export endpoint', async () => {
@@ -307,25 +327,96 @@ describe('Value Lists API', () => {
...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "23" supplied to "list_id"');
+ ).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"'));
expect(httpMock.fetch).not.toHaveBeenCalled();
});
+ });
+
+ describe('readListIndex', () => {
+ beforeEach(() => {
+ httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock());
+ });
+
+ it('GETs the list index', async () => {
+ const abortCtrl = new AbortController();
+ await readListIndex({
+ http: httpMock,
+ signal: abortCtrl.signal,
+ });
+
+ expect(httpMock.fetch).toHaveBeenCalledWith(
+ '/api/lists/index',
+ expect.objectContaining({
+ method: 'GET',
+ })
+ );
+ });
+
+ it('returns the response when valid', async () => {
+ const abortCtrl = new AbortController();
+ const result = await readListIndex({
+ http: httpMock,
+ signal: abortCtrl.signal,
+ });
+
+ expect(result).toEqual(getListItemIndexExistSchemaResponseMock());
+ });
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
- const payload: ApiPayload = {
- listId: 'list-id',
- };
- const badResponse = { ...getListResponseMock(), id: undefined };
+ const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
await expect(
- exportList({
+ readListIndex({
+ http: httpMock,
+ signal: abortCtrl.signal,
+ })
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"'));
+ });
+ });
+
+ describe('createListIndex', () => {
+ beforeEach(() => {
+ httpMock.fetch.mockResolvedValue(getAcknowledgeSchemaResponseMock());
+ });
+
+ it('GETs the list index', async () => {
+ const abortCtrl = new AbortController();
+ await createListIndex({
+ http: httpMock,
+ signal: abortCtrl.signal,
+ });
+
+ expect(httpMock.fetch).toHaveBeenCalledWith(
+ '/api/lists/index',
+ expect.objectContaining({
+ method: 'POST',
+ })
+ );
+ });
+
+ it('returns the response when valid', async () => {
+ const abortCtrl = new AbortController();
+ const result = await createListIndex({
+ http: httpMock,
+ signal: abortCtrl.signal,
+ });
+
+ expect(result).toEqual(getAcknowledgeSchemaResponseMock());
+ });
+
+ it('rejects with an error if response payload is invalid', async () => {
+ const abortCtrl = new AbortController();
+ const badResponse = { acknowledged: undefined };
+ httpMock.fetch.mockResolvedValue(badResponse);
+
+ await expect(
+ createListIndex({
http: httpMock,
- ...payload,
signal: abortCtrl.signal,
})
- ).rejects.toEqual('Invalid value "undefined" supplied to "id"');
+ ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "acknowledged"'));
});
});
});
diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts
index d615239f4eb01..606109f1910c4 100644
--- a/x-pack/plugins/lists/public/lists/api.ts
+++ b/x-pack/plugins/lists/public/lists/api.ts
@@ -9,24 +9,28 @@ import { flow } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import {
+ AcknowledgeSchema,
DeleteListSchemaEncoded,
ExportListItemQuerySchemaEncoded,
FindListSchemaEncoded,
FoundListSchema,
ImportListItemQuerySchemaEncoded,
ImportListItemSchemaEncoded,
+ ListItemIndexExistSchema,
ListSchema,
+ acknowledgeSchema,
deleteListSchema,
exportListItemQuerySchema,
findListSchema,
foundListSchema,
importListItemQuerySchema,
importListItemSchema,
+ listItemIndexExistSchema,
listSchema,
} from '../../common/schemas';
-import { LIST_ITEM_URL, LIST_URL } from '../../common/constants';
+import { LIST_INDEX, LIST_ITEM_URL, LIST_PRIVILEGES_URL, LIST_URL } from '../../common/constants';
import { validateEither } from '../../common/siem_common_deps';
-import { toPromise } from '../common/fp_utils';
+import { toError, toPromise } from '../common/fp_utils';
import {
ApiParams,
@@ -55,6 +59,7 @@ const findLists = async ({
};
const findListsWithValidation = async ({
+ cursor,
http,
pageIndex,
pageSize,
@@ -62,11 +67,12 @@ const findListsWithValidation = async ({
}: FindListsParams): Promise =>
pipe(
{
- page: String(pageIndex),
- per_page: String(pageSize),
+ cursor: cursor?.toString(),
+ page: pageIndex?.toString(),
+ per_page: pageSize?.toString(),
},
(payload) => fromEither(validateEither(findListSchema, payload)),
- chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), String)),
+ chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)),
chain((response) => fromEither(validateEither(foundListSchema, response))),
flow(toPromise)
);
@@ -113,7 +119,7 @@ const importListWithValidation = async ({
map((body) => ({ ...body, ...query }))
)
),
- chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), String)),
+ chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), toError)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);
@@ -139,7 +145,7 @@ const deleteListWithValidation = async ({
pipe(
{ id },
(payload) => fromEither(validateEither(deleteListSchema, payload)),
- chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), String)),
+ chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), toError)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);
@@ -165,9 +171,51 @@ const exportListWithValidation = async ({
pipe(
{ list_id: listId },
(payload) => fromEither(validateEither(exportListItemQuerySchema, payload)),
- chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)),
- chain((response) => fromEither(validateEither(listSchema, response))),
+ chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)),
flow(toPromise)
);
export { exportListWithValidation as exportList };
+
+const readListIndex = async ({ http, signal }: ApiParams): Promise =>
+ http.fetch(LIST_INDEX, {
+ method: 'GET',
+ signal,
+ });
+
+const readListIndexWithValidation = async ({
+ http,
+ signal,
+}: ApiParams): Promise =>
+ flow(
+ () => tryCatch(() => readListIndex({ http, signal }), toError),
+ chain((response) => fromEither(validateEither(listItemIndexExistSchema, response))),
+ flow(toPromise)
+ )();
+
+export { readListIndexWithValidation as readListIndex };
+
+// TODO add types and validation
+export const readListPrivileges = async ({ http, signal }: ApiParams): Promise =>
+ http.fetch(LIST_PRIVILEGES_URL, {
+ method: 'GET',
+ signal,
+ });
+
+const createListIndex = async ({ http, signal }: ApiParams): Promise =>
+ http.fetch(LIST_INDEX, {
+ method: 'POST',
+ signal,
+ });
+
+const createListIndexWithValidation = async ({
+ http,
+ signal,
+}: ApiParams): Promise =>
+ flow(
+ () => tryCatch(() => createListIndex({ http, signal }), toError),
+ chain((response) => fromEither(validateEither(acknowledgeSchema, response))),
+ flow(toPromise)
+ )();
+
+export { createListIndexWithValidation as createListIndex };
diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts
new file mode 100644
index 0000000000000..9f784dd8790bf
--- /dev/null
+++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { act, renderHook } from '@testing-library/react-hooks';
+
+import * as Api from '../api';
+import { httpServiceMock } from '../../../../../../src/core/public/mocks';
+import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock';
+
+import { useCreateListIndex } from './use_create_list_index';
+
+jest.mock('../api');
+
+describe('useCreateListIndex', () => {
+ let httpMock: ReturnType;
+
+ beforeEach(() => {
+ httpMock = httpServiceMock.createStartContract();
+ (Api.createListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock());
+ });
+
+ it('invokes Api.createListIndex', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useCreateListIndex());
+ act(() => {
+ result.current.start({ http: httpMock });
+ });
+ await waitForNextUpdate();
+
+ expect(Api.createListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock }));
+ });
+});
diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts
new file mode 100644
index 0000000000000..18df26c2ecfd7
--- /dev/null
+++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_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.
+ */
+
+import { withOptionalSignal } from '../../common/with_optional_signal';
+import { useAsync } from '../../common/hooks/use_async';
+import { createListIndex } from '../api';
+
+const createListIndexWithOptionalSignal = withOptionalSignal(createListIndex);
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export const useCreateListIndex = () => useAsync(createListIndexWithOptionalSignal);
diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts
new file mode 100644
index 0000000000000..9f4e41f1cdc9e
--- /dev/null
+++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { act, renderHook } from '@testing-library/react-hooks';
+
+import * as Api from '../api';
+import { httpServiceMock } from '../../../../../../src/core/public/mocks';
+import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock';
+
+import { useReadListIndex } from './use_read_list_index';
+
+jest.mock('../api');
+
+describe('useReadListIndex', () => {
+ let httpMock: ReturnType;
+
+ beforeEach(() => {
+ httpMock = httpServiceMock.createStartContract();
+ (Api.readListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock());
+ });
+
+ it('invokes Api.readListIndex', async () => {
+ const { result, waitForNextUpdate } = renderHook(() => useReadListIndex());
+ act(() => {
+ result.current.start({ http: httpMock });
+ });
+ await waitForNextUpdate();
+
+ expect(Api.readListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock }));
+ });
+});
diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts
new file mode 100644
index 0000000000000..7d15a0b1e08c9
--- /dev/null
+++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_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.
+ */
+
+import { withOptionalSignal } from '../../common/with_optional_signal';
+import { useAsync } from '../../common/hooks/use_async';
+import { readListIndex } from '../api';
+
+const readListIndexWithOptionalSignal = withOptionalSignal(readListIndex);
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export const useReadListIndex = () => useAsync(readListIndexWithOptionalSignal);
diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts
new file mode 100644
index 0000000000000..313f17a3bac4b
--- /dev/null
+++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.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.
+ */
+
+import { withOptionalSignal } from '../../common/with_optional_signal';
+import { useAsync } from '../../common/hooks/use_async';
+import { readListPrivileges } from '../api';
+
+const readListPrivilegesWithOptionalSignal = withOptionalSignal(readListPrivileges);
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export const useReadListPrivileges = () => useAsync(readListPrivilegesWithOptionalSignal);
diff --git a/x-pack/plugins/lists/public/lists/types.ts b/x-pack/plugins/lists/public/lists/types.ts
index 6421ad174d4d9..95a21820536e4 100644
--- a/x-pack/plugins/lists/public/lists/types.ts
+++ b/x-pack/plugins/lists/public/lists/types.ts
@@ -14,6 +14,7 @@ export interface ApiParams {
export type ApiPayload = Omit;
export interface FindListsParams extends ApiParams {
+ cursor?: string | undefined;
pageSize: number | undefined;
pageIndex: number | undefined;
}
diff --git a/x-pack/plugins/lists/public/plugin.ts b/x-pack/plugins/lists/public/plugin.ts
new file mode 100644
index 0000000000000..717e5d2885910
--- /dev/null
+++ b/x-pack/plugins/lists/public/plugin.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 {
+ CoreSetup,
+ CoreStart,
+ Plugin as IPlugin,
+ PluginInitializerContext,
+} from '../../../../src/core/public';
+
+import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types';
+
+export class Plugin implements IPlugin {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ constructor(initializerContext: PluginInitializerContext) {} // eslint-disable-line @typescript-eslint/no-useless-constructor
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup {
+ return {};
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ public start(core: CoreStart, plugins: StartPlugins): PluginStart {
+ return {};
+ }
+}
diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/shared_exports.ts
similarity index 73%
rename from x-pack/plugins/lists/public/index.tsx
rename to x-pack/plugins/lists/public/shared_exports.ts
index 72bd46d6e2ce8..57fb2f90b6404 100644
--- a/x-pack/plugins/lists/public/index.tsx
+++ b/x-pack/plugins/lists/public/shared_exports.ts
@@ -5,6 +5,7 @@
*/
// Exports to be shared with plugins
+export { useIsMounted } from './common/hooks/use_is_mounted';
export { useApi } from './exceptions/hooks/use_api';
export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item';
export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list';
@@ -12,7 +13,12 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list';
export { useFindLists } from './lists/hooks/use_find_lists';
export { useImportList } from './lists/hooks/use_import_list';
export { useDeleteList } from './lists/hooks/use_delete_list';
+export { exportList } from './lists/api';
+export { useCursor } from './common/hooks/use_cursor';
export { useExportList } from './lists/hooks/use_export_list';
+export { useReadListIndex } from './lists/hooks/use_read_list_index';
+export { useCreateListIndex } from './lists/hooks/use_create_list_index';
+export { useReadListPrivileges } from './lists/hooks/use_read_list_privileges';
export {
addExceptionListItem,
updateExceptionListItem,
diff --git a/x-pack/plugins/lists/public/types.ts b/x-pack/plugins/lists/public/types.ts
new file mode 100644
index 0000000000000..0a9b0460614bd
--- /dev/null
+++ b/x-pack/plugins/lists/public/types.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.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface PluginSetup {}
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface PluginStart {}
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface SetupPlugins {}
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface StartPlugins {}
diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
index a6c2a18bb8c8a..a318d653450c7 100644
--- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
+++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts
@@ -44,26 +44,34 @@ export const findExceptionListItemRoute = (router: IRouter): void => {
sort_field: sortField,
sort_order: sortOrder,
} = request.query;
- const exceptionListItems = await exceptionLists.findExceptionListItem({
- filter,
- listId,
- namespaceType,
- page,
- perPage,
- sortField,
- sortOrder,
- });
- if (exceptionListItems == null) {
+
+ if (listId.length !== namespaceType.length) {
return siemResponse.error({
- body: `list id: "${listId}" does not exist`,
- statusCode: 404,
+ body: `list_id and namespace_id need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal namespace_type length: ${namespaceType.length}`,
+ statusCode: 400,
});
- }
- const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema);
- if (errors != null) {
- return siemResponse.error({ body: errors, statusCode: 500 });
} else {
- return response.ok({ body: validated ?? {} });
+ const exceptionListItems = await exceptionLists.findExceptionListsItem({
+ filter,
+ listId,
+ namespaceType,
+ page,
+ perPage,
+ sortField,
+ sortOrder,
+ });
+ if (exceptionListItems == null) {
+ return siemResponse.error({
+ body: `list id: "${listId}" does not exist`,
+ statusCode: 404,
+ });
+ }
+ const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema);
+ if (errors != null) {
+ return siemResponse.error({ body: errors, statusCode: 500 });
+ } else {
+ return response.ok({ body: validated ?? {} });
+ }
}
} catch (err) {
const error = transformError(err);
diff --git a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh
index bb431800c56c3..3241bb8411916 100755
--- a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh
+++ b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh
@@ -7,7 +7,7 @@
set -e
./check_env_variables.sh
-# Example: ./delete_all_alerts.sh
+# Example: ./delete_all_exception_lists.sh
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html
curl -s -k \
-H "Content-Type: application/json" \
diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json
index 520bc4ddf1e09..19027ac189a47 100644
--- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json
+++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json
@@ -1,8 +1,8 @@
{
- "list_id": "endpoint_list",
+ "list_id": "simple_list",
"_tags": ["endpoint", "process", "malware", "os:linux"],
"tags": ["user added string for a tag", "malware"],
- "type": "endpoint",
+ "type": "detection",
"description": "This is a sample endpoint type exception",
"name": "Sample Endpoint Exception List"
}
diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json
index 8663be5d649e5..eede855aab199 100644
--- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json
+++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json
@@ -1,6 +1,6 @@
{
- "list_id": "endpoint_list",
- "item_id": "endpoint_list_item",
+ "list_id": "simple_list",
+ "item_id": "simple_list_item",
"_tags": ["endpoint", "process", "malware", "os:linux"],
"tags": ["user added string for a tag", "malware"],
"type": "simple",
diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json
index 3d6253fcb58ad..e0d401eff9269 100644
--- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json
+++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json
@@ -18,7 +18,7 @@
"field": "source.ip",
"operator": "excluded",
"type": "list",
- "list": { "id": "list-ip", "type": "ip" }
+ "list": { "id": "ip_list", "type": "ip" }
}
]
}
diff --git a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh
index 5efad01e9a68e..ba8f1cd0477a1 100755
--- a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh
+++ b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh
@@ -21,6 +21,6 @@ pushd ${FOLDER} > /dev/null
curl -s -k -OJ \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
- -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=list-ip"
+ -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=ip_list"
popd > /dev/null
diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh
index e3f21da56d1b7..ff720afba4157 100755
--- a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh
+++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh
@@ -9,12 +9,23 @@
set -e
./check_env_variables.sh
-LIST_ID=${1:-endpoint_list}
+LIST_ID=${1:-simple_list}
NAMESPACE_TYPE=${2-single}
-# Example: ./find_exception_list_items.sh {list-id}
-# Example: ./find_exception_list_items.sh {list-id} single
-# Example: ./find_exception_list_items.sh {list-id} agnostic
+# First, post two different lists and two list items for the example to work
+# ./post_exception_list.sh ./exception_lists/new/exception_list.json
+# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json
+#
+# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json
+# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json
+
+# Querying a single list item aginst each type
+# Example: ./find_exception_list_items.sh simple_list
+# Example: ./find_exception_list_items.sh simple_list single
+# Example: ./find_exception_list_items.sh endpoint_list agnostic
+#
+# Finding multiple list id's across multiple spaces
+# Example: ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&namespace_type=${NAMESPACE_TYPE}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh
index 57313275ccd0e..79e66be42e441 100755
--- a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh
+++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh
@@ -9,7 +9,7 @@
set -e
./check_env_variables.sh
-LIST_ID=${1:-endpoint_list}
+LIST_ID=${1:-simple_list}
FILTER=${2:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'}
NAMESPACE_TYPE=${3-single}
@@ -17,13 +17,23 @@ NAMESPACE_TYPE=${3-single}
# The %22 is just an encoded quote of "
# Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic
+# First, post two different lists and two list items for the example to work
+# ./post_exception_list.sh ./exception_lists/new/exception_list.json
+# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json
#
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.entries.field:actingProcess.file.signer
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.field:actingProcess.file.signe*"
-# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.match:Elastic*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*"
+# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json
+# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json
+
+# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List
+# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single
+# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic
+#
+# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.entries.field:actingProcess.file.signer
+# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*"
+# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*"
+#
+# Example with multiplie lists, and multiple filters
+# Example: ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/find_list_items.sh b/x-pack/plugins/lists/server/scripts/find_list_items.sh
index 9c8bfd2d5a490..d475da3db61f1 100755
--- a/x-pack/plugins/lists/server/scripts/find_list_items.sh
+++ b/x-pack/plugins/lists/server/scripts/find_list_items.sh
@@ -9,11 +9,11 @@
set -e
./check_env_variables.sh
-LIST_ID=${1-list-ip}
+LIST_ID=${1-ip_list}
PAGE=${2-1}
PER_PAGE=${3-20}
-# Example: ./find_list_items.sh list-ip 1 20
+# Example: ./find_list_items.sh ip_list 1 20
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh
index 8924012cf62cf..38cef7c98994b 100755
--- a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh
+++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh
@@ -9,7 +9,7 @@
set -e
./check_env_variables.sh
-LIST_ID=${1-list-ip}
+LIST_ID=${1-ip_list}
PAGE=${2-1}
PER_PAGE=${3-20}
CURSOR=${4-invalid}
@@ -17,7 +17,7 @@ CURSOR=${4-invalid}
# Example:
# ./find_list_items.sh 1 20 | jq .cursor
# Copy the cursor into the argument below like so
-# ./find_list_items_with_cursor.sh list-ip 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ==
+# ./find_list_items_with_cursor.sh ip_list 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ==
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh
index 37d80c3dd3f28..eb4b23236b7d4 100755
--- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh
+++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh
@@ -9,13 +9,13 @@
set -e
./check_env_variables.sh
-LIST_ID=${1-list-ip}
+LIST_ID=${1-ip_list}
PAGE=${2-1}
PER_PAGE=${3-20}
SORT_FIELD=${4-value}
SORT_ORDER=${4-asc}
-# Example: ./find_list_items_with_sort.sh list-ip 1 20 value asc
+# Example: ./find_list_items_with_sort.sh ip_list 1 20 value asc
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh
index 27d8deb2fc95a..289f9be82f209 100755
--- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh
+++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh
@@ -9,14 +9,14 @@
set -e
./check_env_variables.sh
-LIST_ID=${1-list-ip}
+LIST_ID=${1-ip_list}
PAGE=${2-1}
PER_PAGE=${3-20}
SORT_FIELD=${4-value}
SORT_ORDER=${5-asc}
CURSOR=${6-invalid}
-# Example: ./find_list_items_with_sort_cursor.sh list-ip 1 20 value asc
+# Example: ./find_list_items_with_sort_cursor.sh ip_list 1 20 value asc
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq .
diff --git a/x-pack/plugins/lists/server/scripts/import_list_items.sh b/x-pack/plugins/lists/server/scripts/import_list_items.sh
index a39409cd08267..2ef01fdeed343 100755
--- a/x-pack/plugins/lists/server/scripts/import_list_items.sh
+++ b/x-pack/plugins/lists/server/scripts/import_list_items.sh
@@ -10,10 +10,10 @@ set -e
./check_env_variables.sh
# Uses a defaults if no argument is specified
-LIST_ID=${1:-list-ip}
+LIST_ID=${1:-ip_list}
FILE=${2:-./lists/files/ips.txt}
-# ./import_list_items.sh list-ip ./lists/files/ips.txt
+# ./import_list_items.sh ip_list ./lists/files/ips.txt
curl -s -k \
-H 'kbn-xsrf: 123' \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json
deleted file mode 100644
index d150cfaecc202..0000000000000
--- a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "id": "hand_inserted_item_id",
- "list_id": "list-ip",
- "value": "10.4.3.11"
-}
diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts
index a731371a6ffac..1acc880c851a6 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts
@@ -82,5 +82,5 @@ export const createExceptionListItem = async ({
type,
updated_by: user,
});
- return transformSavedObjectToExceptionListItem({ namespaceType, savedObject });
+ return transformSavedObjectToExceptionListItem({ savedObject });
};
diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts
index 73c52fb8b3ec9..62afda52bd79d 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts
@@ -21,6 +21,7 @@ import {
DeleteExceptionListOptions,
FindExceptionListItemOptions,
FindExceptionListOptions,
+ FindExceptionListsItemOptions,
GetExceptionListItemOptions,
GetExceptionListOptions,
UpdateExceptionListItemOptions,
@@ -36,6 +37,7 @@ import { deleteExceptionList } from './delete_exception_list';
import { deleteExceptionListItem } from './delete_exception_list_item';
import { findExceptionListItem } from './find_exception_list_item';
import { findExceptionList } from './find_exception_list';
+import { findExceptionListsItem } from './find_exception_list_items';
export class ExceptionListClient {
private readonly user: string;
@@ -229,6 +231,28 @@ export class ExceptionListClient {
});
};
+ public findExceptionListsItem = async ({
+ listId,
+ filter,
+ perPage,
+ page,
+ sortField,
+ sortOrder,
+ namespaceType,
+ }: FindExceptionListsItemOptions): Promise => {
+ const { savedObjectsClient } = this;
+ return findExceptionListsItem({
+ filter,
+ listId,
+ namespaceType,
+ page,
+ perPage,
+ savedObjectsClient,
+ sortField,
+ sortOrder,
+ });
+ };
+
public findExceptionList = async ({
filter,
perPage,
diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts
index 3eff2c7e202e7..b3070f2d4a70d 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts
@@ -6,6 +6,9 @@
import { SavedObjectsClientContract } from 'kibana/server';
+import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
+import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array';
+import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array';
import {
CreateCommentsArray,
Description,
@@ -127,6 +130,16 @@ export interface FindExceptionListItemOptions {
sortOrder: SortOrderOrUndefined;
}
+export interface FindExceptionListsItemOptions {
+ listId: NonEmptyStringArrayDecoded;
+ namespaceType: NamespaceTypeArray;
+ filter: EmptyStringArrayDecoded;
+ perPage: PerPageOrUndefined;
+ page: PageOrUndefined;
+ sortField: SortFieldOrUndefined;
+ sortOrder: SortOrderOrUndefined;
+}
+
export interface FindExceptionListOptions {
namespaceType: NamespaceType;
filter: FilterOrUndefined;
diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts
index 1c3103ad1db7e..e997ff5f9adf1 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts
@@ -7,7 +7,6 @@
import { SavedObjectsClientContract } from 'kibana/server';
import {
- ExceptionListSoSchema,
FilterOrUndefined,
FoundExceptionListItemSchema,
ListId,
@@ -17,10 +16,8 @@ import {
SortFieldOrUndefined,
SortOrderOrUndefined,
} from '../../../common/schemas';
-import { SavedObjectType } from '../../saved_objects';
-import { getSavedObjectType, transformSavedObjectsToFoundExceptionListItem } from './utils';
-import { getExceptionList } from './get_exception_list';
+import { findExceptionListsItem } from './find_exception_list_items';
interface FindExceptionListItemOptions {
listId: ListId;
@@ -43,43 +40,14 @@ export const findExceptionListItem = async ({
sortField,
sortOrder,
}: FindExceptionListItemOptions): Promise => {
- const savedObjectType = getSavedObjectType({ namespaceType });
- const exceptionList = await getExceptionList({
- id: undefined,
- listId,
- namespaceType,
+ return findExceptionListsItem({
+ filter: filter != null ? [filter] : [],
+ listId: [listId],
+ namespaceType: [namespaceType],
+ page,
+ perPage,
savedObjectsClient,
+ sortField,
+ sortOrder,
});
- if (exceptionList == null) {
- return null;
- } else {
- const savedObjectsFindResponse = await savedObjectsClient.find({
- filter: getExceptionListItemFilter({ filter, listId, savedObjectType }),
- page,
- perPage,
- sortField,
- sortOrder,
- type: savedObjectType,
- });
- return transformSavedObjectsToFoundExceptionListItem({
- namespaceType,
- savedObjectsFindResponse,
- });
- }
-};
-
-export const getExceptionListItemFilter = ({
- filter,
- listId,
- savedObjectType,
-}: {
- listId: ListId;
- filter: FilterOrUndefined;
- savedObjectType: SavedObjectType;
-}): string => {
- if (filter == null) {
- return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId}`;
- } else {
- return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId} AND ${filter}`;
- }
};
diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts
new file mode 100644
index 0000000000000..a2fbb39103769
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts
@@ -0,0 +1,94 @@
+/*
+ * 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 { LIST_ID } from '../../../common/constants.mock';
+
+import { getExceptionListsItemFilter } from './find_exception_list_items';
+
+describe('find_exception_list_items', () => {
+ describe('getExceptionListsItemFilter', () => {
+ test('It should create a filter with a single listId with an empty filter', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: [],
+ listId: [LIST_ID],
+ savedObjectType: ['exception-list'],
+ });
+ expect(filter).toEqual(
+ '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id)'
+ );
+ });
+
+ test('It should create a filter with a single listId with a single filter', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'],
+ listId: [LIST_ID],
+ savedObjectType: ['exception-list'],
+ });
+ expect(filter).toEqual(
+ '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id) AND exception-list.attributes.name: "Sample Endpoint Exception List")'
+ );
+ });
+
+ test('It should create a filter with 2 listIds and an empty filter', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: [],
+ listId: ['list-1', 'list-2'],
+ savedObjectType: ['exception-list', 'exception-list-agnostic'],
+ });
+ expect(filter).toEqual(
+ '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)'
+ );
+ });
+
+ test('It should create a filter with 2 listIds and a single filter', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'],
+ listId: ['list-1', 'list-2'],
+ savedObjectType: ['exception-list', 'exception-list-agnostic'],
+ });
+ expect(filter).toEqual(
+ '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)'
+ );
+ });
+
+ test('It should create a filter with 3 listIds and an empty filter', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: [],
+ listId: ['list-1', 'list-2', 'list-3'],
+ savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'],
+ });
+ expect(filter).toEqual(
+ '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)'
+ );
+ });
+
+ test('It should create a filter with 3 listIds and a single filter for the first item', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'],
+ listId: ['list-1', 'list-2', 'list-3'],
+ savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'],
+ });
+ expect(filter).toEqual(
+ '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)'
+ );
+ });
+
+ test('It should create a filter with 3 listIds and 3 filters for each', () => {
+ const filter = getExceptionListsItemFilter({
+ filter: [
+ 'exception-list.attributes.name: "Sample Endpoint Exception List 1"',
+ 'exception-list.attributes.name: "Sample Endpoint Exception List 2"',
+ 'exception-list.attributes.name: "Sample Endpoint Exception List 3"',
+ ],
+ listId: ['list-1', 'list-2', 'list-3'],
+ savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'],
+ });
+ expect(filter).toEqual(
+ '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3) AND exception-list.attributes.name: "Sample Endpoint Exception List 3")'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts
new file mode 100644
index 0000000000000..47a0d809cce67
--- /dev/null
+++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts
@@ -0,0 +1,94 @@
+/*
+ * 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 { SavedObjectsClientContract } from 'kibana/server';
+
+import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array';
+import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
+import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array';
+import {
+ ExceptionListSoSchema,
+ FoundExceptionListItemSchema,
+ PageOrUndefined,
+ PerPageOrUndefined,
+ SortFieldOrUndefined,
+ SortOrderOrUndefined,
+} from '../../../common/schemas';
+import { SavedObjectType } from '../../saved_objects';
+
+import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils';
+import { getExceptionList } from './get_exception_list';
+
+interface FindExceptionListItemsOptions {
+ listId: NonEmptyStringArrayDecoded;
+ namespaceType: NamespaceTypeArray;
+ savedObjectsClient: SavedObjectsClientContract;
+ filter: EmptyStringArrayDecoded;
+ perPage: PerPageOrUndefined;
+ page: PageOrUndefined;
+ sortField: SortFieldOrUndefined;
+ sortOrder: SortOrderOrUndefined;
+}
+
+export const findExceptionListsItem = async ({
+ listId,
+ namespaceType,
+ savedObjectsClient,
+ filter,
+ page,
+ perPage,
+ sortField,
+ sortOrder,
+}: FindExceptionListItemsOptions): Promise => {
+ const savedObjectType = getSavedObjectTypes({ namespaceType });
+ const exceptionLists = (
+ await Promise.all(
+ listId.map((singleListId, index) => {
+ return getExceptionList({
+ id: undefined,
+ listId: singleListId,
+ namespaceType: namespaceType[index],
+ savedObjectsClient,
+ });
+ })
+ )
+ ).filter((list) => list != null);
+ if (exceptionLists.length === 0) {
+ return null;
+ } else {
+ const savedObjectsFindResponse = await savedObjectsClient.find({
+ filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }),
+ page,
+ perPage,
+ sortField,
+ sortOrder,
+ type: savedObjectType,
+ });
+ return transformSavedObjectsToFoundExceptionListItem({
+ savedObjectsFindResponse,
+ });
+ }
+};
+
+export const getExceptionListsItemFilter = ({
+ filter,
+ listId,
+ savedObjectType,
+}: {
+ listId: NonEmptyStringArrayDecoded;
+ filter: EmptyStringArrayDecoded;
+ savedObjectType: SavedObjectType[];
+}): string => {
+ return listId.reduce((accum, singleListId, index) => {
+ const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: ${singleListId})`;
+ const listItemAppendWithFilter =
+ filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend;
+ if (accum === '') {
+ return listItemAppendWithFilter;
+ } else {
+ return `${accum} OR ${listItemAppendWithFilter}`;
+ }
+ }, '');
+};
diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts
index d7efdc054c48c..d68863c02148f 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts
@@ -35,7 +35,7 @@ export const getExceptionListItem = async ({
if (id != null) {
try {
const savedObject = await savedObjectsClient.get(savedObjectType, id);
- return transformSavedObjectToExceptionListItem({ namespaceType, savedObject });
+ return transformSavedObjectToExceptionListItem({ savedObject });
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
return null;
@@ -55,7 +55,6 @@ export const getExceptionListItem = async ({
});
if (savedObject.saved_objects[0] != null) {
return transformSavedObjectToExceptionListItem({
- namespaceType,
savedObject: savedObject.saved_objects[0],
});
} else {
diff --git a/x-pack/plugins/lists/server/services/exception_lists/index.ts b/x-pack/plugins/lists/server/services/exception_lists/index.ts
index a66f00819605b..510b2c70c6c94 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/index.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/index.ts
@@ -4,13 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export * from './create_exception_list_item';
export * from './create_exception_list';
-export * from './delete_exception_list_item';
+export * from './create_exception_list_item';
export * from './delete_exception_list';
+export * from './delete_exception_list_item';
+export * from './delete_exception_list_items_by_list';
export * from './find_exception_list';
export * from './find_exception_list_item';
-export * from './get_exception_list_item';
+export * from './find_exception_list_items';
export * from './get_exception_list';
-export * from './update_exception_list_item';
+export * from './get_exception_list_item';
export * from './update_exception_list';
+export * from './update_exception_list_item';
diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts
index ab54647430b9b..ad1e1a3439d7c 100644
--- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts
+++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts
@@ -6,6 +6,7 @@
import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server';
+import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array';
import { ErrorWithStatusCode } from '../../error_with_status_code';
import {
Comments,
@@ -42,6 +43,28 @@ export const getSavedObjectType = ({
}
};
+export const getExceptionListType = ({
+ savedObjectType,
+}: {
+ savedObjectType: string;
+}): NamespaceType => {
+ if (savedObjectType === exceptionListAgnosticSavedObjectType) {
+ return 'agnostic';
+ } else {
+ return 'single';
+ }
+};
+
+export const getSavedObjectTypes = ({
+ namespaceType,
+}: {
+ namespaceType: NamespaceTypeArray;
+}): SavedObjectType[] => {
+ return namespaceType.map((singleNamespaceType) =>
+ getSavedObjectType({ namespaceType: singleNamespaceType })
+ );
+};
+
export const transformSavedObjectToExceptionList = ({
savedObject,
namespaceType,
@@ -126,10 +149,8 @@ export const transformSavedObjectUpdateToExceptionList = ({
export const transformSavedObjectToExceptionListItem = ({
savedObject,
- namespaceType,
}: {
savedObject: SavedObject;
- namespaceType: NamespaceType;
}): ExceptionListItemSchema => {
const dateNow = new Date().toISOString();
const {
@@ -167,7 +188,7 @@ export const transformSavedObjectToExceptionListItem = ({
list_id,
meta,
name,
- namespace_type: namespaceType,
+ namespace_type: getExceptionListType({ savedObjectType: savedObject.type }),
tags,
tie_breaker_id,
type: exceptionListItemType.is(type) ? type : 'simple',
@@ -229,14 +250,12 @@ export const transformSavedObjectUpdateToExceptionListItem = ({
export const transformSavedObjectsToFoundExceptionListItem = ({
savedObjectsFindResponse,
- namespaceType,
}: {
savedObjectsFindResponse: SavedObjectsFindResponse;
- namespaceType: NamespaceType;
}): FoundExceptionListItemSchema => {
return {
data: savedObjectsFindResponse.saved_objects.map((savedObject) =>
- transformSavedObjectToExceptionListItem({ namespaceType, savedObject })
+ transformSavedObjectToExceptionListItem({ savedObject })
),
page: savedObjectsFindResponse.page,
per_page: savedObjectsFindResponse.per_page,
diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts
index c7bfe94742bd6..1bd8c5401eb1d 100644
--- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts
+++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts
@@ -5,7 +5,7 @@
*/
/* eslint-disable @typescript-eslint/consistent-type-definitions */
-import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants';
+import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants';
import { MapExtent, MapQuery } from './map_descriptor';
import { Filter, TimeRange } from '../../../../../src/plugins/data/common';
@@ -26,10 +26,12 @@ type ESSearchSourceSyncMeta = {
scalingType: SCALING_TYPES;
topHitsSplitField: string;
topHitsSize: number;
+ sourceType: SOURCE_TYPES.ES_SEARCH;
};
type ESGeoGridSourceSyncMeta = {
requestType: RENDER_AS;
+ sourceType: SOURCE_TYPES.ES_GEO_GRID;
};
export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null;
@@ -51,7 +53,6 @@ export type VectorStyleRequestMeta = MapFilters & {
export type ESSearchSourceResponseMeta = {
areResultsTrimmed?: boolean;
- sourceType?: string;
// top hits meta
areEntitiesTrimmed?: boolean;
diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/sources.ts
index e32b5f44c8272..7eda37bf53351 100644
--- a/x-pack/plugins/maps/common/descriptor_types/sources.ts
+++ b/x-pack/plugins/maps/common/descriptor_types/sources.ts
@@ -77,8 +77,8 @@ export type ESPewPewSourceDescriptor = AbstractESAggSourceDescriptor & {
};
export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & {
- indexPatternTitle: string;
- term: string; // term field name
+ indexPatternTitle?: string;
+ term?: string; // term field name
whereQuery?: Query;
};
@@ -138,7 +138,7 @@ export type GeojsonFileSourceDescriptor = {
};
export type JoinDescriptor = {
- leftField: string;
+ leftField?: string;
right: ESTermSourceDescriptor;
};
diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json
index e422efb31cb0d..fbf45aee02125 100644
--- a/x-pack/plugins/maps/kibana.json
+++ b/x-pack/plugins/maps/kibana.json
@@ -21,7 +21,6 @@
"server": true,
"extraPublicDirs": ["common/constants"],
"requiredBundles": [
- "charts",
"kibanaReact",
"kibanaUtils",
"savedObjects"
diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts
index 551e20fc5ceb5..26a0ffc1b1a37 100644
--- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts
+++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts
@@ -126,7 +126,7 @@ function getClusterStyleDescriptor(
),
}
: undefined;
- // @ts-ignore
+ // @ts-expect-error
clusterStyleDescriptor.properties[styleName] = {
type: STYLE_TYPE.DYNAMIC,
options: {
@@ -136,7 +136,7 @@ function getClusterStyleDescriptor(
};
} else {
// copy static styles to cluster style
- // @ts-ignore
+ // @ts-expect-error
clusterStyleDescriptor.properties[styleName] = {
type: STYLE_TYPE.STATIC,
options: { ...styleProperty.getOptions() },
@@ -192,8 +192,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer {
const requestMeta = sourceDataRequest.getMeta();
if (
requestMeta &&
- requestMeta.sourceType &&
- requestMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID
+ requestMeta.sourceMeta &&
+ requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID
) {
isClustered = true;
}
@@ -220,8 +220,12 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer {
: displayName;
}
- isJoinable() {
- return false;
+ showJoinEditor() {
+ return true;
+ }
+
+ getJoinsDisabledReason() {
+ return this._documentSource.getJoinsDisabledReason();
}
getJoins() {
diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx
index d6f6ee8fa609b..d8def155a9185 100644
--- a/x-pack/plugins/maps/public/classes/layers/layer.tsx
+++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx
@@ -78,6 +78,8 @@ export interface ILayer {
isPreviewLayer: () => boolean;
areLabelsOnTop: () => boolean;
supportsLabelsOnTop: () => boolean;
+ showJoinEditor(): boolean;
+ getJoinsDisabledReason(): string | null;
}
export type Footnote = {
icon: ReactElement;
@@ -141,13 +143,12 @@ export class AbstractLayer implements ILayer {
}
static getBoundDataForSource(mbMap: unknown, sourceId: string): FeatureCollection {
- // @ts-ignore
+ // @ts-expect-error
const mbStyle = mbMap.getStyle();
return mbStyle.sources[sourceId].data;
}
async cloneDescriptor(): Promise {
- // @ts-ignore
const clonedDescriptor = copyPersistentState(this._descriptor);
// layer id is uuid used to track styles/layers in mapbox
clonedDescriptor.id = uuid();
@@ -155,14 +156,10 @@ export class AbstractLayer implements ILayer {
clonedDescriptor.label = `Clone of ${displayName}`;
clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor();
- // todo: remove this
- // This should not be in AbstractLayer. It relies on knowledge of VectorLayerDescriptor
- // @ts-ignore
if (clonedDescriptor.joins) {
- // @ts-ignore
+ // @ts-expect-error
clonedDescriptor.joins.forEach((joinDescriptor) => {
// right.id is uuid used to track requests in inspector
- // @ts-ignore
joinDescriptor.right.id = uuid();
});
}
@@ -173,8 +170,12 @@ export class AbstractLayer implements ILayer {
return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`;
}
- isJoinable(): boolean {
- return this.getSource().isJoinable();
+ showJoinEditor(): boolean {
+ return this.getSource().showJoinEditor();
+ }
+
+ getJoinsDisabledReason() {
+ return this.getSource().getJoinsDisabledReason();
}
isPreviewLayer(): boolean {
@@ -394,7 +395,6 @@ export class AbstractLayer implements ILayer {
const requestTokens = this._dataRequests.map((dataRequest) => dataRequest.getRequestToken());
// Compact removes all the undefineds
- // @ts-ignore
return _.compact(requestTokens);
}
@@ -478,7 +478,7 @@ export class AbstractLayer implements ILayer {
}
syncVisibilityWithMb(mbMap: unknown, mbLayerId: string) {
- // @ts-ignore
+ // @ts-expect-error
mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none');
}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx
index 715c16b22dc51..ee97fdd0a2bf6 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx
@@ -28,7 +28,7 @@ import {
VECTOR_STYLES,
STYLE_TYPE,
} from '../../../../common/constants';
-import { COLOR_GRADIENTS } from '../../styles/color_utils';
+import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes';
export const clustersLayerWizardConfig: LayerWizard = {
categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH],
@@ -57,7 +57,7 @@ export const clustersLayerWizardConfig: LayerWizard = {
name: COUNT_PROP_NAME,
origin: FIELD_ORIGIN.SOURCE,
},
- color: COLOR_GRADIENTS[0].value,
+ color: NUMERICAL_COLOR_PALETTES[0].value,
type: COLOR_MAP_TYPE.ORDINAL,
},
},
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
index 9431fb55dc88b..1be74140fe1bf 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js
@@ -63,6 +63,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
getSyncMeta() {
return {
requestType: this._descriptor.requestType,
+ sourceType: SOURCE_TYPES.ES_GEO_GRID,
};
}
@@ -103,7 +104,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
return true;
}
- isJoinable() {
+ showJoinEditor() {
return false;
}
@@ -307,7 +308,6 @@ export class ESGeoGridSource extends AbstractESAggSource {
},
meta: {
areResultsTrimmed: false,
- sourceType: SOURCE_TYPES.ES_GEO_GRID,
},
};
}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
index a4cff7c89a011..98db7bcdcc8a3 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js
@@ -51,7 +51,7 @@ export class ESPewPewSource extends AbstractESAggSource {
return true;
}
- isJoinable() {
+ showJoinEditor() {
return false;
}
diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx
index ae7414b827c8d..fee84d0208978 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx
+++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx
@@ -18,7 +18,7 @@ import {
VECTOR_STYLES,
STYLE_TYPE,
} from '../../../../common/constants';
-import { COLOR_GRADIENTS } from '../../styles/color_utils';
+import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes';
// @ts-ignore
import { CreateSourceEditor } from './create_source_editor';
import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry';
@@ -50,7 +50,7 @@ export const point2PointLayerWizardConfig: LayerWizard = {
name: COUNT_PROP_NAME,
origin: FIELD_ORIGIN.SOURCE,
},
- color: COLOR_GRADIENTS[0].value,
+ color: NUMERICAL_COLOR_PALETTES[0].value,
},
},
[VECTOR_STYLES.LINE_WIDTH]: {
diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
index c8f14f1dc6a4b..330fa6e8318ed 100644
--- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js
@@ -385,7 +385,7 @@ export class ESSearchSource extends AbstractESSource {
return {
data: featureCollection,
- meta: { ...meta, sourceType: SOURCE_TYPES.ES_SEARCH },
+ meta,
};
}
@@ -540,6 +540,7 @@ export class ESSearchSource extends AbstractESSource {
scalingType: this._descriptor.scalingType,
topHitsSplitField: this._descriptor.topHitsSplitField,
topHitsSize: this._descriptor.topHitsSize,
+ sourceType: SOURCE_TYPES.ES_SEARCH,
};
}
@@ -551,6 +552,14 @@ export class ESSearchSource extends AbstractESSource {
path: geoField.name,
};
}
+
+ getJoinsDisabledReason() {
+ return this._descriptor.scalingType === SCALING_TYPES.CLUSTERS
+ ? i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', {
+ defaultMessage: 'Joins are not supported when scaling by clusters',
+ })
+ : null;
+ }
}
registerSource({
diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts
index c68e22ada8b0c..696c07376575b 100644
--- a/x-pack/plugins/maps/public/classes/sources/source.ts
+++ b/x-pack/plugins/maps/public/classes/sources/source.ts
@@ -54,7 +54,8 @@ export interface ISource {
isESSource(): boolean;
renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement | null;
supportsFitToBounds(): Promise;
- isJoinable(): boolean;
+ showJoinEditor(): boolean;
+ getJoinsDisabledReason(): string | null;
cloneDescriptor(): SourceDescriptor;
getFieldNames(): string[];
getApplyGlobalQuery(): boolean;
@@ -80,7 +81,6 @@ export class AbstractSource implements ISource {
destroy(): void {}
cloneDescriptor(): SourceDescriptor {
- // @ts-ignore
return copyPersistentState(this._descriptor);
}
@@ -148,10 +148,14 @@ export class AbstractSource implements ISource {
return 0;
}
- isJoinable(): boolean {
+ showJoinEditor(): boolean {
return false;
}
+ getJoinsDisabledReason() {
+ return null;
+ }
+
isESSource(): boolean {
return false;
}
diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
index ecb13bb875721..98ed89a6ff0ad 100644
--- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
+++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js
@@ -122,7 +122,7 @@ export class AbstractVectorSource extends AbstractSource {
return false;
}
- isJoinable() {
+ showJoinEditor() {
return true;
}
diff --git a/x-pack/plugins/maps/public/classes/styles/_index.scss b/x-pack/plugins/maps/public/classes/styles/_index.scss
index 3ee713ffc1a02..bd1467bed9d4e 100644
--- a/x-pack/plugins/maps/public/classes/styles/_index.scss
+++ b/x-pack/plugins/maps/public/classes/styles/_index.scss
@@ -1,4 +1,4 @@
-@import 'components/color_gradient';
+@import 'heatmap/components/legend/color_gradient';
@import 'vector/components/style_prop_editor';
@import 'vector/components/color/color_stops';
@import 'vector/components/symbol/icon_select';
diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts
new file mode 100644
index 0000000000000..b964ecf6d6b63
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import {
+ getColorRampCenterColor,
+ getOrdinalMbColorRampStops,
+ getColorPalette,
+} from './color_palettes';
+
+describe('getColorPalette', () => {
+ it('Should create RGB color ramp', () => {
+ expect(getColorPalette('Blues')).toEqual([
+ '#ecf1f7',
+ '#d9e3ef',
+ '#c5d5e7',
+ '#b2c7df',
+ '#9eb9d8',
+ '#8bacd0',
+ '#769fc8',
+ '#6092c0',
+ ]);
+ });
+});
+
+describe('getColorRampCenterColor', () => {
+ it('Should get center color from color ramp', () => {
+ expect(getColorRampCenterColor('Blues')).toBe('#9eb9d8');
+ });
+});
+
+describe('getOrdinalMbColorRampStops', () => {
+ it('Should create color stops for custom range', () => {
+ expect(getOrdinalMbColorRampStops('Blues', 0, 1000)).toEqual([
+ 0,
+ '#ecf1f7',
+ 125,
+ '#d9e3ef',
+ 250,
+ '#c5d5e7',
+ 375,
+ '#b2c7df',
+ 500,
+ '#9eb9d8',
+ 625,
+ '#8bacd0',
+ 750,
+ '#769fc8',
+ 875,
+ '#6092c0',
+ ]);
+ });
+
+ it('Should snap to end of color stops for identical range', () => {
+ expect(getOrdinalMbColorRampStops('Blues', 23, 23)).toEqual([23, '#6092c0']);
+ });
+});
diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts
new file mode 100644
index 0000000000000..e7574b4e7b3e4
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts
@@ -0,0 +1,172 @@
+/*
+ * 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 tinycolor from 'tinycolor2';
+import {
+ // @ts-ignore
+ euiPaletteForStatus,
+ // @ts-ignore
+ euiPaletteForTemperature,
+ // @ts-ignore
+ euiPaletteCool,
+ // @ts-ignore
+ euiPaletteWarm,
+ // @ts-ignore
+ euiPaletteNegative,
+ // @ts-ignore
+ euiPalettePositive,
+ // @ts-ignore
+ euiPaletteGray,
+ // @ts-ignore
+ euiPaletteColorBlind,
+} from '@elastic/eui/lib/services';
+import { EuiColorPalettePickerPaletteProps } from '@elastic/eui';
+
+export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic';
+
+export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind();
+export const DEFAULT_LINE_COLORS: string[] = [
+ ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()),
+ // Explicitly add black & white as border color options
+ '#000',
+ '#FFF',
+];
+
+const COLOR_PALETTES: EuiColorPalettePickerPaletteProps[] = [
+ {
+ value: 'Blues',
+ palette: euiPaletteCool(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Greens',
+ palette: euiPalettePositive(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Greys',
+ palette: euiPaletteGray(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Reds',
+ palette: euiPaletteNegative(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Yellow to Red',
+ palette: euiPaletteWarm(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Green to Red',
+ palette: euiPaletteForStatus(8),
+ type: 'gradient',
+ },
+ {
+ value: 'Blue to Red',
+ palette: euiPaletteForTemperature(8),
+ type: 'gradient',
+ },
+ {
+ value: DEFAULT_HEATMAP_COLOR_RAMP_NAME,
+ palette: [
+ 'rgb(65, 105, 225)', // royalblue
+ 'rgb(0, 256, 256)', // cyan
+ 'rgb(0, 256, 0)', // lime
+ 'rgb(256, 256, 0)', // yellow
+ 'rgb(256, 0, 0)', // red
+ ],
+ type: 'gradient',
+ },
+ {
+ value: 'palette_0',
+ palette: euiPaletteColorBlind(),
+ type: 'fixed',
+ },
+ {
+ value: 'palette_20',
+ palette: euiPaletteColorBlind({ rotations: 2 }),
+ type: 'fixed',
+ },
+ {
+ value: 'palette_30',
+ palette: euiPaletteColorBlind({ rotations: 3 }),
+ type: 'fixed',
+ },
+];
+
+export const NUMERICAL_COLOR_PALETTES = COLOR_PALETTES.filter(
+ (palette: EuiColorPalettePickerPaletteProps) => {
+ return palette.type === 'gradient';
+ }
+);
+
+export const CATEGORICAL_COLOR_PALETTES = COLOR_PALETTES.filter(
+ (palette: EuiColorPalettePickerPaletteProps) => {
+ return palette.type === 'fixed';
+ }
+);
+
+export function getColorPalette(colorPaletteId: string): string[] {
+ const colorPalette = COLOR_PALETTES.find(({ value }: EuiColorPalettePickerPaletteProps) => {
+ return value === colorPaletteId;
+ });
+ return colorPalette ? (colorPalette.palette as string[]) : [];
+}
+
+export function getColorRampCenterColor(colorPaletteId: string): string | null {
+ if (!colorPaletteId) {
+ return null;
+ }
+ const palette = getColorPalette(colorPaletteId);
+ return palette.length === 0 ? null : palette[Math.floor(palette.length / 2)];
+}
+
+// Returns an array of color stops
+// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ]
+export function getOrdinalMbColorRampStops(
+ colorPaletteId: string,
+ min: number,
+ max: number
+): Array | null {
+ if (!colorPaletteId) {
+ return null;
+ }
+
+ if (min > max) {
+ return null;
+ }
+
+ const palette = getColorPalette(colorPaletteId);
+ if (palette.length === 0) {
+ return null;
+ }
+
+ if (max === min) {
+ // just return single stop value
+ return [max, palette[palette.length - 1]];
+ }
+
+ const delta = max - min;
+ return palette.reduce(
+ (accu: Array, stopColor: string, idx: number, srcArr: string[]) => {
+ const stopNumber = min + (delta * idx) / srcArr.length;
+ return [...accu, stopNumber, stopColor];
+ },
+ []
+ );
+}
+
+export function getLinearGradient(colorStrings: string[]): string {
+ const intervals = colorStrings.length;
+ let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`;
+ for (let i = 1; i < intervals - 1; i++) {
+ linearGradient = `${linearGradient} ${colorStrings[i]} \
+ ${Math.floor((100 * i) / (intervals - 1))}%,`;
+ }
+ return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`;
+}
diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts b/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts
deleted file mode 100644
index ed7cafd53a6fc..0000000000000
--- a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts
+++ /dev/null
@@ -1,104 +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 {
- COLOR_GRADIENTS,
- getColorRampCenterColor,
- getOrdinalMbColorRampStops,
- getHexColorRangeStrings,
- getLinearGradient,
- getRGBColorRangeStrings,
-} from './color_utils';
-
-jest.mock('ui/new_platform');
-
-describe('COLOR_GRADIENTS', () => {
- it('Should contain EuiSuperSelect options list of color ramps', () => {
- expect(COLOR_GRADIENTS.length).toBe(6);
- const colorGradientOption = COLOR_GRADIENTS[0];
- expect(colorGradientOption.value).toBe('Blues');
- });
-});
-
-describe('getRGBColorRangeStrings', () => {
- it('Should create RGB color ramp', () => {
- expect(getRGBColorRangeStrings('Blues', 8)).toEqual([
- 'rgb(247,250,255)',
- 'rgb(221,234,247)',
- 'rgb(197,218,238)',
- 'rgb(157,201,224)',
- 'rgb(106,173,213)',
- 'rgb(65,145,197)',
- 'rgb(32,112,180)',
- 'rgb(7,47,107)',
- ]);
- });
-});
-
-describe('getHexColorRangeStrings', () => {
- it('Should create HEX color ramp', () => {
- expect(getHexColorRangeStrings('Blues')).toEqual([
- '#f7faff',
- '#ddeaf7',
- '#c5daee',
- '#9dc9e0',
- '#6aadd5',
- '#4191c5',
- '#2070b4',
- '#072f6b',
- ]);
- });
-});
-
-describe('getColorRampCenterColor', () => {
- it('Should get center color from color ramp', () => {
- expect(getColorRampCenterColor('Blues')).toBe('rgb(106,173,213)');
- });
-});
-
-describe('getColorRampStops', () => {
- it('Should create color stops for custom range', () => {
- expect(getOrdinalMbColorRampStops('Blues', 0, 1000, 8)).toEqual([
- 0,
- '#f7faff',
- 125,
- '#ddeaf7',
- 250,
- '#c5daee',
- 375,
- '#9dc9e0',
- 500,
- '#6aadd5',
- 625,
- '#4191c5',
- 750,
- '#2070b4',
- 875,
- '#072f6b',
- ]);
- });
-
- it('Should snap to end of color stops for identical range', () => {
- expect(getOrdinalMbColorRampStops('Blues', 23, 23, 8)).toEqual([23, '#072f6b']);
- });
-});
-
-describe('getLinearGradient', () => {
- it('Should create linear gradient from color ramp', () => {
- const colorRamp = [
- 'rgb(247,250,255)',
- 'rgb(221,234,247)',
- 'rgb(197,218,238)',
- 'rgb(157,201,224)',
- 'rgb(106,173,213)',
- 'rgb(65,145,197)',
- 'rgb(32,112,180)',
- 'rgb(7,47,107)',
- ];
- expect(getLinearGradient(colorRamp)).toBe(
- 'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)'
- );
- });
-});
diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx
deleted file mode 100644
index 0192a9d7ca68f..0000000000000
--- a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx
+++ /dev/null
@@ -1,174 +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 from 'react';
-import tinycolor from 'tinycolor2';
-import chroma from 'chroma-js';
-// @ts-ignore
-import { euiPaletteColorBlind } from '@elastic/eui/lib/services';
-import { ColorGradient } from './components/color_gradient';
-import { RawColorSchema, vislibColorMaps } from '../../../../../../src/plugins/charts/public';
-
-export const GRADIENT_INTERVALS = 8;
-
-export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind();
-export const DEFAULT_LINE_COLORS: string[] = [
- ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()),
- // Explicitly add black & white as border color options
- '#000',
- '#FFF',
-];
-
-function getRGBColors(colorRamp: Array<[number, number[]]>, numLegendColors: number = 4): string[] {
- const colors = [];
- colors[0] = getRGBColor(colorRamp, 0);
- for (let i = 1; i < numLegendColors - 1; i++) {
- colors[i] = getRGBColor(colorRamp, Math.floor((colorRamp.length * i) / numLegendColors));
- }
- colors[numLegendColors - 1] = getRGBColor(colorRamp, colorRamp.length - 1);
- return colors;
-}
-
-function getRGBColor(colorRamp: Array<[number, number[]]>, i: number): string {
- const rgbArray = colorRamp[i][1];
- const red = Math.floor(rgbArray[0] * 255);
- const green = Math.floor(rgbArray[1] * 255);
- const blue = Math.floor(rgbArray[2] * 255);
- return `rgb(${red},${green},${blue})`;
-}
-
-function getColorSchema(colorRampName: string): RawColorSchema {
- const colorSchema = vislibColorMaps[colorRampName];
- if (!colorSchema) {
- throw new Error(
- `${colorRampName} not found. Expected one of following values: ${Object.keys(
- vislibColorMaps
- )}`
- );
- }
- return colorSchema;
-}
-
-export function getRGBColorRangeStrings(
- colorRampName: string,
- numberColors: number = GRADIENT_INTERVALS
-): string[] {
- const colorSchema = getColorSchema(colorRampName);
- return getRGBColors(colorSchema.value, numberColors);
-}
-
-export function getHexColorRangeStrings(
- colorRampName: string,
- numberColors: number = GRADIENT_INTERVALS
-): string[] {
- return getRGBColorRangeStrings(colorRampName, numberColors).map((rgbColor) =>
- chroma(rgbColor).hex()
- );
-}
-
-export function getColorRampCenterColor(colorRampName: string): string | null {
- if (!colorRampName) {
- return null;
- }
- const colorSchema = getColorSchema(colorRampName);
- const centerIndex = Math.floor(colorSchema.value.length / 2);
- return getRGBColor(colorSchema.value, centerIndex);
-}
-
-// Returns an array of color stops
-// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ]
-export function getOrdinalMbColorRampStops(
- colorRampName: string,
- min: number,
- max: number,
- numberColors: number
-): Array | null {
- if (!colorRampName) {
- return null;
- }
-
- if (min > max) {
- return null;
- }
-
- const hexColors = getHexColorRangeStrings(colorRampName, numberColors);
- if (max === min) {
- // just return single stop value
- return [max, hexColors[hexColors.length - 1]];
- }
-
- const delta = max - min;
- return hexColors.reduce(
- (accu: Array, stopColor: string, idx: number, srcArr: string[]) => {
- const stopNumber = min + (delta * idx) / srcArr.length;
- return [...accu, stopNumber, stopColor];
- },
- []
- );
-}
-
-export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map((colorRampName) => ({
- value: colorRampName,
- inputDisplay: ,
-}));
-
-export const COLOR_RAMP_NAMES = Object.keys(vislibColorMaps);
-
-export function getLinearGradient(colorStrings: string[]): string {
- const intervals = colorStrings.length;
- let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`;
- for (let i = 1; i < intervals - 1; i++) {
- linearGradient = `${linearGradient} ${colorStrings[i]} \
- ${Math.floor((100 * i) / (intervals - 1))}%,`;
- }
- return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`;
-}
-
-export interface ColorPalette {
- id: string;
- colors: string[];
-}
-
-const COLOR_PALETTES_CONFIGS: ColorPalette[] = [
- {
- id: 'palette_0',
- colors: euiPaletteColorBlind(),
- },
- {
- id: 'palette_20',
- colors: euiPaletteColorBlind({ rotations: 2 }),
- },
- {
- id: 'palette_30',
- colors: euiPaletteColorBlind({ rotations: 3 }),
- },
-];
-
-export function getColorPalette(paletteId: string): string[] | null {
- const palette = COLOR_PALETTES_CONFIGS.find(({ id }: ColorPalette) => id === paletteId);
- return palette ? palette.colors : null;
-}
-
-export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map((palette) => {
- const paletteDisplay = palette.colors.map((color) => {
- const style: React.CSSProperties = {
- backgroundColor: color,
- width: `${100 / palette.colors.length}%`,
- position: 'relative',
- height: '100%',
- display: 'inline-block',
- };
- return (
-
-
-
- );
- });
- return {
- value: palette.id,
- inputDisplay: {paletteDisplay}
,
- };
-});
diff --git a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx
deleted file mode 100644
index b29146062e46d..0000000000000
--- a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx
+++ /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 React from 'react';
-import {
- COLOR_RAMP_NAMES,
- GRADIENT_INTERVALS,
- getRGBColorRangeStrings,
- getLinearGradient,
-} from '../color_utils';
-
-interface Props {
- colorRamp?: string[];
- colorRampName?: string;
-}
-
-export const ColorGradient = ({ colorRamp, colorRampName }: Props) => {
- if (!colorRamp && (!colorRampName || !COLOR_RAMP_NAMES.includes(colorRampName))) {
- return null;
- }
-
- const rgbColorStrings = colorRampName
- ? getRGBColorRangeStrings(colorRampName, GRADIENT_INTERVALS)
- : colorRamp!;
- const background = getLinearGradient(rgbColorStrings);
- return
;
-};
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap
index 9d07b9c641e0f..7c42b78fdc552 100644
--- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap
@@ -10,66 +10,120 @@ exports[`HeatmapStyleEditor is rendered 1`] = `
label="Color range"
labelType="label"
>
- ,
- "text": "theclassic",
- "value": "theclassic",
- },
- Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#ecf1f7",
+ "#d9e3ef",
+ "#c5d5e7",
+ "#b2c7df",
+ "#9eb9d8",
+ "#8bacd0",
+ "#769fc8",
+ "#6092c0",
+ ],
+ "type": "gradient",
"value": "Blues",
},
Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#e6f1ee",
+ "#cce4de",
+ "#b3d6cd",
+ "#9ac8bd",
+ "#80bbae",
+ "#65ad9e",
+ "#47a08f",
+ "#209280",
+ ],
+ "type": "gradient",
"value": "Greens",
},
Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#e0e4eb",
+ "#c2c9d5",
+ "#a6afbf",
+ "#8c95a5",
+ "#757c8b",
+ "#5e6471",
+ "#494d58",
+ "#343741",
+ ],
+ "type": "gradient",
"value": "Greys",
},
Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#fdeae5",
+ "#f9d5cc",
+ "#f4c0b4",
+ "#eeab9c",
+ "#e79685",
+ "#df816e",
+ "#d66c58",
+ "#cc5642",
+ ],
+ "type": "gradient",
"value": "Reds",
},
Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#f9eac5",
+ "#f6d9af",
+ "#f3c89a",
+ "#efb785",
+ "#eba672",
+ "#e89361",
+ "#e58053",
+ "#e7664c",
+ ],
+ "type": "gradient",
"value": "Yellow to Red",
},
Object {
- "inputDisplay": ,
+ "palette": Array [
+ "#209280",
+ "#3aa38d",
+ "#54b399",
+ "#95b978",
+ "#df9352",
+ "#e7664c",
+ "#da5e47",
+ "#cc5642",
+ ],
+ "type": "gradient",
"value": "Green to Red",
},
+ Object {
+ "palette": Array [
+ "#6092c0",
+ "#84a9cd",
+ "#a8bfda",
+ "#cad7e8",
+ "#f0d3b0",
+ "#ecb385",
+ "#ea8d69",
+ "#e7664c",
+ ],
+ "type": "gradient",
+ "value": "Blue to Red",
+ },
+ Object {
+ "palette": Array [
+ "rgb(65, 105, 225)",
+ "rgb(0, 256, 256)",
+ "rgb(0, 256, 0)",
+ "rgb(256, 256, 0)",
+ "rgb(256, 0, 0)",
+ ],
+ "type": "gradient",
+ "value": "theclassic",
+ },
]
}
valueOfSelected="Blues"
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts
index 583c78e56581b..b043c2791b146 100644
--- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts
@@ -6,17 +6,6 @@
import { i18n } from '@kbn/i18n';
-// Color stops from default Mapbox heatmap-color
-export const DEFAULT_RGB_HEATMAP_COLOR_RAMP = [
- 'rgb(65, 105, 225)', // royalblue
- 'rgb(0, 256, 256)', // cyan
- 'rgb(0, 256, 0)', // lime
- 'rgb(256, 256, 0)', // yellow
- 'rgb(256, 0, 0)', // red
-];
-
-export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic';
-
export const HEATMAP_COLOR_RAMP_LABEL = i18n.translate('xpack.maps.heatmap.colorRampLabel', {
defaultMessage: 'Color range',
});
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx
index d15fdbd79de75..48713f1ddfd4b 100644
--- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx
@@ -6,14 +6,9 @@
import React from 'react';
-import { EuiFormRow, EuiSuperSelect } from '@elastic/eui';
-import { COLOR_GRADIENTS } from '../../color_utils';
-import { ColorGradient } from '../../components/color_gradient';
-import {
- DEFAULT_RGB_HEATMAP_COLOR_RAMP,
- DEFAULT_HEATMAP_COLOR_RAMP_NAME,
- HEATMAP_COLOR_RAMP_LABEL,
-} from './heatmap_constants';
+import { EuiFormRow, EuiColorPalettePicker } from '@elastic/eui';
+import { NUMERICAL_COLOR_PALETTES } from '../../color_palettes';
+import { HEATMAP_COLOR_RAMP_LABEL } from './heatmap_constants';
interface Props {
colorRampName: string;
@@ -21,28 +16,18 @@ interface Props {
}
export function HeatmapStyleEditor({ colorRampName, onHeatmapColorChange }: Props) {
- const onColorRampChange = (selectedColorRampName: string) => {
+ const onColorRampChange = (selectedPaletteId: string) => {
onHeatmapColorChange({
- colorRampName: selectedColorRampName,
+ colorRampName: selectedPaletteId,
});
};
- const colorRampOptions = [
- {
- value: DEFAULT_HEATMAP_COLOR_RAMP_NAME,
- text: DEFAULT_HEATMAP_COLOR_RAMP_NAME,
- inputDisplay: ,
- },
- ...COLOR_GRADIENTS,
- ];
-
return (
-
diff --git a/x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss
similarity index 100%
rename from x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss
rename to x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx
new file mode 100644
index 0000000000000..b4a241f625683
--- /dev/null
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx
@@ -0,0 +1,19 @@
+/*
+ * 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 { getColorPalette, getLinearGradient } from '../../../color_palettes';
+
+interface Props {
+ colorPaletteId: string;
+}
+
+export const ColorGradient = ({ colorPaletteId }: Props) => {
+ const palette = getColorPalette(colorPaletteId);
+ return palette.length ? (
+
+ ) : null;
+};
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js
index 1d8dfe9c7bdbf..5c3600a149afe 100644
--- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js
@@ -7,13 +7,9 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
-import { ColorGradient } from '../../../components/color_gradient';
+import { ColorGradient } from './color_gradient';
import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row';
-import {
- DEFAULT_RGB_HEATMAP_COLOR_RAMP,
- DEFAULT_HEATMAP_COLOR_RAMP_NAME,
- HEATMAP_COLOR_RAMP_LABEL,
-} from '../heatmap_constants';
+import { HEATMAP_COLOR_RAMP_LABEL } from '../heatmap_constants';
export class HeatmapLegend extends React.Component {
constructor() {
@@ -41,17 +37,9 @@ export class HeatmapLegend extends React.Component {
}
render() {
- const colorRampName = this.props.colorRampName;
- const header =
- colorRampName === DEFAULT_HEATMAP_COLOR_RAMP_NAME ? (
-
- ) : (
-
- );
-
return (
}
minLabel={i18n.translate('xpack.maps.heatmapLegend.coldLabel', {
defaultMessage: 'cold',
})}
diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js
index 5f920d0ba52d3..55bbbc9319dfb 100644
--- a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js
+++ b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js
@@ -8,15 +8,15 @@ import React from 'react';
import { AbstractStyle } from '../style';
import { HeatmapStyleEditor } from './components/heatmap_style_editor';
import { HeatmapLegend } from './components/legend/heatmap_legend';
-import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants';
+import { DEFAULT_HEATMAP_COLOR_RAMP_NAME, getOrdinalMbColorRampStops } from '../color_palettes';
import { LAYER_STYLE_TYPE, GRID_RESOLUTION } from '../../../../common/constants';
-import { getOrdinalMbColorRampStops, GRADIENT_INTERVALS } from '../color_utils';
+
import { i18n } from '@kbn/i18n';
import { EuiIcon } from '@elastic/eui';
//The heatmap range chosen hear runs from 0 to 1. It is arbitrary.
//Weighting is on the raw count/sum values.
-const MIN_RANGE = 0;
+const MIN_RANGE = 0.1; // 0 to 0.1 is displayed as transparent color stop
const MAX_RANGE = 1;
export class HeatmapStyle extends AbstractStyle {
@@ -83,40 +83,19 @@ export class HeatmapStyle extends AbstractStyle {
property: propertyName,
});
- const { colorRampName } = this._descriptor;
- if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) {
- const colorStops = getOrdinalMbColorRampStops(
- colorRampName,
- MIN_RANGE,
- MAX_RANGE,
- GRADIENT_INTERVALS
- );
- // TODO handle null
- mbMap.setPaintProperty(layerId, 'heatmap-color', [
- 'interpolate',
- ['linear'],
- ['heatmap-density'],
- 0,
- 'rgba(0, 0, 255, 0)',
- ...colorStops.slice(2), // remove first stop from colorStops to avoid conflict with transparent stop at zero
- ]);
- } else {
+ const colorStops = getOrdinalMbColorRampStops(
+ this._descriptor.colorRampName,
+ MIN_RANGE,
+ MAX_RANGE
+ );
+ if (colorStops) {
mbMap.setPaintProperty(layerId, 'heatmap-color', [
'interpolate',
['linear'],
['heatmap-density'],
0,
'rgba(0, 0, 255, 0)',
- 0.1,
- 'royalblue',
- 0.3,
- 'cyan',
- 0.5,
- 'lime',
- 0.7,
- 'yellow',
- 1,
- 'red',
+ ...colorStops,
]);
}
}
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js
index fe2f302504a15..a7d849265d815 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js
+++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js
@@ -6,10 +6,17 @@
import React, { Component, Fragment } from 'react';
-import { EuiSpacer, EuiSelect, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import {
+ EuiSpacer,
+ EuiSelect,
+ EuiColorPalettePicker,
+ EuiFlexGroup,
+ EuiFlexItem,
+} from '@elastic/eui';
import { ColorStopsOrdinal } from './color_stops_ordinal';
import { COLOR_MAP_TYPE } from '../../../../../../common/constants';
import { ColorStopsCategorical } from './color_stops_categorical';
+import { CATEGORICAL_COLOR_PALETTES, NUMERICAL_COLOR_PALETTES } from '../../../color_palettes';
import { i18n } from '@kbn/i18n';
const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP';
@@ -65,10 +72,10 @@ export class ColorMapSelect extends Component {
);
}
- _onColorMapSelect = (selectedValue) => {
- const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP;
+ _onColorPaletteSelect = (selectedPaletteId) => {
+ const useCustomColorMap = selectedPaletteId === CUSTOM_COLOR_MAP;
this.props.onChange({
- color: useCustomColorMap ? null : selectedValue,
+ color: useCustomColorMap ? null : selectedPaletteId,
useCustomColorMap,
type: this.props.colorMapType,
});
@@ -126,26 +133,28 @@ export class ColorMapSelect extends Component {
return null;
}
- const colorMapOptionsWithCustom = [
+ const palettes =
+ this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL
+ ? NUMERICAL_COLOR_PALETTES
+ : CATEGORICAL_COLOR_PALETTES;
+
+ const palettesWithCustom = [
{
value: CUSTOM_COLOR_MAP,
- inputDisplay: this.props.customOptionLabel,
+ title:
+ this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL
+ ? i18n.translate('xpack.maps.style.customColorRampLabel', {
+ defaultMessage: 'Custom color ramp',
+ })
+ : i18n.translate('xpack.maps.style.customColorPaletteLabel', {
+ defaultMessage: 'Custom color palette',
+ }),
+ type: 'text',
'data-test-subj': `colorMapSelectOption_${CUSTOM_COLOR_MAP}`,
},
- ...this.props.colorMapOptions,
+ ...palettes,
];
- let valueOfSelected;
- if (this.props.useCustomColorMap) {
- valueOfSelected = CUSTOM_COLOR_MAP;
- } else {
- valueOfSelected = this.props.colorMapOptions.find(
- (option) => option.value === this.props.color
- )
- ? this.props.color
- : '';
- }
-
const toggle = this.props.showColorMapTypeToggle ? (
{this._renderColorMapToggle()}
) : null;
@@ -155,12 +164,13 @@ export class ColorMapSelect extends Component {
{toggle}
-
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js
index 90070343a1b48..1034e8f5d6525 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js
+++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js
@@ -10,8 +10,6 @@ import { FieldSelect } from '../field_select';
import { ColorMapSelect } from './color_map_select';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants';
-import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils';
-import { i18n } from '@kbn/i18n';
export function DynamicColorForm({
fields,
@@ -91,14 +89,10 @@ export function DynamicColorForm({
return (
{
fieldMetaOptions,
} as ColorDynamicOptions,
} as ColorDynamicStylePropertyDescriptor;
- expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe(
- 'rgb(106,173,213)'
- );
+ expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe('#9eb9d8');
});
});
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts
index dadb3f201fa33..4a3f45a929fd1 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts
+++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts
@@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-// @ts-ignore
-import { getColorRampCenterColor, getColorPalette } from '../../../color_utils';
+import { getColorRampCenterColor, getColorPalette } from '../../../color_palettes';
import { COLOR_MAP_TYPE, STYLE_TYPE } from '../../../../../../common/constants';
import {
ColorDynamicOptions,
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js
index 6528648eff552..53a3fc95adbeb 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js
+++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js
@@ -15,7 +15,7 @@ import { VectorStyleLabelEditor } from './label/vector_style_label_editor';
import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor';
import { OrientationEditor } from './orientation/orientation_editor';
import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults';
-import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils';
+import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui';
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap
index 29eb52897a50e..402eab355406b 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap
+++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap
@@ -175,7 +175,7 @@ exports[`ordinal Should render only single band of last color when delta is 0 1`
key="0"
>
{
- const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / GRADIENT_INTERVALS);
+ const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / colors.length);
return {
color,
stop: dynamicRound(rawStopValue),
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js
index 1879b260da2e2..7992ee5b3aeaf 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js
+++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js
@@ -323,21 +323,21 @@ describe('get mapbox color expression (via internal _getMbColor)', () => {
-1,
'rgba(0,0,0,0)',
0,
- '#f7faff',
+ '#ecf1f7',
12.5,
- '#ddeaf7',
+ '#d9e3ef',
25,
- '#c5daee',
+ '#c5d5e7',
37.5,
- '#9dc9e0',
+ '#b2c7df',
50,
- '#6aadd5',
+ '#9eb9d8',
62.5,
- '#4191c5',
+ '#8bacd0',
75,
- '#2070b4',
+ '#769fc8',
87.5,
- '#072f6b',
+ '#6092c0',
]);
});
});
diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts
index a6878a0d760c7..a3ae80e0a5935 100644
--- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts
+++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts
@@ -12,11 +12,11 @@ import {
STYLE_TYPE,
} from '../../../../common/constants';
import {
- COLOR_GRADIENTS,
- COLOR_PALETTES,
DEFAULT_FILL_COLORS,
DEFAULT_LINE_COLORS,
-} from '../color_utils';
+ NUMERICAL_COLOR_PALETTES,
+ CATEGORICAL_COLOR_PALETTES,
+} from '../color_palettes';
import { VectorStylePropertiesDescriptor } from '../../../../common/descriptor_types';
// @ts-ignore
import { getUiSettings } from '../../../kibana_services';
@@ -28,8 +28,8 @@ export const DEFAULT_MAX_SIZE = 32;
export const DEFAULT_SIGMA = 3;
export const DEFAULT_LABEL_SIZE = 14;
export const DEFAULT_ICON_SIZE = 6;
-export const DEFAULT_COLOR_RAMP = COLOR_GRADIENTS[0].value;
-export const DEFAULT_COLOR_PALETTE = COLOR_PALETTES[0].value;
+export const DEFAULT_COLOR_RAMP = NUMERICAL_COLOR_PALETTES[0].value;
+export const DEFAULT_COLOR_PALETTE = CATEGORICAL_COLOR_PALETTES[0].value;
export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH];
export const POLYGON_STYLES = [
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap
index 1c48ed2290dce..2cf5287ae6594 100644
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap
@@ -96,8 +96,8 @@ exports[`LayerPanel is rendered 1`] = `
"getId": [Function],
"getImmutableSourceProperties": [Function],
"getLayerTypeIconName": [Function],
- "isJoinable": [Function],
"renderSourceSettingsEditor": [Function],
+ "showJoinEditor": [Function],
"supportsElasticsearchFilters": [Function],
}
}
@@ -107,6 +107,17 @@ exports[`LayerPanel is rendered 1`] = `
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js
index 1c8dcdb43d434..17fd41d120194 100644
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js
@@ -12,7 +12,7 @@ import { updateSourceProp } from '../../actions';
function mapStateToProps(state = {}) {
const selectedLayer = getSelectedLayer(state);
return {
- key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.isJoinable()}` : '',
+ key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.showJoinEditor()}` : '',
selectedLayer,
};
}
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap
new file mode 100644
index 0000000000000..00d7f44d6273f
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap
@@ -0,0 +1,100 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Should render callout when joins are disabled 1`] = `
+
+
+
+
+
+
+
+
+
+ Simulated disabled reason
+
+
+`;
+
+exports[`Should render join editor 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js
deleted file mode 100644
index cf55c16bbe0be..0000000000000
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js
+++ /dev/null
@@ -1,31 +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 { connect } from 'react-redux';
-import { JoinEditor } from './view';
-import {
- getSelectedLayer,
- getSelectedLayerJoinDescriptors,
-} from '../../../selectors/map_selectors';
-import { setJoinsForLayer } from '../../../actions';
-
-function mapDispatchToProps(dispatch) {
- return {
- onChange: (layer, joins) => {
- dispatch(setJoinsForLayer(layer, joins));
- },
- };
-}
-
-function mapStateToProps(state = {}) {
- return {
- joins: getSelectedLayerJoinDescriptors(state),
- layer: getSelectedLayer(state),
- };
-}
-
-const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor);
-export { connectedJoinEditor as JoinEditor };
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx
new file mode 100644
index 0000000000000..0348b38351971
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx
@@ -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 { AnyAction, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { JoinEditor } from './join_editor';
+import { getSelectedLayerJoinDescriptors } from '../../../selectors/map_selectors';
+import { setJoinsForLayer } from '../../../actions';
+import { MapStoreState } from '../../../reducers/store';
+import { ILayer } from '../../../classes/layers/layer';
+import { JoinDescriptor } from '../../../../common/descriptor_types';
+
+function mapStateToProps(state: MapStoreState) {
+ return {
+ joins: getSelectedLayerJoinDescriptors(state),
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+ return {
+ onChange: (layer: ILayer, joins: JoinDescriptor[]) => {
+ dispatch(setJoinsForLayer(layer, joins));
+ },
+ };
+}
+
+const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor);
+export { connectedJoinEditor as JoinEditor };
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx
new file mode 100644
index 0000000000000..12da1c4bb9388
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * 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 { ILayer } from '../../../classes/layers/layer';
+import { JoinEditor } from './join_editor';
+import { shallow } from 'enzyme';
+import { JoinDescriptor } from '../../../../common/descriptor_types';
+
+class MockLayer {
+ private readonly _disableReason: string | null;
+
+ constructor(disableReason: string | null) {
+ this._disableReason = disableReason;
+ }
+
+ getJoinsDisabledReason() {
+ return this._disableReason;
+ }
+}
+
+const defaultProps = {
+ joins: [
+ {
+ leftField: 'iso2',
+ right: {
+ id: '673ff994-fc75-4c67-909b-69fcb0e1060e',
+ indexPatternTitle: 'kibana_sample_data_logs',
+ term: 'geo.src',
+ indexPatternId: 'abcde',
+ metrics: [
+ {
+ type: 'count',
+ label: 'web logs count',
+ },
+ ],
+ },
+ } as JoinDescriptor,
+ ],
+ layerDisplayName: 'myLeftJoinField',
+ leftJoinFields: [],
+ onChange: () => {},
+};
+
+test('Should render join editor', () => {
+ const component = shallow(
+
+ );
+ expect(component).toMatchSnapshot();
+});
+
+test('Should render callout when joins are disabled', () => {
+ const component = shallow(
+
+ );
+ expect(component).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx
new file mode 100644
index 0000000000000..c589604e85112
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx
@@ -0,0 +1,124 @@
+/*
+ * 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, { Fragment } from 'react';
+import uuid from 'uuid/v4';
+
+import {
+ EuiButtonEmpty,
+ EuiTitle,
+ EuiSpacer,
+ EuiToolTip,
+ EuiTextAlign,
+ EuiCallOut,
+} from '@elastic/eui';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+import { i18n } from '@kbn/i18n';
+// @ts-expect-error
+import { Join } from './resources/join';
+import { ILayer } from '../../../classes/layers/layer';
+import { JoinDescriptor } from '../../../../common/descriptor_types';
+import { IField } from '../../../classes/fields/field';
+
+interface Props {
+ joins: JoinDescriptor[];
+ layer: ILayer;
+ layerDisplayName: string;
+ leftJoinFields: IField[];
+ onChange: (layer: ILayer, joins: JoinDescriptor[]) => void;
+}
+
+export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }: Props) {
+ const renderJoins = () => {
+ return joins.map((joinDescriptor: JoinDescriptor, index: number) => {
+ const handleOnChange = (updatedDescriptor: JoinDescriptor) => {
+ onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]);
+ };
+
+ const handleOnRemove = () => {
+ onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]);
+ };
+
+ return (
+
+
+
+
+ );
+ });
+ };
+
+ const addJoin = () => {
+ onChange(layer, [
+ ...joins,
+ {
+ right: {
+ id: uuid(),
+ applyGlobalQuery: true,
+ },
+ } as JoinDescriptor,
+ ]);
+ };
+
+ const renderContent = () => {
+ const disabledReason = layer.getJoinsDisabledReason();
+ return disabledReason ? (
+ {disabledReason}
+ ) : (
+
+ {renderJoins()}
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {renderContent()}
+
+ );
+}
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js
deleted file mode 100644
index 900f5c9ff53ea..0000000000000
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js
+++ /dev/null
@@ -1,103 +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, { Fragment } from 'react';
-import uuid from 'uuid/v4';
-
-import {
- EuiFlexGroup,
- EuiFlexItem,
- EuiButtonIcon,
- EuiTitle,
- EuiSpacer,
- EuiToolTip,
-} from '@elastic/eui';
-
-import { Join } from './resources/join';
-import { FormattedMessage } from '@kbn/i18n/react';
-import { i18n } from '@kbn/i18n';
-
-export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }) {
- const renderJoins = () => {
- return joins.map((joinDescriptor, index) => {
- const handleOnChange = (updatedDescriptor) => {
- onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]);
- };
-
- const handleOnRemove = () => {
- onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]);
- };
-
- return (
-
-
-
-
- );
- });
- };
-
- const addJoin = () => {
- onChange(layer, [
- ...joins,
- {
- right: {
- id: uuid(),
- applyGlobalQuery: true,
- },
- },
- ]);
- };
-
- if (!layer.isJoinable()) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {renderJoins()}
-
- );
-}
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js
index 71d76ff53d8a9..2e20a4492f08b 100644
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js
@@ -75,7 +75,7 @@ export class LayerPanel extends React.Component {
};
async _loadLeftJoinFields() {
- if (!this.props.selectedLayer || !this.props.selectedLayer.isJoinable()) {
+ if (!this.props.selectedLayer || !this.props.selectedLayer.showJoinEditor()) {
return;
}
@@ -120,7 +120,7 @@ export class LayerPanel extends React.Component {
}
_renderJoinSection() {
- if (!this.props.selectedLayer.isJoinable()) {
+ if (!this.props.selectedLayer.showJoinEditor()) {
return null;
}
@@ -128,6 +128,7 @@ export class LayerPanel extends React.Component {
diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js
index 99893c1bc5bee..33ca80b00c451 100644
--- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js
+++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js
@@ -55,7 +55,7 @@ const mockLayer = {
getImmutableSourceProperties: () => {
return [{ label: 'source prop1', value: 'you get one chance to set me' }];
},
- isJoinable: () => {
+ showJoinEditor: () => {
return true;
},
supportsElasticsearchFilters: () => {
diff --git a/x-pack/plugins/maps/public/meta.test.js b/x-pack/plugins/maps/public/meta.test.js
index 5c04a57c00058..3486bf003aee0 100644
--- a/x-pack/plugins/maps/public/meta.test.js
+++ b/x-pack/plugins/maps/public/meta.test.js
@@ -36,6 +36,11 @@ describe('getGlyphUrl', () => {
beforeAll(() => {
require('./kibana_services').getIsEmsEnabled = () => true;
require('./kibana_services').getEmsFontLibraryUrl = () => EMS_FONTS_URL_MOCK;
+ require('./kibana_services').getHttp = () => ({
+ basePath: {
+ prepend: (url) => url, // No need to actually prepend a dev basepath for test
+ },
+ });
});
describe('EMS proxy enabled', () => {
diff --git a/x-pack/plugins/maps/public/meta.ts b/x-pack/plugins/maps/public/meta.ts
index 54c5eac7fe1b0..34c5f004fd7f3 100644
--- a/x-pack/plugins/maps/public/meta.ts
+++ b/x-pack/plugins/maps/public/meta.ts
@@ -30,8 +30,6 @@ import {
getKibanaVersion,
} from './kibana_services';
-const GIS_API_RELATIVE = `../${GIS_API_PATH}`;
-
export function getKibanaRegionList(): unknown[] {
return getRegionmapLayers();
}
@@ -69,10 +67,14 @@ export function getEMSClient(): EMSClient {
const proxyElasticMapsServiceInMaps = getProxyElasticMapsServiceInMaps();
const proxyPath = '';
const tileApiUrl = proxyElasticMapsServiceInMaps
- ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_TILES_CATALOGUE_PATH}`)
+ ? relativeToAbsolute(
+ getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}`)
+ )
: getEmsTileApiUrl();
const fileApiUrl = proxyElasticMapsServiceInMaps
- ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_FILES_CATALOGUE_PATH}`)
+ ? relativeToAbsolute(
+ getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_FILES_CATALOGUE_PATH}`)
+ )
: getEmsFileApiUrl();
emsClient = new EMSClient({
@@ -101,8 +103,11 @@ export function getGlyphUrl(): string {
return getHttp().basePath.prepend(`/${FONTS_API_PATH}/{fontstack}/{range}`);
}
return getProxyElasticMapsServiceInMaps()
- ? relativeToAbsolute(`../${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`) +
- `/{fontstack}/{range}`
+ ? relativeToAbsolute(
+ getHttp().basePath.prepend(
+ `/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`
+ )
+ ) + `/{fontstack}/{range}`
: getEmsFontLibraryUrl();
}
diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts
index dbcce50ac2b9a..7d091099c1aaa 100644
--- a/x-pack/plugins/maps/server/plugin.ts
+++ b/x-pack/plugins/maps/server/plugin.ts
@@ -26,12 +26,14 @@ import { initRoutes } from './routes';
import { ILicense } from '../../licensing/common/types';
import { LicensingPluginSetup } from '../../licensing/server';
import { HomeServerPluginSetup } from '../../../../src/plugins/home/server';
+import { MapsLegacyPluginSetup } from '../../../../src/plugins/maps_legacy/server';
interface SetupDeps {
features: FeaturesPluginSetupContract;
usageCollection: UsageCollectionSetup;
home: HomeServerPluginSetup;
licensing: LicensingPluginSetup;
+ mapsLegacy: MapsLegacyPluginSetup;
}
export class MapsPlugin implements Plugin {
@@ -129,9 +131,10 @@ export class MapsPlugin implements Plugin {
// @ts-ignore
async setup(core: CoreSetup, plugins: SetupDeps) {
- const { usageCollection, home, licensing, features } = plugins;
+ const { usageCollection, home, licensing, features, mapsLegacy } = plugins;
// @ts-ignore
const config$ = this._initializerContext.config.create();
+ const mapsLegacyConfig = await mapsLegacy.config$.pipe(take(1)).toPromise();
const currentConfig = await config$.pipe(take(1)).toPromise();
// @ts-ignore
@@ -150,7 +153,7 @@ export class MapsPlugin implements Plugin {
initRoutes(
core.http.createRouter(),
license.uid,
- currentConfig,
+ mapsLegacyConfig,
this.kibanaVersion,
this._logger
);
diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js
index ad66712eb3ad6..1876c0de19c56 100644
--- a/x-pack/plugins/maps/server/routes.js
+++ b/x-pack/plugins/maps/server/routes.js
@@ -73,9 +73,10 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
validate: {
query: schema.object({
id: schema.maybe(schema.string()),
- x: schema.maybe(schema.number()),
- y: schema.maybe(schema.number()),
- z: schema.maybe(schema.number()),
+ elastic_tile_service_tos: schema.maybe(schema.string()),
+ my_app_name: schema.maybe(schema.string()),
+ my_app_version: schema.maybe(schema.string()),
+ license: schema.maybe(schema.string()),
}),
},
},
@@ -111,9 +112,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`,
validate: false,
},
- async (context, request, { ok, badRequest }) => {
+ async (context, request, response) => {
if (!checkEMSProxyEnabled()) {
- return badRequest('map.proxyElasticMapsServiceInMaps disabled');
+ return response.badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
if (
@@ -138,7 +139,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
.replace('{y}', request.query.y)
.replace('{z}', request.query.z);
- return await proxyResource({ url, contentType: 'image/png' }, { ok, badRequest });
+ return await proxyResource({ url, contentType: 'image/png' }, response);
}
);
@@ -203,7 +204,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
});
//rewrite
return ok({
- body: layers,
+ body: {
+ layers,
+ },
});
}
);
@@ -293,7 +296,11 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`,
validate: {
query: schema.object({
- id: schema.maybe(schema.string()),
+ id: schema.string(),
+ elastic_tile_service_tos: schema.maybe(schema.string()),
+ my_app_name: schema.maybe(schema.string()),
+ my_app_version: schema.maybe(schema.string()),
+ license: schema.maybe(schema.string()),
}),
},
},
@@ -302,11 +309,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
return badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
- if (!request.query.id) {
- logger.warn('Must supply id parameter to retrieve EMS vector style');
- return null;
- }
-
const tmsServices = await emsClient.getTMSServices();
const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id);
if (!tmsService) {
@@ -342,8 +344,12 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`,
validate: {
query: schema.object({
- id: schema.maybe(schema.string()),
+ id: schema.string(),
sourceId: schema.maybe(schema.string()),
+ elastic_tile_service_tos: schema.maybe(schema.string()),
+ my_app_name: schema.maybe(schema.string()),
+ my_app_version: schema.maybe(schema.string()),
+ license: schema.maybe(schema.string()),
}),
},
},
@@ -352,11 +358,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
return badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
- if (!request.query.id || !request.query.sourceId) {
- logger.warn('Must supply id and sourceId parameter to retrieve EMS vector source');
- return null;
- }
-
const tmsServices = await emsClient.getTMSServices();
const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id);
if (!tmsService) {
@@ -381,28 +382,21 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`,
validate: {
query: schema.object({
- id: schema.maybe(schema.string()),
- sourceId: schema.maybe(schema.string()),
- x: schema.maybe(schema.number()),
- y: schema.maybe(schema.number()),
- z: schema.maybe(schema.number()),
+ id: schema.string(),
+ sourceId: schema.string(),
+ x: schema.number(),
+ y: schema.number(),
+ z: schema.number(),
+ elastic_tile_service_tos: schema.maybe(schema.string()),
+ my_app_name: schema.maybe(schema.string()),
+ my_app_version: schema.maybe(schema.string()),
+ license: schema.maybe(schema.string()),
}),
},
},
- async (context, request, { ok, badRequest }) => {
+ async (context, request, response) => {
if (!checkEMSProxyEnabled()) {
- return badRequest('map.proxyElasticMapsServiceInMaps disabled');
- }
-
- if (
- !request.query.id ||
- !request.query.sourceId ||
- typeof parseInt(request.query.x, 10) !== 'number' ||
- typeof parseInt(request.query.y, 10) !== 'number' ||
- typeof parseInt(request.query.z, 10) !== 'number'
- ) {
- logger.warn('Must supply id/sourceId/x/y/z parameters to retrieve EMS vector tile');
- return null;
+ return response.badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
const tmsServices = await emsClient.getTMSServices();
@@ -417,24 +411,29 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
.replace('{y}', request.query.y)
.replace('{z}', request.query.z);
- return await proxyResource({ url }, { ok, badRequest });
+ return await proxyResource({ url }, response);
}
);
router.get(
{
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`,
- validate: false,
+ validate: {
+ params: schema.object({
+ fontstack: schema.string(),
+ range: schema.string(),
+ }),
+ },
},
- async (context, request, { ok, badRequest }) => {
+ async (context, request, response) => {
if (!checkEMSProxyEnabled()) {
- return badRequest('map.proxyElasticMapsServiceInMaps disabled');
+ return response.badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
const url = mapConfig.emsFontLibraryUrl
.replace('{fontstack}', request.params.fontstack)
.replace('{range}', request.params.range);
- return await proxyResource({ url }, { ok, badRequest });
+ return await proxyResource({ url }, response);
}
);
@@ -442,19 +441,22 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
{
path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`,
validate: {
+ query: schema.object({
+ elastic_tile_service_tos: schema.maybe(schema.string()),
+ my_app_name: schema.maybe(schema.string()),
+ my_app_version: schema.maybe(schema.string()),
+ license: schema.maybe(schema.string()),
+ }),
params: schema.object({
id: schema.string(),
+ scaling: schema.maybe(schema.string()),
+ extension: schema.string(),
}),
},
},
- async (context, request, { ok, badRequest }) => {
+ async (context, request, response) => {
if (!checkEMSProxyEnabled()) {
- return badRequest('map.proxyElasticMapsServiceInMaps disabled');
- }
-
- if (!request.params.id) {
- logger.warn('Must supply id parameter to retrieve EMS vector source sprite');
- return null;
+ return response.badRequest('map.proxyElasticMapsServiceInMaps disabled');
}
const tmsServices = await emsClient.getTMSServices();
@@ -479,7 +481,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
url: proxyPathUrl,
contentType: request.params.extension === 'png' ? 'image/png' : '',
},
- { ok, badRequest }
+ response
);
}
);
@@ -570,25 +572,23 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) {
return proxyEMSInMaps;
}
- async function proxyResource({ url, contentType }, { ok, badRequest }) {
+ async function proxyResource({ url, contentType }, response) {
try {
const resource = await fetch(url);
const arrayBuffer = await resource.arrayBuffer();
- const bufferedResponse = Buffer.from(arrayBuffer);
- const headers = {
- 'Content-Disposition': 'inline',
- };
- if (contentType) {
- headers['Content-type'] = contentType;
- }
-
- return ok({
- body: bufferedResponse,
- headers,
+ const buffer = Buffer.from(arrayBuffer);
+
+ return response.ok({
+ body: buffer,
+ headers: {
+ 'content-disposition': 'inline',
+ 'content-length': buffer.length,
+ ...(contentType ? { 'Content-type': contentType } : {}),
+ },
});
} catch (e) {
logger.warn(`Cannot connect to EMS for resource, error: ${e.message}`);
- return badRequest(`Cannot connect to EMS`);
+ return response.badRequest(`Cannot connect to EMS`);
}
}
}
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
index 618ea5184007d..06254f0de092e 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts
@@ -339,6 +339,7 @@ export interface UpdateDataFrameAnalyticsConfig {
allow_lazy_start?: string;
description?: string;
model_memory_limit?: string;
+ max_num_threads?: number;
}
export interface DataFrameAnalyticsConfig {
@@ -358,6 +359,7 @@ export interface DataFrameAnalyticsConfig {
excludes: string[];
};
model_memory_limit: string;
+ max_num_threads?: number;
create_time: number;
version: string;
allow_lazy_start?: boolean;
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx
index a9c8b6d4040ad..875590d0f9ee4 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx
@@ -45,6 +45,7 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({
jobType,
lambda,
method,
+ maxNumThreads,
maxTrees,
modelMemoryLimit,
nNeighbors,
@@ -214,6 +215,15 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({
);
}
+ if (maxNumThreads !== undefined) {
+ advancedFirstCol.push({
+ title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.maxNumThreads', {
+ defaultMessage: 'Maximum number of threads',
+ }),
+ description: `${maxNumThreads}`,
+ });
+ }
+
return (
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx
index 21b0d3d7dd89e..11184afb0e715 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx
@@ -9,7 +9,7 @@ import {
EuiAccordion,
EuiFieldNumber,
EuiFieldText,
- EuiFlexGroup,
+ EuiFlexGrid,
EuiFlexItem,
EuiFormRow,
EuiSelect,
@@ -57,6 +57,7 @@ export const AdvancedStepForm: FC = ({
gamma,
jobType,
lambda,
+ maxNumThreads,
maxTrees,
method,
modelMemoryLimit,
@@ -82,7 +83,8 @@ export const AdvancedStepForm: FC = ({
const isStepInvalid =
mmlInvalid ||
Object.keys(advancedParamErrors).length > 0 ||
- fetchingAdvancedParamErrors === true;
+ fetchingAdvancedParamErrors === true ||
+ maxNumThreads === 0;
useEffect(() => {
setFetchingAdvancedParamErrors(true);
@@ -112,6 +114,7 @@ export const AdvancedStepForm: FC = ({
featureInfluenceThreshold,
gamma,
lambda,
+ maxNumThreads,
maxTrees,
method,
nNeighbors,
@@ -123,7 +126,7 @@ export const AdvancedStepForm: FC = ({
const outlierDetectionAdvancedConfig = (
-
+
= ({
/>
-
+
= ({
const regAndClassAdvancedConfig = (
-
+
= ({
/>
-
+
= ({
})}
-
+
{jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && outlierDetectionAdvancedConfig}
{isRegOrClassJob && regAndClassAdvancedConfig}
{jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && (
-
+
= ({
)}
-
+
= ({
/>
-
+
+
+
+ setFormState({
+ maxNumThreads: e.target.value === '' ? undefined : +e.target.value,
+ })
+ }
+ step={1}
+ value={getNumberValue(maxNumThreads)}
+ />
+
+
+
= ({
initialIsOpen={false}
data-test-subj="mlAnalyticsCreateJobWizardHyperParametersSection"
>
-
+
{jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && (
= ({
advancedParamErrors={advancedParamErrors}
/>
)}
-
+
= ({ actions, state, advancedParamErrors
return (
-
+
= ({ actions, state, advancedParamErrors
/>
-
+
= ({ actions, state, advancedParamErrors
/>
-
+
= ({ actions, state, advancedParamErrors
/>
-
+
= ({ actions, state, advancedParamErrors
/>
-
+
= ({ actions, state, advancedParamErrors
/>
-
+
= ({ actions, state, advancedPara
return (
-
+
= ({ actions, state, advancedPara
/>
-
+
= ({ actions, state, advancedPara
/>
-
+
= ({ actions, state, advancedPara
/>
-
+
> = ({ closeFlyout, item }
const [description, setDescription] = useState(config.description || '');
const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit);
const [mmlValidationError, setMmlValidationError] = useState();
+ const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads);
const {
services: { notifications },
@@ -59,7 +61,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item }
const { refresh } = useRefreshAnalyticsList();
// Disable if mml is not valid
- const updateButtonDisabled = mmlValidationError !== undefined;
+ const updateButtonDisabled = mmlValidationError !== undefined || maxNumThreads === 0;
useEffect(() => {
if (mmLValidator === undefined) {
@@ -93,7 +95,8 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item }
allow_lazy_start: allowLazyStart,
description,
},
- modelMemoryLimit && { model_memory_limit: modelMemoryLimit }
+ modelMemoryLimit && { model_memory_limit: modelMemoryLimit },
+ maxNumThreads && { max_num_threads: maxNumThreads }
);
try {
@@ -210,7 +213,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item }
helpText={
state !== DATA_FRAME_TASK_STATE.STOPPED &&
i18n.translate('xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryHelpText', {
- defaultMessage: 'Model memory limit cannot be edited while the job is running.',
+ defaultMessage: 'Model memory limit cannot be edited until the job has stopped.',
})
}
label={i18n.translate(
@@ -236,6 +239,49 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item }
)}
/>
+
+
+ setMaxNumThreads(e.target.value === '' ? undefined : +e.target.value)
+ }
+ step={1}
+ min={1}
+ readOnly={state !== DATA_FRAME_TASK_STATE.STOPPED}
+ value={maxNumThreads}
+ />
+
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts
index 0d425c8ead4a2..68a3613f91b5e 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts
@@ -23,6 +23,7 @@ export enum DEFAULT_MODEL_MEMORY_LIMIT {
}
export const DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES = 0;
+export const DEFAULT_MAX_NUM_THREADS = 1;
export const UNSET_CONFIG_ITEM = '--';
export type EsIndexName = string;
@@ -68,6 +69,7 @@ export interface State {
jobConfigQueryString: string | undefined;
lambda: number | undefined;
loadingFieldOptions: boolean;
+ maxNumThreads: undefined | number;
maxTrees: undefined | number;
method: undefined | string;
modelMemoryLimit: string | undefined;
@@ -134,6 +136,7 @@ export const getInitialState = (): State => ({
jobConfigQueryString: undefined,
lambda: undefined,
loadingFieldOptions: false,
+ maxNumThreads: DEFAULT_MAX_NUM_THREADS,
maxTrees: undefined,
method: undefined,
modelMemoryLimit: undefined,
@@ -200,6 +203,10 @@ export const getJobConfigFromFormState = (
model_memory_limit: formState.modelMemoryLimit,
};
+ if (formState.maxNumThreads !== undefined) {
+ jobConfig.max_num_threads = formState.maxNumThreads;
+ }
+
const resultsFieldEmpty =
typeof formState?.resultsField === 'string' && formState?.resultsField.trim() === '';
@@ -291,6 +298,7 @@ export function getCloneFormStateFromJobConfig(
? analyticsJobConfig.source.index.join(',')
: analyticsJobConfig.source.index,
modelMemoryLimit: analyticsJobConfig.model_memory_limit,
+ maxNumThreads: analyticsJobConfig.max_num_threads,
includes: analyticsJobConfig.analyzed_fields.includes,
};
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json
new file mode 100644
index 0000000000000..ca61db7992083
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json
@@ -0,0 +1,3 @@
+{
+ "icon": "logoSecurity"
+}
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json
new file mode 100644
index 0000000000000..b7afe8d2b158a
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json
@@ -0,0 +1,64 @@
+{
+ "id": "siem_cloudtrail",
+ "title": "SIEM Cloudtrail",
+ "description": "Detect suspicious activity recorded in your cloudtrail logs.",
+ "type": "Filebeat data",
+ "logoFile": "logo.json",
+ "defaultIndexPattern": "filebeat-*",
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}}
+ ]
+ }
+ },
+ "jobs": [
+ {
+ "id": "rare_method_for_a_city",
+ "file": "rare_method_for_a_city.json"
+ },
+ {
+ "id": "rare_method_for_a_country",
+ "file": "rare_method_for_a_country.json"
+ },
+ {
+ "id": "rare_method_for_a_username",
+ "file": "rare_method_for_a_username.json"
+ },
+ {
+ "id": "high_distinct_count_error_message",
+ "file": "high_distinct_count_error_message.json"
+ },
+ {
+ "id": "rare_error_code",
+ "file": "rare_error_code.json"
+ }
+ ],
+ "datafeeds": [
+ {
+ "id": "datafeed-rare_method_for_a_city",
+ "file": "datafeed_rare_method_for_a_city.json",
+ "job_id": "rare_method_for_a_city"
+ },
+ {
+ "id": "datafeed-rare_method_for_a_country",
+ "file": "datafeed_rare_method_for_a_country.json",
+ "job_id": "rare_method_for_a_country"
+ },
+ {
+ "id": "datafeed-rare_method_for_a_username",
+ "file": "datafeed_rare_method_for_a_username.json",
+ "job_id": "rare_method_for_a_username"
+ },
+ {
+ "id": "datafeed-high_distinct_count_error_message",
+ "file": "datafeed_high_distinct_count_error_message.json",
+ "job_id": "high_distinct_count_error_message"
+ },
+ {
+ "id": "datafeed-rare_error_code",
+ "file": "datafeed_rare_error_code.json",
+ "job_id": "rare_error_code"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json
new file mode 100644
index 0000000000000..269aac2ea72a1
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}},
+ {"term": {"event.module": "aws"}},
+ {"exists": {"field": "aws.cloudtrail.error_message"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json
new file mode 100644
index 0000000000000..4b463a4d10991
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}},
+ {"term": {"event.module": "aws"}},
+ {"exists": {"field": "aws.cloudtrail.error_code"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json
new file mode 100644
index 0000000000000..e436273a848e7
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}},
+ {"term": {"event.module": "aws"}},
+ {"exists": {"field": "source.geo.city_name"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json
new file mode 100644
index 0000000000000..f0e80174b8791
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}},
+ {"term": {"event.module": "aws"}},
+ {"exists": {"field": "source.geo.country_iso_code"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json
new file mode 100644
index 0000000000000..2fd3622ff81ce
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json
@@ -0,0 +1,16 @@
+{
+ "job_id": "JOB_ID",
+ "indices": [
+ "INDEX_PATTERN_NAME"
+ ],
+ "max_empty_searches": 10,
+ "query": {
+ "bool": {
+ "filter": [
+ {"term": {"event.dataset": "aws.cloudtrail"}},
+ {"term": {"event.module": "aws"}},
+ {"exists": {"field": "user.name"}}
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json
new file mode 100644
index 0000000000000..fdabf66ac91b3
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json
@@ -0,0 +1,33 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Looks for a spike in the rate of an error message which may simply indicate an impending service failure but these can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.",
+ "groups": [
+ "siem",
+ "cloudtrail"
+ ],
+ "analysis_config": {
+ "bucket_span": "15m",
+ "detectors": [
+ {
+ "detector_description": "high_distinct_count(\"aws.cloudtrail.error_message\")",
+ "function": "high_distinct_count",
+ "field_name": "aws.cloudtrail.error_message"
+ }
+ ],
+ "influencers": [
+ "aws.cloudtrail.user_identity.arn",
+ "source.ip",
+ "source.geo.city_name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "16mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-siem-cloudtrail"
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json
new file mode 100644
index 0000000000000..0f8fa814ac60a
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json
@@ -0,0 +1,33 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Looks for unsual errors. Rare and unusual errors may simply indicate an impending service failure but they can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.",
+ "groups": [
+ "siem",
+ "cloudtrail"
+ ],
+ "analysis_config": {
+ "bucket_span": "60m",
+ "detectors": [
+ {
+ "detector_description": "rare by \"aws.cloudtrail.error_code\"",
+ "function": "rare",
+ "by_field_name": "aws.cloudtrail.error_code"
+ }
+ ],
+ "influencers": [
+ "aws.cloudtrail.user_identity.arn",
+ "source.ip",
+ "source.geo.city_name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "16mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-siem-cloudtrail"
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json
new file mode 100644
index 0000000000000..eff4d4cdbb889
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json
@@ -0,0 +1,34 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (city) that is unusual. This can be the result of compromised credentials or keys.",
+ "groups": [
+ "siem",
+ "cloudtrail"
+ ],
+ "analysis_config": {
+ "bucket_span": "60m",
+ "detectors": [
+ {
+ "detector_description": "rare by \"event.action\" partition by \"source.geo.city_name\"",
+ "function": "rare",
+ "by_field_name": "event.action",
+ "partition_field_name": "source.geo.city_name"
+ }
+ ],
+ "influencers": [
+ "aws.cloudtrail.user_identity.arn",
+ "source.ip",
+ "source.geo.city_name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "64mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-siem-cloudtrail"
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json
new file mode 100644
index 0000000000000..810822c30a5dd
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json
@@ -0,0 +1,34 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (country) that is unusual. This can be the result of compromised credentials or keys.",
+ "groups": [
+ "siem",
+ "cloudtrail"
+ ],
+ "analysis_config": {
+ "bucket_span": "60m",
+ "detectors": [
+ {
+ "detector_description": "rare by \"event.action\" partition by \"source.geo.country_iso_code\"",
+ "function": "rare",
+ "by_field_name": "event.action",
+ "partition_field_name": "source.geo.country_iso_code"
+ }
+ ],
+ "influencers": [
+ "aws.cloudtrail.user_identity.arn",
+ "source.ip",
+ "source.geo.country_iso_code"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "64mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-siem-cloudtrail"
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json
new file mode 100644
index 0000000000000..2edf52e8351ed
--- /dev/null
+++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json
@@ -0,0 +1,34 @@
+{
+ "job_type": "anomaly_detector",
+ "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a user context that does not normally call the method. This can be the result of compromised credentials or keys as someone uses a valid account to persist, move laterally, or exfil data.",
+ "groups": [
+ "siem",
+ "cloudtrail"
+ ],
+ "analysis_config": {
+ "bucket_span": "60m",
+ "detectors": [
+ {
+ "detector_description": "rare by \"event.action\" partition by \"user.name\"",
+ "function": "rare",
+ "by_field_name": "event.action",
+ "partition_field_name": "user.name"
+ }
+ ],
+ "influencers": [
+ "user.name",
+ "source.ip",
+ "source.geo.city_name"
+ ]
+ },
+ "allow_lazy_open": true,
+ "analysis_limits": {
+ "model_memory_limit": "128mb"
+ },
+ "data_description": {
+ "time_field": "@timestamp"
+ },
+ "custom_settings": {
+ "created_by": "ml-module-siem-cloudtrail"
+ }
+ }
\ No newline at end of file
diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts
index 5469c2fefdf33..0c3e186c314cc 100644
--- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts
+++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts
@@ -28,6 +28,7 @@ export const dataAnalyticsJobConfigSchema = schema.object({
analysis: schema.any(),
analyzed_fields: schema.any(),
model_memory_limit: schema.string(),
+ max_num_threads: schema.maybe(schema.number()),
});
export const dataAnalyticsEvaluateSchema = schema.object({
@@ -52,6 +53,7 @@ export const dataAnalyticsExplainSchema = schema.object({
analysis: schema.any(),
analyzed_fields: schema.maybe(schema.any()),
model_memory_limit: schema.maybe(schema.string()),
+ max_num_threads: schema.maybe(schema.number()),
});
export const analyticsIdSchema = schema.object({
@@ -73,6 +75,7 @@ export const dataAnalyticsJobUpdateSchema = schema.object({
description: schema.maybe(schema.string()),
model_memory_limit: schema.maybe(schema.string()),
allow_lazy_start: schema.maybe(schema.boolean()),
+ max_num_threads: schema.maybe(schema.number()),
});
export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({
diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx
index 4c80195d33ace..c0dc67b3373b1 100644
--- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx
@@ -44,12 +44,16 @@ export const AlertsSection = ({ alerts }: Props) => {
return (
diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx
index d4b8236e0ef49..7b9d7276dd1c5 100644
--- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx
@@ -8,6 +8,7 @@ import * as fetcherHook from '../../../../hooks/use_fetcher';
import { render } from '../../../../utils/test_helper';
import { APMSection } from './';
import { response } from './mock_data/apm.mock';
+import moment from 'moment';
describe('APMSection', () => {
it('renders with transaction series and stats', () => {
@@ -18,8 +19,11 @@ describe('APMSection', () => {
});
const { getByText, queryAllByTestId } = render(
);
@@ -38,8 +42,11 @@ describe('APMSection', () => {
});
const { getByText, queryAllByText, getByTestId } = render(
);
diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx
index 697d4adfa0b75..dce80ed324456 100644
--- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx
@@ -21,8 +21,8 @@ import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
- startTime?: string;
- endTime?: string;
+ absoluteTime: { start?: number; end?: number };
+ relativeTime: { start: string; end: string };
bucketSize?: string;
}
@@ -30,20 +30,25 @@ function formatTpm(value?: number) {
return numeral(value).format('0.00a');
}
-export const APMSection = ({ startTime, endTime, bucketSize }: Props) => {
+export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
const history = useHistory();
+ const { start, end } = absoluteTime;
const { data, status } = useFetcher(() => {
- if (startTime && endTime && bucketSize) {
- return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize });
+ if (start && end && bucketSize) {
+ return getDataHandler('apm')?.fetchData({
+ absoluteTime: { start, end },
+ relativeTime,
+ bucketSize,
+ });
}
- }, [startTime, endTime, bucketSize]);
+ }, [start, end, bucketSize]);
- const { title = 'APM', appLink, stats, series } = data || {};
+ const { appLink, stats, series } = data || {};
- const min = moment.utc(startTime).valueOf();
- const max = moment.utc(endTime).valueOf();
+ const min = moment.utc(absoluteTime.start).valueOf();
+ const max = moment.utc(absoluteTime.end).valueOf();
const formatter = niceTimeFormatter([min, max]);
@@ -53,8 +58,15 @@ export const APMSection = ({ startTime, endTime, bucketSize }: Props) => {
return (
diff --git a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts
index 5857021b1537f..edc236c714d32 100644
--- a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts
+++ b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts
@@ -7,8 +7,6 @@
import { ApmFetchDataResponse } from '../../../../../typings';
export const response: ApmFetchDataResponse = {
- title: 'APM',
-
appLink: '/app/apm',
stats: {
services: { value: 11, type: 'number' },
diff --git a/x-pack/plugins/observability/public/components/app/section/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/index.test.tsx
index 49cb175d0c094..708a5e468dc7c 100644
--- a/x-pack/plugins/observability/public/components/app/section/index.test.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/index.test.tsx
@@ -20,13 +20,13 @@ describe('SectionContainer', () => {
});
it('renders section with app link', () => {
const component = render(
-
+
I am a very nice component
);
expect(component.getByText('I am a very nice component')).toBeInTheDocument();
expect(component.getByText('Foo')).toBeInTheDocument();
- expect(component.getByText('View in app')).toBeInTheDocument();
+ expect(component.getByText('foo')).toBeInTheDocument();
});
it('renders section with error', () => {
const component = render(
diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx
index 3556e8c01ab30..9ba524259ea1c 100644
--- a/x-pack/plugins/observability/public/components/app/section/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/index.tsx
@@ -4,21 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
import React from 'react';
import { ErrorPanel } from './error_panel';
import { usePluginContext } from '../../../hooks/use_plugin_context';
+interface AppLink {
+ label: string;
+ href?: string;
+}
+
interface Props {
title: string;
hasError: boolean;
children: React.ReactNode;
- minHeight?: number;
- appLink?: string;
- appLinkName?: string;
+ appLink?: AppLink;
}
-export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => {
+export const SectionContainer = ({ title, appLink, children, hasError }: Props) => {
const { core } = usePluginContext();
return (
}
extraAction={
- appLink && (
-
-
- {appLinkName
- ? appLinkName
- : i18n.translate('xpack.observability.chart.viewInAppLabel', {
- defaultMessage: 'View in app',
- })}
-
+ appLink?.href && (
+
+ {appLink.label}
)
}
diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx
index f3ba2ef6fa83a..9b232ea33cbfb 100644
--- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx
@@ -25,8 +25,8 @@ import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
- startTime?: string;
- endTime?: string;
+ absoluteTime: { start?: number; end?: number };
+ relativeTime: { start: string; end: string };
bucketSize?: string;
}
@@ -45,21 +45,26 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) {
return colorsPerItem;
}
-export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => {
+export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => {
const history = useHistory();
+ const { start, end } = absoluteTime;
const { data, status } = useFetcher(() => {
- if (startTime && endTime && bucketSize) {
- return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize });
+ if (start && end && bucketSize) {
+ return getDataHandler('infra_logs')?.fetchData({
+ absoluteTime: { start, end },
+ relativeTime,
+ bucketSize,
+ });
}
- }, [startTime, endTime, bucketSize]);
+ }, [start, end, bucketSize]);
- const min = moment.utc(startTime).valueOf();
- const max = moment.utc(endTime).valueOf();
+ const min = moment.utc(absoluteTime.start).valueOf();
+ const max = moment.utc(absoluteTime.end).valueOf();
const formatter = niceTimeFormatter([min, max]);
- const { title, appLink, stats, series } = data || {};
+ const { appLink, stats, series } = data || {};
const colorsPerItem = getColorPerItem(series);
@@ -67,8 +72,15 @@ export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => {
return (
diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx
index 6276e1ba1baca..9e5fdadaf4e5f 100644
--- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx
@@ -18,8 +18,8 @@ import { ChartContainer } from '../../chart_container';
import { StyledStat } from '../../styled_stat';
interface Props {
- startTime?: string;
- endTime?: string;
+ absoluteTime: { start?: number; end?: number };
+ relativeTime: { start: string; end: string };
bucketSize?: string;
}
@@ -46,17 +46,23 @@ const StyledProgress = styled.div<{ color?: string }>`
}
`;
-export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => {
+export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
+
+ const { start, end } = absoluteTime;
const { data, status } = useFetcher(() => {
- if (startTime && endTime && bucketSize) {
- return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize });
+ if (start && end && bucketSize) {
+ return getDataHandler('infra_metrics')?.fetchData({
+ absoluteTime: { start, end },
+ relativeTime,
+ bucketSize,
+ });
}
- }, [startTime, endTime, bucketSize]);
+ }, [start, end, bucketSize]);
const isLoading = status === FETCH_STATUS.LOADING;
- const { title = 'Metrics', appLink, stats, series } = data || {};
+ const { appLink, stats, series } = data || {};
const cpuColor = theme.eui.euiColorVis7;
const memoryColor = theme.eui.euiColorVis0;
@@ -65,9 +71,15 @@ export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => {
return (
diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx
index 1f8ca6e61f132..73a566460a593 100644
--- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx
+++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx
@@ -30,37 +30,49 @@ import { StyledStat } from '../../styled_stat';
import { onBrushEnd } from '../helper';
interface Props {
- startTime?: string;
- endTime?: string;
+ absoluteTime: { start?: number; end?: number };
+ relativeTime: { start: string; end: string };
bucketSize?: string;
}
-export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => {
+export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => {
const theme = useContext(ThemeContext);
const history = useHistory();
+ const { start, end } = absoluteTime;
const { data, status } = useFetcher(() => {
- if (startTime && endTime && bucketSize) {
- return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize });
+ if (start && end && bucketSize) {
+ return getDataHandler('uptime')?.fetchData({
+ absoluteTime: { start, end },
+ relativeTime,
+ bucketSize,
+ });
}
- }, [startTime, endTime, bucketSize]);
+ }, [start, end, bucketSize]);
+
+ const min = moment.utc(absoluteTime.start).valueOf();
+ const max = moment.utc(absoluteTime.end).valueOf();
- const min = moment.utc(startTime).valueOf();
- const max = moment.utc(endTime).valueOf();
const formatter = niceTimeFormatter([min, max]);
const isLoading = status === FETCH_STATUS.LOADING;
- const { title = 'Uptime', appLink, stats, series } = data || {};
+ const { appLink, stats, series } = data || {};
const downColor = theme.eui.euiColorVis2;
const upColor = theme.eui.euiColorLightShade;
return (
diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts
index 71c2c942239fd..7170ffe1486dc 100644
--- a/x-pack/plugins/observability/public/data_handler.test.ts
+++ b/x-pack/plugins/observability/public/data_handler.test.ts
@@ -4,10 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { registerDataHandler, getDataHandler } from './data_handler';
+import moment from 'moment';
const params = {
- startTime: '0',
- endTime: '1',
+ absoluteTime: {
+ start: moment('2020-07-02T13:25:11.629Z').valueOf(),
+ end: moment('2020-07-09T13:25:11.629Z').valueOf(),
+ },
+ relativeTime: {
+ start: 'now-15m',
+ end: 'now',
+ },
bucketSize: '10s',
};
diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx
index 3674e69ab5702..088fab032d930 100644
--- a/x-pack/plugins/observability/public/pages/overview/index.tsx
+++ b/x-pack/plugins/observability/public/pages/overview/index.tsx
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
-import moment from 'moment';
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import { EmptySection } from '../../components/app/empty_section';
@@ -23,7 +22,7 @@ import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_sett
import { usePluginContext } from '../../hooks/use_plugin_context';
import { RouteParams } from '../../routes';
import { getObservabilityAlerts } from '../../services/get_observability_alerts';
-import { getParsedDate } from '../../utils/date';
+import { getAbsoluteTime } from '../../utils/date';
import { getBucketSize } from '../../utils/get_bucket_size';
import { getEmptySections } from './empty_section';
import { LoadingObservability } from './loading_observability';
@@ -33,13 +32,9 @@ interface Props {
routeParams: RouteParams<'/overview'>;
}
-function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) {
- if (startTime && endTime) {
- return getBucketSize({
- start: moment.utc(startTime).valueOf(),
- end: moment.utc(endTime).valueOf(),
- minInterval: '60s',
- });
+function calculatetBucketSize({ start, end }: { start?: number; end?: number }) {
+ if (start && end) {
+ return getBucketSize({ start, end, minInterval: '60s' });
}
}
@@ -62,16 +57,22 @@ export const OverviewPage = ({ routeParams }: Props) => {
return ;
}
- const {
- rangeFrom = timePickerTime.from,
- rangeTo = timePickerTime.to,
- refreshInterval = 10000,
- refreshPaused = true,
- } = routeParams.query;
+ const { refreshInterval = 10000, refreshPaused = true } = routeParams.query;
- const startTime = getParsedDate(rangeFrom);
- const endTime = getParsedDate(rangeTo, { roundUp: true });
- const bucketSize = calculatetBucketSize({ startTime, endTime });
+ const relativeTime = {
+ start: routeParams.query.rangeFrom ?? timePickerTime.from,
+ end: routeParams.query.rangeTo ?? timePickerTime.to,
+ };
+
+ const absoluteTime = {
+ start: getAbsoluteTime(relativeTime.start),
+ end: getAbsoluteTime(relativeTime.end, { roundUp: true }),
+ };
+
+ const bucketSize = calculatetBucketSize({
+ start: absoluteTime.start,
+ end: absoluteTime.end,
+ });
const appEmptySections = getEmptySections({ core }).filter(({ id }) => {
if (id === 'alert') {
@@ -93,8 +94,8 @@ export const OverviewPage = ({ routeParams }: Props) => {
@@ -116,8 +117,8 @@ export const OverviewPage = ({ routeParams }: Props) => {
{hasData.infra_logs && (
@@ -125,8 +126,8 @@ export const OverviewPage = ({ routeParams }: Props) => {
{hasData.infra_metrics && (
@@ -134,8 +135,8 @@ export const OverviewPage = ({ routeParams }: Props) => {
{hasData.apm && (
@@ -143,8 +144,8 @@ export const OverviewPage = ({ routeParams }: Props) => {
{hasData.uptime && (
diff --git a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts
index 7303b78cc0132..6a0e1a64aa115 100644
--- a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts
+++ b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts
@@ -10,7 +10,6 @@ export const fetchApmData: FetchData = () => {
};
const response: ApmFetchDataResponse = {
- title: 'APM',
appLink: '/app/apm',
stats: {
services: {
@@ -607,7 +606,6 @@ const response: ApmFetchDataResponse = {
};
export const emptyResponse: ApmFetchDataResponse = {
- title: 'APM',
appLink: '/app/apm',
stats: {
services: {
diff --git a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts
index 5bea1fbf19ace..8d1fb4d59c2cc 100644
--- a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts
+++ b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts
@@ -11,7 +11,6 @@ export const fetchLogsData: FetchData = () => {
};
const response: LogsFetchDataResponse = {
- title: 'Logs',
appLink:
"/app/logs/stream?logPosition=(end:'2020-06-30T21:30:00.000Z',start:'2020-06-27T22:00:00.000Z')",
stats: {
@@ -2319,7 +2318,6 @@ const response: LogsFetchDataResponse = {
};
export const emptyResponse: LogsFetchDataResponse = {
- title: 'Logs',
appLink: '/app/logs',
stats: {},
series: {},
diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts
index 37233b4f6342c..d5a7992ceabd8 100644
--- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts
+++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts
@@ -11,7 +11,6 @@ export const fetchMetricsData: FetchData = () => {
};
const response: MetricsFetchDataResponse = {
- title: 'Metrics',
appLink: '/app/apm',
stats: {
hosts: { value: 11, type: 'number' },
@@ -113,7 +112,6 @@ const response: MetricsFetchDataResponse = {
};
export const emptyResponse: MetricsFetchDataResponse = {
- title: 'Metrics',
appLink: '/app/apm',
stats: {
hosts: { value: 0, type: 'number' },
diff --git a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts
index ab5874f8bfcd4..c4fa09ceb11f7 100644
--- a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts
+++ b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts
@@ -10,7 +10,6 @@ export const fetchUptimeData: FetchData = () => {
};
const response: UptimeFetchDataResponse = {
- title: 'Uptime',
appLink: '/app/uptime#/',
stats: {
monitors: {
@@ -1191,7 +1190,6 @@ const response: UptimeFetchDataResponse = {
};
export const emptyResponse: UptimeFetchDataResponse = {
- title: 'Uptime',
appLink: '/app/uptime#/',
stats: {
monitors: {
diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
index 2dafd70896cc5..a3d7308ff9e4a 100644
--- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
+++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts
@@ -21,11 +21,8 @@ export interface Series {
}
export interface FetchDataParams {
- // The start timestamp in milliseconds of the queried time interval
- startTime: string;
- // The end timestamp in milliseconds of the queried time interval
- endTime: string;
- // The aggregation bucket size in milliseconds if applicable to the data source
+ absoluteTime: { start: number; end: number };
+ relativeTime: { start: string; end: string };
bucketSize: string;
}
@@ -41,7 +38,6 @@ export interface DataHandler {
}
export interface FetchDataResponse {
- title: string;
appLink: string;
}
diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts
index fc0bbdae20cb9..bdc89ad6e8fc0 100644
--- a/x-pack/plugins/observability/public/utils/date.ts
+++ b/x-pack/plugins/observability/public/utils/date.ts
@@ -5,11 +5,9 @@
*/
import datemath from '@elastic/datemath';
-export function getParsedDate(range?: string, opts = {}) {
- if (range) {
- const parsed = datemath.parse(range, opts);
- if (parsed) {
- return parsed.toISOString();
- }
+export function getAbsoluteTime(range: string, opts = {}) {
+ const parsed = datemath.parse(range, opts);
+ if (parsed) {
+ return parsed.valueOf();
}
}
diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts
index 2b9e9299852f5..2819c28cfb54f 100644
--- a/x-pack/plugins/reporting/common/types.ts
+++ b/x-pack/plugins/reporting/common/types.ts
@@ -6,6 +6,8 @@
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
export { ReportingConfigType } from '../server/config';
+// eslint-disable-next-line @kbn/eslint/no-restricted-paths
+export { LayoutInstance } from '../server/export_types/common/layouts';
export type JobId = string;
export type JobStatus =
diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx
index aad3d9b026c6e..8a25df0a74bbf 100644
--- a/x-pack/plugins/reporting/public/plugin.tsx
+++ b/x-pack/plugins/reporting/public/plugin.tsx
@@ -26,7 +26,7 @@ import {
import { ManagementSectionId, ManagementSetup } from '../../../../src/plugins/management/public';
import { SharePluginSetup } from '../../../../src/plugins/share/public';
import { LicensingPluginSetup } from '../../licensing/public';
-import { ReportingConfigType, JobId, JobStatusBuckets } from '../common/types';
+import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types';
import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants';
import { getGeneralErrorToast } from './components';
import { ReportListing } from './components/report_listing';
@@ -144,7 +144,7 @@ export class ReportingPublicPlugin implements Plugin {
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action);
- share.register(csvReportingProvider({ apiClient, toasts, license$ }));
+ share.register(csvReportingProvider({ apiClient, toasts, license$, uiSettings }));
share.register(
reportingPDFPNGProvider({
apiClient,
diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx
index ea4ecaa60ab2c..4ad35fd768825 100644
--- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx
+++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx
@@ -5,22 +5,29 @@
*/
import { i18n } from '@kbn/i18n';
+import moment from 'moment-timezone';
import React from 'react';
-
-import { ToastsSetup } from 'src/core/public';
+import { IUiSettingsClient, ToastsSetup } from 'src/core/public';
+import { ShareContext } from '../../../../../src/plugins/share/public';
+import { LicensingPluginSetup } from '../../../licensing/public';
+import { JobParamsDiscoverCsv, SearchRequest } from '../../server/export_types/csv/types';
import { ReportingPanelContent } from '../components/reporting_panel_content';
-import { ReportingAPIClient } from '../lib/reporting_api_client';
import { checkLicense } from '../lib/license_check';
-import { LicensingPluginSetup } from '../../../licensing/public';
-import { ShareContext } from '../../../../../src/plugins/share/public';
+import { ReportingAPIClient } from '../lib/reporting_api_client';
interface ReportingProvider {
apiClient: ReportingAPIClient;
toasts: ToastsSetup;
license$: LicensingPluginSetup['license$'];
+ uiSettings: IUiSettingsClient;
}
-export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingProvider) => {
+export const csvReportingProvider = ({
+ apiClient,
+ toasts,
+ license$,
+ uiSettings,
+}: ReportingProvider) => {
let toolTipContent = '';
let disabled = true;
let hasCSVReporting = false;
@@ -33,6 +40,14 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP
disabled = !enableLinks;
});
+ // If the TZ is set to the default "Browser", it will not be useful for
+ // server-side export. We need to derive the timezone and pass it as a param
+ // to the export API.
+ const browserTimezone =
+ uiSettings.get('dateFormat:tz') === 'Browser'
+ ? moment.tz.guess()
+ : uiSettings.get('dateFormat:tz');
+
const getShareMenuItems = ({
objectType,
objectId,
@@ -44,13 +59,19 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP
return [];
}
- const getJobParams = () => {
- return {
- ...sharingData,
- type: objectType,
- };
+ const jobParams: JobParamsDiscoverCsv = {
+ browserTimezone,
+ objectType,
+ title: sharingData.title as string,
+ indexPatternId: sharingData.indexPatternId as string,
+ searchRequest: sharingData.searchRequest as SearchRequest,
+ fields: sharingData.fields as string[],
+ metaFields: sharingData.metaFields as string[],
+ conflictedTypesFields: sharingData.conflictedTypesFields as string[],
};
+ const getJobParams = () => jobParams;
+
const shareActions = [];
if (hasCSVReporting) {
diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx
index 2343947a6d383..e10d04ea5fc6b 100644
--- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx
+++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx
@@ -7,12 +7,15 @@
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import React from 'react';
-import { ToastsSetup, IUiSettingsClient } from 'src/core/public';
-import { ReportingAPIClient } from '../lib/reporting_api_client';
-import { checkLicense } from '../lib/license_check';
-import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content';
-import { LicensingPluginSetup } from '../../../licensing/public';
+import { IUiSettingsClient, ToastsSetup } from 'src/core/public';
import { ShareContext } from '../../../../../src/plugins/share/public';
+import { LicensingPluginSetup } from '../../../licensing/public';
+import { LayoutInstance } from '../../common/types';
+import { JobParamsPNG } from '../../server/export_types/png/types';
+import { JobParamsPDF } from '../../server/export_types/printable_pdf/types';
+import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content';
+import { checkLicense } from '../lib/license_check';
+import { ReportingAPIClient } from '../lib/reporting_api_client';
interface ReportingPDFPNGProvider {
apiClient: ReportingAPIClient;
@@ -39,6 +42,14 @@ export const reportingPDFPNGProvider = ({
disabled = !enableLinks;
});
+ // If the TZ is set to the default "Browser", it will not be useful for
+ // server-side export. We need to derive the timezone and pass it as a param
+ // to the export API.
+ const browserTimezone =
+ uiSettings.get('dateFormat:tz') === 'Browser'
+ ? moment.tz.guess()
+ : uiSettings.get('dateFormat:tz');
+
const getShareMenuItems = ({
objectType,
objectId,
@@ -57,7 +68,7 @@ export const reportingPDFPNGProvider = ({
return [];
}
- const getReportingJobParams = () => {
+ const getPdfJobParams = (): JobParamsPDF => {
// Relative URL must have URL prefix (Spaces ID prefix), but not server basePath
// Replace hashes with original RISON values.
const relativeUrl = shareableUrl.replace(
@@ -65,36 +76,28 @@ export const reportingPDFPNGProvider = ({
''
);
- const browserTimezone =
- uiSettings.get('dateFormat:tz') === 'Browser'
- ? moment.tz.guess()
- : uiSettings.get('dateFormat:tz');
-
return {
- ...sharingData,
objectType,
browserTimezone,
- relativeUrls: [relativeUrl],
+ relativeUrls: [relativeUrl], // multi URL for PDF
+ layout: sharingData.layout as LayoutInstance,
+ title: sharingData.title as string,
};
};
- const getPngJobParams = () => {
+ const getPngJobParams = (): JobParamsPNG => {
// Replace hashes with original RISON values.
const relativeUrl = shareableUrl.replace(
window.location.origin + apiClient.getServerBasePath(),
''
);
- const browserTimezone =
- uiSettings.get('dateFormat:tz') === 'Browser'
- ? moment.tz.guess()
- : uiSettings.get('dateFormat:tz');
-
return {
- ...sharingData,
objectType,
browserTimezone,
- relativeUrl,
+ relativeUrl, // single URL for PNG
+ layout: sharingData.layout as LayoutInstance,
+ title: sharingData.title as string,
};
};
@@ -161,7 +164,7 @@ export const reportingPDFPNGProvider = ({
reportType="printablePdf"
objectType={objectType}
objectId={objectId}
- getJobParams={getReportingJobParams}
+ getJobParams={getPdfJobParams}
isDirty={isDirty}
onClose={onClose}
/>
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts
index c4fa1cd8e4fa6..fb2d9bfdc5838 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts
@@ -13,7 +13,6 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) {
const config = reporting.getConfig();
const crypto = cryptoFactory(config.get('encryptionKey'));
- const setupDeps = reporting.getPluginSetupDeps();
return async function scheduleTask(jobParams, context, request) {
const serializedEncryptedHeaders = await crypto.encrypt(request.headers);
@@ -21,13 +20,12 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory {
+ const decryptHeaders = async () => {
+ try {
+ if (typeof headers !== 'string') {
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage',
+ {
+ defaultMessage: 'Job headers are missing',
+ }
+ )
+ );
+ }
+ return await crypto.decrypt(headers);
+ } catch (err) {
+ logger.error(err);
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage',
+ {
+ defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}',
+ values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() },
+ }
+ )
+ ); // prettier-ignore
+ }
+ };
+
+ return KibanaRequest.from({
+ headers: await decryptHeaders(),
+ // This is used by the spaces SavedObjectClientWrapper to determine the existing space.
+ // We use the basePath from the saved job, which we'll have post spaces being implemented;
+ // or we use the server base path, which uses the default space
+ path: '/',
+ route: { settings: {} },
+ url: { href: '/' },
+ raw: { req: { url: '/' } },
+ } as Hapi.Request);
+};
export const runTaskFnFactory: RunTaskFnFactory {
- try {
- if (typeof headers !== 'string') {
- throw new Error(
- i18n.translate(
- 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage',
- {
- defaultMessage: 'Job headers are missing',
- }
- )
- );
- }
- return await crypto.decrypt(headers);
- } catch (err) {
- logger.error(err);
- throw new Error(
- i18n.translate(
- 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage',
- {
- defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}',
- values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() },
- }
- )
- ); // prettier-ignore
- }
- };
-
- const fakeRequest = KibanaRequest.from({
- headers: await decryptHeaders(),
- // This is used by the spaces SavedObjectClientWrapper to determine the existing space.
- // We use the basePath from the saved job, which we'll have post spaces being implemented;
- // or we use the server base path, which uses the default space
- getBasePath: () => basePath || serverBasePath,
- path: '/',
- route: { settings: {} },
- url: { href: '/' },
- raw: { req: { url: '/' } },
- } as Hapi.Request);
+ const { headers } = job;
+ const fakeRequest = await getRequest(headers, crypto, logger);
const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(fakeRequest);
const callEndpoint = (endpoint: string, clientParams = {}, options = {}) =>
@@ -87,62 +76,18 @@ export const runTaskFnFactory: RunTaskFnFactory {
- const fieldFormats = await getFieldFormats().fieldFormatServiceFactory(client);
- return fieldFormatMapFactory(indexPatternSavedObject, fieldFormats);
- };
- const getUiSettings = async (client: IUiSettingsClient) => {
- const [separator, quoteValues, timezone] = await Promise.all([
- client.get(CSV_SEPARATOR_SETTING),
- client.get(CSV_QUOTE_VALUES_SETTING),
- client.get('dateFormat:tz'),
- ]);
-
- if (timezone === 'Browser') {
- logger.warn(
- i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', {
- defaultMessage: 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.',
- values: { dateFormatTimezone: 'dateFormat:tz' }
- })
- ); // prettier-ignore
- }
-
- return {
- separator,
- quoteValues,
- timezone,
- };
- };
-
- const [formatsMap, uiSettings] = await Promise.all([
- getFormatsMap(uiSettingsClient),
- getUiSettings(uiSettingsClient),
- ]);
-
- const generateCsv = createGenerateCsv(jobLogger);
- const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : '';
-
- const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv({
- searchRequest,
- fields,
- metaFields,
- conflictedTypesFields,
+ const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv(
+ job,
+ config,
+ uiSettingsClient,
callEndpoint,
- cancellationToken,
- formatsMap,
- settings: {
- ...uiSettings,
- checkForFormulas: config.get('csv', 'checkForFormulas'),
- maxSizeBytes: config.get('csv', 'maxSizeBytes'),
- scroll: config.get('csv', 'scroll'),
- escapeFormulaValues: config.get('csv', 'escapeFormulaValues'),
- },
- });
+ cancellationToken
+ );
// @TODO: Consolidate these one-off warnings into the warnings array (max-size reached and csv contains formulas)
return {
- content_type: 'text/csv',
- content: bom + content,
+ content_type: CONTENT_TYPE_CSV,
+ content,
max_size_reached: maxSizeReached,
size,
csv_contains_formulas: csvContainsFormulas,
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts
similarity index 74%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts
index 83aa23de67663..1f0e450da698f 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts
@@ -5,25 +5,17 @@
*/
import expect from '@kbn/expect';
-
-import {
- fieldFormats,
- FieldFormatsGetConfigFn,
- UI_SETTINGS,
-} from '../../../../../../../../src/plugins/data/server';
+import { fieldFormats, FieldFormatsGetConfigFn, UI_SETTINGS } from 'src/plugins/data/server';
+import { IndexPatternSavedObject } from '../../types';
import { fieldFormatMapFactory } from './field_format_map';
type ConfigValue = { number: { id: string; params: {} } } | string;
describe('field format map', function () {
- const indexPatternSavedObject = {
- id: 'logstash-*',
- type: 'index-pattern',
- version: 'abc',
+ const indexPatternSavedObject: IndexPatternSavedObject = {
+ timeFieldName: '@timestamp',
+ title: 'logstash-*',
attributes: {
- title: 'logstash-*',
- timeFieldName: '@timestamp',
- notExpandable: true,
fields: '[{"name":"field1","type":"number"}, {"name":"field2","type":"number"}]',
fieldFormatMap: '{"field1":{"id":"bytes","params":{"pattern":"0,0.[0]b"}}}',
},
@@ -35,11 +27,16 @@ describe('field format map', function () {
configMock[UI_SETTINGS.FORMAT_NUMBER_DEFAULT_PATTERN] = '0,0.[000]';
const getConfig = ((key: string) => configMock[key]) as FieldFormatsGetConfigFn;
const testValue = '4000';
+ const mockTimezone = 'Browser';
const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry();
fieldFormatsRegistry.init(getConfig, {}, [fieldFormats.BytesFormat, fieldFormats.NumberFormat]);
- const formatMap = fieldFormatMapFactory(indexPatternSavedObject, fieldFormatsRegistry);
+ const formatMap = fieldFormatMapFactory(
+ indexPatternSavedObject,
+ fieldFormatsRegistry,
+ mockTimezone
+ );
it('should build field format map with entry per index pattern field', function () {
expect(formatMap.has('field1')).to.be(true);
@@ -48,10 +45,10 @@ describe('field format map', function () {
});
it('should create custom FieldFormat for fields with configured field formatter', function () {
- expect(formatMap.get('field1').convert(testValue)).to.be('3.9KB');
+ expect(formatMap.get('field1')!.convert(testValue)).to.be('3.9KB');
});
it('should create default FieldFormat for fields with no field formatter', function () {
- expect(formatMap.get('field2').convert(testValue)).to.be('4,000');
+ expect(formatMap.get('field2')!.convert(testValue)).to.be('4,000');
});
});
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts
similarity index 56%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts
index 6cb4d0bbb1c65..848cf569bc8d7 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts
@@ -5,19 +5,9 @@
*/
import _ from 'lodash';
-import {
- FieldFormatConfig,
- IFieldFormatsRegistry,
-} from '../../../../../../../../src/plugins/data/server';
-
-interface IndexPatternSavedObject {
- attributes: {
- fieldFormatMap: string;
- };
- id: string;
- type: string;
- version: string;
-}
+import { FieldFormat } from 'src/plugins/data/common';
+import { FieldFormatConfig, IFieldFormatsRegistry } from 'src/plugins/data/server';
+import { IndexPatternSavedObject } from '../../types';
/**
* Create a map of FieldFormat instances for index pattern fields
@@ -28,30 +18,39 @@ interface IndexPatternSavedObject {
*/
export function fieldFormatMapFactory(
indexPatternSavedObject: IndexPatternSavedObject,
- fieldFormatsRegistry: IFieldFormatsRegistry
+ fieldFormatsRegistry: IFieldFormatsRegistry,
+ timezone: string | undefined
) {
- const formatsMap = new Map();
+ const formatsMap = new Map();
+
+ // From here, the browser timezone can't be determined, so we accept a
+ // timezone field from job params posted to the API. Here is where it gets used.
+ const serverDateParams = { timezone };
// Add FieldFormat instances for fields with custom formatters
if (_.has(indexPatternSavedObject, 'attributes.fieldFormatMap')) {
const fieldFormatMap = JSON.parse(indexPatternSavedObject.attributes.fieldFormatMap);
Object.keys(fieldFormatMap).forEach((fieldName) => {
const formatConfig: FieldFormatConfig = fieldFormatMap[fieldName];
+ const formatParams = {
+ ...formatConfig.params,
+ ...serverDateParams,
+ };
if (!_.isEmpty(formatConfig)) {
- formatsMap.set(
- fieldName,
- fieldFormatsRegistry.getInstance(formatConfig.id, formatConfig.params)
- );
+ formatsMap.set(fieldName, fieldFormatsRegistry.getInstance(formatConfig.id, formatParams));
}
});
}
- // Add default FieldFormat instances for all other fields
+ // Add default FieldFormat instances for non-custom formatted fields
const indexFields = JSON.parse(_.get(indexPatternSavedObject, 'attributes.fields', '[]'));
indexFields.forEach((field: any) => {
if (!formatsMap.has(field.name)) {
- formatsMap.set(field.name, fieldFormatsRegistry.getDefaultInstance(field.type));
+ formatsMap.set(
+ field.name,
+ fieldFormatsRegistry.getDefaultInstance(field.type, [], serverDateParams)
+ );
}
});
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts
similarity index 86%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts
index bb4e2be86f5df..387066415a1bc 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts
@@ -5,13 +5,14 @@
*/
import { isNull, isObject, isUndefined } from 'lodash';
+import { FieldFormat } from 'src/plugins/data/common';
import { RawValue } from '../../types';
export function createFormatCsvValues(
escapeValue: (value: RawValue, index: number, array: RawValue[]) => string,
separator: string,
fields: string[],
- formatsMap: any
+ formatsMap: Map
) {
return function formatCsvValues(values: Record) {
return fields
@@ -29,7 +30,9 @@ export function createFormatCsvValues(
let formattedValue = value;
if (formatsMap.has(field)) {
const formatter = formatsMap.get(field);
- formattedValue = formatter.convert(value);
+ if (formatter) {
+ formattedValue = formatter.convert(value);
+ }
}
return formattedValue;
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts
new file mode 100644
index 0000000000000..8f72c467b0711
--- /dev/null
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { IUiSettingsClient } from 'kibana/server';
+import { ReportingConfig } from '../../../..';
+import { LevelLogger } from '../../../../lib';
+
+export const getUiSettings = async (
+ timezone: string | undefined,
+ client: IUiSettingsClient,
+ config: ReportingConfig,
+ logger: LevelLogger
+) => {
+ // Timezone
+ let setTimezone: string;
+ // look for timezone in job params
+ if (timezone) {
+ setTimezone = timezone;
+ } else {
+ // if empty, look for timezone in settings
+ setTimezone = await client.get('dateFormat:tz');
+ if (setTimezone === 'Browser') {
+ // if `Browser`, hardcode it to 'UTC' so the export has data that makes sense
+ logger.warn(
+ i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', {
+ defaultMessage:
+ 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.',
+ values: { dateFormatTimezone: 'dateFormat:tz' },
+ })
+ );
+ setTimezone = 'UTC';
+ }
+ }
+
+ // Separator, QuoteValues
+ const [separator, quoteValues] = await Promise.all([
+ client.get('csv:separator'),
+ client.get('csv:quoteValues'),
+ ]);
+
+ return {
+ timezone: setTimezone,
+ separator,
+ quoteValues,
+ escapeFormulaValues: config.get('csv', 'escapeFormulaValues'),
+ maxSizeBytes: config.get('csv', 'maxSizeBytes'),
+ scroll: config.get('csv', 'scroll'),
+ checkForFormulas: config.get('csv', 'checkForFormulas'),
+ };
+};
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts
similarity index 100%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts
similarity index 82%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts
index 38b28573d602d..b877023064ac6 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts
@@ -10,8 +10,10 @@ import { CancellationToken } from '../../../../../common';
import { LevelLogger } from '../../../../lib';
import { ScrollConfig } from '../../../../types';
-async function parseResponse(request: SearchResponse) {
- const response = await request;
+export type EndpointCaller = (method: string, params: object) => Promise>;
+
+function parseResponse(request: SearchResponse) {
+ const response = request;
if (!response || !response._scroll_id) {
throw new Error(
i18n.translate('xpack.reporting.exportTypes.csv.hitIterator.expectedScrollIdErrorMessage', {
@@ -39,14 +41,15 @@ async function parseResponse(request: SearchResponse) {
export function createHitIterator(logger: LevelLogger) {
return async function* hitIterator(
scrollSettings: ScrollConfig,
- callEndpoint: Function,
+ callEndpoint: EndpointCaller,
searchRequest: SearchParams,
cancellationToken: CancellationToken
) {
logger.debug('executing search request');
- function search(index: string | boolean | string[] | undefined, body: object) {
+ async function search(index: string | boolean | string[] | undefined, body: object) {
return parseResponse(
- callEndpoint('search', {
+ await callEndpoint('search', {
+ ignore_unavailable: true, // ignores if the index pattern contains any aliases that point to closed indices
index,
body,
scroll: scrollSettings.duration,
@@ -55,10 +58,10 @@ export function createHitIterator(logger: LevelLogger) {
);
}
- function scroll(scrollId: string | undefined) {
+ async function scroll(scrollId: string | undefined) {
logger.debug('executing scroll request');
return parseResponse(
- callEndpoint('scroll', {
+ await callEndpoint('scroll', {
scrollId,
scroll: scrollSettings.duration,
})
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts
similarity index 55%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts
index 019fa3c9c8e9d..2cb10e291619c 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts
@@ -5,30 +5,68 @@
*/
import { i18n } from '@kbn/i18n';
+import { IUiSettingsClient } from 'src/core/server';
+import { getFieldFormats } from '../../../../services';
+import { ReportingConfig } from '../../../..';
+import { CancellationToken } from '../../../../../../../plugins/reporting/common';
+import { CSV_BOM_CHARS } from '../../../../../common/constants';
import { LevelLogger } from '../../../../lib';
-import { GenerateCsvParams, SavedSearchGeneratorResult } from '../../types';
+import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../../types';
+import { checkIfRowsHaveFormulas } from './check_cells_for_formulas';
+import { createEscapeValue } from './escape_value';
+import { fieldFormatMapFactory } from './field_format_map';
import { createFlattenHit } from './flatten_hit';
import { createFormatCsvValues } from './format_csv_values';
-import { createEscapeValue } from './escape_value';
-import { createHitIterator } from './hit_iterator';
+import { getUiSettings } from './get_ui_settings';
+import { createHitIterator, EndpointCaller } from './hit_iterator';
import { MaxSizeStringBuilder } from './max_size_string_builder';
-import { checkIfRowsHaveFormulas } from './check_cells_for_formulas';
+
+interface SearchRequest {
+ index: string;
+ body:
+ | {
+ _source: { excludes: string[]; includes: string[] };
+ docvalue_fields: string[];
+ query: { bool: { filter: any[]; must_not: any[]; should: any[]; must: any[] } } | any;
+ script_fields: any;
+ sort: Array<{ [key: string]: { order: string } }>;
+ stored_fields: string[];
+ }
+ | any;
+}
+
+export interface GenerateCsvParams {
+ jobParams: {
+ browserTimezone: string;
+ };
+ searchRequest: SearchRequest;
+ indexPatternSavedObject: IndexPatternSavedObject;
+ fields: string[];
+ metaFields: string[];
+ conflictedTypesFields: string[];
+}
export function createGenerateCsv(logger: LevelLogger) {
const hitIterator = createHitIterator(logger);
- return async function generateCsv({
- searchRequest,
- fields,
- formatsMap,
- metaFields,
- conflictedTypesFields,
- callEndpoint,
- cancellationToken,
- settings,
- }: GenerateCsvParams): Promise {
+ return async function generateCsv(
+ job: GenerateCsvParams,
+ config: ReportingConfig,
+ uiSettingsClient: IUiSettingsClient,
+ callEndpoint: EndpointCaller,
+ cancellationToken: CancellationToken
+ ): Promise {
+ const settings = await getUiSettings(
+ job.jobParams?.browserTimezone,
+ uiSettingsClient,
+ config,
+ logger
+ );
const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues);
- const builder = new MaxSizeStringBuilder(settings.maxSizeBytes);
+ const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : '';
+ const builder = new MaxSizeStringBuilder(settings.maxSizeBytes, bom);
+
+ const { fields, metaFields, conflictedTypesFields } = job;
const header = `${fields.map(escapeValue).join(settings.separator)}\n`;
const warnings: string[] = [];
@@ -41,11 +79,22 @@ export function createGenerateCsv(logger: LevelLogger) {
};
}
- const iterator = hitIterator(settings.scroll, callEndpoint, searchRequest, cancellationToken);
+ const iterator = hitIterator(
+ settings.scroll,
+ callEndpoint,
+ job.searchRequest,
+ cancellationToken
+ );
let maxSizeReached = false;
let csvContainsFormulas = false;
const flattenHit = createFlattenHit(fields, metaFields, conflictedTypesFields);
+ const formatsMap = await getFieldFormats()
+ .fieldFormatServiceFactory(uiSettingsClient)
+ .then((fieldFormats) =>
+ fieldFormatMapFactory(job.indexPatternSavedObject, fieldFormats, settings.timezone)
+ );
+
const formatCsvValues = createFormatCsvValues(
escapeValue,
settings.separator,
@@ -76,7 +125,9 @@ export function createGenerateCsv(logger: LevelLogger) {
if (!builder.tryAppend(rows + '\n')) {
logger.warn('max Size Reached');
maxSizeReached = true;
- cancellationToken.cancel();
+ if (cancellationToken) {
+ cancellationToken.cancel();
+ }
break;
}
}
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts
similarity index 91%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts
index 7a35de1cea19b..e3cd1f32856e6 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts
@@ -62,6 +62,14 @@ describe('MaxSizeStringBuilder', function () {
builder.tryAppend(str);
expect(builder.getString()).to.be('a');
});
+
+ it('should return string with bom character prepended', function () {
+ const str = 'a'; // each a is one byte
+ const builder = new MaxSizeStringBuilder(1, '∆');
+ builder.tryAppend(str);
+ builder.tryAppend(str);
+ expect(builder.getString()).to.be('∆a');
+ });
});
describe('getSizeInBytes', function () {
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts
similarity index 82%
rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts
rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts
index 70bc2030d290c..147031c104c8e 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts
@@ -8,11 +8,13 @@ export class MaxSizeStringBuilder {
private _buffer: Buffer;
private _size: number;
private _maxSize: number;
+ private _bom: string;
- constructor(maxSizeBytes: number) {
+ constructor(maxSizeBytes: number, bom = '') {
this._buffer = Buffer.alloc(maxSizeBytes);
this._size = 0;
this._maxSize = maxSizeBytes;
+ this._bom = bom;
}
tryAppend(str: string) {
@@ -31,6 +33,6 @@ export class MaxSizeStringBuilder {
}
getString() {
- return this._buffer.slice(0, this._size).toString();
+ return this._bom + this._buffer.slice(0, this._size).toString();
}
}
diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts
new file mode 100644
index 0000000000000..21e49bd62ccc7
--- /dev/null
+++ b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { Crypto } from '@elastic/node-crypto';
+import { i18n } from '@kbn/i18n';
+import Hapi from 'hapi';
+import { KibanaRequest } from '../../../../../../../../src/core/server';
+import { LevelLogger } from '../../../../lib';
+
+export const getRequest = async (
+ headers: string | undefined,
+ crypto: Crypto,
+ logger: LevelLogger
+) => {
+ const decryptHeaders = async () => {
+ try {
+ if (typeof headers !== 'string') {
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage',
+ {
+ defaultMessage: 'Job headers are missing',
+ }
+ )
+ );
+ }
+ return await crypto.decrypt(headers);
+ } catch (err) {
+ logger.error(err);
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage',
+ {
+ defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}',
+ values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() },
+ }
+ )
+ ); // prettier-ignore
+ }
+ };
+
+ return KibanaRequest.from({
+ headers: await decryptHeaders(),
+ // This is used by the spaces SavedObjectClientWrapper to determine the existing space.
+ // We use the basePath from the saved job, which we'll have post spaces being implemented;
+ // or we use the server base path, which uses the default space
+ path: '/',
+ route: { settings: {} },
+ url: { href: '/' },
+ raw: { req: { url: '/' } },
+ } as Hapi.Request);
+};
diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts
index ab3e114c7c995..9e86a5bb254a3 100644
--- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts
@@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { CancellationToken } from '../../../common';
-import { JobParamPostPayload, ScheduledTaskParams, ScrollConfig } from '../../types';
+import { ScheduledTaskParams } from '../../types';
export type RawValue = string | object | null | undefined;
@@ -19,17 +18,25 @@ interface SortOptions {
unmapped_type: string;
}
-export interface JobParamPostPayloadDiscoverCsv extends JobParamPostPayload {
- state?: {
- query: any;
- sort: Array>;
- docvalue_fields: DocValueField[];
+export interface IndexPatternSavedObject {
+ title: string;
+ timeFieldName: string;
+ fields?: any[];
+ attributes: {
+ fields: string;
+ fieldFormatMap: string;
};
}
export interface JobParamsDiscoverCsv {
- indexPatternId?: string;
- post?: JobParamPostPayloadDiscoverCsv;
+ browserTimezone: string;
+ indexPatternId: string;
+ objectType: string;
+ title: string;
+ searchRequest: SearchRequest;
+ fields: string[];
+ metaFields: string[];
+ conflictedTypesFields: string[];
}
export interface ScheduledTaskParamsCSV extends ScheduledTaskParams {
@@ -71,8 +78,6 @@ export interface SearchRequest {
| any;
}
-type EndpointCaller = (method: string, params: any) => Promise;
-
type FormatsMap = Map<
string,
{
@@ -95,22 +100,3 @@ export interface CsvResultFromSearch {
type: string;
result: SavedSearchGeneratorResult;
}
-
-export interface GenerateCsvParams {
- searchRequest: SearchRequest;
- callEndpoint: EndpointCaller;
- fields: string[];
- formatsMap: FormatsMap;
- metaFields: string[];
- conflictedTypesFields: string[];
- cancellationToken: CancellationToken;
- settings: {
- separator: string;
- quoteValues: boolean;
- timezone: string | null;
- maxSizeBytes: number;
- scroll: ScrollConfig;
- checkForFormulas?: boolean;
- escapeFormulaValues: boolean;
- };
-}
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts
similarity index 76%
rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts
rename to x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts
index da9810b03aff6..96fb2033f0954 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts
@@ -7,18 +7,18 @@
import { notFound, notImplemented } from 'boom';
import { get } from 'lodash';
import { KibanaRequest, RequestHandlerContext } from 'src/core/server';
-import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../../common/constants';
-import { cryptoFactory } from '../../../../lib';
-import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../../types';
+import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants';
+import { cryptoFactory } from '../../../lib';
+import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../types';
import {
JobParamsPanelCsv,
SavedObject,
+ SavedObjectReference,
SavedObjectServiceError,
SavedSearchObjectAttributesJSON,
SearchPanel,
VisObjectAttributesJSON,
-} from '../../types';
-import { createJobSearch } from './create_job_search';
+} from '../types';
export type ImmediateCreateJobFn = (
jobParams: JobParamsPanelCsv,
@@ -26,7 +26,7 @@ export type ImmediateCreateJobFn = (
context: RequestHandlerContext,
req: KibanaRequest
) => Promise<{
- type: string | null;
+ type: string;
title: string;
jobParams: JobParamsPanelCsv;
}>;
@@ -73,7 +73,28 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory
}
// saved search type
- return await createJobSearch(timerange, attributes, references, kibanaSavedObjectMeta);
+ const { searchSource } = kibanaSavedObjectMeta;
+ if (!searchSource || !references) {
+ throw new Error('The saved search object is missing configuration fields!');
+ }
+
+ const indexPatternMeta = references.find(
+ (ref: SavedObjectReference) => ref.type === 'index-pattern'
+ );
+ if (!indexPatternMeta) {
+ throw new Error('Could not find index pattern for the saved search!');
+ }
+
+ const sPanel = {
+ attributes: {
+ ...attributes,
+ kibanaSavedObjectMeta: { searchSource },
+ },
+ indexPatternSavedObjectId: indexPatternMeta.id,
+ timerange,
+ };
+
+ return { panel: sPanel, title: attributes.title, visType: 'search' };
})
.catch((err: Error) => {
const boomErr = (err as unknown) as { isBoom: boolean };
@@ -93,7 +114,7 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory
return {
headers: serializedEncryptedHeaders,
jobParams: { ...jobParams, panel, visType },
- type: null,
+ type: visType,
title,
};
};
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts
deleted file mode 100644
index 02abfb90091a1..0000000000000
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts
+++ /dev/null
@@ -1,49 +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 { TimeRangeParams } from '../../../../types';
-import {
- SavedObjectMeta,
- SavedObjectReference,
- SavedSearchObjectAttributes,
- SearchPanel,
-} from '../../types';
-
-interface SearchPanelData {
- title: string;
- visType: string;
- panel: SearchPanel;
-}
-
-export async function createJobSearch(
- timerange: TimeRangeParams,
- attributes: SavedSearchObjectAttributes,
- references: SavedObjectReference[],
- kibanaSavedObjectMeta: SavedObjectMeta
-): Promise {
- const { searchSource } = kibanaSavedObjectMeta;
- if (!searchSource || !references) {
- throw new Error('The saved search object is missing configuration fields!');
- }
-
- const indexPatternMeta = references.find(
- (ref: SavedObjectReference) => ref.type === 'index-pattern'
- );
- if (!indexPatternMeta) {
- throw new Error('Could not find index pattern for the saved search!');
- }
-
- const sPanel = {
- attributes: {
- ...attributes,
- kibanaSavedObjectMeta: { searchSource },
- },
- indexPatternSavedObjectId: indexPatternMeta.id,
- timerange,
- };
-
- return { panel: sPanel, title: attributes.title, visType: 'search' };
-}
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts
index 912ae0809cf92..a7992c34a88f1 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts
@@ -4,13 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { i18n } from '@kbn/i18n';
import { KibanaRequest, RequestHandlerContext } from 'src/core/server';
+import { CancellationToken } from '../../../../common';
import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants';
import { RunTaskFnFactory, ScheduledTaskParams, TaskRunResult } from '../../../types';
-import { CsvResultFromSearch } from '../../csv/types';
+import { createGenerateCsv } from '../../csv/server/generate_csv';
import { JobParamsPanelCsv, SearchPanel } from '../types';
-import { createGenerateCsv } from './lib';
+import { getFakeRequest } from './lib/get_fake_request';
+import { getGenerateCsvParams } from './lib/get_csv_job';
/*
* The run function receives the full request which provides the un-encrypted
@@ -33,45 +34,47 @@ export const runTaskFnFactory: RunTaskFnFactory = function e
reporting,
parentLogger
) {
+ const config = reporting.getConfig();
const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']);
- const generateCsv = createGenerateCsv(reporting, parentLogger);
- return async function runTask(jobId: string | null, job, context, request) {
+ return async function runTask(jobId: string | null, jobPayload, context, req) {
// There will not be a jobID for "immediate" generation.
// jobID is only for "queued" jobs
// Use the jobID as a logging tag or "immediate"
+ const { jobParams } = jobPayload;
const jobLogger = logger.clone([jobId === null ? 'immediate' : jobId]);
+ const generateCsv = createGenerateCsv(jobLogger);
+ const { isImmediate, panel, visType } = jobParams as JobParamsPanelCsv & {
+ panel: SearchPanel;
+ };
- const { jobParams } = job;
- const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel };
+ jobLogger.debug(`Execute job generating [${visType}] csv`);
- if (!panel) {
- i18n.translate(
- 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel',
- { defaultMessage: 'Failed to access panel metadata for job execution' }
- );
+ if (isImmediate && req) {
+ jobLogger.info(`Executing job from Immediate API using request context`);
+ } else {
+ jobLogger.info(`Executing job async using encrypted headers`);
+ req = await getFakeRequest(jobPayload, config.get('encryptionKey')!, jobLogger);
}
- jobLogger.debug(`Execute job generating [${visType}] csv`);
+ const savedObjectsClient = context.core.savedObjects.client;
+
+ const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient);
+ const job = await getGenerateCsvParams(jobParams, panel, savedObjectsClient, uiConfig);
+
+ const elasticsearch = reporting.getElasticsearchService();
+ const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req);
- let content: string;
- let maxSizeReached = false;
- let size = 0;
- try {
- const generateResults: CsvResultFromSearch = await generateCsv(
- context,
- request,
- visType as string,
- panel,
- jobParams
- );
+ const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv(
+ job,
+ config,
+ uiConfig,
+ callAsCurrentUser,
+ new CancellationToken() // can not be cancelled
+ );
- ({
- result: { content, maxSizeReached, size },
- } = generateResults);
- } catch (err) {
- jobLogger.error(`Generate CSV Error! ${err}`);
- throw err;
+ if (csvContainsFormulas) {
+ jobLogger.warn(`CSV may contain formulas whose values have been escaped`);
}
if (maxSizeReached) {
@@ -83,6 +86,8 @@ export const runTaskFnFactory: RunTaskFnFactory = function e
content,
max_size_reached: maxSizeReached,
size,
+ csv_contains_formulas: csvContainsFormulas,
+ warnings,
};
};
};
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts
deleted file mode 100644
index dd0fb34668e9e..0000000000000
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts
+++ /dev/null
@@ -1,41 +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 { badRequest } from 'boom';
-import { KibanaRequest, RequestHandlerContext } from 'src/core/server';
-import { ReportingCore } from '../../../..';
-import { LevelLogger } from '../../../../lib';
-import { FakeRequest, JobParamsPanelCsv, SearchPanel, VisPanel } from '../../types';
-import { generateCsvSearch } from './generate_csv_search';
-
-export function createGenerateCsv(reporting: ReportingCore, logger: LevelLogger) {
- return async function generateCsv(
- context: RequestHandlerContext,
- request: KibanaRequest | FakeRequest,
- visType: string,
- panel: VisPanel | SearchPanel,
- jobParams: JobParamsPanelCsv
- ) {
- // This should support any vis type that is able to fetch
- // and model data on the server-side
-
- // This structure will not be needed when the vis data just consists of an
- // expression that we could run through the interpreter to get csv
- switch (visType) {
- case 'search':
- return await generateCsvSearch(
- reporting,
- context,
- request as KibanaRequest,
- panel as SearchPanel,
- jobParams,
- logger
- );
- default:
- throw badRequest(`Unsupported or unrecognized saved object type: ${visType}`);
- }
- };
-}
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts
deleted file mode 100644
index aee3e40025ff2..0000000000000
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts
+++ /dev/null
@@ -1,187 +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 { ReportingCore } from '../../../../';
-import {
- IUiSettingsClient,
- KibanaRequest,
- RequestHandlerContext,
-} from '../../../../../../../../src/core/server';
-import {
- esQuery,
- EsQueryConfig,
- Filter,
- IIndexPattern,
- Query,
- UI_SETTINGS,
-} from '../../../../../../../../src/plugins/data/server';
-import {
- CSV_SEPARATOR_SETTING,
- CSV_QUOTE_VALUES_SETTING,
-} from '../../../../../../../../src/plugins/share/server';
-import { CancellationToken } from '../../../../../common';
-import { LevelLogger } from '../../../../lib';
-import { createGenerateCsv } from '../../../csv/server/lib/generate_csv';
-import {
- CsvResultFromSearch,
- GenerateCsvParams,
- JobParamsDiscoverCsv,
- SearchRequest,
-} from '../../../csv/types';
-import { IndexPatternField, QueryFilter, SearchPanel, SearchSource } from '../../types';
-import { getDataSource } from './get_data_source';
-import { getFilters } from './get_filters';
-
-const getEsQueryConfig = async (config: IUiSettingsClient) => {
- const configs = await Promise.all([
- config.get(UI_SETTINGS.QUERY_ALLOW_LEADING_WILDCARDS),
- config.get(UI_SETTINGS.QUERY_STRING_OPTIONS),
- config.get(UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX),
- ]);
- const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs;
- return {
- allowLeadingWildcards,
- queryStringOptions,
- ignoreFilterIfFieldNotInIndex,
- } as EsQueryConfig;
-};
-
-const getUiSettings = async (config: IUiSettingsClient) => {
- const configs = await Promise.all([
- config.get(CSV_SEPARATOR_SETTING),
- config.get(CSV_QUOTE_VALUES_SETTING),
- ]);
- const [separator, quoteValues] = configs;
- return { separator, quoteValues };
-};
-
-export async function generateCsvSearch(
- reporting: ReportingCore,
- context: RequestHandlerContext,
- req: KibanaRequest,
- searchPanel: SearchPanel,
- jobParams: JobParamsDiscoverCsv,
- logger: LevelLogger
-): Promise {
- const savedObjectsClient = context.core.savedObjects.client;
- const { indexPatternSavedObjectId, timerange } = searchPanel;
- const savedSearchObjectAttr = searchPanel.attributes;
- const { indexPatternSavedObject } = await getDataSource(
- savedObjectsClient,
- indexPatternSavedObjectId
- );
-
- const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient);
- const esQueryConfig = await getEsQueryConfig(uiConfig);
-
- const {
- kibanaSavedObjectMeta: {
- searchSource: {
- filter: [searchSourceFilter],
- query: searchSourceQuery,
- },
- },
- } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } };
-
- const {
- timeFieldName: indexPatternTimeField,
- title: esIndex,
- fields: indexPatternFields,
- } = indexPatternSavedObject;
-
- let payloadQuery: QueryFilter | undefined;
- let payloadSort: any[] = [];
- let docValueFields: any[] | undefined;
- if (jobParams.post && jobParams.post.state) {
- ({
- post: {
- state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields },
- },
- } = jobParams);
- }
-
- const { includes, timezone, combinedFilter } = getFilters(
- indexPatternSavedObjectId,
- indexPatternTimeField,
- timerange,
- savedSearchObjectAttr,
- searchSourceFilter,
- payloadQuery
- );
-
- const savedSortConfigs = savedSearchObjectAttr.sort;
- const sortConfig = [...payloadSort];
- savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => {
- sortConfig.push({ [savedSortField]: { order: savedSortOrder } });
- });
- const scriptFieldsConfig = indexPatternFields
- .filter((f: IndexPatternField) => f.scripted)
- .reduce((accum: any, curr: IndexPatternField) => {
- return {
- ...accum,
- [curr.name]: {
- script: {
- source: curr.script,
- lang: curr.lang,
- },
- },
- };
- }, {});
-
- if (indexPatternTimeField) {
- if (docValueFields) {
- docValueFields = [indexPatternTimeField].concat(docValueFields);
- } else {
- docValueFields = [indexPatternTimeField];
- }
- }
-
- const searchRequest: SearchRequest = {
- index: esIndex,
- body: {
- _source: { includes },
- docvalue_fields: docValueFields,
- query: esQuery.buildEsQuery(
- indexPatternSavedObject as IIndexPattern,
- (searchSourceQuery as unknown) as Query,
- (combinedFilter as unknown) as Filter,
- esQueryConfig
- ),
- script_fields: scriptFieldsConfig,
- sort: sortConfig,
- },
- };
-
- const config = reporting.getConfig();
- const elasticsearch = reporting.getElasticsearchService();
- const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req);
- const callCluster = (...params: [string, object]) => callAsCurrentUser(...params);
- const uiSettings = await getUiSettings(uiConfig);
-
- const generateCsvParams: GenerateCsvParams = {
- searchRequest,
- callEndpoint: callCluster,
- fields: includes,
- formatsMap: new Map(), // there is no field formatting in this API; this is required for generateCsv
- metaFields: [],
- conflictedTypesFields: [],
- cancellationToken: new CancellationToken(),
- settings: {
- ...uiSettings,
- maxSizeBytes: config.get('csv', 'maxSizeBytes'),
- scroll: config.get('csv', 'scroll'),
- escapeFormulaValues: config.get('csv', 'escapeFormulaValues'),
- timezone,
- },
- };
-
- const generateCsv = createGenerateCsv(logger);
-
- return {
- type: 'CSV from Saved Search',
- result: await generateCsv(generateCsvParams),
- };
-}
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts
new file mode 100644
index 0000000000000..3271c6fdae24d
--- /dev/null
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts
@@ -0,0 +1,341 @@
+/*
+ * 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 { JobParamsPanelCsv, SearchPanel } from '../../types';
+import { getGenerateCsvParams } from './get_csv_job';
+
+describe('Get CSV Job', () => {
+ let mockJobParams: JobParamsPanelCsv;
+ let mockSearchPanel: SearchPanel;
+ let mockSavedObjectsClient: any;
+ let mockUiSettingsClient: any;
+ beforeEach(() => {
+ mockJobParams = { isImmediate: true, savedObjectType: 'search', savedObjectId: '234-ididid' };
+ mockSearchPanel = {
+ indexPatternSavedObjectId: '123-indexId',
+ attributes: {
+ title: 'my search',
+ sort: [],
+ kibanaSavedObjectMeta: {
+ searchSource: { query: { isSearchSourceQuery: true }, filter: [] },
+ },
+ uiState: 56,
+ },
+ timerange: { timezone: 'PST', min: 0, max: 100 },
+ };
+ mockSavedObjectsClient = {
+ get: () => ({
+ attributes: { fields: null, title: null, timeFieldName: null },
+ }),
+ };
+ mockUiSettingsClient = {
+ get: () => ({}),
+ };
+ });
+
+ it('creates a data structure needed by generateCsv', async () => {
+ const result = await getGenerateCsvParams(
+ mockJobParams,
+ mockSearchPanel,
+ mockSavedObjectsClient,
+ mockUiSettingsClient
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "conflictedTypesFields": Array [],
+ "fields": Array [],
+ "indexPatternSavedObject": Object {
+ "attributes": Object {
+ "fields": null,
+ "timeFieldName": null,
+ "title": null,
+ },
+ "fields": Array [],
+ "timeFieldName": null,
+ "title": null,
+ },
+ "jobParams": Object {
+ "browserTimezone": "PST",
+ },
+ "metaFields": Array [],
+ "searchRequest": Object {
+ "body": Object {
+ "_source": Object {
+ "includes": Array [],
+ },
+ "docvalue_fields": undefined,
+ "query": Object {
+ "bool": Object {
+ "filter": Array [],
+ "must": Array [],
+ "must_not": Array [],
+ "should": Array [],
+ },
+ },
+ "script_fields": Object {},
+ "sort": Array [],
+ },
+ "index": null,
+ },
+ }
+ `);
+ });
+
+ it('uses query and sort from the payload', async () => {
+ mockJobParams.post = {
+ state: {
+ query: ['this is the query'],
+ sort: ['this is the sort'],
+ },
+ };
+ const result = await getGenerateCsvParams(
+ mockJobParams,
+ mockSearchPanel,
+ mockSavedObjectsClient,
+ mockUiSettingsClient
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "conflictedTypesFields": Array [],
+ "fields": Array [],
+ "indexPatternSavedObject": Object {
+ "attributes": Object {
+ "fields": null,
+ "timeFieldName": null,
+ "title": null,
+ },
+ "fields": Array [],
+ "timeFieldName": null,
+ "title": null,
+ },
+ "jobParams": Object {
+ "browserTimezone": "PST",
+ },
+ "metaFields": Array [],
+ "searchRequest": Object {
+ "body": Object {
+ "_source": Object {
+ "includes": Array [],
+ },
+ "docvalue_fields": undefined,
+ "query": Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "0": "this is the query",
+ },
+ ],
+ "must": Array [],
+ "must_not": Array [],
+ "should": Array [],
+ },
+ },
+ "script_fields": Object {},
+ "sort": Array [
+ "this is the sort",
+ ],
+ },
+ "index": null,
+ },
+ }
+ `);
+ });
+
+ it('uses timerange timezone from the payload', async () => {
+ mockJobParams.post = {
+ timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 9000 },
+ };
+ const result = await getGenerateCsvParams(
+ mockJobParams,
+ mockSearchPanel,
+ mockSavedObjectsClient,
+ mockUiSettingsClient
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "conflictedTypesFields": Array [],
+ "fields": Array [],
+ "indexPatternSavedObject": Object {
+ "attributes": Object {
+ "fields": null,
+ "timeFieldName": null,
+ "title": null,
+ },
+ "fields": Array [],
+ "timeFieldName": null,
+ "title": null,
+ },
+ "jobParams": Object {
+ "browserTimezone": "Africa/Timbuktu",
+ },
+ "metaFields": Array [],
+ "searchRequest": Object {
+ "body": Object {
+ "_source": Object {
+ "includes": Array [],
+ },
+ "docvalue_fields": undefined,
+ "query": Object {
+ "bool": Object {
+ "filter": Array [],
+ "must": Array [],
+ "must_not": Array [],
+ "should": Array [],
+ },
+ },
+ "script_fields": Object {},
+ "sort": Array [],
+ },
+ "index": null,
+ },
+ }
+ `);
+ });
+
+ it('uses timerange min and max (numeric) when index pattern has timefieldName', async () => {
+ mockJobParams.post = {
+ timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 900000000 },
+ };
+ mockSavedObjectsClient = {
+ get: () => ({
+ attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' },
+ }),
+ };
+ const result = await getGenerateCsvParams(
+ mockJobParams,
+ mockSearchPanel,
+ mockSavedObjectsClient,
+ mockUiSettingsClient
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "conflictedTypesFields": Array [],
+ "fields": Array [
+ "@test_time",
+ ],
+ "indexPatternSavedObject": Object {
+ "attributes": Object {
+ "fields": null,
+ "timeFieldName": "@test_time",
+ "title": "test search",
+ },
+ "fields": Array [],
+ "timeFieldName": "@test_time",
+ "title": "test search",
+ },
+ "jobParams": Object {
+ "browserTimezone": "Africa/Timbuktu",
+ },
+ "metaFields": Array [],
+ "searchRequest": Object {
+ "body": Object {
+ "_source": Object {
+ "includes": Array [
+ "@test_time",
+ ],
+ },
+ "docvalue_fields": undefined,
+ "query": Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "range": Object {
+ "@test_time": Object {
+ "format": "strict_date_time",
+ "gte": "1970-01-01T00:00:00Z",
+ "lte": "1970-01-11T10:00:00Z",
+ },
+ },
+ },
+ ],
+ "must": Array [],
+ "must_not": Array [],
+ "should": Array [],
+ },
+ },
+ "script_fields": Object {},
+ "sort": Array [],
+ },
+ "index": "test search",
+ },
+ }
+ `);
+ });
+
+ it('uses timerange min and max (string) when index pattern has timefieldName', async () => {
+ mockJobParams.post = {
+ timerange: {
+ timezone: 'Africa/Timbuktu',
+ min: '1980-01-01T00:00:00Z',
+ max: '1990-01-01T00:00:00Z',
+ },
+ };
+ mockSavedObjectsClient = {
+ get: () => ({
+ attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' },
+ }),
+ };
+ const result = await getGenerateCsvParams(
+ mockJobParams,
+ mockSearchPanel,
+ mockSavedObjectsClient,
+ mockUiSettingsClient
+ );
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "conflictedTypesFields": Array [],
+ "fields": Array [
+ "@test_time",
+ ],
+ "indexPatternSavedObject": Object {
+ "attributes": Object {
+ "fields": null,
+ "timeFieldName": "@test_time",
+ "title": "test search",
+ },
+ "fields": Array [],
+ "timeFieldName": "@test_time",
+ "title": "test search",
+ },
+ "jobParams": Object {
+ "browserTimezone": "Africa/Timbuktu",
+ },
+ "metaFields": Array [],
+ "searchRequest": Object {
+ "body": Object {
+ "_source": Object {
+ "includes": Array [
+ "@test_time",
+ ],
+ },
+ "docvalue_fields": undefined,
+ "query": Object {
+ "bool": Object {
+ "filter": Array [
+ Object {
+ "range": Object {
+ "@test_time": Object {
+ "format": "strict_date_time",
+ "gte": "1980-01-01T00:00:00Z",
+ "lte": "1990-01-01T00:00:00Z",
+ },
+ },
+ },
+ ],
+ "must": Array [],
+ "must_not": Array [],
+ "should": Array [],
+ },
+ },
+ "script_fields": Object {},
+ "sort": Array [],
+ },
+ "index": "test search",
+ },
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts
new file mode 100644
index 0000000000000..5f1954b80e1bc
--- /dev/null
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts
@@ -0,0 +1,146 @@
+/*
+ * 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 { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server';
+import { EsQueryConfig } from 'src/plugins/data/server';
+import {
+ esQuery,
+ Filter,
+ IIndexPattern,
+ Query,
+} from '../../../../../../../../src/plugins/data/server';
+import {
+ DocValueFields,
+ IndexPatternField,
+ JobParamsPanelCsv,
+ QueryFilter,
+ SavedSearchObjectAttributes,
+ SearchPanel,
+ SearchSource,
+} from '../../types';
+import { getDataSource } from './get_data_source';
+import { getFilters } from './get_filters';
+import { GenerateCsvParams } from '../../../csv/server/generate_csv';
+
+export const getEsQueryConfig = async (config: IUiSettingsClient) => {
+ const configs = await Promise.all([
+ config.get('query:allowLeadingWildcards'),
+ config.get('query:queryString:options'),
+ config.get('courier:ignoreFilterIfFieldNotInIndex'),
+ ]);
+ const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs;
+ return {
+ allowLeadingWildcards,
+ queryStringOptions,
+ ignoreFilterIfFieldNotInIndex,
+ } as EsQueryConfig;
+};
+
+/*
+ * Create a CSV Job object for CSV From SavedObject to use as a job parameter
+ * for generateCsv
+ */
+export const getGenerateCsvParams = async (
+ jobParams: JobParamsPanelCsv,
+ panel: SearchPanel,
+ savedObjectsClient: SavedObjectsClientContract,
+ uiConfig: IUiSettingsClient
+): Promise => {
+ let timerange;
+ if (jobParams.post?.timerange) {
+ timerange = jobParams.post?.timerange;
+ } else {
+ timerange = panel.timerange;
+ }
+ const { indexPatternSavedObjectId } = panel;
+ const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes;
+ const { indexPatternSavedObject } = await getDataSource(
+ savedObjectsClient,
+ indexPatternSavedObjectId
+ );
+ const esQueryConfig = await getEsQueryConfig(uiConfig);
+
+ const {
+ kibanaSavedObjectMeta: {
+ searchSource: {
+ filter: [searchSourceFilter],
+ query: searchSourceQuery,
+ },
+ },
+ } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } };
+
+ const {
+ timeFieldName: indexPatternTimeField,
+ title: esIndex,
+ fields: indexPatternFields,
+ } = indexPatternSavedObject;
+
+ let payloadQuery: QueryFilter | undefined;
+ let payloadSort: any[] = [];
+ let docValueFields: DocValueFields[] | undefined;
+ if (jobParams.post && jobParams.post.state) {
+ ({
+ post: {
+ state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields },
+ },
+ } = jobParams);
+ }
+ const { includes, combinedFilter } = getFilters(
+ indexPatternSavedObjectId,
+ indexPatternTimeField,
+ timerange,
+ savedSearchObjectAttr,
+ searchSourceFilter,
+ payloadQuery
+ );
+
+ const savedSortConfigs = savedSearchObjectAttr.sort;
+ const sortConfig = [...payloadSort];
+ savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => {
+ sortConfig.push({ [savedSortField]: { order: savedSortOrder } });
+ });
+
+ const scriptFieldsConfig =
+ indexPatternFields &&
+ indexPatternFields
+ .filter((f: IndexPatternField) => f.scripted)
+ .reduce((accum: any, curr: IndexPatternField) => {
+ return {
+ ...accum,
+ [curr.name]: {
+ script: {
+ source: curr.script,
+ lang: curr.lang,
+ },
+ },
+ };
+ }, {});
+
+ const searchRequest = {
+ index: esIndex,
+ body: {
+ _source: { includes },
+ docvalue_fields: docValueFields,
+ query: esQuery.buildEsQuery(
+ indexPatternSavedObject as IIndexPattern,
+ (searchSourceQuery as unknown) as Query,
+ (combinedFilter as unknown) as Filter,
+ esQueryConfig
+ ),
+ script_fields: scriptFieldsConfig,
+ sort: sortConfig,
+ },
+ };
+
+ return {
+ jobParams: { browserTimezone: timerange.timezone },
+ indexPatternSavedObject,
+ searchRequest,
+ fields: includes,
+ metaFields: [],
+ conflictedTypesFields: [],
+ };
+};
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts
index b7e560853e89e..bf915696c8974 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts
@@ -4,12 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- IndexPatternSavedObject,
- SavedObjectReference,
- SavedSearchObjectAttributesJSON,
- SearchSource,
-} from '../../types';
+import { IndexPatternSavedObject } from '../../../csv/types';
+import { SavedObjectReference, SavedSearchObjectAttributesJSON, SearchSource } from '../../types';
export async function getDataSource(
savedObjectsClient: any,
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts
new file mode 100644
index 0000000000000..09c58806de120
--- /dev/null
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { KibanaRequest } from 'kibana/server';
+import { cryptoFactory, LevelLogger } from '../../../../lib';
+import { ScheduledTaskParams } from '../../../../types';
+import { JobParamsPanelCsv } from '../../types';
+
+export const getFakeRequest = async (
+ job: ScheduledTaskParams,
+ encryptionKey: string,
+ jobLogger: LevelLogger
+) => {
+ // TODO remove this block: csv from savedobject download is always "sync"
+ const crypto = cryptoFactory(encryptionKey);
+ let decryptedHeaders: KibanaRequest['headers'];
+ const serializedEncryptedHeaders = job.headers;
+ try {
+ if (typeof serializedEncryptedHeaders !== 'string') {
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage',
+ {
+ defaultMessage: 'Job headers are missing',
+ }
+ )
+ );
+ }
+ decryptedHeaders = (await crypto.decrypt(
+ serializedEncryptedHeaders
+ )) as KibanaRequest['headers'];
+ } catch (err) {
+ jobLogger.error(err);
+ throw new Error(
+ i18n.translate(
+ 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage',
+ {
+ defaultMessage:
+ 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}',
+ values: { encryptionKey: 'xpack.reporting.encryptionKey', err },
+ }
+ )
+ );
+ }
+
+ return { headers: decryptedHeaders } as KibanaRequest;
+};
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts
index 26631548cc797..1258b03d3051b 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts
@@ -22,7 +22,7 @@ export function getFilters(
let timezone: string | null;
if (indexPatternTimeField) {
- if (!timerange || !timerange.min || !timerange.max) {
+ if (!timerange || timerange.min == null || timerange.max == null) {
throw badRequest(
`Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]`
);
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts
index c182fe49a31f6..0d19a24114f06 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts
+++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts
@@ -95,20 +95,6 @@ export interface SavedObject {
references: SavedObjectReference[];
}
-/* This object is passed to different helpers in different parts of the code
- - packages/kbn-es-query/src/es_query/build_es_query
- The structure has redundant parts and json-parsed / json-unparsed versions of the same data
- */
-export interface IndexPatternSavedObject {
- title: string;
- timeFieldName: string;
- fields: any[];
- attributes: {
- fieldFormatMap: string;
- fields: string;
- };
-}
-
export interface VisPanel {
indexPatternSavedObjectId?: string;
savedSearchObjectId?: string;
@@ -122,6 +108,11 @@ export interface SearchPanel {
timerange: TimeRangeParams;
}
+export interface DocValueFields {
+ field: string;
+ format: string;
+}
+
export interface SearchSourceQuery {
isSearchSourceQuery: boolean;
}
diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts
index 97441bba70984..773295deea954 100644
--- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts
+++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts
@@ -9,10 +9,10 @@ import { ReportingCore } from '../';
import { API_BASE_GENERATE_V1 } from '../../common/constants';
import { scheduleTaskFnFactory } from '../export_types/csv_from_savedobject/server/create_job';
import { runTaskFnFactory } from '../export_types/csv_from_savedobject/server/execute_job';
-import { getJobParamsFromRequest } from '../export_types/csv_from_savedobject/server/lib/get_job_params_from_request';
import { LevelLogger as Logger } from '../lib';
import { TaskRunResult } from '../types';
import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing';
+import { getJobParamsFromRequest } from './lib/get_job_params_from_request';
import { HandlerErrorFunction } from './types';
/*
diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts
similarity index 87%
rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts
rename to x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts
index 5aed02c10b961..e5c1f38241349 100644
--- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts
+++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts
@@ -5,7 +5,10 @@
*/
import { KibanaRequest } from 'src/core/server';
-import { JobParamsPanelCsv, JobParamsPostPayloadPanelCsv } from '../../types';
+import {
+ JobParamsPanelCsv,
+ JobParamsPostPayloadPanelCsv,
+} from '../../export_types/csv_from_savedobject/types';
export function getJobParamsFromRequest(
request: KibanaRequest,
diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts
index 96eef81672610..667c1546c6147 100644
--- a/x-pack/plugins/reporting/server/types.ts
+++ b/x-pack/plugins/reporting/server/types.ts
@@ -50,19 +50,19 @@ export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPa
export interface TimeRangeParams {
timezone: string;
- min: Date | string | number | null;
- max: Date | string | number | null;
+ min?: Date | string | number | null;
+ max?: Date | string | number | null;
}
export interface JobParamPostPayload {
- timerange: TimeRangeParams;
+ timerange?: TimeRangeParams;
}
export interface ScheduledTaskParams {
headers?: string; // serialized encrypted headers
jobParams: JobParamsType;
title: string;
- type: string | null;
+ type: string;
}
export interface JobSource {
@@ -80,6 +80,7 @@ export interface TaskRunResult {
content_type: string;
content: string | null;
size: number;
+ csv_contains_formulas?: boolean;
max_size_reached?: boolean;
warnings?: string[];
}
diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts
index bac98d5639755..37b97a8472310 100644
--- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts
+++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts
@@ -6,7 +6,7 @@
jest.mock('./account_management_page');
-import { AppMount, AppNavLinkStatus, ScopedHistory } from 'src/core/public';
+import { AppMount, AppNavLinkStatus } from 'src/core/public';
import { UserAPIClient } from '../management';
import { accountManagementApp } from './account_management_app';
@@ -54,7 +54,7 @@ describe('accountManagementApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1);
diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts
index add2db6a3c170..0e262e9089842 100644
--- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts
+++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts
@@ -6,7 +6,7 @@
jest.mock('./access_agreement_page');
-import { AppMount, ScopedHistory } from 'src/core/public';
+import { AppMount } from 'src/core/public';
import { accessAgreementApp } from './access_agreement_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -48,7 +48,7 @@ describe('accessAgreementApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage;
diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts
index f0c18a3f1408e..15d55136b405d 100644
--- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts
+++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts
@@ -6,7 +6,7 @@
jest.mock('./logged_out_page');
-import { AppMount, ScopedHistory } from 'src/core/public';
+import { AppMount } from 'src/core/public';
import { loggedOutApp } from './logged_out_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -46,7 +46,7 @@ describe('loggedOutApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage;
diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts
index b7119d179b0b6..a6e5a321ef6ec 100644
--- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts
+++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts
@@ -6,7 +6,7 @@
jest.mock('./login_page');
-import { AppMount, ScopedHistory } from 'src/core/public';
+import { AppMount } from 'src/core/public';
import { loginApp } from './login_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -51,7 +51,7 @@ describe('loginApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
const mockRenderApp = jest.requireMock('./login_page').renderLoginPage;
diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts
index 279500d14f211..46b1083a2ed14 100644
--- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts
+++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { AppMount, ScopedHistory } from 'src/core/public';
+import { AppMount } from 'src/core/public';
import { logoutApp } from './logout_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -52,7 +52,7 @@ describe('logoutApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1);
diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts
index 96e72ead22990..0eed1382c270b 100644
--- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts
+++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts
@@ -6,7 +6,7 @@
jest.mock('./overwritten_session_page');
-import { AppMount, ScopedHistory } from 'src/core/public';
+import { AppMount } from 'src/core/public';
import { overwrittenSessionApp } from './overwritten_session_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -53,7 +53,7 @@ describe('overwrittenSessionApp', () => {
element: containerMock,
appBasePath: '',
onAppLeave: jest.fn(),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
const mockRenderApp = jest.requireMock('./overwritten_session_page')
diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx
index 5f07b14ee71ef..30c5f8a361b42 100644
--- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx
+++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx
@@ -7,7 +7,6 @@
jest.mock('./api_keys_grid', () => ({
APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`,
}));
-import { ScopedHistory } from 'src/core/public';
import { apiKeysManagementApp } from './api_keys_management_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -37,7 +36,7 @@ describe('apiKeysManagementApp', () => {
basePath: '/some-base-path',
element: container,
setBreadcrumbs,
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
});
expect(setBreadcrumbs).toHaveBeenCalledTimes(1);
diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx
index b4e755507f8c5..04dc9c6dfa950 100644
--- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx
+++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx
@@ -12,7 +12,6 @@ import { findTestSubject } from 'test_utils/find_test_subject';
// This is not required for the tests to pass, but it rather suppresses lengthy
// warnings in the console which adds unnecessary noise to the test output.
import 'test_utils/stub_web_worker';
-import { ScopedHistory } from 'kibana/public';
import { EditRoleMappingPage } from '.';
import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components';
@@ -28,7 +27,7 @@ import { rolesAPIClientMock } from '../../roles/roles_api_client.mock';
import { RoleComboBox } from '../../role_combo_box';
describe('EditRoleMappingPage', () => {
- const history = (scopedHistoryMock.create() as unknown) as ScopedHistory;
+ const history = scopedHistoryMock.create();
let rolesAPI: PublicMethodsOf;
beforeEach(() => {
diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx
index fb81ddb641e1f..727d7bf56e9e2 100644
--- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx
+++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx
@@ -24,7 +24,7 @@ describe('RoleMappingsGridPage', () => {
let coreStart: CoreStart;
beforeEach(() => {
- history = (scopedHistoryMock.create() as unknown) as ScopedHistory;
+ history = scopedHistoryMock.create();
coreStart = coreMock.createStart();
});
diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx
index c95d78f90f51a..e65310ba399ea 100644
--- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx
+++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx
@@ -12,7 +12,6 @@ jest.mock('./edit_role_mapping', () => ({
EditRoleMappingPage: (props: any) => `Role Mapping Edit Page: ${JSON.stringify(props)}`,
}));
-import { ScopedHistory } from 'src/core/public';
import { roleMappingsManagementApp } from './role_mappings_management_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -26,7 +25,7 @@ async function mountApp(basePath: string, pathname: string) {
basePath,
element: container,
setBreadcrumbs,
- history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create({ pathname }),
});
return { unmount, container, setBreadcrumbs };
diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx
index 43387d913e6fc..f6fe2f394fd36 100644
--- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx
+++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx
@@ -8,7 +8,7 @@ import { ReactWrapper } from 'enzyme';
import React from 'react';
import { act } from '@testing-library/react';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
-import { Capabilities, ScopedHistory } from 'src/core/public';
+import { Capabilities } from 'src/core/public';
import { Feature } from '../../../../../features/public';
import { Role } from '../../../../common/model';
import { DocumentationLinksService } from '../documentation_links';
@@ -187,7 +187,7 @@ function getProps({
docLinks: new DocumentationLinksService(docLinks),
fatalErrors,
uiCapabilities: buildUICapabilities(canManageSpaces),
- history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create(),
};
}
diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx
index d83d5ef3f6468..005eebbfbf3bb 100644
--- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx
+++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx
@@ -16,7 +16,6 @@ import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/publi
import { rolesAPIClientMock } from '../index.mock';
import { ReservedBadge, DisabledBadge } from '../../badges';
import { findTestSubject } from 'test_utils/find_test_subject';
-import { ScopedHistory } from 'kibana/public';
const mock403 = () => ({ body: { statusCode: 403 } });
@@ -42,12 +41,12 @@ const waitForRender = async (
describe(' ', () => {
let apiClientMock: jest.Mocked>;
- let history: ScopedHistory;
+ let history: ReturnType;
beforeEach(() => {
- history = (scopedHistoryMock.create({
- createHref: jest.fn((location) => location.pathname!),
- }) as unknown) as ScopedHistory;
+ history = scopedHistoryMock.create();
+ history.createHref.mockImplementation((location) => location.pathname!);
+
apiClientMock = rolesAPIClientMock.create();
apiClientMock.getRoles.mockResolvedValue([
{
diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx
index e7f38c86b045e..c45528399db99 100644
--- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx
+++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx
@@ -14,8 +14,6 @@ jest.mock('./edit_role', () => ({
EditRolePage: (props: any) => `Role Edit Page: ${JSON.stringify(props)}`,
}));
-import { ScopedHistory } from 'src/core/public';
-
import { rolesManagementApp } from './roles_management_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -40,7 +38,7 @@ async function mountApp(basePath: string, pathname: string) {
basePath,
element: container,
setBreadcrumbs,
- history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create({ pathname }),
});
return { unmount, container, setBreadcrumbs };
diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx
index 7ee33357b9af4..40ffc508f086b 100644
--- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx
+++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx
@@ -5,7 +5,6 @@
*/
import { act } from '@testing-library/react';
-import { ScopedHistory } from 'kibana/public';
import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
import { EditUserPage } from './edit_user_page';
import React from 'react';
@@ -104,7 +103,7 @@ function expectMissingSaveButton(wrapper: ReactWrapper) {
}
describe('EditUserPage', () => {
- const history = (scopedHistoryMock.create() as unknown) as ScopedHistory;
+ const history = scopedHistoryMock.create();
it('allows reserved users to be viewed', async () => {
const user = createUser('reserved_user');
diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx
index edce7409e28d5..df8fe8cee7699 100644
--- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx
+++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx
@@ -22,7 +22,7 @@ describe('UsersGridPage', () => {
let coreStart: CoreStart;
beforeEach(() => {
- history = (scopedHistoryMock.create() as unknown) as ScopedHistory;
+ history = scopedHistoryMock.create();
history.createHref = (location: LocationDescriptorObject) => {
return `${location.pathname}${location.search ? '?' + location.search : ''}`;
};
diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx
index 98906f560e6cb..06bd2eff6aa1e 100644
--- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx
+++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx
@@ -12,7 +12,6 @@ jest.mock('./edit_user', () => ({
EditUserPage: (props: any) => `User Edit Page: ${JSON.stringify(props)}`,
}));
-import { ScopedHistory } from 'src/core/public';
import { usersManagementApp } from './users_management_app';
import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks';
@@ -31,7 +30,7 @@ async function mountApp(basePath: string, pathname: string) {
basePath,
element: container,
setBreadcrumbs,
- history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory,
+ history: scopedHistoryMock.create({ pathname }),
});
return { unmount, container, setBreadcrumbs };
diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts
index 4ab00b511b48b..5e38045b88c74 100644
--- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts
+++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts
@@ -43,17 +43,6 @@ describe('#checkSavedObjectsPrivileges', () => {
describe('when checking multiple namespaces', () => {
const namespaces = [namespace1, namespace2];
- test(`throws an error when Spaces is disabled`, async () => {
- mockSpacesService = undefined;
- const checkSavedObjectsPrivileges = createFactory();
-
- await expect(
- checkSavedObjectsPrivileges(actions, namespaces)
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"`
- );
- });
-
test(`throws an error when using an empty namespaces array`, async () => {
const checkSavedObjectsPrivileges = createFactory();
diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts
index d9b070c72f946..0c2260542bf72 100644
--- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts
+++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts
@@ -29,21 +29,21 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = (
namespaceOrNamespaces?: string | string[]
) {
const spacesService = getSpacesService();
- if (Array.isArray(namespaceOrNamespaces)) {
- if (spacesService === undefined) {
- throw new Error(
- `Can't check saved object privileges for multiple namespaces if Spaces is disabled`
- );
- } else if (!namespaceOrNamespaces.length) {
+ if (!spacesService) {
+ // Spaces disabled, authorizing globally
+ return await checkPrivilegesWithRequest(request).globally(actions);
+ } else if (Array.isArray(namespaceOrNamespaces)) {
+ // Spaces enabled, authorizing against multiple spaces
+ if (!namespaceOrNamespaces.length) {
throw new Error(`Can't check saved object privileges for 0 namespaces`);
}
const spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x));
return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions);
- } else if (spacesService) {
+ } else {
+ // Spaces enabled, authorizing against a single space
const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces);
return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions);
}
- return await checkPrivilegesWithRequest(request).globally(actions);
};
};
};
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
index c646cd95228f0..1cf879adc5415 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts
@@ -27,6 +27,7 @@ const createSecureSavedObjectsClientWrapperOptions = () => {
const errors = ({
decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError),
decorateGeneralError: jest.fn().mockReturnValue(generalError),
+ createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)),
isNotFoundError: jest.fn().mockReturnValue(false),
} as unknown) as jest.Mocked;
const getSpacesService = jest.fn().mockReturnValue(true);
@@ -73,7 +74,9 @@ const expectForbiddenError = async (fn: Function, args: Record) =>
SavedObjectActions['get']
>).mock.calls;
const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0];
- const spaceId = args.options?.namespace || 'default';
+ const spaceId = args.options?.namespaces
+ ? args.options?.namespaces[0]
+ : args.options?.namespace || 'default';
const ACTION = getCalls[0][1];
const types = getCalls.map((x) => x[0]);
@@ -100,7 +103,7 @@ const expectSuccess = async (fn: Function, args: Record) => {
>).mock.calls;
const ACTION = getCalls[0][1];
const types = getCalls.map((x) => x[0]);
- const spaceIds = [args.options?.namespace || 'default'];
+ const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default'];
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
@@ -128,7 +131,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record) =>
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1);
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith(
actions,
- args.options?.namespace
+ args.options?.namespace ?? args.options?.namespaces
);
};
@@ -344,7 +347,7 @@ describe('#addToNamespaces', () => {
);
});
- test(`checks privileges for user, actions, and namespace`, async () => {
+ test(`checks privileges for user, actions, and namespaces`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce(
getMockCheckPrivilegesSuccess // create
);
@@ -539,12 +542,12 @@ describe('#find', () => {
});
test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => {
- const options = Object.freeze({ type: type1, namespace: 'some-ns' });
+ const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
});
test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
});
@@ -552,18 +555,34 @@ describe('#find', () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
- const options = Object.freeze({ type: type1, namespace: 'some-ns' });
+ const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
const result = await expectSuccess(client.find, { options });
expect(result).toEqual(apiCallReturnValue);
});
- test(`checks privileges for user, actions, and namespace`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ test(`throws BadRequestError when searching across namespaces when spaces is disabled`, async () => {
+ clientOpts = createSecureSavedObjectsClientWrapperOptions();
+ clientOpts.getSpacesService.mockReturnValue(undefined);
+ client = new SecureSavedObjectsClientWrapper(clientOpts);
+
+ // succeed privilege checks by default
+ clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
+ getMockCheckPrivilegesSuccess
+ );
+
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
+ await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"_find across namespaces is not permitted when the Spaces plugin is disabled."`
+ );
+ });
+
+ test(`checks privileges for user, actions, and namespaces`, async () => {
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectPrivilegeCheck(client.find, { options });
});
test(`filters namespaces that the user doesn't have access to`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectObjectsNamespaceFiltering(client.find, { options });
});
});
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
index 969344afae5e3..621299a0f025e 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
@@ -99,7 +99,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
public async find(options: SavedObjectsFindOptions) {
- await this.ensureAuthorized(options.type, 'find', options.namespace, { options });
+ if (
+ this.getSpacesService() == null &&
+ Array.isArray(options.namespaces) &&
+ options.namespaces.length > 0
+ ) {
+ throw this.errors.createBadRequestError(
+ `_find across namespaces is not permitted when the Spaces plugin is disabled.`
+ );
+ }
+ await this.ensureAuthorized(options.type, 'find', options.namespaces, { options });
const response = await this.baseClient.find(options);
return await this.redactSavedObjectsNamespaces(response);
@@ -293,7 +302,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
private async redactSavedObjectNamespaces(
savedObject: T
): Promise {
- if (this.getSpacesService() === undefined || savedObject.namespaces == null) {
+ if (
+ this.getSpacesService() === undefined ||
+ savedObject.namespaces == null ||
+ savedObject.namespaces.length === 0
+ ) {
return savedObject;
}
diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts
index 4e9514feec74f..516ee19dd3b03 100644
--- a/x-pack/plugins/security_solution/common/constants.ts
+++ b/x-pack/plugins/security_solution/common/constants.ts
@@ -42,7 +42,7 @@ export enum SecurityPageName {
network = 'network',
timelines = 'timelines',
case = 'case',
- management = 'management',
+ administration = 'administration',
}
export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`;
diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts
index ed0344207d18f..26a219507c3ae 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts
@@ -22,10 +22,82 @@ import {
EntryMatch,
EntryMatchAny,
EntriesArray,
+ Operator,
} from '../../../lists/common/schemas';
import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock';
describe('build_exceptions_query', () => {
+ let exclude: boolean;
+ const makeMatchEntry = ({
+ field,
+ value = 'value-1',
+ operator = 'included',
+ }: {
+ field: string;
+ value?: string;
+ operator?: Operator;
+ }): EntryMatch => {
+ return {
+ field,
+ operator,
+ type: 'match',
+ value,
+ };
+ };
+ const makeMatchAnyEntry = ({
+ field,
+ operator = 'included',
+ value = ['value-1', 'value-2'],
+ }: {
+ field: string;
+ operator?: Operator;
+ value?: string[];
+ }): EntryMatchAny => {
+ return {
+ field,
+ operator,
+ value,
+ type: 'match_any',
+ };
+ };
+ const makeExistsEntry = ({
+ field,
+ operator = 'included',
+ }: {
+ field: string;
+ operator?: Operator;
+ }): EntryExists => {
+ return {
+ field,
+ operator,
+ type: 'exists',
+ };
+ };
+ const matchEntryWithIncluded: EntryMatch = makeMatchEntry({
+ field: 'host.name',
+ value: 'suricata',
+ });
+ const matchEntryWithExcluded: EntryMatch = makeMatchEntry({
+ field: 'host.name',
+ value: 'suricata',
+ operator: 'excluded',
+ });
+ const matchAnyEntryWithIncludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({
+ field: 'host.name',
+ value: ['suricata', 'auditd'],
+ });
+ const existsEntryWithIncluded: EntryExists = makeExistsEntry({
+ field: 'host.name',
+ });
+ const existsEntryWithExcluded: EntryExists = makeExistsEntry({
+ field: 'host.name',
+ operator: 'excluded',
+ });
+
+ beforeEach(() => {
+ exclude = true;
+ });
+
describe('getLanguageBooleanOperator', () => {
test('it returns value as uppercase if language is "lucene"', () => {
const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' });
@@ -41,239 +113,376 @@ describe('build_exceptions_query', () => {
});
describe('operatorBuilder', () => {
- describe('kuery', () => {
- test('it returns "not " when operator is "included"', () => {
- const operator = operatorBuilder({ operator: 'included', language: 'kuery' });
-
- expect(operator).toEqual('not ');
+ describe("when 'exclude' is true", () => {
+ describe('and langauge is kuery', () => {
+ test('it returns "not " when operator is "included"', () => {
+ const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude });
+ expect(operator).toEqual('not ');
+ });
+ test('it returns empty string when operator is "excluded"', () => {
+ const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude });
+ expect(operator).toEqual('');
+ });
});
- test('it returns empty string when operator is "excluded"', () => {
- const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' });
-
- expect(operator).toEqual('');
+ describe('and language is lucene', () => {
+ test('it returns "NOT " when operator is "included"', () => {
+ const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude });
+ expect(operator).toEqual('NOT ');
+ });
+ test('it returns empty string when operator is "excluded"', () => {
+ const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude });
+ expect(operator).toEqual('');
+ });
});
});
-
- describe('lucene', () => {
- test('it returns "NOT " when operator is "included"', () => {
- const operator = operatorBuilder({ operator: 'included', language: 'lucene' });
-
- expect(operator).toEqual('NOT ');
+ describe("when 'exclude' is false", () => {
+ beforeEach(() => {
+ exclude = false;
});
- test('it returns empty string when operator is "excluded"', () => {
- const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' });
+ describe('and language is kuery', () => {
+ test('it returns empty string when operator is "included"', () => {
+ const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude });
+ expect(operator).toEqual('');
+ });
+ test('it returns "not " when operator is "excluded"', () => {
+ const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude });
+ expect(operator).toEqual('not ');
+ });
+ });
- expect(operator).toEqual('');
+ describe('and language is lucene', () => {
+ test('it returns empty string when operator is "included"', () => {
+ const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude });
+ expect(operator).toEqual('');
+ });
+ test('it returns "NOT " when operator is "excluded"', () => {
+ const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude });
+ expect(operator).toEqual('NOT ');
+ });
});
});
});
describe('buildExists', () => {
- describe('kuery', () => {
- test('it returns formatted wildcard string when operator is "excluded"', () => {
- const query = buildExists({
- item: { type: 'exists', operator: 'excluded', field: 'host.name' },
- language: 'kuery',
+ describe("when 'exclude' is true", () => {
+ describe('kuery', () => {
+ test('it returns formatted wildcard string when operator is "excluded"', () => {
+ const query = buildExists({
+ item: existsEntryWithExcluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('host.name:*');
+ });
+ test('it returns formatted wildcard string when operator is "included"', () => {
+ const query = buildExists({
+ item: existsEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('not host.name:*');
});
-
- expect(query).toEqual('host.name:*');
});
- test('it returns formatted wildcard string when operator is "included"', () => {
- const query = buildExists({
- item: { type: 'exists', operator: 'included', field: 'host.name' },
- language: 'kuery',
+ describe('lucene', () => {
+ test('it returns formatted wildcard string when operator is "excluded"', () => {
+ const query = buildExists({
+ item: existsEntryWithExcluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('_exists_host.name');
+ });
+ test('it returns formatted wildcard string when operator is "included"', () => {
+ const query = buildExists({
+ item: existsEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('NOT _exists_host.name');
});
-
- expect(query).toEqual('not host.name:*');
});
});
- describe('lucene', () => {
- test('it returns formatted wildcard string when operator is "excluded"', () => {
- const query = buildExists({
- item: { type: 'exists', operator: 'excluded', field: 'host.name' },
- language: 'lucene',
- });
-
- expect(query).toEqual('_exists_host.name');
+ describe("when 'exclude' is false", () => {
+ beforeEach(() => {
+ exclude = false;
});
- test('it returns formatted wildcard string when operator is "included"', () => {
- const query = buildExists({
- item: { type: 'exists', operator: 'included', field: 'host.name' },
- language: 'lucene',
+ describe('kuery', () => {
+ test('it returns formatted wildcard string when operator is "excluded"', () => {
+ const query = buildExists({
+ item: existsEntryWithExcluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('not host.name:*');
+ });
+ test('it returns formatted wildcard string when operator is "included"', () => {
+ const query = buildExists({
+ item: existsEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('host.name:*');
});
+ });
- expect(query).toEqual('NOT _exists_host.name');
+ describe('lucene', () => {
+ test('it returns formatted wildcard string when operator is "excluded"', () => {
+ const query = buildExists({
+ item: existsEntryWithExcluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('NOT _exists_host.name');
+ });
+ test('it returns formatted wildcard string when operator is "included"', () => {
+ const query = buildExists({
+ item: existsEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('_exists_host.name');
+ });
});
});
});
describe('buildMatch', () => {
- describe('kuery', () => {
- test('it returns formatted string when operator is "included"', () => {
- const query = buildMatch({
- item: {
- type: 'match',
- operator: 'included',
- field: 'host.name',
- value: 'suricata',
- },
- language: 'kuery',
+ describe("when 'exclude' is true", () => {
+ describe('kuery', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const query = buildMatch({
+ item: matchEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('not host.name:suricata');
+ });
+ test('it returns formatted string when operator is "excluded"', () => {
+ const query = buildMatch({
+ item: matchEntryWithExcluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('host.name:suricata');
});
-
- expect(query).toEqual('not host.name:suricata');
});
- test('it returns formatted string when operator is "excluded"', () => {
- const query = buildMatch({
- item: {
- type: 'match',
- operator: 'excluded',
- field: 'host.name',
- value: 'suricata',
- },
- language: 'kuery',
+ describe('lucene', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const query = buildMatch({
+ item: matchEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('NOT host.name:suricata');
+ });
+ test('it returns formatted string when operator is "excluded"', () => {
+ const query = buildMatch({
+ item: matchEntryWithExcluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('host.name:suricata');
});
-
- expect(query).toEqual('host.name:suricata');
});
});
- describe('lucene', () => {
- test('it returns formatted string when operator is "included"', () => {
- const query = buildMatch({
- item: {
- type: 'match',
- operator: 'included',
- field: 'host.name',
- value: 'suricata',
- },
- language: 'lucene',
- });
-
- expect(query).toEqual('NOT host.name:suricata');
+ describe("when 'exclude' is false", () => {
+ beforeEach(() => {
+ exclude = false;
});
- test('it returns formatted string when operator is "excluded"', () => {
- const query = buildMatch({
- item: {
- type: 'match',
- operator: 'excluded',
- field: 'host.name',
- value: 'suricata',
- },
- language: 'lucene',
+ describe('kuery', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const query = buildMatch({
+ item: matchEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('host.name:suricata');
});
+ test('it returns formatted string when operator is "excluded"', () => {
+ const query = buildMatch({
+ item: matchEntryWithExcluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(query).toEqual('not host.name:suricata');
+ });
+ });
- expect(query).toEqual('host.name:suricata');
+ describe('lucene', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const query = buildMatch({
+ item: matchEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('host.name:suricata');
+ });
+ test('it returns formatted string when operator is "excluded"', () => {
+ const query = buildMatch({
+ item: matchEntryWithExcluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(query).toEqual('NOT host.name:suricata');
+ });
});
});
});
describe('buildMatchAny', () => {
- describe('kuery', () => {
- test('it returns empty string if given an empty array for "values"', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'included',
- field: 'host.name',
- value: [],
- type: 'match_any',
- },
- language: 'kuery',
- });
-
- expect(exceptionSegment).toEqual('');
- });
+ const entryWithIncludedAndNoValues: EntryMatchAny = makeMatchAnyEntry({
+ field: 'host.name',
+ value: [],
+ });
+ const entryWithIncludedAndOneValue: EntryMatchAny = makeMatchAnyEntry({
+ field: 'host.name',
+ value: ['suricata'],
+ });
+ const entryWithExcludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({
+ field: 'host.name',
+ value: ['suricata', 'auditd'],
+ operator: 'excluded',
+ });
- test('it returns formatted string when "values" includes only one item', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'included',
- field: 'host.name',
- value: ['suricata'],
- type: 'match_any',
- },
- language: 'kuery',
+ describe("when 'exclude' is true", () => {
+ describe('kuery', () => {
+ test('it returns empty string if given an empty array for "values"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndNoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('');
});
-
- expect(exceptionSegment).toEqual('not host.name:(suricata)');
- });
-
- test('it returns formatted string when operator is "included"', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'included',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- type: 'match_any',
- },
- language: 'kuery',
+ test('it returns formatted string when "values" includes only one item', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndOneValue,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('not host.name:(suricata)');
+ });
+ test('it returns formatted string when operator is "included"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)');
});
- expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)');
+ test('it returns formatted string when operator is "excluded"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithExcludedAndTwoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata or auditd)');
+ });
});
- test('it returns formatted string when operator is "excluded"', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'excluded',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- type: 'match_any',
- },
- language: 'kuery',
+ describe('lucene', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)');
+ });
+ test('it returns formatted string when operator is "excluded"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithExcludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)');
+ });
+ test('it returns formatted string when "values" includes only one item', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndOneValue,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('NOT host.name:(suricata)');
});
-
- expect(exceptionSegment).toEqual('host.name:(suricata or auditd)');
});
});
- describe('lucene', () => {
- test('it returns formatted string when operator is "included"', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'included',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- type: 'match_any',
- },
- language: 'lucene',
- });
-
- expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)');
+ describe("when 'exclude' is false", () => {
+ beforeEach(() => {
+ exclude = false;
});
- test('it returns formatted string when operator is "excluded"', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'excluded',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- type: 'match_any',
- },
- language: 'lucene',
+ describe('kuery', () => {
+ test('it returns empty string if given an empty array for "values"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndNoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('');
+ });
+ test('it returns formatted string when "values" includes only one item', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndOneValue,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata)');
+ });
+ test('it returns formatted string when operator is "included"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata or auditd)');
});
- expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)');
+ test('it returns formatted string when operator is "excluded"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithExcludedAndTwoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)');
+ });
});
- test('it returns formatted string when "values" includes only one item', () => {
- const exceptionSegment = buildMatchAny({
- item: {
- operator: 'included',
- field: 'host.name',
- value: ['suricata'],
- type: 'match_any',
- },
- language: 'lucene',
+ describe('lucene', () => {
+ test('it returns formatted string when operator is "included"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)');
+ });
+ test('it returns formatted string when operator is "excluded"', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithExcludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)');
+ });
+ test('it returns formatted string when "values" includes only one item', () => {
+ const exceptionSegment = buildMatchAny({
+ item: entryWithIncludedAndOneValue,
+ language: 'lucene',
+ exclude,
+ });
+ expect(exceptionSegment).toEqual('host.name:(suricata)');
});
-
- expect(exceptionSegment).toEqual('NOT host.name:(suricata)');
});
});
});
@@ -284,18 +493,11 @@ describe('build_exceptions_query', () => {
const item: EntryNested = {
field: 'parent',
type: 'nested',
- entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
- ],
+ entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })],
};
const result = buildNested({ item, language: 'kuery' });
- expect(result).toEqual('parent:{ nestedField:value-3 }');
+ expect(result).toEqual('parent:{ nestedField:value-1 }');
});
test('it returns formatted query when multiple items in nested entry', () => {
@@ -303,23 +505,13 @@ describe('build_exceptions_query', () => {
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
- {
- field: 'nestedFieldB',
- operator: 'excluded',
- type: 'match',
- value: 'value-4',
- },
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded' }),
+ makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }),
],
};
const result = buildNested({ item, language: 'kuery' });
- expect(result).toEqual('parent:{ nestedField:value-3 and nestedFieldB:value-4 }');
+ expect(result).toEqual('parent:{ nestedField:value-1 and nestedFieldB:value-2 }');
});
});
@@ -329,18 +521,11 @@ describe('build_exceptions_query', () => {
const item: EntryNested = {
field: 'parent',
type: 'nested',
- entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
- ],
+ entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })],
};
const result = buildNested({ item, language: 'lucene' });
- expect(result).toEqual('parent:{ nestedField:value-3 }');
+ expect(result).toEqual('parent:{ nestedField:value-1 }');
});
test('it returns formatted query when multiple items in nested entry', () => {
@@ -348,129 +533,157 @@ describe('build_exceptions_query', () => {
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
- {
- field: 'nestedFieldB',
- operator: 'excluded',
- type: 'match',
- value: 'value-4',
- },
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded' }),
+ makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }),
],
};
const result = buildNested({ item, language: 'lucene' });
- expect(result).toEqual('parent:{ nestedField:value-3 AND nestedFieldB:value-4 }');
+ expect(result).toEqual('parent:{ nestedField:value-1 AND nestedFieldB:value-2 }');
});
});
});
describe('evaluateValues', () => {
- describe('kuery', () => {
- test('it returns formatted wildcard string when "type" is "exists"', () => {
- const list: EntryExists = {
- operator: 'included',
- type: 'exists',
- field: 'host.name',
- };
- const result = evaluateValues({
- item: list,
- language: 'kuery',
+ describe("when 'exclude' is true", () => {
+ describe('kuery', () => {
+ test('it returns formatted wildcard string when "type" is "exists"', () => {
+ const result = evaluateValues({
+ item: existsEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(result).toEqual('not host.name:*');
});
-
- expect(result).toEqual('not host.name:*');
- });
-
- test('it returns formatted string when "type" is "match"', () => {
- const list: EntryMatch = {
- operator: 'included',
- type: 'match',
- field: 'host.name',
- value: 'suricata',
- };
- const result = evaluateValues({
- item: list,
- language: 'kuery',
+ test('it returns formatted string when "type" is "match"', () => {
+ const result = evaluateValues({
+ item: matchEntryWithIncluded,
+ language: 'kuery',
+ exclude,
+ });
+ expect(result).toEqual('not host.name:suricata');
+ });
+ test('it returns formatted string when "type" is "match_any"', () => {
+ const result = evaluateValues({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'kuery',
+ exclude,
+ });
+ expect(result).toEqual('not host.name:(suricata or auditd)');
});
-
- expect(result).toEqual('not host.name:suricata');
});
- test('it returns formatted string when "type" is "match_any"', () => {
- const list: EntryMatchAny = {
- operator: 'included',
- type: 'match_any',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- };
-
- const result = evaluateValues({
- item: list,
- language: 'kuery',
+ describe('lucene', () => {
+ describe('kuery', () => {
+ test('it returns formatted wildcard string when "type" is "exists"', () => {
+ const result = evaluateValues({
+ item: existsEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('NOT _exists_host.name');
+ });
+ test('it returns formatted string when "type" is "match"', () => {
+ const result = evaluateValues({
+ item: matchEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('NOT host.name:suricata');
+ });
+ test('it returns formatted string when "type" is "match_any"', () => {
+ const result = evaluateValues({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('NOT host.name:(suricata OR auditd)');
+ });
});
-
- expect(result).toEqual('not host.name:(suricata or auditd)');
});
});
- describe('lucene', () => {
+ describe("when 'exclude' is false", () => {
+ beforeEach(() => {
+ exclude = false;
+ });
+
describe('kuery', () => {
test('it returns formatted wildcard string when "type" is "exists"', () => {
- const list: EntryExists = {
- operator: 'included',
- type: 'exists',
- field: 'host.name',
- };
const result = evaluateValues({
- item: list,
- language: 'lucene',
+ item: existsEntryWithIncluded,
+ language: 'kuery',
+ exclude,
});
-
- expect(result).toEqual('NOT _exists_host.name');
+ expect(result).toEqual('host.name:*');
});
-
test('it returns formatted string when "type" is "match"', () => {
- const list: EntryMatch = {
- operator: 'included',
- type: 'match',
- field: 'host.name',
- value: 'suricata',
- };
const result = evaluateValues({
- item: list,
- language: 'lucene',
+ item: matchEntryWithIncluded,
+ language: 'kuery',
+ exclude,
});
-
- expect(result).toEqual('NOT host.name:suricata');
+ expect(result).toEqual('host.name:suricata');
});
-
test('it returns formatted string when "type" is "match_any"', () => {
- const list: EntryMatchAny = {
- operator: 'included',
- type: 'match_any',
- field: 'host.name',
- value: ['suricata', 'auditd'],
- };
-
const result = evaluateValues({
- item: list,
- language: 'lucene',
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'kuery',
+ exclude,
});
+ expect(result).toEqual('host.name:(suricata or auditd)');
+ });
+ });
- expect(result).toEqual('NOT host.name:(suricata OR auditd)');
+ describe('lucene', () => {
+ describe('kuery', () => {
+ test('it returns formatted wildcard string when "type" is "exists"', () => {
+ const result = evaluateValues({
+ item: existsEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('_exists_host.name');
+ });
+ test('it returns formatted string when "type" is "match"', () => {
+ const result = evaluateValues({
+ item: matchEntryWithIncluded,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('host.name:suricata');
+ });
+ test('it returns formatted string when "type" is "match_any"', () => {
+ const result = evaluateValues({
+ item: matchAnyEntryWithIncludedAndTwoValues,
+ language: 'lucene',
+ exclude,
+ });
+ expect(result).toEqual('host.name:(suricata OR auditd)');
+ });
});
});
});
});
describe('formatQuery', () => {
+ describe('when query is empty string', () => {
+ test('it returns query if "exceptions" is empty array', () => {
+ const formattedQuery = formatQuery({ exceptions: [], query: '', language: 'kuery' });
+ expect(formattedQuery).toEqual('');
+ });
+ test('it returns expected query string when single exception in array', () => {
+ const formattedQuery = formatQuery({
+ exceptions: ['b:(value-1 or value-2) and not c:*'],
+ query: '',
+ language: 'kuery',
+ });
+ expect(formattedQuery).toEqual('(b:(value-1 or value-2) and not c:*)');
+ });
+ });
+
test('it returns query if "exceptions" is empty array', () => {
const formattedQuery = formatQuery({ exceptions: [], query: 'a:*', language: 'kuery' });
-
expect(formattedQuery).toEqual('a:*');
});
@@ -480,7 +693,6 @@ describe('build_exceptions_query', () => {
query: 'a:*',
language: 'kuery',
});
-
expect(formattedQuery).toEqual('(a:* and b:(value-1 or value-2) and not c:*)');
});
@@ -490,7 +702,6 @@ describe('build_exceptions_query', () => {
query: 'a:*',
language: 'kuery',
});
-
expect(formattedQuery).toEqual(
'(a:* and b:(value-1 or value-2) and not c:*) or (a:* and not d:*)'
);
@@ -502,6 +713,7 @@ describe('build_exceptions_query', () => {
const query = buildExceptionItemEntries({
language: 'kuery',
lists: [],
+ exclude,
});
expect(query).toEqual('');
@@ -511,22 +723,13 @@ describe('build_exceptions_query', () => {
// Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c)
// https://www.dcode.fr/boolean-expressions-calculator
const payload: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value-1', 'value-2'],
- },
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
+ makeMatchAnyEntry({ field: 'b' }),
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists: payload,
+ exclude,
});
const expectedQuery = 'not b:(value-1 or value-2) and c:value-3';
@@ -537,28 +740,19 @@ describe('build_exceptions_query', () => {
// Equal to query && !(b || !c) -> (query AND NOT b AND c)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value-1', 'value-2'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
],
},
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }';
@@ -569,33 +763,20 @@ describe('build_exceptions_query', () => {
// Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value-1', 'value-2'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
],
},
- {
- field: 'd',
- operator: 'included',
- type: 'exists',
- },
+ makeExistsEntry({ field: 'd' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery =
'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*';
@@ -606,72 +787,151 @@ describe('build_exceptions_query', () => {
// Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value-1', 'value-2'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'nestedField',
- operator: 'excluded',
- type: 'match',
- value: 'value-3',
- },
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
],
},
- {
- field: 'e',
- operator: 'excluded',
- type: 'exists',
- },
+ makeExistsEntry({ field: 'e', operator: 'excluded' }),
];
const query = buildExceptionItemEntries({
language: 'lucene',
lists,
+ exclude,
});
const expectedQuery =
'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e';
expect(query).toEqual(expectedQuery);
});
- describe('exists', () => {
- test('it returns expected query when list includes single list item with operator of "included"', () => {
- // Equal to query && !(b) -> (query AND NOT b)
+ describe('when "exclude" is false', () => {
+ beforeEach(() => {
+ exclude = false;
+ });
+
+ test('it returns empty string if empty lists array passed in', () => {
+ const query = buildExceptionItemEntries({
+ language: 'kuery',
+ lists: [],
+ exclude,
+ });
+
+ expect(query).toEqual('');
+ });
+ test('it returns expected query when more than one item in list', () => {
+ // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const payload: EntriesArray = [
+ makeMatchAnyEntry({ field: 'b' }),
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }),
+ ];
+ const query = buildExceptionItemEntries({
+ language: 'kuery',
+ lists: payload,
+ exclude,
+ });
+ const expectedQuery = 'b:(value-1 or value-2) and not c:value-3';
+
+ expect(query).toEqual(expectedQuery);
+ });
+
+ test('it returns expected query when list item includes nested value', () => {
+ // Equal to query && !(b || !c) -> (query AND NOT b AND c)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
+ makeMatchAnyEntry({ field: 'b' }),
{
- field: 'b',
- operator: 'included',
- type: 'exists',
+ field: 'parent',
+ type: 'nested',
+ entries: [
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
+ ],
},
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'not b:*';
+ const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 }';
expect(query).toEqual(expectedQuery);
});
- test('it returns expected query when list includes single list item with operator of "excluded"', () => {
- // Equal to query && !(!b) -> (query AND b)
+ test('it returns expected query when list includes multiple items and nested "and" values', () => {
+ // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
+ makeMatchAnyEntry({ field: 'b' }),
{
- field: 'b',
- operator: 'excluded',
- type: 'exists',
+ field: 'parent',
+ type: 'nested',
+ entries: [
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
+ ],
},
+ makeExistsEntry({ field: 'd' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
+ });
+ const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 } and d:*';
+ expect(query).toEqual(expectedQuery);
+ });
+
+ test('it returns expected query when language is "lucene"', () => {
+ // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const lists: EntriesArray = [
+ makeMatchAnyEntry({ field: 'b' }),
+ {
+ field: 'parent',
+ type: 'nested',
+ entries: [
+ makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }),
+ ],
+ },
+ makeExistsEntry({ field: 'e', operator: 'excluded' }),
+ ];
+ const query = buildExceptionItemEntries({
+ language: 'lucene',
+ lists,
+ exclude,
+ });
+ const expectedQuery =
+ 'b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND NOT _exists_e';
+ expect(query).toEqual(expectedQuery);
+ });
+ });
+
+ describe('exists', () => {
+ test('it returns expected query when list includes single list item with operator of "included"', () => {
+ // Equal to query && !(b) -> (query AND NOT b)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const lists: EntriesArray = [makeExistsEntry({ field: 'b' })];
+ const query = buildExceptionItemEntries({
+ language: 'kuery',
+ lists,
+ exclude,
+ });
+ const expectedQuery = 'not b:*';
+
+ expect(query).toEqual(expectedQuery);
+ });
+
+ test('it returns expected query when list includes single list item with operator of "excluded"', () => {
+ // Equal to query && !(!b) -> (query AND b)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const lists: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })];
+ const query = buildExceptionItemEntries({
+ language: 'kuery',
+ lists,
+ exclude,
});
const expectedQuery = 'b:*';
@@ -682,27 +942,17 @@ describe('build_exceptions_query', () => {
// Equal to query && !(!b || !c) -> (query AND b AND c)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'excluded',
- type: 'exists',
- },
+ makeExistsEntry({ field: 'b', operator: 'excluded' }),
{
field: 'parent',
type: 'nested',
- entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'value-1',
- },
- ],
+ entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' })],
},
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'b:* and parent:{ c:value-1 }';
@@ -713,38 +963,21 @@ describe('build_exceptions_query', () => {
// Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'exists',
- },
+ makeExistsEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'value-1',
- },
- {
- field: 'd',
- operator: 'included',
- type: 'match',
- value: 'value-2',
- },
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' }),
+ makeMatchEntry({ field: 'd', value: 'value-2' }),
],
},
- {
- field: 'e',
- operator: 'included',
- type: 'exists',
- },
+ makeExistsEntry({ field: 'e' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*';
@@ -756,17 +989,11 @@ describe('build_exceptions_query', () => {
test('it returns expected query when list includes single list item with operator of "included"', () => {
// Equal to query && !(b) -> (query AND NOT b)
// https://www.dcode.fr/boolean-expressions-calculator
- const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match',
- value: 'value',
- },
- ];
+ const lists: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'not b:value';
@@ -777,16 +1004,12 @@ describe('build_exceptions_query', () => {
// Equal to query && !(!b) -> (query AND b)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'excluded',
- type: 'match',
- value: 'value',
- },
+ makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'b:value';
@@ -797,28 +1020,17 @@ describe('build_exceptions_query', () => {
// Equal to query && !(!b || !c) -> (query AND b AND c)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'excluded',
- type: 'match',
- value: 'value',
- },
+ makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }),
{
field: 'parent',
type: 'nested',
- entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
- ],
+ entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })],
},
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
const expectedQuery = 'b:value and parent:{ c:valueC }';
@@ -829,42 +1041,23 @@ describe('build_exceptions_query', () => {
// Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match',
- value: 'value',
- },
+ makeMatchEntry({ field: 'b', value: 'value' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
- {
- field: 'd',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }),
+ makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }),
],
},
- {
- field: 'e',
- operator: 'included',
- type: 'match',
- value: 'valueC',
- },
+ makeMatchEntry({ field: 'e', value: 'valueE' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueC } and not e:valueC';
+ const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueD } and not e:valueE';
expect(query).toEqual(expectedQuery);
});
@@ -874,19 +1067,13 @@ describe('build_exceptions_query', () => {
test('it returns expected query when list includes single list item with operator of "included"', () => {
// Equal to query && !(b) -> (query AND NOT b)
// https://www.dcode.fr/boolean-expressions-calculator
- const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
- ];
+ const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b' })];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'not b:(value or value-1)';
+ const expectedQuery = 'not b:(value-1 or value-2)';
expect(query).toEqual(expectedQuery);
});
@@ -894,19 +1081,13 @@ describe('build_exceptions_query', () => {
test('it returns expected query when list includes single list item with operator of "excluded"', () => {
// Equal to query && !(!b) -> (query AND b)
// https://www.dcode.fr/boolean-expressions-calculator
- const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'excluded',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
- ];
+ const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'b:(value or value-1)';
+ const expectedQuery = 'b:(value-1 or value-2)';
expect(query).toEqual(expectedQuery);
});
@@ -915,30 +1096,19 @@ describe('build_exceptions_query', () => {
// Equal to query && !(!b || c) -> (query AND b AND NOT c)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'excluded',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
+ makeMatchAnyEntry({ field: 'b', operator: 'excluded' }),
{
field: 'parent',
type: 'nested',
- entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
- ],
+ entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })],
},
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'b:(value or value-1) and parent:{ c:valueC }';
+ const expectedQuery = 'b:(value-1 or value-2) and parent:{ c:valueC }';
expect(query).toEqual(expectedQuery);
});
@@ -947,24 +1117,15 @@ describe('build_exceptions_query', () => {
// Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e)
// https://www.dcode.fr/boolean-expressions-calculator
const lists: EntriesArray = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
- {
- field: 'e',
- operator: 'included',
- type: 'match_any',
- value: ['valueE', 'value-4'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
+ makeMatchAnyEntry({ field: 'c' }),
];
const query = buildExceptionItemEntries({
language: 'kuery',
lists,
+ exclude,
});
- const expectedQuery = 'not b:(value or value-1) and not e:(valueE or value-4)';
+ const expectedQuery = 'not b:(value-1 or value-2) and not c:(value-1 or value-2)';
expect(query).toEqual(expectedQuery);
});
@@ -985,36 +1146,16 @@ describe('build_exceptions_query', () => {
const payload = getExceptionListItemSchemaMock();
const payload2 = getExceptionListItemSchemaMock();
payload2.entries = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
- {
- field: 'd',
- operator: 'excluded',
- type: 'match',
- value: 'valueD',
- },
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }),
+ makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }),
],
},
- {
- field: 'e',
- operator: 'included',
- type: 'match_any',
- value: ['valueE', 'value-4'],
- },
+ makeMatchAnyEntry({ field: 'e' }),
];
const query = buildQueryExceptions({
query: 'a:*',
@@ -1022,7 +1163,7 @@ describe('build_exceptions_query', () => {
lists: [payload, payload2],
});
const expectedQuery =
- '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value or value-1) and parent:{ c:valueC and d:valueD } and not e:(valueE or value-4))';
+ '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and not e:(value-1 or value-2))';
expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]);
});
@@ -1033,36 +1174,16 @@ describe('build_exceptions_query', () => {
const payload = getExceptionListItemSchemaMock();
const payload2 = getExceptionListItemSchemaMock();
payload2.entries = [
- {
- field: 'b',
- operator: 'included',
- type: 'match_any',
- value: ['value', 'value-1'],
- },
+ makeMatchAnyEntry({ field: 'b' }),
{
field: 'parent',
type: 'nested',
entries: [
- {
- field: 'c',
- operator: 'excluded',
- type: 'match',
- value: 'valueC',
- },
- {
- field: 'd',
- operator: 'excluded',
- type: 'match',
- value: 'valueD',
- },
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }),
+ makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }),
],
},
- {
- field: 'e',
- operator: 'included',
- type: 'match_any',
- value: ['valueE', 'value-4'],
- },
+ makeMatchAnyEntry({ field: 'e' }),
];
const query = buildQueryExceptions({
query: 'a:*',
@@ -1070,9 +1191,85 @@ describe('build_exceptions_query', () => {
lists: [payload, payload2],
});
const expectedQuery =
- '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value OR value-1) AND parent:{ c:valueC AND d:valueD } AND NOT e:(valueE OR value-4))';
+ '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND NOT e:(value-1 OR value-2))';
expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]);
});
+
+ describe('when "exclude" is false', () => {
+ beforeEach(() => {
+ exclude = false;
+ });
+
+ test('it returns original query if lists is empty array', () => {
+ const query = buildQueryExceptions({
+ query: 'host.name: *',
+ language: 'kuery',
+ lists: [],
+ exclude,
+ });
+ const expectedQuery = 'host.name: *';
+
+ expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]);
+ });
+
+ test('it returns expected query when lists exist and language is "kuery"', () => {
+ // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const payload = getExceptionListItemSchemaMock();
+ const payload2 = getExceptionListItemSchemaMock();
+ payload2.entries = [
+ makeMatchAnyEntry({ field: 'b' }),
+ {
+ field: 'parent',
+ type: 'nested',
+ entries: [
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }),
+ makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }),
+ ],
+ },
+ makeMatchAnyEntry({ field: 'e' }),
+ ];
+ const query = buildQueryExceptions({
+ query: 'a:*',
+ language: 'kuery',
+ lists: [payload, payload2],
+ exclude,
+ });
+ const expectedQuery =
+ '(a:* and some.parentField:{ nested.field:some value } and some.not.nested.field:some value) or (a:* and b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and e:(value-1 or value-2))';
+
+ expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]);
+ });
+
+ test('it returns expected query when lists exist and language is "lucene"', () => {
+ // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e)
+ // https://www.dcode.fr/boolean-expressions-calculator
+ const payload = getExceptionListItemSchemaMock();
+ const payload2 = getExceptionListItemSchemaMock();
+ payload2.entries = [
+ makeMatchAnyEntry({ field: 'b' }),
+ {
+ field: 'parent',
+ type: 'nested',
+ entries: [
+ makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }),
+ makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }),
+ ],
+ },
+ makeMatchAnyEntry({ field: 'e' }),
+ ];
+ const query = buildQueryExceptions({
+ query: 'a:*',
+ language: 'lucene',
+ lists: [payload, payload2],
+ exclude,
+ });
+ const expectedQuery =
+ '(a:* AND some.parentField:{ nested.field:some value } AND some.not.nested.field:some value) OR (a:* AND b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND e:(value-1 OR value-2))';
+
+ expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]);
+ });
+ });
});
});
diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts
index a69ee809987f7..a70e6a6638589 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts
@@ -17,7 +17,8 @@ import {
entriesMatch,
entriesNested,
ExceptionListItemSchema,
-} from '../../../lists/common/schemas';
+ CreateExceptionListItemSchema,
+} from '../shared_imports';
import { Language, Query } from './schemas/common/schemas';
type Operators = 'and' | 'or' | 'not';
@@ -45,32 +46,35 @@ export const getLanguageBooleanOperator = ({
export const operatorBuilder = ({
operator,
language,
+ exclude,
}: {
operator: Operator;
language: Language;
+ exclude: boolean;
}): string => {
const not = getLanguageBooleanOperator({
language,
value: 'not',
});
- switch (operator) {
- case 'included':
- return `${not} `;
- default:
- return '';
+ if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) {
+ return `${not} `;
+ } else {
+ return '';
}
};
export const buildExists = ({
item,
language,
+ exclude,
}: {
item: EntryExists;
language: Language;
+ exclude: boolean;
}): string => {
const { operator, field } = item;
- const exceptionOperator = operatorBuilder({ operator, language });
+ const exceptionOperator = operatorBuilder({ operator, language, exclude });
switch (language) {
case 'kuery':
@@ -85,12 +89,14 @@ export const buildExists = ({
export const buildMatch = ({
item,
language,
+ exclude,
}: {
item: EntryMatch;
language: Language;
+ exclude: boolean;
}): string => {
const { value, operator, field } = item;
- const exceptionOperator = operatorBuilder({ operator, language });
+ const exceptionOperator = operatorBuilder({ operator, language, exclude });
return `${exceptionOperator}${field}:${value}`;
};
@@ -98,9 +104,11 @@ export const buildMatch = ({
export const buildMatchAny = ({
item,
language,
+ exclude,
}: {
item: EntryMatchAny;
language: Language;
+ exclude: boolean;
}): string => {
const { value, operator, field } = item;
@@ -109,7 +117,7 @@ export const buildMatchAny = ({
return '';
default:
const or = getLanguageBooleanOperator({ language, value: 'or' });
- const exceptionOperator = operatorBuilder({ operator, language });
+ const exceptionOperator = operatorBuilder({ operator, language, exclude });
const matchAnyValues = value.map((v) => v);
return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`;
@@ -133,16 +141,18 @@ export const buildNested = ({
export const evaluateValues = ({
item,
language,
+ exclude,
}: {
item: Entry | EntryNested;
language: Language;
+ exclude: boolean;
}): string => {
if (entriesExists.is(item)) {
- return buildExists({ item, language });
+ return buildExists({ item, language, exclude });
} else if (entriesMatch.is(item)) {
- return buildMatch({ item, language });
+ return buildMatch({ item, language, exclude });
} else if (entriesMatchAny.is(item)) {
- return buildMatchAny({ item, language });
+ return buildMatchAny({ item, language, exclude });
} else if (entriesNested.is(item)) {
return buildNested({ item, language });
} else {
@@ -163,7 +173,11 @@ export const formatQuery = ({
const or = getLanguageBooleanOperator({ language, value: 'or' });
const and = getLanguageBooleanOperator({ language, value: 'and' });
const formattedExceptions = exceptions.map((exception) => {
- return `(${query} ${and} ${exception})`;
+ if (query === '') {
+ return `(${exception})`;
+ } else {
+ return `(${query} ${and} ${exception})`;
+ }
});
return formattedExceptions.join(` ${or} `);
@@ -175,15 +189,17 @@ export const formatQuery = ({
export const buildExceptionItemEntries = ({
lists,
language,
+ exclude,
}: {
lists: EntriesArray;
language: Language;
+ exclude: boolean;
}): string => {
const and = getLanguageBooleanOperator({ language, value: 'and' });
const exceptionItem = lists
.filter(({ type }) => type !== 'list')
.reduce((accum, listItem) => {
- const exceptionSegment = evaluateValues({ item: listItem, language });
+ const exceptionSegment = evaluateValues({ item: listItem, language, exclude });
return [...accum, exceptionSegment];
}, []);
@@ -194,15 +210,22 @@ export const buildQueryExceptions = ({
query,
language,
lists,
+ exclude = true,
}: {
query: Query;
language: Language;
- lists: ExceptionListItemSchema[] | undefined;
+ lists: Array | undefined;
+ exclude?: boolean;
}): DataQuery[] => {
if (lists != null) {
- const exceptions = lists.map((exceptionItem) =>
- buildExceptionItemEntries({ lists: exceptionItem.entries, language })
- );
+ const exceptions = lists.reduce((acc, exceptionItem) => {
+ return [
+ ...acc,
+ ...(exceptionItem.entries !== undefined
+ ? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })]
+ : []),
+ ];
+ }, []);
const formattedQuery = formatQuery({ exceptions, language, query });
return [
{
diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts
index 6edd2489e90c9..c19ef45605f83 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts
@@ -456,6 +456,96 @@ describe('get_filter', () => {
});
});
+ describe('when "excludeExceptions" is false', () => {
+ test('it should work with a list', () => {
+ const esQuery = getQueryFilter(
+ 'host.name: linux',
+ 'kuery',
+ [],
+ ['auditbeat-*'],
+ [getExceptionListItemSchemaMock()],
+ false
+ );
+ expect(esQuery).toEqual({
+ bool: {
+ filter: [
+ {
+ bool: {
+ filter: [
+ {
+ bool: {
+ minimum_should_match: 1,
+ should: [
+ {
+ match: {
+ 'host.name': 'linux',
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ filter: [
+ {
+ nested: {
+ path: 'some.parentField',
+ query: {
+ bool: {
+ minimum_should_match: 1,
+ should: [
+ {
+ match: {
+ 'some.parentField.nested.field': 'some value',
+ },
+ },
+ ],
+ },
+ },
+ score_mode: 'none',
+ },
+ },
+ {
+ bool: {
+ minimum_should_match: 1,
+ should: [
+ {
+ match: {
+ 'some.not.nested.field': 'some value',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ must: [],
+ must_not: [],
+ should: [],
+ },
+ });
+ });
+
+ test('it should work with an empty list', () => {
+ const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], [], false);
+ expect(esQuery).toEqual({
+ bool: {
+ filter: [
+ { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } },
+ ],
+ must: [],
+ must_not: [],
+ should: [],
+ },
+ });
+ });
+ });
+
test('it should work with a nested object queries', () => {
const esQuery = getQueryFilter(
'category:{ name:Frank and trusted:true }',
diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts
index ef390c3b44939..6584373b806d8 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts
@@ -11,7 +11,10 @@ import {
buildEsQuery,
Query as DataQuery,
} from '../../../../../src/plugins/data/common';
-import { ExceptionListItemSchema } from '../../../lists/common/schemas';
+import {
+ ExceptionListItemSchema,
+ CreateExceptionListItemSchema,
+} from '../../../lists/common/schemas';
import { buildQueryExceptions } from './build_exceptions_query';
import { Query, Language, Index } from './schemas/common/schemas';
@@ -20,14 +23,20 @@ export const getQueryFilter = (
language: Language,
filters: Array>,
index: Index,
- lists: ExceptionListItemSchema[]
+ lists: Array,
+ excludeExceptions: boolean = true
) => {
const indexPattern: IIndexPattern = {
fields: [],
title: index.join(),
};
- const queries: DataQuery[] = buildQueryExceptions({ query, language, lists });
+ const queries: DataQuery[] = buildQueryExceptions({
+ query,
+ language,
+ lists,
+ exclude: excludeExceptions,
+ });
const config = {
allowLeadingWildcards: true,
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts
index cadc32a37a05d..e5aaee6d3ec74 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts
@@ -6,7 +6,7 @@
import * as t from 'io-ts';
-import { exceptionListType, namespaceType } from '../../lists_common_deps';
+import { exceptionListType, namespaceType } from '../../../shared_imports';
export const list = t.exact(
t.type({
diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts
index 86cccff957211..9b4550f52ff22 100644
--- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts
@@ -82,7 +82,6 @@ export function getAncestryAsArray(event: ResolverEvent | undefined): string[] {
* @param event The event to get the category for
*/
export function primaryEventCategory(event: ResolverEvent): string | undefined {
- // Returning "Process" as a catch-all here because it seems pretty general
if (isLegacyEvent(event)) {
const legacyFullType = event.endgame.event_type_full;
if (legacyFullType) {
@@ -96,6 +95,20 @@ export function primaryEventCategory(event: ResolverEvent): string | undefined {
}
}
+/**
+ * @param event The event to get the full ECS category for
+ */
+export function allEventCategories(event: ResolverEvent): string | string[] | undefined {
+ if (isLegacyEvent(event)) {
+ const legacyFullType = event.endgame.event_type_full;
+ if (legacyFullType) {
+ return legacyFullType;
+ }
+ } else {
+ return event.event.category;
+ }
+}
+
/**
* ECS event type will be things like 'creation', 'deletion', 'access', etc.
* see: https://www.elastic.co/guide/en/ecs/current/ecs-event.html
diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts
index 42cbc2327fc28..c67ad3665d004 100644
--- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts
@@ -12,10 +12,10 @@ import { schema } from '@kbn/config-schema';
export const validateTree = {
params: schema.object({ id: schema.string() }),
query: schema.object({
- children: schema.number({ defaultValue: 10, min: 0, max: 100 }),
- ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }),
- events: schema.number({ defaultValue: 100, min: 0, max: 1000 }),
- alerts: schema.number({ defaultValue: 100, min: 0, max: 1000 }),
+ children: schema.number({ defaultValue: 200, min: 0, max: 10000 }),
+ ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }),
+ events: schema.number({ defaultValue: 1000, min: 0, max: 10000 }),
+ alerts: schema.number({ defaultValue: 1000, min: 0, max: 10000 }),
afterEvent: schema.maybe(schema.string()),
afterAlert: schema.maybe(schema.string()),
afterChild: schema.maybe(schema.string()),
@@ -29,7 +29,7 @@ export const validateTree = {
export const validateEvents = {
params: schema.object({ id: schema.string() }),
query: schema.object({
- events: schema.number({ defaultValue: 100, min: 1, max: 1000 }),
+ events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }),
afterEvent: schema.maybe(schema.string()),
legacyEndpointID: schema.maybe(schema.string()),
}),
@@ -41,7 +41,7 @@ export const validateEvents = {
export const validateAlerts = {
params: schema.object({ id: schema.string() }),
query: schema.object({
- alerts: schema.number({ defaultValue: 100, min: 1, max: 1000 }),
+ alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }),
afterAlert: schema.maybe(schema.string()),
legacyEndpointID: schema.maybe(schema.string()),
}),
@@ -53,7 +53,7 @@ export const validateAlerts = {
export const validateAncestry = {
params: schema.object({ id: schema.string() }),
query: schema.object({
- ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }),
+ ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }),
legacyEndpointID: schema.maybe(schema.string()),
}),
};
@@ -64,7 +64,7 @@ export const validateAncestry = {
export const validateChildren = {
params: schema.object({ id: schema.string() }),
query: schema.object({
- children: schema.number({ defaultValue: 10, min: 1, max: 100 }),
+ children: schema.number({ defaultValue: 200, min: 1, max: 10000 }),
afterChild: schema.maybe(schema.string()),
legacyEndpointID: schema.maybe(schema.string()),
}),
diff --git a/x-pack/plugins/security_solution/common/index.ts b/x-pack/plugins/security_solution/common/index.ts
new file mode 100644
index 0000000000000..b55ca5db30a44
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/index.ts
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export * from './shared_exports';
diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts
new file mode 100644
index 0000000000000..1b5b17ef35cae
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/shared_exports.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.
+ */
+
+export { NonEmptyString } from './detection_engine/schemas/types/non_empty_string';
+export { DefaultUuid } from './detection_engine/schemas/types/default_uuid';
+export { DefaultStringArray } from './detection_engine/schemas/types/default_string_array';
+export { exactCheck } from './exact_check';
+export { getPaths, foldLeftRight } from './test_utils';
+export { validate, validateEither } from './validate';
+export { formatErrors } from './format_errors';
diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts
new file mode 100644
index 0000000000000..a607906e1b92a
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/shared_imports.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+export {
+ ListSchema,
+ CommentsArray,
+ CreateCommentsArray,
+ Comments,
+ CreateComments,
+ ExceptionListSchema,
+ ExceptionListItemSchema,
+ CreateExceptionListItemSchema,
+ UpdateExceptionListItemSchema,
+ Entry,
+ EntryExists,
+ EntryMatch,
+ EntryMatchAny,
+ EntryNested,
+ EntryList,
+ EntriesArray,
+ NamespaceType,
+ Operator,
+ OperatorEnum,
+ OperatorType,
+ OperatorTypeEnum,
+ ExceptionListTypeEnum,
+ exceptionListItemSchema,
+ exceptionListType,
+ createExceptionListItemSchema,
+ listSchema,
+ entry,
+ entriesNested,
+ entriesMatch,
+ entriesMatchAny,
+ entriesExists,
+ entriesList,
+ namespaceType,
+ ExceptionListType,
+ Type,
+} from '../../lists/common';
diff --git a/x-pack/plugins/security_solution/common/validate.test.ts b/x-pack/plugins/security_solution/common/validate.test.ts
index b2217099fca19..8cd322a25b5c0 100644
--- a/x-pack/plugins/security_solution/common/validate.test.ts
+++ b/x-pack/plugins/security_solution/common/validate.test.ts
@@ -43,6 +43,6 @@ describe('validateEither', () => {
const payload = { a: 'some other value' };
const result = validateEither(schema, payload);
- expect(result).toEqual(left('Invalid value "some other value" supplied to "a"'));
+ expect(result).toEqual(left(new Error('Invalid value "some other value" supplied to "a"')));
});
});
diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts
index f36df38c2a90d..9745c21a191f0 100644
--- a/x-pack/plugins/security_solution/common/validate.ts
+++ b/x-pack/plugins/security_solution/common/validate.ts
@@ -27,9 +27,9 @@ export const validate = (
export const validateEither = (
schema: T,
obj: A
-): Either =>
+): Either =>
pipe(
obj,
(a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())),
- mapLeft((errors) => formatErrors(errors).join(','))
+ mapLeft((errors) => new Error(formatErrors(errors).join(',')))
);
diff --git a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts
index e4f0ec2c4828f..792eee3660429 100644
--- a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts
@@ -7,7 +7,7 @@ import {
CASES,
DETECTIONS,
HOSTS,
- MANAGEMENT,
+ ADMINISTRATION,
NETWORK,
OVERVIEW,
TIMELINES,
@@ -73,7 +73,7 @@ describe('top-level navigation common to all pages in the Security app', () => {
});
it('navigates to the Administration page', () => {
- navigateFromHeaderTo(MANAGEMENT);
+ navigateFromHeaderTo(ADMINISTRATION);
cy.url().should('include', ADMINISTRATION_URL);
});
});
diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts
index 20fcae60415ae..a337db7a9bfaa 100644
--- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts
@@ -14,7 +14,7 @@ export const HOSTS = '[data-test-subj="navigation-hosts"]';
export const KQL_INPUT = '[data-test-subj="queryInput"]';
-export const MANAGEMENT = '[data-test-subj="navigation-management"]';
+export const ADMINISTRATION = '[data-test-subj="navigation-administration"]';
export const NETWORK = '[data-test-subj="navigation-network"]';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
index 37ce9094dc594..761fd2c1e6a0b 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
@@ -27,6 +27,8 @@ import {
import { drag, drop } from '../tasks/common';
+export const hostExistsQuery = 'host.name: *';
+
export const addDescriptionToTimeline = (description: string) => {
cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`);
cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating');
@@ -77,6 +79,7 @@ export const openTimelineSettings = () => {
};
export const populateTimeline = () => {
+ executeTimelineKQL(hostExistsQuery);
cy.get(SERVER_SIDE_EVENT_COUNT)
.invoke('text')
.then((strCount) => {
diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json
index 29d0ab58e8b55..92fc93453b9f1 100644
--- a/x-pack/plugins/security_solution/kibana.json
+++ b/x-pack/plugins/security_solution/kibana.json
@@ -1,6 +1,7 @@
{
"id": "securitySolution",
"version": "8.0.0",
+ "extraPublicDirs": ["common"],
"kibanaVersion": "kibana",
"configPath": ["xpack", "securitySolution"],
"requiredPlugins": [
@@ -30,10 +31,5 @@
],
"server": true,
"ui": true,
- "requiredBundles": [
- "kibanaUtils",
- "esUiShared",
- "kibanaReact",
- "ingestManager"
- ]
+ "requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"]
}
diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx
index 543a4634ceecc..9f0f5351d8a54 100644
--- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx
@@ -61,11 +61,11 @@ export const navTabs: SiemNavTab = {
disabled: false,
urlKey: 'case',
},
- [SecurityPageName.management]: {
- id: SecurityPageName.management,
+ [SecurityPageName.administration]: {
+ id: SecurityPageName.administration,
name: i18n.ADMINISTRATION,
href: APP_MANAGEMENT_PATH,
disabled: false,
- urlKey: SecurityPageName.management,
+ urlKey: SecurityPageName.administration,
},
};
diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap
index 096df5ceab256..bed5ac6950a2b 100644
--- a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap
+++ b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap
@@ -25,6 +25,10 @@ exports[`PageView component should display body header custom element 1`] = `
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -120,6 +124,10 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] =
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -331,6 +344,10 @@ exports[`PageView component should display only body if not header props used 1`
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -403,6 +420,10 @@ exports[`PageView component should display only header left 1`] = `
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -505,6 +527,10 @@ exports[`PageView component should display only header right but include an empt
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -604,6 +631,10 @@ exports[`PageView component should pass through EuiPage props 1`] = `
margin-left: 12px;
}
+.c0 .endpoint-header-leftSection {
+ overflow: hidden;
+}
+
@@ -721,10 +756,11 @@ exports[`PageView component should use custom element for header left and not wr
className="euiPageHeader euiPageHeader--responsive endpoint-header"
>
diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx
index 3d2a1d2d6fc9b..d4753b3a64e24 100644
--- a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx
@@ -17,6 +17,7 @@ import {
EuiTab,
EuiTabs,
EuiTitle,
+ EuiTitleProps,
} from '@elastic/eui';
import React, { memo, MouseEventHandler, ReactNode, useMemo } from 'react';
import styled from 'styled-components';
@@ -45,6 +46,9 @@ const StyledEuiPage = styled(EuiPage)`
.endpoint-navTabs {
margin-left: ${(props) => props.theme.eui.euiSizeM};
}
+ .endpoint-header-leftSection {
+ overflow: hidden;
+ }
`;
const isStringOrNumber = /(string|number)/;
@@ -54,13 +58,15 @@ const isStringOrNumber = /(string|number)/;
* Can be used when wanting to customize the `headerLeft` value but still use the standard
* title component
*/
-export const PageViewHeaderTitle = memo<{ children: ReactNode }>(({ children }) => {
- return (
-
- {children}
-
- );
-});
+export const PageViewHeaderTitle = memo & { children: ReactNode }>(
+ ({ children, size = 'l', ...otherProps }) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
PageViewHeaderTitle.displayName = 'PageViewHeaderTitle';
@@ -135,7 +141,10 @@ export const PageView = memo(
{(headerLeft || headerRight) && (
-
+
{isStringOrNumber.test(typeof headerLeft) ? (
{headerLeft}
) : (
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
index 10d510c5f56c3..d5eeef0f1e768 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx
@@ -251,13 +251,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({
const onAddExceptionConfirm = useCallback(() => {
if (addOrUpdateExceptionItems !== null) {
- if (shouldCloseAlert && alertData) {
- addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id);
- } else {
- addOrUpdateExceptionItems(enrichExceptionItems());
- }
+ const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined;
+ const bulkCloseIndex =
+ shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined;
+ addOrUpdateExceptionItems(enrichExceptionItems(), alertIdToClose, bulkCloseIndex);
}
- }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]);
+ }, [
+ addOrUpdateExceptionItems,
+ enrichExceptionItems,
+ shouldCloseAlert,
+ shouldBulkCloseAlert,
+ alertData,
+ signalIndexName,
+ ]);
const isSubmitButtonDisabled = useCallback(
() => fetchOrCreateListError || exceptionItemsToAdd.length === 0,
@@ -330,7 +336,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
{alertData !== undefined && (
-
+
)}
-
+
{
if (addOrUpdateExceptionItems !== null) {
- addOrUpdateExceptionItems(enrichExceptionItems());
+ const bulkCloseIndex =
+ shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined;
+ addOrUpdateExceptionItems(enrichExceptionItems(), undefined, bulkCloseIndex);
}
- }, [addOrUpdateExceptionItems, enrichExceptionItems]);
+ }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]);
const indexPatternConfig = useCallback(() => {
if (exceptionListType === 'endpoint') {
@@ -239,10 +241,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({
-
+
{
expect(result).toEqual(true);
});
});
+
+ describe('#prepareExceptionItemsForBulkClose', () => {
+ test('it should return no exceptionw when passed in an empty array', () => {
+ const payload: ExceptionListItemSchema[] = [];
+ const result = prepareExceptionItemsForBulkClose(payload);
+ expect(result).toEqual([]);
+ });
+
+ test("should not make any updates when the exception entries don't contain 'event.'", () => {
+ const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()];
+ const result = prepareExceptionItemsForBulkClose(payload);
+ expect(result).toEqual(payload);
+ });
+
+ test("should update entry fields when they start with 'event.'", () => {
+ const payload = [
+ {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryMatchMock(),
+ field: 'event.kind',
+ },
+ getEntryMatchMock(),
+ ],
+ },
+ {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryMatchMock(),
+ field: 'event.module',
+ },
+ ],
+ },
+ ];
+ const expected = [
+ {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryMatchMock(),
+ field: 'signal.original_event.kind',
+ },
+ getEntryMatchMock(),
+ ],
+ },
+ {
+ ...getExceptionListItemSchemaMock(),
+ entries: [
+ {
+ ...getEntryMatchMock(),
+ field: 'signal.original_event.module',
+ },
+ ],
+ },
+ ];
+ const result = prepareExceptionItemsForBulkClose(payload);
+ expect(result).toEqual(expected);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
index 481b2736b7597..3d028431de8ff 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
@@ -36,6 +36,7 @@ import {
exceptionListItemSchema,
UpdateExceptionListItemSchema,
ExceptionListType,
+ EntryNested,
} from '../../../lists_plugin_deps';
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { TimelineNonEcsData } from '../../../graphql/types';
@@ -380,6 +381,35 @@ export const formatExceptionItemForUpdate = (
};
};
+/**
+ * Maps "event." fields to "signal.original_event.". This is because when a rule is created
+ * the "event" field is copied over to "original_event". When the user creates an exception,
+ * they expect it to match against the original_event's fields, not the signal event's.
+ * @param exceptionItems new or existing ExceptionItem[]
+ */
+export const prepareExceptionItemsForBulkClose = (
+ exceptionItems: Array
+): Array => {
+ return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
+ if (item.entries !== undefined) {
+ const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => {
+ return {
+ ...itemEntry,
+ field: itemEntry.field.startsWith('event.')
+ ? itemEntry.field.replace(/^event./, 'signal.original_event.')
+ : itemEntry.field,
+ };
+ });
+ return {
+ ...item,
+ entries: newEntries,
+ };
+ } else {
+ return item;
+ }
+ });
+};
+
/**
* Adds new and existing comments to all new exceptionItems if not present already
* @param exceptionItems new or existing ExceptionItem[]
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
index 018ca1d29c369..bf07ff21823eb 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx
@@ -9,6 +9,8 @@ import { KibanaServices } from '../../../common/lib/kibana';
import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api';
import * as listsApi from '../../../../../lists/public/exceptions/api';
+import * as getQueryFilterHelper from '../../../../common/detection_engine/get_query_filter';
+import * as buildAlertStatusFilterHelper from '../../../detections/components/alerts_table/default_config';
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock';
import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock';
@@ -38,11 +40,16 @@ describe('useAddOrUpdateException', () => {
let updateExceptionListItem: jest.SpyInstance>;
+ let getQueryFilter: jest.SpyInstance>;
+ let buildAlertStatusFilter: jest.SpyInstance>;
let addOrUpdateItemsArgs: Parameters;
let render: () => RenderHookResult;
const onError = jest.fn();
const onSuccess = jest.fn();
const alertIdToClose = 'idToClose';
+ const bulkCloseIndex = ['.signals'];
const itemsToAdd: CreateExceptionListItemSchema[] = [
{
...getCreateExceptionListItemSchemaMock(),
@@ -113,6 +120,10 @@ describe('useAddOrUpdateException', () => {
.spyOn(listsApi, 'updateExceptionListItem')
.mockResolvedValue(getExceptionListItemSchemaMock());
+ getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter');
+
+ buildAlertStatusFilter = jest.spyOn(buildAlertStatusFilterHelper, 'buildAlertStatusFilter');
+
addOrUpdateItemsArgs = [itemsToAddOrUpdate];
render = () =>
renderHook(() =>
@@ -244,4 +255,92 @@ describe('useAddOrUpdateException', () => {
});
});
});
+
+ describe('when bulkCloseIndex is passed in', () => {
+ beforeEach(() => {
+ addOrUpdateItemsArgs = [itemsToAddOrUpdate, undefined, bulkCloseIndex];
+ });
+ it('should update the status of only alerts that are open', async () => {
+ await act(async () => {
+ const { rerender, result, waitForNextUpdate } = render();
+ const addOrUpdateItems = await waitForAddOrUpdateFunc({
+ rerender,
+ result,
+ waitForNextUpdate,
+ });
+ if (addOrUpdateItems) {
+ addOrUpdateItems(...addOrUpdateItemsArgs);
+ }
+ await waitForNextUpdate();
+ expect(buildAlertStatusFilter).toHaveBeenCalledTimes(1);
+ expect(buildAlertStatusFilter.mock.calls[0][0]).toEqual('open');
+ });
+ });
+ it('should generate the query filter using exceptions', async () => {
+ await act(async () => {
+ const { rerender, result, waitForNextUpdate } = render();
+ const addOrUpdateItems = await waitForAddOrUpdateFunc({
+ rerender,
+ result,
+ waitForNextUpdate,
+ });
+ if (addOrUpdateItems) {
+ addOrUpdateItems(...addOrUpdateItemsArgs);
+ }
+ await waitForNextUpdate();
+ expect(getQueryFilter).toHaveBeenCalledTimes(1);
+ expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate);
+ expect(getQueryFilter.mock.calls[0][5]).toEqual(false);
+ });
+ });
+ it('should update the alert status', async () => {
+ await act(async () => {
+ const { rerender, result, waitForNextUpdate } = render();
+ const addOrUpdateItems = await waitForAddOrUpdateFunc({
+ rerender,
+ result,
+ waitForNextUpdate,
+ });
+ if (addOrUpdateItems) {
+ addOrUpdateItems(...addOrUpdateItemsArgs);
+ }
+ await waitForNextUpdate();
+ expect(updateAlertStatus).toHaveBeenCalledTimes(1);
+ });
+ });
+ it('creates new items', async () => {
+ await act(async () => {
+ const { rerender, result, waitForNextUpdate } = render();
+ const addOrUpdateItems = await waitForAddOrUpdateFunc({
+ rerender,
+ result,
+ waitForNextUpdate,
+ });
+ if (addOrUpdateItems) {
+ addOrUpdateItems(...addOrUpdateItemsArgs);
+ }
+ await waitForNextUpdate();
+ expect(addExceptionListItem).toHaveBeenCalledTimes(2);
+ expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]);
+ });
+ });
+ it('updates existing items', async () => {
+ await act(async () => {
+ const { rerender, result, waitForNextUpdate } = render();
+ const addOrUpdateItems = await waitForAddOrUpdateFunc({
+ rerender,
+ result,
+ waitForNextUpdate,
+ });
+ if (addOrUpdateItems) {
+ addOrUpdateItems(...addOrUpdateItemsArgs);
+ }
+ await waitForNextUpdate();
+ expect(updateExceptionListItem).toHaveBeenCalledTimes(2);
+ expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual(
+ itemsToUpdateFormatted[1]
+ );
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
index 267a9afd9cf6d..55c3ea35716d5 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx
@@ -16,18 +16,23 @@ import {
} from '../../../lists_plugin_deps';
import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api';
import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions';
-import { formatExceptionItemForUpdate } from './helpers';
+import { buildAlertStatusFilter } from '../../../detections/components/alerts_table/default_config';
+import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter';
+import { Index } from '../../../../common/detection_engine/schemas/common/schemas';
+import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers';
/**
* Adds exception items to the list. Also optionally closes alerts.
*
* @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update
* @param alertIdToClose - optional string representing alert to close
+ * @param bulkCloseIndex - optional index used to create bulk close query
*
*/
export type AddOrUpdateExceptionItemsFunc = (
exceptionItemsToAddOrUpdate: Array,
- alertIdToClose?: string
+ alertIdToClose?: string,
+ bulkCloseIndex?: Index
) => Promise;
export type ReturnUseAddOrUpdateException = [
@@ -100,7 +105,8 @@ export const useAddOrUpdateException = ({
const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async (
exceptionItemsToAddOrUpdate,
- alertIdToClose
+ alertIdToClose,
+ bulkCloseIndex
) => {
try {
setIsLoading(true);
@@ -111,6 +117,23 @@ export const useAddOrUpdateException = ({
});
}
+ if (bulkCloseIndex != null) {
+ const filter = getQueryFilter(
+ '',
+ 'kuery',
+ buildAlertStatusFilter('open'),
+ bulkCloseIndex,
+ prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate),
+ false
+ );
+ await updateAlertStatus({
+ query: {
+ query: filter,
+ },
+ status: 'closed',
+ });
+ }
+
await addOrUpdateItems(exceptionItemsToAddOrUpdate);
if (isSubscribed) {
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts
index dc5324adbac7d..845ef580ddbe2 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts
@@ -15,12 +15,14 @@ import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/p
import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../../cases/pages/utils';
import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils';
import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages';
+import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/pages';
import { SecurityPageName } from '../../../../app/types';
import {
RouteSpyState,
HostRouteSpyState,
NetworkRouteSpyState,
TimelineRouteSpyState,
+ AdministrationRouteSpyState,
} from '../../../utils/route/types';
import { getAppOverviewUrl } from '../../link_to';
@@ -61,6 +63,10 @@ const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState =>
const isAlertsRoutes = (spyState: RouteSpyState) =>
spyState != null && spyState.pageName === SecurityPageName.detections;
+const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState =>
+ spyState != null && spyState.pageName === SecurityPageName.administration;
+
+// eslint-disable-next-line complexity
export const getBreadcrumbsForRoute = (
object: RouteSpyState & TabNavigationProps,
getUrlForApp: GetUrlForApp
@@ -159,6 +165,27 @@ export const getBreadcrumbsForRoute = (
),
];
}
+
+ if (isAdminRoutes(spyState) && object.navTabs) {
+ const tempNav: SearchNavTab = { urlKey: 'administration', isDetailPage: false };
+ let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)];
+ if (spyState.tabName != null) {
+ urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)];
+ }
+
+ return [
+ ...siemRootBreadcrumb,
+ ...getAdminBreadcrumbs(
+ spyState,
+ urlStateKeys.reduce(
+ (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)],
+ []
+ ),
+ getUrlForApp
+ ),
+ ];
+ }
+
if (
spyState != null &&
object.navTabs &&
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx
index 229e2d2402298..c60feb63241fb 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx
@@ -106,12 +106,12 @@ describe('SIEM Navigation', () => {
name: 'Cases',
urlKey: 'case',
},
- management: {
+ administration: {
disabled: false,
href: '/app/security/administration',
- id: 'management',
+ id: 'administration',
name: 'Administration',
- urlKey: 'management',
+ urlKey: 'administration',
},
hosts: {
disabled: false,
@@ -218,12 +218,12 @@ describe('SIEM Navigation', () => {
name: 'Hosts',
urlKey: 'host',
},
- management: {
+ administration: {
disabled: false,
href: '/app/security/administration',
- id: 'management',
+ id: 'administration',
name: 'Administration',
- urlKey: 'management',
+ urlKey: 'administration',
},
network: {
disabled: false,
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
index 0489ebba738c8..c17abaad525a2 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts
@@ -48,7 +48,7 @@ export type SiemNavTabKey =
| SecurityPageName.detections
| SecurityPageName.timelines
| SecurityPageName.case
- | SecurityPageName.management;
+ | SecurityPageName.administration;
export type SiemNavTab = Record;
diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
index 1faff2594ce80..5a4aec93dd9aa 100644
--- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
+++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts
@@ -30,4 +30,4 @@ export type UrlStateType =
| 'network'
| 'overview'
| 'timeline'
- | 'management';
+ | 'administration';
diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
index 6febf95aae01d..5e40cd00fa69e 100644
--- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
+++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts
@@ -96,6 +96,8 @@ export const getUrlType = (pageName: string): UrlStateType => {
return 'timeline';
} else if (pageName === SecurityPageName.case) {
return 'case';
+ } else if (pageName === SecurityPageName.administration) {
+ return 'administration';
}
return 'overview';
};
diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts
index 8881a82e5cd1c..f383e18132385 100644
--- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts
+++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts
@@ -46,7 +46,7 @@ export const URL_STATE_KEYS: Record = {
CONSTANTS.timerange,
CONSTANTS.timeline,
],
- management: [],
+ administration: [],
network: [
CONSTANTS.appQuery,
CONSTANTS.filters,
diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts
index 813907d9af416..2e0ac826c6947 100644
--- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts
+++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts
@@ -8,11 +8,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 '../../../../../security/common/model';
import { convertToCamelCase } from '../../../cases/containers/utils';
+import { StartServices } from '../../../types';
+import { useUiSetting, useKibana } from './kibana_react';
export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT);
@@ -23,6 +25,11 @@ export const useTimeZone = (): string => {
export const useBasePath = (): string => useKibana().services.http.basePath.get();
+export const useToasts = (): StartServices['notifications']['toasts'] =>
+ useKibana().services.notifications.toasts;
+
+export const useHttp = (): StartServices['http'] => useKibana().services.http;
+
interface UserRealm {
name: string;
type: string;
diff --git a/x-pack/plugins/security_solution/public/common/utils/api/index.ts b/x-pack/plugins/security_solution/public/common/utils/api/index.ts
index e47e03ce4e627..ab442d0d09cf9 100644
--- a/x-pack/plugins/security_solution/public/common/utils/api/index.ts
+++ b/x-pack/plugins/security_solution/public/common/utils/api/index.ts
@@ -7,6 +7,7 @@
import { has } from 'lodash/fp';
export interface KibanaApiError {
+ name: string;
message: string;
body: {
message: string;
diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts
index 8656f20c92959..13eb03b07353d 100644
--- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts
+++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts
@@ -12,9 +12,10 @@ import { TimelineType } from '../../../../common/types/timeline';
import { HostsTableType } from '../../../hosts/store/model';
import { NetworkRouteType } from '../../../network/pages/navigation/types';
+import { AdministrationSubTab as AdministrationType } from '../../../management/types';
import { FlowTarget } from '../../../graphql/types';
-export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType;
+export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType;
export interface RouteSpyState {
pageName: string;
detailName: string | undefined;
@@ -38,6 +39,10 @@ export interface TimelineRouteSpyState extends RouteSpyState {
tabName: TimelineType | undefined;
}
+export interface AdministrationRouteSpyState extends RouteSpyState {
+ tabName: AdministrationType | undefined;
+}
+
export type RouteSpyAction =
| {
type: 'updateSearch';
diff --git a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts
new file mode 100644
index 0000000000000..5a3cddb74657d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 { ReactWrapper } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+
+// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073
+export const waitForUpdates = async (wrapper: ReactWrapper
) => {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ wrapper.update();
+ });
+};
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx
new file mode 100644
index 0000000000000..ce5d19259e9ee
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx
@@ -0,0 +1,109 @@
+/*
+ * 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, { FormEvent } from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+
+import { waitForUpdates } from '../../../common/utils/test_utils';
+import { TestProviders } from '../../../common/mock';
+import { ValueListsForm } from './form';
+import { useImportList } from '../../../shared_imports';
+
+jest.mock('../../../shared_imports');
+const mockUseImportList = useImportList as jest.Mock;
+
+const mockFile = ({
+ name: 'foo.csv',
+ path: '/home/foo.csv',
+} as unknown) as File;
+
+const mockSelectFile:
(container: ReactWrapper
, file: File) => Promise = async (
+ container,
+ file
+) => {
+ const fileChange = container.find('EuiFilePicker').prop('onChange');
+ act(() => {
+ if (fileChange) {
+ fileChange(([file] as unknown) as FormEvent);
+ }
+ });
+ await waitForUpdates(container);
+ expect(
+ container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled')
+ ).not.toEqual(true);
+};
+
+describe('ValueListsForm', () => {
+ let mockImportList: jest.Mock;
+
+ beforeEach(() => {
+ mockImportList = jest.fn();
+ mockUseImportList.mockImplementation(() => ({
+ start: mockImportList,
+ }));
+ });
+
+ it('disables upload button when file is absent', () => {
+ const container = mount(
+
+
+
+ );
+
+ expect(
+ container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled')
+ ).toEqual(true);
+ });
+
+ it('calls importList when upload is clicked', async () => {
+ const container = mount(
+
+
+
+ );
+
+ await mockSelectFile(container, mockFile);
+
+ container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click');
+ await waitForUpdates(container);
+
+ expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile }));
+ });
+
+ it('calls onError if import fails', async () => {
+ mockUseImportList.mockImplementation(() => ({
+ start: jest.fn(),
+ error: 'whoops',
+ }));
+
+ const onError = jest.fn();
+ const container = mount(
+
+
+
+ );
+ await waitForUpdates(container);
+
+ expect(onError).toHaveBeenCalledWith('whoops');
+ });
+
+ it('calls onSuccess if import succeeds', async () => {
+ mockUseImportList.mockImplementation(() => ({
+ start: jest.fn(),
+ result: { mockResult: true },
+ }));
+
+ const onSuccess = jest.fn();
+ const container = mount(
+
+
+
+ );
+ await waitForUpdates(container);
+
+ expect(onSuccess).toHaveBeenCalledWith({ mockResult: true });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx
new file mode 100644
index 0000000000000..b8416c3242e4a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx
@@ -0,0 +1,172 @@
+/*
+ * 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, useState, ReactNode, useEffect, useRef } from 'react';
+import styled from 'styled-components';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiForm,
+ EuiFormRow,
+ EuiFilePicker,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiRadioGroup,
+} from '@elastic/eui';
+
+import { useImportList, ListSchema, Type } from '../../../shared_imports';
+import * as i18n from './translations';
+import { useKibana } from '../../../common/lib/kibana';
+
+const InlineRadioGroup = styled(EuiRadioGroup)`
+ display: flex;
+
+ .euiRadioGroup__item + .euiRadioGroup__item {
+ margin: 0 0 0 12px;
+ }
+`;
+
+interface ListTypeOptions {
+ id: Type;
+ label: ReactNode;
+}
+
+const options: ListTypeOptions[] = [
+ {
+ id: 'keyword',
+ label: i18n.KEYWORDS_RADIO,
+ },
+ {
+ id: 'ip',
+ label: i18n.IP_RADIO,
+ },
+];
+
+const defaultListType: Type = 'keyword';
+
+export interface ValueListsFormProps {
+ onError: (error: Error) => void;
+ onSuccess: (response: ListSchema) => void;
+}
+
+export const ValueListsFormComponent: React.FC = ({ onError, onSuccess }) => {
+ const ctrl = useRef(new AbortController());
+ const [files, setFiles] = useState(null);
+ const [type, setType] = useState(defaultListType);
+ const filePickerRef = useRef(null);
+ const { http } = useKibana().services;
+ const { start: importList, ...importState } = useImportList();
+
+ // EuiRadioGroup's onChange only infers 'string' from our options
+ const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]);
+
+ const resetForm = useCallback(() => {
+ if (filePickerRef.current?.fileInput) {
+ filePickerRef.current.fileInput.value = '';
+ filePickerRef.current.handleChange();
+ }
+ setFiles(null);
+ setType(defaultListType);
+ }, [setType]);
+
+ const handleCancel = useCallback(() => {
+ ctrl.current.abort();
+ }, []);
+
+ const handleSuccess = useCallback(
+ (response: ListSchema) => {
+ resetForm();
+ onSuccess(response);
+ },
+ [resetForm, onSuccess]
+ );
+ const handleError = useCallback(
+ (error: Error) => {
+ onError(error);
+ },
+ [onError]
+ );
+
+ const handleImport = useCallback(() => {
+ if (!importState.loading && files && files.length) {
+ ctrl.current = new AbortController();
+ importList({
+ file: files[0],
+ listId: undefined,
+ http,
+ signal: ctrl.current.signal,
+ type,
+ });
+ }
+ }, [importState.loading, files, importList, http, type]);
+
+ useEffect(() => {
+ if (!importState.loading && importState.result) {
+ handleSuccess(importState.result);
+ } else if (!importState.loading && importState.error) {
+ handleError(importState.error as Error);
+ }
+ }, [handleError, handleSuccess, importState.error, importState.loading, importState.result]);
+
+ useEffect(() => {
+ return handleCancel;
+ }, [handleCancel]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {importState.loading && (
+ {i18n.CANCEL_BUTTON}
+ )}
+
+
+
+ {i18n.UPLOAD_BUTTON}
+
+
+
+
+
+
+
+
+ );
+};
+
+ValueListsFormComponent.displayName = 'ValueListsFormComponent';
+
+export const ValueListsForm = React.memo(ValueListsFormComponent);
+
+ValueListsForm.displayName = 'ValueListsForm';
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx
new file mode 100644
index 0000000000000..1fbe0e312bd8a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export { ValueListsModal } from './modal';
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx
new file mode 100644
index 0000000000000..daf1cbd68df91
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * 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 { TestProviders } from '../../../common/mock';
+import { ValueListsModal } from './modal';
+import { waitForUpdates } from '../../../common/utils/test_utils';
+
+describe('ValueListsModal', () => {
+ it('renders nothing if showModal is false', () => {
+ const container = mount(
+
+
+
+ );
+
+ expect(container.find('EuiModal')).toHaveLength(0);
+ });
+
+ it('renders modal if showModal is true', async () => {
+ const container = mount(
+
+
+
+ );
+ await waitForUpdates(container);
+
+ expect(container.find('EuiModal')).toHaveLength(1);
+ });
+
+ it('calls onClose when modal is closed', async () => {
+ const onClose = jest.fn();
+ const container = mount(
+
+
+
+ );
+
+ container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click');
+
+ await waitForUpdates(container);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('renders ValueListsForm and ValueListsTable', async () => {
+ const container = mount(
+
+
+
+ );
+
+ await waitForUpdates(container);
+
+ expect(container.find('ValueListsForm')).toHaveLength(1);
+ expect(container.find('ValueListsTable')).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx
new file mode 100644
index 0000000000000..0a935a9cdb1c4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx
@@ -0,0 +1,164 @@
+/*
+ * 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, useEffect, useState } from 'react';
+import {
+ EuiButton,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ EuiSpacer,
+} from '@elastic/eui';
+
+import {
+ ListSchema,
+ exportList,
+ useFindLists,
+ useDeleteList,
+ useCursor,
+} from '../../../shared_imports';
+import { useToasts, useKibana } from '../../../common/lib/kibana';
+import { GenericDownloader } from '../../../common/components/generic_downloader';
+import * as i18n from './translations';
+import { ValueListsTable } from './table';
+import { ValueListsForm } from './form';
+
+interface ValueListsModalProps {
+ onClose: () => void;
+ showModal: boolean;
+}
+
+export const ValueListsModalComponent: React.FC = ({
+ onClose,
+ showModal,
+}) => {
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(5);
+ const [cursor, setCursor] = useCursor({ pageIndex, pageSize });
+ const { http } = useKibana().services;
+ const { start: findLists, ...lists } = useFindLists();
+ const { start: deleteList, result: deleteResult } = useDeleteList();
+ const [exportListId, setExportListId] = useState();
+ const toasts = useToasts();
+
+ const fetchLists = useCallback(() => {
+ findLists({ cursor, http, pageIndex: pageIndex + 1, pageSize });
+ }, [cursor, http, findLists, pageIndex, pageSize]);
+
+ const handleDelete = useCallback(
+ ({ id }: { id: string }) => {
+ deleteList({ http, id });
+ },
+ [deleteList, http]
+ );
+
+ useEffect(() => {
+ if (deleteResult != null) {
+ fetchLists();
+ }
+ }, [deleteResult, fetchLists]);
+
+ const handleExport = useCallback(
+ async ({ ids }: { ids: string[] }) =>
+ exportList({ http, listId: ids[0], signal: new AbortController().signal }),
+ [http]
+ );
+ const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []);
+ const handleExportComplete = useCallback(() => setExportListId(undefined), []);
+
+ const handleTableChange = useCallback(
+ ({ page: { index, size } }: { page: { index: number; size: number } }) => {
+ setPageIndex(index);
+ setPageSize(size);
+ },
+ [setPageIndex, setPageSize]
+ );
+ const handleUploadError = useCallback(
+ (error: Error) => {
+ if (error.name !== 'AbortError') {
+ toasts.addError(error, { title: i18n.UPLOAD_ERROR });
+ }
+ },
+ [toasts]
+ );
+ const handleUploadSuccess = useCallback(
+ (response: ListSchema) => {
+ toasts.addSuccess({
+ text: i18n.uploadSuccessMessage(response.name),
+ title: i18n.UPLOAD_SUCCESS_TITLE,
+ });
+ fetchLists();
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [toasts]
+ );
+
+ useEffect(() => {
+ if (showModal) {
+ fetchLists();
+ }
+ }, [showModal, fetchLists]);
+
+ useEffect(() => {
+ if (!lists.loading && lists.result?.cursor) {
+ setCursor(lists.result.cursor);
+ }
+ }, [lists.loading, lists.result, setCursor]);
+
+ if (!showModal) {
+ return null;
+ }
+
+ const pagination = {
+ pageIndex,
+ pageSize,
+ totalItemCount: lists.result?.total ?? 0,
+ hidePerPageOptions: true,
+ };
+
+ return (
+
+
+
+ {i18n.MODAL_TITLE}
+
+
+
+
+
+
+
+
+ {i18n.CLOSE_BUTTON}
+
+
+
+
+
+ );
+};
+
+ValueListsModalComponent.displayName = 'ValueListsModalComponent';
+
+export const ValueListsModal = React.memo(ValueListsModalComponent);
+
+ValueListsModal.displayName = 'ValueListsModal';
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx
new file mode 100644
index 0000000000000..d0ed41ea58588
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx
@@ -0,0 +1,113 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+
+import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
+import { ListSchema } from '../../../../../lists/common/schemas/response';
+import { TestProviders } from '../../../common/mock';
+import { ValueListsTable } from './table';
+
+describe('ValueListsTable', () => {
+ it('renders a row for each list', () => {
+ const lists = Array(3).fill(getListResponseMock());
+ const container = mount(
+
+
+
+ );
+
+ expect(container.find('tbody tr')).toHaveLength(3);
+ });
+
+ it('calls onChange when pagination is modified', () => {
+ const lists = Array(6).fill(getListResponseMock());
+ const onChange = jest.fn();
+ const container = mount(
+
+
+
+ );
+
+ act(() => {
+ container.find('a[data-test-subj="pagination-button-next"]').simulate('click');
+ });
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ page: expect.objectContaining({ index: 1 }) })
+ );
+ });
+
+ it('calls onExport when export is clicked', () => {
+ const lists = Array(3).fill(getListResponseMock());
+ const onExport = jest.fn();
+ const container = mount(
+
+
+
+ );
+
+ act(() => {
+ container
+ .find('tbody tr')
+ .first()
+ .find('button[data-test-subj="action-export-value-list"]')
+ .simulate('click');
+ });
+
+ expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
+ });
+
+ it('calls onDelete when delete is clicked', () => {
+ const lists = Array(3).fill(getListResponseMock());
+ const onDelete = jest.fn();
+ const container = mount(
+
+
+
+ );
+
+ act(() => {
+ container
+ .find('tbody tr')
+ .first()
+ .find('button[data-test-subj="action-delete-value-list"]')
+ .simulate('click');
+ });
+
+ expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' }));
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx
new file mode 100644
index 0000000000000..07d52603a6fd1
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx
@@ -0,0 +1,103 @@
+/*
+ * 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 { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui';
+
+import { ListSchema } from '../../../../../lists/common/schemas/response';
+import { FormattedDate } from '../../../common/components/formatted_date';
+import * as i18n from './translations';
+
+type TableProps = EuiBasicTableProps;
+type ActionCallback = (item: ListSchema) => void;
+
+export interface ValueListsTableProps {
+ lists: TableProps['items'];
+ loading: boolean;
+ onChange: TableProps['onChange'];
+ onExport: ActionCallback;
+ onDelete: ActionCallback;
+ pagination: Exclude;
+}
+
+const buildColumns = (
+ onExport: ActionCallback,
+ onDelete: ActionCallback
+): TableProps['columns'] => [
+ {
+ field: 'name',
+ name: i18n.COLUMN_FILE_NAME,
+ truncateText: true,
+ },
+ {
+ field: 'created_at',
+ name: i18n.COLUMN_UPLOAD_DATE,
+ /* eslint-disable-next-line react/display-name */
+ render: (value: ListSchema['created_at']) => (
+
+ ),
+ width: '30%',
+ },
+ {
+ field: 'created_by',
+ name: i18n.COLUMN_CREATED_BY,
+ truncateText: true,
+ width: '20%',
+ },
+ {
+ name: i18n.COLUMN_ACTIONS,
+ actions: [
+ {
+ name: i18n.ACTION_EXPORT_NAME,
+ description: i18n.ACTION_EXPORT_DESCRIPTION,
+ icon: 'exportAction',
+ type: 'icon',
+ onClick: onExport,
+ 'data-test-subj': 'action-export-value-list',
+ },
+ {
+ name: i18n.ACTION_DELETE_NAME,
+ description: i18n.ACTION_DELETE_DESCRIPTION,
+ icon: 'trash',
+ type: 'icon',
+ onClick: onDelete,
+ 'data-test-subj': 'action-delete-value-list',
+ },
+ ],
+ width: '15%',
+ },
+];
+
+export const ValueListsTableComponent: React.FC = ({
+ lists,
+ loading,
+ onChange,
+ onExport,
+ onDelete,
+ pagination,
+}) => {
+ const columns = buildColumns(onExport, onDelete);
+ return (
+
+
+ {i18n.TABLE_TITLE}
+
+
+
+ );
+};
+
+ValueListsTableComponent.displayName = 'ValueListsTableComponent';
+
+export const ValueListsTable = React.memo(ValueListsTableComponent);
+
+ValueListsTable.displayName = 'ValueListsTable';
diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts
new file mode 100644
index 0000000000000..dca6e43a98143
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts
@@ -0,0 +1,138 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const MODAL_TITLE = i18n.translate('xpack.securitySolution.lists.uploadValueListTitle', {
+ defaultMessage: 'Upload value lists',
+});
+
+export const FILE_PICKER_LABEL = i18n.translate(
+ 'xpack.securitySolution.lists.uploadValueListDescription',
+ {
+ defaultMessage: 'Upload single value lists to use while writing rules or rule exceptions.',
+ }
+);
+
+export const FILE_PICKER_PROMPT = i18n.translate(
+ 'xpack.securitySolution.lists.uploadValueListPrompt',
+ {
+ defaultMessage: 'Select or drag and drop a file',
+ }
+);
+
+export const CLOSE_BUTTON = i18n.translate(
+ 'xpack.securitySolution.lists.closeValueListsModalTitle',
+ {
+ defaultMessage: 'Close',
+ }
+);
+
+export const CANCEL_BUTTON = i18n.translate(
+ 'xpack.securitySolution.lists.cancelValueListsUploadTitle',
+ {
+ defaultMessage: 'Cancel upload',
+ }
+);
+
+export const UPLOAD_BUTTON = i18n.translate('xpack.securitySolution.lists.valueListsUploadButton', {
+ defaultMessage: 'Upload list',
+});
+
+export const UPLOAD_SUCCESS_TITLE = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsUploadSuccessTitle',
+ {
+ defaultMessage: 'Value list uploaded',
+ }
+);
+
+export const UPLOAD_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsUploadError', {
+ defaultMessage: 'There was an error uploading the value list.',
+});
+
+export const uploadSuccessMessage = (fileName: string) =>
+ i18n.translate('xpack.securitySolution.lists.valueListsUploadSuccess', {
+ defaultMessage: "Value list '{fileName}' was uploaded",
+ values: { fileName },
+ });
+
+export const COLUMN_FILE_NAME = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.fileNameColumn',
+ {
+ defaultMessage: 'Filename',
+ }
+);
+
+export const COLUMN_UPLOAD_DATE = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.uploadDateColumn',
+ {
+ defaultMessage: 'Upload Date',
+ }
+);
+
+export const COLUMN_CREATED_BY = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.createdByColumn',
+ {
+ defaultMessage: 'Created by',
+ }
+);
+
+export const COLUMN_ACTIONS = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.actionsColumn',
+ {
+ defaultMessage: 'Actions',
+ }
+);
+
+export const ACTION_EXPORT_NAME = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.exportActionName',
+ {
+ defaultMessage: 'Export',
+ }
+);
+
+export const ACTION_EXPORT_DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.exportActionDescription',
+ {
+ defaultMessage: 'Export value list',
+ }
+);
+
+export const ACTION_DELETE_NAME = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.deleteActionName',
+ {
+ defaultMessage: 'Remove',
+ }
+);
+
+export const ACTION_DELETE_DESCRIPTION = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsTable.deleteActionDescription',
+ {
+ defaultMessage: 'Remove value list',
+ }
+);
+
+export const TABLE_TITLE = i18n.translate('xpack.securitySolution.lists.valueListsTable.title', {
+ defaultMessage: 'Value lists',
+});
+
+export const LIST_TYPES_RADIO_LABEL = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsForm.listTypesRadioLabel',
+ {
+ defaultMessage: 'Type of value list',
+ }
+);
+
+export const IP_RADIO = i18n.translate('xpack.securitySolution.lists.valueListsForm.ipRadioLabel', {
+ defaultMessage: 'IP addresses',
+});
+
+export const KEYWORDS_RADIO = i18n.translate(
+ 'xpack.securitySolution.lists.valueListsForm.keywordsRadioLabel',
+ {
+ defaultMessage: 'Keywords',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx
new file mode 100644
index 0000000000000..0f8e0fba1e3af
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx
@@ -0,0 +1,7 @@
+/*
+ * 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.
+ */
+
+export const useListsConfig = jest.fn().mockReturnValue({});
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts
new file mode 100644
index 0000000000000..8c72f092918c9
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.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 { i18n } from '@kbn/i18n';
+
+export const LISTS_INDEX_FETCH_FAILURE = i18n.translate(
+ 'xpack.securitySolution.containers.detectionEngine.alerts.fetchListsIndex.errorDescription',
+ {
+ defaultMessage: 'Failed to retrieve the lists index',
+ }
+);
+
+export const LISTS_INDEX_CREATE_FAILURE = i18n.translate(
+ 'xpack.securitySolution.containers.detectionEngine.alerts.createListsIndex.errorDescription',
+ {
+ defaultMessage: 'Failed to create the lists index',
+ }
+);
+
+export const LISTS_PRIVILEGES_READ_FAILURE = i18n.translate(
+ 'xpack.securitySolution.containers.detectionEngine.alerts.readListsPrivileges.errorDescription',
+ {
+ defaultMessage: 'Failed to retrieve lists privileges',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx
new file mode 100644
index 0000000000000..ea5e075811d4b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.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 { useEffect } from 'react';
+
+import { useKibana } from '../../../../common/lib/kibana';
+import { useListsIndex } from './use_lists_index';
+import { useListsPrivileges } from './use_lists_privileges';
+
+export interface UseListsConfigReturn {
+ canManageIndex: boolean | null;
+ canWriteIndex: boolean | null;
+ enabled: boolean;
+ loading: boolean;
+ needsConfiguration: boolean;
+}
+
+export const useListsConfig = (): UseListsConfigReturn => {
+ const { createIndex, indexExists, loading: indexLoading } = useListsIndex();
+ const { canManageIndex, canWriteIndex, loading: privilegesLoading } = useListsPrivileges();
+ const { lists } = useKibana().services;
+
+ const enabled = lists != null;
+ const loading = indexLoading || privilegesLoading;
+ const needsIndex = indexExists === false;
+ const needsConfiguration = !enabled || needsIndex || canWriteIndex === false;
+
+ useEffect(() => {
+ if (canManageIndex && needsIndex) {
+ createIndex();
+ }
+ }, [canManageIndex, createIndex, needsIndex]);
+
+ return { canManageIndex, canWriteIndex, enabled, loading, needsConfiguration };
+};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx
new file mode 100644
index 0000000000000..a9497fd4971c1
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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 { useEffect, useState, useCallback } from 'react';
+
+import { useReadListIndex, useCreateListIndex } from '../../../../shared_imports';
+import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana';
+import { isApiError } from '../../../../common/utils/api';
+import * as i18n from './translations';
+
+export interface UseListsIndexState {
+ indexExists: boolean | null;
+}
+
+export interface UseListsIndexReturn extends UseListsIndexState {
+ loading: boolean;
+ createIndex: () => void;
+}
+
+export const useListsIndex = (): UseListsIndexReturn => {
+ const [state, setState] = useState({
+ indexExists: null,
+ });
+ const { lists } = useKibana().services;
+ const http = useHttp();
+ const toasts = useToasts();
+ const { loading: readLoading, start: readListIndex, ...readListIndexState } = useReadListIndex();
+ const {
+ loading: createLoading,
+ start: createListIndex,
+ ...createListIndexState
+ } = useCreateListIndex();
+ const loading = readLoading || createLoading;
+
+ const readIndex = useCallback(() => {
+ if (lists) {
+ readListIndex({ http });
+ }
+ }, [http, lists, readListIndex]);
+
+ const createIndex = useCallback(() => {
+ if (lists) {
+ createListIndex({ http });
+ }
+ }, [createListIndex, http, lists]);
+
+ // initial read list
+ useEffect(() => {
+ if (!readLoading && state.indexExists === null) {
+ readIndex();
+ }
+ }, [readIndex, readLoading, state.indexExists]);
+
+ // handle read result
+ useEffect(() => {
+ if (readListIndexState.result != null) {
+ setState({
+ indexExists:
+ readListIndexState.result.list_index && readListIndexState.result.list_item_index,
+ });
+ }
+ }, [readListIndexState.result]);
+
+ // refetch index after creation
+ useEffect(() => {
+ if (createListIndexState.result != null) {
+ readIndex();
+ }
+ }, [createListIndexState.result, readIndex]);
+
+ // handle read error
+ useEffect(() => {
+ const error = readListIndexState.error;
+ if (isApiError(error)) {
+ setState({ indexExists: false });
+ if (error.body.status_code !== 404) {
+ toasts.addError(error, {
+ title: i18n.LISTS_INDEX_FETCH_FAILURE,
+ toastMessage: error.body.message,
+ });
+ }
+ }
+ }, [readListIndexState.error, toasts]);
+
+ // handle create error
+ useEffect(() => {
+ const error = createListIndexState.error;
+ if (isApiError(error)) {
+ toasts.addError(error, {
+ title: i18n.LISTS_INDEX_CREATE_FAILURE,
+ toastMessage: error.body.message,
+ });
+ }
+ }, [createListIndexState.error, toasts]);
+
+ return { loading, createIndex, ...state };
+};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx
new file mode 100644
index 0000000000000..fbbcff33402c3
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx
@@ -0,0 +1,132 @@
+/*
+ * 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 { useEffect, useState, useCallback } from 'react';
+
+import { useReadListPrivileges } from '../../../../shared_imports';
+import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana';
+import { isApiError } from '../../../../common/utils/api';
+import * as i18n from './translations';
+
+export interface UseListsPrivilegesState {
+ isAuthenticated: boolean | null;
+ canManageIndex: boolean | null;
+ canWriteIndex: boolean | null;
+}
+
+export interface UseListsPrivilegesReturn extends UseListsPrivilegesState {
+ loading: boolean;
+}
+
+interface ListIndexPrivileges {
+ [indexName: string]: {
+ all: boolean;
+ create: boolean;
+ create_doc: boolean;
+ create_index: boolean;
+ delete: boolean;
+ delete_index: boolean;
+ index: boolean;
+ manage: boolean;
+ manage_follow_index: boolean;
+ manage_ilm: boolean;
+ manage_leader_index: boolean;
+ monitor: boolean;
+ read: boolean;
+ read_cross_cluster: boolean;
+ view_index_metadata: boolean;
+ write: boolean;
+ };
+}
+
+interface ListPrivileges {
+ is_authenticated: boolean;
+ lists: {
+ index: ListIndexPrivileges;
+ };
+ listItems: {
+ index: ListIndexPrivileges;
+ };
+}
+
+const canManageIndex = (indexPrivileges: ListIndexPrivileges): boolean => {
+ const [indexName] = Object.keys(indexPrivileges);
+ const privileges = indexPrivileges[indexName];
+ if (privileges == null) {
+ return false;
+ }
+ return privileges.manage;
+};
+
+const canWriteIndex = (indexPrivileges: ListIndexPrivileges): boolean => {
+ const [indexName] = Object.keys(indexPrivileges);
+ const privileges = indexPrivileges[indexName];
+ if (privileges == null) {
+ return false;
+ }
+
+ return privileges.create || privileges.create_doc || privileges.index || privileges.write;
+};
+
+export const useListsPrivileges = (): UseListsPrivilegesReturn => {
+ const [state, setState] = useState({
+ isAuthenticated: null,
+ canManageIndex: null,
+ canWriteIndex: null,
+ });
+ const { lists } = useKibana().services;
+ const http = useHttp();
+ const toasts = useToasts();
+ const { loading, start: readListPrivileges, ...privilegesState } = useReadListPrivileges();
+
+ const readPrivileges = useCallback(() => {
+ if (lists) {
+ readListPrivileges({ http });
+ }
+ }, [http, lists, readListPrivileges]);
+
+ // initRead
+ useEffect(() => {
+ if (!loading && state.isAuthenticated === null) {
+ readPrivileges();
+ }
+ }, [loading, readPrivileges, state.isAuthenticated]);
+
+ // handleReadResult
+ useEffect(() => {
+ if (privilegesState.result != null) {
+ try {
+ const {
+ is_authenticated: isAuthenticated,
+ lists: { index: listsPrivileges },
+ listItems: { index: listItemsPrivileges },
+ } = privilegesState.result as ListPrivileges;
+
+ setState({
+ isAuthenticated,
+ canManageIndex: canManageIndex(listsPrivileges) && canManageIndex(listItemsPrivileges),
+ canWriteIndex: canWriteIndex(listsPrivileges) && canWriteIndex(listItemsPrivileges),
+ });
+ } catch (e) {
+ setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false });
+ }
+ }
+ }, [privilegesState.result]);
+
+ // handleReadError
+ useEffect(() => {
+ const error = privilegesState.error;
+ if (isApiError(error)) {
+ setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false });
+ toasts.addError(error, {
+ title: i18n.LISTS_PRIVILEGES_READ_FAILURE,
+ toastMessage: error.body.message,
+ });
+ }
+ }, [privilegesState.error, toasts]);
+
+ return { loading, ...state };
+};
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx
index fa7c85c95d87b..d5aa57ddd8754 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx
@@ -14,6 +14,7 @@ import { DetectionEnginePageComponent } from './detection_engine';
import { useUserInfo } from '../../components/user_info';
import { useWithSource } from '../../../common/containers/source';
+jest.mock('../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../components/user_info');
jest.mock('../../../common/containers/source');
jest.mock('../../../common/components/link_to');
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx
index 11f738320db6e..84cfc744312f9 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx
@@ -34,6 +34,7 @@ import { useUserInfo } from '../../components/user_info';
import { OverviewEmpty } from '../../../overview/components/overview_empty';
import { DetectionEngineNoIndex } from './detection_engine_no_signal_index';
import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page';
+import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config';
import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated';
import * as i18n from './translations';
import { LinkButton } from '../../../common/components/links';
@@ -46,7 +47,7 @@ export const DetectionEnginePageComponent: React.FC = ({
}) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime();
const {
- loading,
+ loading: userInfoLoading,
isSignalIndexExists,
isAuthenticated: isUserAuthenticated,
hasEncryptionKey,
@@ -54,9 +55,14 @@ export const DetectionEnginePageComponent: React.FC = ({
signalIndexName,
hasIndexWrite,
} = useUserInfo();
+ const {
+ loading: listsConfigLoading,
+ needsConfiguration: needsListsConfiguration,
+ } = useListsConfig();
const history = useHistory();
const [lastAlerts] = useAlertInfo({});
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
+ const loading = userInfoLoading || listsConfigLoading;
const updateDateRangeCallback = useCallback(
({ x }) => {
@@ -90,7 +96,8 @@ export const DetectionEnginePageComponent: React.FC = ({
);
}
- if (isSignalIndexExists != null && !isSignalIndexExists && !loading) {
+
+ if (!loading && (isSignalIndexExists === false || needsListsConfiguration)) {
return (
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx
index b7a2d017c3666..f7430a56c74d3 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx
@@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => {
};
});
+jest.mock('../../../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../components/user_info');
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx
index 6475b6f6b6b54..f6e13786e98d0 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx
@@ -10,6 +10,7 @@ import { useHistory } from 'react-router-dom';
import styled, { StyledComponent } from 'styled-components';
import { usePersistRule } from '../../../../containers/detection_engine/rules';
+import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config';
import {
getRulesUrl,
@@ -84,12 +85,17 @@ StepDefineRuleAccordion.displayName = 'StepDefineRuleAccordion';
const CreateRulePageComponent: React.FC = () => {
const {
- loading,
+ loading: userInfoLoading,
isSignalIndexExists,
isAuthenticated,
hasEncryptionKey,
canUserCRUD,
} = useUserInfo();
+ const {
+ loading: listsConfigLoading,
+ needsConfiguration: needsListsConfiguration,
+ } = useListsConfig();
+ const loading = userInfoLoading || listsConfigLoading;
const [, dispatchToaster] = useStateToaster();
const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule);
const defineRuleRef = useRef(null);
@@ -278,7 +284,14 @@ const CreateRulePageComponent: React.FC = () => {
return null;
}
- if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) {
+ if (
+ redirectToDetections(
+ isSignalIndexExists,
+ isAuthenticated,
+ hasEncryptionKey,
+ needsListsConfiguration
+ )
+ ) {
history.replace(getDetectionEngineUrl());
return null;
} else if (userHasNoPermissions(canUserCRUD)) {
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx
index 11099e8cfc755..0a42602e5fbb2 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx
@@ -15,6 +15,7 @@ import { useUserInfo } from '../../../../components/user_info';
import { useWithSource } from '../../../../../common/containers/source';
import { useParams } from 'react-router-dom';
+jest.mock('../../../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../components/user_info');
jest.mock('../../../../../common/containers/source');
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
index 6ab08d94fa781..c74a2a3cf993a 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx
@@ -34,6 +34,7 @@ import {
import { SiemSearchBar } from '../../../../../common/components/search_bar';
import { WrapperPage } from '../../../../../common/components/wrapper_page';
import { useRule } from '../../../../containers/detection_engine/rules';
+import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config';
import { useWithSource } from '../../../../../common/containers/source';
import { SpyRoute } from '../../../../../common/utils/route/spy_routes';
@@ -105,7 +106,7 @@ export const RuleDetailsPageComponent: FC = ({
}) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime();
const {
- loading,
+ loading: userInfoLoading,
isSignalIndexExists,
isAuthenticated,
hasEncryptionKey,
@@ -113,6 +114,11 @@ export const RuleDetailsPageComponent: FC = ({
hasIndexWrite,
signalIndexName,
} = useUserInfo();
+ const {
+ loading: listsConfigLoading,
+ needsConfiguration: needsListsConfiguration,
+ } = useListsConfig();
+ const loading = userInfoLoading || listsConfigLoading;
const { detailName: ruleId } = useParams();
const [isLoading, rule] = useRule(ruleId);
// This is used to re-trigger api rule status when user de/activate rule
@@ -282,7 +288,14 @@ export const RuleDetailsPageComponent: FC = ({
}
}, [rule]);
- if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) {
+ if (
+ redirectToDetections(
+ isSignalIndexExists,
+ isAuthenticated,
+ hasEncryptionKey,
+ needsListsConfiguration
+ )
+ ) {
history.replace(getDetectionEngineUrl());
return null;
}
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx
index d754329bdd97f..71930e1523549 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx
@@ -12,6 +12,7 @@ import { EditRulePage } from './index';
import { useUserInfo } from '../../../../components/user_info';
import { useParams } from 'react-router-dom';
+jest.mock('../../../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../components/user_info');
jest.mock('react-router-dom', () => {
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx
index 777f7766993d0..87cb5e77697b5 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx
@@ -20,6 +20,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } fr
import { useParams, useHistory } from 'react-router-dom';
import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules';
+import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config';
import { WrapperPage } from '../../../../../common/components/wrapper_page';
import {
getRuleDetailsUrl,
@@ -74,12 +75,17 @@ const EditRulePageComponent: FC = () => {
const history = useHistory();
const [, dispatchToaster] = useStateToaster();
const {
- loading: initLoading,
+ loading: userInfoLoading,
isSignalIndexExists,
isAuthenticated,
hasEncryptionKey,
canUserCRUD,
} = useUserInfo();
+ const {
+ loading: listsConfigLoading,
+ needsConfiguration: needsListsConfiguration,
+ } = useListsConfig();
+ const initLoading = userInfoLoading || listsConfigLoading;
const { detailName: ruleId } = useParams();
const [loading, rule] = useRule(ruleId);
@@ -365,7 +371,14 @@ const EditRulePageComponent: FC = () => {
return null;
}
- if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) {
+ if (
+ redirectToDetections(
+ isSignalIndexExists,
+ isAuthenticated,
+ hasEncryptionKey,
+ needsListsConfiguration
+ )
+ ) {
history.replace(getDetectionEngineUrl());
return null;
} else if (userHasNoPermissions(canUserCRUD)) {
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx
index bf49ed5be90fb..6a98280076b30 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx
@@ -236,12 +236,13 @@ export const setFieldValue = (
export const redirectToDetections = (
isSignalIndexExists: boolean | null,
isAuthenticated: boolean | null,
- hasEncryptionKey: boolean | null
+ hasEncryptionKey: boolean | null,
+ needsListsConfiguration: boolean
) =>
- isSignalIndexExists != null &&
- isAuthenticated != null &&
- hasEncryptionKey != null &&
- (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey);
+ isSignalIndexExists === false ||
+ isAuthenticated === false ||
+ hasEncryptionKey === false ||
+ needsListsConfiguration;
export const getActionMessageRuleParams = (ruleType: RuleType): string[] => {
const commonRuleParamsKeys = [
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx
index f0ad670ddb665..9e30a735367b3 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx
@@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => {
};
});
+jest.mock('../../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../../../common/components/link_to');
jest.mock('../../../components/user_info');
jest.mock('../../../containers/detection_engine/rules');
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx
index 9cbc0e2aabfbe..0fce9e5ea3a44 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx
@@ -9,6 +9,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules';
+import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config';
import {
getDetectionEngineUrl,
getCreateRuleUrl,
@@ -21,6 +22,7 @@ import { useUserInfo } from '../../../components/user_info';
import { AllRules } from './all';
import { ImportDataModal } from '../../../../common/components/import_data_modal';
import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout';
+import { ValueListsModal } from '../../../components/value_lists_management_modal';
import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout';
import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers';
import * as i18n from './translations';
@@ -33,15 +35,23 @@ type Func = (refreshPrePackagedRule?: boolean) => void;
const RulesPageComponent: React.FC = () => {
const history = useHistory();
const [showImportModal, setShowImportModal] = useState(false);
+ const [isValueListsModalShown, setIsValueListsModalShown] = useState(false);
+ const showValueListsModal = useCallback(() => setIsValueListsModalShown(true), []);
+ const hideValueListsModal = useCallback(() => setIsValueListsModalShown(false), []);
const refreshRulesData = useRef(null);
const {
- loading,
+ loading: userInfoLoading,
isSignalIndexExists,
isAuthenticated,
hasEncryptionKey,
canUserCRUD,
hasIndexWrite,
} = useUserInfo();
+ const {
+ loading: listsConfigLoading,
+ needsConfiguration: needsListsConfiguration,
+ } = useListsConfig();
+ const loading = userInfoLoading || listsConfigLoading;
const {
createPrePackagedRules,
loading: prePackagedRuleLoading,
@@ -58,12 +68,12 @@ const RulesPageComponent: React.FC = () => {
isAuthenticated,
hasEncryptionKey,
});
+ const { formatUrl } = useFormatUrl(SecurityPageName.detections);
const prePackagedRuleStatus = getPrePackagedRuleStatus(
rulesInstalled,
rulesNotInstalled,
rulesNotUpdated
);
- const { formatUrl } = useFormatUrl(SecurityPageName.detections);
const handleRefreshRules = useCallback(async () => {
if (refreshRulesData.current != null) {
@@ -96,7 +106,14 @@ const RulesPageComponent: React.FC = () => {
[history]
);
- if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) {
+ if (
+ redirectToDetections(
+ isSignalIndexExists,
+ isAuthenticated,
+ hasEncryptionKey,
+ needsListsConfiguration
+ )
+ ) {
history.replace(getDetectionEngineUrl());
return null;
}
@@ -104,6 +121,7 @@ const RulesPageComponent: React.FC = () => {
return (
<>
{userHasNoPermissions(canUserCRUD) && }
+
setShowImportModal(false)}
@@ -154,6 +172,15 @@ const RulesPageComponent: React.FC = () => {
)}
+
+
+ {i18n.UPLOAD_VALUE_LISTS}
+
+
`${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, {
- tabName: ManagementSubTab.policies,
+ tabName: AdministrationSubTab.policies,
})}${appendSearch(search)}`;
export const getPolicyDetailPath = (policyId: string, search?: string) =>
`${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, {
- tabName: ManagementSubTab.policies,
+ tabName: AdministrationSubTab.policies,
policyId,
})}${appendSearch(search)}`;
diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts
new file mode 100644
index 0000000000000..70ccf715eaa09
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/management/common/translations.ts
@@ -0,0 +1,15 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const HOSTS_TAB = i18n.translate('xpack.securitySolution.hostsTab', {
+ defaultMessage: 'Hosts',
+});
+
+export const POLICIES_TAB = i18n.translate('xpack.securitySolution.policiesTab', {
+ defaultMessage: 'Policies',
+});
diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx
index 6486b1f3be6d1..fb9f97f3f7570 100644
--- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx
@@ -18,14 +18,21 @@ import {
EuiSelectableProps,
EuiIcon,
EuiLoadingSpinner,
+ EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
+import onboardingLogo from '../images/security_administration_onboarding.svg';
const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({
textAlign: 'center',
});
+const MAX_SIZE_ONBOARDING_LOGO: CSSProperties = Object.freeze({
+ maxWidth: 550,
+ maxHeight: 420,
+});
+
interface ManagementStep {
title: string;
children: JSX.Element;
@@ -45,7 +52,7 @@ const PolicyEmptyState = React.memo<{
) : (
-
+
@@ -55,26 +62,26 @@ const PolicyEmptyState = React.memo<{
/>
-
+
-
+
-
-
+
+
-
+
@@ -91,14 +98,14 @@ const PolicyEmptyState = React.memo<{