From 75b96ec6f9cd377b2a046e34d86976b98219891e Mon Sep 17 00:00:00 2001 From: Joel Griffith Date: Wed, 29 May 2019 10:31:45 -0700 Subject: [PATCH] [Feature/Reporting] Export Saved Search CSV as Dashboard Panel Action (#34571) --- .../dashboard/store/panel_actions_store.ts | 4 +- .../discover/embeddable/search_embeddable.ts | 4 + .../__snapshots__/index.test.js.snap | 4 + .../cancellation_token.ts} | 11 +- x-pack/plugins/reporting/common/constants.ts | 9 +- .../add_force_now_query_string.test.ts | 36 +- .../execute_job/add_force_now_query_string.ts | 10 +- .../execute_job/decrypt_job_headers.test.ts | 24 +- .../common/execute_job/decrypt_job_headers.ts | 4 +- .../get_conditional_headers.test.ts | 131 +++- .../execute_job/get_conditional_headers.ts | 4 +- .../execute_job/get_custom_logo.test.ts | 20 +- .../common/execute_job/get_custom_logo.ts | 5 +- .../omit_blacklisted_headers.test.ts | 10 +- .../execute_job/omit_blacklisted_headers.ts | 4 +- .../csv/server/__tests__/execute_job.js | 2 +- .../csv/server/lib/flatten_hit.js | 1 + .../csv_from_savedobject/index.d.ts | 150 ++++ .../csv_from_savedobject/index.ts | 14 + .../csv_from_savedobject/metadata.ts | 12 + .../server/create_job/create_job.ts | 96 +++ .../server/create_job/create_job_search.ts | 49 ++ .../server/create_job/index.ts | 7 + .../server/execute_job.ts | 102 +++ .../csv_from_savedobject/server/index.ts | 22 + .../server/lib/generate_csv.ts | 45 ++ .../server/lib/generate_csv_search.ts | 166 ++++ .../server/lib/get_data_source.ts | 59 ++ .../server/lib/get_filters.test.ts | 207 +++++ .../server/lib/get_filters.ts | 55 ++ .../server/lib/index.d.ts | 79 ++ .../csv_from_savedobject/server/lib/index.ts | 8 + .../printable_pdf/server/execute_job/index.js | 3 +- x-pack/plugins/reporting/index.js | 8 +- .../panel_actions/get_csv_panel_action.tsx | 154 ++++ .../chromium/driver/chromium_driver.ts | 1 + .../__tests__/helpers/cancellation_token.js | 11 +- .../reporting/server/lib/esqueue/worker.js | 2 +- .../server/lib/export_types_registry.js | 2 +- x-pack/plugins/reporting/server/lib/index.ts | 14 + .../routes/generate_from_savedobject.ts | 66 ++ .../generate_from_savedobject_immediate.ts | 77 ++ .../plugins/reporting/server/routes/index.ts | 23 +- .../routes/lib/get_job_params_from_request.ts | 24 + .../routes/lib/route_config_factories.ts | 23 + .../reporting/server/routes/types.d.ts | 10 + x-pack/plugins/reporting/types.d.ts | 88 ++- x-pack/scripts/functional_tests.js | 1 + x-pack/test/api_integration/config.js | 17 +- x-pack/test/functional/config.js | 2 + .../es_archives/reporting/logs/data.json.gz | Bin 0 -> 1342 bytes .../es_archives/reporting/logs/mappings.json | 302 +++++++ .../es_archives/reporting/sales/data.json.gz | Bin 0 -> 1719 bytes .../es_archives/reporting/sales/mappings.json | 334 ++++++++ .../reporting/scripted/data.json.gz | Bin 0 -> 30941 bytes .../reporting/scripted/mappings.json | 742 ++++++++++++++++++ .../api/generate/csv_saved_search.ts | 346 ++++++++ .../test/reporting/api/generate/fixtures.ts | 175 +++++ x-pack/test/reporting/api/generate/index.js | 12 + x-pack/test/reporting/configs/api.js | 4 + x-pack/test/reporting/configs/chromium_api.js | 1 + .../reporting/configs/chromium_functional.js | 1 + x-pack/test/reporting/configs/functional.js | 1 + x-pack/test/reporting/configs/generate_api.js | 32 + 64 files changed, 3760 insertions(+), 70 deletions(-) rename x-pack/plugins/reporting/{server/lib/esqueue/helpers/cancellation_token.js => common/cancellation_token.ts} (86%) create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/index.d.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/index.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/metadata.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job_search.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/index.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/index.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_data_source.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.test.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.d.ts create mode 100644 x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.ts create mode 100644 x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx create mode 100644 x-pack/plugins/reporting/server/lib/index.ts create mode 100644 x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts create mode 100644 x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts create mode 100644 x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts create mode 100644 x-pack/test/functional/es_archives/reporting/logs/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/logs/mappings.json create mode 100644 x-pack/test/functional/es_archives/reporting/sales/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/sales/mappings.json create mode 100644 x-pack/test/functional/es_archives/reporting/scripted/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/scripted/mappings.json create mode 100644 x-pack/test/reporting/api/generate/csv_saved_search.ts create mode 100644 x-pack/test/reporting/api/generate/fixtures.ts create mode 100644 x-pack/test/reporting/api/generate/index.js create mode 100644 x-pack/test/reporting/configs/generate_api.js diff --git a/src/legacy/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts b/src/legacy/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts index 449125d0ecfa4..69a9a93b1828a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts @@ -28,7 +28,9 @@ class PanelActionsStore { */ public initializeFromRegistry(panelActionsRegistry: ContextMenuAction[]) { panelActionsRegistry.forEach(panelAction => { - this.actions.push(panelAction); + if (!this.actions.includes(panelAction)) { + this.actions.push(panelAction); + } }); } } diff --git a/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable.ts b/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable.ts index c41092682283d..fe5b9994c574e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/discover/embeddable/search_embeddable.ts @@ -110,6 +110,10 @@ export class SearchEmbeddable extends Embeddable { return this.inspectorAdaptors; } + public getPanelTitle() { + return this.panelTitle; + } + public onContainerStateChanged(containerState: ContainerState) { this.customization = containerState.embeddableCustomization || {}; this.filters = containerState.filters; diff --git a/x-pack/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/plugins/reporting/__snapshots__/index.test.js.snap index f19785d5df0e8..ed5d0d236e603 100644 --- a/x-pack/plugins/reporting/__snapshots__/index.test.js.snap +++ b/x-pack/plugins/reporting/__snapshots__/index.test.js.snap @@ -25,6 +25,7 @@ Object { "zoom": 2, }, "csv": Object { + "enablePanelActionDownload": false, "maxSizeBytes": 10485760, "scroll": Object { "duration": "30s", @@ -85,6 +86,7 @@ Object { "zoom": 2, }, "csv": Object { + "enablePanelActionDownload": false, "maxSizeBytes": 10485760, "scroll": Object { "duration": "30s", @@ -144,6 +146,7 @@ Object { "zoom": 2, }, "csv": Object { + "enablePanelActionDownload": false, "maxSizeBytes": 10485760, "scroll": Object { "duration": "30s", @@ -204,6 +207,7 @@ Object { "zoom": 2, }, "csv": Object { + "enablePanelActionDownload": false, "maxSizeBytes": 10485760, "scroll": Object { "duration": "30s", diff --git a/x-pack/plugins/reporting/server/lib/esqueue/helpers/cancellation_token.js b/x-pack/plugins/reporting/common/cancellation_token.ts similarity index 86% rename from x-pack/plugins/reporting/server/lib/esqueue/helpers/cancellation_token.js rename to x-pack/plugins/reporting/common/cancellation_token.ts index 8d5633cd1c1d8..293431d9f1338 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/helpers/cancellation_token.js +++ b/x-pack/plugins/reporting/common/cancellation_token.ts @@ -7,12 +7,15 @@ import { isFunction } from 'lodash'; export class CancellationToken { + private isCancelled: boolean; + private _callbacks: any[]; + constructor() { this.isCancelled = false; this._callbacks = []; } - on = (callback) => { + on(callback: Function) { if (!isFunction(callback)) { throw new Error('Expected callback to be a function'); } @@ -23,10 +26,10 @@ export class CancellationToken { } this._callbacks.push(callback); - }; + } - cancel = () => { + cancel() { this.isCancelled = true; this._callbacks.forEach(callback => callback()); - }; + } } diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index cf2f6555a34a1..baca113331268 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -9,12 +9,16 @@ export const PLUGIN_ID = 'reporting'; export const JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY = 'xpack.reporting.jobCompletionNotifications'; -export const API_BASE_URL = '/api/reporting'; +export const API_BASE_URL = '/api/reporting'; // "Generation URL" from share menu +export const API_BASE_URL_V1 = '/api/reporting/v1'; // +export const API_BASE_GENERATE_V1 = `${API_BASE_URL_V1}/generate`; + +export const CONTENT_TYPE_CSV = 'text/csv'; export const WHITELISTED_JOB_CONTENT_TYPES = [ 'application/json', 'application/pdf', - 'text/csv', + CONTENT_TYPE_CSV, 'image/png', ]; @@ -41,4 +45,5 @@ export const KIBANA_REPORTING_TYPE = 'reporting'; export const PDF_JOB_TYPE = 'printable_pdf'; export const PNG_JOB_TYPE = 'PNG'; export const CSV_JOB_TYPE = 'csv'; +export const CSV_FROM_SAVEDOBJECT_JOB_TYPE = 'csv_from_savedobject'; export const USES_HEADLESS_JOB_TYPES = [PDF_JOB_TYPE, PNG_JOB_TYPE]; diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.test.ts b/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.test.ts index 815d0169be2af..117b72e59bf23 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.test.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.test.ts @@ -15,7 +15,15 @@ beforeEach(() => { test(`fails if no URL is passed`, async () => { await expect( addForceNowQuerystring({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, server: mockServer, }) ).rejects.toBeDefined(); @@ -24,7 +32,17 @@ test(`fails if no URL is passed`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const { urls } = await addForceNowQuerystring({ - job: { relativeUrl: '/app/kibana#/something', forceNow }, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + relativeUrl: '/app/kibana#/something', + forceNow, + }, server: mockServer, }); @@ -38,6 +56,13 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const { urls } = await addForceNowQuerystring({ job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, relativeUrl: '/app/kibana#/something?_g=something', forceNow, }, @@ -52,6 +77,13 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const { urls } = await addForceNowQuerystring({ job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, relativeUrl: '/app/kibana#/something', }, server: mockServer, diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.ts b/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.ts index f9bf39e495b92..fda91c51ef0c2 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/add_force_now_query_string.ts @@ -3,12 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore + import url from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; -import { ConditionalHeaders, KbnServer, ReportingJob } from '../../../types'; +import { ConditionalHeaders, JobDocPayload, KbnServer } from '../../../types'; -function getSavedObjectAbsoluteUrl(job: ReportingJob, relativeUrl: string, server: KbnServer) { +function getSavedObjectAbsoluteUrl(job: JobDocPayload, relativeUrl: string, server: KbnServer) { const getAbsoluteUrl: any = getAbsoluteUrlFactory(server); const { pathname: path, hash, search } = url.parse(relativeUrl); @@ -21,7 +21,7 @@ export const addForceNowQuerystring = async ({ logo, server, }: { - job: ReportingJob; + job: JobDocPayload; conditionalHeaders?: ConditionalHeaders; logo?: any; server: KbnServer; @@ -34,7 +34,7 @@ export const addForceNowQuerystring = async ({ job.urls = [getSavedObjectAbsoluteUrl(job, job.relativeUrl, server)]; } - const urls = job.urls.map(jobUrl => { + const urls = job.urls.map((jobUrl: string) => { if (!job.forceNow) { return jobUrl; } diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index a289093d2ebaa..7318beadd6a69 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -23,7 +23,17 @@ describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { await expect( decryptJobHeaders({ - job: { relativeUrl: '/app/kibana#/something', timeRange: {} }, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + relativeUrl: '/app/kibana#/something', + timeRange: {}, + }, server: mockServer, }) ).rejects.toBeDefined(); @@ -37,7 +47,17 @@ describe('headers', () => { const encryptedHeaders = await encryptHeaders(headers); const { decryptedHeaders } = await decryptJobHeaders({ - job: { relativeUrl: '/app/kibana#/something', headers: encryptedHeaders }, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + relativeUrl: '/app/kibana#/something', + headers: encryptedHeaders, + }, server: mockServer, }); expect(decryptedHeaders).toEqual(headers); diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index d10d8b51f84ec..e933056c79441 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -5,13 +5,13 @@ */ // @ts-ignore import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, KbnServer, ReportingJob } from '../../../types'; +import { CryptoFactory, JobDocPayload, KbnServer } from '../../../types'; export const decryptJobHeaders = async ({ job, server, }: { - job: ReportingJob; + job: JobDocPayload; server: KbnServer; }) => { const crypto: CryptoFactory = cryptoFactory(server); diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index 8106286de423a..768c25e548717 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -26,7 +26,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -43,7 +51,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -64,7 +80,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -81,7 +105,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -96,7 +128,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -119,7 +159,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -136,7 +184,15 @@ describe('conditions', () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -152,7 +208,15 @@ test('uses basePath from job when creating saved object service', async () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -162,7 +226,16 @@ test('uses basePath from job when creating saved object service', async () => { const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ - job: { basePath: jobBasePath }, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + basePath: jobBasePath, + }, conditionalHeaders, server: mockServer, }); @@ -179,7 +252,15 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); @@ -188,7 +269,15 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav mockServer.uiSettingsServiceFactory().get.mockReturnValue(logo); await getCustomLogo({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, conditionalHeaders, server: mockServer, }); @@ -202,7 +291,15 @@ describe('config formatting', () => { test(`lowercases server.host`, async () => { mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: {}, server: mockServer, }); @@ -214,7 +311,15 @@ describe('config formatting', () => { settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, }); const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: {}, server: mockServer, }); diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index 0da4b9892b7cd..04959712a117a 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,14 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders, ConfigObject, KbnServer, ReportingJob } from '../../../types'; +import { ConditionalHeaders, ConfigObject, JobDocPayload, KbnServer } from '../../../types'; export const getConditionalHeaders = ({ job, filteredHeaders, server, }: { - job: ReportingJob; + job: JobDocPayload; filteredHeaders: Record; server: KbnServer; }) => { diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index 8ab96af979cd8..029b641bd73e5 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -19,13 +19,29 @@ test(`gets logo from uiSettings`, async () => { }; const { conditionalHeaders } = await getConditionalHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, filteredHeaders: permittedHeaders, server: mockServer, }); const { logo } = await getCustomLogo({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, conditionalHeaders, server: mockServer, }); diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index 0b25187a938ae..b41e4c8218614 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -3,15 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ConditionalHeaders, KbnServer, ReportingJob } from '../../../types'; +import { ConditionalHeaders, JobDocPayload, KbnServer } from '../../../types'; export const getCustomLogo = async ({ job, conditionalHeaders, server, }: { - job: ReportingJob; + job: JobDocPayload; conditionalHeaders: ConditionalHeaders; server: KbnServer; }) => { diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.test.ts b/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.test.ts index 5ee21d89aed56..c030fc4c662cb 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.test.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.test.ts @@ -27,7 +27,15 @@ test(`omits blacklisted headers`, async () => { }; const { filteredHeaders } = await omitBlacklistedHeaders({ - job: {}, + job: { + title: 'cool-job-bro', + type: 'csv', + jobParams: { + savedObjectId: 'abc-123', + isImmediate: false, + savedObjectType: 'search', + }, + }, decryptedHeaders: { ...permittedHeaders, ...blacklistedHeaders, diff --git a/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.ts b/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.ts index a334d57a73f68..44a9ed5a8ee51 100644 --- a/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.ts +++ b/x-pack/plugins/reporting/export_types/common/execute_job/omit_blacklisted_headers.ts @@ -5,14 +5,14 @@ */ import { omit } from 'lodash'; import { KBN_SCREENSHOT_HEADER_BLACKLIST } from '../../../common/constants'; -import { KbnServer, ReportingJob } from '../../../types'; +import { JobDocPayload, KbnServer } from '../../../types'; export const omitBlacklistedHeaders = ({ job, decryptedHeaders, server, }: { - job: ReportingJob; + job: JobDocPayload; decryptedHeaders: Record; server: KbnServer; }) => { diff --git a/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js b/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js index b8799f7a516d9..5ef2a897d10fd 100644 --- a/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js +++ b/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js @@ -9,7 +9,7 @@ import Puid from 'puid'; import sinon from 'sinon'; import nodeCrypto from '@elastic/node-crypto'; -import { CancellationToken } from '../../../../server/lib/esqueue/helpers/cancellation_token'; +import { CancellationToken } from '../../../../common/cancellation_token'; import { FieldFormat } from '../../../../../../../src/legacy/ui/field_formats/field_format.js'; import { FieldFormatsService } from '../../../../../../../src/legacy/ui/field_formats/field_formats_service.js'; import { createStringFormat } from '../../../../../../../src/legacy/core_plugins/kibana/common/field_formats/types/string.js'; diff --git a/x-pack/plugins/reporting/export_types/csv/server/lib/flatten_hit.js b/x-pack/plugins/reporting/export_types/csv/server/lib/flatten_hit.js index 38bcd98aa1de4..375b096e32651 100644 --- a/x-pack/plugins/reporting/export_types/csv/server/lib/flatten_hit.js +++ b/x-pack/plugins/reporting/export_types/csv/server/lib/flatten_hit.js @@ -6,6 +6,7 @@ import _ from 'lodash'; +// TODO this logic should be re-used with Discover export function createFlattenHit(fields, metaFields, conflictedTypesFields) { const flattenSource = (flat, obj, keyPrefix) => { keyPrefix = keyPrefix ? keyPrefix + '.' : ''; diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/index.d.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/index.d.ts new file mode 100644 index 0000000000000..3f35f7e237f5b --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/index.d.ts @@ -0,0 +1,150 @@ +/* + * 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 SavedObjectServiceError { + statusCode: number; + error?: string; + message?: string; +} + +export interface SavedObjectMetaJSON { + searchSourceJSON: string; +} + +export interface SavedObjectMeta { + searchSource: SearchSource; +} + +export interface SavedSearchObjectAttributesJSON { + title: string; + sort: any[]; + columns: string[]; + kibanaSavedObjectMeta: SavedObjectMetaJSON; + uiState: any; +} + +export interface SavedSearchObjectAttributes { + title: string; + sort: any[]; + columns?: string[]; + kibanaSavedObjectMeta: SavedObjectMeta; + uiState: any; +} + +export interface VisObjectAttributesJSON { + title: string; + visState: string; // JSON string + type: string; + params: any; + uiStateJSON: string; // also JSON string + aggs: any[]; + sort: any[]; + kibanaSavedObjectMeta: SavedObjectMeta; +} + +export interface VisObjectAttributes { + title: string; + visState: string; // JSON string + type: string; + params: any; + uiState: { + vis: { + params: { + sort: { + columnIndex: string; + direction: string; + }; + }; + }; + }; + aggs: any[]; + sort: any[]; + kibanaSavedObjectMeta: SavedObjectMeta; +} + +export interface SavedObjectReference { + name: string; // should be kibanaSavedObjectMeta.searchSourceJSON.index + type: string; // should be index-pattern + id: string; +} + +export interface SavedObject { + attributes: any; + 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 + - x-pack/plugins/reporting/export_types/csv/server/lib/field_format_map + 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 TimeRangeParams { + timezone: string; + min: Date | string | number; + max: Date | string | number; +} + +export interface VisPanel { + indexPatternSavedObjectId?: string; + savedSearchObjectId?: string; + attributes: VisObjectAttributes; + timerange: TimeRangeParams; +} + +export interface SearchPanel { + indexPatternSavedObjectId: string; + attributes: SavedSearchObjectAttributes; + timerange: TimeRangeParams; +} + +export interface SearchSourceQuery { + isSearchSourceQuery: boolean; +} + +export interface SearchSource { + query: SearchSourceQuery; + filter: any[]; +} + +export 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; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/index.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/index.ts new file mode 100644 index 0000000000000..6cc56cb5fa4f1 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/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. + */ + +export * from './index.d'; + +/* + * These functions are exported to share with the API route handler that + * generates csv from saved object immediately on request. + */ +export { executeJobFactory } from './server/execute_job'; +export { createJobFactory } from './server/create_job'; diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/metadata.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/metadata.ts new file mode 100644 index 0000000000000..fcef889e52fe4 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/metadata.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. + */ + +import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; + +export const metadata = { + id: CSV_FROM_SAVEDOBJECT_JOB_TYPE, + name: CSV_FROM_SAVEDOBJECT_JOB_TYPE, +}; diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts new file mode 100644 index 0000000000000..3ba54473474ee --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -0,0 +1,96 @@ +/* + * 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 { notFound, notImplemented } from 'boom'; +import { Request } from 'hapi'; +import { get } from 'lodash'; + +import { cryptoFactory, LevelLogger, oncePerServer } from '../../../../server/lib'; +import { JobDocPayload, JobParams, KbnServer } from '../../../../types'; +import { + SavedObject, + SavedObjectServiceError, + SavedSearchObjectAttributesJSON, + SearchPanel, + TimeRangeParams, + VisObjectAttributesJSON, +} from '../../'; +import { createJobSearch } from './create_job_search'; + +interface VisData { + title: string; + visType: string; + panel: SearchPanel; +} + +type CreateJobFn = (jobParams: JobParams, headers: any, req: Request) => Promise; + +function createJobFn(server: KbnServer): CreateJobFn { + const crypto = cryptoFactory(server); + const logger = LevelLogger.createForServer(server, ['reporting', 'savedobject-csv']); + + return async function createJob( + jobParams: JobParams, + headers: any, + req: Request + ): Promise { + const { savedObjectType, savedObjectId } = jobParams; + const serializedEncryptedHeaders = await crypto.encrypt(headers); + const client = req.getSavedObjectsClient(); + + const { panel, title, visType }: VisData = await Promise.resolve() + .then(() => client.get(savedObjectType, savedObjectId)) + .then(async (savedObject: SavedObject) => { + const { attributes, references } = savedObject; + const { + kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON, + } = attributes as SavedSearchObjectAttributesJSON; + const { timerange } = req.payload as { timerange: TimeRangeParams }; + + if (!kibanaSavedObjectMetaJSON) { + throw new Error('Could not parse saved object data!'); + } + + const kibanaSavedObjectMeta = { + ...kibanaSavedObjectMetaJSON, + searchSource: JSON.parse(kibanaSavedObjectMetaJSON.searchSourceJSON), + }; + + const { visState: visStateJSON } = attributes as VisObjectAttributesJSON; + if (visStateJSON) { + throw notImplemented('Visualization types are not yet implemented'); + } + + // saved search type + return await createJobSearch(timerange, attributes, references, kibanaSavedObjectMeta); + }) + .catch((err: Error) => { + const boomErr = (err as unknown) as { isBoom: boolean }; + if (boomErr.isBoom) { + throw err; + } + const errPayload: SavedObjectServiceError = get(err, 'output.payload', { statusCode: 0 }); + if (errPayload.statusCode === 404) { + throw notFound(errPayload.message); + } + if (err.stack) { + logger.error(err.stack); + } + throw new Error(`Unable to create a job from saved object data! Error: ${err}`); + }); + + return { + basePath: req.getBasePath(), + headers: serializedEncryptedHeaders, + jobParams: { ...jobParams, panel, visType }, + type: null, // resolved in executeJob + objects: null, // resolved in executeJob + title, + }; + }; +} + +export const createJobFactory = oncePerServer(createJobFn); diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job_search.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job_search.ts new file mode 100644 index 0000000000000..c98a0f965aa99 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job_search.ts @@ -0,0 +1,49 @@ +/* + * 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 { + SavedObjectMeta, + SavedObjectReference, + SavedSearchObjectAttributes, + SearchPanel, + TimeRangeParams, +} from '../../'; + +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/export_types/csv_from_savedobject/server/create_job/index.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/index.ts new file mode 100644 index 0000000000000..e129e3e5f47ec --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/create_job/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 { createJobFactory } from './create_job'; diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts new file mode 100644 index 0000000000000..67bcea92785e1 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -0,0 +1,102 @@ +/* + * 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 { Request } from 'hapi'; +import { i18n } from '@kbn/i18n'; + +import { cryptoFactory, LevelLogger, oncePerServer } from '../../../server/lib'; +import { JobDocOutputExecuted, JobDocPayload, KbnServer } from '../../../types'; +import { CONTENT_TYPE_CSV } from '../../../common/constants'; +import { CsvResultFromSearch, createGenerateCsv } from './lib'; + +interface FakeRequest { + headers: any; + getBasePath: (opts: any) => string; + server: KbnServer; +} + +type ExecuteJobFn = (job: JobDocPayload, realRequest?: Request) => Promise; + +function executeJobFn(server: KbnServer): ExecuteJobFn { + const crypto = cryptoFactory(server); + const config = server.config(); + const serverBasePath = config.get('server.basePath'); + const logger = LevelLogger.createForServer(server, ['reporting', 'savedobject-csv']); + const generateCsv = createGenerateCsv(logger); + + return async function executeJob( + job: JobDocPayload, + realRequest?: Request + ): Promise { + const { basePath, jobParams } = job; + const { isImmediate, panel, visType } = jobParams; + + logger.debug(`Execute job generating [${visType}] csv`); + + let requestObject: Request | FakeRequest; + if (isImmediate && realRequest) { + logger.debug(`executing job from immediate API`); + requestObject = realRequest; + } else { + logger.debug(`executing job async using encrypted headers`); + let decryptedHeaders; + const serializedEncryptedHeaders = job.headers; + try { + decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); + } catch (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 }, + } + ) + ); + } + + requestObject = { + headers: decryptedHeaders, + getBasePath: () => basePath || serverBasePath, + server, + }; + } + + let content: string; + let maxSizeReached = false; + let size = 0; + try { + const generateResults: CsvResultFromSearch = await generateCsv( + requestObject, + server, + visType as string, + panel, + jobParams + ); + + ({ + result: { content, maxSizeReached, size }, + } = generateResults); + } catch (err) { + logger.error(`Generate CSV Error! ${err}`); + throw err; + } + + if (maxSizeReached) { + logger.warn(`Max size reached: CSV output truncated to ${size} bytes`); + } + + return { + content_type: CONTENT_TYPE_CSV, + content, + max_size_reached: maxSizeReached, + size, + }; + }; +} + +export const executeJobFactory = oncePerServer(executeJobFn); diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/index.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/index.ts new file mode 100644 index 0000000000000..b614fd3c681b3 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; +import { ExportTypesRegistry } from '../../../types'; +import { metadata } from '../metadata'; +import { createJobFactory } from './create_job'; +import { executeJobFactory } from './execute_job'; + +export function register(registry: ExportTypesRegistry) { + registry.register({ + ...metadata, + jobType: CSV_FROM_SAVEDOBJECT_JOB_TYPE, + jobContentExtension: 'csv', + createJobFactory, + executeJobFactory, + validLicenses: ['trial', 'basic', 'standard', 'gold', 'platinum'], + }); +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv.ts new file mode 100644 index 0000000000000..cf2d621b7d201 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv.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 { badRequest } from 'boom'; +import { Request } from 'hapi'; +import { KbnServer, Logger, JobParams } from '../../../../types'; +import { SearchPanel, VisPanel } from '../../'; +import { generateCsvSearch } from './generate_csv_search'; + +interface FakeRequest { + headers: any; + getBasePath: (opts: any) => string; + server: KbnServer; +} + +export function createGenerateCsv(logger: Logger) { + return async function generateCsv( + request: Request | FakeRequest, + server: KbnServer, + visType: string, + panel: VisPanel | SearchPanel, + jobParams: JobParams + ) { + // 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( + request as Request, + server, + logger, + panel as SearchPanel, + jobParams + ); + default: + throw badRequest(`Unsupported or unrecognized saved object type: ${visType}`); + } + }; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts new file mode 100644 index 0000000000000..9d0b588b02da7 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts @@ -0,0 +1,166 @@ +/* + * 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 { Request } from 'hapi'; + +// @ts-ignore no module definition +import { buildEsQuery } from '@kbn/es-query'; +// @ts-ignore no module definition +import { createGenerateCsv } from '../../../csv/server/lib/generate_csv'; + +import { CancellationToken } from '../../../../common/cancellation_token'; + +import { KbnServer, Logger, JobParams } from '../../../../types'; +import { + IndexPatternSavedObject, + SavedSearchObjectAttributes, + SearchPanel, + SearchRequest, + SearchSource, + SearchSourceQuery, +} from '../../'; +import { + CsvResultFromSearch, + ESQueryConfig, + GenerateCsvParams, + Filter, + IndexPatternField, + QueryFilter, +} from './'; +import { getDataSource } from './get_data_source'; +import { getFilters } from './get_filters'; + +const getEsQueryConfig = async (config: any) => { + 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 }; +}; + +const getUiSettings = async (config: any) => { + const configs = await Promise.all([config.get('csv:separator'), config.get('csv:quoteValues')]); + const [separator, quoteValues] = configs; + return { separator, quoteValues }; +}; + +export async function generateCsvSearch( + req: Request, + server: KbnServer, + logger: Logger, + searchPanel: SearchPanel, + jobParams: JobParams +): Promise { + const { savedObjects, uiSettingsServiceFactory } = server; + const savedObjectsClient = savedObjects.getScopedSavedObjectsClient(req); + const { indexPatternSavedObjectId, timerange } = searchPanel; + const savedSearchObjectAttr = searchPanel.attributes as SavedSearchObjectAttributes; + const { indexPatternSavedObject } = await getDataSource( + savedObjectsClient, + indexPatternSavedObjectId + ); + const uiConfig = uiSettingsServiceFactory({ 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[] = []; + if (jobParams.post && jobParams.post.state) { + ({ + post: { state: { query: payloadQuery, sort: payloadSort = [] } }, + } = jobParams); + } + + const { includes, timezone, combinedFilter } = getFilters( + indexPatternSavedObjectId, + indexPatternTimeField, + timerange, + savedSearchObjectAttr, + searchSourceFilter, + payloadQuery + ); + + const [savedSortField, savedSortOrder] = savedSearchObjectAttr.sort; + const sortConfig = [...payloadSort, { [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, + }, + }, + }; + }, {}); + const docValueFields = indexPatternTimeField ? [indexPatternTimeField] : undefined; + + // this array helps ensure the params are passed to buildEsQuery (non-Typescript) in the right order + const buildCsvParams: [IndexPatternSavedObject, SearchSourceQuery, Filter[], ESQueryConfig] = [ + indexPatternSavedObject, + searchSourceQuery, + combinedFilter, + esQueryConfig, + ]; + + const searchRequest: SearchRequest = { + index: esIndex, + body: { + _source: { includes }, + docvalue_fields: docValueFields, + query: buildEsQuery(...buildCsvParams), + script_fields: scriptFieldsConfig, + sort: sortConfig, + }, + }; + + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + const callCluster = (...params: any[]) => callWithRequest(req, ...params); + const config = server.config(); + 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('xpack.reporting.csv.maxSizeBytes'), + scroll: config.get('xpack.reporting.csv.scroll'), + timezone, + }, + }; + + const generateCsv = createGenerateCsv(logger); + + return { + type: 'CSV from Saved Search', + result: await generateCsv(generateCsvParams), + }; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_data_source.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_data_source.ts new file mode 100644 index 0000000000000..920c55da4f83d --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_data_source.ts @@ -0,0 +1,59 @@ +/* + * 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 { + IndexPatternSavedObject, + SavedObjectReference, + SavedSearchObjectAttributesJSON, + SearchSource, +} from '../../'; + +export async function getDataSource( + savedObjectsClient: any, + indexPatternId?: string, + savedSearchObjectId?: string +): Promise<{ + indexPatternSavedObject: IndexPatternSavedObject; + searchSource: SearchSource | null; +}> { + let indexPatternSavedObject: IndexPatternSavedObject; + let searchSource: SearchSource | null = null; + + if (savedSearchObjectId) { + try { + const { attributes, references } = (await savedObjectsClient.get( + 'search', + savedSearchObjectId + )) as { attributes: SavedSearchObjectAttributesJSON; references: SavedObjectReference[] }; + searchSource = JSON.parse(attributes.kibanaSavedObjectMeta.searchSourceJSON); + const { id: indexPatternFromSearchId } = references.find( + ({ type }) => type === 'index-pattern' + ) as { id: string }; + ({ indexPatternSavedObject } = await getDataSource( + savedObjectsClient, + indexPatternFromSearchId + )); + return { searchSource, indexPatternSavedObject }; + } catch (err) { + throw new Error(`Could not get saved search info! ${err}`); + } + } + try { + const { attributes } = await savedObjectsClient.get('index-pattern', indexPatternId); + const { fields, title, timeFieldName } = attributes; + const parsedFields = fields ? JSON.parse(fields) : []; + + indexPatternSavedObject = { + fields: parsedFields, + title, + timeFieldName, + attributes, + }; + } catch (err) { + throw new Error(`Could not get index pattern saved object! ${err}`); + } + return { indexPatternSavedObject, searchSource }; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.test.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.test.ts new file mode 100644 index 0000000000000..e87cfc212d404 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.test.ts @@ -0,0 +1,207 @@ +/* + * 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 { SavedSearchObjectAttributes, TimeRangeParams } from '../../'; +import { QueryFilter, SearchSourceFilter } from './'; +import { getFilters } from './get_filters'; + +interface Args { + indexPatternId: string; + indexPatternTimeField: string | null; + timerange: TimeRangeParams | null; + savedSearchObjectAttr: SavedSearchObjectAttributes; + searchSourceFilter: SearchSourceFilter; + queryFilter: QueryFilter; +} + +describe('CSV from Saved Object: get_filters', () => { + let args: Args; + beforeEach(() => { + args = { + indexPatternId: 'logs-test-*', + indexPatternTimeField: 'testtimestamp', + timerange: { + timezone: 'UTC', + min: '1901-01-01T00:00:00.000Z', + max: '1902-01-01T00:00:00.000Z', + }, + savedSearchObjectAttr: { + title: 'test', + sort: [{ sortField: { order: 'asc' } }], + kibanaSavedObjectMeta: { + searchSource: { + query: { isSearchSourceQuery: true }, + filter: ['hello searchSource filter 1'], + }, + }, + columns: ['larry'], + uiState: null, + }, + searchSourceFilter: { isSearchSourceFilter: true, isFilter: true }, + queryFilter: { isQueryFilter: true, isFilter: true }, + }; + }); + + describe('search', () => { + it('for timebased search', () => { + const filters = getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr, + args.searchSourceFilter, + args.queryFilter + ); + + expect(filters).toEqual({ + combinedFilter: [ + { + range: { + testtimestamp: { + format: 'strict_date_time', + gte: '1901-01-01T00:00:00Z', + lte: '1902-01-01T00:00:00Z', + }, + }, + }, + { isFilter: true, isSearchSourceFilter: true }, + { isFilter: true, isQueryFilter: true }, + ], + includes: ['testtimestamp', 'larry'], + timezone: 'UTC', + }); + }); + + it('for non-timebased search', () => { + args.indexPatternTimeField = null; + args.timerange = null; + + const filters = getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr, + args.searchSourceFilter, + args.queryFilter + ); + + expect(filters).toEqual({ + combinedFilter: [ + { isFilter: true, isSearchSourceFilter: true }, + { isFilter: true, isQueryFilter: true }, + ], + includes: ['larry'], + timezone: null, + }); + }); + }); + + describe('errors', () => { + it('throw if timebased and timerange is missing', () => { + args.timerange = null; + + const throwFn = () => + getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr, + args.searchSourceFilter, + args.queryFilter + ); + + expect(throwFn).toThrow( + 'Time range params are required for index pattern [logs-test-*], using time field [testtimestamp]' + ); + }); + }); + + it('composes the defined filters', () => { + expect( + getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr, + undefined, + undefined + ) + ).toEqual({ + combinedFilter: [ + { + range: { + testtimestamp: { + format: 'strict_date_time', + gte: '1901-01-01T00:00:00Z', + lte: '1902-01-01T00:00:00Z', + }, + }, + }, + ], + includes: ['testtimestamp', 'larry'], + timezone: 'UTC', + }); + + expect( + getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr, + undefined, + args.queryFilter + ) + ).toEqual({ + combinedFilter: [ + { + range: { + testtimestamp: { + format: 'strict_date_time', + gte: '1901-01-01T00:00:00Z', + lte: '1902-01-01T00:00:00Z', + }, + }, + }, + { isFilter: true, isQueryFilter: true }, + ], + includes: ['testtimestamp', 'larry'], + timezone: 'UTC', + }); + }); + + describe('timefilter', () => { + it('formats the datetime to the provided timezone', () => { + args.timerange = { + timezone: 'MST', + min: '1901-01-01T00:00:00Z', + max: '1902-01-01T00:00:00Z', + }; + + expect( + getFilters( + args.indexPatternId, + args.indexPatternTimeField, + args.timerange, + args.savedSearchObjectAttr + ) + ).toEqual({ + combinedFilter: [ + { + range: { + testtimestamp: { + format: 'strict_date_time', + gte: '1900-12-31T17:00:00-07:00', + lte: '1901-12-31T17:00:00-07:00', + }, + }, + }, + ], + includes: ['testtimestamp', 'larry'], + timezone: 'MST', + }); + }); + }); +}); diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts new file mode 100644 index 0000000000000..bb3c6b8283f33 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.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 { badRequest } from 'boom'; +import moment from 'moment-timezone'; + +import { SavedSearchObjectAttributes, TimeRangeParams } from '../../'; +import { QueryFilter, Filter, SearchSourceFilter } from './'; + +export function getFilters( + indexPatternId: string, + indexPatternTimeField: string | null, + timerange: TimeRangeParams | null, + savedSearchObjectAttr: SavedSearchObjectAttributes, + searchSourceFilter?: SearchSourceFilter, + queryFilter?: QueryFilter +) { + let includes: string[]; + let timeFilter: any | null; + let timezone: string | null; + + if (indexPatternTimeField) { + if (!timerange) { + throw badRequest( + `Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]` + ); + } + + timezone = timerange.timezone; + const { min: gte, max: lte } = timerange; + timeFilter = { + range: { + [indexPatternTimeField]: { + format: 'strict_date_time', + gte: moment.tz(moment(gte), timezone).format(), + lte: moment.tz(moment(lte), timezone).format(), + }, + }, + }; + + const savedSearchCols = savedSearchObjectAttr.columns || []; + includes = [indexPatternTimeField, ...savedSearchCols]; + } else { + includes = savedSearchObjectAttr.columns || []; + timeFilter = null; + timezone = null; + } + + const combinedFilter: Filter[] = [timeFilter, searchSourceFilter, queryFilter].filter(Boolean); // builds an array of defined filters + + return { timezone, combinedFilter, includes }; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.d.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.d.ts new file mode 100644 index 0000000000000..183a9c29da0c3 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.d.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 { CancellationToken } from '../../../../common/cancellation_token'; +import { SavedSearchObjectAttributes, SearchPanel, SearchRequest, SearchSource } from '../../'; + +export interface SavedSearchGeneratorResult { + content: string; + maxSizeReached: boolean; + size: number; +} + +export interface CsvResultFromSearch { + type: string; + result: SavedSearchGeneratorResult; +} + +type EndpointCaller = (method: string, params: any) => Promise; +type FormatsMap = Map< + string, + { + id: string; + params: { + pattern: string; + }; + } +>; + +export interface GenerateCsvParams { + searchRequest: SearchRequest; + callEndpoint: EndpointCaller; + fields: string[]; + formatsMap: FormatsMap; + metaFields: string[]; // FIXME not sure what this is for + conflictedTypesFields: string[]; // FIXME not sure what this is for + cancellationToken: CancellationToken; + settings: { + separator: string; + quoteValues: boolean; + timezone: string | null; + maxSizeBytes: number; + scroll: { duration: string; size: number }; + }; +} + +/* + * These filter types are stub types to help ensure things get passed to + * non-Typescript functions in the right order. An actual structure is not + * needed because the code doesn't look into the properties; just combines them + * and passes them through to other non-TS modules. + */ +export interface Filter { + isFilter: boolean; +} +export interface TimeFilter extends Filter { + isTimeFilter: boolean; +} +export interface QueryFilter extends Filter { + isQueryFilter: boolean; +} +export interface SearchSourceFilter extends Filter { + isSearchSourceFilter: boolean; +} + +export interface ESQueryConfig { + allowLeadingWildcards: boolean; + queryStringOptions: boolean; + ignoreFilterIfFieldNotInIndex: boolean; +} + +export interface IndexPatternField { + scripted: boolean; + lang?: string; + script?: string; + name: string; +} diff --git a/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.ts b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.ts new file mode 100644 index 0000000000000..048cf5b457794 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/csv_from_savedobject/server/lib/index.ts @@ -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 './index.d'; +export { createGenerateCsv } from './generate_csv'; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js index f128ae7c97f34..95100836d134d 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js @@ -58,7 +58,8 @@ function executeJobFn(server) { })) ); - const stop$ = Rx.fromEventPattern(cancellationToken.on); + const boundCancelOn = cancellationToken.on.bind(cancellationToken); + const stop$ = Rx.fromEventPattern(boundCancelOn); return process$.pipe(takeUntil(stop$)).toPromise(); }); diff --git a/x-pack/plugins/reporting/index.js b/x-pack/plugins/reporting/index.js index d7837528034e0..a73d674017744 100644 --- a/x-pack/plugins/reporting/index.js +++ b/x-pack/plugins/reporting/index.js @@ -36,12 +36,17 @@ export const reporting = (kibana) => { 'plugins/reporting/share_context_menu/register_csv_reporting', 'plugins/reporting/share_context_menu/register_reporting', ], + contextMenuActions: [ + 'plugins/reporting/panel_actions/get_csv_panel_action', + ], hacks: ['plugins/reporting/hacks/job_completion_notifier'], home: ['plugins/reporting/register_feature'], managementSections: ['plugins/reporting/views/management'], injectDefaultVars(server, options) { + const config = server.config(); return { - reportingPollConfig: options.poll + reportingPollConfig: options.poll, + enablePanelActionDownload: config.get('xpack.reporting.csv.enablePanelActionDownload'), }; }, uiSettingDefaults: { @@ -117,6 +122,7 @@ export const reporting = (kibana) => { }).default() }).default(), csv: Joi.object({ + enablePanelActionDownload: Joi.boolean().default(false), maxSizeBytes: Joi.number().integer().default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 scroll: Joi.object({ duration: Joi.string().regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }).default('30s'), diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx new file mode 100644 index 0000000000000..939aef9586cc1 --- /dev/null +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -0,0 +1,154 @@ +/* + * 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 dateMath from '@elastic/datemath'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment-timezone'; + +import { ContextMenuAction, ContextMenuActionsRegistryProvider } from 'ui/embeddable'; +import { PanelActionAPI } from 'ui/embeddable/context_menu_actions/types'; +import { kfetch } from 'ui/kfetch'; +import { toastNotifications } from 'ui/notify'; +import chrome from 'ui/chrome'; +import { API_BASE_URL_V1 } from '../../common/constants'; + +const API_BASE_URL = `${API_BASE_URL_V1}/generate/immediate/csv/saved-object`; + +class GetCsvReportPanelAction extends ContextMenuAction { + private isDownloading: boolean; + + constructor() { + super( + { + id: 'downloadCsvReport', + parentPanelId: 'mainMenu', + }, + { + icon: 'document', + getDisplayName: () => + i18n.translate('xpack.reporting.dashboard.downloadCsvPanelTitle', { + defaultMessage: 'Download CSV', + }), + } + ); + + this.isDownloading = false; + } + + public async getSearchRequestBody({ searchEmbeddable }: { searchEmbeddable: any }) { + const adapters = searchEmbeddable.getInspectorAdapters(); + if (!adapters) { + return {}; + } + + if (adapters.requests.requests.length === 0) { + return {}; + } + + return searchEmbeddable.searchScope.searchSource.getSearchRequestBody(); + } + + public isVisible = (panelActionAPI: PanelActionAPI): boolean => { + const enablePanelActionDownload = chrome.getInjected('enablePanelActionDownload'); + + if (!enablePanelActionDownload) { + return false; + } + + const { embeddable, containerState } = panelActionAPI; + + return ( + containerState.viewMode !== 'edit' && !!embeddable && embeddable.hasOwnProperty('savedSearch') + ); + }; + + public onClick = async (panelActionAPI: PanelActionAPI) => { + const { embeddable } = panelActionAPI as any; + const { + timeRange: { from, to }, + } = embeddable; + + if (!embeddable || this.isDownloading) { + return; + } + + const searchEmbeddable = embeddable; + const searchRequestBody = await this.getSearchRequestBody({ searchEmbeddable }); + const state = _.pick(searchRequestBody, ['sort', 'docvalue_fields', 'query']); + const kibanaTimezone = chrome.getUiSettingsClient().get('dateFormat:tz'); + + const id = `search:${embeddable.savedSearch.id}`; + const filename = embeddable.getPanelTitle(); + const timezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; + const fromTime = dateMath.parse(from); + const toTime = dateMath.parse(to); + + if (!fromTime || !toTime) { + return this.onGenerationFail( + new Error(`Invalid time range: From: ${fromTime}, To: ${toTime}`) + ); + } + + const body = JSON.stringify({ + timerange: { + min: fromTime.format(), + max: toTime.format(), + timezone, + }, + state, + }); + + this.isDownloading = true; + + toastNotifications.addSuccess({ + title: i18n.translate('xpack.reporting.dashboard.csvDownloadStartedTitle', { + defaultMessage: `CSV Download Started`, + }), + text: i18n.translate('xpack.reporting.dashboard.csvDownloadStartedMessage', { + defaultMessage: `Your CSV will download momentarily.`, + }), + 'data-test-subj': 'csvDownloadStarted', + }); + + await kfetch({ method: 'POST', pathname: `${API_BASE_URL}/${id}`, body }) + .then((rawResponse: string) => { + this.isDownloading = false; + + const download = `${filename}.csv`; + const blob = new Blob([rawResponse], { type: 'text/csv;charset=utf-8;' }); + + // Hack for IE11 Support + if (window.navigator.msSaveOrOpenBlob) { + return window.navigator.msSaveOrOpenBlob(blob, download); + } + + const a = window.document.createElement('a'); + const downloadObject = window.URL.createObjectURL(blob); + + a.href = downloadObject; + a.download = download; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(downloadObject); + document.body.removeChild(a); + }) + .catch(this.onGenerationFail.bind(this)); + }; + + private onGenerationFail(error: Error) { + this.isDownloading = false; + toastNotifications.addDanger({ + title: i18n.translate('xpack.reporting.dashboard.failedCsvDownloadTitle', { + defaultMessage: `CSV download failed`, + }), + text: i18n.translate('xpack.reporting.dashboard.failedCsvDownloadMessage', { + defaultMessage: `We couldn't generate your CSV at this time.`, + }), + 'data-test-subj': 'downloadCsvFail', + }); + } +} + +ContextMenuActionsRegistryProvider.register(() => new GetCsvReportPanelAction()); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 4bdd35ca381f2..f1b8936f412c5 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -33,6 +33,7 @@ export class HeadlessChromiumDriver { constructor(page: Chrome.Page, { logger }: ChromiumDriverOptions) { this.page = page; + // @ts-ignore https://github.com/elastic/kibana/issues/32140 this.logger = logger.clone(['headless-chromium-driver']); } diff --git a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/cancellation_token.js b/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/cancellation_token.js index b40765810fb63..9a7c263a76f08 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/cancellation_token.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/__tests__/helpers/cancellation_token.js @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import { CancellationToken } from '../../helpers/cancellation_token'; +import { CancellationToken } from '../../../../../common/cancellation_token'; describe('CancellationToken', function () { let cancellationToken; @@ -17,12 +17,14 @@ describe('CancellationToken', function () { describe('on', function () { [true, null, undefined, 1, 'string', {}, []].forEach(function (value) { it(`should throw an Error if value is ${value}`, function () { - expect(cancellationToken.on).withArgs(value).to.throwError(); + const boundOn = cancellationToken.on.bind(cancellationToken); + expect(boundOn).withArgs(value).to.throwError(); }); }); it('accepts a function', function () { - expect(cancellationToken.on).withArgs(function () {}).to.not.throwError(); + const boundOn = cancellationToken.on.bind(cancellationToken); + expect(boundOn).withArgs(function () {}).not.to.throwError(); }); it(`calls function if cancel has previously been called`, function () { @@ -35,7 +37,8 @@ describe('CancellationToken', function () { describe('cancel', function () { it('should be a function accepting no parameters', function () { - expect(cancellationToken.cancel).withArgs().to.not.throwError(); + const boundCancel = cancellationToken.cancel.bind(cancellationToken); + expect(boundCancel).withArgs().to.not.throwError(); }); it('should call a single callback', function () { diff --git a/x-pack/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/plugins/reporting/server/lib/esqueue/worker.js index 0670b72ad99cb..c71ec211804cd 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/worker.js @@ -9,7 +9,7 @@ import Puid from 'puid'; import moment from 'moment'; import { constants } from './constants'; import { WorkerTimeoutError, UnspecifiedWorkerError } from './helpers/errors'; -import { CancellationToken } from './helpers/cancellation_token'; +import { CancellationToken } from '../../../common/cancellation_token'; import { Poller } from '../../../../../common/poller'; const puid = new Puid(); diff --git a/x-pack/plugins/reporting/server/lib/export_types_registry.js b/x-pack/plugins/reporting/server/lib/export_types_registry.js index 7631161c4f899..da9fb79d1efaa 100644 --- a/x-pack/plugins/reporting/server/lib/export_types_registry.js +++ b/x-pack/plugins/reporting/server/lib/export_types_registry.js @@ -21,7 +21,7 @@ function scan(pattern) { }); } -const pattern = resolve(__dirname, '../../export_types/*/server/index.js'); +const pattern = resolve(__dirname, '../../export_types/*/server/index.[jt]s'); async function exportTypesRegistryFn(server) { const exportTypesRegistry = new ExportTypesRegistry(); const files = await scan(pattern); diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts new file mode 100644 index 0000000000000..026c444ca35d7 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/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. + */ + +// @ts-ignore untyped module +export { createTaggedLogger } from './create_tagged_logger'; +// @ts-ignore untyped module +export { cryptoFactory } from './crypto'; +// @ts-ignore untyped module +export { oncePerServer } from './once_per_server'; + +export { LevelLogger } from './level_logger'; diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts new file mode 100644 index 0000000000000..f2a254f6077f6 --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -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 { Request, ResponseToolkit } from 'hapi'; +import { get } from 'lodash'; + +import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; +import { KbnServer } from '../../types'; +import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; +import { getRouteOptions } from './lib/route_config_factories'; +import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; + +/* + * 1. Build `jobParams` object: job data that execution will need to reference in various parts of the lifecycle + * 2. Pass the jobParams and other common params to `handleRoute`, a shared function to enqueue the job with the params + * 3. Ensure that details for a queued job were returned + */ +const getJobFromRouteHandler = async ( + handleRoute: HandlerFunction, + handleRouteError: HandlerErrorFunction, + request: Request, + h: ResponseToolkit +): Promise => { + let result: QueuedJobPayload; + try { + const jobParams = getJobParamsFromRequest(request, { isImmediate: false }); + result = await handleRoute(CSV_FROM_SAVEDOBJECT_JOB_TYPE, jobParams, request, h); + } catch (err) { + throw handleRouteError(CSV_FROM_SAVEDOBJECT_JOB_TYPE, err); + } + + if (get(result, 'source.job') == null) { + throw new Error(`The Export handler is expected to return a result with job info! ${result}`); + } + + return result; +}; + +/* + * This function registers API Endpoints for queuing Reporting jobs. The API inputs are: + * - saved object type and ID + * - time range and time zone + * - application state: + * - filters + * - query bar + * - local (transient) changes the user made to the saved object + */ +export function registerGenerateCsvFromSavedObject( + server: KbnServer, + handleRoute: HandlerFunction, + handleRouteError: HandlerErrorFunction +) { + const routeOptions = getRouteOptions(server); + + server.route({ + path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, + method: 'POST', + options: routeOptions, + handler: async (request: Request, h: ResponseToolkit) => { + return getJobFromRouteHandler(handleRoute, handleRouteError, request, h); + }, + }); +} 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 new file mode 100644 index 0000000000000..10f634aedcb4c --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -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 { Request, ResponseObject, ResponseToolkit } from 'hapi'; + +import { API_BASE_GENERATE_V1 } from '../../common/constants'; +import { createJobFactory, executeJobFactory } from '../../export_types/csv_from_savedobject'; +import { JobDocPayload, JobDocOutputExecuted, KbnServer } from '../../types'; +import { LevelLogger } from '../lib/level_logger'; +import { getRouteOptions } from './lib/route_config_factories'; +import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; + +interface KibanaResponse extends ResponseObject { + isBoom: boolean; +} + +/* + * This function registers API Endpoints for immediate Reporting jobs. The API inputs are: + * - saved object type and ID + * - time range and time zone + * - application state: + * - filters + * - query bar + * - local (transient) changes the user made to the saved object + */ +export function registerGenerateCsvFromSavedObjectImmediate(server: KbnServer) { + const routeOptions = getRouteOptions(server); + + /* + * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: + * - re-use the createJob function to build up es query config + * - re-use the executeJob function to run the scan and scroll queries and capture the entire CSV in a result object. + */ + server.route({ + path: `${API_BASE_GENERATE_V1}/immediate/csv/saved-object/{savedObjectType}:{savedObjectId}`, + method: 'POST', + options: routeOptions, + handler: async (request: Request, h: ResponseToolkit) => { + const logger = LevelLogger.createForServer(server, ['reporting', 'savedobject-csv']); + const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); + const createJobFn = createJobFactory(server); + const executeJobFn = executeJobFactory(server, request); + const jobDocPayload: JobDocPayload = await createJobFn(jobParams, request.headers, request); + const { + content_type: jobOutputContentType, + content: jobOutputContent, + size: jobOutputSize, + }: JobDocOutputExecuted = await executeJobFn(jobDocPayload, request); + + logger.info(`job output size: ${jobOutputSize} bytes`); + + /* + * ESQueue worker function defaults `content` to null, even if the + * executeJob returned undefined. + * + * This converts null to undefined so the value can be sent to h.response() + */ + if (jobOutputContent === null) { + logger.warn('CSV Job Execution created empty content result'); + } + const response = h + .response(jobOutputContent ? jobOutputContent : undefined) + .type(jobOutputContentType); + + // Set header for buffer download, not streaming + const { isBoom } = response as KibanaResponse; + if (isBoom == null) { + response.header('accept-ranges', 'none'); + } + + return response; + }, + }); +} diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index f51634056b1b3..9fbdfae7206f2 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -11,6 +11,8 @@ import { KbnServer } from '../../types'; // @ts-ignore import { enqueueJobFactory } from '../lib/enqueue_job'; import { registerGenerate } from './generate'; +import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; +import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; import { registerJobs } from './jobs'; import { registerLegacy } from './legacy'; @@ -20,7 +22,15 @@ export function registerRoutes(server: KbnServer) { const { errors: esErrors } = server.plugins.elasticsearch.getCluster('admin'); const enqueueJob = enqueueJobFactory(server); - async function handler(exportTypeId: any, jobParams: any, request: Request, h: ResponseToolkit) { + /* + * Generates enqueued job details to use in responses + */ + async function handler( + exportTypeId: string, + jobParams: any, + request: Request, + h: ResponseToolkit + ) { // @ts-ignore const user = request.pre.user; const headers = request.headers; @@ -38,12 +48,12 @@ export function registerRoutes(server: KbnServer) { .type('application/json'); } - function handleError(exportType: any, err: Error) { + function handleError(exportTypeId: string, err: Error) { if (err instanceof esErrors['401']) { return boom.unauthorized(`Sorry, you aren't authenticated`); } if (err instanceof esErrors['403']) { - return boom.forbidden(`Sorry, you are not authorized to create ${exportType} reports`); + return boom.forbidden(`Sorry, you are not authorized to create ${exportTypeId} reports`); } if (err instanceof esErrors['404']) { return boom.boomify(err, { statusCode: 404 }); @@ -53,5 +63,12 @@ export function registerRoutes(server: KbnServer) { registerGenerate(server, handler, handleError); registerLegacy(server, handler, handleError); + + // Register beta panel-action download-related API's + if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(server, handler, handleError); + registerGenerateCsvFromSavedObjectImmediate(server); + } + registerJobs(server); } diff --git a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts new file mode 100644 index 0000000000000..097b41c4fb697 --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { JobParamPostPayload, JobParams } from '../../../types'; + +export function getJobParamsFromRequest( + request: Request, + opts: { isImmediate: boolean } +): JobParams { + const { savedObjectType, savedObjectId } = request.params; + const { timerange, state } = request.payload as JobParamPostPayload; + const post = timerange || state ? { timerange, state } : undefined; + + return { + isImmediate: opts.isImmediate, + savedObjectType, + savedObjectId, + post, + }; +} diff --git a/x-pack/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/plugins/reporting/server/routes/lib/route_config_factories.ts index 2396694d5c56f..e09db6e536fee 100644 --- a/x-pack/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -5,11 +5,13 @@ */ import { Request } from 'hapi'; +import Joi from 'joi'; import { KbnServer } from '../../../types'; // @ts-ignore import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; // @ts-ignore import { reportingFeaturePreRoutingFactory } from './reporting_feature_pre_routing'; +import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; const API_TAG = 'api'; @@ -41,6 +43,27 @@ export function getRouteConfigFactoryReportingPre(server: KbnServer) { }; } +export function getRouteOptions(server: KbnServer) { + const getRouteConfig = getRouteConfigFactoryReportingPre(server); + return { + ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), + validate: { + params: Joi.object({ + savedObjectType: Joi.string().required(), + savedObjectId: Joi.string().required(), + }).required(), + payload: Joi.object({ + state: Joi.object().default({}), + timerange: Joi.object({ + timezone: Joi.string().default('UTC'), + min: Joi.date().required(), + max: Joi.date().required(), + }).optional(), + }), + }, + }; +} + export function getRouteConfigFactoryManagementPre(server: KbnServer) { const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server); const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server); diff --git a/x-pack/plugins/reporting/server/routes/types.d.ts b/x-pack/plugins/reporting/server/routes/types.d.ts index 90040870778bd..6004811d8a2b4 100644 --- a/x-pack/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/plugins/reporting/server/routes/types.d.ts @@ -5,6 +5,7 @@ */ import { Request, ResponseToolkit } from 'hapi'; +import { JobDocPayload } from '../../types'; export type HandlerFunction = ( exportType: any, @@ -14,3 +15,12 @@ export type HandlerFunction = ( ) => any; export type HandlerErrorFunction = (exportType: any, err: Error) => any; + +export interface QueuedJobPayload { + error?: boolean; + source: { + job: { + payload: JobDocPayload; + }; + }; +} diff --git a/x-pack/plugins/reporting/types.d.ts b/x-pack/plugins/reporting/types.d.ts index c0284dc3e8007..235bf04e58437 100644 --- a/x-pack/plugins/reporting/types.d.ts +++ b/x-pack/plugins/reporting/types.d.ts @@ -18,6 +18,7 @@ export interface KbnServer { plugins: Record; route: any; log: any; + fieldFormatServiceFactory: (uiConfig: any) => any; savedObjects: { getScopedSavedObjectsClient: ( fakeRequest: { headers: object; getBasePath: () => string } @@ -28,6 +29,20 @@ export interface KbnServer { ) => UiSettings; } +export interface ExportTypeDefinition { + id: string; + name: string; + jobType: string; + jobContentExtension: string; + createJobFactory: () => any; + executeJobFactory: () => any; + validLicenses: string[]; +} + +export interface ExportTypesRegistry { + register: (exportTypeDefinition: ExportTypeDefinition) => void; +} + export interface ConfigObject { get: (path?: string) => any; } @@ -41,7 +56,7 @@ export interface Logger { debug: (message: string) => void; error: (message: string) => void; warning: (message: string) => void; - clone: (tags: string[]) => Logger; + clone?: (tags: string[]) => Logger; } export interface ViewZoomWidthHeight { @@ -92,20 +107,58 @@ export interface CryptoFactory { decrypt: (headers?: Record) => string; } -export interface ReportingJob { - headers?: Record; +export interface TimeRangeParams { + timezone: string; + min: Date | string | number; + max: Date | string | number; +} + +type PostPayloadState = Partial<{ + state: { + query: any; + sort: any[]; + columns: string[]; // TODO + }; +}>; + +// retain POST payload data, needed for async +interface JobParamPostPayload extends PostPayloadState { + timerange: TimeRangeParams; +} + +// params that come into a request +export interface JobParams { + savedObjectType: string; + savedObjectId: string; + isImmediate: boolean; + post?: JobParamPostPayload; + panel?: any; // has to be resolved by the request handler + visType?: string; // has to be resolved by the request handler +} + +export interface JobDocPayload { basePath?: string; - urls?: string[]; - relativeUrl?: string; forceNow?: string; + headers?: Record; + jobParams: JobParams; + relativeUrl?: string; timeRange?: any; - objects?: [any]; + title: string; + urls?: string[]; + type?: string | null; // string if completed job; null if incomplete job; + objects?: string | null; // string if completed job; null if incomplete job; +} + +export interface JobDocOutput { + content: string; // encoded content + contentType: string; } export interface JobDoc { - output: any; jobtype: string; - payload: ReportingJob; + output: JobDocOutput; + payload: JobDocPayload; + status: string; // completed, failed, etc } export interface JobSource { @@ -113,6 +166,25 @@ export interface JobSource { _source: JobDoc; } +/* + * A snake_cased field is the only significant difference in structure of + * JobDocOutputExecuted vs JobDocOutput. + * + * JobDocOutput is the structure of the object returned by getDocumentPayload + * + * data in the _source fields of the + * Reporting index. + * + * The ESQueueWorker internals have executed job objects returned with this + * structure. See `_formatOutput` in reporting/server/lib/esqueue/worker.js + */ +export interface JobDocOutputExecuted { + content_type: string; // vs `contentType` above + content: string | null; // defaultOutput is null + max_size_reached: boolean; + size: number; +} + export interface ESQueueWorker { on: (event: string, handler: any) => void; } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index fe46b447c9090..385678852e9a1 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -8,6 +8,7 @@ require('@kbn/plugin-helpers').babelRegister(); require('@kbn/test').runTestsCli([ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), + require.resolve('../test/reporting/configs/generate_api'), require.resolve('../test/functional/config.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index eafad98a0e655..bce6cbc61260f 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -21,16 +21,11 @@ import { SpacesServiceProvider, } from '../common/services'; -export default async function ({ readConfigFile }) { - const kibanaAPITestsConfig = await readConfigFile( - require.resolve('../../../test/api_integration/config.js') - ); - const xPackFunctionalTestsConfig = await readConfigFile( - require.resolve('../functional/config.js') - ); - const kibanaCommonConfig = await readConfigFile( - require.resolve('../../../test/common/config.js') - ); +export async function getApiIntegrationConfig({ readConfigFile }) { + + const kibanaAPITestsConfig = await readConfigFile(require.resolve('../../../test/api_integration/config.js')); + const xPackFunctionalTestsConfig = await readConfigFile(require.resolve('../functional/config.js')); + const kibanaCommonConfig = await readConfigFile(require.resolve('../../../test/common/config.js')); return { testFiles: [require.resolve('./apis')], @@ -73,3 +68,5 @@ export default async function ({ readConfigFile }) { }, }; } + +export default getApiIntegrationConfig; diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 6b8dc8a2fc106..43fade16af105 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -192,6 +192,8 @@ export default async function ({ readConfigFile }) { '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', '--xpack.xpack_main.telemetry.enabled=false', '--xpack.maps.showMapsInspectorAdapter=true', + '--xpack.reporting.queue.pollInterval=3000', // make it explicitly the default + '--xpack.reporting.csv.maxSizeBytes=2850', // small-ish limit for cutting off a 1999 byte report '--stats.maximumWaitTimeForAllCollectorsInS=0', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions '--xpack.code.security.enableGitCertCheck=false', // Disable git certificate check diff --git a/x-pack/test/functional/es_archives/reporting/logs/data.json.gz b/x-pack/test/functional/es_archives/reporting/logs/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..ff16bc0c594bdf44cafb4df2c8ca3eda34abcd27 GIT binary patch literal 1342 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 Q_X+EN0mmEoTXTj70Gv4o?f?J) literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/reporting/scripted/mappings.json b/x-pack/test/functional/es_archives/reporting/scripted/mappings.json new file mode 100644 index 0000000000000..d36bbc72f4ffa --- /dev/null +++ b/x-pack/test/functional/es_archives/reporting/scripted/mappings.json @@ -0,0 +1,742 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "apm-telemetry": "0383a570af33654a51c8a1352417bc6b", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "config": "87aca8fdb053154f11383fce3dbf3edf", + "dashboard": "eb3789e1af878e73f85304333240f65f", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "10acdf67d9a06d462e198282fd6d4b81", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "space": "0d5011d73a0ef2f0f615bb42f26f187e", + "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "type": "2f4316de49999235636386fe51dc06c1", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "user-action": "0d409297dc5ebe1e3a1da691c6ee32e3", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search:queryLanguage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "mapsTotalCount": { + "type": "long" + }, + "timeCaptured": { + "type": "date" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "disabledFeatures": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "dynamic": "true", + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "user-action": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "babynames", + "mappings": { + "properties": { + "date": { + "type": "date" + }, + "gender": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "percent": { + "type": "float" + }, + "value": { + "type": "integer" + }, + "year": { + "type": "integer" + } + } + }, + "settings": { + "index": { + "mapping": { + "coerce": "false" + }, + "number_of_replicas": "0", + "number_of_shards": "2" + } + } + } +} diff --git a/x-pack/test/reporting/api/generate/csv_saved_search.ts b/x-pack/test/reporting/api/generate/csv_saved_search.ts new file mode 100644 index 0000000000000..9e234fbe26ec1 --- /dev/null +++ b/x-pack/test/reporting/api/generate/csv_saved_search.ts @@ -0,0 +1,346 @@ +/* + * 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 expect from '@kbn/expect'; +import supertest from 'supertest'; +import { + CSV_RESULT_HUGE, + CSV_RESULT_SCRIPTED, + CSV_RESULT_SCRIPTED_REQUERY, + CSV_RESULT_SCRIPTED_RESORTED, + CSV_RESULT_TIMEBASED, + CSV_RESULT_TIMELESS, +} from './fixtures'; + +interface GenerateOpts { + timerange?: { + timezone: string; + min: number | string | Date; + max: number | string | Date; + }; + state: any; +} + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: { getService: any }) { + const esArchiver = getService('esArchiver'); + const supertestSvc = getService('supertest'); + const generateAPI = { + getCsvFromSavedSearch: async ( + id: string, + { timerange, state }: GenerateOpts, + isImmediate = true + ) => { + return await supertestSvc + .post(`/api/reporting/v1/generate/${isImmediate ? 'immediate/' : ''}csv/saved-object/${id}`) + .set('kbn-xsrf', 'xxx') + .send({ timerange, state }); + }, + }; + + describe('Generation from Saved Search ID', () => { + describe('Saved Search Features', () => { + it('With filters and timebased data', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/logs'); + await esArchiver.load('logstash_functional'); + + // TODO: check headers for inline filename + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', + { + timerange: { + timezone: 'UTC', + min: '2015-09-19T10:00:00.000Z', + max: '2015-09-21T10:00:00.000Z', + }, + state: {}, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_TIMEBASED); + + await esArchiver.unload('reporting/logs'); + await esArchiver.unload('logstash_functional'); + }); + + it('With filters and non-timebased data', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/sales'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:71e3ee20-3f99-11e9-b8ee-6b9604f2f877', + { + state: {}, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_TIMELESS); + + await esArchiver.unload('reporting/sales'); + }); + + it('With scripted fields and field formatters', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/scripted'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + { + timerange: { + timezone: 'UTC', + min: '1979-01-01T10:00:00Z', + max: '1981-01-01T10:00:00Z', + }, + state: {}, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_SCRIPTED); + + await esArchiver.unload('reporting/scripted'); + }); + }); + + describe('API Features', () => { + it('Return a 404', async () => { + const { body } = (await generateAPI.getCsvFromSavedSearch('search:gobbledygook', { + timerange: { timezone: 'UTC', min: 63097200000, max: 126255599999 }, + state: {}, + })) as supertest.Response; + const expectedBody = { + error: 'Not Found', + message: 'Saved object [search/gobbledygook] not found', + statusCode: 404, + }; + expect(body).to.eql(expectedBody); + }); + + it('Return 400 if time range param is needed but missing', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/logs'); + await esArchiver.load('logstash_functional'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', + { state: {} } + )) as supertest.Response; + + expect(resStatus).to.eql(400); + expect(resType).to.eql('application/json'); + const { message: errorMessage } = JSON.parse(resText); + expect(errorMessage).to.eql( + 'Time range params are required for index pattern [logstash-*], using time field [@timestamp]' + ); + + await esArchiver.unload('reporting/logs'); + await esArchiver.unload('logstash_functional'); + }); + + it('Stops at Max Size Reached', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/scripted'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + { + timerange: { + timezone: 'UTC', + min: '1960-01-01T10:00:00Z', + max: '1999-01-01T10:00:00Z', + }, + state: {}, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_HUGE); + + await esArchiver.unload('reporting/scripted'); + }); + }); + + describe('Merge user state into the query', () => { + it('for query', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/scripted'); + + const params = { + searchId: 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + postPayload: { + timerange: { timezone: 'UTC', min: '1979-01-01T10:00:00Z', max: '1981-01-01T10:00:00Z' }, // prettier-ignore + state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } } // prettier-ignore + }, + isImmediate: true, + }; + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + params.searchId, + params.postPayload, + params.isImmediate + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_SCRIPTED_REQUERY); + + await esArchiver.unload('reporting/scripted'); + }); + + it('for sort', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/scripted'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + { + timerange: { + timezone: 'UTC', + min: '1979-01-01T10:00:00Z', + max: '1981-01-01T10:00:00Z', + }, + state: { sort: [{ name: { order: 'asc', unmapped_type: 'boolean' } }] }, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(CSV_RESULT_SCRIPTED_RESORTED); + + await esArchiver.unload('reporting/scripted'); + }); + }); + + describe('Non-Immediate', () => { + it('using queries in job params', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/scripted'); + + const params = { + searchId: 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + postPayload: { + timerange: { timezone: 'UTC', min: '1979-01-01T10:00:00Z', max: '1981-01-01T10:00:00Z' }, // prettier-ignore + state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } } // prettier-ignore + }, + isImmediate: false, + }; + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + params.searchId, + params.postPayload, + params.isImmediate + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('application/json'); + const { + path: jobDownloadPath, + job: { index: jobIndex, jobtype: jobType, created_by: jobCreatedBy, payload: jobPayload }, + } = JSON.parse(resText); + + expect(jobDownloadPath.slice(0, 29)).to.equal('/api/reporting/jobs/download/'); + expect(jobIndex.slice(0, 11)).to.equal('.reporting-'); + expect(jobType).to.be('csv_from_savedobject'); + expect(jobCreatedBy).to.be('elastic'); + + const { + title: payloadTitle, + objects: payloadObjects, + jobParams: payloadParams, + } = jobPayload; + expect(payloadTitle).to.be('EVERYBABY2'); + expect(payloadObjects).to.be(null); // value for non-immediate + expect(payloadParams.savedObjectType).to.be('search'); + expect(payloadParams.savedObjectId).to.be('f34bf440-5014-11e9-bce7-4dabcb8bef24'); + expect(payloadParams.isImmediate).to.be(false); + + const { state: postParamState, timerange: postParamTimerange } = payloadParams.post; + expect(postParamState).to.eql({ + query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } // prettier-ignore + }); + expect(postParamTimerange).to.eql({ + max: '1981-01-01T10:00:00.000Z', + min: '1979-01-01T10:00:00.000Z', + timezone: 'UTC', + }); + + const { + indexPatternSavedObjectId: payloadPanelIndexPatternSavedObjectId, + timerange: payloadPanelTimerange, + } = payloadParams.panel; + expect(payloadPanelIndexPatternSavedObjectId).to.be('89655130-5013-11e9-bce7-4dabcb8bef24'); + expect(payloadPanelTimerange).to.eql({ + timezone: 'UTC', + min: '1979-01-01T10:00:00.000Z', + max: '1981-01-01T10:00:00.000Z', + }); + + expect(payloadParams.visType).to.be('search'); + + // check the resource at jobDownloadPath + const downloadFromPath = async (downloadPath: string) => { + const { status, text, type } = await supertestSvc + .get(downloadPath) + .set('kbn-xsrf', 'xxx'); + return { + status, + text, + type, + }; + }; + + await new Promise(resolve => { + setTimeout(async () => { + const { status, text, type } = await downloadFromPath(jobDownloadPath); + expect(status).to.eql(200); + expect(type).to.eql('text/csv'); + expect(text).to.eql(CSV_RESULT_SCRIPTED_REQUERY); + resolve(); + }, 5000); // x-pack/test/functional/config settings are inherited, uses 3 seconds for polling interval. + }); + + await esArchiver.unload('reporting/scripted'); + }); + }); + }); +} diff --git a/x-pack/test/reporting/api/generate/fixtures.ts b/x-pack/test/reporting/api/generate/fixtures.ts new file mode 100644 index 0000000000000..2cac49f650842 --- /dev/null +++ b/x-pack/test/reporting/api/generate/fixtures.ts @@ -0,0 +1,175 @@ +/* + * 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 CSV_RESULT_TIMEBASED = `"@timestamp",clientip,extension +"2015-09-20T10:26:48.725Z","74.214.76.90",jpg +"2015-09-20T10:26:48.540Z","146.86.123.109",jpg +"2015-09-20T10:26:48.353Z","233.126.159.144",jpg +"2015-09-20T10:26:45.468Z","153.139.156.196",png +"2015-09-20T10:26:34.063Z","25.140.171.133",css +"2015-09-20T10:26:11.181Z","239.249.202.59",jpg +"2015-09-20T10:26:00.639Z","95.59.225.31",css +"2015-09-20T10:26:00.094Z","247.174.57.245",jpg +"2015-09-20T10:25:55.744Z","116.126.47.226",css +"2015-09-20T10:25:54.701Z","169.228.188.120",jpg +"2015-09-20T10:25:52.360Z","74.224.77.232",css +"2015-09-20T10:25:49.913Z","97.83.96.39",css +"2015-09-20T10:25:44.979Z","175.188.44.145",css +"2015-09-20T10:25:40.968Z","89.143.125.181",jpg +"2015-09-20T10:25:36.331Z","231.169.195.137",css +"2015-09-20T10:25:34.064Z","137.205.146.206",jpg +"2015-09-20T10:25:32.312Z","53.0.188.251",jpg +"2015-09-20T10:25:27.254Z","111.214.104.239",jpg +"2015-09-20T10:25:22.561Z","111.46.85.146",jpg +"2015-09-20T10:25:06.674Z","55.100.60.111",jpg +"2015-09-20T10:25:05.114Z","34.197.178.155",jpg +"2015-09-20T10:24:55.114Z","163.123.136.118",jpg +"2015-09-20T10:24:54.818Z","11.195.163.57",jpg +"2015-09-20T10:24:53.742Z","96.222.137.213",png +"2015-09-20T10:24:48.798Z","227.228.214.218",jpg +"2015-09-20T10:24:20.223Z","228.53.110.116",jpg +"2015-09-20T10:24:01.794Z","196.131.253.111",png +"2015-09-20T10:23:49.521Z","125.163.133.47",jpg +"2015-09-20T10:23:45.816Z","148.47.216.255",jpg +"2015-09-20T10:23:36.052Z","51.105.100.214",jpg +"2015-09-20T10:23:34.323Z","41.210.252.157",gif +"2015-09-20T10:23:27.213Z","248.163.75.193",png +"2015-09-20T10:23:14.866Z","48.43.210.167",png +"2015-09-20T10:23:10.578Z","33.95.78.209",css +"2015-09-20T10:23:07.001Z","96.40.73.208",css +"2015-09-20T10:23:02.876Z","174.32.230.63",jpg +"2015-09-20T10:23:00.019Z","140.233.207.177",jpg +"2015-09-20T10:22:47.447Z","37.127.124.65",jpg +"2015-09-20T10:22:45.803Z","130.171.208.139",png +"2015-09-20T10:22:45.590Z","39.250.210.253",jpg +"2015-09-20T10:22:43.997Z","248.239.221.43",css +"2015-09-20T10:22:36.107Z","232.64.207.109",gif +"2015-09-20T10:22:30.527Z","24.186.122.118",jpg +"2015-09-20T10:22:25.697Z","23.3.174.206",jpg +"2015-09-20T10:22:08.272Z","185.170.80.142",php +"2015-09-20T10:21:40.822Z","202.22.74.232",png +"2015-09-20T10:21:36.210Z","39.227.27.167",jpg +"2015-09-20T10:21:19.154Z","140.233.207.177",jpg +"2015-09-20T10:21:09.852Z","22.151.97.227",jpg +"2015-09-20T10:21:06.079Z","157.39.25.197",css +"2015-09-20T10:21:01.357Z","37.127.124.65",jpg +"2015-09-20T10:20:56.519Z","23.184.94.58",jpg +"2015-09-20T10:20:40.189Z","80.83.92.252",jpg +"2015-09-20T10:20:27.012Z","66.194.157.171",png +"2015-09-20T10:20:24.450Z","15.191.218.38",jpg +"2015-09-20T10:19:45.764Z","199.113.69.162",jpg +"2015-09-20T10:19:43.754Z","171.243.18.67",gif +"2015-09-20T10:19:41.208Z","126.87.234.213",jpg +"2015-09-20T10:19:40.307Z","78.216.173.242",css +`; + +export const CSV_RESULT_TIMELESS = `name,power +"Jonelle-Jane Marth","1.1768" +"Suzie-May Rishel","1.824" +"Suzie-May Rishel","2.077" +"Rosana Casto","2.8084" +"Stephen Cortez","4.9856" +"Jonelle-Jane Marth","6.156" +"Jonelle-Jane Marth","7.0966" +"Florinda Alejandro","10.3734" +"Jonelle-Jane Marth","14.8074" +"Suzie-May Rishel","19.7377" +"Suzie-May Rishel","20.9198" +"Florinda Alejandro","22.2092" +`; + +export const CSV_RESULT_SCRIPTED = `date,year,name,value,"years_ago" +"1981-01-01T00:00:00.000Z",1981,Fetty,1763,38 +"1981-01-01T00:00:00.000Z",1981,Fonnie,2330,38 +"1981-01-01T00:00:00.000Z",1981,Farbara,6456,38 +"1981-01-01T00:00:00.000Z",1981,Felinda,1886,38 +"1981-01-01T00:00:00.000Z",1981,Frenda,7162,38 +"1981-01-01T00:00:00.000Z",1981,Feth,3685,38 +"1981-01-01T00:00:00.000Z",1981,Feverly,1987,38 +"1981-01-01T00:00:00.000Z",1981,Fecky,1930,38 +"1980-01-01T00:00:00.000Z",1980,Fonnie,2748,39 +"1980-01-01T00:00:00.000Z",1980,Frenda,8335,39 +"1980-01-01T00:00:00.000Z",1980,Fetty,1967,39 +"1980-01-01T00:00:00.000Z",1980,Farbara,8026,39 +"1980-01-01T00:00:00.000Z",1980,Feth,4246,39 +"1980-01-01T00:00:00.000Z",1980,Feverly,2249,39 +"1980-01-01T00:00:00.000Z",1980,Fecky,2071,39 +`; + +export const CSV_RESULT_SCRIPTED_REQUERY = `date,year,name,value,"years_ago" +"1981-01-01T00:00:00.000Z",1981,Fetty,1763,38 +"1981-01-01T00:00:00.000Z",1981,Felinda,1886,38 +"1981-01-01T00:00:00.000Z",1981,Feth,3685,38 +"1981-01-01T00:00:00.000Z",1981,Feverly,1987,38 +"1981-01-01T00:00:00.000Z",1981,Fecky,1930,38 +"1980-01-01T00:00:00.000Z",1980,Fetty,1967,39 +"1980-01-01T00:00:00.000Z",1980,Feth,4246,39 +"1980-01-01T00:00:00.000Z",1980,Feverly,2249,39 +"1980-01-01T00:00:00.000Z",1980,Fecky,2071,39 +`; + +export const CSV_RESULT_SCRIPTED_RESORTED = `date,year,name,value,"years_ago" +"1981-01-01T00:00:00.000Z",1981,Farbara,6456,38 +"1980-01-01T00:00:00.000Z",1980,Farbara,8026,39 +"1981-01-01T00:00:00.000Z",1981,Fecky,1930,38 +"1980-01-01T00:00:00.000Z",1980,Fecky,2071,39 +"1981-01-01T00:00:00.000Z",1981,Felinda,1886,38 +"1981-01-01T00:00:00.000Z",1981,Feth,3685,38 +"1980-01-01T00:00:00.000Z",1980,Feth,4246,39 +"1981-01-01T00:00:00.000Z",1981,Fetty,1763,38 +"1980-01-01T00:00:00.000Z",1980,Fetty,1967,39 +"1981-01-01T00:00:00.000Z",1981,Feverly,1987,38 +"1980-01-01T00:00:00.000Z",1980,Feverly,2249,39 +"1981-01-01T00:00:00.000Z",1981,Fonnie,2330,38 +"1980-01-01T00:00:00.000Z",1980,Fonnie,2748,39 +"1981-01-01T00:00:00.000Z",1981,Frenda,7162,38 +"1980-01-01T00:00:00.000Z",1980,Frenda,8335,39 +`; + +export const CSV_RESULT_HUGE = `date,year,name,value,"years_ago" +"1984-01-01T00:00:00.000Z",1984,Fobby,2791,35 +"1984-01-01T00:00:00.000Z",1984,Frent,3416,35 +"1984-01-01T00:00:00.000Z",1984,Frett,2679,35 +"1984-01-01T00:00:00.000Z",1984,Filly,3366,35 +"1984-01-01T00:00:00.000Z",1984,Frian,34468,35 +"1984-01-01T00:00:00.000Z",1984,Fenjamin,7191,35 +"1984-01-01T00:00:00.000Z",1984,Frandon,5863,35 +"1984-01-01T00:00:00.000Z",1984,Fruce,1855,35 +"1984-01-01T00:00:00.000Z",1984,Fryan,7236,35 +"1984-01-01T00:00:00.000Z",1984,Frad,2482,35 +"1984-01-01T00:00:00.000Z",1984,Fradley,5175,35 +"1983-01-01T00:00:00.000Z",1983,Fryan,7114,36 +"1983-01-01T00:00:00.000Z",1983,Fradley,4752,36 +"1983-01-01T00:00:00.000Z",1983,Frian,35717,36 +"1983-01-01T00:00:00.000Z",1983,Farbara,4434,36 +"1983-01-01T00:00:00.000Z",1983,Fenjamin,5235,36 +"1983-01-01T00:00:00.000Z",1983,Fruce,1914,36 +"1983-01-01T00:00:00.000Z",1983,Fobby,2888,36 +"1983-01-01T00:00:00.000Z",1983,Frett,3031,36 +"1982-01-01T00:00:00.000Z",1982,Fonnie,1853,37 +"1982-01-01T00:00:00.000Z",1982,Frandy,2082,37 +"1982-01-01T00:00:00.000Z",1982,Fecky,1786,37 +"1982-01-01T00:00:00.000Z",1982,Frandi,2056,37 +"1982-01-01T00:00:00.000Z",1982,Fridget,1864,37 +"1982-01-01T00:00:00.000Z",1982,Farbara,5081,37 +"1982-01-01T00:00:00.000Z",1982,Feth,2818,37 +"1982-01-01T00:00:00.000Z",1982,Frenda,6270,37 +"1981-01-01T00:00:00.000Z",1981,Fetty,1763,38 +"1981-01-01T00:00:00.000Z",1981,Fonnie,2330,38 +"1981-01-01T00:00:00.000Z",1981,Farbara,6456,38 +"1981-01-01T00:00:00.000Z",1981,Felinda,1886,38 +"1981-01-01T00:00:00.000Z",1981,Frenda,7162,38 +"1981-01-01T00:00:00.000Z",1981,Feth,3685,38 +"1981-01-01T00:00:00.000Z",1981,Feverly,1987,38 +"1981-01-01T00:00:00.000Z",1981,Fecky,1930,38 +"1980-01-01T00:00:00.000Z",1980,Fonnie,2748,39 +"1980-01-01T00:00:00.000Z",1980,Frenda,8335,39 +"1980-01-01T00:00:00.000Z",1980,Fetty,1967,39 +"1980-01-01T00:00:00.000Z",1980,Farbara,8026,39 +"1980-01-01T00:00:00.000Z",1980,Feth,4246,39 +"1980-01-01T00:00:00.000Z",1980,Feverly,2249,39 +"1980-01-01T00:00:00.000Z",1980,Fecky,2071,39 +`; diff --git a/x-pack/test/reporting/api/generate/index.js b/x-pack/test/reporting/api/generate/index.js new file mode 100644 index 0000000000000..99286762f44f1 --- /dev/null +++ b/x-pack/test/reporting/api/generate/index.js @@ -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 default function ({ loadTestFile }) { + describe('CSV', function () { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./csv_saved_search')); + }); +} diff --git a/x-pack/test/reporting/configs/api.js b/x-pack/test/reporting/configs/api.js index 7c30f299f3f46..b93e89b568648 100644 --- a/x-pack/test/reporting/configs/api.js +++ b/x-pack/test/reporting/configs/api.js @@ -24,14 +24,18 @@ export async function getReportingApiConfig({ readConfigFile }) { junit: { reportName: 'X-Pack Reporting API Tests', }, + testFiles: [require.resolve('../api/generate')], esTestCluster: apiConfig.get('esTestCluster'), kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ ...apiConfig.get('kbnTestServer.serverArgs'), + '--xpack.reporting.csv.enablePanelActionDownload=true', `--optimize.enabled=true`, '--logging.events.log', JSON.stringify(['info', 'warning', 'error', 'fatal', 'optimize', 'reporting']) ], }, }; } + +export default getReportingApiConfig; diff --git a/x-pack/test/reporting/configs/chromium_api.js b/x-pack/test/reporting/configs/chromium_api.js index 461c6c0df5271..1377a4af9de9f 100644 --- a/x-pack/test/reporting/configs/chromium_api.js +++ b/x-pack/test/reporting/configs/chromium_api.js @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }) { ...reportingApiConfig.kbnTestServer, serverArgs: [ ...reportingApiConfig.kbnTestServer.serverArgs, + '--xpack.reporting.csv.enablePanelActionDownload=true', `--xpack.reporting.capture.browser.type=chromium`, `--xpack.spaces.enabled=false`, ], diff --git a/x-pack/test/reporting/configs/chromium_functional.js b/x-pack/test/reporting/configs/chromium_functional.js index 80a9024c06ab8..69d8ae9d55b57 100644 --- a/x-pack/test/reporting/configs/chromium_functional.js +++ b/x-pack/test/reporting/configs/chromium_functional.js @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }) { ...functionalConfig.kbnTestServer, serverArgs: [ ...functionalConfig.kbnTestServer.serverArgs, + '--xpack.reporting.csv.enablePanelActionDownload=true', `--xpack.reporting.capture.browser.type=chromium`, ], }, diff --git a/x-pack/test/reporting/configs/functional.js b/x-pack/test/reporting/configs/functional.js index 4eafa10369e93..24d1a0228a70c 100644 --- a/x-pack/test/reporting/configs/functional.js +++ b/x-pack/test/reporting/configs/functional.js @@ -25,6 +25,7 @@ export async function getFunctionalConfig({ readConfigFile }) { ...xPackFunctionalTestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.reporting.csv.enablePanelActionDownload=true', '--logging.events.log', JSON.stringify(['info', 'warning', 'error', 'fatal', 'optimize', 'reporting']) ], }, diff --git a/x-pack/test/reporting/configs/generate_api.js b/x-pack/test/reporting/configs/generate_api.js new file mode 100644 index 0000000000000..cd81dea583fff --- /dev/null +++ b/x-pack/test/reporting/configs/generate_api.js @@ -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 { getApiIntegrationConfig } from '../../api_integration/config'; +import { getReportingApiConfig } from './api'; + +export default async function ({ readConfigFile }) { + const apiTestConfig = await getApiIntegrationConfig({ readConfigFile }); + const reportingApiConfig = await getReportingApiConfig({ readConfigFile }); + const xPackFunctionalTestsConfig = await readConfigFile(require.resolve('../../functional/config.js')); + + return { + ...reportingApiConfig, + junit: { reportName: 'X-Pack Reporting Generate API Integration Tests' }, + testFiles: [require.resolve('../api/generate')], + services: { + ...apiTestConfig.services, + ...reportingApiConfig.services, + }, + kbnTestServer: { + ...xPackFunctionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.reporting.csv.enablePanelActionDownload=true', + ], + }, + esArchiver: apiTestConfig.esArchiver, + }; +}